The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
290 lines
14 KiB
TypeScript
290 lines
14 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`;
|
|
}
|
|
|
|
/**
|
|
* 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<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`, `: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;
|
|
}
|