From a4966dd0dd03bc4e9948de132717d8adee648ea0 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 3 Oct 2026 11:27:59 -0500 Subject: [PATCH] feat: support split Dashboard and API origins (ADR 0045) An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty , which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 13 +- apps/api/src/config.ts | 16 + apps/api/src/dashboard-host-assets.ts | 47 ++- apps/api/src/server.ts | 9 +- apps/api/tests/api-http.integration.ts | 13 +- apps/api/tests/app.test.ts | 104 +++++++ apps/api/tests/config.test.ts | 23 +- apps/api/tests/dashboard-host-assets.test.ts | 58 +++- core/src/diagnostics/doctor.ts | 62 +++- core/tests/doctor.test.ts | 82 +++++ deploy/Caddyfile.split.example | 109 +++++++ deploy/README.md | 55 +++- deploy/compose.proxy.yaml | 4 + deploy/env/compose.env.example | 7 +- deploy/env/ekklesia.env.example | 14 +- .../0045-split-dashboard-and-api-origins.md | 2 +- docs/architecture/decisions/README.md | 2 +- docs/development/api.md | 57 ++-- docs/development/security.md | 7 +- packages/module-ui-sdk/src/host-context.ts | 5 + scripts/lib/deploy-smoke-browser.mjs | 287 ++++++++++++++++++ scripts/lib/deploy-smoke-client.mjs | 124 ++++++-- scripts/smoke-deploy.sh | 106 ++++++- surfaces/shell/index.html | 4 + surfaces/shell/scripts/build-browser.mjs | 29 +- surfaces/shell/src/browser/main.tsx | 53 ++-- surfaces/shell/src/display/main.tsx | 9 +- surfaces/shell/src/host-protocol.ts | 37 ++- surfaces/shell/src/index.ts | 1 + surfaces/shell/tests/dashboard-assets.test.ts | 16 + surfaces/shell/tests/host-protocol.test.ts | 34 +++ surfaces/shell/tests/modularity.test.ts | 2 - 32 files changed, 1275 insertions(+), 116 deletions(-) create mode 100644 deploy/Caddyfile.split.example create mode 100644 scripts/lib/deploy-smoke-browser.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d881d12..9523a62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,9 +139,18 @@ jobs: deploy-smoke: needs: verify runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 + # Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which + # adds a test-only module and drives a real headless browser across the two origins. + strategy: + fail-fast: false + matrix: + topology: + - same-origin + - split env: DOCKER_BUILDKIT: "1" + EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }} steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -156,5 +165,5 @@ jobs: # PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full # database TLS through the proxy. Every secret is generated for this run and discarded with # it; nothing is pushed, published, or logged in to. - - name: Smoke-test the reference deployment + - name: Smoke-test the reference deployment (${{ matrix.topology }}) run: bash scripts/smoke-deploy.sh diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index a959a7f..14d2f56 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -200,3 +200,19 @@ export function createApiRuntimeConfig( storage, }); } + +/** + * The exact browser origins the API trusts: CORS on `/api/auth/*` and `/api/v1/*`, and every + * mutation route's origin check (ADR 0045). + * + * The configured trusted origins plus the identity base URL's own origin, which is where the API + * lives. Better Auth already trusts its base URL implicitly; this makes the API's own checks agree, + * so a page the API itself serves (the display host, and the Dashboard in the same-origin topology) + * passes without an operator listing the API's origin. Order is preserved and duplicates removed; + * every entry is already a canonical origin. + */ +export function apiTrustedOrigins( + identity: Pick, +): readonly string[] { + return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]); +} diff --git a/apps/api/src/dashboard-host-assets.ts b/apps/api/src/dashboard-host-assets.ts index 22700d0..167066d 100644 --- a/apps/api/src/dashboard-host-assets.ts +++ b/apps/api/src/dashboard-host-assets.ts @@ -54,15 +54,54 @@ export function createDashboardHostAssets(html: string, script: string): Dashboa }); } -/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather - * than leaving an API whose own origin answers `/` with a 404. */ -export async function loadDashboardHostAssets(): Promise { +/** + * The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell + * reads the filled-in value at run time; empty means the page's own origin. + */ +export const DASHBOARD_API_ORIGIN_PLACEHOLDER = ''; + +function isCanonicalHttpOrigin(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value; + } catch { + return false; + } +} + +/** + * Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed. + * + * The origin is the identity base URL's, which is where `/api` lives in either topology: the page's + * own origin when the Dashboard and the API share one, the API hostname when they are split. The + * built page must carry exactly one empty placeholder; anything else is a shell build this API does + * not understand, and startup fails rather than serving a page that might send credentials somewhere + * unintended. The value is escaped for an attribute even though a canonical origin cannot contain + * any character that needs it. + */ +export function renderDashboardPage(html: string, apiOrigin: string): string { + const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER); + if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) { + throw new DashboardHostAssetError(); + } + const escaped = apiOrigin + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); + return parts.join(``); +} + +/** Loads only the two surface-declared files, and renders the API origin into the page. A missing, + * unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers + * `/` with a 404 or a page that cannot find its API. */ +export async function loadDashboardHostAssets(apiOrigin: string): Promise { try { // Sequential deliberately, as for the display host: diagnostics stay deterministic and no // rejected sibling read is left running after this function has already failed. const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8"); const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8"); - return createDashboardHostAssets(html, script); + return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script); } catch { throw new DashboardHostAssetError(); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 81cf2e5..6822668 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -44,7 +44,7 @@ import { createClientAddressResolver, wrapFetchWithClientAddress, } from "./client-address.js"; -import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; +import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js"; import { loadDashboardHostAssets } from "./dashboard-host-assets.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { withDevicePrincipals } from "./device-principal.js"; @@ -550,7 +550,9 @@ export async function startApi( moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets(); // Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044), // so a missing shell build fails here, before any module code is imported. - dashboardHostAssets = await loadDashboardHostAssets(); + // The page learns its API origin from the API that serves it, so one build serves either + // topology (ADR 0045). + dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl); } catch (error) { await safeCloseStorage(storageProvider); await safeCloseMail(mailProvider); @@ -648,7 +650,8 @@ export async function startApi( // Principal resolution, extended with managed devices. A human session still wins, and this // is the only place a device credential ever becomes a principal. runtime: withDevicePrincipals({ devices: deviceStore, runtime }), - trustedOrigins: config.identity.trustedOrigins, + // The configured origins plus the API's own (ADR 0045). + trustedOrigins: apiTrustedOrigins(config.identity), }); } catch (error) { await safeStopModuleRuntime(moduleRuntime); diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 1aa82f8..304ef8a 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -29,6 +29,7 @@ import type { ClientAddressConfigInput } from "../src/client-address.js"; import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; import { createApiRuntimeConfig } from "../src/config.js"; import { createApiDatabase } from "../src/database.js"; +import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js"; import { DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH, @@ -2989,7 +2990,7 @@ describe.sequential("live API — published module UI artifact", () => { }); describe.sequential("live API — the built Dashboard on the API's own origin", () => { - it("serves the built shell's exact index.html at / and its exact script, and nothing beside them", async () => { + it("serves the built shell's index.html at /, with only its API origin written in, and its exact script, and nothing beside them", async () => { const database = await createDisposableDatabase("dashboard_host"); await applyCoreMigrations(database.url); const running = await startMigratedApi(database.url); @@ -3006,7 +3007,15 @@ describe.sequential("live API — the built Dashboard on the API's own origin", expect(page.headers.get("x-frame-options")).toBe("DENY"); expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); expect(page.headers.get("referrer-policy")).toBe("no-referrer"); - expect(html).toBe(builtHtml); + // The built page carries one empty API-origin element; the API fills in its own origin + // (the identity base URL) and changes nothing else (ADR 0045). + expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2); + expect(html).toBe( + builtHtml.replace( + DASHBOARD_API_ORIGIN_PLACEHOLDER, + ``, + ), + ); expect(html).toContain('id="ekklesia-host"'); expect(html).toContain(``); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index fe86e99..b7d3885 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -3,11 +3,16 @@ import { createAuthenticatedPrincipal, type ModuleDatabase, type ModuleRuntimeServices, + moduleIdSchema, + parseEkklesiaUserId, + parsePermissionId, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, type AuthorizationStore, + CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_REGISTRY, + createPermissionRegistry, type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; @@ -16,6 +21,7 @@ import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, type ModuleUiPublicationRegistry, + StagedModuleRealtimeRegistry, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { @@ -910,3 +916,101 @@ describe("Ekklesia API Hono application", () => { }); }); }); + +describe("split Dashboard and API origins (ADR 0045)", () => { + const moduleId = moduleIdSchema.parse("announcements"); + const permission = parsePermissionId("announcements.slideshow.view"); + const endpoint = "/api/v1/module-realtime/announcements/state"; + + /** The full application with one live channel, a signed-in caller, and an evaluator that allows + * it: the same composition a Dashboard on another origin reaches. */ + function realtimeApp() { + const staged = new StagedModuleRealtimeRegistry(); + const registration = staged.createRegistrationStage(moduleId, new Set([permission])); + registration.realtime.channel({ channel: "state", permission }); + registration.commit(); + staged.publisherFor(moduleId).publish("state", { mode: "on" } as never); + const moduleRealtime = staged.publish(); + const app = createApiAppWithModules({ + moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), + moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), + moduleStorage: emptyModuleStorage(), + moduleRealtime, + dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), + health: readyHealth(), + moduleServices: fakeModuleServices(), + authorization: fakeAuthorization({ + evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) }, + registry: createPermissionRegistry([ + ...CORE_PERMISSION_DECLARATIONS, + { + baseline: "restricted", + description: "View the announcement slideshow.", + id: permission, + }, + ]), + }), + runtime: fakeRuntime({ + resolvePrincipal: vi + .fn() + .mockResolvedValue( + createAuthenticatedPrincipal( + parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"), + ), + ), + }), + trustedOrigins: [trustedOrigin], + }); + return { app, moduleRealtime }; + } + + it("lets the Dashboard's origin open a credentialed realtime stream", async () => { + const { app, moduleRealtime } = realtimeApp(); + + const response = await app.request(endpoint, { + headers: { cookie: "session=1", origin: trustedOrigin }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8"); + expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + expect(response.headers.get("vary")).toContain("Origin"); + await response.body?.cancel(); + moduleRealtime.closeSubscriptions(); + }); + + it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => { + const { app, moduleRealtime } = realtimeApp(); + + const response = await app.request(endpoint, { + headers: { cookie: "session=1", origin: untrustedOrigin }, + }); + + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + await response.body?.cancel(); + moduleRealtime.closeSubscriptions(); + }); + + it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => { + const app = createAuthWiringApp(fakeRuntime()); + + const response = await app.request("/api/v1/principal", { + headers: { + "access-control-request-headers": "content-type, x-module-custom", + "access-control-request-method": "PATCH", + origin: trustedOrigin, + }, + method: "OPTIONS", + }); + + expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); + expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe( + "content-type", + ); + expect(response.headers.get("access-control-expose-headers")).toBeNull(); + }); +}); diff --git a/apps/api/tests/config.test.ts b/apps/api/tests/config.test.ts index 9d8457c..d3f69e2 100644 --- a/apps/api/tests/config.test.ts +++ b/apps/api/tests/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js"; +import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js"; const validSecret = "correct-horse-battery-staple-0123456789"; @@ -337,3 +337,24 @@ describe("API runtime configuration", () => { expect(publicText).not.toContain("db.internal.example"); }); }); + +describe("the API's trusted browser origins (ADR 0045)", () => { + it("adds the API's own origin to the configured ones, as Better Auth does", () => { + const origins = apiTrustedOrigins({ + baseUrl: "https://api.example.test", + trustedOrigins: ["https://dash.example.test"], + }); + + expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]); + expect(Object.isFrozen(origins)).toBe(true); + }); + + it("does not duplicate an API origin already listed (the same-origin topology)", () => { + expect( + apiTrustedOrigins({ + baseUrl: "https://ekklesia.example.test", + trustedOrigins: ["https://ekklesia.example.test"], + }), + ).toEqual(["https://ekklesia.example.test"]); + }); +}); diff --git a/apps/api/tests/dashboard-host-assets.test.ts b/apps/api/tests/dashboard-host-assets.test.ts index 8b8f00f..1b16208 100644 --- a/apps/api/tests/dashboard-host-assets.test.ts +++ b/apps/api/tests/dashboard-host-assets.test.ts @@ -13,10 +13,14 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({ import { createDashboardHostAssets, + DASHBOARD_API_ORIGIN_PLACEHOLDER, DashboardHostAssetError, loadDashboardHostAssets, + renderDashboardPage, } from "../src/dashboard-host-assets.js"; +const API_ORIGIN = "https://api.example.test"; + function sha256Etag(text: string): string { return `"${createHash("sha256").update(text).digest("base64url")}"`; } @@ -25,19 +29,20 @@ describe("Dashboard host asset loading", () => { beforeEach(() => readFileMock.mockReset()); it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => { - const html = '
'; + const html = `${DASHBOARD_API_ORIGIN_PLACEHOLDER}
`; const script = "export const dashboardHost = true;\n"; readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); - const assets = await loadDashboardHostAssets(); + const assets = await loadDashboardHostAssets(API_ORIGIN); expect(readFileMock.mock.calls).toEqual([ [new URL("file:///surface/browser/index.html"), "utf8"], [new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"], ]); + const rendered = `
`; expect(assets).toEqual({ - html, - htmlEtag: sha256Etag(html), + html: rendered, + htmlEtag: sha256Etag(rendered), script, scriptEtag: sha256Etag(script), }); @@ -49,7 +54,7 @@ describe("Dashboard host asset loading", () => { new Error("ENOENT /home/operator/private/surface-build secret-token"), ); - const failure = await loadDashboardHostAssets().catch((error: unknown) => error); + const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error); expect(readFileMock).toHaveBeenCalledTimes(1); expect(failure).toBeInstanceOf(DashboardHostAssetError); @@ -69,10 +74,12 @@ describe("Dashboard host asset loading", () => { it("fails when the script is missing even though the page was read", async () => { readFileMock - .mockResolvedValueOnce("") + .mockResolvedValueOnce(`${DASHBOARD_API_ORIGIN_PLACEHOLDER}`) .mockRejectedValueOnce(new Error("ENOENT app.js")); - await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError); + await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf( + DashboardHostAssetError, + ); expect(readFileMock).toHaveBeenCalledTimes(2); }); @@ -84,4 +91,41 @@ describe("Dashboard host asset loading", () => { expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError); } }); + + it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => { + for (const html of [ + "", + `${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`, + '', + ]) { + readFileMock.mockReset(); + readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n"); + await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf( + DashboardHostAssetError, + ); + } + }); +}); + +describe("Dashboard page API origin (ADR 0045)", () => { + const page = `${DASHBOARD_API_ORIGIN_PLACEHOLDER}`; + + it("writes the API's origin into the one placeholder, in either topology", () => { + expect(renderDashboardPage(page, "https://api.example.test")).toBe( + '', + ); + expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe( + '', + ); + }); + + it.each([ + "https://api.example.test/", + "https://api.example.test/api", + 'https://api.example.test">