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]>
340 lines
16 KiB
TypeScript
340 lines
16 KiB
TypeScript
import {
|
|
isVersionCompatible,
|
|
type PermissionDeclaration,
|
|
type PermissionId,
|
|
} from "@ekklesia/contracts";
|
|
import type { DiscoveredModule } from "./discovery.js";
|
|
|
|
/**
|
|
* One installed module whose UI artifact the platform is willing to publish at a browser URL.
|
|
*
|
|
* `artifactPath` is the canonical absolute path discovery already resolved and validated for
|
|
* `entrypoints.ui` — realpath-resolved, proven to be a compiled `.js`/`.mjs` file, and proven to
|
|
* stay inside the module package both before and after symlink resolution
|
|
* (`resolveEntrypoints`, `./discovery.js`). A publisher serves **exactly this path** and never
|
|
* composes a path of its own from request input; that is the whole reason this record exists
|
|
* rather than a module id plus a path-building convention.
|
|
*/
|
|
export interface PublishableModuleUi {
|
|
readonly moduleId: string;
|
|
readonly version: string;
|
|
/**
|
|
* The module's own `manifest.name` — its product identity, required by every manifest version and
|
|
* therefore available without any manifest change (ADR 0027).
|
|
*
|
|
* Carried here so a host can render something a person recognizes instead of a module id. It is
|
|
* presentation *identity*, not presentation *placement*: a module says what it is called, while
|
|
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping,
|
|
* ordering, or navigation belongs on this record.
|
|
*/
|
|
readonly name: string;
|
|
readonly artifactPath: string;
|
|
/**
|
|
* The module's own manifest-declared permissions, carried here so a publisher can compute the
|
|
* presentation-only permission hints a host context needs (ADR 0022) **without** reaching back
|
|
* into the manifest and re-deriving which modules are publishable — which would put the
|
|
* publication policy in two places.
|
|
*
|
|
* Declarations only: this says what the module declares, never what any principal holds.
|
|
* Evaluating a principal against them is the caller's job, through Core's one authorization
|
|
* evaluator, and the answer is a hint rather than a grant. `artifactPath` and these declarations
|
|
* are the complete set of things a publisher may need; nothing else from the manifest belongs
|
|
* here.
|
|
*/
|
|
readonly permissions: readonly PermissionDeclaration[];
|
|
}
|
|
|
|
/**
|
|
* One installed module whose dedicated full-viewport display artifact may be published.
|
|
*
|
|
* This is deliberately a separate record from {@link PublishableModuleUi}. A primary UI and a
|
|
* display UI are two independently declared artifacts with different host roles, and a module may
|
|
* publish either one without publishing the other. `displayPermission` is the module-owned
|
|
* permission the manifest places beside `entrypoints.display`; it travels with the exact artifact
|
|
* identity so a display descriptor cannot accidentally authorize one artifact with another
|
|
* module's policy. The complete declaration list supports the same browser-safe permission hints
|
|
* as the primary UI while conferring no authority by itself.
|
|
*/
|
|
export interface PublishableModuleDisplay {
|
|
readonly moduleId: string;
|
|
readonly version: string;
|
|
readonly name: string;
|
|
readonly artifactPath: string;
|
|
readonly displayPermission: PermissionId;
|
|
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?"
|
|
*
|
|
* Lookup is by module **identity**, never by path. There is deliberately no method that accepts a
|
|
* filename, a relative path, a glob, or anything else that could turn an installed module
|
|
* directory into a static file root.
|
|
*/
|
|
export interface ModuleUiPublicationRegistry {
|
|
/** Every publishable module, in the discovery order it was built from. Stable and frozen. */
|
|
list(): readonly PublishableModuleUi[];
|
|
/**
|
|
* The publishable record for an exact module id **and** exact installed version, or `undefined`.
|
|
*
|
|
* Takes `unknown` for the same reason `PermissionRegistry.get` does: the caller is usually
|
|
* handing over decoded request input, and a lookup that fails closed on anything malformed is
|
|
* safer than one whose type signature quietly assumes validation already happened. A module that
|
|
* is not installed, one that declares no `entrypoints.ui`, and one installed at a different
|
|
* version are all indistinguishable here — each returns `undefined`.
|
|
*/
|
|
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
|
|
/** Every dedicated display artifact, in discovery order. Stable and frozen. */
|
|
listDisplays(): readonly PublishableModuleDisplay[];
|
|
/**
|
|
* The dedicated display record for an exact module id and installed version, or `undefined`.
|
|
*
|
|
* This lookup never falls back to `entrypoints.ui`: a primary/dashboard artifact cannot become
|
|
* a display merely because a caller appends `/display` to its publication URL.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Builds the platform's module UI publication policy from an already-discovered module list.
|
|
*
|
|
* The policy is deliberately small. "Publishable" means exactly:
|
|
*
|
|
* 1. the module is in the **eligible** set the platform started — it survived discovery *and*
|
|
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
|
|
* and
|
|
* 2. for each role independently, its manifest declares `entrypoints.ui` or
|
|
* `entrypoints.display`, which discovery resolved to a real compiled file inside the module
|
|
* package.
|
|
*
|
|
* A module directory sitting on disk satisfies neither on its own — it has to have survived
|
|
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
|
|
* artifact on disk must not be what makes it servable*. Since ADR 0028 the same is true of a module
|
|
* an administrator disabled: it is discovered and validated, and it is still not publishable,
|
|
* because this registry is built from the eligible list rather than the discovered one. That is why
|
|
* the filtering lives at the *call site's choice of input* rather than as a `disabled` check
|
|
* somewhere downstream — there is exactly one authoritative eligible set, and everything that
|
|
* exposes a module consumes it.
|
|
*
|
|
* Pure and synchronous: no filesystem, environment, database, or network access, and it never
|
|
* imports, evaluates, or otherwise executes a module's UI artifact. Production composition
|
|
* (`apps/api/src/server.ts`) passes the exact list it just started, and only after
|
|
* `startDiscoveredModuleRuntime` succeeded — startup is all-or-nothing across that set, so a module
|
|
* that failed to load, register, or start takes API startup down with it rather than lingering as a
|
|
* publishable-but-not-running module.
|
|
*/
|
|
export function createModuleUiPublicationRegistry(
|
|
modules: readonly DiscoveredModule[],
|
|
): ModuleUiPublicationRegistry {
|
|
const byModuleId = new Map<string, PublishableModuleUi>();
|
|
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) {
|
|
const artifactPath = module.entrypoints.ui;
|
|
if (artifactPath !== undefined) {
|
|
byModuleId.set(
|
|
module.manifest.id,
|
|
Object.freeze({
|
|
artifactPath,
|
|
moduleId: module.manifest.id,
|
|
name: module.manifest.name,
|
|
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
|
// this record can never reach the manifest object graph itself.
|
|
permissions: Object.freeze([...module.manifest.permissions]),
|
|
version: module.manifest.version,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const display = module.entrypoints.display;
|
|
if (display !== undefined) {
|
|
displaysByModuleId.set(
|
|
module.manifest.id,
|
|
Object.freeze({
|
|
artifactPath: display.artifactPath,
|
|
displayPermission: display.permission,
|
|
moduleId: module.manifest.id,
|
|
name: module.manifest.name,
|
|
permissions: Object.freeze([...module.manifest.permissions]),
|
|
version: module.manifest.version,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
const published = Object.freeze([...byModuleId.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({
|
|
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,
|
|
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
|
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
|
if (typeof moduleId !== "string" || typeof version !== "string") {
|
|
return undefined;
|
|
}
|
|
const candidate = byModuleId.get(moduleId);
|
|
// Exact version equality, never a range, a "latest" fallback, or a nearest match. A request
|
|
// naming a version the platform does not have installed resolves to nothing at all rather
|
|
// than to a different artifact that happens to exist.
|
|
return candidate?.version === version ? candidate : undefined;
|
|
},
|
|
resolveDisplay: (moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined => {
|
|
if (typeof moduleId !== "string" || typeof version !== "string") {
|
|
return undefined;
|
|
}
|
|
const candidate = displaysByModuleId.get(moduleId);
|
|
return candidate?.version === version ? candidate : undefined;
|
|
},
|
|
});
|
|
}
|