Files
Ekklesia/apps/api/src/module-ui-inventory.ts
T

297 lines
14 KiB
TypeScript

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 { moduleDisplayPageUrl } from "./module-display-path.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/<module-id>/<version>` (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 };
/** Canonical page for this module's separately declared display artifact, or `null` when the
* module publishes no display. Server-produced so neither shell nor module spells platform
* routing or infers deployment topology. */
readonly display: { readonly url: string } | null;
/** 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/<id>` 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<readonly string[]> {
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<HostModuleUiDescriptor> {
const display = registry.resolveDisplay(published.moduleId, published.version);
return {
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
contributions: describeModuleContributions(registry, published.moduleId),
display: display === undefined ? null : { url: moduleDisplayPageUrl(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<readonly MountableModuleDescription[]> {
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;
}