feat: publish validated module UI artifacts at a platform-owned URL
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI build pipeline and the generic host loader: Core resolves entrypoints.ui to an absolute server filesystem path, and a browser needs a URL. This adds that publication layer. Core owns the policy. createModuleUiPublicationRegistry builds, from the discovered and started module inventory, the answer to "may this be published, and which exact file is it?" — publishable means the module survived discovery and declares entrypoints.ui, with no new lifecycle state and no installer. apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both segments are lookup keys, not path components. An exact match yields the canonical artifact path discovery already validated; no request value is ever composed into a filesystem path, and the route declares no wildcard segment, so traversal, encoded separators, sibling chunks, package-root files, and source maps are unreachable by construction. An installed module directory never becomes a static file root. The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module route segment, so a module registering GET /ui/:version would otherwise compete with the platform for one URL with routing order deciding the winner. The route requires no authentication, deliberately. Fetching the bundle confers no authority, and a cookie-gated route would break cross-origin import(), which sends none. The cost is stated rather than hidden: a caller who knows a module id and its exact version can download that bundle. The three failure modes return one identical 404 so the route is not an enumeration oracle. Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match handling, not immutable: without an installer nothing stops a version being rebuilt with different bytes. The server reads bytes and writes bytes — never imports, evaluates, or transforms the artifact. Nothing mounts a module UI. A host could now obtain a URL, which was previously impossible; there is still no Dashboard shell, navigation, or metadata endpoint. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82cbeef437
commit
a02deb9433
@@ -0,0 +1,219 @@
|
||||
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,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||
* installed module declares no `entrypoints.ui`, 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;
|
||||
});
|
||||
}
|
||||
|
||||
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`, async (c) => {
|
||||
const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version"));
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
Reference in New Issue
Block a user