Files
Ekklesia/apps/api/tests/module-ui-inventory.test.ts
T
ekklesiaandClaude Opus 5 c444ff570b 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]>
2026-08-27 07:47:10 -05:00

629 lines
22 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
createAuthorizationEvaluator,
type PermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js";
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
interface ModuleFixture {
readonly id: string;
readonly version?: string;
/** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */
readonly withUi?: boolean;
readonly permissions?: ReadonlyArray<{
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> {
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.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: 7,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
description: `Test permission for ${fixture.id}.`,
})),
version,
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
);
if (fixture.withUi) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`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 {
readonly fixtures: readonly ModuleFixture[];
/** Anonymous by default, so every scenario has to opt in to being signed in. */
readonly principal?: EkklesiaPrincipal;
/** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so
* baselines, the owner bypass, and deny-by-default all behave exactly as in production. */
readonly grants?: readonly string[];
readonly platformOwner?: boolean;
/** Makes the grant port fail, so the "a hint that could not be determined is never reported as
* denied" path can be exercised. */
readonly grantsUnavailable?: boolean;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly modulesDirectory: string;
readonly permissionRegistry: PermissionRegistry;
}
/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the
* real publication registry, the real authorization evaluator, and the real `createApiApp`. Only
* identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL
* to answer the questions these tests ask. */
async function install(options: InstallOptions): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const granted = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: {
hasActiveGrant: async (query) => {
if (options.grantsUnavailable) {
throw new Error("grant store unavailable");
}
return granted.has(query.permission);
},
},
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const store = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
});
const permissionRegistry = composePlatformPermissionRegistry(modules);
const app = createApiApp({
authorization: { evaluator, registry: permissionRegistry, store },
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules),
dashboard: createInMemoryDashboardStore(),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, modulesDirectory, permissionRegistry };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
async function inventoryOf(app: Hono): Promise<{
readonly body: string;
readonly modules: ReadonlyArray<Record<string, unknown>>;
readonly response: Response;
}> {
const response = await app.request(MODULE_UI_INVENTORY_PATH);
const body = await response.clone().text();
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
return { body, modules: parsed.modules ?? [], response };
}
describe("module UI inventory — what it lists", () => {
it("describes a publishable module with exactly the fields a host needs", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([
{
contributions: [],
api: { baseUrl: "/api/v1/modules/example" },
id: "example",
permissions: [],
realtime: { baseUrl: "/api/v1/module-realtime/example" },
ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0",
},
]);
});
it("builds all host URLs with the server's own canonical helpers", async () => {
const { app, modules: discovered } = await install({
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
principal: signedIn(),
});
const module = discovered[0] as DiscoveredModule;
const { modules } = await inventoryOf(app);
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
// hand-written template string anywhere would fail here.
expect(modules[0]?.ui).toEqual({
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
});
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
expect(modules[0]?.realtime).toEqual({
baseUrl: moduleRealtimeBaseUrl(module.manifest.id),
});
});
it("omits a module that declares no UI entrypoint", async () => {
const { app } = await install({
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules.map((module) => module.id)).toEqual(["example"]);
});
it("is empty, not an error, when no installed module provides a UI", async () => {
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
const { modules, response } = await inventoryOf(app);
expect(response.status).toBe(200);
expect(modules).toEqual([]);
});
it("orders modules deterministically, matching the publication registry", async () => {
const { app } = await install({
fixtures: [
{ id: "signage", withUi: true },
{ id: "announcements", withUi: true },
{ id: "calendar", withUi: true },
],
principal: signedIn(),
});
const first = await inventoryOf(app);
const second = await inventoryOf(app);
// Discovery order, which is sorted; the same on every request.
expect(first.modules.map((module) => module.id)).toEqual([
"announcements",
"calendar",
"signage",
]);
expect(second.modules.map((module) => module.id)).toEqual(
first.modules.map((module) => module.id),
);
});
it("leaks no filesystem path, package root, or other manifest internal", async () => {
const {
app,
modules: discovered,
modulesDirectory,
} = await install({
fixtures: [{ id: "example", permissions: [], withUi: true }],
principal: signedIn(),
});
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
const { body, modules } = await inventoryOf(app);
expect(body).not.toContain(modulesDirectory);
expect(body).not.toContain(artifactPath);
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("@test/module-example");
expect(body).not.toContain("ekklesia.module.json");
// 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",
"ui",
"version",
]);
});
it("carries no navigation or presentation metadata", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const { body } = await inventoryOf(app);
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
expect(body, field).not.toContain(`"${field}"`);
}
});
});
describe("module UI inventory — authentication", () => {
it("refuses an anonymous caller", async () => {
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
const response = await app.request(MODULE_UI_INVENTORY_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("tells an anonymous caller nothing about what is installed", async () => {
const { app } = await install({
fixtures: [
{ id: "example", withUi: true },
{ id: "calendar", withUi: true },
],
});
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
expect(body).not.toContain("example");
expect(body).not.toContain("calendar");
});
it("admits any signed-in user without needing an explicit grant", async () => {
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
// that rendered nothing until an operator granted every user a permission would be unusable.
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
grants: [],
principal: signedIn(),
platformOwner: false,
});
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
});
it("is never cached and varies on the caller's identity", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("cache-control")).toBe("no-store");
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
expect(vary).toContain("origin");
expect(vary).toContain("cookie");
});
});
describe("module UI inventory — permission hints", () => {
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
it("returns only the permissions the server actually granted this principal", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: ["example.records.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
});
it("returns none of them for a signed-in user holding no grant", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
// Never a default of true, and never the module's full declared surface.
expect(modules[0]?.permissions).toEqual([]);
});
it("honours a baseline that needs no grant at all", async () => {
const { app } = await install({
fixtures: [
{
id: "example",
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
withUi: true,
},
],
grants: [],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
});
it("honours the platform-owner bypass, exactly like a real request would", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
grants: [],
platformOwner: true,
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
});
it("scopes hints to each module's own declared permissions", async () => {
const { app } = await install({
fixtures: [
{ id: "example", permissions: [readPermission], withUi: true },
{
id: "calendar",
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
withUi: true,
},
],
grants: ["example.records.read", "calendar.events.read"],
principal: signedIn(),
});
const { modules } = await inventoryOf(app);
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
// Neither module learns anything about the other's authorization state, and neither is told
// about Core's own permissions.
expect(byId.get("example")).toEqual(["example.records.read"]);
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
});
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
const { app } = await install({
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
grantsUnavailable: true,
principal: signedIn(),
});
const response = await app.request(MODULE_UI_INVENTORY_PATH);
// A hint that silently read "denied" during an outage would blank the UI and look like a
// permissions problem.
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
});
describe("module UI inventory — it is not a module management API", () => {
it("routes no method other than GET", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
expect(response.status, method).toBe(404);
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
}
});
it("still refuses to serve an artifact under the collection path", async () => {
const { app } = await install({
fixtures: [{ id: "example", withUi: true }],
principal: signedIn(),
});
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
expect(response.status, suffix).toBe(404);
}
});
});
/**
* 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",
]);
});
});