297 lines
14 KiB
TypeScript
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;
|
|
}
|