import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts"; import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization"; import type { ModuleUiPublicationRegistry, PublishableModuleContribution, PublishableModuleUi, } from "@ekklesia/core/modules"; import { Hono } from "hono"; import type { Context } from "hono"; import { type AuthorizationMiddlewareOptions, getAuthorizedPrincipal, requirePermission, } from "./authorization-middleware.js"; import { moduleApiBaseUrl } from "./module-http-routes.js"; import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js"; import { MODULE_UI_ASSET_PREFIX, moduleContributionArtifactUrl, moduleUiArtifactUrl, } from "./module-ui-routes.js"; /** * The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may * mount, and `/api/v1/module-ui//` (ADR 0025) serves one artifact's bytes. One * prefix, one resource, two representations. */ export const MODULE_UI_INVENTORY_PATH = MODULE_UI_ASSET_PREFIX; /** * Exactly what a browser host needs to mount one module's UI, and deliberately nothing else. * * No filesystem path, package root, package name, server/schema/migrations entrypoint, manifest * version, capability, dependency, or storage bucket appears here. Those are server-side facts a * browser has no use for, and several of them are precisely what ADR 0025's publication route * exists to keep off the wire. * * There is also no `name`, `icon`, `description`, `category`, `folder`, or `order`. Those are * *navigation* concerns, and ADR 0021 established that Dashboard grouping is presentation * configuration an administrator owns rather than something a module declares. A field added here * now would quietly become module-owned navigation metadata before that design exists; the * temporary selector renders the module id instead. */ export interface HostModuleUiDescriptor { readonly id: string; readonly version: string; /** Where the browser fetches this module's compiled UI artifact. Produced by the server's own * canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication * routing can change without touching a single line of host or module code. */ readonly ui: { readonly url: string }; /** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths * a module UI asks for (`context.api.request("records")`). Server-produced for the same reason * `ui.url` is: `/api/v1/modules/` is Ekklesia's private deployment detail, and neither the * module nor the browser host should be the one spelling it. */ readonly api: { readonly baseUrl: string }; /** Server-produced root for this module's host-neutral browser realtime facade. The browser host, * not module UI, resolves and appends a validated local channel. */ readonly realtime: { readonly baseUrl: string }; /** * The subset of **this module's own declared permissions** that the current principal holds, * evaluated server-side by Core's one authorization evaluator. * * Scoped to the module deliberately: a host has no business handing one module the platform's * whole authorization state, and the module's manifest already declares exactly which permissions * its UI can meaningfully ask about. Granted ids only — a permission the principal lacks is simply * absent, so this never enumerates a module's full permission surface to someone who holds none of * it. * * **These are presentation hints, not authority** (ADR 0022). They exist so a UI can hide a * control instead of offering one that would fail. Every module route remains enforced by * `requirePermission` against the composed platform registry, and that enforcement neither reads * nor trusts anything in this array. */ readonly permissions: readonly string[]; /** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for * the overwhelming majority of modules, which provide no extension point. */ readonly contributions: readonly HostModuleContributionDescriptor[]; } /** * One renderer another module contributed to a capability **this** module provides (ADR 0041). * * The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a * consuming module names a contribution and the host resolves it, so no module ever builds * Ekklesia's publication URL and every one of them stays hostable elsewhere. * * There is no payload schema, slot, order, or rendering hint here, and there will not be: what a * contributed renderer draws and what shape its input takes are the consuming module's contract, * carried in the browser between two modules without passing through the platform. */ export interface HostModuleContributionDescriptor { readonly id: string; readonly version: string; /** The capability it was contributed to — one this module declares. */ readonly capability: string; readonly url: string; } export interface HostModuleUiInventory { readonly modules: readonly HostModuleUiDescriptor[]; } /** * The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI * inventory and the display descriptor so the two can never disagree about what a consuming module * may mount. */ export function describeModuleContributions( registry: ModuleUiPublicationRegistry, moduleId: string, ): readonly HostModuleContributionDescriptor[] { return registry .contributionsForModule(moduleId) .map((contribution: PublishableModuleContribution) => ({ capability: contribution.capability, id: contribution.contributionId, url: moduleContributionArtifactUrl( { moduleId: contribution.moduleId, version: contribution.moduleVersion }, contribution.contributionId, ), version: contribution.version, })); } export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions { /** The same publication registry the artifact route serves from — the single accepted/publishable * module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs * no discovery, reads no manifest, and touches no filesystem; a module absent from this registry * is absent from the inventory, by construction rather than by filtering. Named `moduleUi` rather * than `registry` because `AuthorizationMiddlewareOptions.registry` is already the *permission* * registry, and one route needs both. */ readonly moduleUi: ModuleUiPublicationRegistry; } /** * Evaluates one module's declared permissions against the caller, returning only the granted ids. * * Uses Core's one evaluator, so baselines, the platform-owner bypass, grant expiry, and * deny-by-default all behave exactly as they do on a real request — there is no second, * browser-only interpretation of what a permission means. An infrastructure failure propagates as * `AuthorizationEvaluationError` rather than being folded into "not granted", because a hint that * silently read "denied" during a database outage would blank the whole UI and look like a * permissions problem. */ /** Shared by the primary-UI inventory and the singular display descriptor. Both provide the same * presentation-only subset of a module's own declarations and use Core's one evaluator. */ export async function grantedModulePermissionIds( evaluator: AuthorizationMiddlewareOptions["evaluator"], principal: EkklesiaPrincipal, declarations: readonly PermissionDeclaration[], ): Promise { const decisions = await Promise.all( declarations.map(async (declaration) => ({ allowed: ( await evaluator.authorize({ baseline: declaration.baseline, permission: declaration.id, principal, }) ).allowed, id: String(declaration.id), })), ); return decisions.filter((decision) => decision.allowed).map((decision) => decision.id); } async function describeModule( evaluator: AuthorizationMiddlewareOptions["evaluator"], principal: EkklesiaPrincipal, published: PublishableModuleUi, registry: ModuleUiPublicationRegistry, ): Promise { return { api: { baseUrl: moduleApiBaseUrl(published.moduleId) }, contributions: describeModuleContributions(registry, published.moduleId), id: published.moduleId, permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions), realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) }, ui: { url: moduleUiArtifactUrl(published) }, version: published.version, }; } /** One mountable module, described once: the host-neutral mount descriptor plus the module's own * display name, kept beside it rather than inside it. */ export interface MountableModuleDescription { readonly descriptor: HostModuleUiDescriptor; /** `manifest.name`. Presentation *identity* the module owns — never its placement, which the * Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the * host-neutral contract a standalone host consumes stays free of presentation concerns. */ readonly name: string; } /** * The one composition of the mountable-module set, shared by the generic inventory route and the * Dashboard navigation route (`./dashboard-routes.js`). * * Both derive from the same publication registry and the same evaluator, so the two endpoints can * never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing * this function rather than duplicating the mapping is what makes that a structural property instead * of a convention. */ export async function describeMountableModules( evaluator: AuthorizationMiddlewareOptions["evaluator"], principal: EkklesiaPrincipal, registry: ModuleUiPublicationRegistry, ): Promise { return Promise.all( registry.list().map(async (published) => ({ descriptor: await describeModule(evaluator, principal, published, registry), name: published.name, })), ); } /** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that * could not be determined is never reported as "not granted". Carries no module id, permission id, * or caught error. */ function sanitizedInventoryFailure(c: Context): Response { c.header("Cache-Control", "no-store"); return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503); } /** * The browser host's one bootstrap query: which module UIs may I mount, where do I fetch each one, * where does each one's API live, and what may this viewer do inside it. * * ## It is not a module management API * * It lists mountable UIs and nothing else. There is no create, update, enable, disable, install, or * configure; no module that declares no UI; and no field beyond what constructing a * `ModuleUiHostContext` requires. Ekklesia has no enable/disable state to report — * `ModuleLifecycleStage` is vocabulary with no runtime state machine behind it — and inventing one * here would be inventing a lifecycle rather than reporting one. * * ## The module set comes from the publication registry, never a rescan * * `moduleUi.list()` is the exact accepted set ADR 0025 publishes artifacts from: modules that * survived discovery and declare `entrypoints.ui`, captured after the module runtime started. This * route never scans a directory, reads a manifest, or reconstructs a module from `package.json`, so * the inventory and the artifact route can never disagree about what exists. Order is the * registry's own discovery order, which is deterministic (topologically sorted, then by id). * * ## Authenticated, deliberately, unlike the artifact route * * ADR 0025 made the artifact bytes anonymous — fetching compiled UI code confers no authority — but * was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong * version collapse into one indistinguishable `404`, so a caller must already know both halves of an * identity to learn anything. **This endpoint is that enumeration.** Serving it anonymously would * hand over exactly what that route withholds, so it requires `core.modules.ui.read` (baseline * `authenticated`): any signed-in user of the installation, and nobody else. * * Enforcement goes through the same `requirePermission` middleware every other protected route uses * — no second browser-auth mechanism — which also resolves the principal exactly once and keeps a * genuine denial (`403`) distinct from an infrastructure failure (`503`). * * The response is per-principal (its `permissions` depend on who is asking), so it is `no-store` and * varies on `Origin` and `Cookie`, exactly like `/api/v1/principal`. */ export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRoutesOptions): Hono { const routes = new Hono(); const requireInventoryPermission = requirePermission( { evaluator: options.evaluator, registry: options.registry, runtime: options.runtime }, CORE_MODULES_UI_READ, ); routes.get(MODULE_UI_INVENTORY_PATH, requireInventoryPermission, async (c) => { c.header("Cache-Control", "no-store"); c.header("Vary", "Origin, Cookie"); // Resolved and already authorized by the middleware above; never resolved a second time here. // The `authenticated` baseline guarantees this is an authenticated principal. const principal = getAuthorizedPrincipal(c); try { const described = await describeMountableModules( options.evaluator, principal, options.moduleUi, ); // Only the host-neutral descriptor reaches this response: the display name stays out of the // portable contract and is carried by the Dashboard navigation view instead. const modules = described.map((entry) => entry.descriptor); return c.json({ modules } satisfies HostModuleUiInventory, 200); } catch { return sanitizedInventoryFailure(c); } }); return routes; }