feat: mount discovered module UI in a minimal Ekklesia browser host
Phase 6C pass 2. Every link in the module UI chain existed except the one that closes it: a module could be authored, built, validated, published at a browser URL, and loaded by a generic loader — and nothing in Ekklesia did any of it. There was no browser application at all. Two pieces were missing. A browser could not discover what to mount, and it must not work that out for itself. GET /api/v1/module-ui is the inventory: per publishable module, its id and version, the server-produced artifact URL, the server-produced root of the module's own API, and the subset of that module's declared permissions the caller holds. Five fields, each because building a ModuleUiHostContext needs it — no filesystem paths, and no name/icon/folder/order, because navigation metadata is a design that does not exist yet. Its module set is the ADR 0025 publication registry rather than a rescan, so the inventory and the artifact route cannot disagree. It requires a new Core permission, core.modules.ui.read (baseline authenticated), because it is precisely the enumeration the anonymous artifact route deliberately withholds. Both URLs are server-produced. moduleUiArtifactUrl already existed; moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL shape has exactly one author. Without it the shell would hardcode /api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling, reappearing in the host instead. surfaces/shell (@ekklesia/shell) is the repository's first surface: a small React application built to static files by its own esbuild script. It resolves the principal, fetches the inventory, builds the host context entirely from server-derived data, and hands the artifact URL to @ekklesia/module-ui-host. One frame per selection — keyed by module identity — defeats the stale-load race; the host owns the container, the loading state, and every failure, so a broken module costs its own panel rather than the shell. The shell statically imports no module, and both levels are checked: no source import may name a module, and the emitted bundle must contain exactly one dynamic import (the loader's) with no module id, no /api/v1/modules, no eval, and no blob URL. Permission hints come from Core's own evaluator and stay presentation-only — the end-to-end test has a UI holding no grant call its API anyway and still receive 403. Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts is deliberately excluded, since its barrel pulls Drizzle's types and Node's globals into the browser — the hole ADR 0024 closed one layer down. Not the Dashboard: no navigation, grouping, icons, ordering, design system, or routing. The selector is a list of module ids. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a76ff6f26
commit
616c1d4834
@@ -0,0 +1,193 @@
|
||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||
import type { ModuleUiPublicationRegistry, 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 { MODULE_UI_ASSET_PREFIX, 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 };
|
||||
/** 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 };
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
export interface HostModuleUiInventory {
|
||||
readonly modules: readonly HostModuleUiDescriptor[];
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
async function grantedPermissionIds(
|
||||
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,
|
||||
): Promise<HostModuleUiDescriptor> {
|
||||
return {
|
||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||
id: published.moduleId,
|
||||
permissions: await grantedPermissionIds(evaluator, principal, published.permissions),
|
||||
ui: { url: moduleUiArtifactUrl(published) },
|
||||
version: published.version,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 modules = await Promise.all(
|
||||
options.moduleUi
|
||||
.list()
|
||||
.map((published) => describeModule(options.evaluator, principal, published)),
|
||||
);
|
||||
return c.json({ modules } satisfies HostModuleUiInventory, 200);
|
||||
} catch {
|
||||
return sanitizedInventoryFailure(c);
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
Reference in New Issue
Block a user