feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
07835b7b56
commit
c444ff570b
+7
-1
@@ -39,7 +39,8 @@ pnpm infrastructure:config
|
|||||||
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
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
|
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
|
`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
|
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
|
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
|
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
|
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||||
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||||
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
`@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
|
- 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
|
`/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
|
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 { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-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";
|
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||||
|
|
||||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
/** 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 api: { readonly baseUrl: string };
|
||||||
readonly realtime: { readonly baseUrl: string };
|
readonly realtime: { readonly baseUrl: string };
|
||||||
readonly permissions: readonly 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,
|
viewer,
|
||||||
module: {
|
module: {
|
||||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
|
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
id: display.moduleId,
|
id: display.moduleId,
|
||||||
permissions: await grantedModulePermissionIds(
|
permissions: await grantedModulePermissionIds(
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
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 { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +14,11 @@ import {
|
|||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-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
|
* 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.
|
* nor trusts anything in this array.
|
||||||
*/
|
*/
|
||||||
readonly permissions: readonly string[];
|
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 {
|
export interface HostModuleUiInventory {
|
||||||
readonly modules: readonly HostModuleUiDescriptor[];
|
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 {
|
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||||
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||||
@@ -116,9 +168,11 @@ async function describeModule(
|
|||||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
published: PublishableModuleUi,
|
published: PublishableModuleUi,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
): Promise<HostModuleUiDescriptor> {
|
): Promise<HostModuleUiDescriptor> {
|
||||||
return {
|
return {
|
||||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
|
contributions: describeModuleContributions(registry, published.moduleId),
|
||||||
id: published.moduleId,
|
id: published.moduleId,
|
||||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
@@ -153,7 +207,7 @@ export async function describeMountableModules(
|
|||||||
): Promise<readonly MountableModuleDescription[]> {
|
): Promise<readonly MountableModuleDescription[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
registry.list().map(async (published) => ({
|
registry.list().map(async (published) => ({
|
||||||
descriptor: await describeModule(evaluator, principal, published),
|
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||||
name: published.name,
|
name: published.name,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
|||||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
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
|
* 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
|
* 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
|
* ## The request never touches the filesystem
|
||||||
*
|
*
|
||||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||||
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
|
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||||
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
* `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
|
* 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
|
* 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
|
* 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 {
|
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||||
const routes = new 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) => {
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||||
const published = options.registry.resolveDisplay(
|
const published = options.registry.resolveDisplay(
|
||||||
c.req.param("moduleId"),
|
c.req.param("moduleId"),
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ async function createGatedRouteModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -374,7 +374,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -479,7 +479,7 @@ async function createUiModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -269,6 +269,7 @@ describe("Dashboard navigation — reading", () => {
|
|||||||
|
|
||||||
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||||
expect(livestream?.module).toEqual({
|
expect(livestream?.module).toEqual({
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||||
id: "livestream",
|
id: "livestream",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -254,12 +254,13 @@ function fakeDiscoveredModule(
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
configuration: fixture.configuration ?? [],
|
configuration: fixture.configuration ?? [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: fixture.display
|
permissions: fixture.display
|
||||||
? [
|
? [
|
||||||
@@ -266,6 +266,7 @@ describe("module display routes — authorization and descriptor", () => {
|
|||||||
expect(await descriptor.json()).toEqual({
|
expect(await descriptor.json()).toEqual({
|
||||||
viewer: { kind: "authenticated", userId: viewerId },
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
display: {
|
display: {
|
||||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
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 } : {}),
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async function createFixtureModule(
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name,
|
name,
|
||||||
permissions: [
|
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.
|
// 2. The URLs came from the server, not from anything the browser composed.
|
||||||
expect(descriptor).toEqual({
|
expect(descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
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({
|
expect(bootstrap.descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read", "example.display.view"],
|
permissions: ["example.records.read", "example.display.view"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
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([
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ interface ModuleFixture {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly baseline: "authenticated" | "public" | "restricted";
|
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> {
|
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",
|
server: "./dist/server/index.js",
|
||||||
...(fixture.withUi ? { ui: "./dist/ui/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,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
...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`,
|
`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 {
|
interface InstallOptions {
|
||||||
@@ -219,6 +242,7 @@ describe("module UI inventory — what it lists", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(modules).toEqual([
|
expect(modules).toEqual([
|
||||||
{
|
{
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/example" },
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: [],
|
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.
|
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||||
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
"id",
|
"id",
|
||||||
"permissions",
|
"permissions",
|
||||||
"realtime",
|
"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,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
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 { Window } from "happy-dom";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
MODULE_UI_ASSET_PREFIX,
|
MODULE_UI_ASSET_PREFIX,
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
moduleDisplayUiArtifactUrl,
|
moduleDisplayUiArtifactUrl,
|
||||||
moduleUiArtifactUrl,
|
moduleUiArtifactUrl,
|
||||||
} from "../src/module-ui-routes.js";
|
} 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
|
* 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. */
|
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||||
readonly extraFiles?: Readonly<Record<string, string>>;
|
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> {
|
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,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
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 mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
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) {
|
if (fixture.display !== undefined) {
|
||||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
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() {
|
function hostContext() {
|
||||||
return {
|
return {
|
||||||
api: { request: async () => new Response(null, { status: 200 }) },
|
api: { request: async () => new Response(null, { status: 200 }) },
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId: "announcements",
|
moduleId: "announcements",
|
||||||
permissions: { has: () => false },
|
permissions: { has: () => false },
|
||||||
principal: { kind: "anonymous" as const },
|
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" });
|
).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;
|
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 {
|
export interface ResolvedModuleEntrypoints {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly schema?: string;
|
readonly schema?: string;
|
||||||
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
|||||||
|
|
||||||
export interface DiscoveredModule {
|
export interface DiscoveredModule {
|
||||||
readonly directory: string;
|
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 entrypoints: ResolvedModuleEntrypoints;
|
||||||
readonly manifest: ReadonlyModuleManifest;
|
readonly manifest: ReadonlyModuleManifest;
|
||||||
readonly manifestPath: string;
|
readonly manifestPath: string;
|
||||||
@@ -110,6 +126,7 @@ interface ModulePackageData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleCandidate {
|
interface ModuleCandidate {
|
||||||
|
contributions: readonly ResolvedModuleContribution[];
|
||||||
directory: string;
|
directory: string;
|
||||||
directoryName: string;
|
directoryName: string;
|
||||||
entrypoints: ResolvedModuleEntrypoints;
|
entrypoints: ResolvedModuleEntrypoints;
|
||||||
@@ -257,13 +274,19 @@ function validatePackageData(
|
|||||||
return { name, version };
|
return { name, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedModuleArtifacts {
|
||||||
|
readonly contributions: readonly ResolvedModuleContribution[];
|
||||||
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveEntrypoints(
|
async function resolveEntrypoints(
|
||||||
moduleDirectory: string,
|
moduleDirectory: string,
|
||||||
directoryName: string,
|
directoryName: string,
|
||||||
manifest: ModuleManifest,
|
manifest: ModuleManifest,
|
||||||
issues: ModuleDiscoveryIssue[],
|
issues: ModuleDiscoveryIssue[],
|
||||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
): Promise<ResolvedModuleArtifacts | undefined> {
|
||||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||||
|
const resolvedContributions = new Map<string, string>();
|
||||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||||
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
|
|||||||
issuePath: `entrypoints.${entrypointName}`,
|
issuePath: `entrypoints.${entrypointName}`,
|
||||||
relativeEntrypoint,
|
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 = [
|
const configuredEntrypoints = [
|
||||||
...ordinaryConfiguredEntrypoints,
|
...ordinaryConfiguredEntrypoints,
|
||||||
...(display === undefined
|
...(display === undefined
|
||||||
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
|
|||||||
relativeEntrypoint: display.artifact,
|
relativeEntrypoint: display.artifact,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
...configuredContributions,
|
||||||
];
|
];
|
||||||
let entrypointCollision = false;
|
let entrypointCollision = false;
|
||||||
|
|
||||||
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entrypointName.startsWith("contribution:")) {
|
||||||
|
resolvedContributions.set(
|
||||||
|
entrypointName.slice("contribution:".length),
|
||||||
|
canonicalEntrypoint,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = errorCode(error);
|
const code = errorCode(error);
|
||||||
issues.push({
|
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 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,
|
server: resolved.server,
|
||||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||||
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { contributions: Object.freeze(contributions), entrypoints };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanModuleDirectory(
|
async function scanModuleDirectory(
|
||||||
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||||
if (!entrypoints) {
|
if (!artifacts) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
contributions: artifacts.contributions,
|
||||||
directory: canonicalModuleDirectory,
|
directory: canonicalModuleDirectory,
|
||||||
directoryName,
|
directoryName,
|
||||||
entrypoints,
|
entrypoints: artifacts.entrypoints,
|
||||||
manifest,
|
manifest,
|
||||||
manifestPath: canonicalManifestPath,
|
manifestPath: canonicalManifestPath,
|
||||||
packageName: packageData.name,
|
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";
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
|
|||||||
readonly permissions: readonly PermissionDeclaration[];
|
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?"
|
* 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.
|
* a display merely because a caller appends `/display` to its publication URL.
|
||||||
*/
|
*/
|
||||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
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 {
|
): ModuleUiPublicationRegistry {
|
||||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
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) {
|
for (const module of modules) {
|
||||||
const artifactPath = module.entrypoints.ui;
|
const artifactPath = module.entrypoints.ui;
|
||||||
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
|
|||||||
|
|
||||||
const published = Object.freeze([...byModuleId.values()]);
|
const published = Object.freeze([...byModuleId.values()]);
|
||||||
const publishedDisplays = Object.freeze([...displaysByModuleId.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({
|
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,
|
list: (): readonly PublishableModuleUi[] => published,
|
||||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
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" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
|
|||||||
|
|
||||||
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/unpublished/${id}`,
|
directory: `/unpublished/${id}`,
|
||||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
|
|||||||
|
|
||||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/unpublished/${id}`,
|
directory: `/unpublished/${id}`,
|
||||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 6,
|
"manifestVersion": 7,
|
||||||
"id": "db-facade",
|
"id": "db-facade",
|
||||||
"name": "Database Facade Integration Test Module",
|
"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.",
|
"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 {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -27,6 +27,7 @@ function fakeModule(
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function manifestWith(
|
|||||||
id = "announcements",
|
id = "announcements",
|
||||||
) {
|
) {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
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", () => {
|
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
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", () => {
|
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
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", () => {
|
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
|||||||
],
|
],
|
||||||
])("rejects %s", (_label, declaration) => {
|
])("rejects %s", (_label, declaration) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
|||||||
"valueOf",
|
"valueOf",
|
||||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
|||||||
"",
|
"",
|
||||||
])("rejects the malformed configuration key %s", (key) => {
|
])("rejects the malformed configuration key %s", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
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. */
|
* by eligibility resolution. */
|
||||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
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" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -36,6 +36,7 @@ function fakeModule(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
|||||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function manifest(
|
|||||||
permissions: readonly Record<string, string>[] = [],
|
permissions: readonly Record<string, string>[] = [],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function manifestFor(
|
|||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -29,6 +29,7 @@ function fakeModule(
|
|||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ interface ModuleFixture {
|
|||||||
readonly ui?: string;
|
readonly ui?: string;
|
||||||
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
||||||
readonly display?: string;
|
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> {
|
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,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
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 mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
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
|
/** 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());
|
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
|
- [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
|
- [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
|
- [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
|
`@ekklesia/module-ui-sdk`. Since
|
||||||
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
[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
|
`.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
|
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
|
||||||
host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
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
|
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
|
(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,
|
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 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
|
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
||||||
semantics.
|
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
|
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
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
|
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
|
```json
|
||||||
{
|
{
|
||||||
"manifestVersion": 6,
|
"manifestVersion": 7,
|
||||||
"id": "calendar",
|
"id": "calendar",
|
||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"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": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/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
|
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.
|
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
|
`manifestVersion: 7` (`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
|
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.
|
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
|
Version 7 adds the optional `contributions` array
|
||||||
changes only the version literal; `ui` remains independently optional
|
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
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
|
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
|
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
`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
|
```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": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/index.js",
|
"schema": "./dist/schema/index.js",
|
||||||
@@ -271,7 +292,7 @@ artifact is the role selection.
|
|||||||
|
|
||||||
### What the host gives you
|
### 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 |
|
| 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 }` |
|
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
||||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
| `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) |
|
| `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
|
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
|
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
|
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.
|
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
|
## Runtime server definition
|
||||||
|
|
||||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
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 "./identifiers.js";
|
||||||
export * from "./identity.js";
|
export * from "./identity.js";
|
||||||
export * from "./module-configuration.js";
|
export * from "./module-configuration.js";
|
||||||
|
export * from "./module-contribution.js";
|
||||||
export * from "./module-database.js";
|
export * from "./module-database.js";
|
||||||
export * from "./module-http.js";
|
export * from "./module-http.js";
|
||||||
export * from "./module-lifecycle.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,
|
type ModuleConfigurationDeclaration,
|
||||||
moduleConfigurationDeclarationSchema,
|
moduleConfigurationDeclarationSchema,
|
||||||
} from "./module-configuration.js";
|
} from "./module-configuration.js";
|
||||||
|
import {
|
||||||
|
type ModuleContributionDeclaration,
|
||||||
|
moduleContributionDeclarationSchema,
|
||||||
|
} from "./module-contribution.js";
|
||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
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 one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`,
|
||||||
* the static display declaration is an incompatible shape change and the canonical manifest moves
|
* so the static browser-contribution declaration is an incompatible shape change and the canonical
|
||||||
* to `manifestVersion: 6` rather than silently reinterpreting version 5.
|
* manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6.
|
||||||
*
|
*
|
||||||
* Configuration is declared **statically, in this inert document**, rather than registered by
|
* 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
|
* 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
|
* 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.
|
* 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
|
* 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
|
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 5
|
||||||
* manifest document can therefore never be read as two different versions. Upgrading a version 5
|
* by version 6 (ADR 0033). One manifest document can therefore never be read as two different
|
||||||
* manifest is a one-character edit for a module that declares no display: the new field is optional.
|
* 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
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -158,6 +163,10 @@ export const moduleManifestSchema = z
|
|||||||
* module code during discovery. A module that needs nothing configured omits the field. */
|
* module code during discovery. A module that needs nothing configured omits the field. */
|
||||||
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []),
|
||||||
storage: moduleStorageSchema,
|
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,
|
entrypoints: entrypointsSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.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) => {
|
manifest.permissions.forEach((permission, index) => {
|
||||||
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||||
context.addIssue({
|
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 dependencyKind of ["required", "optional"] as const) {
|
||||||
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
||||||
if (ownedCapabilities.has(capability)) {
|
if (ownedCapabilityIds.has(capability)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "A module does not need to depend on a capability it owns.",
|
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 ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
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>;
|
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
|
||||||
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
|
||||||
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -226,15 +226,21 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
|
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 };
|
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
|
||||||
|
|
||||||
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
|
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", () => {
|
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
||||||
expect(MODULE_MANIFEST_VERSION).toBe(6);
|
expect(MODULE_MANIFEST_VERSION).toBe(7);
|
||||||
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
|
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 8, "7", 7.5, null]) {
|
||||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
@@ -243,6 +249,124 @@ describe("module manifest contract", () => {
|
|||||||
).toBe(true);
|
).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", () => {
|
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
||||||
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
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";
|
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_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
||||||
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/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";
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||||
|
|
||||||
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
/** 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_STYLESHEET_UNDECLARED"
|
||||||
| "MODULE_UI_BUILD_FAILED";
|
| "MODULE_UI_BUILD_FAILED";
|
||||||
|
|
||||||
export type ModuleUiArtifactKind = "ui" | "display";
|
export type ModuleUiArtifactKind = "ui" | "display" | "contribution";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
||||||
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
||||||
@@ -165,13 +178,17 @@ export interface BuildModuleUiOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleUiBuildResult {
|
export interface ModuleUiBuildResult {
|
||||||
/** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only
|
/** Built artifacts in stable role order: primary UI, then display, then each declared contribution
|
||||||
* modules, which makes the canonical command usable unchanged by every module package. */
|
* in manifest order. Empty for backend-only modules, which makes the canonical command usable
|
||||||
|
* unchanged by every module package. */
|
||||||
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
|
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleUiBuildArtifactResult {
|
export interface ModuleUiBuildArtifactResult {
|
||||||
readonly kind: ModuleUiArtifactKind;
|
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. */
|
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
|
||||||
readonly outputFile: string;
|
readonly outputFile: string;
|
||||||
/** Absolute path of the fixed source entrypoint that produced it. */
|
/** Absolute path of the fixed source entrypoint that produced it. */
|
||||||
@@ -203,7 +220,15 @@ async function resolveDeclaredArtifacts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const display = entrypoints.display;
|
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 {
|
const declarations: readonly {
|
||||||
|
readonly contributionId?: string;
|
||||||
readonly declared: unknown;
|
readonly declared: unknown;
|
||||||
readonly kind: ModuleUiArtifactKind;
|
readonly kind: ModuleUiArtifactKind;
|
||||||
readonly present: boolean;
|
readonly present: boolean;
|
||||||
@@ -221,6 +246,13 @@ async function resolveDeclaredArtifacts(
|
|||||||
present: display !== undefined,
|
present: display !== undefined,
|
||||||
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
|
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[] = [];
|
const artifacts: DeclaredModuleUiArtifact[] = [];
|
||||||
|
|
||||||
@@ -239,6 +271,9 @@ async function resolveDeclaredArtifacts(
|
|||||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
|
||||||
}
|
}
|
||||||
artifacts.push({
|
artifacts.push({
|
||||||
|
...(declaration.contributionId === undefined
|
||||||
|
? {}
|
||||||
|
: { contributionId: declaration.contributionId }),
|
||||||
kind: declaration.kind,
|
kind: declaration.kind,
|
||||||
outputFile: path.resolve(moduleDirectory, declared),
|
outputFile: path.resolve(moduleDirectory, declared),
|
||||||
sourceFile: path.join(moduleDirectory, declaration.source),
|
sourceFile: path.join(moduleDirectory, declaration.source),
|
||||||
@@ -249,17 +284,24 @@ async function resolveDeclaredArtifacts(
|
|||||||
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayArtifact = artifacts.find((artifact) => artifact.kind === "display");
|
// No declared artifact may overwrite another declared file — an entrypoint this build does not
|
||||||
if (displayArtifact !== undefined) {
|
// produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so
|
||||||
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
|
// a collision can never destroy output that was already correct.
|
||||||
const configured = entrypoints[entrypointName];
|
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 (
|
if (
|
||||||
typeof configured === "string" &&
|
backendEntrypointPaths.has(artifact.outputFile) ||
|
||||||
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile
|
claimedOutputs.has(artifact.outputFile)
|
||||||
) {
|
) {
|
||||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display");
|
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
|
// 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) {
|
for (const artifact of result.artifacts) {
|
||||||
process.stdout.write(
|
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;
|
return 0;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
|
|||||||
const manifest =
|
const manifest =
|
||||||
options.manifest ??
|
options.manifest ??
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 6,
|
manifestVersion: 7,
|
||||||
id: "example",
|
id: "example",
|
||||||
name: "Example",
|
name: "Example",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./isolated-root.js";
|
export * from "./isolated-root.js";
|
||||||
|
export * from "./load-module-contribution.js";
|
||||||
export * from "./load-module-ui.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 =
|
export type ModuleUiHostErrorCode =
|
||||||
| "MODULE_UI_IMPORT_FAILED"
|
| "MODULE_UI_IMPORT_FAILED"
|
||||||
| "MODULE_UI_ISOLATION_UNAVAILABLE"
|
| "MODULE_UI_ISOLATION_UNAVAILABLE"
|
||||||
| "MODULE_UI_MOUNT_FAILED";
|
| "MODULE_UI_MOUNT_FAILED"
|
||||||
|
| "MODULE_CONTRIBUTION_UPDATE_FAILED";
|
||||||
|
|
||||||
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
|
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_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
|
||||||
MODULE_UI_ISOLATION_UNAVAILABLE:
|
MODULE_UI_ISOLATION_UNAVAILABLE:
|
||||||
"A module UI artifact could not be given an isolated root in this environment.",
|
"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_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",
|
"id": "example",
|
||||||
"name": "Example",
|
"name": "Example",
|
||||||
"version": "0.1.0",
|
"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,
|
MODULE_UI_ABI_VERSION,
|
||||||
ModuleUiContractError,
|
ModuleUiContractError,
|
||||||
type ModuleUiHostContext,
|
type ModuleUiHostContext,
|
||||||
|
NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
} from "@ekklesia/module-ui-sdk";
|
} from "@ekklesia/module-ui-sdk";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ export default {
|
|||||||
|
|
||||||
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId,
|
moduleId,
|
||||||
api: {
|
api: {
|
||||||
async request(path) {
|
async request(path) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
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 { beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
loadModuleUi,
|
loadModuleUi,
|
||||||
@@ -32,6 +32,7 @@ let artifactSource: string;
|
|||||||
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
|
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
|
||||||
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
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. */
|
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
|
||||||
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
function standaloneContext(requested: string[]): ModuleUiHostContext {
|
||||||
return {
|
return {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api: {
|
api: {
|
||||||
async request(pathname) {
|
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";
|
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +84,16 @@ export interface ModuleUiHostContext {
|
|||||||
readonly permissions: ModuleUiPermissions;
|
readonly permissions: ModuleUiPermissions;
|
||||||
/** Host-owned delivery of this module's own runtime-published state. */
|
/** Host-owned delivery of this module's own runtime-published state. */
|
||||||
readonly realtime: ModuleUiRealtimeClient;
|
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 "./define-module-ui.js";
|
||||||
export * from "./host-context.js";
|
export * from "./host-context.js";
|
||||||
|
export * from "./json.js";
|
||||||
export * from "./module-ui.js";
|
export * from "./module-ui.js";
|
||||||
export * from "./realtime.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";
|
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.
|
* 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.
|
* 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,
|
* 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
|
* 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.
|
* 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 =
|
export type ModuleUiRealtimeJsonValue = ModuleUiJsonValue;
|
||||||
| boolean
|
|
||||||
| number
|
|
||||||
| string
|
|
||||||
| null
|
|
||||||
| readonly ModuleUiRealtimeJsonValue[]
|
|
||||||
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
|
|
||||||
|
|
||||||
/** Connection state useful for rendering a small, honest status indicator. */
|
/** Connection state useful for rendering a small, honest status indicator. */
|
||||||
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
|
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isModuleUiDefinition,
|
isModuleUiDefinition,
|
||||||
MODULE_UI_ABI_VERSION,
|
MODULE_UI_ABI_VERSION,
|
||||||
ModuleUiContractError,
|
ModuleUiContractError,
|
||||||
|
NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
type ModuleUiApiClient,
|
type ModuleUiApiClient,
|
||||||
type ModuleUiDefinition,
|
type ModuleUiDefinition,
|
||||||
type ModuleUiDefinitionInput,
|
type ModuleUiDefinitionInput,
|
||||||
@@ -184,6 +185,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
name: "ekklesia",
|
name: "ekklesia",
|
||||||
requestedUrls,
|
requestedUrls,
|
||||||
context: {
|
context: {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId,
|
moduleId,
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
@@ -208,6 +210,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
|||||||
name: "standalone",
|
name: "standalone",
|
||||||
requestedUrls,
|
requestedUrls,
|
||||||
context: {
|
context: {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId,
|
moduleId,
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
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
|
* keeps the relaxation honest: a module gains stylesheets for its browser artifacts, not the ability
|
||||||
* to scatter presentation files through its backend.
|
* 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
|
/** 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. */
|
* packages live at `modules/<name>/…` and `templates/module/…`, both two segments deep. */
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
|||||||
config,
|
config,
|
||||||
descriptor,
|
descriptor,
|
||||||
principal,
|
principal,
|
||||||
|
...(importModule ? { importModule } : {}),
|
||||||
...(onAuthorizationResponse
|
...(onAuthorizationResponse
|
||||||
? { onAuthorizationResponse: reportAuthorizationResponse }
|
? { onAuthorizationResponse: reportAuthorizationResponse }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -159,7 +159,12 @@ function DisplayUiAttempt(props: DisplayUiAttemptProps): ReactElement {
|
|||||||
try {
|
try {
|
||||||
const instance = await loadModuleUi({
|
const instance = await loadModuleUi({
|
||||||
container,
|
container,
|
||||||
context: createModuleUiContext({ config, descriptor, principal }),
|
context: createModuleUiContext({
|
||||||
|
config,
|
||||||
|
descriptor,
|
||||||
|
principal,
|
||||||
|
...(importModule ? { importModule } : {}),
|
||||||
|
}),
|
||||||
...(importModule ? { importModule } : {}),
|
...(importModule ? { importModule } : {}),
|
||||||
source: {
|
source: {
|
||||||
moduleId: descriptor.id,
|
moduleId: descriptor.id,
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ export interface HostModuleUiDescriptor {
|
|||||||
readonly realtimeBaseUrl: string;
|
readonly realtimeBaseUrl: string;
|
||||||
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
|
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
|
||||||
readonly permissions: readonly string[];
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -213,6 +228,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
|
|||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
|
contributions: parseContributions(isRecord(entry) ? entry.contributions : undefined),
|
||||||
id,
|
id,
|
||||||
permissions: Object.freeze([...(permissions as string[])]),
|
permissions: Object.freeze([...(permissions as string[])]),
|
||||||
realtimeBaseUrl,
|
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.
|
* The display host's one-module response.
|
||||||
*
|
*
|
||||||
@@ -264,7 +318,15 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
|||||||
const viewer = parseDisplayViewer(payload.viewer);
|
const viewer = parseDisplayViewer(payload.viewer);
|
||||||
const entry = payload.module;
|
const entry = payload.module;
|
||||||
if (
|
if (
|
||||||
!hasExactKeys(entry, ["api", "display", "id", "permissions", "realtime", "version"]) ||
|
!hasExactKeys(entry, [
|
||||||
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
|
"id",
|
||||||
|
"permissions",
|
||||||
|
"realtime",
|
||||||
|
"version",
|
||||||
|
]) ||
|
||||||
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
!hasExactKeys(entry.api, ["baseUrl"]) ||
|
||||||
!hasExactKeys(entry.display, ["url"]) ||
|
!hasExactKeys(entry.display, ["url"]) ||
|
||||||
!hasExactKeys(entry.realtime, ["baseUrl"])
|
!hasExactKeys(entry.realtime, ["baseUrl"])
|
||||||
@@ -296,6 +358,7 @@ export function parseHostModuleDisplay(payload: unknown): HostModuleDisplayRespo
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
module: Object.freeze({
|
module: Object.freeze({
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
|
contributions: parseContributions(entry.contributions),
|
||||||
id,
|
id,
|
||||||
permissions: Object.freeze([...(permissions as string[])]),
|
permissions: Object.freeze([...(permissions as string[])]),
|
||||||
realtimeBaseUrl,
|
realtimeBaseUrl,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import type {
|
import { loadModuleContribution } from "@ekklesia/module-ui-host";
|
||||||
ModuleUiApiClient,
|
import {
|
||||||
ModuleUiHostContext,
|
ModuleContributionError,
|
||||||
ModuleUiPrincipal,
|
type ModuleContributionDescriptor,
|
||||||
ModuleUiRealtimeClient,
|
type ModuleUiApiClient,
|
||||||
ModuleUiRealtimeError,
|
type ModuleUiContributions,
|
||||||
ModuleUiRealtimeErrorCode,
|
type ModuleUiHostContext,
|
||||||
ModuleUiRealtimeHandlers,
|
type ModuleUiPrincipal,
|
||||||
ModuleUiRealtimeStatus,
|
type ModuleUiRealtimeClient,
|
||||||
|
type ModuleUiRealtimeError,
|
||||||
|
type ModuleUiRealtimeErrorCode,
|
||||||
|
type ModuleUiRealtimeHandlers,
|
||||||
|
type ModuleUiRealtimeStatus,
|
||||||
|
type MountedModuleContribution,
|
||||||
|
type MountModuleContributionOptions,
|
||||||
} from "@ekklesia/module-ui-sdk";
|
} from "@ekklesia/module-ui-sdk";
|
||||||
import {
|
import {
|
||||||
type EkklesiaHostConfig,
|
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
|
/** 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. */
|
* included in the returned ModuleUiHostContext and is intentionally absent from the UI SDK. */
|
||||||
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
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.
|
* 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
|
* Every member comes from host-derived data: the module id, API root, realtime root, and contribution
|
||||||
* inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the
|
* 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
|
* server's evaluation of that module's declarations. Nothing is guessed, defaulted to `true`, or
|
||||||
* inferred from a client-side role name.
|
* inferred from a client-side role name.
|
||||||
*
|
*
|
||||||
@@ -282,6 +345,11 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
|||||||
const granted = new Set(options.descriptor.permissions);
|
const granted = new Set(options.descriptor.permissions);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
api: createModuleApiClient(options.config, options.descriptor, options.onAuthorizationResponse),
|
api: createModuleApiClient(options.config, options.descriptor, options.onAuthorizationResponse),
|
||||||
|
contributions: createModuleContributions(
|
||||||
|
options.config,
|
||||||
|
options.descriptor,
|
||||||
|
options.importModule,
|
||||||
|
),
|
||||||
moduleId: options.descriptor.id,
|
moduleId: options.descriptor.id,
|
||||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||||
principal: options.principal,
|
principal: options.principal,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ function descriptorPayload(
|
|||||||
return {
|
return {
|
||||||
viewer,
|
viewer,
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
|
||||||
id,
|
id,
|
||||||
@@ -235,6 +236,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
|
|
||||||
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
@@ -327,6 +329,7 @@ describe("display host — full-viewport boundary", () => {
|
|||||||
ungrouped: [
|
ungrouped: [
|
||||||
{
|
{
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
permissions: ["announcements.slideshow.view"],
|
permissions: ["announcements.slideshow.view"],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function displayPayload(overrides: Record<string, unknown> = {}): {
|
|||||||
// human session (ADR 0036).
|
// human session (ADR 0036).
|
||||||
viewer: { kind: "authenticated", userId: "user-1" },
|
viewer: { kind: "authenticated", userId: "user-1" },
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/announcements" },
|
api: { baseUrl: "/api/v1/modules/announcements" },
|
||||||
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
display: { url: "/api/v1/module-ui/announcements/1.2.0/display" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
@@ -43,6 +44,7 @@ describe("display host protocol — strict descriptor", () => {
|
|||||||
expect(parsed).toEqual({
|
expect(parsed).toEqual({
|
||||||
module: {
|
module: {
|
||||||
apiBaseUrl: "/api/v1/modules/announcements",
|
apiBaseUrl: "/api/v1/modules/announcements",
|
||||||
|
contributions: [],
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
permissions: ["announcements.slideshow.view"],
|
permissions: ["announcements.slideshow.view"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/announcements",
|
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[] = []) {
|
function entry(id: string, name: string, version = "0.1.0", permissions: readonly string[] = []) {
|
||||||
return {
|
return {
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function stubFetch(routes: Readonly<Record<string, () => Response>>) {
|
|||||||
const inventoryPayload = {
|
const inventoryPayload = {
|
||||||
modules: [
|
modules: [
|
||||||
{
|
{
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/example" },
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
@@ -48,6 +49,7 @@ const inventoryPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calendarDescriptor: HostModuleUiDescriptor = {
|
const calendarDescriptor: HostModuleUiDescriptor = {
|
||||||
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/calendar",
|
apiBaseUrl: "/api/v1/modules/calendar",
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -68,6 +70,7 @@ const navigationPayload = {
|
|||||||
ungrouped: [
|
ungrouped: [
|
||||||
{
|
{
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/calendar" },
|
api: { baseUrl: "/api/v1/modules/calendar" },
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -81,6 +84,7 @@ const navigationPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||||
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
@@ -403,6 +407,7 @@ describe("module UI host context", () => {
|
|||||||
it("gives each module only its own API root", async () => {
|
it("gives each module only its own API root", async () => {
|
||||||
const { calls, implementation } = recordingFetch();
|
const { calls, implementation } = recordingFetch();
|
||||||
const calendar: HostModuleUiDescriptor = {
|
const calendar: HostModuleUiDescriptor = {
|
||||||
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/calendar",
|
apiBaseUrl: "/api/v1/modules/calendar",
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -433,13 +438,14 @@ describe("module UI host context", () => {
|
|||||||
expect(none.has("core.authorization.grants.manage")).toBe(false);
|
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 { implementation } = recordingFetch();
|
||||||
|
|
||||||
const context = contextFor(implementation);
|
const context = contextFor(implementation);
|
||||||
|
|
||||||
expect(Object.keys(context).sort()).toEqual([
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
|
|||||||
@@ -135,14 +135,15 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
|||||||
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
||||||
}, 60_000);
|
}, 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)];
|
const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)];
|
||||||
|
|
||||||
// Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell
|
// Exactly two, both `@ekklesia/module-ui-host`'s `(url) => import(url)`: one for a module UI
|
||||||
// acquired its own loader; fewer would mean the bundler resolved it away and installed modules
|
// artifact and one for a contributed renderer (ADR 0041). More would mean the shell acquired a
|
||||||
// could never be fetched at run time.
|
// loader of its own; fewer would mean the bundler resolved one away and installed code could
|
||||||
expect(dynamicImports).toHaveLength(1);
|
// never be fetched at run time.
|
||||||
expect(bundle).toContain("(url) => import(url)");
|
expect(dynamicImports).toHaveLength(2);
|
||||||
|
expect(bundle.split("(url) => import(url)")).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("contains no module identity and no module API prefix", () => {
|
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");
|
bundle = result.outputFiles.map((file) => file.text).join("\n");
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("contains the generic loader's one dynamic import and no statically known module", () => {
|
it("contains the generic loaders' dynamic imports and no statically known module", () => {
|
||||||
expect([...bundle.matchAll(/\bimport\s*\(/gu)]).toHaveLength(1);
|
// Two, both the generic loader's: a display artifact and a contributed renderer (ADR 0041).
|
||||||
expect(bundle).toContain("(url) => import(url)");
|
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("ekklesia.module.json");
|
||||||
expect(bundle).not.toContain("/api/v1/modules");
|
expect(bundle).not.toContain("/api/v1/modules");
|
||||||
for (const moduleId of ["announcements", "calendar", "livestream", "signage"]) {
|
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 apiBaseUrl = "https://ekklesia.example.test";
|
||||||
|
|
||||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||||
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.state.read"],
|
permissions: ["example.state.read"],
|
||||||
@@ -102,6 +103,7 @@ describe("module UI realtime host adapter", () => {
|
|||||||
const harness = eventSourceHarness();
|
const harness = eventSourceHarness();
|
||||||
const calendar: HostModuleUiDescriptor = {
|
const calendar: HostModuleUiDescriptor = {
|
||||||
...exampleDescriptor,
|
...exampleDescriptor,
|
||||||
|
contributions: [],
|
||||||
apiBaseUrl: "/api/v1/modules/calendar",
|
apiBaseUrl: "/api/v1/modules/calendar",
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 6,
|
"manifestVersion": 7,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
"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."
|
"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": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js",
|
"server": "./dist/server/index.js",
|
||||||
"schema": "./dist/schema/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 {
|
import {
|
||||||
type ModuleUiApiClient,
|
type ModuleUiApiClient,
|
||||||
type ModuleUiHostContext,
|
type ModuleUiHostContext,
|
||||||
|
NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
parseModuleContributionExport,
|
||||||
parseModuleUiExport,
|
parseModuleUiExport,
|
||||||
} from "@ekklesia/module-ui-sdk";
|
} from "@ekklesia/module-ui-sdk";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
@@ -31,6 +33,9 @@ let displayArtifactPath: string;
|
|||||||
let manifestUi: string;
|
let manifestUi: string;
|
||||||
let manifestDisplay: string;
|
let manifestDisplay: string;
|
||||||
let manifestIdentity: { id: string; version: 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. */
|
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
|
||||||
function recordingHost(options: {
|
function recordingHost(options: {
|
||||||
@@ -50,6 +55,7 @@ function recordingHost(options: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
api,
|
api,
|
||||||
principal: { kind: "anonymous" },
|
principal: { kind: "anonymous" },
|
||||||
@@ -82,17 +88,36 @@ describe("module UI build pipeline", () => {
|
|||||||
manifestDisplay = manifest.entrypoints.display.artifact;
|
manifestDisplay = manifest.entrypoints.display.artifact;
|
||||||
manifestIdentity = { id: manifest.id, version: manifest.version };
|
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 result = await buildModuleUi({ moduleDirectory });
|
||||||
const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||||
const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
|
const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
|
||||||
if (!uiArtifact || !displayArtifact) {
|
const contributionArtifact = result.artifacts.find(
|
||||||
throw new Error("The template build must produce both declared UI roles.");
|
(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;
|
artifactPath = uiArtifact.outputFile;
|
||||||
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
|
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
|
||||||
displayArtifactPath = displayArtifact.outputFile;
|
displayArtifactPath = displayArtifact.outputFile;
|
||||||
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
|
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
|
||||||
|
contributionArtifactPath = contributionArtifact.outputFile;
|
||||||
|
contributionArtifactHref = pathToFileURL(contributionArtifact.outputFile).href;
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("writes both artifacts exactly where the manifest declares them", () => {
|
it("writes both artifacts exactly where the manifest declares them", () => {
|
||||||
@@ -246,3 +271,90 @@ describe("module UI build pipeline", () => {
|
|||||||
await (unmount as () => Promise<void>)();
|
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/**/*.tsx",
|
||||||
"src/ui/**/*.ts",
|
"src/ui/**/*.ts",
|
||||||
"src/display/**/*.tsx",
|
"src/display/**/*.tsx",
|
||||||
"src/display/**/*.ts"
|
"src/display/**/*.ts",
|
||||||
|
"src/contributions/**/*.tsx",
|
||||||
|
"src/contributions/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user