Files
Ekklesia/apps/api/src/module-ui-routes.ts
T
ekklesia 2e0fe059c3
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
feat: add display kiosk host
2026-08-23 20:50:56 -05:00

252 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules";
import { Hono } from "hono";
import type { Context } from "hono";
/**
* The platform-owned URL space for published module UI artifacts.
*
* Deliberately **not** nested under `/api/v1/modules/<module-id>`, and that is a security decision
* rather than a stylistic one. A module may declare any route path matching Core's own grammar
* (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static
* segment — so a module registering `GET /ui/:version` would be mounted at exactly
* `/api/v1/modules/<module-id>/ui/<version>`. Publishing there would put the platform and installed
* modules in the same URL namespace, with Hono's registration order silently deciding which one
* answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and
* nothing the platform serves here does.
*
* It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected
* trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires
* — instead of introducing a second, separately-configured public URL space.
*/
export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui";
/**
* Revalidate-always, with a strong `ETag` doing the actual work.
*
* The URL carries module id and exact installed version, which is *identity*, not *immutability*.
* Ekklesia has no installer, no content addressing, and no integrity verification, so nothing stops
* an operator from rebuilding version `1.2.3` with different bytes and restarting — the ordinary
* inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a
* promise the platform cannot keep, and a browser that cached a superseded artifact under a reused
* version would have no way to recover.
*
* `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With
* the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed
* one is picked up immediately. A version bump changes the URL and never collides at all. When a
* real installer can guarantee that an installed version's bytes never change, this is the single
* constant that would move to immutable caching.
*/
export const MODULE_UI_CACHE_CONTROL = "no-cache";
/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing
* standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses
* to evaluate an ES module served as anything else, so this is a correctness requirement, not a
* courtesy. */
export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8";
export interface ModuleUiIdentity {
readonly moduleId: string;
readonly version: string;
}
/**
* The one canonical way to turn an installed module's identity into the browser URL its UI artifact
* is published at. Every producer of that URL — this package's own route registration, a future
* module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is
* defined once instead of being re-spelled as a template string in each of them.
*
* The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024
* built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a
* host on a different origin resolves it against the same API base URL it already needs for
* `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not
* absolute: the API's public origin is deployment configuration, and baking one in here would break
* both split-origin deployments and reverse proxies.
*
* This function accepts **identity only**. There is no parameter for a filename, a relative path,
* or any other artifact selector, so no caller — however careless — can use it to reach a second
* file in a module package. Both components are percent-encoded, so even a value that never came
* from a validated manifest can only ever produce one extra path segment's worth of text, never a
* new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail
* to resolve against the publication registry.
*/
export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent(
identity.version,
)}`;
}
/**
* Canonical browser URL for a module's independently declared display artifact.
*
* The literal trailing segment is host-owned, not a caller-selected role. Keeping this as a
* separate helper and an exact route means neither request input nor a generic role parameter can
* select a neighbouring artifact. A future standalone host may expose a different outer display
* page while consuming this same published module artifact internally.
*/
export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
return `${moduleUiArtifactUrl(identity)}/display`;
}
/**
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
* installed module declares no artifact for the requested UI role, or the requested version is not
* the installed one.
*
* They are deliberately indistinguishable. The route requires no authentication (see
* `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate
* which modules an installation runs and at which versions. Collapsing them means a caller must
* already know both the exact module id and the exact version to learn anything at all.
*/
function notPublished(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404);
}
/**
* The artifact was validated at discovery and is not retrievable now — deleted, replaced by a
* directory, or unreadable. That is a server-side condition rather than a bad request, and often a
* transient one (an operator mid-upgrade), so it reports like every other "could not be determined"
* result in this application: a sanitized 503 with a fixed code. Never carries the artifact path,
* the module directory, the `errno`, or the caught error.
*/
function artifactUnavailable(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503);
}
/**
* RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and
* every listed entity-tag is compared with the **weak** comparison function, so a client echoing
* `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to
* match, which costs a full 200 and can never produce a wrongly-fresh 304.
*/
function isNotModified(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;
});
}
interface PublishedArtifact {
readonly artifactPath: string;
}
/** Serve one exact path Core already validated, with one response contract for both UI roles. */
async function serveArtifact(c: Context, published: PublishedArtifact): Promise<Response> {
// Discovery validated this exact path earlier; the file may still have changed or vanished
// since. The response is produced from whatever a single read of that one path returns right
// now — there is deliberately no fallback path, no re-resolution, and no retry against a
// reconstructed location, so a vanished artifact fails rather than quietly resolving to
// something else.
let artifact: Buffer;
try {
artifact = await readFile(published.artifactPath);
} catch {
return artifactUnavailable(c);
}
// Hashing the bytes actually being served — rather than trusting an mtime or size — means the
// validator can never describe a different revision than the body does.
const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`;
if (isNotModified(c.req.header("if-none-match"), etag)) {
return new Response(null, {
headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag },
status: 304,
});
}
return new Response(artifact, {
headers: {
"Cache-Control": MODULE_UI_CACHE_CONTROL,
"Content-Type": MODULE_UI_CONTENT_TYPE,
ETag: etag,
// The declared type is the only type: a browser must not be allowed to sniff a module
// artifact into some other interpretation, and `import()` requires the JavaScript type
// anyway.
"X-Content-Type-Options": "nosniff",
},
status: 200,
});
}
export interface CreateModuleUiRoutesOptions {
/** The platform's publication policy, built by Core from the discovered, started module
* inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives
* every byte it serves from this registry and nothing else. */
readonly registry: ModuleUiPublicationRegistry;
}
/**
* Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser
* URL, closing the one gap between Core's server-side artifact path and the generic loader's
* browser-side URL (ADR 0024, ADR 0025).
*
* ## The request never touches the filesystem
*
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
* are all unreachable by construction rather than by filtering. An installed module directory never
* becomes a static file root, and only the single artifact its manifest declares is reachable.
*
* ## No authentication, deliberately
*
* Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an
* operator reviewed and installed; it carries no credential, and the host — not the artifact —
* supplies the principal and permissions at mount time. Every module API stays behind
* `requirePermission` against the composed platform registry, and nothing about fetching this file
* changes a single authorization decision.
*
* Requiring a session would also break the thing it is meant to protect: a browser's dynamic
* `import()` uses `credentials: "same-origin"`, so a cross-origin import (the split-origin
* deployments `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support) sends no cookie and would fail
* — a failure that tests green on a server and only shows up in a real browser, exactly the class of
* error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a
* `blob:` URL, which costs a host its `script-src` CSP.
*
* The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and**
* its exact installed version can download that module's UI bundle. That is information disclosure,
* not authority, and it is the reason the failure responses above are indistinguishable.
*
* ## The artifact is never executed
*
* This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify,
* re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like
* any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and
* publication does not become a second, quieter way to run browser code on the server.
*/
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
const routes = new Hono();
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
const published = options.registry.resolveDisplay(
c.req.param("moduleId"),
c.req.param("version"),
);
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
});
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => {
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
if (!published) {
return notPublished(c);
}
return serveArtifact(c, published);
});
return routes;
}