feat: publish validated module UI artifacts at a platform-owned URL
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 41s

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:
ekklesia
2026-08-22 08:11:53 -05:00
co-authored by Claude Opus 5
parent 82cbeef437
commit a02deb9433
20 changed files with 1898 additions and 30 deletions
+1
View File
@@ -4,3 +4,4 @@ export * from "./migration-discovery.js";
export * from "./permissions.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
export * from "./ui-publication.js";
+101
View File
@@ -0,0 +1,101 @@
import type { DiscoveredModule } from "./discovery.js";
/**
* One installed module whose UI artifact the platform is willing to publish at a browser URL.
*
* `artifactPath` is the canonical absolute path discovery already resolved and validated for
* `entrypoints.ui` — realpath-resolved, proven to be a compiled `.js`/`.mjs` file, and proven to
* stay inside the module package both before and after symlink resolution
* (`resolveEntrypoints`, `./discovery.js`). A publisher serves **exactly this path** and never
* composes a path of its own from request input; that is the whole reason this record exists
* rather than a module id plus a path-building convention.
*/
export interface PublishableModuleUi {
readonly moduleId: string;
readonly version: string;
readonly artifactPath: string;
}
/**
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
*
* Lookup is by module **identity**, never by path. There is deliberately no method that accepts a
* filename, a relative path, a glob, or anything else that could turn an installed module
* directory into a static file root.
*/
export interface ModuleUiPublicationRegistry {
/** Every publishable module, in the discovery order it was built from. Stable and frozen. */
list(): readonly PublishableModuleUi[];
/**
* The publishable record for an exact module id **and** exact installed version, or `undefined`.
*
* Takes `unknown` for the same reason `PermissionRegistry.get` does: the caller is usually
* handing over decoded request input, and a lookup that fails closed on anything malformed is
* safer than one whose type signature quietly assumes validation already happened. A module that
* is not installed, one that declares no `entrypoints.ui`, and one installed at a different
* version are all indistinguishable here — each returns `undefined`.
*/
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
}
/**
* Builds the platform's module UI publication policy from an already-discovered module list.
*
* The policy is deliberately small, because the repository's module state is deliberately small:
* Ekklesia has one accepted module set — the modules `discoverModules` validated at startup — and
* no installer, no enable/disable state, and no trust store. `ModuleLifecycleStage`
* (`@ekklesia/contracts`) names stages as a vocabulary but no runtime state machine implements
* them. So "publishable" means exactly:
*
* 1. the module is in the discovered, fully validated set the platform accepted at startup, and
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
* inside the module package.
*
* A module directory sitting on disk satisfies neither on its own — it has to have survived
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
* artifact on disk must not be what makes it servable*.
*
* Pure and synchronous: no filesystem, environment, database, or network access, and it never
* imports, evaluates, or otherwise executes a module's UI artifact. The caller supplies a complete
* discovered list; production composition (`apps/api/src/server.ts`) builds this only after the
* module runtime has started, so the published set is also the running set — module startup is
* all-or-nothing (`startDiscoveredModuleRuntime`, `./runtime.js`), so a module that failed to load,
* register, or start takes the whole API startup down with it rather than lingering as a
* publishable-but-not-running module.
*/
export function createModuleUiPublicationRegistry(
modules: readonly DiscoveredModule[],
): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>();
for (const module of modules) {
const artifactPath = module.entrypoints.ui;
if (artifactPath === undefined) {
continue;
}
byModuleId.set(
module.manifest.id,
Object.freeze({
artifactPath,
moduleId: module.manifest.id,
version: module.manifest.version,
}),
);
}
const published = Object.freeze([...byModuleId.values()]);
return Object.freeze({
list: (): readonly PublishableModuleUi[] => published,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
if (typeof moduleId !== "string" || typeof version !== "string") {
return undefined;
}
const candidate = byModuleId.get(moduleId);
// Exact version equality, never a range, a "latest" fallback, or a nearest match. A request
// naming a version the platform does not have installed resolves to nothing at all rather
// than to a different artifact that happens to exist.
return candidate?.version === version ? candidate : undefined;
},
});
}
+186
View File
@@ -0,0 +1,186 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
} from "../src/modules/index.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function createModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-publication-"));
temporaryDirectories.push(directory);
return directory;
}
interface ModuleFixture {
readonly id: string;
readonly version?: string;
/** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */
readonly ui?: string;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0";
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version,
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 4,
name: `${fixture.id} module`,
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
);
if (fixture.ui !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
}
}
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
* it consumes only records that already passed manifest validation, compiled-artifact validation,
* and package-containment/symlink resolution. */
async function discover(fixtures: readonly ModuleFixture[]): Promise<readonly DiscoveredModule[]> {
const modulesDirectory = await createModulesDirectory();
for (const fixture of fixtures) {
await createModule(modulesDirectory, fixture);
}
return discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
}
describe("module UI publication registry", () => {
it("publishes a discovered module that declares entrypoints.ui", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.resolve("announcements", "0.1.0");
expect(published).toMatchObject({ moduleId: "announcements", version: "0.1.0" });
expect(registry.list()).toHaveLength(1);
});
it("exposes exactly the canonical artifact path discovery already validated", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const discovered = modules[0] as DiscoveredModule;
const registry = createModuleUiPublicationRegistry(modules);
// Identical to `entrypoints.ui` — not recomposed from the module directory plus a convention,
// which is the failure mode a publisher must never reintroduce.
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBe(
discovered.entrypoints.ui,
);
expect(path.isAbsolute(discovered.entrypoints.ui as string)).toBe(true);
});
it("omits a discovered module that declares no UI entrypoint", async () => {
const modules = await discover([{ id: "calendar" }]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.list()).toEqual([]);
expect(registry.resolve("calendar", "0.1.0")).toBeUndefined();
});
it("publishes only the modules that declare UI, from a mixed installation", async () => {
const modules = await discover([
{ id: "announcements", ui: "export default {};" },
{ id: "calendar" },
{ id: "signage", ui: "export default {};" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.list().map((entry) => entry.moduleId)).toEqual(["announcements", "signage"]);
});
it("never resolves a module that is not part of the discovered set", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolve("calendar", "0.1.0")).toBeUndefined();
expect(registry.resolve("", "0.1.0")).toBeUndefined();
});
it("refuses a version other than the exact installed one", async () => {
const modules = await discover([
{ id: "announcements", ui: "export default {};", version: "1.4.2" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolve("announcements", "1.4.2")).toBeDefined();
for (const version of ["1.4.1", "1.4.3", "1.4", "^1.4.2", "latest", "1.4.2 ", ""]) {
expect(registry.resolve("announcements", version)).toBeUndefined();
}
});
it("fails closed on non-string lookup input rather than trusting its caller", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) {
expect(registry.resolve(value, "0.1.0")).toBeUndefined();
expect(registry.resolve("announcements", value)).toBeUndefined();
}
});
it("is an empty, usable registry when nothing is installed", () => {
const registry = createModuleUiPublicationRegistry([]);
expect(registry.list()).toEqual([]);
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
});
it("publishes an immutable snapshot that no caller can extend", async () => {
const modules = await discover([{ id: "announcements", ui: "export default {};" }]);
const registry = createModuleUiPublicationRegistry(modules);
expect(Object.isFrozen(registry)).toBe(true);
expect(Object.isFrozen(registry.list())).toBe(true);
expect(Object.isFrozen(registry.list()[0])).toBe(true);
// The same array identity every time: nothing recomputes or re-reads the filesystem per call.
expect(registry.list()).toBe(registry.list());
});
it("never reads or evaluates the artifact it publishes", async () => {
// A top-level throw would surface immediately if anything imported this file. Registry
// construction and resolution are pure metadata operations.
const modules = await discover([
{ id: "announcements", ui: 'throw new Error("module UI must never run on the server");' },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined();
});
});