Phase 8/module platform prerequisites #40
+7
-1
@@ -39,7 +39,8 @@ pnpm infrastructure:config
|
|||||||
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
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;
|
||||||
}
|
}
|
||||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
if (entrypointName.startsWith("contribution:")) {
|
||||||
|
resolvedContributions.set(
|
||||||
|
entrypointName.slice("contribution:".length),
|
||||||
|
canonicalEntrypoint,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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(
|
||||||
if (
|
(["server", "schema", "migrations", "jobs"] as const)
|
||||||
typeof configured === "string" &&
|
.map((entrypointName) => entrypoints[entrypointName])
|
||||||
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile
|
.filter((configured): configured is string => typeof configured === "string")
|
||||||
) {
|
.map((configured) => path.resolve(moduleDirectory, configured)),
|
||||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display");
|
);
|
||||||
}
|
const claimedOutputs = new Set<string>();
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
if (
|
||||||
|
backendEntrypointPaths.has(artifact.outputFile) ||
|
||||||
|
claimedOutputs.has(artifact.outputFile)
|
||||||
|
) {
|
||||||
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind);
|
||||||
}
|
}
|
||||||
|
claimedOutputs.add(artifact.outputFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
|
// 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