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
+85 -7
View File
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
readonly path?: string;
}
/**
* One declared browser contribution, with its artifact resolved and validated exactly like every
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
* what it renders.
*/
export interface ResolvedModuleContribution {
readonly id: string;
readonly version: string;
readonly capability: string;
readonly capabilityRange: string;
readonly artifactPath: string;
}
export interface ResolvedModuleEntrypoints {
readonly server: string;
readonly schema?: string;
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
export interface DiscoveredModule {
readonly directory: string;
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
* inside this module package. Empty for a module that contributes nothing. */
readonly contributions: readonly ResolvedModuleContribution[];
readonly entrypoints: ResolvedModuleEntrypoints;
readonly manifest: ReadonlyModuleManifest;
readonly manifestPath: string;
@@ -110,6 +126,7 @@ interface ModulePackageData {
}
interface ModuleCandidate {
contributions: readonly ResolvedModuleContribution[];
directory: string;
directoryName: string;
entrypoints: ResolvedModuleEntrypoints;
@@ -257,13 +274,19 @@ function validatePackageData(
return { name, version };
}
interface ResolvedModuleArtifacts {
readonly contributions: readonly ResolvedModuleContribution[];
readonly entrypoints: ResolvedModuleEntrypoints;
}
async function resolveEntrypoints(
moduleDirectory: string,
directoryName: string,
manifest: ModuleManifest,
issues: ModuleDiscoveryIssue[],
): Promise<ResolvedModuleEntrypoints | undefined> {
): Promise<ResolvedModuleArtifacts | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
const resolvedContributions = new Map<string, string>();
const canonicalModuleDirectory = await realpath(moduleDirectory);
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
issuePath: `entrypoints.${entrypointName}`,
relativeEntrypoint,
}));
// A contribution artifact is resolved through the identical loop as every other compiled
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
// are one implementation, so a contribution can never quietly receive weaker checks than
// `entrypoints.ui` (ADR 0041).
const configuredContributions = manifest.contributions.map((contribution, index) => ({
entrypointName: `contribution:${contribution.id}`,
issuePath: `contributions.${index}.artifact`,
relativeEntrypoint: contribution.artifact,
}));
const configuredEntrypoints = [
...ordinaryConfiguredEntrypoints,
...(display === undefined
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
relativeEntrypoint: display.artifact,
},
]),
...configuredContributions,
];
let entrypointCollision = false;
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
});
continue;
}
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
if (entrypointName.startsWith("contribution:")) {
resolvedContributions.set(
entrypointName.slice("contribution:".length),
canonicalEntrypoint,
);
} else {
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
}
} catch (error) {
const code = errorCode(error);
issues.push({
@@ -394,11 +434,46 @@ async function resolveEntrypoints(
}
}
if (!resolved.server || entrypointCollision) {
// A contribution artifact must be its own file. Two declarations resolving to one path — through
// a link, since the manifest already rejects two identical declared paths — would make one
// contribution silently serve another's bytes, and the loader's identity check would then reject
// whichever one lost.
const canonicalContributionPaths = new Map<string, string>();
for (const [contributionId, canonicalPath] of resolvedContributions) {
const claimedBy = canonicalContributionPaths.get(canonicalPath);
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
entrypointCollision = true;
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "contributions",
});
continue;
}
canonicalContributionPaths.set(canonicalPath, contributionId);
}
if (
!resolved.server ||
entrypointCollision ||
resolvedContributions.size !== manifest.contributions.length
) {
return undefined;
}
return {
const contributions = manifest.contributions.map((contribution) =>
Object.freeze({
artifactPath: resolvedContributions.get(contribution.id) as string,
capability: contribution.capability,
capabilityRange: contribution.capabilityRange,
id: contribution.id,
version: contribution.version,
}),
);
const entrypoints: ResolvedModuleEntrypoints = {
server: resolved.server,
...(resolved.schema ? { schema: resolved.schema } : {}),
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
}
: {}),
};
return { contributions: Object.freeze(contributions), entrypoints };
}
async function scanModuleDirectory(
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
});
}
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!entrypoints) {
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) {
return undefined;
}
return {
contributions: artifacts.contributions,
directory: canonicalModuleDirectory,
directoryName,
entrypoints,
entrypoints: artifacts.entrypoints,
manifest,
manifestPath: canonicalManifestPath,
packageName: packageData.name,
+155 -1
View File
@@ -1,4 +1,8 @@
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
import {
isVersionCompatible,
type PermissionDeclaration,
type PermissionId,
} from "@ekklesia/contracts";
import type { DiscoveredModule } from "./discovery.js";
/**
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
readonly permissions: readonly PermissionDeclaration[];
}
/**
* One installed module's browser contribution that the platform is willing to publish (ADR 0041).
*
* Publishable means three things, and nothing beyond them: the contributing module survived
* discovery and is in the eligible set, some **other** eligible module provides the targeted
* capability, and that provider's declared capability version satisfies the contribution's declared
* range. A contribution whose capability nobody provides, or whose provider moved outside its range,
* is simply absent — never published-but-broken, and never mounted with a shrug.
*
* Note what is *not* here. There is no payload schema, no slot, no ordering, no rendering hint, and
* no domain vocabulary of any kind: Core carries identity, version, compatibility, and one file
* path. What a contributed renderer draws, and what shape its input takes, are the consumer module's
* business and travel between the two modules in the browser without passing through the platform.
*/
export interface PublishableModuleContribution {
/** The module that *contributes* the renderer. */
readonly moduleId: string;
readonly moduleVersion: string;
readonly contributionId: string;
/** The contribution's own version, independent of its module's. */
readonly version: string;
readonly capability: string;
/** The version the providing module declares for that capability — the value the contribution's
* declared range was actually checked against. */
readonly capabilityVersion: string;
readonly artifactPath: string;
}
/**
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
*
@@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry {
* a display merely because a caller appends `/display` to its publication URL.
*/
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
listContributions(): readonly PublishableModuleContribution[];
/**
* Every publishable contribution targeting one capability, in the same stable order.
*
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
* its own declared capability id and gets back exactly the contributions for it.
*/
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
/**
* Every publishable contribution aimed at **any** capability the named module provides.
*
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
* module id and gets that module's own extension points filled in, with no way to ask about
* someone else's.
*/
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
/**
* The publishable record for an exact contributing module id, installed module version, and
* contribution id, or `undefined`.
*
* All three must match. A contribution that is not installed, one whose module is at a different
* version, one whose capability has no compatible provider, and an id that was never declared are
* all indistinguishable here — each returns `undefined`.
*/
resolveContribution(
moduleId: unknown,
moduleVersion: unknown,
contributionId: unknown,
): PublishableModuleContribution | undefined;
}
/**
@@ -124,6 +188,41 @@ export function createModuleUiPublicationRegistry(
): ModuleUiPublicationRegistry {
const byModuleId = new Map<string, PublishableModuleUi>();
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
const contributions: PublishableModuleContribution[] = [];
// Every capability the eligible set actually provides, at the version its provider declares. Built
// from the same list everything else here is built from, so a contribution can never be checked
// against a provider the platform is not running.
const capabilityVersions = new Map<string, string>();
for (const module of modules) {
for (const capability of module.manifest.capabilities) {
capabilityVersions.set(capability.id, capability.version);
}
}
for (const module of modules) {
for (const contribution of module.contributions) {
const capabilityVersion = capabilityVersions.get(contribution.capability);
// Fails closed on both halves: an absent provider and an incompatible one are equally
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
if (
capabilityVersion === undefined ||
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
) {
continue;
}
contributions.push(
Object.freeze({
artifactPath: contribution.artifactPath,
capability: contribution.capability,
capabilityVersion,
contributionId: contribution.id,
moduleId: module.manifest.id,
moduleVersion: module.manifest.version,
version: contribution.version,
}),
);
}
}
for (const module of modules) {
const artifactPath = module.entrypoints.ui;
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
const published = Object.freeze([...byModuleId.values()]);
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
const publishedContributions = Object.freeze([...contributions]);
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
const contributionsByKey = new Map<string, PublishableModuleContribution>();
for (const contribution of publishedContributions) {
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
forCapability.push(contribution);
contributionsByCapability.set(contribution.capability, forCapability);
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
contributionsByKey.set(
`${contribution.moduleId}\u0000${contribution.contributionId}`,
contribution,
);
}
const frozenByCapability = new Map(
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
);
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
modules.map((module) => [
module.manifest.id,
Object.freeze(
module.manifest.capabilities.flatMap(
(capability) => contributionsByCapability.get(capability.id) ?? [],
),
),
]),
);
return Object.freeze({
contributionsForCapability: (
capabilityId: unknown,
): readonly PublishableModuleContribution[] =>
typeof capabilityId === "string"
? (frozenByCapability.get(capabilityId) ?? noContributions)
: noContributions,
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
typeof moduleId === "string"
? (byConsumingModule.get(moduleId) ?? noContributions)
: noContributions,
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
resolveContribution: (
moduleId: unknown,
moduleVersion: unknown,
contributionId: unknown,
): PublishableModuleContribution | undefined => {
if (
typeof moduleId !== "string" ||
typeof moduleVersion !== "string" ||
typeof contributionId !== "string"
) {
return undefined;
}
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
// Exact installed-version equality, exactly as the UI and display lookups require: a request
// naming a version the platform does not have installed resolves to nothing at all.
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
},
list: (): readonly PublishableModuleUi[] => published,
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 6,
manifestVersion: 7,
name: fixture.name,
version: "1.0.0",
}),
+2 -1
View File
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
return {
contributions: [],
directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: {
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 6,
manifestVersion: 7,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
+2 -1
View File
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
return {
contributions: [],
directory: `/unpublished/${id}`,
entrypoints: { server: `/unpublished/${id}/server.js` },
manifest: {
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 6,
manifestVersion: 7,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
@@ -1,5 +1,5 @@
{
"manifestVersion": 6,
"manifestVersion": 7,
"id": "db-facade",
"name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
@@ -17,7 +17,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "1.0.0",
@@ -27,6 +27,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" },
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
@@ -194,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements",
) {
return parseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
],
])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"",
])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "1.0.0",
+177 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> {
return {
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
@@ -957,3 +957,179 @@ describe("module discovery", () => {
});
});
});
/**
* A contribution artifact is resolved through the identical loop as every other compiled entrypoint
* (ADR 0041), so it inherits every protection `entrypoints.ui` already had rather than receiving a
* second, weaker implementation of them.
*/
describe("browser contribution artifact resolution", () => {
function contributingManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
...manifestFor("announcements"),
contributions: [
{
id: "announcements.signage-slide",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/signage-slide/index.js",
...overrides,
},
],
};
}
it("resolves a declared contribution to a canonical path inside the package", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
extraFiles: { "dist/contributions/signage-slide/index.js": "export default {};" },
manifest: contributingManifest(),
});
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const contribution = modules[0]?.contributions[0];
expect(contribution).toMatchObject({
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
id: "announcements.signage-slide",
version: "1.0.0",
});
expect(contribution?.artifactPath.endsWith("dist/contributions/signage-slide/index.js")).toBe(
true,
);
expect(path.isAbsolute(contribution?.artifactPath ?? "")).toBe(true);
});
it("reports an empty contribution list for a module that contributes nothing", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "calendar");
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules[0]?.contributions).toEqual([]);
});
it("rejects a missing contribution artifact", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", { manifest: contributingManifest() });
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_MISSING" }),
]),
});
});
it("rejects a contribution artifact that is not compiled JavaScript", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
extraFiles: { "src/contributions/signage-slide/index.tsx": "export default {};" },
manifest: contributingManifest({ artifact: "./src/contributions/signage-slide/index.tsx" }),
});
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID" }),
]),
});
});
it("rejects a contribution artifact that escapes the package through a link", async () => {
const modulesDirectory = await createModulesDirectory();
const outside = await createModulesDirectory();
await writeFile(path.join(outside, "elsewhere.js"), "export default {};");
await createModule(modulesDirectory, "announcements", {
manifest: contributingManifest(),
});
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/signage-slide"), {
recursive: true,
});
await symlink(
path.join(outside, "elsewhere.js"),
path.join(modulesDirectory, "announcements/dist/contributions/signage-slide/index.js"),
);
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "contributions.0.artifact",
}),
]),
});
});
it("rejects two contributions that resolve through a link to the same file", async () => {
const modulesDirectory = await createModulesDirectory();
const manifest = {
...manifestFor("announcements"),
contributions: [
{
id: "announcements.first",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/first/index.js",
},
{
id: "announcements.second",
version: "1.0.0",
capability: "signage.slide-renderer",
capabilityRange: "^1.0.0",
artifact: "./dist/contributions/second/index.js",
},
],
};
await createModule(modulesDirectory, "announcements", {
extraFiles: { "dist/contributions/first/index.js": "export default {};" },
manifest,
});
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/second"), {
recursive: true,
});
await symlink(
path.join(modulesDirectory, "announcements/dist/contributions/first/index.js"),
path.join(modulesDirectory, "announcements/dist/contributions/second/index.js"),
);
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID", path: "contributions" }),
]),
});
});
});
@@ -200,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "1.0.0",
@@ -209,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record<string, unknown>): Discove
entrypoints: { server: "./dist/does-not-exist/index.js" },
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
+3 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
@@ -36,6 +36,7 @@ function fakeModule(
},
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
@@ -65,7 +65,7 @@ function manifestFor(
} = {},
): Record<string, unknown> {
return {
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
+2 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
},
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> {
return {
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -57,7 +57,7 @@ function manifestFor(
} = {},
): Record<string, unknown> {
return {
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> {
return {
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
+2 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 6,
manifestVersion: 7,
id,
name: `${id} module`,
version: "1.0.0",
@@ -29,6 +29,7 @@ function fakeModule(
entrypoints: { server: "./dist/does-not-exist/index.js" },
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
manifest,
+152 -1
View File
@@ -29,6 +29,15 @@ interface ModuleFixture {
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> {
@@ -61,8 +70,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: contribution.version,
})),
id: fixture.id,
manifestVersion: 6,
manifestVersion: 7,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
@@ -89,6 +106,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
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
@@ -294,3 +320,128 @@ describe("module display UI publication registry", () => {
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",
]);
});
});