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/`, 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//ui/`. 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`; } /** * Canonical browser URL for one of a module's declared browser contributions (ADR 0041). * * The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact * route or the `display` sibling. The contribution id is percent-encoded for the same reason the * module id and version are: a value that somehow did not come from a validated manifest can only * ever become one segment's worth of text — never a new segment, a traversal, a query, or a * fragment — and a value that needed encoding simply fails to resolve against the registry. * * Like every other publication helper, this takes **identity only**. There is no parameter for a * filename or any other artifact selector, so no caller can use it to reach a second file inside a * module package. */ export function moduleContributionArtifactUrl( identity: ModuleUiIdentity, contributionId: string, ): string { return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`; } /** * 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 { // 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`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path * components**. They are handed to the registry 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/contributions/:contributionId`, async (c) => { // Three lookup keys, one exact registry match, one already-validated canonical path. Nothing // is composed, so a contribution artifact is reachable on exactly the same terms as a module // UI artifact and on no others. const published = options.registry.resolveContribution( c.req.param("moduleId"), c.req.param("version"), c.req.param("contributionId"), ); if (!published) { return notPublished(c); } return serveArtifact(c, published); }, ); 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; }