feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s

This commit is contained in:
ekklesia
2026-08-23 20:50:56 -05:00
parent be5ed7853d
commit 2e0fe059c3
80 changed files with 4868 additions and 397 deletions
+203
View File
@@ -0,0 +1,203 @@
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 { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { grantedModulePermissionIds } 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 MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
export function moduleDisplayPageUrl(moduleId: string): string {
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
}
export function moduleDisplayDescriptorUrl(moduleId: string): string {
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
}
/** 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 };
readonly api: { readonly baseUrl: string };
readonly realtime: { readonly baseUrl: string };
readonly permissions: readonly string[];
}
export interface HostModuleDisplayResource {
readonly module: HostModuleDisplayDescriptor;
}
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);
}
/** 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. */
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
try {
const snapshot = createDisplayHostAssets(input.html, input.script);
if (snapshot.scriptEtag !== input.scriptEtag) {
throw new ModuleDisplayConfigurationError();
}
return snapshot;
} catch {
throw new ModuleDisplayConfigurationError();
}
}
async function describeDisplay(
options: ModuleDisplayRoutesOptions,
principal: EkklesiaPrincipal,
display: PublishableModuleDisplay,
): Promise<HostModuleDisplayResource> {
return {
module: {
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
display: { url: moduleDisplayUiArtifactUrl(display) },
id: 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);
app.get(moduleDisplayPageUrl(display.moduleId), requireDisplayPermission, () => {
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 {
return c.json(await describeDisplay(options, getAuthorizedPrincipal(c), display), 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,
});
});
for (const display of displays) {
mountDisplay(app, { ...options, assets }, display);
}
}