feat: let one module contribute a browser renderer to another's capability

The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.

Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.

Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.

A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.

The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.

The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.

The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-27 07:47:10 -05:00
co-authored by Claude Opus 5
parent 07835b7b56
commit c444ff570b
79 changed files with 3317 additions and 137 deletions
+9
View File
@@ -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>;
+339
View File
@@ -0,0 +1,339 @@
import type { Awaitable } from "./awaitable.js";
import type { ModuleUiJsonValue } from "./json.js";
/**
* The browser **contribution** contract: what a module's contributed renderer exports, and what a
* consuming module can do with one (ADR 0041).
*
* A contribution is not a third UI role. `entrypoints.ui` and `entrypoints.display` are artifacts a
* *host* mounts, in surfaces the host owns. A contribution is mounted by a **consumer module**,
* inside a container that consumer owns, at a moment only that consumer understands — a slot in its
* own layout. The platform loads the code and validates its identity; it never decides that a
* contribution should be on screen.
*
* ## The consumer owns the domain contract, entirely
*
* `input` is opaque JSON. The platform validates that it is JSON and nothing else: not its shape,
* not its meaning, not its version. A consumer that provides a "slide renderer" capability publishes
* *its own* input contract, versions it through its own capability version, and every contributor
* writes against that. Core cannot learn what a slide is from this file, which is the point.
*
* ## What a contribution does not receive
*
* No database, no object storage, no credential, no session, no API client, and no reach into any
* module — including the one that contributed it. Loading a contribution grants its module nothing:
* server-side authorization is unchanged and remains the only authority. Everything a contributed
* renderer needs arrives through the consumer's input, because the consumer is the party that was
* authorized to obtain it.
*
* ## Still trusted code
*
* A contribution artifact is installed first-party JavaScript running in the host's realm, exactly
* like every other module artifact (ADR 0022, ADR 0024). Its isolated root (ADR 0040) is a
* correctness boundary for styles, not a sandbox, and this contract must never be described as one.
*/
/**
* The version of the contribution ABI this package defines, and the only one a host mounts.
*
* Separate from `MODULE_UI_ABI_VERSION` on purpose: a contributed renderer and a mounted module UI
* are different contracts with different lifecycles — one is handed a host context, the other is
* handed a consumer's input and can be updated in place — and versioning them together would force
* every module to rebuild whenever either moved.
*
* `defineModuleContribution` stamps it. An author never writes it.
*/
export const MODULE_CONTRIBUTION_ABI_VERSION = 1;
/** The consumer's own versioned input. Opaque to the platform. */
export type ModuleContributionInput = ModuleUiJsonValue;
/**
* Everything a contributed renderer is given, and the complete list of it.
*
* Deliberately two members. Anything else — a principal, an API client, a realtime subscription,
* configuration — would either be authority the contribution has not earned or state the consumer
* should be passing in its own input, where it can version it.
*/
export interface ModuleContributionContext {
/** This contribution's own id, so it never re-derives its identity from anything. */
readonly contributionId: string;
/** The consumer's input for this mount. */
readonly input: ModuleContributionInput;
}
/**
* What a contribution's `mount` returns: how to give it new input, and how to release it.
*
* `update` exists so a consumer can drive a renderer through changing state — a new slide, a new
* selection — **without** remounting. Remounting for every change would destroy DOM, restart
* animations, and reload media on every tick, which is exactly wrong for the long-running screens
* this contract is for. A contribution that genuinely cannot update in place omits it, and the
* consumer remounts.
*
* Returning nothing at all declares that the mount acquired nothing and cannot be updated.
*/
export interface ModuleContributionInstance {
readonly update?: (input: ModuleContributionInput) => Awaitable<void>;
readonly unmount?: () => Awaitable<void>;
}
/** What a module author passes to {@link defineModuleContribution}. */
export interface ModuleContributionDefinitionInput {
/** `<module-id>.<local-name>`, matching the manifest declaration exactly. */
readonly id: string;
/** This contribution's own version, matching the manifest declaration exactly. */
readonly version: string;
/** The artifact's compiled CSS text, installed by the host into this artifact's isolated root
* (ADR 0040). Exactly the same mechanism a module UI artifact uses. */
readonly styles?: string;
/**
* Asynchronous by contract. A renderer that has to decode media, measure a layout, or wait for a
* font before it is honestly ready returns a promise, and the consumer's `mount` resolves only
* once it has settled — so a consumer can sequence a transition instead of guessing.
*/
readonly mount: (
container: Element,
context: ModuleContributionContext,
) => Awaitable<ModuleContributionInstance | void>;
}
/** What a compiled contribution artifact default-exports: the author's declaration plus the ABI
* marker `defineModuleContribution` stamped on it. */
export interface ModuleContributionDefinition extends ModuleContributionDefinitionInput {
readonly abi: number;
}
export type ModuleContributionErrorCode =
| "MODULE_CONTRIBUTION_EXPORT_MISSING"
| "MODULE_CONTRIBUTION_DEFINITION_INVALID"
| "MODULE_CONTRIBUTION_ABI_UNSUPPORTED"
| "MODULE_CONTRIBUTION_STYLES_INVALID"
| "MODULE_CONTRIBUTION_IDENTITY_MISMATCH"
| "MODULE_CONTRIBUTION_INPUT_INVALID"
| "MODULE_CONTRIBUTION_UNKNOWN"
| "MODULE_CONTRIBUTION_NOT_UPDATABLE";
const errorMessages: Readonly<Record<ModuleContributionErrorCode, string>> = Object.freeze({
MODULE_CONTRIBUTION_EXPORT_MISSING:
"A module contribution artifact must have a default export produced by defineModuleContribution.",
MODULE_CONTRIBUTION_DEFINITION_INVALID:
"A module contribution definition must declare a non-empty id and version and a mount function, and nothing else.",
MODULE_CONTRIBUTION_ABI_UNSUPPORTED:
"This contribution artifact was built for a different contribution ABI. Rebuild the module against the current module UI SDK.",
MODULE_CONTRIBUTION_STYLES_INVALID:
"A module contribution's styles must be the artifact's compiled CSS text, as a non-empty string.",
MODULE_CONTRIBUTION_IDENTITY_MISMATCH:
"The contribution artifact's id and version do not match the contribution it was loaded for.",
MODULE_CONTRIBUTION_INPUT_INVALID:
"A contribution's input must be a JSON-serializable value supplied by the consuming module.",
MODULE_CONTRIBUTION_UNKNOWN: "No contribution with that id is available to this module.",
MODULE_CONTRIBUTION_NOT_UPDATABLE:
"This contribution does not support updating in place. Unmount it and mount it again.",
});
/** Carries a stable code and a fixed message, never the offending value, the artifact URL, or the
* consumer's input — which is domain data and never belongs in a platform error. */
export class ModuleContributionError extends Error {
readonly code: ModuleContributionErrorCode;
constructor(code: ModuleContributionErrorCode) {
super(errorMessages[code]);
this.name = "ModuleContributionError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
const definitionKeys = new Set(["abi", "id", "mount", "styles", "version"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
* `JSON.stringify` and hoping.
*
* A consumer's input crosses from one module's code to another's. `undefined`, a function, a
* `Symbol`, a `Date`, a cyclic object, and a class instance all either vanish or throw when
* serialized, and a contribution that received one would fail somewhere far from the mistake. This
* is a bounded structural walk, so a cycle is caught rather than followed.
*/
export function isModuleContributionInput(value: unknown, seen = new Set<object>()): boolean {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return true;
}
if (typeof value === "number") {
return Number.isFinite(value);
}
if (typeof value !== "object") {
return false;
}
if (seen.has(value)) {
return false;
}
seen.add(value);
const entries = Array.isArray(value) ? value : Object.values(value);
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) {
return false;
}
const valid = entries.every((entry) => isModuleContributionInput(entry, seen));
seen.delete(value);
return valid;
}
export function isModuleContributionDefinition(
value: unknown,
): value is ModuleContributionDefinition {
if (!isRecord(value) || value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
return false;
}
if (Object.keys(value).some((key) => !definitionKeys.has(key))) {
return false;
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
return false;
}
return (
isNonEmptyString(value.id) &&
isNonEmptyString(value.version) &&
typeof value.mount === "function"
);
}
/** Parses a contribution artifact's default export. The ABI marker is checked first and on its own,
* so an artifact built against a different contribution ABI is reported as exactly that. */
export function parseModuleContributionDefinition(value: unknown): ModuleContributionDefinition {
if (!isRecord(value)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
}
if (value.abi !== MODULE_CONTRIBUTION_ABI_VERSION) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_ABI_UNSUPPORTED");
}
if (value.styles !== undefined && !isNonEmptyString(value.styles)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_STYLES_INVALID");
}
if (!isModuleContributionDefinition(value)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_DEFINITION_INVALID");
}
return Object.freeze({
abi: MODULE_CONTRIBUTION_ABI_VERSION,
id: value.id,
mount: value.mount,
...(value.styles === undefined ? {} : { styles: value.styles }),
version: value.version,
});
}
/** The identity a host already knows from the contributing module's validated manifest. */
export interface ModuleContributionIdentity {
readonly id: string;
readonly version: string;
}
/** The one entry point a host uses after importing a contribution artifact. Imports nothing itself,
* exactly like `parseModuleUiExport`, so this package stays free of any loader or URL assumption. */
export function parseModuleContributionExport(
namespace: unknown,
expected: ModuleContributionIdentity,
): ModuleContributionDefinition {
if (!isRecord(namespace) || !("default" in namespace)) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_EXPORT_MISSING");
}
const definition = parseModuleContributionDefinition(namespace.default);
if (definition.id !== expected.id || definition.version !== expected.version) {
throw new ModuleContributionError("MODULE_CONTRIBUTION_IDENTITY_MISMATCH");
}
return definition;
}
/**
* The contributing module author's entry point, mirroring `defineModuleUi`: validate at authoring
* time, freeze, and stamp the contribution ABI this artifact was built against.
*/
export function defineModuleContribution(
definition: ModuleContributionDefinitionInput,
): ModuleContributionDefinition {
return parseModuleContributionDefinition({
...definition,
abi: MODULE_CONTRIBUTION_ABI_VERSION,
});
}
/**
* What a **consumer** module sees of the contributions aimed at a capability it provides.
*
* Identity and version only. There is deliberately no artifact URL: a consumer names a contribution
* and the host resolves it, exactly as a module names a relative API path and the host resolves that
* (ADR 0022). A module that built `/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would
* be welded to Ekklesia's private deployment detail and unhostable anywhere else.
*/
export interface ModuleContributionDescriptor {
readonly id: string;
readonly version: string;
/** The capability this renderer was contributed to — one the consuming module provides. */
readonly capability: string;
}
/** One mounted contribution, owned by the consumer that mounted it. */
export interface MountedModuleContribution {
readonly contributionId: string;
readonly version: string;
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
readonly mounted: boolean;
/**
* Hands the renderer new input without remounting it.
*
* Rejects if the contribution declared no `update`, so a consumer learns that it must remount
* rather than silently rendering stale content.
*/
update(input: ModuleContributionInput): Promise<void>;
/** Runs the renderer's cleanup and removes its isolated root. Idempotent. */
unmount(): Promise<void>;
}
export interface MountModuleContributionOptions {
/** The exact contribution id from a {@link ModuleContributionDescriptor}. */
readonly contributionId: string;
/** A container the **consumer** owns — its own slot. The host builds the renderer's isolated root
* inside it and never reaches outside it. */
readonly container: Element;
/** The consumer's own versioned input. */
readonly input: ModuleContributionInput;
}
/**
* A consuming module's access to the renderers contributed to its own capabilities.
*
* The host supplies this; a module never constructs one. It can only ever see contributions aimed at
* a capability this module actually provides, because that is the only thing the server put in its
* descriptor — a module cannot enumerate the platform's contributions, only its own extension
* points.
*/
export interface ModuleUiContributions {
/** Contributions declared for one capability this module provides, in a stable order. Returns an
* empty list for a capability this module does not provide, rather than throwing: a consumer
* asking about an extension point nobody filled is the ordinary case. */
list(capabilityId: string): readonly ModuleContributionDescriptor[];
/** Loads, verifies, and mounts one contribution into a consumer-owned container. */
mount(options: MountModuleContributionOptions): Promise<MountedModuleContribution>;
}
/**
* What a host with no contribution mechanism supplies.
*
* A standalone host running exactly one module has no other module to contribute anything, so an
* empty implementation is the *correct* one rather than a stub — which is precisely why this member
* is always present on the context: a module writes the same code either way, and finds nothing.
*/
export const NO_MODULE_UI_CONTRIBUTIONS: ModuleUiContributions = Object.freeze({
list: (): readonly ModuleContributionDescriptor[] => Object.freeze([]),
mount: (): Promise<MountedModuleContribution> =>
Promise.reject(new ModuleContributionError("MODULE_CONTRIBUTION_UNKNOWN")),
});
@@ -1,3 +1,4 @@
import type { ModuleUiContributions } from "./contribution.js";
import type { ModuleUiRealtimeClient } from "./realtime.js";
/**
@@ -83,6 +84,16 @@ export interface ModuleUiHostContext {
readonly permissions: ModuleUiPermissions;
/** Host-owned delivery of this module's own runtime-published state. */
readonly realtime: ModuleUiRealtimeClient;
/**
* The renderers other modules contributed to capabilities **this** module provides (ADR 0041).
*
* Present on every context, and empty for the overwhelming majority of modules — a module that
* provides no extension point simply finds nothing in it. It is the one member that lets one
* module's UI extend another's without either importing the other, and it deliberately exposes
* identity and a mount operation rather than a URL: the host resolves publication, exactly as it
* resolves `api.request`.
*/
readonly contributions: ModuleUiContributions;
}
/**
+2
View File
@@ -1,4 +1,6 @@
export * from "./contribution.js";
export * from "./define-module-ui.js";
export * from "./host-context.js";
export * from "./json.js";
export * from "./module-ui.js";
export * from "./realtime.js";
+15
View File
@@ -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 };
+2 -1
View File
@@ -1,6 +1,7 @@
import type { ModuleUiHostContext } from "./host-context.js";
export type Awaitable<Value> = Value | PromiseLike<Value>;
export type { Awaitable } from "./awaitable.js";
import type { Awaitable } from "./awaitable.js";
/**
* The version of the browser mount ABI this package defines, and the only one a host accepts.
+6 -7
View File
@@ -1,17 +1,16 @@
import type { ModuleUiJsonValue } from "./json.js";
/**
* A JSON value carried from a module's server runtime to its browser UI.
*
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
* owns all of that and gives the module only typed snapshots and updates.
*
* An alias of the SDK's one {@link ModuleUiJsonValue}, kept under its original name because it is
* the published realtime contract. There is one JSON shape in this package, not one per feature.
*/
export type ModuleUiRealtimeJsonValue =
| boolean
| number
| string
| null
| readonly ModuleUiRealtimeJsonValue[]
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
export type ModuleUiRealtimeJsonValue = ModuleUiJsonValue;
/** Connection state useful for rendering a small, honest status indicator. */
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";