Files
Ekklesia/core/src/modules/ui-publication.ts
T
ekklesia 2e0fe059c3
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
feat: add display kiosk host
2026-08-23 20:50:56 -05:00

186 lines
9.1 KiB
TypeScript

import type { PermissionDeclaration, 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[];
}
/**
* 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;
}
/**
* 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>();
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()]);
return Object.freeze({
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;
},
});
}