Files
Ekklesia/apps/api/src/module-display-routes.ts
T
ekklesiaandClaude Opus 5.5 a12cbe9600
CI / verify (pull_request) Successful in 46s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m44s
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 <[email protected]>
2026-10-01 17:54:55 -05:00

289 lines
12 KiB
TypeScript

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/<module-id>`, 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/<module-id>` 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<HostModuleDisplayResource> {
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/<id>` 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);
}
}