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; }