diff --git a/apps/admin/src/components/AdminLayout.test.tsx b/apps/admin/src/components/AdminLayout.test.tsx
index b9c5998..92fcc5c 100644
--- a/apps/admin/src/components/AdminLayout.test.tsx
+++ b/apps/admin/src/components/AdminLayout.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppRoutes from "../routes";
@@ -46,5 +46,19 @@ describe("AdminLayout", () => {
expect(screen.getByRole("link", { name: /data explorer/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /analytics studio/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /action center/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /lock admin app/i })).toBeInTheDocument();
+ });
+
+ it("clears the stored admin session when locking the app", async () => {
+ render(
+
+
+
+ );
+
+ const button = await screen.findByRole("button", { name: /lock admin app/i });
+ fireEvent.click(button);
+
+ expect(localStorage.getItem("xtablo-admin-session")).toBeNull();
});
});
diff --git a/apps/admin/src/components/AdminLayout.tsx b/apps/admin/src/components/AdminLayout.tsx
index 4133974..257aa04 100644
--- a/apps/admin/src/components/AdminLayout.tsx
+++ b/apps/admin/src/components/AdminLayout.tsx
@@ -4,7 +4,7 @@ import { AdminNavigation } from "./AdminNavigation";
import { ProductionBadge } from "./ProductionBadge";
export function AdminLayout() {
- const { operatorEmail } = useAdminSession();
+ const { logout, operatorEmail } = useAdminSession();
return (
@@ -17,6 +17,15 @@ export function AdminLayout() {
{operatorEmail ?? "Unknown operator"}
+
diff --git a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx
index e6b710c..d9dc2c4 100644
--- a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx
+++ b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx
@@ -75,7 +75,7 @@ describe("AnalyticsStudioPage", () => {
render();
expect(await screen.findByText(/analytics studio/i)).toBeInTheDocument();
- expect(await screen.findByText(/user growth/i)).toBeInTheDocument();
+ expect(await screen.findByRole("button", { name: /user growth/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /plan mix/i }));
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
index 586e2a9..4a98594 100644
--- a/apps/admin/tsconfig.json
+++ b/apps/admin/tsconfig.json
@@ -26,6 +26,6 @@
"@xtablo/shared-types/*": ["../../packages/shared-types/src/*"]
}
},
- "include": ["src"],
+ "include": ["src", "worker"],
"references": []
}
diff --git a/apps/admin/worker/index.test.ts b/apps/admin/worker/index.test.ts
new file mode 100644
index 0000000..87124e8
--- /dev/null
+++ b/apps/admin/worker/index.test.ts
@@ -0,0 +1,88 @@
+// @vitest-environment node
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import worker, {
+ ADMIN_APP_SESSION_COOKIE,
+ buildAccessDeniedHtml,
+ createSignedAdminAppSession,
+} from "./index";
+
+const env = {
+ ADMIN_APP_ACCESS_TOKEN: "super-secret-admin-app-token",
+ ADMIN_APP_SESSION_SECRET: "worker-session-secret",
+ ASSETS: {
+ fetch: vi.fn(async () => new Response("app", { status: 200 })),
+ },
+};
+
+describe("admin worker firewall", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("serves the admin access gate when no session cookie is present", async () => {
+ const response = await worker.fetch(
+ new Request("https://admin.internal.xtablo.com/", {
+ headers: {
+ accept: "text/html",
+ },
+ }),
+ env
+ );
+
+ expect(response.status).toBe(401);
+ await expect(response.text()).resolves.toContain("Internal Admin Access");
+ });
+
+ it("creates a signed app session cookie from a valid access token", async () => {
+ const request = new Request("https://admin.internal.xtablo.com/__admin/access", {
+ body: new URLSearchParams({ accessToken: env.ADMIN_APP_ACCESS_TOKEN }),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ method: "POST",
+ });
+
+ const response = await worker.fetch(request, env);
+
+ expect(response.status).toBe(302);
+ expect(response.headers.get("location")).toBe("https://admin.internal.xtablo.com/");
+ expect(response.headers.get("set-cookie")).toContain(`${ADMIN_APP_SESSION_COOKIE}=`);
+ });
+
+ it("allows authenticated requests through to static assets", async () => {
+ const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET);
+ const request = new Request("https://admin.internal.xtablo.com/", {
+ headers: {
+ cookie: `${ADMIN_APP_SESSION_COOKIE}=${session}`,
+ },
+ });
+
+ const response = await worker.fetch(request, env);
+
+ expect(response.status).toBe(200);
+ expect(env.ASSETS.fetch).toHaveBeenCalledOnce();
+ });
+
+ it("rejects invalid access tokens", async () => {
+ const request = new Request("https://admin.internal.xtablo.com/__admin/access", {
+ body: new URLSearchParams({ accessToken: "wrong-token" }),
+ headers: {
+ accept: "text/html",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ method: "POST",
+ });
+
+ const response = await worker.fetch(request, env);
+
+ expect(response.status).toBe(401);
+ await expect(response.text()).resolves.toContain("Invalid app access token");
+ });
+});
+
+describe("buildAccessDeniedHtml", () => {
+ it("renders the access error when provided", () => {
+ expect(buildAccessDeniedHtml("Bad token")).toContain("Bad token");
+ });
+});
diff --git a/apps/admin/worker/index.ts b/apps/admin/worker/index.ts
index bee9dbb..32af99f 100644
--- a/apps/admin/worker/index.ts
+++ b/apps/admin/worker/index.ts
@@ -1,9 +1,280 @@
+export const ADMIN_APP_SESSION_COOKIE = "xtablo-admin-app-session";
+const ADMIN_ACCESS_PATH = "/__admin/access";
+const ADMIN_LOGOUT_PATH = "/__admin/logout";
+const SESSION_TTL_SECONDS = 60 * 60 * 12;
+
+type WorkerEnv = {
+ ADMIN_APP_ACCESS_TOKEN: string;
+ ADMIN_APP_SESSION_SECRET: string;
+ ASSETS: {
+ fetch: (request: Request) => Promise;
+ };
+};
+
+function base64UrlEncode(bytes: Uint8Array) {
+ return btoa(String.fromCharCode(...bytes))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");
+}
+
+function base64UrlDecode(value: string) {
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
+ const binary = atob(`${normalized}${padding}`);
+
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
+
+async function importSigningKey(secret: string) {
+ return crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign", "verify"]
+ );
+}
+
+async function signValue(value: string, secret: string) {
+ const key = await importSigningKey(secret);
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
+
+ return base64UrlEncode(new Uint8Array(signature));
+}
+
+async function verifyValueSignature(value: string, signature: string, secret: string) {
+ const key = await importSigningKey(secret);
+
+ return crypto.subtle.verify(
+ "HMAC",
+ key,
+ base64UrlDecode(signature),
+ new TextEncoder().encode(value)
+ );
+}
+
+function parseCookie(cookieHeader: string | null, cookieName: string) {
+ if (!cookieHeader) {
+ return null;
+ }
+
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`));
+ return match?.[1] ?? null;
+}
+
+export async function createSignedAdminAppSession(secret: string, now = Date.now()) {
+ const expiresAt = Math.floor(now / 1000) + SESSION_TTL_SECONDS;
+ const payload = `${expiresAt}`;
+ const signature = await signValue(payload, secret);
+
+ return `${payload}.${signature}`;
+}
+
+export async function hasValidAdminAppSession(request: Request, secret: string, now = Date.now()) {
+ const sessionCookie = parseCookie(request.headers.get("cookie"), ADMIN_APP_SESSION_COOKIE);
+ if (!sessionCookie) {
+ return false;
+ }
+
+ const [expiresAtValue, signature] = sessionCookie.split(".");
+ if (!expiresAtValue || !signature) {
+ return false;
+ }
+
+ const expiresAt = Number.parseInt(expiresAtValue, 10);
+ if (Number.isNaN(expiresAt) || expiresAt <= Math.floor(now / 1000)) {
+ return false;
+ }
+
+ return verifyValueSignature(expiresAtValue, signature, secret);
+}
+
+function isHtmlRequest(request: Request) {
+ const accept = request.headers.get("accept") ?? "";
+ return accept.includes("text/html") || accept.includes("*/*");
+}
+
+function buildSessionCookie(session: string) {
+ return `${ADMIN_APP_SESSION_COOKIE}=${session}; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=${SESSION_TTL_SECONDS}`;
+}
+
+function buildExpiredSessionCookie() {
+ return `${ADMIN_APP_SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=0`;
+}
+
+export function buildAccessDeniedHtml(error?: string) {
+ return `
+
+
+
+
+ XTablo Admin Access
+
+
+
+
+ Internal Only
+ Internal Admin Access
+
+ This app is firewalled behind a dedicated app-access token before any admin session
+ can be established.
+
+
+ ${error ? `${error}
` : ""}
+
+
+
+`;
+}
+
+function respondUnauthorized(request: Request, error?: string) {
+ if (isHtmlRequest(request)) {
+ return new Response(buildAccessDeniedHtml(error), {
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-store",
+ },
+ status: 401,
+ });
+ }
+
+ return new Response("Unauthorized", { status: 401 });
+}
+
+async function handleAccessRequest(request: Request, env: WorkerEnv) {
+ const formData = await request.formData().catch(() => null);
+ const accessToken = formData?.get("accessToken");
+
+ if (accessToken !== env.ADMIN_APP_ACCESS_TOKEN) {
+ return respondUnauthorized(request, "Invalid app access token");
+ }
+
+ const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET);
+ const location = new URL("/", request.url).toString();
+
+ return new Response(null, {
+ headers: {
+ Location: location,
+ "Set-Cookie": buildSessionCookie(session),
+ "Cache-Control": "no-store",
+ },
+ status: 302,
+ });
+}
+
+async function handleLogoutRequest(request: Request) {
+ return new Response(null, {
+ headers: {
+ Location: new URL("/", request.url).toString(),
+ "Set-Cookie": buildExpiredSessionCookie(),
+ "Cache-Control": "no-store",
+ },
+ status: 302,
+ });
+}
+
export default {
- fetch(request: Request) {
+ async fetch(request: Request, env: WorkerEnv) {
const url = new URL(request.url);
- if (url.pathname.startsWith("/api/")) {
- return Response.json({ name: "XTablo Admin Worker" });
+
+ if (request.method === "POST" && url.pathname === ADMIN_ACCESS_PATH) {
+ return handleAccessRequest(request, env);
}
- return new Response(null, { status: 404 });
+
+ if (request.method === "POST" && url.pathname === ADMIN_LOGOUT_PATH) {
+ return handleLogoutRequest(request);
+ }
+
+ const hasSession = await hasValidAdminAppSession(request, env.ADMIN_APP_SESSION_SECRET);
+ if (!hasSession) {
+ return respondUnauthorized(request);
+ }
+
+ return env.ASSETS.fetch(request);
},
};
diff --git a/docs/ADMIN_APP_ACCESS_SETUP.md b/docs/ADMIN_APP_ACCESS_SETUP.md
index 0e27441..8734cca 100644
--- a/docs/ADMIN_APP_ACCESS_SETUP.md
+++ b/docs/ADMIN_APP_ACCESS_SETUP.md
@@ -2,6 +2,16 @@
The admin app is designed to be internal-only and requires a separate privileged token flow.
+## Required admin worker configuration
+
+Set these values for `apps/admin`:
+
+- `ADMIN_APP_ACCESS_TOKEN`
+- `ADMIN_APP_SESSION_SECRET`
+
+`ADMIN_APP_ACCESS_TOKEN` is the first-layer token required before the admin SPA will be served.
+`ADMIN_APP_SESSION_SECRET` signs the worker-issued app session cookie after that token is accepted.
+
## Required API configuration
Set these values for `apps/api`:
@@ -17,10 +27,13 @@ Set these values for `apps/api`:
## Access model
1. The operator reaches the private `apps/admin` deployment from the internal network boundary.
-2. The operator pastes a privileged access token into the admin gate.
-3. `POST /admin/auth/exchange` validates that token and returns a short-lived `admin_session`.
-4. The admin app stores that session locally and attaches it as a bearer token for admin routes.
-5. All privileged data and mutations go through `/admin/*` API routes guarded by admin middleware.
+2. The admin worker presents a dedicated app-access gate before any SPA asset is served.
+3. The operator submits the app access token, and the worker issues a signed session cookie.
+4. Only then does the browser load the React admin shell.
+5. Inside the shell, the operator pastes a separate privileged admin API token.
+6. `POST /admin/auth/exchange` validates that token and returns a short-lived `admin_session`.
+7. The admin app stores that session locally and attaches it as a bearer token for admin routes.
+8. All privileged data and mutations go through `/admin/*` API routes guarded by admin middleware.
Normal product auth is not sufficient for admin access.
@@ -42,7 +55,8 @@ All write paths emit admin audit log entries.
- Run the API and local Supabase stack.
- Start the admin app with `pnpm dev:admin`.
-- Use a valid privileged access token compatible with `ADMIN_TOKEN_SIGNING_SECRET` and `ADMIN_TOKEN_AUDIENCE`.
+- Configure worker env for `ADMIN_APP_ACCESS_TOKEN` and `ADMIN_APP_SESSION_SECRET`.
+- Use the app-access token at the worker gate, then use a valid privileged API token compatible with `ADMIN_TOKEN_SIGNING_SECRET` and `ADMIN_TOKEN_AUDIENCE`.
## Initial action coverage