Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
363 lines
17 KiB
TypeScript
363 lines
17 KiB
TypeScript
import {
|
|
isVersionCompatible,
|
|
type ModuleNavigationDeclaration,
|
|
moduleManifestNavigation,
|
|
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
|
|
* or ordering belongs on this record.
|
|
*/
|
|
readonly name: string;
|
|
/**
|
|
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon it is
|
|
* drawn with and its primary pages, in its declared order. Like `name`, this is the module's own
|
|
* identity — what it is and which pages it has — never where it sits in the Dashboard, which stays
|
|
* the administrator's (ADR 0027). Empty for a manifest v8 module and for one that declares none:
|
|
* the generic icon and a single root page.
|
|
*/
|
|
readonly navigation: ModuleNavigationDeclaration;
|
|
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[];
|
|
}
|
|
|
|
/** A fresh, deeply frozen copy, so a caller holding a record never reaches the manifest graph. */
|
|
function frozenNavigation(navigation: ModuleNavigationDeclaration): ModuleNavigationDeclaration {
|
|
return Object.freeze({
|
|
...(navigation.icon === undefined ? {} : { icon: navigation.icon }),
|
|
pages: Object.freeze(
|
|
navigation.pages.map((page) =>
|
|
Object.freeze({ id: page.id, label: page.label, path: page.path }),
|
|
),
|
|
),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
navigation: frozenNavigation(moduleManifestNavigation(module.manifest)),
|
|
// 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;
|
|
},
|
|
});
|
|
}
|