From b7f694a77c496c379d1bb4a30bb642b2a7711cb4 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 18 Sep 2026 21:08:36 -0700 Subject: [PATCH 1/3] Gate admin design system by inbox access --- plugins/admin/public/index.html | 6 +++++- plugins/admin/src/index.ts | 14 +++++++++++++- plugins/admin/test/branding.test.ts | 9 ++++++++- plugins/admin/test/default-view.test.ts | 7 ++++++- plugins/admin/test/principal-allowlist.test.ts | 10 ++++++++++ plugins/admin/test/whoami.test.ts | 5 ++++- plugins/chassis/src/principal-allowlist.ts | 8 ++++++++ plugins/web-ui/server/index.ts | 12 +++--------- plugins/web-ui/test/inbox-source.test.ts | 2 +- 9 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 plugins/admin/test/principal-allowlist.test.ts create mode 100644 plugins/chassis/src/principal-allowlist.ts diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index 9111a8c8f..e557549ce 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -7816,6 +7816,7 @@

Add custom provider

]; const VIEWS = [...SECTIONS.flatMap((s) => s.views), "onboarding", "user", "ackemoji", "keychain"]; + let permissions = []; function paritySubsection(card) { card.classList.remove("card", "sv-governance", "sv-models", "sv-credentials", "sv-customize"); card.classList.add("parity-subsection"); @@ -8579,6 +8580,7 @@

Add custom provider

$("who-name").title = "Signed in as " + me.data.principal; orgId = me.data.org; $("who-name").textContent = me.data.principal; + permissions = Array.isArray(me.data.permissions) ? me.data.permissions : []; if (me.data.isAdmin === false) { $("who").classList.remove("hidden"); $("na-sub").textContent = me.data.principal; @@ -8637,6 +8639,7 @@

Add custom provider

nav.appendChild(h); } s.views.forEach((v) => { + if (v === "design-system" && !permissions.includes("inbox")) return; const b = document.createElement("button"); b.className = "tab" + (s.label ? "" : " tab-top") + (v === view ? " active" : ""); if (v === "design-system") b.classList.add("tab-footer"); @@ -8730,7 +8733,7 @@

Add custom provider

const turnRaw = p.get("turn"); let resolvedView = DEFAULT_VIEW; - if (VIEWS.includes(v)) resolvedView = v; + if (VIEWS.includes(v) && (v !== "design-system" || permissions.includes("inbox"))) resolvedView = v; else if (session) resolvedView = "history"; return { view: resolvedView, @@ -8884,6 +8887,7 @@

Add custom provider

}); } function setView(v) { + if (v === "design-system" && !permissions.includes("inbox")) return; go({ view: v, scope: v === "history" || v === "errors" ? "org:" + orgId : scope, diff --git a/plugins/admin/src/index.ts b/plugins/admin/src/index.ts index cc1ed2640..7705efb24 100644 --- a/plugins/admin/src/index.ts +++ b/plugins/admin/src/index.ts @@ -10,6 +10,7 @@ import { json, readBody, cookie, gzipAccepted } from "../../chassis/src/http.ts" import { createBrandingCache, injectBranding, type OrgBranding } from "../../chassis/src/branding.ts"; import { verifyPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts"; import { errMessage } from "../../chassis/src/errors.ts"; +import { principalInAllowlist } from "../../chassis/src/principal-allowlist.ts"; import { CORE_API_URL as CORE, CORE_ORG_ID as ORG, @@ -94,6 +95,9 @@ const cookiePrincipal = (req: IncomingMessage): string | null => { return principal ?? (!CORE_SIGNING_SECRET || ALLOW_UNSIGNED_TEST_IDENTITY ? cookie(req, "admin") : null); }; +const inboxPermissions = (principal: string): string[] => + principalInAllowlist(principal, process.env.INBOX_USERS) ? ["inbox"] : []; + const portalTokenStore = new AsyncLocalStorage(); function portalIdentityHeader(): Record { const t = portalTokenStore.getStore(); @@ -389,7 +393,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (!p) return json(res, 401, { error: "signed_out" }); const who = await coreWhoami(p); if (!who) return json(res, 502, { error: "core_unreachable", message: "could not verify admin status" }); - return json(res, 200, { principal: p, org: ORG, ...who }); + return json(res, 200, { principal: p, org: ORG, ...who, permissions: inboxPermissions(p) }); } if (method === "POST" && pathname === "/api/logout") { res.writeHead(200, { @@ -496,6 +500,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise return forward(req, res, principal, "GET", `/v1/admin/${rest}${url.search}`); } + if (method === "GET" && pathname === "/design-system") { + const viewer = cookiePrincipal(req); + if (!viewer || !principalInAllowlist(viewer, process.env.INBOX_USERS)) { + return json(res, 404, { error: "not_found" }); + } + return serveShell(); + } + if (method === "GET" && !pathname.startsWith("/api/") && !pathname.startsWith("/deployments/")) { return serveShell(); } diff --git a/plugins/admin/test/branding.test.ts b/plugins/admin/test/branding.test.ts index 51b37717a..0cbebd0a0 100644 --- a/plugins/admin/test/branding.test.ts +++ b/plugins/admin/test/branding.test.ts @@ -29,6 +29,7 @@ await new Promise((r) => core.listen(0, r)); process.env.CORE_API_URL = `http://localhost:${(core.address() as AddressInfo).port}`; process.env.CORE_ORG_ID = "acme"; process.env.CORE_SIGNING_SECRET = "admin-branding-test-secret"; +process.env.INBOX_USERS = "U-admin"; const { server } = await import("../src/index.ts"); await new Promise((r) => server.listen(0, r)); @@ -93,7 +94,7 @@ test("the brand icon is a CSS variable the org can point at its own image", () = }); test("design system routes embed the shared component library and retain the script CSP", async () => { - const response = await fetch(base + "/design-system"); + const response = await fetch(base + "/design-system", { headers: { cookie: "admin=U-admin" } }); assert.equal(response.status, 200); const html = await response.text(); const css = readFileSync(new URL("../public/admin-components.css", import.meta.url), "utf8"); @@ -103,3 +104,9 @@ test("design system routes embed the shared component library and retain the scr const hash = createHash("sha256").update(script).digest("base64"); assert.ok(response.headers.get("content-security-policy")?.includes("sha256-" + hash)); }); + +test("design system routes use the inbox allowlist", async () => { + const denied = await fetch(base + "/design-system", { headers: { cookie: "admin=U-rando" } }); + assert.equal(denied.status, 404); + assert.deepEqual(await denied.json(), { error: "not_found" }); +}); diff --git a/plugins/admin/test/default-view.test.ts b/plugins/admin/test/default-view.test.ts index a3f5c0526..8a3e7d0a2 100644 --- a/plugins/admin/test/default-view.test.ts +++ b/plugins/admin/test/default-view.test.ts @@ -74,10 +74,15 @@ test("admin shell defaults bare admin URLs to org history", () => { assert.match(html, /let view = DEFAULT_VIEW;/); assert.match( html, - /let resolvedView = DEFAULT_VIEW;\s*if \(VIEWS\.includes\(v\)\) resolvedView = v;\s*else if \(session\) resolvedView = "history";[\s\S]*view: resolvedView/, + /let resolvedView = DEFAULT_VIEW;\s*if \(VIEWS\.includes\(v\) && \(v !== "design-system" \|\| permissions\.includes\("inbox"\)\)\) resolvedView = v;\s*else if \(session\) resolvedView = "history";[\s\S]*view: resolvedView/, ); }); +test("the design system follows the inbox permission", () => { + assert.match(html, /if \(v === "design-system" && !permissions\.includes\("inbox"\)\) return;/); + assert.match(html, /permissions = Array\.isArray\(me\.data\.permissions\) \? me\.data\.permissions : \[\];/); +}); + test("connector setup uses the live catalog and shows exact provider and callback links", () => { assert.match(html, /api\("GET", "\/api\/connector-catalog"\)/); assert.match(html, /setupGuide\.url/); diff --git a/plugins/admin/test/principal-allowlist.test.ts b/plugins/admin/test/principal-allowlist.test.ts new file mode 100644 index 000000000..e65b9cfd2 --- /dev/null +++ b/plugins/admin/test/principal-allowlist.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { principalInAllowlist } from "../../chassis/src/principal-allowlist.ts"; + +test("principal allowlists match email addresses case-insensitively and support all", () => { + assert.equal(principalInAllowlist("Alice@Example.com", "other@example.com, alice@example.COM"), true); + assert.equal(principalInAllowlist("alice@example.com", "all"), true); + assert.equal(principalInAllowlist("alice@example.com", "other@example.com"), false); + assert.equal(principalInAllowlist("", "all"), false); +}); diff --git a/plugins/admin/test/whoami.test.ts b/plugins/admin/test/whoami.test.ts index 067c70db7..da2ed8fa3 100644 --- a/plugins/admin/test/whoami.test.ts +++ b/plugins/admin/test/whoami.test.ts @@ -34,6 +34,7 @@ const corePort = (core.address() as AddressInfo).port; process.env.CORE_API_URL = `http://localhost:${corePort}`; process.env.CORE_SIGNING_SECRET = "admin-whoami-test-secret"; +process.env.INBOX_USERS = "U-admin"; const { server } = await import("../src/index.ts"); await new Promise((r) => server.listen(0, r)); @@ -79,6 +80,7 @@ test("/api/whoami with cookie admin=U-admin → 200 with the core's admin status isAdmin: true, role: "org_admin", scopeId: "org:acme", + permissions: ["inbox"], }); assert.equal(lastActor, "U-admin@acme", "x-admin-actor forwarded as @"); assert.equal(lastSigned, true, "source-auth headers present when CORE_SIGNING_SECRET is set"); @@ -87,7 +89,7 @@ test("/api/whoami with cookie admin=U-admin → 200 with the core's admin status test("/api/whoami with cookie admin=U-rando → 200 { isAdmin:false } (any non-empty cookie is trusted identity; the core decides)", async () => { const r = await api("/api/whoami", "admin=U-rando"); assert.equal(r.status, 200); - assert.deepEqual(await r.json(), { principal: "U-rando", org: "acme", isAdmin: false }); + assert.deepEqual(await r.json(), { principal: "U-rando", org: "acme", isAdmin: false, permissions: [] }); }); test("/api/whoami with NO admin cookie → 401 signed_out", async () => { @@ -139,6 +141,7 @@ test("a forwarded portal identity is relayed to core (so an enforcing core can v isAdmin: true, role: "org_admin", scopeId: "org:acme", + permissions: ["inbox"], }); }); diff --git a/plugins/chassis/src/principal-allowlist.ts b/plugins/chassis/src/principal-allowlist.ts new file mode 100644 index 000000000..5c13abd84 --- /dev/null +++ b/plugins/chassis/src/principal-allowlist.ts @@ -0,0 +1,8 @@ +export function principalInAllowlist(principalId: string, configuredPrincipals: string | undefined): boolean { + const principal = principalId.trim().toLowerCase(); + if (!principal) return false; + return (configuredPrincipals ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .some((entry) => entry === "all" || entry === principal); +} diff --git a/plugins/web-ui/server/index.ts b/plugins/web-ui/server/index.ts index 9fb679df1..f13b07a30 100644 --- a/plugins/web-ui/server/index.ts +++ b/plugins/web-ui/server/index.ts @@ -32,6 +32,7 @@ import { import { verifyPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts"; import { createBrandingCache, injectBranding } from "../../chassis/src/branding.ts"; import { parseSuggestedActivities } from "../../chassis/src/suggested-activities.ts"; +import { principalInAllowlist } from "../../chassis/src/principal-allowlist.ts"; import { CORE_API_URL as CORE, @@ -55,15 +56,8 @@ const ALLOW = (process.env.WEB_UI_PRINCIPALS ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); -const INBOX_USERS = new Set( - (process.env.INBOX_USERS ?? "") - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean), -); - -export function isInboxUser(principalId: string): boolean { - return INBOX_USERS.has("all") || INBOX_USERS.has(principalId.trim().toLowerCase()); +export function isInboxUser(principalId: string, configuredUsers = process.env.INBOX_USERS): boolean { + return principalInAllowlist(principalId, configuredUsers); } export function isLoopsUser(principalId: string, configuredUsers = process.env.LOOPS_USERS): boolean { diff --git a/plugins/web-ui/test/inbox-source.test.ts b/plugins/web-ui/test/inbox-source.test.ts index 06a700bdd..0f915fb90 100644 --- a/plugins/web-ui/test/inbox-source.test.ts +++ b/plugins/web-ui/test/inbox-source.test.ts @@ -21,7 +21,7 @@ test("inbox access rides the existing permissions plumbing", () => { assert.match(shell, /can\("inbox"\) \? html`\$\{inboxNavRow\(\)\}/); assert.match(shellState, /if \(view === "inbox" \|\| view === "calendar"\) return can\("inbox"\);/); assert.match(server, /process\.env\.INBOX_USERS/); - assert.match(server, /INBOX_USERS\.has\("all"\) \|\| INBOX_USERS\.has\(principalId\.trim\(\)\.toLowerCase\(\)\)/); + assert.match(server, /principalInAllowlist\(principalId, configuredUsers\)/); assert.match(server, /if \(isInboxUser\(user\)\) permissions\.push\("inbox"\);/); assert.match(inbox, /if \(!can\("inbox"\)\) return;/); }); From 3afbb06d69768449323950a42da5a39fd99d00b3 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 20 Sep 2026 22:51:42 -0700 Subject: [PATCH 2/3] Authorize legacy design system routes --- plugins/admin/src/index.ts | 2 +- plugins/admin/test/branding.test.ts | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/plugins/admin/src/index.ts b/plugins/admin/src/index.ts index 18b6dcc71..283eecb23 100644 --- a/plugins/admin/src/index.ts +++ b/plugins/admin/src/index.ts @@ -502,7 +502,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise return forward(req, res, principal, "GET", `/v1/admin/${rest}${url.search}`); } - if (method === "GET" && (pathname === "/design-system" || pathname === "/design-system/")) { + if (method === "GET" && ["/design-system", "/design-system/", "/design", "/design/"].includes(pathname)) { const viewer = cookiePrincipal(req); if (!viewer || !principalInAllowlist(viewer, process.env.INBOX_USERS)) { return json(res, 404, { error: "not_found" }); diff --git a/plugins/admin/test/branding.test.ts b/plugins/admin/test/branding.test.ts index d9afe94cf..2165b783d 100644 --- a/plugins/admin/test/branding.test.ts +++ b/plugins/admin/test/branding.test.ts @@ -99,19 +99,21 @@ test("the brand icon is a CSS variable the org can point at its own image", () = }); test("design system routes embed the shared component library and retain the script CSP", async () => { - const response = await fetch(base + "/design-system", { headers: { cookie: "admin=U-admin" } }); - assert.equal(response.status, 200); - const html = await response.text(); - const css = readFileSync(new URL("../public/admin-components.css", import.meta.url), "utf8"); - assert.ok(html.includes("")); - const script = html.match(/