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]>
448 lines
18 KiB
TypeScript
448 lines
18 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;
|
|
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
|
readonly display?: string;
|
|
/** Capabilities this module provides — what another module's contributions can target. */
|
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
|
/** Browser renderers this module contributes to another module's capability. */
|
|
readonly contributions?: readonly {
|
|
readonly localName: string;
|
|
readonly version: string;
|
|
readonly capability: string;
|
|
readonly capabilityRange: string;
|
|
}[];
|
|
}
|
|
|
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
|
const version = fixture.version ?? "0.1.0";
|
|
const displayPermission = `${fixture.id}.display.view`;
|
|
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" }),
|
|
...(fixture.display === undefined
|
|
? {}
|
|
: {
|
|
display: {
|
|
artifact: "./dist/display/index.js",
|
|
permission: displayPermission,
|
|
},
|
|
}),
|
|
},
|
|
capabilities: fixture.capabilities ?? [],
|
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
|
capability: contribution.capability,
|
|
capabilityRange: contribution.capabilityRange,
|
|
id: `${fixture.id}.${contribution.localName}`,
|
|
version: contribution.version,
|
|
})),
|
|
id: fixture.id,
|
|
manifestVersion: 7,
|
|
name: `${fixture.id} module`,
|
|
permissions:
|
|
fixture.display === undefined
|
|
? []
|
|
: [
|
|
{
|
|
baseline: "restricted",
|
|
description: `View ${fixture.id}'s display`,
|
|
id: displayPermission,
|
|
},
|
|
],
|
|
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);
|
|
}
|
|
if (fixture.display !== undefined) {
|
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
|
}
|
|
for (const contribution of fixture.contributions ?? []) {
|
|
const contributionDirectory = path.join(
|
|
moduleDirectory,
|
|
"dist/contributions",
|
|
contribution.localName,
|
|
);
|
|
await mkdir(contributionDirectory, { recursive: true });
|
|
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};");
|
|
}
|
|
}
|
|
|
|
/** 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.listDisplays()).toEqual([]);
|
|
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
|
|
expect(registry.resolveDisplay("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();
|
|
});
|
|
});
|
|
|
|
describe("module display UI publication registry", () => {
|
|
it("keeps primary UI and display publication independent for display-only, both, and neither", async () => {
|
|
const modules = await discover([
|
|
{ display: "export default { role: 'display' };", id: "announcements" },
|
|
{
|
|
display: "export default { role: 'display' };",
|
|
id: "signage",
|
|
ui: "export default { role: 'primary' };",
|
|
},
|
|
{ id: "calendar" },
|
|
]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.list().map((entry) => entry.moduleId)).toEqual(["signage"]);
|
|
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual([
|
|
"announcements",
|
|
"signage",
|
|
]);
|
|
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
|
|
expect(registry.resolveDisplay("calendar", "0.1.0")).toBeUndefined();
|
|
|
|
const signage = registry.resolveDisplay("signage", "0.1.0");
|
|
expect(signage).toMatchObject({
|
|
displayPermission: "signage.display.view",
|
|
moduleId: "signage",
|
|
name: "signage module",
|
|
version: "0.1.0",
|
|
});
|
|
expect(signage?.artifactPath).not.toBe(registry.resolve("signage", "0.1.0")?.artifactPath);
|
|
});
|
|
|
|
it("carries only discovery's canonical display artifact and declared permission metadata", async () => {
|
|
const modules = await discover([
|
|
{ display: 'throw new Error("display must remain inert");', id: "announcements" },
|
|
]);
|
|
const discovered = modules[0] as DiscoveredModule;
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
const published = registry.resolveDisplay("announcements", "0.1.0");
|
|
|
|
expect(published?.artifactPath).toBe(discovered.entrypoints.display?.artifactPath);
|
|
expect(path.isAbsolute(published?.artifactPath as string)).toBe(true);
|
|
expect(published?.displayPermission).toBe(discovered.entrypoints.display?.permission);
|
|
expect(published?.permissions.map((permission) => permission.id)).toEqual([
|
|
"announcements.display.view",
|
|
]);
|
|
});
|
|
|
|
it("requires exact identity and fails closed on malformed lookup values", async () => {
|
|
const modules = await discover([
|
|
{ display: "export default {};", id: "announcements", version: "1.4.2" },
|
|
]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.resolveDisplay("announcements", "1.4.2")).toBeDefined();
|
|
for (const version of ["1.4.1", "latest", "^1.4.2", "1.4.2 ", ""]) {
|
|
expect(registry.resolveDisplay("announcements", version)).toBeUndefined();
|
|
}
|
|
for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) {
|
|
expect(registry.resolveDisplay(value, "1.4.2")).toBeUndefined();
|
|
expect(registry.resolveDisplay("announcements", value)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("publishes a frozen snapshot and cannot expose a module omitted from its eligible input", async () => {
|
|
const modules = await discover([
|
|
{ display: "export default {};", id: "announcements" },
|
|
{ display: "export default {};", id: "signage" },
|
|
]);
|
|
// Production passes Core's eligible/started set. Omitting a blocked module at this boundary is
|
|
// sufficient to make both its metadata and artifact structurally unresolvable.
|
|
const registry = createModuleUiPublicationRegistry(
|
|
modules.filter((module) => module.manifest.id === "signage"),
|
|
);
|
|
|
|
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
|
|
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual(["signage"]);
|
|
expect(Object.isFrozen(registry.listDisplays())).toBe(true);
|
|
expect(Object.isFrozen(registry.listDisplays()[0])).toBe(true);
|
|
expect(Object.isFrozen(registry.listDisplays()[0]?.permissions)).toBe(true);
|
|
expect(registry.listDisplays()).toBe(registry.listDisplays());
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The generic contribution mechanism (ADR 0041), exercised through the exact relationship it exists
|
|
* for and with no platform knowledge of what any of it means: `signage` provides a capability,
|
|
* `announcements` contributes a renderer to it, and neither module imports the other.
|
|
*/
|
|
describe("module contribution publication", () => {
|
|
const signage = {
|
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
|
id: "signage",
|
|
};
|
|
const contributor = (capabilityRange: string) => ({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
capabilityRange,
|
|
localName: "signage-slide",
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
id: "announcements",
|
|
});
|
|
|
|
it("publishes a contribution whose target capability is provided within range", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.listContributions()).toHaveLength(1);
|
|
expect(
|
|
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
|
).toMatchObject({
|
|
capability: "signage.slide-renderer",
|
|
// The provider's declared version, so the range was checked against something real.
|
|
capabilityVersion: "1.2.0",
|
|
contributionId: "announcements.signage-slide",
|
|
moduleId: "announcements",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("serves exactly the canonical artifact path discovery validated", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const announcements = modules.find((module) => module.manifest.id === "announcements");
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
const published = registry.resolveContribution(
|
|
"announcements",
|
|
"0.1.0",
|
|
"announcements.signage-slide",
|
|
);
|
|
expect(published?.artifactPath).toBe(announcements?.contributions[0]?.artifactPath);
|
|
expect(path.isAbsolute(published?.artifactPath ?? "")).toBe(true);
|
|
});
|
|
|
|
it("withholds a contribution whose capability no installed module provides", async () => {
|
|
const modules = await discover([contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.listContributions()).toEqual([]);
|
|
expect(
|
|
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("withholds a contribution whose provider is outside its declared compatible range", async () => {
|
|
// Fails closed: an incompatible renderer is absent rather than mounted and broken.
|
|
const modules = await discover([signage, contributor("^2.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.listContributions()).toEqual([]);
|
|
expect(registry.contributionsForModule("signage")).toEqual([]);
|
|
});
|
|
|
|
it("gives a consuming module exactly the contributions aimed at its own capabilities", async () => {
|
|
const modules = await discover([
|
|
signage,
|
|
{ capabilities: [{ id: "calendar.week-view", version: "1.0.0" }], id: "calendar" },
|
|
contributor("^1.0.0"),
|
|
]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.contributionsForModule("signage").map((entry) => entry.contributionId)).toEqual(
|
|
["announcements.signage-slide"],
|
|
);
|
|
// A module that provides a different capability sees nothing, and the *contributing* module
|
|
// does not see its own contribution: it provides no capability it was aimed at.
|
|
expect(registry.contributionsForModule("calendar")).toEqual([]);
|
|
expect(registry.contributionsForModule("announcements")).toEqual([]);
|
|
});
|
|
|
|
it("fails a lookup closed for an unknown, mismatched, or non-string identity", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(
|
|
registry.resolveContribution("announcements", "9.9.9", "announcements.signage-slide"),
|
|
).toBeUndefined();
|
|
expect(
|
|
registry.resolveContribution("signage", "0.1.0", "announcements.signage-slide"),
|
|
).toBeUndefined();
|
|
expect(
|
|
registry.resolveContribution("announcements", "0.1.0", "announcements.nope"),
|
|
).toBeUndefined();
|
|
expect(registry.resolveContribution(7, "0.1.0", "announcements.signage-slide")).toBeUndefined();
|
|
expect(registry.contributionsForCapability(undefined)).toEqual([]);
|
|
});
|
|
|
|
it("carries no domain vocabulary at all", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
const published = registry.listContributions()[0];
|
|
|
|
// Core knows identity, version, compatibility, and one path. There is deliberately no payload
|
|
// schema, slot, ordering, or rendering hint — what a renderer draws is not the platform's.
|
|
expect(Object.keys(published ?? {}).sort()).toEqual([
|
|
"artifactPath",
|
|
"capability",
|
|
"capabilityVersion",
|
|
"contributionId",
|
|
"moduleId",
|
|
"moduleVersion",
|
|
"version",
|
|
]);
|
|
});
|
|
});
|