Phase 8/module platform prerequisites #40
+7
-1
@@ -39,7 +39,8 @@ pnpm infrastructure:config
|
||||
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
||||
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
|
||||
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
|
||||
@@ -58,6 +59,11 @@ pnpm infrastructure:config
|
||||
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
||||
- A module may contribute a browser renderer to a capability another module provides
|
||||
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
|
||||
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
|
||||
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
|
||||
ordering. A contributed renderer gets its own id and that input, and nothing else.
|
||||
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
||||
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
||||
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import { grantedModulePermissionIds } from "./module-ui-inventory.js";
|
||||
import {
|
||||
describeModuleContributions,
|
||||
grantedModulePermissionIds,
|
||||
type HostModuleContributionDescriptor,
|
||||
} from "./module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||
|
||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||
@@ -37,6 +41,9 @@ export interface HostModuleDisplayDescriptor {
|
||||
readonly api: { readonly baseUrl: string };
|
||||
readonly realtime: { readonly baseUrl: string };
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
|
||||
* mechanism a signage-style display uses to show another module's content without importing it. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +139,7 @@ async function describeDisplay(
|
||||
viewer,
|
||||
module: {
|
||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||
id: display.moduleId,
|
||||
permissions: await grantedModulePermissionIds(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
|
||||
import type {
|
||||
ModuleUiPublicationRegistry,
|
||||
PublishableModuleContribution,
|
||||
PublishableModuleUi,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
@@ -10,7 +14,11 @@ import {
|
||||
} from "./authorization-middleware.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
|
||||
import {
|
||||
MODULE_UI_ASSET_PREFIX,
|
||||
moduleContributionArtifactUrl,
|
||||
moduleUiArtifactUrl,
|
||||
} from "./module-ui-routes.js";
|
||||
|
||||
/**
|
||||
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
||||
@@ -64,12 +72,56 @@ export interface HostModuleUiDescriptor {
|
||||
* nor trusts anything in this array.
|
||||
*/
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
|
||||
* the overwhelming majority of modules, which provide no extension point. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
|
||||
*
|
||||
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
|
||||
* consuming module names a contribution and the host resolves it, so no module ever builds
|
||||
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
|
||||
*
|
||||
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
|
||||
* contributed renderer draws and what shape its input takes are the consuming module's contract,
|
||||
* carried in the browser between two modules without passing through the platform.
|
||||
*/
|
||||
export interface HostModuleContributionDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
/** The capability it was contributed to — one this module declares. */
|
||||
readonly capability: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface HostModuleUiInventory {
|
||||
readonly modules: readonly HostModuleUiDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
|
||||
* inventory and the display descriptor so the two can never disagree about what a consuming module
|
||||
* may mount.
|
||||
*/
|
||||
export function describeModuleContributions(
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
moduleId: string,
|
||||
): readonly HostModuleContributionDescriptor[] {
|
||||
return registry
|
||||
.contributionsForModule(moduleId)
|
||||
.map((contribution: PublishableModuleContribution) => ({
|
||||
capability: contribution.capability,
|
||||
id: contribution.contributionId,
|
||||
url: moduleContributionArtifactUrl(
|
||||
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
|
||||
contribution.contributionId,
|
||||
),
|
||||
version: contribution.version,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||
@@ -116,9 +168,11 @@ async function describeModule(
|
||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||
principal: EkklesiaPrincipal,
|
||||
published: PublishableModuleUi,
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
): Promise<HostModuleUiDescriptor> {
|
||||
return {
|
||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||
contributions: describeModuleContributions(registry, published.moduleId),
|
||||
id: published.moduleId,
|
||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||
@@ -153,7 +207,7 @@ export async function describeMountableModules(
|
||||
): Promise<readonly MountableModuleDescription[]> {
|
||||
return Promise.all(
|
||||
registry.list().map(async (published) => ({
|
||||
descriptor: await describeModule(evaluator, principal, published),
|
||||
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||
name: published.name,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
|
||||
*
|
||||
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
|
||||
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
|
||||
* module id and version are: a value that somehow did not come from a validated manifest can only
|
||||
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
|
||||
* fragment — and a value that needed encoding simply fails to resolve against the registry.
|
||||
*
|
||||
* Like every other publication helper, this takes **identity only**. There is no parameter for a
|
||||
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
|
||||
* module package.
|
||||
*/
|
||||
export function moduleContributionArtifactUrl(
|
||||
identity: ModuleUiIdentity,
|
||||
contributionId: string,
|
||||
): string {
|
||||
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||
@@ -191,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
|
||||
*
|
||||
* ## The request never touches the filesystem
|
||||
*
|
||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
||||
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
|
||||
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
||||
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
||||
* are all unreachable by construction rather than by filtering. An installed module directory never
|
||||
@@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions {
|
||||
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(
|
||||
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
|
||||
async (c) => {
|
||||
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
|
||||
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
|
||||
// UI artifact and on no others.
|
||||
const published = options.registry.resolveContribution(
|
||||
c.req.param("moduleId"),
|
||||
c.req.param("version"),
|
||||
c.req.param("contributionId"),
|
||||
);
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
return serveArtifact(c, published);
|
||||
},
|
||||
);
|
||||
|
||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||
const published = options.registry.resolveDisplay(
|
||||
c.req.param("moduleId"),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
id: "announcements",
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -57,7 +57,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# 0041: One module may contribute a browser renderer to another module's capability
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md)
|
||||
|
||||
## Context
|
||||
|
||||
The platform already has a complete story for one module using another's *backend*: capabilities,
|
||||
versioned dependency ranges, and provider registration (ADR 0006). It has no story at all for one
|
||||
module using another's *browser* code, and the first real product needs one.
|
||||
|
||||
The concrete shape, stated so the mechanism can be judged against it rather than against an
|
||||
abstraction:
|
||||
|
||||
```text
|
||||
Signage provides a backend capability and owns a screen made of timeline slots
|
||||
Announcements requires that capability and registers a backend provider for it
|
||||
Announcements also ships a React renderer that knows how an announcement should look
|
||||
Signage mounts that renderer for one slot, drives it, and takes it down
|
||||
```
|
||||
|
||||
Every alternative available today is wrong in a way that matters. Signage cannot import
|
||||
Announcements: modules are independently installed, independently versioned peers, and
|
||||
`pnpm boundaries` rejects it outright. Signage cannot re-implement the renderer: it does not own the
|
||||
domain and the two would drift on the first change. Announcements cannot declare a second primary UI:
|
||||
`entrypoints.ui` and `entrypoints.display` are artifacts a *host* mounts in surfaces the host owns,
|
||||
and nothing about "a slot inside another module's timeline" is a host concern. And the platform must
|
||||
not learn what a slide is — the moment Core has a `slide` type, every future consumer inherits
|
||||
Signage's vocabulary.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a generic **browser contribution**: a compiled renderer one module declares for a capability
|
||||
another module provides, mounted by that other module inside a container it owns.
|
||||
|
||||
### The manifest declaration is inert and small
|
||||
|
||||
`manifestVersion: 7` adds a top-level `contributions` array. Each entry declares:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `id` | `<module-id>.<local-name>` — namespaced, so uniqueness needs no registry |
|
||||
| `version` | The contribution's own version, independent of its module's |
|
||||
| `capability` | The capability it plugs into, owned by whichever module provides it |
|
||||
| `capabilityRange` | The provider versions it is compatible with |
|
||||
| `artifact` | One compiled browser artifact, package-relative |
|
||||
|
||||
That is the complete list, and the omissions are the decision. There is no payload schema, no slot,
|
||||
no ordering, no rendering hint, and no domain vocabulary of any kind. **Core cannot learn what a
|
||||
slide is from this declaration, because the declaration cannot express it.**
|
||||
|
||||
Validation is static and fails closed: the id must be namespaced to the declaring module and have
|
||||
exactly one local segment; ids and artifact paths must be unique within a module; the artifact must
|
||||
not collide with any entrypoint; and a contribution may not target a capability its own module
|
||||
provides — a module contributing to itself needs no platform mechanism and would make the
|
||||
compatibility check a comparison of a module with itself.
|
||||
|
||||
Version 7 is a hard cutover, exactly as every prior manifest version has been (ADR 0006, 0014, 0021,
|
||||
0033). Upgrading a module that contributes nothing is a one-character edit.
|
||||
|
||||
### Publication is the existing mechanism, extended
|
||||
|
||||
Discovery resolves each contribution artifact through **the identical loop** every other compiled
|
||||
entrypoint uses, so a contribution inherits the path grammar, containment, symlink-resolution,
|
||||
extension, and file-kind checks rather than receiving a second, weaker implementation of them. Two
|
||||
contributions that resolve through a link to one file are rejected.
|
||||
|
||||
The publication registry adds contributions on three conditions, all of which must hold: the
|
||||
contributing module is in the eligible set, *some other* eligible module provides the target
|
||||
capability, and that provider's declared capability version satisfies the declared range. A
|
||||
contribution that fails any of them is **absent** — never published-but-broken, and never mounted
|
||||
with a shrug.
|
||||
|
||||
`apps/api` serves it at `GET /api/v1/module-ui/<module-id>/<version>/contributions/<contribution-id>`
|
||||
— a sibling of the artifact and display routes, under the same prefix, with the same policy: three
|
||||
lookup keys, one exact registry match, one already-validated canonical path, no request value ever
|
||||
composed into a filesystem path, and one indistinguishable `404` for unknown, undeclared, stale, and
|
||||
incompatible alike. Source maps, sibling chunks, and package-root files stay unreachable by
|
||||
construction.
|
||||
|
||||
### A consumer discovers through the host, never through a URL
|
||||
|
||||
The consuming module's descriptor gains a `contributions` array: identity, version, capability, and
|
||||
the **server-produced** URL. A module UI sees only the first three; the URL lives in the host, which
|
||||
resolves it exactly as it resolves `api.request("records")`. A module that built
|
||||
`/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
|
||||
deployment detail and could never run under a standalone host.
|
||||
|
||||
`ModuleUiHostContext` gains a sixth member, `contributions`, with `list(capabilityId)` and
|
||||
`mount({ contributionId, container, input })`. A consumer can only ever see contributions aimed at a
|
||||
capability **it** provides, because that is the only thing the server put in its descriptor — and it
|
||||
cannot name its way to anything else, because a `mount` for an id the server withheld fails without
|
||||
fetching anything.
|
||||
|
||||
### The consumer owns the domain contract, entirely
|
||||
|
||||
`input` is opaque JSON. The platform validates that it *is* genuinely JSON-serializable — structurally,
|
||||
so a `Date`, a function, `undefined`, a non-finite number, or a cycle fails at the boundary rather
|
||||
than vanishing somewhere far from the mistake — and validates nothing about its shape or meaning. A
|
||||
consumer publishes its own input contract alongside its capability and versions it through its own
|
||||
capability version. Neither the manifest, the registry, the route, nor the SDK has a place to put a
|
||||
schema, which is what stops one from appearing.
|
||||
|
||||
### The contribution ABI is separate and versioned
|
||||
|
||||
`MODULE_CONTRIBUTION_ABI_VERSION` is stamped by `defineModuleContribution`, never written by an
|
||||
author. It is deliberately **not** the same number as `MODULE_UI_ABI_VERSION`: the two contracts have
|
||||
different lifecycles — one receives a host context, the other receives a consumer's input and can be
|
||||
updated in place — and versioning them together would force every module to rebuild whenever either
|
||||
moved. A mismatched artifact fails with its own code before mounting.
|
||||
|
||||
The ABI supports exactly what a long-running screen needs:
|
||||
|
||||
- **asynchronous readiness** — `mount` may return a promise, so a consumer can sequence a transition
|
||||
instead of guessing when a renderer has decoded its media;
|
||||
- **update without remounting** — remounting per change would destroy DOM, restart animations, and
|
||||
reload media on every tick. A renderer that genuinely cannot update in place omits `update`, and
|
||||
the consumer is told to remount rather than left rendering stale content behind a silently ignored
|
||||
call;
|
||||
- **deterministic cleanup** — the loader runs the renderer's cleanup and removes its isolated root in
|
||||
a `finally`, so a renderer that fails to clean up still has its styles taken out of the page;
|
||||
- **sanitized failure** — every code is fixed, and no message, stack, or `cause` ever carries the
|
||||
consumer's domain data.
|
||||
|
||||
Each contribution mounts inside its own Shadow DOM root (ADR 0040) with its own compiled stylesheet,
|
||||
so a contributed renderer cannot restyle the consuming module's screen around it, and that module's
|
||||
rules cannot reach in.
|
||||
|
||||
### What a contribution is not given
|
||||
|
||||
No database, no object storage, no credential, no session, no API client, and no reach into any
|
||||
module — including the one that contributed it. Its context is two members: its own id and the
|
||||
consumer's input.
|
||||
|
||||
**Loading a contribution grants its module nothing.** Server-side authorization is unchanged and
|
||||
remains the only authority. Everything a contributed renderer needs arrives through the consumer's
|
||||
input, because the consumer is the party that was authorized to obtain it.
|
||||
|
||||
A contribution artifact is still installed, trusted, first-party code in the host's realm (ADR 0022,
|
||||
ADR 0024). Its isolated root is a correctness boundary for styles and must never be described as a
|
||||
sandbox.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One module can extend another's screen without either importing the other, and without the
|
||||
platform learning either one's vocabulary.
|
||||
- The Dashboard bundle now contains two dynamic imports rather than one, both the generic loader's:
|
||||
a module UI artifact and a contributed renderer.
|
||||
- Manifest version 7 requires every module to update one field. Nothing else changes for a module
|
||||
that contributes nothing.
|
||||
- `ModuleUiHostContext` grew from five members to six. It is present on every context and empty for
|
||||
the overwhelming majority of modules, so a module writes the same code whether or not anything was
|
||||
contributed — and a standalone host running one module supplies an empty implementation that is
|
||||
the *correct* one rather than a stub.
|
||||
- A consuming module must handle an empty list: a contribution can be absent because nobody
|
||||
contributed, because the contributor is disabled, or because it is outside the compatible range.
|
||||
That is the same discipline an optional capability dependency already requires.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Let the consumer import the contributor.** Rejected: it is the layer violation the boundary
|
||||
checker exists to prevent, and it would make two independently installable modules one unit.
|
||||
- **Put the renderer in the consumer.** Rejected: the consumer does not own the domain, so the
|
||||
renderer and the data it draws would drift on the first change to either.
|
||||
- **A second `entrypoints` role.** Rejected: the two existing roles are artifacts a *host* mounts in
|
||||
surfaces the host owns. A contribution is mounted by a module, at a moment and in a place only that
|
||||
module understands; calling it an entrypoint would put the platform in charge of deciding when a
|
||||
slot should be filled.
|
||||
- **A platform-defined payload schema.** Rejected, emphatically. It would make Core the owner of a
|
||||
vocabulary it cannot possibly own, force every future consumer to inherit the first one's shape,
|
||||
and require a platform release to change a domain contract between two modules.
|
||||
- **Give the contribution its own API client.** Rejected: a renderer with its own authority is a
|
||||
privilege the consumer never granted, and a second authorization path to reason about. The consumer
|
||||
was authorized to fetch the data; it passes what it fetched.
|
||||
- **Let a consumer construct the artifact URL.** Rejected for the same reason a module may not build
|
||||
its own API prefix (ADR 0022): it welds the module to Ekklesia's deployment detail.
|
||||
- **One shared ABI version for UI artifacts and contributions.** Rejected: it couples two contracts
|
||||
with different shapes and lifecycles, and would make every module rebuild whenever either moved.
|
||||
|
||||
## Deferred
|
||||
|
||||
Contribution ordering and priority, a consumer-declared "extension point" manifest declaration,
|
||||
per-contribution permissions, contributions to *backend* extension points, host-mediated
|
||||
communication back from a contribution to its own module, contribution-level configuration, hot
|
||||
reload, and a module store or installer that would let contributions arrive without a restart.
|
||||
@@ -49,3 +49,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
|
||||
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
||||
|
||||
@@ -23,8 +23,8 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
`@ekklesia/module-ui-sdk`. Since
|
||||
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
||||
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
|
||||
(`src/ui/`, `src/display/`); a stylesheet anywhere else in a module is a file no build reads and no
|
||||
host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
||||
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
|
||||
no build reads and no host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
||||
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
|
||||
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||
@@ -63,7 +63,19 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
module code remains trusted, same-realm code. The browser mount ABI is versioned
|
||||
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
||||
semantics.
|
||||
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
- A module may declare **browser contributions** (`manifest.contributions`, v7): compiled renderers
|
||||
it offers to capabilities *other* modules provide
|
||||
([ADR 0041](decisions/0041-module-browser-contributions.md)). Core validates identity,
|
||||
compatibility, and the artifact path through the identical resolution every other compiled
|
||||
entrypoint uses, and knows nothing about what a contribution renders — there is no payload schema,
|
||||
slot, ordering, or rendering hint anywhere in the platform. A contribution is published only when
|
||||
another eligible module provides its target capability within the declared range; otherwise it is
|
||||
absent rather than broken. A consuming module discovers contributions through
|
||||
`ModuleUiHostContext.contributions` and mounts them by id; it never constructs a publication URL,
|
||||
and it can only ever see contributions aimed at a capability it itself provides. A contributed
|
||||
renderer receives its own id and the consumer's opaque JSON input, and no database, storage,
|
||||
credential, session, or API client of its own.
|
||||
- Manifest v7 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
||||
|
||||
@@ -73,7 +73,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -122,6 +122,16 @@ without executing module code:
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -140,12 +150,13 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 5 manifest, and any other version, fails validation outright rather than being silently
|
||||
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 6 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
|
||||
changes only the version literal; `ui` remains independently optional
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Version 7 adds the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
@@ -214,6 +225,16 @@ are separate artifacts because a full-viewport display is not a Dashboard screen
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -271,7 +292,7 @@ artifact is the role selection.
|
||||
|
||||
### What the host gives you
|
||||
|
||||
`mount` receives exactly five members, and nothing else is available:
|
||||
`mount` receives exactly six members, and nothing else is available:
|
||||
|
||||
| Member | What it is |
|
||||
| --- | --- |
|
||||
@@ -280,6 +301,7 @@ artifact is the role selection.
|
||||
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
||||
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
|
||||
|
||||
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
|
||||
contract. They need designs that do not exist, and a speculative field frozen into a public contract
|
||||
@@ -770,6 +792,132 @@ A Dashboard may later group modules into folders (`Media` holding Video and Live
|
||||
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||||
declared in your manifest, and your module must never require one to work correctly.
|
||||
|
||||
## Browser contributions
|
||||
|
||||
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
|
||||
owns a timeline; an announcements module knows what an announcement should look like. Neither can
|
||||
import the other — modules are independently installed, independently versioned peers — and neither
|
||||
should re-implement the other's job.
|
||||
|
||||
A **browser contribution** is the mechanism for exactly that
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
|
||||
declare for a capability *another* module provides, which that module mounts inside a container it
|
||||
owns.
|
||||
|
||||
```text
|
||||
signage provides the capability signage.slide-renderer, and publishes its input contract
|
||||
announcements contributes a React renderer for it
|
||||
signage mounts that renderer in one timeline slot, updates it, and takes it down
|
||||
```
|
||||
|
||||
### Contributing a renderer
|
||||
|
||||
Declare it in your manifest and put the source at the fixed path your contribution id implies:
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "announcements.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import styles from "./styles.css";
|
||||
import { Slide, type SlideInput } from "./Slide.js";
|
||||
|
||||
export default defineModuleContribution({
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
styles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Slide input={context.input as SlideInput} />);
|
||||
return {
|
||||
update(input) {
|
||||
root.render(<Slide input={input as SlideInput} />);
|
||||
},
|
||||
unmount() {
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
|
||||
registry. Your contribution carries **its own version**, independent of your module's, and the
|
||||
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
|
||||
rendered. `capabilityRange` says which provider versions you work with: if no installed module
|
||||
provides that capability inside your range, your contribution is simply not published. It is never
|
||||
mounted and broken.
|
||||
|
||||
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
|
||||
|
||||
### What a contributed renderer receives
|
||||
|
||||
Two things: its own id, and the consumer's `input`.
|
||||
|
||||
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
|
||||
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
|
||||
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
|
||||
through the consumer's input, because the consumer is the party that was authorized to fetch it.
|
||||
|
||||
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
|
||||
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
|
||||
module publishes that contract alongside its capability and versions it through its capability
|
||||
version. Validate what you receive and render a sensible default when it is not what you expected —
|
||||
you may be running against an older or newer consumer than the one you developed against.
|
||||
|
||||
`update` is what lets a long-running screen change content without restarting animations or reloading
|
||||
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
|
||||
cannot, and the consumer will unmount and mount you again.
|
||||
|
||||
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
|
||||
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
|
||||
cannot reach in.
|
||||
|
||||
### Consuming contributions
|
||||
|
||||
If *your* module provides a capability other modules can render for, ask the host what was
|
||||
contributed:
|
||||
|
||||
```tsx
|
||||
const renderers = context.contributions.list("signage.slide-renderer");
|
||||
|
||||
const slide = await context.contributions.mount({
|
||||
container: slotElement, // an element your module owns
|
||||
contributionId: renderers[0].id,
|
||||
input: { headline, seconds }, // your own published contract
|
||||
});
|
||||
|
||||
await slide.update({ headline: next, seconds });
|
||||
await slide.unmount();
|
||||
```
|
||||
|
||||
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
|
||||
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
|
||||
|
||||
You will only ever see contributions aimed at a capability **your** module provides, and only ones
|
||||
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
|
||||
your compatible range, and that is ordinary rather than exceptional.
|
||||
|
||||
You own the input contract completely. Publish it beside your capability, version it with your
|
||||
capability version, and remember that a renderer written against an older version of it may still be
|
||||
installed.
|
||||
|
||||
## Runtime server definition
|
||||
|
||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./authorization.js";
|
||||
export * from "./identifiers.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./module-configuration.js";
|
||||
export * from "./module-contribution.js";
|
||||
export * from "./module-database.js";
|
||||
export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import { capabilityIdSchema, packageRelativePathSchema } from "./identifiers.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
/**
|
||||
* A **browser contribution**: one module's compiled renderer, declared for a capability another
|
||||
* module provides, and mounted by that other module inside a container it owns (ADR 0041).
|
||||
*
|
||||
* This is deliberately not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts
|
||||
* *the platform* mounts, in surfaces the platform owns. A contribution is mounted by a **consumer
|
||||
* module**, at a moment and in a place only that module understands — a slot in its own layout —
|
||||
* and the platform never renders one on its own.
|
||||
*
|
||||
* ## What the platform knows, and the complete list of it
|
||||
*
|
||||
* Identity, version, target capability, compatible range, and one compiled artifact. That is all,
|
||||
* and the omissions are the point: nothing here says what the renderer draws, what shape its input
|
||||
* has, when it should be mounted, or what any of it means. Core could not learn what a "slide" or a
|
||||
* "timeline slot" is from this declaration, because the declaration cannot express it — the
|
||||
* domain contract belongs entirely to the consumer's own versioned input.
|
||||
*
|
||||
* Inert, like every other manifest declaration: reading one causes no import, no evaluation, and no
|
||||
* network or database access.
|
||||
*/
|
||||
|
||||
/** `<module-id>.<local-name>` — exactly two segments. The prefix is checked against the declaring
|
||||
* module in `moduleManifestSchema`, which is what makes a contribution id globally unique without a
|
||||
* registry: module ids are already unique, so two modules cannot produce the same contribution id. */
|
||||
export const contributionIdSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(160)
|
||||
.regex(
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
|
||||
"Must be <module-id>.<local-name>, both lowercase kebab-case.",
|
||||
);
|
||||
|
||||
export const moduleContributionDeclarationSchema = z
|
||||
.object({
|
||||
id: contributionIdSchema,
|
||||
/** This contribution's own version, independent of the module's. A consumer sees it and a host
|
||||
* verifies the artifact claims it, so a stale bundle is caught exactly as it is for a module
|
||||
* UI artifact. */
|
||||
version: semanticVersionSchema,
|
||||
/** The capability this renderer plugs into — owned by whichever module provides it, never by
|
||||
* the contributing module. */
|
||||
capability: capabilityIdSchema,
|
||||
/** The provider capability versions this renderer is compatible with. A provider outside this
|
||||
* range makes the contribution unpublishable rather than mounted and broken. */
|
||||
capabilityRange: semanticVersionRangeSchema,
|
||||
/** The compiled browser artifact, resolved and validated during discovery exactly like every
|
||||
* other declared entrypoint: package-relative, inside the package before and after symlink
|
||||
* resolution, and a real `.js`/`.mjs` file. */
|
||||
artifact: packageRelativePathSchema,
|
||||
description: z.string().trim().min(1).max(240).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ModuleContributionDeclaration = z.output<typeof moduleContributionDeclarationSchema>;
|
||||
|
||||
/** The local segment of a contribution id — the part after the declaring module's own id. Used by
|
||||
* the canonical builder to locate the fixed source directory, so a module author configures no
|
||||
* paths and cannot point the build somewhere else. */
|
||||
export function contributionLocalName(contributionId: string): string {
|
||||
return contributionId.slice(contributionId.indexOf(".") + 1);
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
type ModuleConfigurationDeclaration,
|
||||
moduleConfigurationDeclarationSchema,
|
||||
} from "./module-configuration.js";
|
||||
import {
|
||||
type ModuleContributionDeclaration,
|
||||
moduleContributionDeclarationSchema,
|
||||
} from "./module-contribution.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||
@@ -118,9 +122,9 @@ function reportDuplicates(
|
||||
}
|
||||
|
||||
/**
|
||||
* The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so
|
||||
* the static display declaration is an incompatible shape change and the canonical manifest moves
|
||||
* to `manifestVersion: 6` rather than silently reinterpreting version 5.
|
||||
* The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
|
||||
* so the static browser-contribution declaration is an incompatible shape change and the canonical
|
||||
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
|
||||
*
|
||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
||||
* executable module code, for one decisive reason: the platform validates a module's configuration
|
||||
@@ -129,13 +133,14 @@ function reportDuplicates(
|
||||
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
|
||||
* importing a single line of module code, exactly as they always have.
|
||||
*
|
||||
* Version 5, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* Version 6, and every other version, fails validation: there is no compatibility adapter, no
|
||||
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
|
||||
* manifest document can therefore never be read as two different versions. Upgrading a version 5
|
||||
* manifest is a one-character edit for a module that declares no display: the new field is optional.
|
||||
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
|
||||
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different
|
||||
* versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes
|
||||
* nothing: the new field defaults to empty.
|
||||
*/
|
||||
export const MODULE_MANIFEST_VERSION = 6;
|
||||
export const MODULE_MANIFEST_VERSION = 7;
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
@@ -158,6 +163,10 @@ export const moduleManifestSchema = z
|
||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
/** Compiled browser renderers this module offers to capabilities **other** modules provide
|
||||
* (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and
|
||||
* never learns what a contribution renders. A module that contributes nothing omits the field. */
|
||||
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
|
||||
entrypoints: entrypointsSchema,
|
||||
})
|
||||
.strict()
|
||||
@@ -200,6 +209,58 @@ export const moduleManifestSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
reportDuplicates(
|
||||
manifest.contributions.map((contribution) => contribution.id),
|
||||
["contributions"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
|
||||
const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id));
|
||||
const contributionArtifacts = new Set<string>();
|
||||
manifest.contributions.forEach((contribution, index) => {
|
||||
// Namespacing is what makes a contribution id globally unique without a registry: module ids
|
||||
// are already unique, so two modules can never produce the same contribution id.
|
||||
if (!contribution.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["contributions", index, "id"],
|
||||
});
|
||||
}
|
||||
// A contribution extends *another* module's capability. Targeting one this module already
|
||||
// provides would be a module contributing to itself, which needs no platform mechanism at all
|
||||
// and would make the compatibility check meaningless.
|
||||
if (ownedCapabilityIds.has(contribution.capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A browser contribution must target a capability another module provides.",
|
||||
path: ["contributions", index, "capability"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
Object.entries(manifest.entrypoints).some(([entrypointName, configured]) =>
|
||||
entrypointName === "display"
|
||||
? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact
|
||||
: configured === contribution.artifact,
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A contribution artifact must use a different path from every entrypoint.",
|
||||
path: ["contributions", index, "artifact"],
|
||||
});
|
||||
}
|
||||
if (contributionArtifacts.has(contribution.artifact)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Two contributions must not declare the same artifact path.",
|
||||
path: ["contributions", index, "artifact"],
|
||||
});
|
||||
}
|
||||
contributionArtifacts.add(contribution.artifact);
|
||||
});
|
||||
|
||||
manifest.permissions.forEach((permission, index) => {
|
||||
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
@@ -263,10 +324,9 @@ export const moduleManifestSchema = z
|
||||
}
|
||||
}
|
||||
|
||||
const ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id));
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
||||
if (ownedCapabilities.has(capability)) {
|
||||
if (ownedCapabilityIds.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
@@ -297,6 +357,9 @@ export function safeParseModuleManifest(input: unknown) {
|
||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
/** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape
|
||||
* Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */
|
||||
export type ModuleManifestContributionDeclaration = ModuleContributionDeclaration;
|
||||
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
|
||||
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
||||
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
@@ -226,15 +226,21 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 5 manifest rather than silently interpreting it as version 6", () => {
|
||||
it("rejects a version 5 manifest rather than silently interpreting it as a later version", () => {
|
||||
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
|
||||
|
||||
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 6 manifest rather than silently interpreting it as version 7", () => {
|
||||
const version6Manifest = { ...validManifest(), manifestVersion: 6 };
|
||||
|
||||
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(6);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
|
||||
expect(MODULE_MANIFEST_VERSION).toBe(7);
|
||||
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||
}
|
||||
expect(
|
||||
@@ -243,6 +249,124 @@ describe("module manifest contract", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults contributions to an empty list for a module that contributes nothing", () => {
|
||||
const parsed = safeParseModuleManifest(validManifest());
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.success && parsed.data.contributions).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a well-formed browser contribution", () => {
|
||||
const parsed = safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
contributions: [
|
||||
{
|
||||
id: "calendar.signage-slide",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/signage-slide/index.js",
|
||||
description: "Renders an upcoming-events slide.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.success && parsed.data.contributions[0]?.capability).toBe(
|
||||
"signage.slide-renderer",
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, Record<string, unknown>]>([
|
||||
["an id outside this module's namespace", { id: "signage.slide" }],
|
||||
["an id with no local segment", { id: "calendar" }],
|
||||
["an id with more than one local segment", { id: "calendar.signage.slide" }],
|
||||
["an unknown property", { slot: "primary" }],
|
||||
["a non-semver version", { version: "1" }],
|
||||
["an invalid capability range", { capabilityRange: "not a range" }],
|
||||
["an artifact escaping the package", { artifact: "../elsewhere/index.js" }],
|
||||
["an absolute artifact path", { artifact: "/etc/passwd" }],
|
||||
])("rejects a contribution with %s", (_label, override) => {
|
||||
const contribution = {
|
||||
id: "calendar.signage-slide",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/signage-slide/index.js",
|
||||
...override,
|
||||
};
|
||||
|
||||
expect(
|
||||
safeParseModuleManifest({ ...validManifest(), contributions: [contribution] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a contribution targeting a capability this module already provides", () => {
|
||||
// A module contributing to itself needs no platform mechanism, and would make the compatibility
|
||||
// check meaningless: it would always be comparing a module against itself.
|
||||
expect(
|
||||
safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
contributions: [
|
||||
{
|
||||
id: "calendar.self",
|
||||
version: "1.0.0",
|
||||
capability: "calendar.events",
|
||||
capabilityRange: "^2.0.0",
|
||||
artifact: "./dist/contributions/self/index.js",
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects two contributions sharing an id or an artifact path", () => {
|
||||
const base = {
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
};
|
||||
|
||||
expect(
|
||||
safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
contributions: [
|
||||
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
|
||||
{ ...base, id: "calendar.a", artifact: "./dist/contributions/b/index.js" },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
safeParseModuleManifest({
|
||||
...validManifest(),
|
||||
contributions: [
|
||||
{ ...base, id: "calendar.a", artifact: "./dist/contributions/a/index.js" },
|
||||
{ ...base, id: "calendar.b", artifact: "./dist/contributions/a/index.js" },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a contribution artifact that collides with a declared entrypoint", () => {
|
||||
const manifest = validManifest();
|
||||
const entrypoints = manifest.entrypoints as Record<string, unknown>;
|
||||
|
||||
expect(
|
||||
safeParseModuleManifest({
|
||||
...manifest,
|
||||
contributions: [
|
||||
{
|
||||
id: "calendar.signage-slide",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: entrypoints.server,
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
||||
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { moduleDisplayEntrypointSchema, packageRelativePathSchema } from "@ekklesia/contracts";
|
||||
import {
|
||||
contributionLocalName,
|
||||
moduleContributionDeclarationSchema,
|
||||
moduleDisplayEntrypointSchema,
|
||||
packageRelativePathSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { build, type Plugin } from "esbuild";
|
||||
|
||||
/**
|
||||
@@ -13,6 +18,14 @@ import { build, type Plugin } from "esbuild";
|
||||
*/
|
||||
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
||||
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
|
||||
/** Where a declared browser contribution's source lives: `src/contributions/<local-name>/index.tsx`,
|
||||
* where the local name is the part of the contribution id after the module's own id. Derived rather
|
||||
* than configured, exactly like the two role entrypoints, so an author points the build nowhere. */
|
||||
export const MODULE_CONTRIBUTION_SOURCE_DIRECTORY = "src/contributions";
|
||||
|
||||
export function moduleContributionSourceEntrypoint(contributionId: string): string {
|
||||
return `${MODULE_CONTRIBUTION_SOURCE_DIRECTORY}/${contributionLocalName(contributionId)}/index.tsx`;
|
||||
}
|
||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||
|
||||
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
||||
@@ -27,7 +40,7 @@ export type ModuleUiBuildErrorCode =
|
||||
| "MODULE_UI_STYLESHEET_UNDECLARED"
|
||||
| "MODULE_UI_BUILD_FAILED";
|
||||
|
||||
export type ModuleUiArtifactKind = "ui" | "display";
|
||||
export type ModuleUiArtifactKind = "ui" | "display" | "contribution";
|
||||
|
||||
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
||||
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
||||
@@ -165,13 +178,17 @@ export interface BuildModuleUiOptions {
|
||||
}
|
||||
|
||||
export interface ModuleUiBuildResult {
|
||||
/** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only
|
||||
* modules, which makes the canonical command usable unchanged by every module package. */
|
||||
/** Built artifacts in stable role order: primary UI, then display, then each declared contribution
|
||||
* in manifest order. Empty for backend-only modules, which makes the canonical command usable
|
||||
* unchanged by every module package. */
|
||||
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
|
||||
}
|
||||
|
||||
export interface ModuleUiBuildArtifactResult {
|
||||
readonly kind: ModuleUiArtifactKind;
|
||||
/** For a contribution, the declared contribution id — so a multi-contribution build reports which
|
||||
* artifact it produced. Absent for the two role entrypoints. */
|
||||
readonly contributionId?: string;
|
||||
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
|
||||
readonly outputFile: string;
|
||||
/** Absolute path of the fixed source entrypoint that produced it. */
|
||||
@@ -203,7 +220,15 @@ async function resolveDeclaredArtifacts(
|
||||
}
|
||||
|
||||
const display = entrypoints.display;
|
||||
const manifestContributions = isRecord(manifest) ? manifest.contributions : undefined;
|
||||
const declaredContributions = Array.isArray(manifestContributions) ? manifestContributions : [];
|
||||
for (const contribution of declaredContributions) {
|
||||
if (!moduleContributionDeclarationSchema.safeParse(contribution).success) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "contribution");
|
||||
}
|
||||
}
|
||||
const declarations: readonly {
|
||||
readonly contributionId?: string;
|
||||
readonly declared: unknown;
|
||||
readonly kind: ModuleUiArtifactKind;
|
||||
readonly present: boolean;
|
||||
@@ -221,6 +246,13 @@ async function resolveDeclaredArtifacts(
|
||||
present: display !== undefined,
|
||||
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
|
||||
},
|
||||
...(declaredContributions as { artifact: string; id: string }[]).map((contribution) => ({
|
||||
contributionId: contribution.id,
|
||||
declared: contribution.artifact,
|
||||
kind: "contribution" as const,
|
||||
present: true,
|
||||
source: moduleContributionSourceEntrypoint(contribution.id),
|
||||
})),
|
||||
];
|
||||
const artifacts: DeclaredModuleUiArtifact[] = [];
|
||||
|
||||
@@ -239,6 +271,9 @@ async function resolveDeclaredArtifacts(
|
||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
|
||||
}
|
||||
artifacts.push({
|
||||
...(declaration.contributionId === undefined
|
||||
? {}
|
||||
: { contributionId: declaration.contributionId }),
|
||||
kind: declaration.kind,
|
||||
outputFile: path.resolve(moduleDirectory, declared),
|
||||
sourceFile: path.join(moduleDirectory, declaration.source),
|
||||
@@ -249,17 +284,24 @@ async function resolveDeclaredArtifacts(
|
||||
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
|
||||
}
|
||||
|
||||
const displayArtifact = artifacts.find((artifact) => artifact.kind === "display");
|
||||
if (displayArtifact !== undefined) {
|
||||
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
|
||||
const configured = entrypoints[entrypointName];
|
||||
if (
|
||||
typeof configured === "string" &&
|
||||
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile
|
||||
) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display");
|
||||
}
|
||||
// No declared artifact may overwrite another declared file — an entrypoint this build does not
|
||||
// produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so
|
||||
// a collision can never destroy output that was already correct.
|
||||
const backendEntrypointPaths = new Set(
|
||||
(["server", "schema", "migrations", "jobs"] as const)
|
||||
.map((entrypointName) => entrypoints[entrypointName])
|
||||
.filter((configured): configured is string => typeof configured === "string")
|
||||
.map((configured) => path.resolve(moduleDirectory, configured)),
|
||||
);
|
||||
const claimedOutputs = new Set<string>();
|
||||
for (const artifact of artifacts) {
|
||||
if (
|
||||
backendEntrypointPaths.has(artifact.outputFile) ||
|
||||
claimedOutputs.has(artifact.outputFile)
|
||||
) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind);
|
||||
}
|
||||
claimedOutputs.add(artifact.outputFile);
|
||||
}
|
||||
|
||||
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
|
||||
|
||||
@@ -26,7 +26,7 @@ async function main(): Promise<number> {
|
||||
}
|
||||
for (const artifact of result.artifacts) {
|
||||
process.stdout.write(
|
||||
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
|
||||
`Built module UI (${artifact.contributionId ?? artifact.kind}): ${path.relative(moduleDirectory, artifact.outputFile)}\n`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
||||
const manifest =
|
||||
options.manifest ??
|
||||
JSON.stringify({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "example",
|
||||
name: "Example",
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./isolated-root.js";
|
||||
export * from "./load-module-contribution.js";
|
||||
export * from "./load-module-ui.js";
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
isModuleContributionInput,
|
||||
type ModuleContributionDefinition,
|
||||
ModuleContributionError,
|
||||
type ModuleContributionInput,
|
||||
type ModuleContributionInstance,
|
||||
type MountedModuleContribution,
|
||||
parseModuleContributionExport,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { createIsolatedArtifactRoot, type IsolatedArtifactRoot } from "./isolated-root.js";
|
||||
import { type ModuleUiImporter, ModuleUiHostError } from "./load-module-ui.js";
|
||||
|
||||
/**
|
||||
* How a host obtains one contribution artifact.
|
||||
*
|
||||
* `url` is a **browser-loadable module URL** the host produced, never a path a module composed. The
|
||||
* consuming module names a contribution id; turning that into a URL is asset publication, which this
|
||||
* generic package deliberately does not own — exactly as it does not own it for a module UI artifact
|
||||
* (ADR 0024, ADR 0025). Ekklesia implements it at its API edge (ADR 0041).
|
||||
*
|
||||
* `contributionId` and `version` are the identity the host already read from the contributing
|
||||
* module's validated manifest, and are checked against the artifact before anything is mounted.
|
||||
*/
|
||||
export interface ModuleContributionSource {
|
||||
readonly contributionId: string;
|
||||
readonly version: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface LoadModuleContributionOptions {
|
||||
readonly source: ModuleContributionSource;
|
||||
/** A container the **consuming module** owns. The loader builds the contribution's isolated root
|
||||
* inside it and never reaches outside it. */
|
||||
readonly container: Element;
|
||||
/** The consumer's own versioned input, validated as JSON before it crosses the boundary. */
|
||||
readonly input: ModuleContributionInput;
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
|
||||
const defaultImporter: ModuleUiImporter = (url) => import(url);
|
||||
|
||||
/**
|
||||
* Imports a published contribution artifact, verifies it is the contribution the host expected, and
|
||||
* mounts it into an isolated root inside the consumer's container (ADR 0040, ADR 0041).
|
||||
*
|
||||
* **This executes trusted first-party code in the host's own page**, exactly like a module UI
|
||||
* artifact. The isolated root keeps the renderer's *styles* to itself; it is not a sandbox, and
|
||||
* mounting a contribution grants its module no authority whatsoever. Server-side authorization is
|
||||
* untouched: the consumer was already authorized to obtain the data it passes as input, and the
|
||||
* contribution receives nothing else.
|
||||
*
|
||||
* The consumer's `input` is validated as genuinely JSON-serializable **before** the artifact is even
|
||||
* imported. A `Date`, a function, a cyclic object, or `undefined` would either vanish or throw
|
||||
* somewhere far from the mistake, so it fails here, at the boundary, with a sanitized code that
|
||||
* never echoes the domain data itself.
|
||||
*/
|
||||
export async function loadModuleContribution(
|
||||
options: LoadModuleContributionOptions,
|
||||
): Promise<MountedModuleContribution> {
|
||||
const { container, input, source } = options;
|
||||
if (!isModuleContributionInput(input)) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
|
||||
}
|
||||
const importModule = options.importModule ?? defaultImporter;
|
||||
|
||||
let namespace: unknown;
|
||||
try {
|
||||
namespace = await importModule(source.url);
|
||||
} catch (error) {
|
||||
throw new ModuleUiHostError("MODULE_UI_IMPORT_FAILED", { cause: error });
|
||||
}
|
||||
|
||||
// The one contract parser, reused rather than reimplemented: it validates the default export's
|
||||
// shape, its ABI marker, and that its id/version match what the host expected. A mismatched or
|
||||
// stale artifact fails before it can render.
|
||||
const definition: ModuleContributionDefinition = parseModuleContributionExport(namespace, {
|
||||
id: source.contributionId,
|
||||
version: source.version,
|
||||
});
|
||||
|
||||
let isolated: IsolatedArtifactRoot;
|
||||
try {
|
||||
isolated = createIsolatedArtifactRoot(container, definition.styles);
|
||||
} catch (error) {
|
||||
throw new ModuleUiHostError("MODULE_UI_ISOLATION_UNAVAILABLE", { cause: error });
|
||||
}
|
||||
|
||||
let instance: ModuleContributionInstance | undefined;
|
||||
try {
|
||||
const result = await definition.mount(isolated.mountTarget, {
|
||||
contributionId: definition.id,
|
||||
input,
|
||||
});
|
||||
if (result && typeof result === "object") {
|
||||
instance = result;
|
||||
}
|
||||
} catch (error) {
|
||||
isolated.dispose();
|
||||
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
return {
|
||||
contributionId: definition.id,
|
||||
version: definition.version,
|
||||
get mounted() {
|
||||
return mounted;
|
||||
},
|
||||
async update(next: ModuleContributionInput): Promise<void> {
|
||||
if (!mounted) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN");
|
||||
}
|
||||
if (!isModuleContributionInput(next)) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_INPUT_INVALID");
|
||||
}
|
||||
// A renderer that cannot update in place says so by returning no `update`, and the consumer is
|
||||
// told to remount rather than left rendering stale content behind a silently ignored call.
|
||||
if (!instance?.update) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_NOT_UPDATABLE");
|
||||
}
|
||||
try {
|
||||
await instance.update(next);
|
||||
} catch (error) {
|
||||
throw new ModuleUiHostError("MODULE_CONTRIBUTION_UPDATE_FAILED", { cause: error });
|
||||
}
|
||||
},
|
||||
async unmount(): Promise<void> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
// Marked unmounted before cleanup runs, so a throwing cleanup surfaces once and a second call
|
||||
// never re-runs it. The isolated root goes in a `finally`, so a renderer that fails to clean
|
||||
// up still has its styles taken out of the consumer's page.
|
||||
mounted = false;
|
||||
try {
|
||||
await instance?.unmount?.();
|
||||
} finally {
|
||||
isolated.dispose();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -82,13 +82,15 @@ export interface MountedModuleUi {
|
||||
export type ModuleUiHostErrorCode =
|
||||
| "MODULE_UI_IMPORT_FAILED"
|
||||
| "MODULE_UI_ISOLATION_UNAVAILABLE"
|
||||
| "MODULE_UI_MOUNT_FAILED";
|
||||
| "MODULE_UI_MOUNT_FAILED"
|
||||
| "MODULE_CONTRIBUTION_UPDATE_FAILED";
|
||||
|
||||
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
|
||||
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
|
||||
MODULE_UI_ISOLATION_UNAVAILABLE:
|
||||
"A module UI artifact could not be given an isolated root in this environment.",
|
||||
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
|
||||
MODULE_CONTRIBUTION_UPDATE_FAILED: "A contributed renderer failed while applying new input.",
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "example",
|
||||
"name": "Example",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { MODULE_CONTRIBUTION_ABI_VERSION, ModuleContributionError } from "@ekklesia/module-ui-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
loadModuleContribution,
|
||||
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||
MODULE_UI_ISOLATION_STYLE_ATTRIBUTE,
|
||||
ModuleUiHostError,
|
||||
} from "../src/index.js";
|
||||
|
||||
function moduleUrl(source: string): string {
|
||||
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
|
||||
}
|
||||
|
||||
const contributionArtifact = (
|
||||
options: {
|
||||
id?: string;
|
||||
version?: string;
|
||||
styles?: string;
|
||||
updatable?: boolean;
|
||||
cleanup?: boolean;
|
||||
mountThrows?: boolean;
|
||||
updateThrows?: boolean;
|
||||
} = {},
|
||||
) => `
|
||||
export default {
|
||||
abi: ${MODULE_CONTRIBUTION_ABI_VERSION},
|
||||
id: ${JSON.stringify(options.id ?? "announcements.signage-slide")},
|
||||
version: ${JSON.stringify(options.version ?? "1.0.0")},
|
||||
${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`}
|
||||
mount(container, context) {
|
||||
${options.mountThrows ? 'throw new Error("mount exploded");' : ""}
|
||||
globalThis.__contributionMounts ??= [];
|
||||
globalThis.__contributionMounts.push({ container, contributionId: context.contributionId });
|
||||
container.textContent = "slide:" + JSON.stringify(context.input);
|
||||
return {
|
||||
${
|
||||
options.updatable === false
|
||||
? ""
|
||||
: `update(input) {
|
||||
${options.updateThrows ? 'throw new Error("update exploded");' : ""}
|
||||
container.textContent = "slide:" + JSON.stringify(input);
|
||||
},`
|
||||
}
|
||||
${
|
||||
options.cleanup === false
|
||||
? ""
|
||||
: `unmount() {
|
||||
globalThis.__contributionUnmounts = (globalThis.__contributionUnmounts ?? 0) + 1;
|
||||
container.textContent = "";
|
||||
},`
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
function container(): Element {
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function renderedText(element: Element): string {
|
||||
const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||
return (
|
||||
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
const source = { contributionId: "announcements.signage-slide", version: "1.0.0" };
|
||||
|
||||
describe("generic contribution loader", () => {
|
||||
it("mounts a contribution with the consumer's input, in its own isolated root", async () => {
|
||||
const element = container();
|
||||
|
||||
const mounted = await loadModuleContribution({
|
||||
container: element,
|
||||
input: { slide: 1 },
|
||||
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{color:red}" })) },
|
||||
});
|
||||
|
||||
expect(mounted.contributionId).toBe("announcements.signage-slide");
|
||||
expect(mounted.version).toBe("1.0.0");
|
||||
expect(mounted.mounted).toBe(true);
|
||||
expect(renderedText(element)).toBe('slide:{"slide":1}');
|
||||
// Same isolation as every other browser artifact (ADR 0040): the renderer's styles cannot reach
|
||||
// the consuming module's own screen around it.
|
||||
const shadowRoot = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`)?.shadowRoot;
|
||||
expect(
|
||||
shadowRoot?.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)?.textContent,
|
||||
).toContain(".slide{color:red}");
|
||||
expect(element.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("gives the renderer its own id and nothing else from the platform", async () => {
|
||||
const element = container();
|
||||
|
||||
await loadModuleContribution({
|
||||
container: element,
|
||||
input: null,
|
||||
source: { ...source, url: moduleUrl(contributionArtifact()) },
|
||||
});
|
||||
|
||||
const mounts = (
|
||||
globalThis as { __contributionMounts?: { contributionId: string; container: Element }[] }
|
||||
).__contributionMounts;
|
||||
expect(mounts?.at(-1)?.contributionId).toBe("announcements.signage-slide");
|
||||
// The isolated root, never the consumer's own element.
|
||||
expect(mounts?.at(-1)?.container).not.toBe(element);
|
||||
});
|
||||
|
||||
it("updates in place without remounting", async () => {
|
||||
const element = container();
|
||||
const before = (globalThis as { __contributionMounts?: unknown[] }).__contributionMounts
|
||||
?.length;
|
||||
|
||||
const mounted = await loadModuleContribution({
|
||||
container: element,
|
||||
input: { slide: 1 },
|
||||
source: { ...source, url: moduleUrl(contributionArtifact()) },
|
||||
});
|
||||
await mounted.update({ slide: 2 });
|
||||
|
||||
expect(renderedText(element)).toBe('slide:{"slide":2}');
|
||||
// One mount, two renders: exactly the property a long-running screen needs.
|
||||
expect(
|
||||
((globalThis as { __contributionMounts?: unknown[] }).__contributionMounts?.length ?? 0) -
|
||||
(before ?? 0),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("tells a consumer to remount when the renderer cannot update in place", async () => {
|
||||
const element = container();
|
||||
|
||||
const mounted = await loadModuleContribution({
|
||||
container: element,
|
||||
input: { slide: 1 },
|
||||
source: { ...source, url: moduleUrl(contributionArtifact({ updatable: false })) },
|
||||
});
|
||||
|
||||
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
|
||||
code: "MODULE_CONTRIBUTION_NOT_UPDATABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a throwing update as a sanitized failure", async () => {
|
||||
const element = container();
|
||||
|
||||
const mounted = await loadModuleContribution({
|
||||
container: element,
|
||||
input: { slide: 1 },
|
||||
source: { ...source, url: moduleUrl(contributionArtifact({ updateThrows: true })) },
|
||||
});
|
||||
|
||||
try {
|
||||
await mounted.update({ slide: 2 });
|
||||
expect.unreachable("expected the update to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleUiHostError);
|
||||
expect((error as ModuleUiHostError).code).toBe("MODULE_CONTRIBUTION_UPDATE_FAILED");
|
||||
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
||||
expect((error as { cause?: Error }).cause?.message).toBe("update exploded");
|
||||
}
|
||||
});
|
||||
|
||||
it("unmounts deterministically, idempotently, and removes the isolated root", async () => {
|
||||
const element = container();
|
||||
const before = (globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0;
|
||||
|
||||
const mounted = await loadModuleContribution({
|
||||
container: element,
|
||||
input: { slide: 1 },
|
||||
source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{}" })) },
|
||||
});
|
||||
await mounted.unmount();
|
||||
await mounted.unmount();
|
||||
|
||||
expect(mounted.mounted).toBe(false);
|
||||
expect(element.children).toHaveLength(0);
|
||||
expect(
|
||||
((globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0) - before,
|
||||
).toBe(1);
|
||||
await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({
|
||||
code: "MODULE_CONTRIBUTION_UNKNOWN",
|
||||
});
|
||||
});
|
||||
|
||||
it("unmounts safely when the renderer registered no cleanup", async () => {
|
||||
const element = container();
|
||||
|
||||
const mounted = await loadModuleContribution({
|
||||
container: element,
|
||||
input: null,
|
||||
source: { ...source, url: moduleUrl(contributionArtifact({ cleanup: false })) },
|
||||
});
|
||||
|
||||
await expect(mounted.unmount()).resolves.toBeUndefined();
|
||||
expect(element.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses input that is not genuinely JSON, before importing anything", async () => {
|
||||
const element = container();
|
||||
let imported = false;
|
||||
|
||||
for (const input of [
|
||||
new Date() as unknown as null,
|
||||
(() => undefined) as unknown as null,
|
||||
Number.NaN as unknown as null,
|
||||
{ nested: undefined } as unknown as null,
|
||||
]) {
|
||||
await expect(
|
||||
loadModuleContribution({
|
||||
container: element,
|
||||
input,
|
||||
importModule: async () => {
|
||||
imported = true;
|
||||
return {};
|
||||
},
|
||||
source: { ...source, url: "unused" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
|
||||
}
|
||||
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
await expect(
|
||||
loadModuleContribution({
|
||||
container: element,
|
||||
input: cyclic as never,
|
||||
source: { ...source, url: "unused" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" });
|
||||
|
||||
expect(imported).toBe(false);
|
||||
expect(element.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never echoes the consumer's domain data in a failure", async () => {
|
||||
const element = container();
|
||||
|
||||
try {
|
||||
await loadModuleContribution({
|
||||
container: element,
|
||||
input: { secretHeading: new Date() } as unknown as null,
|
||||
source: { ...source, url: "unused" },
|
||||
});
|
||||
expect.unreachable("expected the input to be refused");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleContributionError);
|
||||
expect(String((error as Error).message)).not.toContain("secretHeading");
|
||||
expect(String((error as Error).stack)).not.toContain("secretHeading");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a different contribution id",
|
||||
contributionArtifact({ id: "calendar.week" }),
|
||||
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
|
||||
],
|
||||
[
|
||||
"a different version",
|
||||
contributionArtifact({ version: "2.0.0" }),
|
||||
"MODULE_CONTRIBUTION_IDENTITY_MISMATCH",
|
||||
],
|
||||
[
|
||||
"a different contribution ABI",
|
||||
'export default { abi: 99, id: "announcements.signage-slide", version: "1.0.0", mount() {} };',
|
||||
"MODULE_CONTRIBUTION_ABI_UNSUPPORTED",
|
||||
],
|
||||
["no default export", "export const contribution = {};", "MODULE_CONTRIBUTION_EXPORT_MISSING"],
|
||||
[
|
||||
"a malformed definition",
|
||||
`export default { abi: ${MODULE_CONTRIBUTION_ABI_VERSION}, id: "announcements.signage-slide" };`,
|
||||
"MODULE_CONTRIBUTION_DEFINITION_INVALID",
|
||||
],
|
||||
])("refuses %s before mounting", async (_label, artifact, code) => {
|
||||
const element = container();
|
||||
|
||||
await expect(
|
||||
loadModuleContribution({
|
||||
container: element,
|
||||
input: null,
|
||||
source: { ...source, url: moduleUrl(artifact) },
|
||||
}),
|
||||
).rejects.toMatchObject({ code });
|
||||
expect(element.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports a throwing mount as a sanitized failure and leaves nothing behind", async () => {
|
||||
const element = container();
|
||||
|
||||
try {
|
||||
await loadModuleContribution({
|
||||
container: element,
|
||||
input: null,
|
||||
source: { ...source, url: moduleUrl(contributionArtifact({ mountThrows: true })) },
|
||||
});
|
||||
expect.unreachable("expected the mount to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModuleUiHostError);
|
||||
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED");
|
||||
expect((error as ModuleUiHostError).message).not.toContain("exploded");
|
||||
}
|
||||
expect(element.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports an unreachable artifact as an import failure with no URL in the message", async () => {
|
||||
const element = container();
|
||||
|
||||
try {
|
||||
await loadModuleContribution({
|
||||
container: element,
|
||||
input: null,
|
||||
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
|
||||
});
|
||||
expect.unreachable("expected the import to fail");
|
||||
} catch (error) {
|
||||
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
|
||||
expect((error as ModuleUiHostError).message).not.toContain("data:");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps two mounted contributions independent", async () => {
|
||||
const first = container();
|
||||
const second = container();
|
||||
const url = moduleUrl(contributionArtifact());
|
||||
|
||||
const a = await loadModuleContribution({
|
||||
container: first,
|
||||
input: { slide: "a" },
|
||||
source: { ...source, url },
|
||||
});
|
||||
const b = await loadModuleContribution({
|
||||
container: second,
|
||||
input: { slide: "b" },
|
||||
source: { ...source, url },
|
||||
});
|
||||
|
||||
await a.unmount();
|
||||
|
||||
expect(a.mounted).toBe(false);
|
||||
expect(b.mounted).toBe(true);
|
||||
expect(first.children).toHaveLength(0);
|
||||
expect(renderedText(second)).toBe('slide:{"slide":"b"}');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
MODULE_UI_ABI_VERSION,
|
||||
ModuleUiContractError,
|
||||
type ModuleUiHostContext,
|
||||
NO_MODULE_UI_CONTRIBUTIONS,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
@@ -50,6 +51,7 @@ export default {
|
||||
|
||||
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
||||
return {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
moduleId,
|
||||
api: {
|
||||
async request(path) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
loadModuleUi,
|
||||
@@ -32,6 +32,7 @@ let artifactSource: string;
|
||||
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
|
||||
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||
return {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
moduleId: "example",
|
||||
api: {
|
||||
async request(pathname) {
|
||||
@@ -48,6 +49,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
|
||||
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||
return {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
moduleId: "example",
|
||||
api: {
|
||||
async request(pathname) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* A value or a promise of one.
|
||||
*
|
||||
* In its own file rather than beside either contract that uses it: `module-ui.ts` and
|
||||
* `contribution.ts` both need it, and `module-ui.ts` reaches `host-context.ts`, which reaches
|
||||
* `contribution.ts` — so declaring it in either would close a source cycle that
|
||||
* `scripts/check-boundaries.ts` rejects, including one formed only by `import type`.
|
||||
*/
|
||||
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { Awaitable } from "./awaitable.js";
|
||||
import type { ModuleUiJsonValue } from "./json.js";
|
||||
|
||||
/**
|
||||
* The browser **contribution** contract: what a module's contributed renderer exports, and what a
|
||||
* consuming module can do with one (ADR 0041).
|
||||
*
|
||||
* A contribution is not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts a
|
||||
* *host* mounts, in surfaces the host owns. A contribution is mounted by a **consumer module**,
|
||||
* inside a container that consumer owns, at a moment only that consumer understands — a slot in its
|
||||
* own layout. The platform loads the code and validates its identity; it never decides that a
|
||||
* contribution should be on screen.
|
||||
*
|
||||
* ## The consumer owns the domain contract, entirely
|
||||
*
|
||||
* `input` is opaque JSON. The platform validates that it is JSON and nothing else: not its shape,
|
||||
* not its meaning, not its version. A consumer that provides a "slide renderer" capability publishes
|
||||
* *its own* input contract, versions it through its own capability version, and every contributor
|
||||
* writes against that. Core cannot learn what a slide is from this file, which is the point.
|
||||
*
|
||||
* ## What a contribution does not receive
|
||||
*
|
||||
* No database, no object storage, no credential, no session, no API client, and no reach into any
|
||||
* module — including the one that contributed it. Loading a contribution grants its module nothing:
|
||||
* server-side authorization is unchanged and remains the only authority. Everything a contributed
|
||||
* renderer needs arrives through the consumer's input, because the consumer is the party that was
|
||||
* authorized to obtain it.
|
||||
*
|
||||
* ## Still trusted code
|
||||
*
|
||||
* A contribution artifact is installed first-party JavaScript running in the host's realm, exactly
|
||||
* like every other module artifact (ADR 0022, ADR 0024). Its isolated root (ADR 0040) is a
|
||||
* correctness boundary for styles, not a sandbox, and this contract must never be described as one.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The version of the contribution ABI this package defines, and the only one a host mounts.
|
||||
*
|
||||
* Separate from `MODULE_UI_ABI_VERSION` on purpose: a contributed renderer and a mounted module UI
|
||||
* are different contracts with different lifecycles — one is handed a host context, the other is
|
||||
* handed a consumer's input and can be updated in place — and versioning them together would force
|
||||
* every module to rebuild whenever either moved.
|
||||
*
|
||||
* `defineModuleContribution` stamps it. An author never writes it.
|
||||
*/
|
||||
export const MODULE_CONTRIBUTION_ABI_VERSION = 1;
|
||||
|
||||
/** The consumer's own versioned input. Opaque to the platform. */
|
||||
export type ModuleContributionInput = ModuleUiJsonValue;
|
||||
|
||||
/**
|
||||
* Everything a contributed renderer is given, and the complete list of it.
|
||||
*
|
||||
* Deliberately two members. Anything else — a principal, an API client, a realtime subscription,
|
||||
* configuration — would either be authority the contribution has not earned or state the consumer
|
||||
* should be passing in its own input, where it can version it.
|
||||
*/
|
||||
export interface ModuleContributionContext {
|
||||
/** This contribution's own id, so it never re-derives its identity from anything. */
|
||||
readonly contributionId: string;
|
||||
/** The consumer's input for this mount. */
|
||||
readonly input: ModuleContributionInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a contribution's `mount` returns: how to give it new input, and how to release it.
|
||||
*
|
||||
* `update` exists so a consumer can drive a renderer through changing state — a new slide, a new
|
||||
* selection — **without** remounting. Remounting for every change would destroy DOM, restart
|
||||
* animations, and reload media on every tick, which is exactly wrong for the long-running screens
|
||||
* this contract is for. A contribution that genuinely cannot update in place omits it, and the
|
||||
* consumer remounts.
|
||||
*
|
||||
* Returning nothing at all declares that the mount acquired nothing and cannot be updated.
|
||||
*/
|
||||
export interface ModuleContributionInstance {
|
||||
readonly update?: (input: ModuleContributionInput) => Awaitable<void>;
|
||||
readonly unmount?: () => Awaitable<void>;
|
||||
}
|
||||
|
||||
/** What a module author passes to {@link defineModuleContribution}. */
|
||||
export interface ModuleContributionDefinitionInput {
|
||||
/** `<module-id>.<local-name>`, matching the manifest declaration exactly. */
|
||||
readonly id: string;
|
||||
/** This contribution's own version, matching the manifest declaration exactly. */
|
||||
readonly version: string;
|
||||
/** The artifact's compiled CSS text, installed by the host into this artifact's isolated root
|
||||
* (ADR 0040). Exactly the same mechanism a module UI artifact uses. */
|
||||
readonly styles?: string;
|
||||
/**
|
||||
* Asynchronous by contract. A renderer that has to decode media, measure a layout, or wait for a
|
||||
* font before it is honestly ready returns a promise, and the consumer's `mount` resolves only
|
||||
* once it has settled — so a consumer can sequence a transition instead of guessing.
|
||||
*/
|
||||
readonly mount: (
|
||||
container: Element,
|
||||
context: ModuleContributionContext,
|
||||
) => Awaitable<ModuleContributionInstance | void>;
|
||||
}
|
||||
|
||||
/** What a compiled contribution artifact default-exports: the author's declaration plus the ABI
|
||||
* marker `defineModuleContribution` stamped on it. */
|
||||
export interface ModuleContributionDefinition extends ModuleContributionDefinitionInput {
|
||||
readonly abi: number;
|
||||
}
|
||||
|
||||
export type ModuleContributionErrorCode =
|
||||
| "MODULE_CONTRIBUTION_EXPORT_MISSING"
|
||||
| "MODULE_CONTRIBUTION_DEFINITION_INVALID"
|
||||
| "MODULE_CONTRIBUTION_ABI_UNSUPPORTED"
|
||||
| "MODULE_CONTRIBUTION_STYLES_INVALID"
|
||||
| "MODULE_CONTRIBUTION_IDENTITY_MISMATCH"
|
||||
| "MODULE_CONTRIBUTION_INPUT_INVALID"
|
||||
| "MODULE_CONTRIBUTION_UNKNOWN"
|
||||
| "MODULE_CONTRIBUTION_NOT_UPDATABLE";
|
||||
|
||||
const errorMessages: Readonly<Record<ModuleContributionErrorCode, string>> = Object.freeze({
|
||||
MODULE_CONTRIBUTION_EXPORT_MISSING:
|
||||
"A module contribution artifact must have a default export produced by defineModuleContribution.",
|
||||
MODULE_CONTRIBUTION_DEFINITION_INVALID:
|
||||
"A module contribution definition must declare a non-empty id and version and a mount function, and nothing else.",
|
||||
MODULE_CONTRIBUTION_ABI_UNSUPPORTED:
|
||||
"This contribution artifact was built for a different contribution ABI. Rebuild the module against the current module UI SDK.",
|
||||
MODULE_CONTRIBUTION_STYLES_INVALID:
|
||||
"A module contribution's styles must be the artifact's compiled CSS text, as a non-empty string.",
|
||||
MODULE_CONTRIBUTION_IDENTITY_MISMATCH:
|
||||
"The contribution artifact's id and version do not match the contribution it was loaded for.",
|
||||
MODULE_CONTRIBUTION_INPUT_INVALID:
|
||||
"A contribution's input must be a JSON-serializable value supplied by the consuming module.",
|
||||
MODULE_CONTRIBUTION_UNKNOWN: "No contribution with that id is available to this module.",
|
||||
MODULE_CONTRIBUTION_NOT_UPDATABLE:
|
||||
"This contribution does not support updating in place. Unmount it and mount it again.",
|
||||
});
|
||||
|
||||
/** Carries a stable code and a fixed message, never the offending value, the artifact URL, or the
|
||||
* consumer's input — which is domain data and never belongs in a platform error. */
|
||||
export class ModuleContributionError extends Error {
|
||||
readonly code: ModuleContributionErrorCode;
|
||||
|
||||
constructor(code: ModuleContributionErrorCode) {
|
||||
super(errorMessages[code]);
|
||||
this.name = "ModuleContributionError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
|
||||
* `JSON.stringify` and hoping.
|
||||
*
|
||||
* A consumer's input crosses from one module's code to another's. `undefined`, a function, a
|
||||
* `Symbol`, a `Date`, a cyclic object, and a class instance all either vanish or throw when
|
||||
* serialized, and a contribution that received one would fail somewhere far from the mistake. This
|
||||
* is a bounded structural walk, so a cycle is caught rather than followed.
|
||||
*/
|
||||
export function isModuleContributionInput(value: unknown, seen = new Set<object>()): boolean {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(value);
|
||||
const entries = Array.isArray(value) ? value : Object.values(value);
|
||||
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) {
|
||||
return false;
|
||||
}
|
||||
const valid = entries.every((entry) => isModuleContributionInput(entry, seen));
|
||||
seen.delete(value);
|
||||
return valid;
|
||||
}
|
||||
|
||||
export function isModuleContributionDefinition(
|
||||
value: unknown,
|
||||
): value is ModuleContributionDefinition {
|
||||
if (!isRecord(value) || value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
|
||||
return false;
|
||||
}
|
||||
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
|
||||
return false;
|
||||
}
|
||||
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
isNonEmptyString(value.id) &&
|
||||
isNonEmptyString(value.version) &&
|
||||
typeof value.mount === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses a contribution artifact's default export. The ABI marker is checked first and on its own,
|
||||
* so an artifact built against a different contribution ABI is reported as exactly that. */
|
||||
export function parseModuleContributionDefinition(value: unknown): ModuleContributionDefinition {
|
||||
if (!isRecord(value)) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
|
||||
}
|
||||
if (value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_ABI_UNSUPPORTED");
|
||||
}
|
||||
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_STYLES_INVALID");
|
||||
}
|
||||
if (!isModuleContributionDefinition(value)) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
abi: MODULE_CONTRIBUTION_ABI_VERSION,
|
||||
id: value.id,
|
||||
mount: value.mount,
|
||||
...(value.styles === undefined ? {} : { styles: value.styles }),
|
||||
version: value.version,
|
||||
});
|
||||
}
|
||||
|
||||
/** The identity a host already knows from the contributing module's validated manifest. */
|
||||
export interface ModuleContributionIdentity {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
/** The one entry point a host uses after importing a contribution artifact. Imports nothing itself,
|
||||
* exactly like `parseModuleUiExport`, so this package stays free of any loader or URL assumption. */
|
||||
export function parseModuleContributionExport(
|
||||
namespace: unknown,
|
||||
expected: ModuleContributionIdentity,
|
||||
): ModuleContributionDefinition {
|
||||
if (!isRecord(namespace) || !("default" in namespace)) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_EXPORT_MISSING");
|
||||
}
|
||||
const definition = parseModuleContributionDefinition(namespace.default);
|
||||
if (definition.id !== expected.id || definition.version !== expected.version) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_IDENTITY_MISMATCH");
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contributing module author's entry point, mirroring `defineModuleUi`: validate at authoring
|
||||
* time, freeze, and stamp the contribution ABI this artifact was built against.
|
||||
*/
|
||||
export function defineModuleContribution(
|
||||
definition: ModuleContributionDefinitionInput,
|
||||
): ModuleContributionDefinition {
|
||||
return parseModuleContributionDefinition({
|
||||
...definition,
|
||||
abi: MODULE_CONTRIBUTION_ABI_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a **consumer** module sees of the contributions aimed at a capability it provides.
|
||||
*
|
||||
* Identity and version only. There is deliberately no artifact URL: a consumer names a contribution
|
||||
* and the host resolves it, exactly as a module names a relative API path and the host resolves that
|
||||
* (ADR 0022). A module that built `/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would
|
||||
* be welded to Ekklesia's private deployment detail and unhostable anywhere else.
|
||||
*/
|
||||
export interface ModuleContributionDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
/** The capability this renderer was contributed to — one the consuming module provides. */
|
||||
readonly capability: string;
|
||||
}
|
||||
|
||||
/** One mounted contribution, owned by the consumer that mounted it. */
|
||||
export interface MountedModuleContribution {
|
||||
readonly contributionId: string;
|
||||
readonly version: string;
|
||||
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
|
||||
readonly mounted: boolean;
|
||||
/**
|
||||
* Hands the renderer new input without remounting it.
|
||||
*
|
||||
* Rejects if the contribution declared no `update`, so a consumer learns that it must remount
|
||||
* rather than silently rendering stale content.
|
||||
*/
|
||||
update(input: ModuleContributionInput): Promise<void>;
|
||||
/** Runs the renderer's cleanup and removes its isolated root. Idempotent. */
|
||||
unmount(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MountModuleContributionOptions {
|
||||
/** The exact contribution id from a {@link ModuleContributionDescriptor}. */
|
||||
readonly contributionId: string;
|
||||
/** A container the **consumer** owns — its own slot. The host builds the renderer's isolated root
|
||||
* inside it and never reaches outside it. */
|
||||
readonly container: Element;
|
||||
/** The consumer's own versioned input. */
|
||||
readonly input: ModuleContributionInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* A consuming module's access to the renderers contributed to its own capabilities.
|
||||
*
|
||||
* The host supplies this; a module never constructs one. It can only ever see contributions aimed at
|
||||
* a capability this module actually provides, because that is the only thing the server put in its
|
||||
* descriptor — a module cannot enumerate the platform's contributions, only its own extension
|
||||
* points.
|
||||
*/
|
||||
export interface ModuleUiContributions {
|
||||
/** Contributions declared for one capability this module provides, in a stable order. Returns an
|
||||
* empty list for a capability this module does not provide, rather than throwing: a consumer
|
||||
* asking about an extension point nobody filled is the ordinary case. */
|
||||
list(capabilityId: string): readonly ModuleContributionDescriptor[];
|
||||
/** Loads, verifies, and mounts one contribution into a consumer-owned container. */
|
||||
mount(options: MountModuleContributionOptions): Promise<MountedModuleContribution>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host with no contribution mechanism supplies.
|
||||
*
|
||||
* A standalone host running exactly one module has no other module to contribute anything, so an
|
||||
* empty implementation is the *correct* one rather than a stub — which is precisely why this member
|
||||
* is always present on the context: a module writes the same code either way, and finds nothing.
|
||||
*/
|
||||
export const NO_MODULE_UI_CONTRIBUTIONS: ModuleUiContributions = Object.freeze({
|
||||
list: (): readonly ModuleContributionDescriptor[] => Object.freeze([]),
|
||||
mount: (): Promise<MountedModuleContribution> =>
|
||||
Promise.reject(new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN")),
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ModuleUiContributions } from "./contribution.js";
|
||||
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,16 @@ export interface ModuleUiHostContext {
|
||||
readonly permissions: ModuleUiPermissions;
|
||||
/** Host-owned delivery of this module's own runtime-published state. */
|
||||
readonly realtime: ModuleUiRealtimeClient;
|
||||
/**
|
||||
* The renderers other modules contributed to capabilities **this** module provides (ADR 0041).
|
||||
*
|
||||
* Present on every context, and empty for the overwhelming majority of modules — a module that
|
||||
* provides no extension point simply finds nothing in it. It is the one member that lets one
|
||||
* module's UI extend another's without either importing the other, and it deliberately exposes
|
||||
* identity and a mount operation rather than a URL: the host resolves publication, exactly as it
|
||||
* resolves `api.request`.
|
||||
*/
|
||||
readonly contributions: ModuleUiContributions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./contribution.js";
|
||||
export * from "./define-module-ui.js";
|
||||
export * from "./host-context.js";
|
||||
export * from "./json.js";
|
||||
export * from "./module-ui.js";
|
||||
export * from "./realtime.js";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* A JSON value crossing a host-owned boundary — a realtime snapshot, a contribution's input.
|
||||
*
|
||||
* Data, not vocabulary. Everything the platform carries between two pieces of module code is
|
||||
* serializable and opaque to the host: the host validates that it *is* JSON and never what it
|
||||
* means. That is what keeps a domain contract (a slide, a timeline, an announcement) entirely
|
||||
* between the two modules that share it, with nothing for Core to learn or version.
|
||||
*/
|
||||
export type ModuleUiJsonValue =
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| null
|
||||
| readonly ModuleUiJsonValue[]
|
||||
| { readonly [key: string]: ModuleUiJsonValue };
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ModuleUiHostContext } from "./host-context.js";
|
||||
|
||||
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||
export type { Awaitable } from "./awaitable.js";
|
||||
import type { Awaitable } from "./awaitable.js";
|
||||
|
||||
/**
|
||||
* The version of the browser mount ABI this package defines, and the only one a host accepts.
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { ModuleUiJsonValue } from "./json.js";
|
||||
|
||||
/**
|
||||
* A JSON value carried from a module's server runtime to its browser UI.
|
||||
*
|
||||
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
|
||||
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
|
||||
* owns all of that and gives the module only typed snapshots and updates.
|
||||
*
|
||||
* An alias of the SDK's one {@link ModuleUiJsonValue}, kept under its original name because it is
|
||||
* the published realtime contract. There is one JSON shape in this package, not one per feature.
|
||||
*/
|
||||
export type ModuleUiRealtimeJsonValue =
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| null
|
||||
| readonly ModuleUiRealtimeJsonValue[]
|
||||
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
|
||||
export type ModuleUiRealtimeJsonValue = ModuleUiJsonValue;
|
||||
|
||||
/** Connection state useful for rendering a small, honest status indicator. */
|
||||
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isModuleUiDefinition,
|
||||
MODULE_UI_ABI_VERSION,
|
||||
ModuleUiContractError,
|
||||
NO_MODULE_UI_CONTRIBUTIONS,
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiDefinition,
|
||||
type ModuleUiDefinitionInput,
|
||||
@@ -184,6 +185,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
name: "ekklesia",
|
||||
requestedUrls,
|
||||
context: {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
moduleId,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
@@ -208,6 +210,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
name: "standalone",
|
||||
requestedUrls,
|
||||
context: {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
moduleId,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
|
||||
@@ -172,7 +172,7 @@ const SHARED_PACKAGE_ROLES = new Set([
|
||||
* keeps the relaxation honest: a module gains stylesheets for its browser artifacts, not the ability
|
||||
* to scatter presentation files through its backend.
|
||||
*/
|
||||
const MODULE_BROWSER_SOURCE_ROOTS = ["src/ui/", "src/display/"];
|
||||
const MODULE_BROWSER_SOURCE_ROOTS = ["src/ui/", "src/display/", "src/contributions/"];
|
||||
|
||||
/** Whether a repository-relative path inside a module package sits in one of those roots. Module
|
||||
* packages live at `modules/<name>/…` and `templates/module/…`, both two segments deep. */
|
||||
|
||||
@@ -79,6 +79,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
config,
|
||||
descriptor,
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
...(onAuthorizationResponse
|
||||
? { onAuthorizationResponse: reportAuthorizationResponse }
|
||||
: {}),
|
||||
|
||||
@@ -159,7 +159,12 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
||||
try {
|
||||
const instance = await loadModuleUi({
|
||||
container,
|
||||
context: createModuleUiContext({ config, descriptor, principal }),
|
||||
context: createModuleUiContext({
|
||||
config,
|
||||
descriptor,
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
}),
|
||||
...(importModule ? { importModule } : {}),
|
||||
source: {
|
||||
moduleId: descriptor.id,
|
||||
|
||||
@@ -111,6 +111,21 @@ export interface HostModuleUiDescriptor {
|
||||
readonly realtimeBaseUrl: string;
|
||||
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this one provides (ADR 0041). The shell
|
||||
* hands identity to the module and resolves the URL itself; the module never sees this URL. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/** One contributed renderer this module may mount, exactly as the server described it. Mirrors the
|
||||
* server's `HostModuleContributionDescriptor` rather than importing it, for the same reason the
|
||||
* module descriptor does. */
|
||||
export interface HostModuleContributionDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic
|
||||
* contribution loader unchanged. */
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -213,6 +228,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
||||
}
|
||||
return Object.freeze({
|
||||
apiBaseUrl,
|
||||
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
|
||||
id,
|
||||
permissions: Object.freeze([...(permissions as string[])]),
|
||||
realtimeBaseUrl,
|
||||
@@ -221,6 +237,44 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict, total validation of the contribution list.
|
||||
*
|
||||
* Every entry must carry an exact key set, a well-formed identity, and a root-relative URL that
|
||||
* passes the same containment checks every other server-produced URL does — a contribution URL is a
|
||||
* thing the browser will `import()`, so it gets no weaker treatment than the module artifact URL.
|
||||
* Anything unexpected fails the whole parse rather than yielding a half-built list, because a
|
||||
* descriptor with a missing URL surfaces much later as an inexplicable mount failure.
|
||||
*/
|
||||
function parseContributions(value: unknown): readonly HostModuleContributionDescriptor[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
const descriptors = value.map((entry) => {
|
||||
if (!hasExactKeys(entry, ["capability", "id", "url", "version"])) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
const id = nonEmptyString(entry.id);
|
||||
const version = nonEmptyString(entry.version);
|
||||
const capability = nonEmptyString(entry.capability);
|
||||
const url = rootRelativePath({ url: entry.url }, "url");
|
||||
if (
|
||||
id === undefined ||
|
||||
version === undefined ||
|
||||
!isSemanticVersion(version) ||
|
||||
capability === undefined ||
|
||||
url === undefined
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({ capability, id, url, version });
|
||||
});
|
||||
if (new Set(descriptors.map((descriptor) => descriptor.id)).size !== descriptors.length) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze(descriptors);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display host's one-module response.
|
||||
*
|
||||
@@ -264,7 +318,15 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
const viewer = parseDisplayViewer(payload.viewer);
|
||||
const entry = payload.module;
|
||||
if (
|
||||
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
|
||||
!hasExactKeys(entry, [
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"id",
|
||||
"permissions",
|
||||
"realtime",
|
||||
"version",
|
||||
]) ||
|
||||
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
||||
!hasExactKeys(entry.display, ["url"]) ||
|
||||
!hasExactKeys(entry.realtime, ["baseUrl"])
|
||||
@@ -296,6 +358,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
||||
return Object.freeze({
|
||||
module: Object.freeze({
|
||||
apiBaseUrl,
|
||||
contributions: parseContributions(entry.contributions),
|
||||
id,
|
||||
permissions: Object.freeze([...(permissions as string[])]),
|
||||
realtimeBaseUrl,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type {
|
||||
ModuleUiApiClient,
|
||||
ModuleUiHostContext,
|
||||
ModuleUiPrincipal,
|
||||
ModuleUiRealtimeClient,
|
||||
ModuleUiRealtimeError,
|
||||
ModuleUiRealtimeErrorCode,
|
||||
ModuleUiRealtimeHandlers,
|
||||
ModuleUiRealtimeStatus,
|
||||
import { loadModuleContribution } from "@ekklesia/module-ui-host";
|
||||
import {
|
||||
ModuleContributionError,
|
||||
type ModuleContributionDescriptor,
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiContributions,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiPrincipal,
|
||||
type ModuleUiRealtimeClient,
|
||||
type ModuleUiRealtimeError,
|
||||
type ModuleUiRealtimeErrorCode,
|
||||
type ModuleUiRealtimeHandlers,
|
||||
type ModuleUiRealtimeStatus,
|
||||
type MountedModuleContribution,
|
||||
type MountModuleContributionOptions,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type EkklesiaHostConfig,
|
||||
@@ -263,13 +269,70 @@ export interface CreateModuleUiContextOptions {
|
||||
/** Host-only signal used by the Dashboard to revalidate an apparently expired session. It is not
|
||||
* included in the returned ModuleUiHostContext and is intentionally absent from the UI SDK. */
|
||||
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
||||
/** Test-only seam, forwarded to the generic contribution loader so a test can drive a contributed
|
||||
* renderer without a real network. Production composition leaves it unset. */
|
||||
readonly importModule?: (url: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumer-facing half of the contribution contract (ADR 0041).
|
||||
*
|
||||
* A module names a contribution id; this resolves it against the **server-produced** descriptor and
|
||||
* hands the generic loader a URL. That indirection is the whole point: a module that built
|
||||
* `/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
|
||||
* deployment detail, exactly as one building its own API prefix would be. It also means a module can
|
||||
* only ever mount a contribution the *server* decided it may — one aimed at a capability this module
|
||||
* provides, whose provider satisfies the declared compatibility range. A consumer cannot name its
|
||||
* way to anything else, because nothing else is in the descriptor.
|
||||
*/
|
||||
function createModuleContributions(
|
||||
config: EkklesiaHostConfig,
|
||||
descriptor: HostModuleUiDescriptor,
|
||||
importModule?: (url: string) => Promise<unknown>,
|
||||
): ModuleUiContributions {
|
||||
const byId = new Map(
|
||||
descriptor.contributions.map((contribution) => [contribution.id, contribution]),
|
||||
);
|
||||
const descriptors: readonly ModuleContributionDescriptor[] = Object.freeze(
|
||||
descriptor.contributions.map((contribution) =>
|
||||
Object.freeze({
|
||||
capability: contribution.capability,
|
||||
id: contribution.id,
|
||||
version: contribution.version,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
list(capabilityId: string): readonly ModuleContributionDescriptor[] {
|
||||
return Object.freeze(
|
||||
descriptors.filter((contribution) => contribution.capability === capabilityId),
|
||||
);
|
||||
},
|
||||
async mount(mountOptions: MountModuleContributionOptions): Promise<MountedModuleContribution> {
|
||||
const contribution = byId.get(mountOptions.contributionId);
|
||||
if (!contribution) {
|
||||
throw new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN");
|
||||
}
|
||||
return loadModuleContribution({
|
||||
container: mountOptions.container,
|
||||
...(importModule ? { importModule } : {}),
|
||||
input: mountOptions.input,
|
||||
source: {
|
||||
contributionId: contribution.id,
|
||||
url: resolveApiUrl(config, contribution.url),
|
||||
version: contribution.version,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time.
|
||||
*
|
||||
* All five members come from host-derived data: the module id, API root, and realtime root from the
|
||||
* inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the
|
||||
* Every member comes from host-derived data: the module id, API root, realtime root, and contribution
|
||||
* list from the inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the
|
||||
* server's evaluation of that module's declarations. Nothing is guessed, defaulted to `true`, or
|
||||
* inferred from a client-side role name.
|
||||
*
|
||||
@@ -282,6 +345,11 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
||||
const granted = new Set(options.descriptor.permissions);
|
||||
return Object.freeze({
|
||||
api: createModuleApiClient(options.config, options.descriptor, options.onAuthorizationResponse),
|
||||
contributions: createModuleContributions(
|
||||
options.config,
|
||||
options.descriptor,
|
||||
options.importModule,
|
||||
),
|
||||
moduleId: options.descriptor.id,
|
||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||
principal: options.principal,
|
||||
|
||||
@@ -47,6 +47,7 @@ function descriptorPayload(
|
||||
return {
|
||||
viewer,
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
||||
id,
|
||||
@@ -235,6 +236,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
|
||||
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
@@ -327,6 +329,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
ungrouped: [
|
||||
{
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||
id: "announcements",
|
||||
permissions: ["announcements.slideshow.view"],
|
||||
|
||||
@@ -18,6 +18,7 @@ function displayPayload(overrides: Record<string, unknown> = {}): {
|
||||
// human session (ADR 0036).
|
||||
viewer: { kind: "authenticated", userId: "user-1" },
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
||||
id: "announcements",
|
||||
@@ -43,6 +44,7 @@ describe("display host protocol — strict descriptor", () => {
|
||||
expect(parsed).toEqual({
|
||||
module: {
|
||||
apiBaseUrl: "/api/v1/modules/announcements",
|
||||
contributions: [],
|
||||
id: "announcements",
|
||||
permissions: ["announcements.slideshow.view"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
|
||||
|
||||
@@ -41,6 +41,7 @@ afterEach(async () => {
|
||||
function entry(id: string, name: string, version = "0.1.0", permissions: readonly string[] = []) {
|
||||
return {
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
id,
|
||||
permissions,
|
||||
|
||||
@@ -37,6 +37,7 @@ function stubFetch(routes: Readonly<Record<string, () => Response>>) {
|
||||
const inventoryPayload = {
|
||||
modules: [
|
||||
{
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/example" },
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
@@ -48,6 +49,7 @@ const inventoryPayload = {
|
||||
};
|
||||
|
||||
const calendarDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
@@ -68,6 +70,7 @@ const navigationPayload = {
|
||||
ungrouped: [
|
||||
{
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/calendar" },
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
@@ -81,6 +84,7 @@ const navigationPayload = {
|
||||
};
|
||||
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
@@ -403,6 +407,7 @@ describe("module UI host context", () => {
|
||||
it("gives each module only its own API root", async () => {
|
||||
const { calls, implementation } = recordingFetch();
|
||||
const calendar: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
@@ -433,13 +438,14 @@ describe("module UI host context", () => {
|
||||
expect(none.has("core.authorization.grants.manage")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes exactly the five contract members and the browser principal", () => {
|
||||
it("exposes exactly the six contract members and the browser principal", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
|
||||
const context = contextFor(implementation);
|
||||
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
|
||||
@@ -135,14 +135,15 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
||||
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
||||
}, 60_000);
|
||||
|
||||
it("leaves exactly one dynamic import — the loader's — unresolved for run time", () => {
|
||||
it("leaves exactly the generic loaders' dynamic imports unresolved for run time", () => {
|
||||
const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)];
|
||||
|
||||
// Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell
|
||||
// acquired its own loader; fewer would mean the bundler resolved it away and installed modules
|
||||
// could never be fetched at run time.
|
||||
expect(dynamicImports).toHaveLength(1);
|
||||
expect(bundle).toContain("(url) => import(url)");
|
||||
// Exactly two, both `@ekklesia/module-ui-host`'s `(url) => import(url)`: one for a module UI
|
||||
// artifact and one for a contributed renderer (ADR 0041). More would mean the shell acquired a
|
||||
// loader of its own; fewer would mean the bundler resolved one away and installed code could
|
||||
// never be fetched at run time.
|
||||
expect(dynamicImports).toHaveLength(2);
|
||||
expect(bundle.split("(url) => import(url)")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("contains no module identity and no module API prefix", () => {
|
||||
@@ -214,9 +215,10 @@ describe("the display host is a separate generic browser bundle", () => {
|
||||
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
||||
}, 60_000);
|
||||
|
||||
it("contains the generic loader's one dynamic import and no statically known module", () => {
|
||||
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(1);
|
||||
expect(bundle).toContain("(url) => import(url)");
|
||||
it("contains the generic loaders' dynamic imports and no statically known module", () => {
|
||||
// Two, both the generic loader's: a display artifact and a contributed renderer (ADR 0041).
|
||||
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(2);
|
||||
expect(bundle.split("(url) => import(url)")).toHaveLength(3);
|
||||
expect(bundle).not.toContain("ekklesia.module.json");
|
||||
expect(bundle).not.toContain("/api/v1/modules");
|
||||
for (const moduleId of ["announcements", "calendar", "livestream", "signage"]) {
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
MODULE_CONTRIBUTION_ABI_VERSION,
|
||||
MODULE_UI_ABI_VERSION,
|
||||
type ModuleContributionContext,
|
||||
type ModuleContributionInput,
|
||||
type ModuleUiHostContext,
|
||||
type MountedModuleContribution,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { act, type ReactElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../src/host-protocol.js";
|
||||
import { parseDashboardNavigation } from "../src/host-protocol.js";
|
||||
import { createModuleUiContext } from "../src/module-context.js";
|
||||
import { isolatedText } from "./support/isolated-dom.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
/**
|
||||
* The generic browser-contribution mechanism (ADR 0041), driven through exactly the relationship it
|
||||
* exists for:
|
||||
*
|
||||
* ```text
|
||||
* signage provides the capability signage.slide-renderer, and owns the input contract
|
||||
* announcements contributes a React renderer for it
|
||||
* signage mounts that renderer in a slot it owns, updates it, and unmounts it
|
||||
* ```
|
||||
*
|
||||
* Neither module imports the other, and neither imports the platform's publication routing. The
|
||||
* consumer asks the **host** what was contributed and asks the host to mount it; the URL is the
|
||||
* server's business and never reaches module code. Nothing in this file teaches Ekklesia what a
|
||||
* "slide" is: the payload is opaque JSON that travels between the two modules.
|
||||
*/
|
||||
|
||||
/** The consuming module's own published input contract — its business, never the platform's. */
|
||||
interface SlideInput extends Record<string, ModuleContributionInput> {
|
||||
readonly headline: string;
|
||||
readonly seconds: number;
|
||||
}
|
||||
|
||||
interface Recorder {
|
||||
/** Everything the consumer's artifact was told about the contribution. */
|
||||
readonly seen: unknown[];
|
||||
/** Every URL the *host* imported. The consumer never sees these. */
|
||||
readonly imported: string[];
|
||||
readonly renders: SlideInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The contributed artifact: a real React renderer, exactly as `defineModuleContribution` produces
|
||||
* (the compiled path through the canonical builder is proven in the module template's own suite).
|
||||
*/
|
||||
function contributedArtifact(recorder: Recorder, options: { readonly version?: string } = {}) {
|
||||
function Slide({ input }: { readonly input: SlideInput }): ReactElement {
|
||||
return (
|
||||
<article className="slide" data-testid="contributed-slide">
|
||||
<h2>{input.headline}</h2>
|
||||
<p>{`${input.seconds}s`}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_CONTRIBUTION_ABI_VERSION,
|
||||
id: "announcements.signage-slide",
|
||||
version: options.version ?? "1.0.0",
|
||||
styles: ".slide{color:var(--ekklesia-text-primary)}",
|
||||
mount(container: Element, context: ModuleContributionContext) {
|
||||
const root: Root = createRoot(container);
|
||||
const render = (input: ModuleContributionInput): void => {
|
||||
recorder.renders.push(input as SlideInput);
|
||||
root.render(<Slide input={input as SlideInput} />);
|
||||
};
|
||||
render(context.input);
|
||||
return {
|
||||
update(input: ModuleContributionInput) {
|
||||
render(input);
|
||||
},
|
||||
unmount() {
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The consuming module's own UI: it discovers, mounts, updates, and unmounts through the host. */
|
||||
function consumerArtifact(recorder: Recorder) {
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: "signage",
|
||||
version: "2.0.0",
|
||||
async mount(container: Element, context: ModuleUiHostContext) {
|
||||
const slot = container.ownerDocument.createElement("div");
|
||||
slot.dataset.testid = "signage-slot";
|
||||
container.append(slot);
|
||||
|
||||
// Discovery is a question to the host about this module's *own* capability. There is no
|
||||
// URL, no registry query, and no other module's name involved.
|
||||
const available = context.contributions.list("signage.slide-renderer");
|
||||
recorder.seen.push(...available.map((entry) => ({ ...entry })));
|
||||
const first = available[0];
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mounted: MountedModuleContribution = await context.contributions.mount({
|
||||
container: slot,
|
||||
contributionId: first.id,
|
||||
input: { headline: "Welcome", seconds: 7 } satisfies SlideInput,
|
||||
});
|
||||
(globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide = mounted;
|
||||
return () => {
|
||||
void mounted.unmount();
|
||||
slot.remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function descriptor(overrides: Partial<HostModuleUiDescriptor> = {}): HostModuleUiDescriptor {
|
||||
return {
|
||||
apiBaseUrl: "/api/v1/modules/signage",
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
id: "announcements.signage-slide",
|
||||
url: "/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
id: "signage",
|
||||
permissions: [],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/signage",
|
||||
uiUrl: "/api/v1/module-ui/signage/2.0.0",
|
||||
version: "2.0.0",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let recorder: Recorder;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
recorder = { imported: [], renders: [], seen: [] };
|
||||
});
|
||||
|
||||
function hostConfig(): EkklesiaHostConfig {
|
||||
return { apiBaseUrl };
|
||||
}
|
||||
|
||||
/** The host's importer, standing in for a browser `import()` of a published URL. */
|
||||
function importer(): (url: string) => Promise<unknown> {
|
||||
return async (url: string) => {
|
||||
recorder.imported.push(url);
|
||||
return contributedArtifact(recorder);
|
||||
};
|
||||
}
|
||||
|
||||
async function mountConsumer(
|
||||
contributionsOverride?: HostModuleUiDescriptor["contributions"],
|
||||
): Promise<{ unmount: () => void | Promise<void> }> {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor:
|
||||
contributionsOverride === undefined
|
||||
? descriptor()
|
||||
: descriptor({ contributions: contributionsOverride }),
|
||||
importModule: importer(),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
const consumer = consumerArtifact(recorder).default;
|
||||
let cleanup: (() => void | Promise<void>) | undefined;
|
||||
await act(async () => {
|
||||
cleanup = (await consumer.mount(container, context)) ?? undefined;
|
||||
});
|
||||
return { unmount: () => cleanup?.() };
|
||||
}
|
||||
|
||||
function mountedSlide(): MountedModuleContribution {
|
||||
const mounted = (globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide;
|
||||
if (!mounted) {
|
||||
throw new Error("the consumer did not mount a contribution");
|
||||
}
|
||||
return mounted;
|
||||
}
|
||||
|
||||
describe("a consuming module mounts another module's contributed renderer", () => {
|
||||
it("discovers, loads, and mounts it inside the consumer's own slot", async () => {
|
||||
const handle = await mountConsumer();
|
||||
|
||||
// Discovery gave the consumer identity only — no URL, no filesystem path, no module internals.
|
||||
expect(recorder.seen).toEqual([
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(recorder.seen)).not.toContain("/api/");
|
||||
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]');
|
||||
expect(isolatedText(slot)).toContain("Welcome");
|
||||
expect(isolatedText(slot)).toContain("7s");
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("resolves the publication URL in the host, never in module code", async () => {
|
||||
const handle = await mountConsumer();
|
||||
|
||||
// The host turned the server-produced descriptor path into an absolute URL. A module that built
|
||||
// this itself would be welded to Ekklesia's private deployment detail.
|
||||
expect(recorder.imported).toEqual([
|
||||
`${apiBaseUrl}/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide`,
|
||||
]);
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("updates the renderer in place, without remounting it", async () => {
|
||||
const handle = await mountConsumer();
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]');
|
||||
const beforeNode = slot?.querySelector?.('[data-testid="contributed-slide"]');
|
||||
|
||||
await act(async () => {
|
||||
await mountedSlide().update({ headline: "Second slide", seconds: 12 });
|
||||
});
|
||||
|
||||
expect(isolatedText(slot)).toContain("Second slide");
|
||||
expect(isolatedText(slot)).toContain("12s");
|
||||
// One mount, two renders — the property a long-running screen depends on.
|
||||
expect(recorder.renders).toHaveLength(2);
|
||||
expect(recorder.imported).toHaveLength(1);
|
||||
expect(slot?.querySelector?.('[data-testid="contributed-slide"]')).toBe(beforeNode);
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("isolates the contributed renderer's styles from the consumer's own screen", async () => {
|
||||
const handle = await mountConsumer();
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]') as Element;
|
||||
const shadowRoot = slot.querySelector("[data-ekklesia-module-ui]")?.shadowRoot;
|
||||
|
||||
expect(shadowRoot?.querySelector("style")?.textContent).toContain(".slide{");
|
||||
// Neither the consumer's own subtree nor the host document received the renderer's rules.
|
||||
expect(container.textContent).not.toContain(".slide{");
|
||||
expect(document.head.textContent ?? "").not.toContain(".slide{");
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("tears the renderer down when the consumer unmounts, leaving its slot empty", async () => {
|
||||
const handle = await mountConsumer();
|
||||
const slot = container.querySelector('[data-testid="signage-slot"]') as Element;
|
||||
|
||||
await act(async () => handle.unmount());
|
||||
|
||||
expect(mountedSlide().mounted).toBe(false);
|
||||
expect(slot.children).toHaveLength(0);
|
||||
expect(container.querySelector('[data-testid="signage-slot"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a contribution id the server did not offer this module", async () => {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor: descriptor(),
|
||||
importModule: importer(),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
context.contributions.mount({
|
||||
container,
|
||||
contributionId: "calendar.week-view",
|
||||
input: null,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_UNKNOWN" });
|
||||
// Nothing was fetched: a consumer cannot name its way to an artifact the server withheld.
|
||||
expect(recorder.imported).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds nothing for a capability nothing was contributed to", async () => {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor: descriptor(),
|
||||
importModule: importer(),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
|
||||
expect(context.contributions.list("signage.ticker")).toEqual([]);
|
||||
});
|
||||
|
||||
it("mounts nothing when the platform published no compatible contribution", async () => {
|
||||
// Exactly what a consumer sees when no module contributes, or when every contributor is outside
|
||||
// the declared compatible range: an empty list, and a screen that renders its own fallback.
|
||||
const handle = await mountConsumer([]);
|
||||
|
||||
expect(recorder.seen).toEqual([]);
|
||||
expect(recorder.imported).toEqual([]);
|
||||
await act(async () => handle.unmount());
|
||||
});
|
||||
|
||||
it("refuses an artifact whose declared identity does not match the descriptor", async () => {
|
||||
const context = createModuleUiContext({
|
||||
config: hostConfig(),
|
||||
descriptor: descriptor(),
|
||||
importModule: async () => contributedArtifact(recorder, { version: "9.9.9" }),
|
||||
principal: { kind: "device", deviceId: "screen-1" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
context.contributions.mount({
|
||||
container,
|
||||
contributionId: "announcements.signage-slide",
|
||||
input: { headline: "x", seconds: 1 },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_IDENTITY_MISMATCH" });
|
||||
expect(container.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a contribution out of a descriptor the shell refuses to parse", () => {
|
||||
// The wire is validated on arrival, exactly like every other server-produced URL: a contribution
|
||||
// URL is something the browser will `import()`, so it gets no weaker treatment.
|
||||
for (const url of [
|
||||
"https://evil.example.test/x.js",
|
||||
"//evil.example.test/x.js",
|
||||
"../../x.js",
|
||||
]) {
|
||||
expect(() =>
|
||||
parseDashboardNavigation({
|
||||
groups: [],
|
||||
ungrouped: [
|
||||
{
|
||||
descriptor: {
|
||||
...descriptor({
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
id: "announcements.signage-slide",
|
||||
url,
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
}),
|
||||
api: { baseUrl: "/api/v1/modules/signage" },
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/signage" },
|
||||
ui: { url: "/api/v1/module-ui/signage/2.0.0" },
|
||||
},
|
||||
name: "Signage",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { createModuleUiContext } from "../src/module-context.js";
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.state.read"],
|
||||
@@ -102,6 +103,7 @@ describe("module UI realtime host adapter", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const calendar: HostModuleUiDescriptor = {
|
||||
...exampleDescriptor,
|
||||
contributions: [],
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "example",
|
||||
"name": "Example Module",
|
||||
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
||||
@@ -35,6 +35,16 @@
|
||||
"description": "Prefix this module puts in front of a record value."
|
||||
}
|
||||
],
|
||||
"contributions": [
|
||||
{
|
||||
"id": "example.summary-card",
|
||||
"version": "1.0.0",
|
||||
"capability": "example-host.summary-card",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/summary-card/index.js",
|
||||
"description": "Renders this module's record summary inside a host module's own screen."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* The shape the **consuming** module publishes alongside its capability, restated here as this
|
||||
* module's understanding of it. Ekklesia neither defines nor validates it: a contribution's input is
|
||||
* a domain contract between two modules, versioned by the consumer's capability version, and the
|
||||
* platform carries it as opaque JSON (ADR 0041).
|
||||
*/
|
||||
export interface SummaryInput {
|
||||
readonly heading?: unknown;
|
||||
readonly count?: unknown;
|
||||
}
|
||||
|
||||
export function SummaryCard({ input }: { readonly input: SummaryInput }): ReactElement {
|
||||
const heading = typeof input.heading === "string" ? input.heading : "Example";
|
||||
const count = typeof input.count === "number" ? input.count : 0;
|
||||
|
||||
return (
|
||||
<section className="summary-card" data-testid="summary-card">
|
||||
<h2 className="summary-card__heading">{heading}</h2>
|
||||
<p className="summary-card__count">{`${count} record(s)`}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import styles from "./styles.css";
|
||||
import { SummaryCard, type SummaryInput } from "./SummaryCard.js";
|
||||
|
||||
/**
|
||||
* A **browser contribution**: a renderer this module offers to a capability *another* module
|
||||
* provides (ADR 0041).
|
||||
*
|
||||
* The manifest declares it as `example.summary-card`, targeting `example-host.summary-card`. That
|
||||
* capability belongs to whichever module provides it — this module neither imports it nor knows
|
||||
* anything about it beyond the id and the input shape that module published. If no installed module
|
||||
* provides it within the declared range, the platform simply does not publish this artifact; nothing
|
||||
* here has to notice.
|
||||
*
|
||||
* `pnpm build` compiles this file from the fixed source location the contribution id implies:
|
||||
* `src/contributions/<local-name>/index.tsx`. Module authors configure no paths.
|
||||
*/
|
||||
export default defineModuleContribution({
|
||||
id: "example.summary-card",
|
||||
version: "1.0.0",
|
||||
styles,
|
||||
mount(container, context) {
|
||||
const root: Root = createRoot(container);
|
||||
// The consumer's input is opaque to the platform and typed by the *consumer's* published
|
||||
// contract, not by Ekklesia. Validate it here rather than trusting its shape.
|
||||
root.render(<SummaryCard input={context.input as SummaryInput} />);
|
||||
return {
|
||||
// Updating in place is what lets a long-running screen change content without restarting
|
||||
// animations or reloading media on every tick.
|
||||
update(input) {
|
||||
root.render(<SummaryCard input={input as SummaryInput} />);
|
||||
},
|
||||
unmount() {
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* A contribution's stylesheet works exactly like a module UI artifact's: compiled into the declared
|
||||
* artifact as text and installed by the host into this renderer's own isolated root (ADR 0040). It
|
||||
* therefore cannot restyle the consuming module's screen around it, and that module's rules cannot
|
||||
* reach in here.
|
||||
*/
|
||||
|
||||
.summary-card {
|
||||
background: var(--ekklesia-background-surface);
|
||||
border-radius: var(--ekklesia-radius-lg);
|
||||
color: var(--ekklesia-text-primary);
|
||||
display: grid;
|
||||
font-family: var(--ekklesia-typography-family-sans);
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
padding: var(--ekklesia-spacing-lg);
|
||||
}
|
||||
|
||||
.summary-card__heading {
|
||||
font-size: var(--ekklesia-typography-size-heading-3);
|
||||
font-weight: var(--ekklesia-typography-weight-bold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-card__count {
|
||||
color: var(--ekklesia-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import {
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiHostContext,
|
||||
NO_MODULE_UI_CONTRIBUTIONS,
|
||||
parseModuleContributionExport,
|
||||
parseModuleUiExport,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
@@ -31,6 +33,9 @@ let displayArtifactPath: string;
|
||||
let manifestUi: string;
|
||||
let manifestDisplay: string;
|
||||
let manifestIdentity: { id: string; version: string };
|
||||
let contributionArtifactHref: string;
|
||||
let contributionArtifactPath: string;
|
||||
let manifestContribution: { id: string; version: string; artifact: string };
|
||||
|
||||
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
|
||||
function recordingHost(options: {
|
||||
@@ -50,6 +55,7 @@ function recordingHost(options: {
|
||||
},
|
||||
};
|
||||
return {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
moduleId: "example",
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
@@ -82,17 +88,36 @@ describe("module UI build pipeline", () => {
|
||||
manifestDisplay = manifest.entrypoints.display.artifact;
|
||||
manifestIdentity = { id: manifest.id, version: manifest.version };
|
||||
|
||||
const declaredContribution = manifest.contributions[0];
|
||||
if (!declaredContribution) {
|
||||
throw new Error("The template manifest must declare a browser contribution for this test.");
|
||||
}
|
||||
manifestContribution = {
|
||||
artifact: declaredContribution.artifact,
|
||||
id: declaredContribution.id,
|
||||
version: declaredContribution.version,
|
||||
};
|
||||
|
||||
const result = await buildModuleUi({ moduleDirectory });
|
||||
const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||
const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
|
||||
if (!uiArtifact || !displayArtifact) {
|
||||
throw new Error("The template build must produce both declared UI roles.");
|
||||
const contributionArtifact = result.artifacts.find(
|
||||
(artifact) => artifact.contributionId === declaredContribution.id,
|
||||
);
|
||||
if (!uiArtifact || !displayArtifact || !contributionArtifact) {
|
||||
throw new Error("The template build must produce every declared browser artifact.");
|
||||
}
|
||||
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
|
||||
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual([
|
||||
"ui",
|
||||
"display",
|
||||
"contribution",
|
||||
]);
|
||||
artifactPath = uiArtifact.outputFile;
|
||||
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
|
||||
displayArtifactPath = displayArtifact.outputFile;
|
||||
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
|
||||
contributionArtifactPath = contributionArtifact.outputFile;
|
||||
contributionArtifactHref = pathToFileURL(contributionArtifact.outputFile).href;
|
||||
}, 60_000);
|
||||
|
||||
it("writes both artifacts exactly where the manifest declares them", () => {
|
||||
@@ -246,3 +271,90 @@ describe("module UI build pipeline", () => {
|
||||
await (unmount as () => Promise<void>)();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The contribution half of the same loop (ADR 0041): React/TSX source -> `pnpm build` -> the
|
||||
* compiled artifact the manifest declares -> `parseModuleContributionExport` ->
|
||||
* `mount(container, { contributionId, input })` -> `update(input)` -> `unmount()`.
|
||||
*
|
||||
* The consumer here is the test, standing in for whichever module provides the targeted capability.
|
||||
* That is faithful: a consumer receives identity and calls the host's mount API, and this module
|
||||
* neither imports it nor learns anything about it.
|
||||
*/
|
||||
describe("module contribution build pipeline", () => {
|
||||
it("writes the contribution artifact exactly where the manifest declares it", () => {
|
||||
expect(manifestContribution.artifact).toBe("./dist/contributions/summary-card/index.js");
|
||||
expect(
|
||||
contributionArtifactPath.endsWith(
|
||||
path.join("dist", "contributions", "summary-card", "index.js"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("produces a self-contained artifact carrying its own identity and stylesheet", async () => {
|
||||
const namespace = (await import(contributionArtifactHref)) as unknown;
|
||||
const definition = parseModuleContributionExport(namespace, manifestContribution);
|
||||
const source = await readFile(contributionArtifactPath, "utf8");
|
||||
|
||||
expect(definition.id).toBe("example.summary-card");
|
||||
expect(definition.version).toBe("1.0.0");
|
||||
expect(definition.styles).toContain(".summary-card");
|
||||
// Self-contained: React bundled, nothing resolved at run time, no Ekklesia URL baked in.
|
||||
expect(source).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
||||
expect(source).not.toContain("/api/v1/module-ui");
|
||||
});
|
||||
|
||||
it("rejects the artifact when the consumer expects a different contribution version", async () => {
|
||||
const namespace = (await import(contributionArtifactHref)) as unknown;
|
||||
|
||||
expect(() =>
|
||||
parseModuleContributionExport(namespace, { id: "example.summary-card", version: "9.9.9" }),
|
||||
).toThrowError(expect.objectContaining({ code: "MODULE_CONTRIBUTION_IDENTITY_MISMATCH" }));
|
||||
});
|
||||
|
||||
it("mounts, updates in place, and unmounts from the consumer's own input", async () => {
|
||||
const namespace = (await import(contributionArtifactHref)) as unknown;
|
||||
const definition = parseModuleContributionExport(namespace, manifestContribution);
|
||||
const container = document.createElement("div");
|
||||
|
||||
const instance = await definition.mount(container, {
|
||||
contributionId: definition.id,
|
||||
input: { count: 2, heading: "Upcoming" },
|
||||
});
|
||||
await flush();
|
||||
expect(container.querySelector("[data-testid='summary-card']")?.textContent).toContain(
|
||||
"Upcoming",
|
||||
);
|
||||
expect(container.textContent).toContain("2 record(s)");
|
||||
|
||||
// The whole point of `update`: new content without destroying and rebuilding the DOM.
|
||||
const mountedNode = container.querySelector("[data-testid='summary-card']");
|
||||
await instance?.update?.({ count: 5, heading: "Later today" });
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Later today");
|
||||
expect(container.textContent).toContain("5 record(s)");
|
||||
expect(container.querySelector("[data-testid='summary-card']")).toBe(mountedNode);
|
||||
|
||||
await instance?.unmount?.();
|
||||
await flush();
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders a defensive default rather than trusting the consumer's input shape", async () => {
|
||||
const namespace = (await import(contributionArtifactHref)) as unknown;
|
||||
const definition = parseModuleContributionExport(namespace, manifestContribution);
|
||||
const container = document.createElement("div");
|
||||
|
||||
// The platform carries the input as opaque JSON and validates nothing about its shape, so a
|
||||
// renderer that assumed a field's type would break on a consumer's older or newer contract.
|
||||
const instance = await definition.mount(container, {
|
||||
contributionId: definition.id,
|
||||
input: { count: "many", heading: 7 },
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Example");
|
||||
expect(container.textContent).toContain("0 record(s)");
|
||||
await instance?.unmount?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"src/ui/**/*.tsx",
|
||||
"src/ui/**/*.ts",
|
||||
"src/display/**/*.tsx",
|
||||
"src/display/**/*.ts"
|
||||
"src/display/**/*.ts",
|
||||
"src/contributions/**/*.tsx",
|
||||
"src/contributions/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user