import { moduleIdSchema } from "@ekklesia/contracts"; import { isReservedModuleId } from "@ekklesia/core/modules"; import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes"; import { Hono } from "hono"; import { etagMatches } from "./conditional-request.js"; import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js"; /** * The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API * serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so * this server and the shell's router read one list), and at each installed module's `/`. * Every one of those first segments is either a reserved platform name or a module id Core accepted, * and Core refuses a module id that is a reserved name, so no module page can shadow `/api`, * `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display * host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still * lands on `/` with a query (ADR 0038). */ export const DASHBOARD_HOST_PAGE_PATH = "/"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; /** The pre-paint appearance script (ADR 0062), beside the application script. */ export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.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, input.appearanceScript); if ( snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag || snapshot.appearanceScriptEtag !== input.appearanceScriptEtag ) { 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, }); } export interface DashboardHostRouteOptions { /** * The id of every module this process discovered that declares a Dashboard UI — enabled or not, * since a disabled module's bookmark should open the Dashboard and say the module is unavailable * rather than fail as an unknown address. Fixed at startup, like the rest of the module set. */ readonly modulePages: readonly string[]; } /** Each module page must be a real module id that names no platform namespace, exactly once. * Discovery already guarantees this; the routes refuse to compose rather than trust it. */ function validatedModulePages(modulePages: readonly string[]): readonly string[] { const seen = new Set(); for (const moduleId of modulePages) { if ( !moduleIdSchema.safeParse(moduleId).success || isReservedModuleId(moduleId) || DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) || seen.has(moduleId) ) { throw new DashboardHostConfigurationError(); } seen.add(moduleId); } return [...seen]; } /** * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its * default same-origin shape. * * Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken * from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard * platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed * module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no * server change later) — always with the **same** bytes and the same validator, because the path * selects a page inside the shell rather than a file here; that is what lets a refreshed or * bookmarked deep link stay where it was. * * A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not * installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does * `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall 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, options: DashboardHostRouteOptions, ): Hono { const snapshot = snapshotAssets(assets); const modulePages = validatedModulePages(options.modulePages); const routes = new Hono(); const page = (ifNoneMatch: string | undefined): Response => fixedAsset( ifNoneMatch, snapshot.html, snapshot.htmlEtag, "text/html; charset=utf-8", DASHBOARD_PAGE_SECURITY_HEADERS, ); routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match"))); for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) { routes.get(`/${name}`, (c) => page(c.req.header("if-none-match"))); routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match"))); } routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) => fixedAsset( c.req.header("if-none-match"), snapshot.script, snapshot.scriptEtag, "text/javascript; charset=utf-8", {}, ), ); // Same-origin, revalidated like the application script, and carrying nothing but code: the // person's cached choice lives in their browser and is read by this script, never served by it. routes.get(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, (c) => fixedAsset( c.req.header("if-none-match"), snapshot.appearanceScript, snapshot.appearanceScriptEtag, "text/javascript; charset=utf-8", {}, ), ); return routes; }