import type { EkklesiaPrincipal } from "@ekklesia/contracts"; import type { ModuleUiPublicationRegistry, PublishableModuleDisplay } from "@ekklesia/core/modules"; import type { Context, Hono } from "hono"; import { type AuthorizationMiddlewareOptions, 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, MODULE_DISPLAY_PAGE_PREFIX, moduleDisplayDescriptorUrl, moduleDisplayPageUrl, } from "./module-display-path.js"; import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js"; import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { describeModuleContributions, grantedModulePermissionIds, type HostModuleContributionDescriptor, } from "./module-ui-inventory.js"; import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js"; /** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under * `/api/v1/modules/`, and neither string enters the module-facing UI ABI. */ export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js"; /** * Where the display's offline service worker is served (ADR 0043). * * The path is chosen for its **directory**: a service worker's default scope is the directory it was * served from, so `/display/sw.js` controls display pages and nothing else. Serving it from * `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening * it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for. * * `sw.js` cannot collide with a display page: a display page is registered at the exact literal * `/display/` for each display-capable module, and `sw.js` is not a valid module id. */ export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js"; /** The portable facts a browser host needs to mount this module's one display artifact. It mirrors * the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a * management artifact into a signage route. No outer Ekklesia route or filesystem path crosses the * boundary. */ export interface HostModuleDisplayDescriptor { readonly id: string; readonly version: string; readonly display: { readonly url: string }; /** The public page a screen opens to mount this display. This is distinct from `display.url`, * which is the compiled artifact imported by the platform host. */ readonly page: { readonly url: string }; readonly api: { readonly baseUrl: string }; readonly realtime: { readonly baseUrl: string }; readonly permissions: readonly string[]; /** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the * mechanism a signage-style display uses to show another module's content without importing it. */ readonly contributions: readonly HostModuleContributionDescriptor[]; } /** * Who the host resolved as the viewer of this display. * * The descriptor reports it because the display host must not ask `/api/v1/principal` — that * endpoint answers a question about *people*, and a paired device is deliberately anonymous to it * (ADR 0036). Reading the viewer from the response that already performed the authorization means * the display never has to infer "no human session, so presumably a device", and the two identity * classes never have to be told apart by elimination. */ export type HostDisplayViewer = | { readonly kind: "authenticated"; readonly userId: string } | { readonly kind: "device"; readonly deviceId: string }; export interface HostModuleDisplayResource { readonly module: HostModuleDisplayDescriptor; readonly viewer: HostDisplayViewer; } export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptions { /** The same immutable, eligible/running publication registry used for artifact bytes. */ readonly moduleUi: ModuleUiPublicationRegistry; /** Omitted only when the registry contains no display entrypoints. Production reads the exact * surface-owned files before binding and supplies their frozen contents here. */ readonly assets?: DisplayHostAssets; } /** A composition error, detected before any display route is registered. Carries no module id, * permission id, path, or package location. */ export class ModuleDisplayConfigurationError extends Error { constructor() { super("The module display host configuration is inconsistent."); this.name = "ModuleDisplayConfigurationError"; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } function displayFailure(c: Context): Response { c.header("Cache-Control", "no-store"); return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503); } /** 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. */ function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets { try { const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker); if ( snapshot.scriptEtag !== input.scriptEtag || snapshot.serviceWorkerEtag !== input.serviceWorkerEtag ) { throw new ModuleDisplayConfigurationError(); } return snapshot; } catch { throw new ModuleDisplayConfigurationError(); } } function describeViewer(principal: EkklesiaPrincipal): HostDisplayViewer | undefined { if (principal.kind === "authenticated") { return { kind: "authenticated", userId: principal.userId }; } if (principal.kind === "device") { return { deviceId: principal.deviceId, kind: "device" }; } // Unreachable behind the permission gate: the display permission is `restricted`, which no // anonymous principal satisfies. Stated rather than assumed. return undefined; } async function describeDisplay( options: ModuleDisplayRoutesOptions, principal: EkklesiaPrincipal, display: PublishableModuleDisplay, viewer: HostDisplayViewer, ): Promise { return { viewer, module: { api: { baseUrl: moduleApiBaseUrl(display.moduleId) }, contributions: describeModuleContributions(options.moduleUi, display.moduleId), display: { url: moduleDisplayUiArtifactUrl(display) }, id: display.moduleId, page: { url: moduleDisplayPageUrl(display.moduleId) }, permissions: await grantedModulePermissionIds( options.evaluator, principal, display.permissions, ), realtime: { baseUrl: moduleRealtimeBaseUrl(display.moduleId) }, version: display.version, }, }; } function mountDisplay( app: Hono, options: ModuleDisplayRoutesOptions & { readonly assets: DisplayHostAssets }, display: PublishableModuleDisplay, ): void { // The permission comes from an already-validated manifest/publication record. A request selects // neither the permission nor the artifact; it can only hit this exact pre-composed route. const requireDisplayPermission = requirePermission(options, display.displayPermission); /** * The page itself is **not** permission-gated, and the descriptor below still is. * * An unattended screen has no identity until someone pairs it, so a gate here would mean a new * television could only ever be told 403 — there would be nowhere for it to display a pairing * code, and the only way to adopt one would be to sign a human into it, which is exactly the * model this pass exists to remove (ADR 0036). * * Serving it costs nothing, because it carries nothing: these are the same inert, module-agnostic * bytes for every display, with no module id, no data, and one absolute script URL that was * already public. Authorization lives entirely in the descriptor — the page can render, and then * learns it may show only a pairing code. What this does reveal is that a module with a display * exists at that path, which any pairing flow must reveal to the device it is pairing. */ app.get(moduleDisplayPageUrl(display.moduleId), () => { return new Response(options.assets.html, { headers: { "Cache-Control": "no-store", "Content-Type": "text/html; charset=utf-8", "X-Content-Type-Options": "nosniff", }, status: 200, }); }); app.get(moduleDisplayDescriptorUrl(display.moduleId), requireDisplayPermission, async (c) => { c.header("Cache-Control", "no-store"); c.header("Vary", "Origin, Cookie"); try { const principal = getAuthorizedPrincipal(c); const viewer = describeViewer(principal); if (!viewer) { return displayFailure(c); } return c.json(await describeDisplay(options, principal, display, viewer), 200); } catch { return displayFailure(c); } }); } /** * Mounts one stable page and one descriptor route per eligible, running display declaration. * * There is deliberately no display inventory in this pass. A management UI may need one later, * but a bookmarked TV needs only its version-independent page URL. Unknown, dashboard-only, * disabled, and dependency-blocked modules have no registered route and collapse into the * application's ordinary sanitized 404. * * The page is an inert, module-agnostic shell: its bytes contain no module id and its one absolute * script URL cannot be rebased by a deep `/display/` path. The browser reads the validated id * from its outer URL, requests the permission-gated descriptor, and only then imports the exact * versioned display artifact that the server supplied. */ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutesOptions): void { const displays = options.moduleUi.listDisplays(); if (displays.length === 0) { return; } if (!options.assets) { throw new ModuleDisplayConfigurationError(); } const assets = snapshotAssets(options.assets); for (const display of displays) { if (!options.registry.has(display.displayPermission)) { throw new ModuleDisplayConfigurationError(); } } // One fixed host bundle, never a directory and never a request-selected package file. Like module // artifact bytes, fetching this platform code grants no authority; the page and descriptor are // where identity and the module-owned display permission are enforced. app.get(DISPLAY_HOST_SCRIPT_PATH, (c) => { if (etagMatches(c.req.header("if-none-match"), assets.scriptEtag)) { return new Response(null, { headers: { "Cache-Control": "no-cache", ETag: assets.scriptEtag }, status: 304, }); } return new Response(assets.script, { headers: { "Cache-Control": "no-cache", "Content-Type": "text/javascript; charset=utf-8", ETag: assets.scriptEtag, "X-Content-Type-Options": "nosniff", }, status: 200, }); }); /** * The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser * re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is * how a screen avoids being trapped on an obsolete worker. * * Like the host script and module artifact bytes, fetching this code grants no authority. It * caches only public code and never a permission decision, so serving it anonymously withholds * nothing the platform was protecting. */ app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => { if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) { return new Response(null, { headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag }, status: 304, }); } return new Response(assets.serviceWorker, { headers: { "Cache-Control": "no-cache", "Content-Type": "text/javascript; charset=utf-8", ETag: assets.serviceWorkerEtag, "X-Content-Type-Options": "nosniff", }, status: 200, }); }); for (const display of displays) { mountDisplay(app, { ...options, assets }, display); } }