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
+155 -7
View File
@@ -73,7 +73,7 @@ without executing module code:
```json
{
"manifestVersion": 6,
"manifestVersion": 7,
"id": "calendar",
"name": "Calendar",
"version": "0.1.0",
@@ -122,6 +122,16 @@ without executing module code:
}
]
},
"contributions": [
{
"id": "calendar.signage-slide",
"version": "1.0.0",
"capability": "signage.slide-renderer",
"capabilityRange": "^1.0.0",
"artifact": "./dist/contributions/signage-slide/index.js",
"description": "Renders an upcoming-events slide for a signage timeline."
}
],
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
@@ -140,12 +150,13 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 5 manifest, and any other version, fails validation outright rather than being silently
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 6 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
changes only the version literal; `ui` remains independently optional
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
Version 7 adds the optional `contributions` array
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
contributes nothing changes only the version literal; every browser artifact remains independently
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
@@ -214,6 +225,16 @@ are separate artifacts because a full-viewport display is not a Dashboard screen
```json
{
"contributions": [
{
"id": "calendar.signage-slide",
"version": "1.0.0",
"capability": "signage.slide-renderer",
"capabilityRange": "^1.0.0",
"artifact": "./dist/contributions/signage-slide/index.js",
"description": "Renders an upcoming-events slide for a signage timeline."
}
],
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
@@ -271,7 +292,7 @@ artifact is the role selection.
### What the host gives you
`mount` receives exactly five members, and nothing else is available:
`mount` receives exactly six members, and nothing else is available:
| Member | What it is |
| --- | --- |
@@ -280,6 +301,7 @@ artifact is the role selection.
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
contract. They need designs that do not exist, and a speculative field frozen into a public contract
@@ -770,6 +792,132 @@ A Dashboard may later group modules into folders (`Media` holding Video and Live
configuration an administrator can reorganize freely. They are not dependencies, they are not
declared in your manifest, and your module must never require one to work correctly.
## Browser contributions
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
owns a timeline; an announcements module knows what an announcement should look like. Neither can
import the other — modules are independently installed, independently versioned peers — and neither
should re-implement the other's job.
A **browser contribution** is the mechanism for exactly that
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
declare for a capability *another* module provides, which that module mounts inside a container it
owns.
```text
signage provides the capability signage.slide-renderer, and publishes its input contract
announcements contributes a React renderer for it
signage mounts that renderer in one timeline slot, updates it, and takes it down
```
### Contributing a renderer
Declare it in your manifest and put the source at the fixed path your contribution id implies:
```json
{
"contributions": [
{
"id": "announcements.signage-slide",
"version": "1.0.0",
"capability": "signage.slide-renderer",
"capabilityRange": "^1.0.0",
"artifact": "./dist/contributions/signage-slide/index.js"
}
]
}
```
```text
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
```
```tsx
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
import styles from "./styles.css";
import { Slide, type SlideInput } from "./Slide.js";
export default defineModuleContribution({
id: "announcements.signage-slide",
version: "1.0.0",
styles,
mount(container, context) {
const root = createRoot(container);
root.render(<Slide input={context.input as SlideInput} />);
return {
update(input) {
root.render(<Slide input={input as SlideInput} />);
},
unmount() {
root.unmount();
},
};
},
});
```
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
registry. Your contribution carries **its own version**, independent of your module's, and the
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
rendered. `capabilityRange` says which provider versions you work with: if no installed module
provides that capability inside your range, your contribution is simply not published. It is never
mounted and broken.
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
### What a contributed renderer receives
Two things: its own id, and the consumer's `input`.
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
through the consumer's input, because the consumer is the party that was authorized to fetch it.
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
module publishes that contract alongside its capability and versions it through its capability
version. Validate what you receive and render a sensible default when it is not what you expected —
you may be running against an older or newer consumer than the one you developed against.
`update` is what lets a long-running screen change content without restarting animations or reloading
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
cannot, and the consumer will unmount and mount you again.
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
cannot reach in.
### Consuming contributions
If *your* module provides a capability other modules can render for, ask the host what was
contributed:
```tsx
const renderers = context.contributions.list("signage.slide-renderer");
const slide = await context.contributions.mount({
container: slotElement, // an element your module owns
contributionId: renderers[0].id,
input: { headline, seconds }, // your own published contract
});
await slide.update({ headline: next, seconds });
await slide.unmount();
```
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
You will only ever see contributions aimed at a capability **your** module provides, and only ones
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
your compatible range, and that is ordinary rather than exceptional.
You own the input contract completely. Publish it beside your capability, version it with your
capability version, and remember that a renderer written against an older version of it may still be
installed.
## Runtime server definition
The source entrypoint exports one typed runtime definition and does not repeat the static manifest: