Files
Ekklesia/packages/module-ui-sdk/src/host-context.ts
T
ekklesiaandClaude Opus 5 c444ff570b 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]>
2026-08-27 07:47:10 -05:00

147 lines
7.9 KiB
TypeScript

import type { ModuleUiContributions } from "./contribution.js";
import type { ModuleUiRealtimeClient } from "./realtime.js";
/**
* Who is viewing, as a browser sees it.
*
* This is deliberately **not** Core's `EkklesiaPrincipal`, and the difference is the point. That
* type's `userId` is a Zod-branded string whose brand only means something to server-side parsing,
* and reaching for it made this package import `@ekklesia/contracts` — a backend barrel whose type
* graph pulls Drizzle, and with it Node's global declarations, back into every module UI program
* (measured: a UI file importing this package could typecheck `process.env`). A browser contract
* that drags a database ORM's types behind it is not a browser contract.
*
* So the shape is preserved and the brand is dropped: plain, serializable, and exactly what a UI
* needs to decide what to render. An Ekklesia host can pass its own `EkklesiaPrincipal` straight in
* — a branded string is assignable to `string`, so the two remain structurally compatible without
* this package knowing anything about the server's representation. A standalone host builds the
* same object literal from whatever identity system it has.
*
* Nothing server-internal belongs here. A field is added only when module UI provably needs it to
* render something.
*/
export interface ModuleUiAnonymousPrincipal {
readonly kind: "anonymous";
}
export interface ModuleUiAuthenticatedPrincipal {
readonly kind: "authenticated";
/** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */
readonly userId: string;
}
/**
* A managed display device — a screen the platform paired, with no person behind it.
*
* Module UI is told this so it can render honestly: a signage view has nobody to greet, no personal
* data to show, and no interaction to invite. It is deliberately *not* an authenticated principal,
* because module code that asks "is someone signed in?" must not be told yes about a television.
*
* There is nothing here about pairing. How a device came to exist is the host's business; a module
* receives an identity and its permissions, exactly as it does for a person.
*/
export interface ModuleUiDevicePrincipal {
readonly kind: "device";
/** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */
readonly deviceId: string;
}
export type ModuleUiPrincipal =
| ModuleUiAnonymousPrincipal
| ModuleUiAuthenticatedPrincipal
| ModuleUiDevicePrincipal;
/**
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
*
* This is deliberately the smallest set that proves the architecture rather than everything a real
* screen will eventually want. Navigation, module configuration, organization/platform context, and
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
*
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
* root, they inherit into the artifact's own root, and module code writes `var(--ekklesia-…)`. A
* token map on this context would make styling a run-time negotiation, force every module to
* re-implement it from data, and give a host something it could forget to supply.
*
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
* host that never runs Core, which is what makes one module UI hostable by both. The same context is
* supplied to primary and display artifacts: there is deliberately no role or presentation-mode
* flag, because the separately declared artifact already selects the experience. This package has
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
*/
export interface ModuleUiHostContext {
/** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode
* or re-derive its own identity from a URL. */
readonly moduleId: string;
/** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */
readonly api: ModuleUiApiClient;
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
* a browser-owned shape rather than Core's server principal type. */
readonly principal: ModuleUiPrincipal;
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
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;
}
/**
* A module UI's access to its own module's HTTP API, addressed **relative to the module's own API
* root** — never as an absolute path.
*
* This is the single most important portability rule in this contract. Ekklesia mounts module routes
* under `/api/v1/modules/<module-id>` (`apps/api/src/module-http-routes.ts`), but that prefix is
* Ekklesia's private deployment detail. A standalone host serving one module may expose the same
* routes at `/api`, at a different origin entirely, or through a proxy. A module UI that builds
* `/api/v1/modules/announcements/posts` itself is silently welded to Ekklesia and can never be
* hosted anywhere else.
*
* So the module asks for `posts`, and the host resolves it. `Request`/`Response` are the same Fetch
* vocabulary a module's backend routes already speak (ADR 0015), so one module speaks one HTTP
* shape on both sides of the wire.
*
* A host is responsible for credentials, base URL, and any platform headers. A module UI neither
* supplies nor sees a session token.
*/
export interface ModuleUiApiClient {
/**
* @param path A path relative to this module's own API root, with or without a leading `/`.
* Must not be an absolute URL: a host is free to reject one rather than silently allowing a
* module UI to address something other than its own backend.
*/
request(path: string, init?: RequestInit): Promise<Response>;
}
/**
* Presentation-only permission hints.
*
* **This is not a security boundary and must never be used as one.** A module UI may use it to hide
* an action, disable a control, or choose which empty state to render. Server-side authorization
* remains the only authority: every module route is enforced by Core's `requirePermission`
* middleware against the composed platform permission registry (ADR 0013/0014), and that
* enforcement is unchanged by, and unaware of, anything a browser believes.
*
* A module UI that "checks" a permission here and then calls its API is still fully authorized
* server-side. A module UI that skips the check and calls anyway is still denied server-side. The
* value of this interface is that the user does not see a button that would fail.
*
* `permissionId` is a plain `string`, deliberately, and not the branded `PermissionId` Core uses:
* branding would require a module UI to run Zod parsing in the browser to produce a value for a
* call that decides nothing security-relevant. The branded type remains the server-side type.
*/
export interface ModuleUiPermissions {
/** Whether the host believes the current principal holds this permission. A hint, not a grant. */
has(permissionId: string): boolean;
}