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]>
289 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|