252 lines
13 KiB
TypeScript
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;
|
|
}
|