feat: add display kiosk host
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user