feat: let one module contribute a browser renderer to another's capability

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]>
This commit is contained in:
ekklesia
2026-08-27 07:47:10 -05:00
co-authored by Claude Opus 5
parent 07835b7b56
commit c444ff570b
79 changed files with 3317 additions and 137 deletions
+3 -3
View File
@@ -330,7 +330,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id,
manifestVersion: 6,
manifestVersion: 7,
name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0",
@@ -374,7 +374,7 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
@@ -479,7 +479,7 @@ async function createUiModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 6,
manifestVersion: 7,
name: `${id} module`,
version,
}),
+2 -1
View File
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 6,
manifestVersion: 7,
name: fixture.name,
version: "1.0.0",
}),
@@ -269,6 +269,7 @@ describe("Dashboard navigation — reading", () => {
// Presentation wraps the host-neutral descriptor; it never rewrites it.
expect(livestream?.module).toEqual({
contributions: [],
api: { baseUrl: "/api/v1/modules/livestream" },
id: "livestream",
permissions: [],
+1 -1
View File
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
server: "./dist/server/index.js",
},
id: "announcements",
manifestVersion: 6,
manifestVersion: 7,
name: "Announcements fixture",
permissions: [
{
+3 -2
View File
@@ -88,7 +88,7 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
@@ -254,12 +254,13 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 6,
manifestVersion: 7,
name: `${id} module`,
permissions,
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" },
id: fixture.id,
manifestVersion: 6,
manifestVersion: 7,
name: `${fixture.id} module`,
version: "1.0.0",
}),
+2 -1
View File
@@ -101,7 +101,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}),
},
id,
manifestVersion: 6,
manifestVersion: 7,
name: `${id} module`,
permissions: fixture.display
? [
@@ -266,6 +266,7 @@ describe("module display routes — authorization and descriptor", () => {
expect(await descriptor.json()).toEqual({
viewer: { kind: "authenticated", userId: viewerId },
module: {
contributions: [],
api: { baseUrl: moduleApiBaseUrl("announcements") },
display: {
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 6,
manifestVersion: 7,
name: `${fixture.id} module`,
version: "1.0.0",
}),
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id: "announcements",
manifestVersion: 6,
manifestVersion: 7,
name: "Announcements fixture",
permissions: [
{
+4 -1
View File
@@ -127,7 +127,7 @@ async function createFixtureModule(
: {}),
},
id,
manifestVersion: 6,
manifestVersion: 7,
name,
permissions: [
{
@@ -373,6 +373,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
// 2. The URLs came from the server, not from anything the browser composed.
expect(descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example",
permissions: ["example.records.read"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -436,6 +437,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}
expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
contributions: [],
id: "example",
permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
@@ -460,6 +462,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
});
expect(Object.keys(context).sort()).toEqual([
"api",
"contributions",
"moduleId",
"permissions",
"principal",
+107 -1
View File
@@ -59,6 +59,12 @@ interface ModuleFixture {
readonly id: string;
readonly baseline: "authenticated" | "public" | "restricted";
}>;
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
readonly contributions?: readonly {
readonly localName: string;
readonly capability: string;
readonly capabilityRange: string;
}[];
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -82,8 +88,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
server: "./dist/server/index.js",
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
},
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: "1.0.0",
})),
id: fixture.id,
manifestVersion: 6,
manifestVersion: 7,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
@@ -103,6 +117,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
);
}
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 {};\n");
}
}
interface InstallOptions {
@@ -219,6 +242,7 @@ describe("module UI inventory — what it lists", () => {
expect(response.status).toBe(200);
expect(modules).toEqual([
{
contributions: [],
api: { baseUrl: "/api/v1/modules/example" },
id: "example",
permissions: [],
@@ -315,6 +339,7 @@ describe("module UI inventory — what it lists", () => {
// The exact key set, so a future field cannot be added without a deliberate decision here.
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
"api",
"contributions",
"id",
"permissions",
"realtime",
@@ -520,3 +545,84 @@ describe("module UI inventory — it is not a module management API", () => {
}
});
});
/**
* The inventory is how a consuming module learns what was contributed to its own capabilities
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
*/
describe("module UI inventory — contributions", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
permissions: [],
withUi: true,
};
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
],
id: "announcements",
permissions: [],
withUi: true,
});
it("gives the providing module the contribution and its server-produced URL", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
expect(consumer?.contributions).toEqual([
{
capability: "signage.slide-renderer",
id: "announcements.signage-slide",
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
version: "1.0.0",
},
]);
});
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
const { app } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
});
it("withholds a contribution whose provider is outside its declared range", async () => {
const { app } = await install({
fixtures: [signage, announcements("^2.0.0")],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
});
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
const { app, modulesDirectory } = await install({
fixtures: [signage, announcements()],
principal: signedIn(),
});
const { body, modules } = await inventoryOf(app);
const consumer = modules.find((module) => module.id === "signage");
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
expect(body).not.toContain(modulesDirectory);
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
"capability",
"id",
"url",
"version",
]);
});
});
+137 -2
View File
@@ -16,7 +16,7 @@ import {
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -27,6 +27,7 @@ import {
MODULE_UI_ASSET_PREFIX,
MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE,
moduleContributionArtifactUrl,
moduleDisplayUiArtifactUrl,
moduleUiArtifactUrl,
} from "../src/module-ui-routes.js";
@@ -86,6 +87,15 @@ interface ModuleFixture {
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
* files that genuinely exist on disk rather than against hypothetical ones. */
readonly extraFiles?: Readonly<Record<string, string>>;
/** Capabilities this module provides — what another module's contributions 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 capability: string;
readonly capabilityRange: string;
readonly source: string;
}[];
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -118,8 +128,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
},
}),
},
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: "1.0.0",
})),
id: fixture.id,
manifestVersion: 6,
manifestVersion: 7,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
@@ -142,6 +160,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
}
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"), contribution.source);
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
}
if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
@@ -917,6 +945,7 @@ describe("module UI artifact publication — the generic loader consumes what is
function hostContext() {
return {
api: { request: async () => new Response(null, { status: 200 }) },
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "announcements",
permissions: { has: () => false },
principal: { kind: "anonymous" as const },
@@ -957,3 +986,109 @@ describe("module UI artifact publication — the generic loader consumes what is
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
});
});
/**
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
*/
describe("module contribution artifact publication", () => {
const signage = {
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
id: "signage",
};
const contributionSource = "export default { contributed: true };\n";
const announcements = (capabilityRange = "^1.0.0") => ({
contributions: [
{
capability: "signage.slide-renderer",
capabilityRange,
localName: "signage-slide",
source: contributionSource,
},
],
id: "announcements",
});
const contributionUrl = moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"announcements.signage-slide",
);
it("serves the declared contribution artifact byte for byte", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(contributionUrl);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(await response.text()).toBe(contributionSource);
});
it("builds a URL that cannot select a neighbouring file", () => {
expect(contributionUrl).toBe(
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
);
// The contribution id is one segment's worth of text, always — a value that needed encoding
// simply fails to resolve.
expect(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
"../../../etc/passwd",
),
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
});
it.each([
["an unknown contribution id", "announcements.nope"],
["another module's contribution id", "signage.slide-renderer"],
["a path traversal", "../../../etc/passwd"],
["the artifact's source map", "announcements.signage-slide.map"],
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "0.1.0" },
contributionId,
),
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
});
it("refuses a version the platform does not have installed", async () => {
const { app } = await install([signage, announcements()]);
const response = await app.request(
moduleContributionArtifactUrl(
{ moduleId: "announcements", version: "9.9.9" },
"announcements.signage-slide",
),
);
expect(response.status).toBe(404);
});
it("publishes nothing when no installed module provides the target capability", async () => {
const { app } = await install([announcements()]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("publishes nothing when the provider is outside the declared compatible range", async () => {
const { app } = await install([signage, announcements("^2.0.0")]);
expect((await app.request(contributionUrl)).status).toBe(404);
});
it("keeps the sibling source map and the module's own directory unreachable", async () => {
const { app } = await install([signage, announcements()]);
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
const response = await app.request(`${contributionUrl}${suffix}`);
expect(response.status).toBe(404);
}
});
});