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">