From a12cbe9600b2020407da5f58ac2f15379a70aca4 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 1 Oct 2026 17:54:55 -0500 Subject: [PATCH] feat(api): serve the built Dashboard from the API origin Serve surfaces/shell's built Dashboard from apps/api on the same origin as /api (ADR 0044), exactly as the display host is served: a closed set of files read once at startup, never a static directory, never a path built from request input. - The shell exports @ekklesia/shell/dashboard-assets (the exact built index.html and script URLs), emits the script at assets/ekklesia-dashboard/app.js, and index.html names that absolute path so dev and production serve the same URLs. - GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen snapshot: no-cache with a strong ETag and 304 on match. Source maps are never served; every other path is the ordinary sanitized 404. - The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (that directive only), and Referrer-Policy: no-referrer, on 200 and 304 alike. - A missing or empty shell build fails startup with a fixed DashboardHostAssetError before any module code is imported. - The If-None-Match comparison moves to conditional-request.ts, shared with the display host routes. - The emailed-link token stripping moves from browser/main.tsx into consumeAuthLanding (location and history injected) so it is tested; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 14 + apps/api/src/conditional-request.ts | 16 + apps/api/src/dashboard-host-assets.ts | 69 ++++ apps/api/src/dashboard-host-routes.ts | 123 +++++++ apps/api/src/module-display-routes.ts | 15 +- apps/api/src/server.ts | 6 + apps/api/tests/api-http.integration.ts | 49 ++- apps/api/tests/app.test.ts | 23 ++ apps/api/tests/dashboard-host-assets.test.ts | 87 +++++ apps/api/tests/dashboard-host-routes.test.ts | 347 ++++++++++++++++++ apps/api/tests/dashboard-routes.test.ts | 2 + apps/api/tests/health-routes.test.ts | 2 + apps/api/tests/lifecycle.test.ts | 35 ++ .../tests/module-configuration-routes.test.ts | 2 + apps/api/tests/module-display-routes.test.ts | 2 + apps/api/tests/module-http-routes.test.ts | 2 + .../api/tests/module-lifecycle-routes.test.ts | 2 + apps/api/tests/module-ui-host-e2e.test.ts | 2 + apps/api/tests/module-ui-inventory.test.ts | 2 + apps/api/tests/module-ui-routes.test.ts | 2 + .../tests/support/dashboard-host-assets.ts | 13 + docs/development/api.md | 68 +++- docs/development/security.md | 7 +- surfaces/shell/index.html | 6 +- surfaces/shell/package.json | 4 + surfaces/shell/scripts/build-browser.mjs | 4 +- surfaces/shell/src/auth-protocol.ts | 33 ++ surfaces/shell/src/browser/main.tsx | 35 +- surfaces/shell/src/dashboard-assets.ts | 15 + surfaces/shell/tests/auth-recovery.test.tsx | 54 ++- surfaces/shell/tests/dashboard-assets.test.ts | 55 +++ surfaces/shell/tsconfig.build.json | 1 + 32 files changed, 1041 insertions(+), 56 deletions(-) create mode 100644 apps/api/src/conditional-request.ts create mode 100644 apps/api/src/dashboard-host-assets.ts create mode 100644 apps/api/src/dashboard-host-routes.ts create mode 100644 apps/api/tests/dashboard-host-assets.test.ts create mode 100644 apps/api/tests/dashboard-host-routes.test.ts create mode 100644 apps/api/tests/support/dashboard-host-assets.ts create mode 100644 surfaces/shell/src/dashboard-assets.ts create mode 100644 surfaces/shell/tests/dashboard-assets.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 547a6e8..3e7926b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -27,6 +27,8 @@ import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; +import type { DashboardHostAssets } from "./dashboard-host-assets.js"; +import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDeviceRoutes } from "./device-routes.js"; import type { DisplayHostAssets } from "./display-host-assets.js"; @@ -138,6 +140,13 @@ export interface CreateApiAppOptions { * presentation only — nothing reachable through this store influences discovery, dependency * ordering, module startup, or any authorization decision. */ readonly dashboard: DashboardStore; + /** + * The built Dashboard's two files, read once by production startup (ADR 0044). Required rather + * than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing + * shell build fails startup instead of leaving this origin answering `/` with a 404. See + * `./dashboard-host-routes.js`. + */ + readonly dashboardHostAssets: DashboardHostAssets; /** * The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than * defaulted, like every other dependency here: a composition that cannot reach a database says so @@ -354,6 +363,11 @@ export function createApiApp(options: CreateApiAppOptions): Hono { // own entirely. See `./health-routes.js`. app.route("/", createHealthRoutes(options.health)); + // The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They + // grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See + // `./dashboard-host-routes.js`. + app.route("/", createDashboardHostRoutes(options.dashboardHostAssets)); + app.route( "/", createGrantsRoutes({ diff --git a/apps/api/src/conditional-request.ts b/apps/api/src/conditional-request.ts new file mode 100644 index 0000000..f69f17f --- /dev/null +++ b/apps/api/src/conditional-request.ts @@ -0,0 +1,16 @@ +/** + * RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned + * asset routes that answer `304` against a strong validator they computed from their own bytes. + */ +export function etagMatches(headerValue: string | undefined, etag: string): boolean { + if (headerValue === undefined) { + return false; + } + if (headerValue.trim() === "*") { + return true; + } + return headerValue.split(",").some((candidate) => { + const value = candidate.trim(); + return (value.startsWith("W/") ? value.slice(2) : value) === etag; + }); +} diff --git a/apps/api/src/dashboard-host-assets.ts b/apps/api/src/dashboard-host-assets.ts new file mode 100644 index 0000000..22700d0 --- /dev/null +++ b/apps/api/src/dashboard-host-assets.ts @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; + +/** + * The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044). + * + * Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound, + * from the exact URLs the surface package exports. Request input is never involved in this read, the + * package is never exposed as a static directory, and the source map beside the script is never + * read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value + * every response can reuse. + */ +export interface DashboardHostAssets { + readonly html: string; + readonly htmlEtag: string; + readonly script: string; + readonly scriptEtag: string; +} + +/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package + * location, and machine path are intentionally not retained or surfaced. */ +export class DashboardHostAssetError extends Error { + readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const; + + constructor() { + super("The Dashboard host browser assets are unavailable."); + this.name = "DashboardHostAssetError"; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +function strongEtag(text: string): string { + return `"${createHash("sha256").update(text).digest("base64url")}"`; +} + +/** + * Captures one immutable, internally consistent Dashboard asset snapshot. + * + * Each strong validator is derived from the exact text in the same snapshot, so bytes from one build + * can never be paired with an ETag from another. Empty output is a failed shell build rather than a + * valid page that can never render anything. + */ +export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets { + if (html.trim().length === 0 || script.trim().length === 0) { + throw new DashboardHostAssetError(); + } + return Object.freeze({ + html, + htmlEtag: strongEtag(html), + script, + scriptEtag: strongEtag(script), + }); +} + +/** 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 { + 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); + } catch { + throw new DashboardHostAssetError(); + } +} diff --git a/apps/api/src/dashboard-host-routes.ts b/apps/api/src/dashboard-host-routes.ts new file mode 100644 index 0000000..ea1658d --- /dev/null +++ b/apps/api/src/dashboard-host-routes.ts @@ -0,0 +1,123 @@ +import { Hono } from "hono"; +import { etagMatches } from "./conditional-request.js"; +import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js"; + +/** + * The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API + * serves: `/` is the only page the Dashboard has — it has no client router, and every emailed + * verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never + * inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under + * `/api/v1/modules/`. + */ +export const DASHBOARD_HOST_PAGE_PATH = "/"; +export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; + +/** + * Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The + * shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a + * rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`. + */ +const DASHBOARD_HOST_CACHE_CONTROL = "no-cache"; + +/** + * Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache + * entry never keeps an older set. + * + * The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options` + * for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the + * full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can + * briefly hold an emailed reset token before the shell strips it, out of every request it makes. + */ +const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({ + "Content-Security-Policy": "frame-ancestors 'none'", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", +}); + +/** A composition error, detected before any Dashboard route is registered. Carries no file path, + * package location, or asset content. */ +export class DashboardHostConfigurationError extends Error { + constructor() { + super("The Dashboard host configuration is inconsistent."); + this.name = "DashboardHostConfigurationError"; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** Copies and validates injected assets before registering any route, as the display host does, so + * a composition can never pair bytes with a validator computed from different bytes. */ +function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets { + try { + const snapshot = createDashboardHostAssets(input.html, input.script); + if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) { + throw new DashboardHostConfigurationError(); + } + return snapshot; + } catch { + throw new DashboardHostConfigurationError(); + } +} + +function fixedAsset( + ifNoneMatch: string | undefined, + body: string, + etag: string, + contentType: string, + extraHeaders: Readonly>, +): Response { + if (etagMatches(ifNoneMatch, etag)) { + return new Response(null, { + headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag }, + status: 304, + }); + } + return new Response(body, { + headers: { + "X-Content-Type-Options": "nosniff", + ...extraHeaders, + "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, + "Content-Type": contentType, + ETag: etag, + }, + status: 200, + }); +} + +/** + * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its + * default same-origin shape. + * + * Two exact literal routes over one frozen snapshot: never a static directory, never a path or file + * name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`, + * the `.map`, a traversal spelling — has no route and falls through to the application's ordinary + * sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders + * the sign-in form until `/api/v1/principal` says otherwise. + */ +export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono { + const snapshot = snapshotAssets(assets); + const routes = new Hono(); + + routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => + fixedAsset( + c.req.header("if-none-match"), + snapshot.html, + snapshot.htmlEtag, + "text/html; charset=utf-8", + DASHBOARD_PAGE_SECURITY_HEADERS, + ), + ); + + routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) => + fixedAsset( + c.req.header("if-none-match"), + snapshot.script, + snapshot.scriptEtag, + "text/javascript; charset=utf-8", + {}, + ), + ); + + return routes; +} diff --git a/apps/api/src/module-display-routes.ts b/apps/api/src/module-display-routes.ts index 6a48f5c..81cb5ea 100644 --- a/apps/api/src/module-display-routes.ts +++ b/apps/api/src/module-display-routes.ts @@ -6,6 +6,7 @@ import { getAuthorizedPrincipal, requirePermission, } from "./authorization-middleware.js"; +import { etagMatches } from "./conditional-request.js"; import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js"; export { MODULE_DISPLAY_DESCRIPTOR_PREFIX, @@ -100,20 +101,6 @@ function displayFailure(c: Context): Response { return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503); } -/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */ -function etagMatches(headerValue: string | undefined, etag: string): boolean { - if (headerValue === undefined) { - return false; - } - if (headerValue.trim() === "*") { - return true; - } - return headerValue.split(",").some((candidate) => { - const value = candidate.trim(); - return (value.startsWith("W/") ? value.slice(2) : value) === etag; - }); -} - /** Copies and validates injected assets before registering any route. Production's loader already * creates this exact shape, while this check keeps a test/composition caller from pairing mutable * bytes with a stale validator. */ diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 97c18c0..81cf2e5 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -45,6 +45,7 @@ import { wrapFetchWithClientAddress, } from "./client-address.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; +import { loadDashboardHostAssets } from "./dashboard-host-assets.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { withDevicePrincipals } from "./device-principal.js"; import { loadDisplayHostAssets } from "./display-host-assets.js"; @@ -543,9 +544,13 @@ export async function startApi( // starts and application composition succeeds. const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules); let displayHostAssets: Awaited> | undefined; + let dashboardHostAssets: Awaited>; try { displayHostAssets = 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(); } catch (error) { await safeCloseStorage(storageProvider); await safeCloseMail(mailProvider); @@ -625,6 +630,7 @@ export async function startApi( }, // A typed view over the same single pool as identity, authorization, and the module facade. dashboard: createDashboardStore(databaseHandle.dashboard), + dashboardHostAssets, // The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup // has completed whenever it can answer; it stops counting as complete once shutdown begins. health: { diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 8669a3d..1aa82f8 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { request as httpRequest } from "node:http"; import net from "node:net"; import { tmpdir } from "node:os"; @@ -26,8 +26,13 @@ import { import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; 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_HOST_PAGE_PATH, + DASHBOARD_HOST_SCRIPT_PATH, +} from "../src/dashboard-host-routes.js"; import { HEALTH_LIVE_PATH, HEALTH_READY_PATH, @@ -2983,6 +2988,48 @@ 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 () => { + const database = await createDisposableDatabase("dashboard_host"); + await applyCoreMigrations(database.url); + const running = await startMigratedApi(database.url); + try { + const origin = apiOrigin(running); + const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8"); + const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8"); + + const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`); + const html = await page.text(); + expect(page.status).toBe(200); + expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(page.headers.get("cache-control")).toBe("no-cache"); + 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); + expect(html).toContain('id="ekklesia-host"'); + expect(html).toContain(``); + + const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, { + headers: { "if-none-match": page.headers.get("etag") ?? "" }, + }); + expect(revalidated.status).toBe(304); + + const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`); + expect(script.status).toBe(200); + expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + expect(await script.text()).toBe(builtScript); + + // The source map sits beside the script on disk and is never published. + const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`); + expect(sourceMap.status).toBe(404); + expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" }); + } finally { + await running.shutdown(); + } + }); +}); + describe.sequential("live API — health", () => { it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => { const database = await createDisposableDatabase("health"); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index cfd3e45..fe86e99 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -25,6 +25,7 @@ import { } from "../src/app.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; @@ -105,6 +106,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -123,6 +125,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -358,6 +361,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -382,6 +386,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -408,6 +413,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -436,6 +442,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -464,6 +471,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -489,6 +497,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -516,6 +525,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -540,6 +550,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -566,6 +577,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -599,6 +611,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -633,6 +646,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -672,6 +686,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -693,6 +708,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -716,6 +732,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -738,6 +755,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -758,6 +776,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -785,6 +804,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), @@ -815,6 +835,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), @@ -848,6 +869,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ @@ -872,6 +894,7 @@ describe("Ekklesia API Hono application", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), diff --git a/apps/api/tests/dashboard-host-assets.test.ts b/apps/api/tests/dashboard-host-assets.test.ts new file mode 100644 index 0000000..8b8f00f --- /dev/null +++ b/apps/api/tests/dashboard-host-assets.test.ts @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const readFileMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:fs/promises", () => ({ readFile: readFileMock })); +vi.mock("@ekklesia/shell/dashboard-assets", () => ({ + dashboardHostAssetUrls: Object.freeze({ + html: "file:///surface/browser/index.html", + script: "file:///surface/browser/assets/ekklesia-dashboard/app.js", + }), +})); + +import { + createDashboardHostAssets, + DashboardHostAssetError, + loadDashboardHostAssets, +} from "../src/dashboard-host-assets.js"; + +function sha256Etag(text: string): string { + return `"${createHash("sha256").update(text).digest("base64url")}"`; +} + +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 script = "export const dashboardHost = true;\n"; + readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); + + const assets = await loadDashboardHostAssets(); + + expect(readFileMock.mock.calls).toEqual([ + [new URL("file:///surface/browser/index.html"), "utf8"], + [new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"], + ]); + expect(assets).toEqual({ + html, + htmlEtag: sha256Etag(html), + script, + scriptEtag: sha256Etag(script), + }); + expect(Object.isFrozen(assets)).toBe(true); + }); + + it("fails deterministically without attempting a neighboring file or exposing the read error", async () => { + readFileMock.mockRejectedValueOnce( + new Error("ENOENT /home/operator/private/surface-build secret-token"), + ); + + const failure = await loadDashboardHostAssets().catch((error: unknown) => error); + + expect(readFileMock).toHaveBeenCalledTimes(1); + expect(failure).toBeInstanceOf(DashboardHostAssetError); + expect(failure).toMatchObject({ + code: "DASHBOARD_HOST_ASSET_UNAVAILABLE", + message: "The Dashboard host browser assets are unavailable.", + name: "DashboardHostAssetError", + }); + expect((failure as Error).stack).toBe( + "DashboardHostAssetError: The Dashboard host browser assets are unavailable.", + ); + const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`; + for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) { + expect(publicText).not.toContain(secret); + } + }); + + it("fails when the script is missing even though the page was read", async () => { + readFileMock + .mockResolvedValueOnce("") + .mockRejectedValueOnce(new Error("ENOENT app.js")); + + await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError); + expect(readFileMock).toHaveBeenCalledTimes(2); + }); + + it("rejects an empty page or script as an unavailable shell build", () => { + for (const [html, script] of [ + ["", "export {};"], + ["", " \n"], + ] as const) { + expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError); + } + }); +}); diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts new file mode 100644 index 0000000..ca5ffce --- /dev/null +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -0,0 +1,347 @@ +import { createHash } from "node:crypto"; +import { + ANONYMOUS_PRINCIPAL, + type ModuleDatabase, + type ModuleRouteHandler, + type ModuleRuntimeServices, + parsePermissionId, + type RegisteredModuleHttpRoute, +} from "@ekklesia/contracts"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + createPermissionRegistry, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + createModuleUiPublicationRegistry, + EMPTY_MODULE_REALTIME_REGISTRY, +} from "@ekklesia/core/modules"; +import { describe, expect, it, vi } from "vitest"; + +// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read +// while answering a request — the hallmark of a static directory — fails these tests. +const readFileMock = vi.hoisted(() => vi.fn()); +vi.mock("node:fs/promises", () => ({ readFile: readFileMock })); + +import { createApiApp } from "../src/app.js"; +import { + createDashboardHostAssets, + type DashboardHostAssets, +} from "../src/dashboard-host-assets.js"; +import { + DASHBOARD_HOST_PAGE_PATH, + DASHBOARD_HOST_SCRIPT_PATH, + DashboardHostConfigurationError, +} from "../src/dashboard-host-routes.js"; +import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; +import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; +import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; +import { readyHealth } from "./support/ready-health.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const modulePermission = parsePermissionId("dashboard.page.read"); + +const PAGE_HTML = + '
\n'; +const SCRIPT = "export const dashboard = 'built shell';\n"; + +function sha256Etag(text: string): string { + return `"${createHash("sha256").update(text).digest("base64url")}"`; +} + +const PAGE_ETAG = sha256Etag(PAGE_HTML); +const SCRIPT_ETAG = sha256Etag(SCRIPT); + +function fakeRuntime(): EkklesiaIdentityRuntime { + return { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + }; +} + +function fakeStore(): AuthorizationStore { + return { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + }; +} + +function fakeModuleServices(): ModuleRuntimeServices { + return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); +} + +function buildApp( + options: { + readonly assets?: DashboardHostAssets; + readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[]; + } = {}, +) { + const evaluator: AuthorizationEvaluator = { + authorize: vi.fn().mockResolvedValue({ allowed: true }), + }; + const runtime = fakeRuntime(); + const app = createApiApp({ + authorization: { + evaluator, + registry: createPermissionRegistry([ + { baseline: "public", description: "Test permission.", id: modulePermission }, + ]), + store: fakeStore(), + }, + moduleRoutes: options.moduleRoutes ?? Object.freeze([]), + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, + moduleServices: fakeModuleServices(), + moduleUi: createModuleUiPublicationRegistry([]), + moduleLifecycle: emptyModuleLifecycle(), + moduleConfiguration: emptyModuleConfiguration(), + moduleStorage: emptyModuleStorage(), + dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT), + health: readyHealth(), + runtime, + trustedOrigins: [trustedOrigin], + }); + return { app, evaluator, runtime }; +} + +function expectPageSecurityHeaders(response: Response): void { + expect(response.headers.get("x-frame-options")).toBe("DENY"); + expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); +} + +async function expectSanitizedNotFound(response: Response, label: string): Promise { + expect(response.status, label).toBe(404); + expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u); + expect(await response.json(), label).toEqual({ code: "NOT_FOUND" }); +} + +describe("GET / (the Dashboard page)", () => { + it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => { + const { app } = buildApp(); + + const response = await app.request(DASHBOARD_HOST_PAGE_PATH); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(PAGE_HTML); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("no-cache"); + expect(response.headers.get("etag")).toBe(PAGE_ETAG); + expectPageSecurityHeaders(response); + // The one CSP directive is the only one: the full policy is a separate decision. + expect(response.headers.get("content-security-policy")).not.toContain(";"); + }); + + it("serves the same page for an emailed-link landing, since the query never selects a file", async () => { + const { app } = buildApp(); + + const response = await app.request("/?ekklesia_auth=reset-password&token=abc123"); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(PAGE_HTML); + expect(response.headers.get("etag")).toBe(PAGE_ETAG); + }); + + it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => { + const { app } = buildApp(); + + for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) { + const response = await app.request(DASHBOARD_HOST_PAGE_PATH, { + headers: { "If-None-Match": ifNoneMatch }, + }); + + expect(response.status, ifNoneMatch).toBe(304); + expect(await response.text(), ifNoneMatch).toBe(""); + expect(response.headers.get("etag")).toBe(PAGE_ETAG); + expect(response.headers.get("cache-control")).toBe("no-cache"); + expectPageSecurityHeaders(response); + } + }); + + it("serves the full page again when the browser holds a different build's validator", async () => { + const { app } = buildApp(); + + const response = await app.request(DASHBOARD_HOST_PAGE_PATH, { + headers: { "If-None-Match": sha256Etag("an older build") }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(PAGE_HTML); + }); + + it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => { + const { app, evaluator, runtime } = buildApp(); + + const response = await app.request(DASHBOARD_HOST_PAGE_PATH, { + headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin }, + }); + + expect(response.status).toBe(200); + expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); + expect(evaluator.authorize).not.toHaveBeenCalled(); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + expect(response.headers.get("set-cookie")).toBeNull(); + }); +}); + +describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => { + it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => { + const { app } = buildApp(); + + const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(SCRIPT); + expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("no-cache"); + expect(response.headers.get("etag")).toBe(SCRIPT_ETAG); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => { + const { app } = buildApp(); + + const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, { + headers: { "If-None-Match": SCRIPT_ETAG }, + }); + + expect(response.status).toBe(304); + expect(await response.text()).toBe(""); + expect(response.headers.get("etag")).toBe(SCRIPT_ETAG); + expect(response.headers.get("cache-control")).toBe("no-cache"); + }); + + it("does not accept the page's validator as the script's", async () => { + const { app } = buildApp(); + + const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, { + headers: { "If-None-Match": PAGE_ETAG }, + }); + + expect(response.status).toBe(200); + }); +}); + +describe("Dashboard host boundaries", () => { + it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => { + const { app } = buildApp(); + + for (const path of [ + "/index.html", + "/app.js", + "/app.js.map", + "/assets/ekklesia-dashboard/app.js.map", + "/assets/ekklesia-dashboard/", + "/assets/ekklesia-dashboard", + "/assets/ekklesia-dashboard/app.js/", + "/assets/ekklesia-dashboard/index.html", + "/assets/", + "/assets/ekklesia-dashboard/APP.JS", + "/dashboard", + "/login", + ]) { + await expectSanitizedNotFound(await app.request(path), path); + } + }); + + it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => { + const { app } = buildApp(); + + for (const path of [ + "/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json", + "/assets/ekklesia-dashboard/..%2fapp.js.map", + "/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map", + "/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json", + "/assets/ekklesia-dashboard/..%5c..%5cpackage.json", + "/%2e%2e/%2e%2e/etc/passwd", + "/../../etc/passwd", + "//assets/ekklesia-dashboard/app.js", + "/assets//ekklesia-dashboard/app.js", + "/assets/ekklesia-dashboard/app.js%00.map", + ]) { + const response = await app.request(path); + expect(await response.text(), path).not.toContain(SCRIPT); + expect(response.status, path).toBe(404); + } + expect(readFileMock).not.toHaveBeenCalled(); + }); + + it("never touches the filesystem while answering, even across every served path", async () => { + const { app } = buildApp(); + + await app.request(DASHBOARD_HOST_PAGE_PATH); + await app.request(DASHBOARD_HOST_SCRIPT_PATH); + await app.request("/assets/ekklesia-dashboard/app.js.map"); + + expect(readFileMock).not.toHaveBeenCalled(); + }); + + it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => { + const { app } = buildApp(); + + const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(head.headers.get("etag")).toBe(PAGE_ETAG); + expect(await head.text()).toBe(""); + + for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) { + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`); + } + } + }); + + it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => { + const moduleHandler = vi.fn( + async () => new Response("module", { status: 200 }), + ); + const { app } = buildApp({ + moduleRoutes: [ + Object.freeze({ + bodyMode: "buffered", + handler: moduleHandler, + maxBodyBytes: 1024, + method: "GET", + moduleId: "dashboard", + path: "/", + permission: modulePermission, + }) as RegisteredModuleHttpRoute, + ], + }); + + expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" }); + expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" }); + expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module"); + await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown"); + // No display is published here, so the display host's script is absent rather than answered + // by the Dashboard's. + await expectSanitizedNotFound( + await app.request(DISPLAY_HOST_SCRIPT_PATH), + DISPLAY_HOST_SCRIPT_PATH, + ); + expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false); + expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML); + expect(moduleHandler).toHaveBeenCalledTimes(1); + }); + + it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => { + const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT); + + for (const assets of [ + { ...consistent, html: `${PAGE_HTML}` }, + { ...consistent, scriptEtag: PAGE_ETAG }, + { html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG }, + ]) { + expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError); + } + }); +}); diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index 7c34463..e50439c 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -27,6 +27,7 @@ import { createApiApp } from "../src/app.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { createInMemoryModuleLifecycleStore, @@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, dashboard: createInMemoryDashboardStore(options.configuration ?? {}), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleRoutes: Object.freeze([]), moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, diff --git a/apps/api/tests/health-routes.test.ts b/apps/api/tests/health-routes.test.ts index 37e5cbb..9def9b9 100644 --- a/apps/api/tests/health-routes.test.ts +++ b/apps/api/tests/health-routes.test.ts @@ -25,6 +25,7 @@ import { READINESS_DATABASE_TIMEOUT_MS, } from "../src/health-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; @@ -91,6 +92,7 @@ function buildApp( moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health, runtime, trustedOrigins: [trustedOrigin], diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 2efe957..7f5f12e 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn()); const serveMock = vi.hoisted(() => vi.fn()); const processOnSpy = vi.hoisted(() => vi.fn()); const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn()); +const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn()); vi.mock("pg", () => ({ Pool: poolConstructor })); vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("@hono/node-server", () => ({ serve: serveMock })); +// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell, +// so the loader answers a small consistent snapshot unless a test makes it fail. +vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => { + const actual = await importOriginal(); + loadDashboardHostAssetsMock.mockImplementation(async () => + actual.createDashboardHostAssets("", "export {};\n"), + ); + return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock }; +}); vi.mock("../src/display-host-assets.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock }; @@ -35,6 +45,7 @@ import { import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { createApiApp } from "../src/app.js"; import { createApiRuntimeConfig } from "../src/config.js"; +import { DashboardHostAssetError } from "../src/dashboard-host-assets.js"; import { DisplayHostAssetError } from "../src/display-host-assets.js"; import { HEALTH_LIVE_PATH, @@ -1057,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => { expect(serveMock).not.toHaveBeenCalled(); }); + it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + const recorder = createEventRecorder(); + await createWorkingFixtureModule(modulesDirectory, "calendar", recorder); + const { poolEnd } = mockPoolAndAuth(); + loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError()); + + const failure = await startApi(validConfig(), { + modulesDirectoryOverride: modulesDirectory, + }).catch((error: unknown) => error); + + expect(failure).toMatchObject({ + code: "DASHBOARD_HOST_ASSET_UNAVAILABLE", + message: "The Dashboard host browser assets are unavailable.", + name: "DashboardHostAssetError", + }); + expect((failure as Error).stack).toBe( + "DashboardHostAssetError: The Dashboard host browser assets are unavailable.", + ); + expect(recorder.events).toEqual([]); + expect(poolEnd).toHaveBeenCalledTimes(1); + expect(serveMock).not.toHaveBeenCalled(); + }); + it("does not read display-host assets when the eligible set declares no display", async () => { const modulesDirectory = await createTemporaryModulesDirectory(); const recorder = createEventRecorder(); diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts index c8dca50..4ed885d 100644 --- a/apps/api/tests/module-configuration-routes.test.ts +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -35,6 +35,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { createFakeModuleSecretSource, @@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise { store: authorizationStore, }, dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleConfiguration: { bootSettings, diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index 9799e97..276b02c 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -38,6 +38,7 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { @@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise { store: fakeAuthorizationStore(), }, dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), ...(moduleUi.listDisplays().length > 0 && options.withAssets !== false ? { displayHostAssets: options.assets ?? displayHostAssets } diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index 69acd55..4f5bc75 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -23,6 +23,7 @@ import { } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { @@ -124,6 +125,7 @@ function buildModuleApp(options: { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index f7b94dd..9dd3505 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -33,6 +33,7 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; @@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise { store: authorizationStore, }, dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index d34b6ca..ad86b99 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -36,6 +36,7 @@ import { } from "@ekklesia/shell"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js"; @@ -289,6 +290,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), runtime, trustedOrigins: ["https://dashboard.example.test"], diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index daa0cdf..be1508c 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -25,6 +25,7 @@ import { import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js"; @@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise { moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), runtime, trustedOrigins: [trustedOrigin], diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index 2b1f4fa..3074905 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -21,6 +21,7 @@ import { Window } from "happy-dom"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { createDisplayHostAssets } from "../src/display-host-assets.js"; @@ -256,6 +257,7 @@ async function install( moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), runtime, trustedOrigins: [trustedOrigin], diff --git a/apps/api/tests/support/dashboard-host-assets.ts b/apps/api/tests/support/dashboard-host-assets.ts new file mode 100644 index 0000000..b44d9fe --- /dev/null +++ b/apps/api/tests/support/dashboard-host-assets.ts @@ -0,0 +1,13 @@ +import { + createDashboardHostAssets, + type DashboardHostAssets, +} from "../../src/dashboard-host-assets.js"; + +/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise + * the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */ +export function testDashboardHostAssets(): DashboardHostAssets { + return createDashboardHostAssets( + '
', + "export const dashboardHost = true;\n", + ); +} diff --git a/docs/development/api.md b/docs/development/api.md index a1d0cd5..31664b7 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -77,6 +77,8 @@ No real values are shown here — configure per environment and never commit the | `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. | | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | +| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), served from the same origin as `/api`. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). | +| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). | | `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). | | `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | @@ -136,6 +138,65 @@ a new raw token when asked to revoke other sessions. The principal response neve contains email, name, image, session id or expiry, session token, account/provider details, or any role, permission, membership, or volunteer data. +## Dashboard host + +`apps/api` serves the built Dashboard itself, from the same origin as `/api` +([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). That keeps the +identity system's default same-origin shape: `EKKLESIA_DASHBOARD_ORIGIN` stays unset, the session +cookie stays host-only, and no second static server or proxy file root is needed. It works exactly +like the display host: + +- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the + exact URLs of two built files, `dist/browser/index.html` and + `dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any + module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256 + strong `ETag` for each. +- **A missing build fails startup.** An absent, unreadable, or empty file stops `startApi()` with a + fixed `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources + already opened. It never carries a path or the filesystem error. Build the shell first; + `pnpm api:start` already runs the full build. +- **Two exact routes, never a directory.** `GET /` answers the page and + `GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and + nothing touches the filesystem while answering. Every other path, including `/index.html`, + `/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary + `404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client + router, and emailed links land on `/` with a query (see + [The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's + implicit `HEAD`) are also the ordinary `404`. +- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two + files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive + a `404` for it. + +Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's +CORS policy does not apply. The bytes grant no authority: the page renders sign-in until +`/api/v1/principal` says otherwise. + +| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` | +| --- | --- | --- | +| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` | +| `Cache-Control` | `no-cache` | `no-cache` | +| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script | +| `X-Content-Type-Options` | `nosniff` | `nosniff` | +| `X-Frame-Options` | `DENY` | — | +| `Content-Security-Policy` | `frame-ancestors 'none'` | — | +| `Referrer-Policy` | `no-referrer` | — | + +**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every +load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match` +(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and +`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry +keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load. + +**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed, +through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries: +the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of +every request the page makes, which matters while an emailed reset token is briefly in it. The shell +strips that token from the address bar before it renders anything. + +**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute +paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names +that path absolutely, so the page never depends on the URL that served it. + ## Dashboard browser authentication The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the @@ -1551,7 +1612,8 @@ Cookie: better-auth.session_token= See the repository root [README](../../README.md#current-status) and [platform architecture](../architecture/platform-architecture.md#current-implementation-status) -for what has landed across every phase and what remains. Surface work is underway; production -Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing -remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and +for what has landed across every phase and what remains. Surface work is underway; the API now +serves the built Dashboard ([Dashboard host](#dashboard-host)), while its production proxy, TLS, +full Content-Security-Policy, enrollment/recovery, display device identity, and public Website +routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve. diff --git a/docs/development/security.md b/docs/development/security.md index fb88359..b75cdc2 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -486,9 +486,10 @@ This checklist gates a **public launch**. It does not gate development: Surfaces work all proceed against the local development infrastructure while every item below is still outstanding. -1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an - explicitly split origin is covered by the exact trusted-origin/CORS configuration. The - repository currently supplies neither deployment. +1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly + split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the + built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)); the repository + does not yet supply the proxy deployment. 2. The origin port is unreachable from the public Internet. 3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private proxy path connects. Security must not depend on an unpublished DNS name or origin IP. diff --git a/surfaces/shell/index.html b/surfaces/shell/index.html index 7bfdf6a..4ef5068 100644 --- a/surfaces/shell/index.html +++ b/surfaces/shell/index.html @@ -11,7 +11,9 @@
- + `import()` of a published module artifact stays same-origin under it. The path is absolute + and fixed, exactly like the display host's, so it names one host-owned route rather than a + file relative to whatever URL served the page. --> + diff --git a/surfaces/shell/package.json b/surfaces/shell/package.json index 891b88d..6aa8e97 100644 --- a/surfaces/shell/package.json +++ b/surfaces/shell/package.json @@ -11,6 +11,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./dashboard-assets": { + "types": "./dist/dashboard-assets.d.ts", + "import": "./dist/dashboard-assets.js" + }, "./display-assets": { "types": "./dist/display-assets.d.ts", "import": "./dist/display-assets.js" diff --git a/surfaces/shell/scripts/build-browser.mjs b/surfaces/shell/scripts/build-browser.mjs index 04a8cdc..9ceee2e 100644 --- a/surfaces/shell/scripts/build-browser.mjs +++ b/surfaces/shell/scripts/build-browser.mjs @@ -42,7 +42,9 @@ const buildOptions = { "process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"), }, entryPoints: { - app: path.join(packageDirectory, "src", "browser", "main.tsx"), + // Emitted under the exact path `apps/api` serves it from (ADR 0044), so the development server's + // `servedir` and production answer the same absolute URL `index.html` names. + "assets/ekklesia-dashboard/app": path.join(packageDirectory, "src", "browser", "main.tsx"), "display/app": path.join(packageDirectory, "src", "display", "main.tsx"), // The display service worker (ADR 0043). A separate entry because a worker is a separate // realm with its own globals; bundling it into the page would make it unregisterable and its diff --git a/surfaces/shell/src/auth-protocol.ts b/surfaces/shell/src/auth-protocol.ts index 1baa4c2..ad45d00 100644 --- a/surfaces/shell/src/auth-protocol.ts +++ b/surfaces/shell/src/auth-protocol.ts @@ -277,3 +277,36 @@ export function readAuthLanding(search: string): HostAuthLanding | undefined { } return undefined; } + +/** + * Captures what an emailed verification or reset link asked for, then removes it from the address + * bar. + * + * A password-reset token is a bearer credential for one account. Leaving it in `location.search` + * would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of + * anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather + * than pushing a new one, so the Back button cannot return to the URL that held it. It is + * deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of + * the reset form, and never in `localStorage` or `sessionStorage`. + * + * The location and history are passed in rather than read here, so this module still reads no + * global and `browser/main.tsx` remains the shell's one browser-global boundary. Only a recognized + * Ekklesia landing rewrites the URL; any other query is left exactly as it arrived. + */ +export function consumeAuthLanding( + location: Pick, + history: Pick, +): HostAuthLanding | undefined { + const landing = readAuthLanding(location.search); + if (!landing) { + return undefined; + } + try { + history.replaceState(null, "", location.pathname); + } catch { + // A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to + // finish the flow: the token is already captured, and failing here would strand the person on a + // page that cannot do anything. + } + return landing; +} diff --git a/surfaces/shell/src/browser/main.tsx b/surfaces/shell/src/browser/main.tsx index 1618c09..d8f49bd 100644 --- a/surfaces/shell/src/browser/main.tsx +++ b/surfaces/shell/src/browser/main.tsx @@ -1,7 +1,7 @@ import { installEkklesiaDesignTokens } from "@ekklesia/design-tokens"; import { EkklesiaUiStyles } from "@ekklesia/ui"; import { createRoot } from "react-dom/client"; -import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js"; +import { consumeAuthLanding } from "../auth-protocol.js"; import type { EkklesiaHostConfig } from "../host-protocol.js"; import { HostApp } from "./HostApp.js"; @@ -26,36 +26,9 @@ const config: EkklesiaHostConfig = { apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin, }; -/** - * Captures what an emailed verification or reset link asked for, then removes it from the address - * bar. - * - * A password-reset token is a bearer credential for one account. Leaving it in `location.search` - * would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of - * anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather - * than pushing a new one, so the Back button cannot return to the URL that held it. It is - * deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of - * the reset form, and never in `localStorage` or `sessionStorage`. - * - * The parse is a pure function that recognizes only Ekklesia's own two landings, so nothing in the - * query string can name a destination. - */ -function captureAuthLanding(): HostAuthLanding | undefined { - const landing = readAuthLanding(window.location.search); - if (!landing) { - return undefined; - } - try { - window.history.replaceState(null, "", window.location.pathname); - } catch { - // A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to - // finish the flow: the token is already captured, and failing here would strand the person on a - // page that cannot do anything. - } - return landing; -} - -const authLanding = captureAuthLanding(); +/** Read once, before anything renders, and stripped from the address bar in the same step. See + * `consumeAuthLanding` for why the token must not stay in the URL. */ +const authLanding = consumeAuthLanding(window.location, window.history); const container = document.getElementById("ekklesia-host"); if (!container) { diff --git a/surfaces/shell/src/dashboard-assets.ts b/surfaces/shell/src/dashboard-assets.ts new file mode 100644 index 0000000..ff64506 --- /dev/null +++ b/surfaces/shell/src/dashboard-assets.ts @@ -0,0 +1,15 @@ +/** + * Exact built files a deployable app may publish for the Ekklesia Dashboard (ADR 0044). + * + * The same shape as `./display-assets.ts`, for the same reason: these are file URLs relative to this + * package's compiled `dist/dashboard-assets.js`, not repository paths and not request-derived + * selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed + * host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic + * static-file root, and the source map beside the script is deliberately not listed. + */ +const packageRoot = new URL("../", import.meta.url); + +export const dashboardHostAssetUrls = Object.freeze({ + html: new URL("dist/browser/index.html", packageRoot).href, + script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href, +}); diff --git a/surfaces/shell/tests/auth-recovery.test.tsx b/surfaces/shell/tests/auth-recovery.test.tsx index b10e306..0165e0a 100644 --- a/surfaces/shell/tests/auth-recovery.test.tsx +++ b/surfaces/shell/tests/auth-recovery.test.tsx @@ -1,7 +1,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; +import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; import { HostApp } from "../src/browser/HostApp.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js"; @@ -140,6 +140,58 @@ async function submit(hostTestId: string): Promise { }); } +describe("consuming an emailed-link landing", () => { + it("captures a reset token and strips the whole query from the address bar in place", () => { + window.history.replaceState({}, "", "/?ekklesia_auth=reset-password&token=secret-reset-token"); + const lengthBefore = window.history.length; + + const landing = consumeAuthLanding(window.location, window.history); + + expect(landing).toEqual({ kind: "reset-password", token: "secret-reset-token" }); + expect(window.location.search).toBe(""); + expect(window.location.href).not.toContain("secret-reset-token"); + // Replaced, not pushed: Back cannot return to the entry that held the token. + expect(window.history.length).toBe(lengthBefore); + }); + + it("strips every recognized landing, including a failed verification and an invalid reset link", () => { + for (const search of [ + "?ekklesia_auth=verify-email", + "?ekklesia_auth=verify-email&error=TOKEN_EXPIRED", + "?ekklesia_auth=reset-password&error=INVALID_TOKEN", + "?ekklesia_auth=reset-password&token=not%20a%20valid%20token", + ]) { + window.history.replaceState({}, "", `/${search}`); + + expect(consumeAuthLanding(window.location, window.history), search).toBeDefined(); + expect(window.location.search, search).toBe(""); + } + }); + + it("leaves an unrelated query untouched and rewrites nothing", () => { + window.history.replaceState({}, "", "/?unrelated=1"); + const replaceState = vi.fn(); + + expect(consumeAuthLanding(window.location, { replaceState })).toBeUndefined(); + expect(replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe("?unrelated=1"); + }); + + it("still returns the captured landing when the browser refuses the rewrite", () => { + const replaceState = vi.fn(() => { + throw new Error("SecurityError"); + }); + + expect( + consumeAuthLanding( + { pathname: "/", search: "?ekklesia_auth=reset-password&token=abc123" }, + { replaceState }, + ), + ).toEqual({ kind: "reset-password", token: "abc123" }); + expect(replaceState).toHaveBeenCalledWith(null, "", "/"); + }); +}); + describe("emailed-link landing parsing", () => { it("recognizes exactly Ekklesia's own two landings", () => { expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" }); diff --git a/surfaces/shell/tests/dashboard-assets.test.ts b/surfaces/shell/tests/dashboard-assets.test.ts new file mode 100644 index 0000000..4886ca4 --- /dev/null +++ b/surfaces/shell/tests/dashboard-assets.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { dashboardHostAssetUrls } from "../src/dashboard-assets.js"; + +const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +describe("Dashboard host asset locator", () => { + it("exports only frozen immutable href strings for the two exact built files, never the source map", () => { + expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true); + expect(dashboardHostAssetUrls).toEqual({ + html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href, + script: new URL( + "dist/browser/assets/ekklesia-dashboard/app.js", + `file://${packageDirectory}/`, + ).href, + }); + expect(JSON.stringify(dashboardHostAssetUrls)).not.toContain(".map"); + }); + + it("publishes the locator through a narrow package subpath, not the shell root barrel", async () => { + const manifest = JSON.parse( + await readFile(path.join(packageDirectory, "package.json"), "utf8"), + ) as { exports?: Record }; + expect(manifest.exports?.["./dashboard-assets"]).toEqual({ + import: "./dist/dashboard-assets.js", + types: "./dist/dashboard-assets.d.ts", + }); + + const rootSource = await readFile(path.join(packageDirectory, "src/index.ts"), "utf8"); + expect(rootSource).not.toContain("dashboardHostAssetUrls"); + expect(rootSource).not.toContain("dashboard-assets"); + }); + + it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => { + const html = await readFile(path.join(packageDirectory, "index.html"), "utf8"); + + expect(html).toContain('id="ekklesia-host"'); + expect(html.match(/', + ); + }); + + it("builds the Dashboard entry at the exact path the locator names", async () => { + const buildScript = await readFile( + path.join(packageDirectory, "scripts/build-browser.mjs"), + "utf8", + ); + + expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join('); + }); +}); diff --git a/surfaces/shell/tsconfig.build.json b/surfaces/shell/tsconfig.build.json index afa1ebd..ae7742e 100644 --- a/surfaces/shell/tsconfig.build.json +++ b/surfaces/shell/tsconfig.build.json @@ -10,6 +10,7 @@ }, "include": [ "src/index.ts", + "src/dashboard-assets.ts", "src/display-assets.ts", "src/host-protocol.ts", "src/module-context.ts"