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]>
187 lines
7.3 KiB
TypeScript
187 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
});
|