diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 284dc63..774b21f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,12 @@ pnpm infrastructure:config API by relative path — never by constructing `/api/v1/modules//...`, which is Ekklesia's private deployment detail and would break standalone hosting. - The containing application shell, navigation, global theme, and platform policy belong to Core and - the host application, never to a module. + the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers + installed module UIs through `GET /api/v1/module-ui` and mounts them with + `@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026). +- A surface is browser code. It may depend on browser-safe shared roles only — never + `@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the + browser — and it reaches the server over HTTP. - Add or update tests when changing a contract or architectural rule. - Record major architectural changes in an ADR rather than burying them in an implementation. diff --git a/README.md b/README.md index b0a4bb8..9d97552 100644 --- a/README.md +++ b/README.md @@ -326,11 +326,39 @@ download that bundle. Caching is `no-cache` with a strong `ETag` rather than `im without an installer nothing stops a version being rebuilt with different bytes. See [ADR 0025](docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md). -**This is not Dashboard integration.** A host *could* now obtain a browser URL, which was previously -impossible — but nothing in Ekklesia mounts a module UI. There is no Dashboard shell, no navigation, -no place a module's screen appears, and no code calls the URL helper outside its own tests. A design -system, a stylesheet or general asset publication contract, a module metadata endpoint, and the -standalone host also do not exist. +Phase 6C pass 2 connects the chain: **Ekklesia now has a browser host that mounts module-owned UI.** +`surfaces/shell` (`@ekklesia/shell`) is the repository's first surface — a small React application, +built to static files with esbuild — and it does the whole loop at run time: + +```text +GET /api/v1/principal → who is viewing +GET /api/v1/module-ui → which module UIs may be mounted, and where + ↓ server-produced artifact URL +@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container + ↓ +context.api.request("records") → /api/v1/modules/example/records +``` + +The new inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version, +artifact URL, module API root, and the viewer's granted permissions **for that module** — and nothing +else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because navigation metadata is a +design that does not exist yet. It is authenticated (`core.modules.ui.read`, baseline +`authenticated`) precisely because it is the module enumeration the anonymous artifact route +deliberately withholds. Both URLs are produced by the server's own canonical helpers, so the browser +never rebuilds Ekklesia's module prefixes and either can change without touching host or module code. + +**The shell statically imports no module.** Every module it can mount arrives at run time from the +inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no +module id, no `/api/v1/modules`, no `eval`, and no blob URLs. Permission hints come from the server's +own evaluator and remain presentation only: the end-to-end test proves a viewer holding no grant is +still refused `403` when the UI calls the API anyway. The host owns the container, the loading state, +and every failure; a broken module costs its own panel, not the shell. See +[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md). + +**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home +screen, design system, or client-side routing — the selector is a list of module ids. Sign-in UI, a +production CSP and deployment for the built shell, asset publication beyond the UI entrypoint, React +runtime sharing, the standalone host, and every real feature module also do not exist. **Security model.** Installed modules are trusted compiled backend code that runs arbitrary JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission diff --git a/apps/api/package.json b/apps/api/package.json index 97407da..800df94 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@biomejs/biome": "2.5.3", "@ekklesia/module-ui-host": "workspace:*", + "@ekklesia/shell": "workspace:*", "@ekklesia/typescript-config": "workspace:*", "@types/node": "24.13.3", "@types/pg": "8.20.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4d42de8..8a51d39 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -16,6 +16,7 @@ import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; +import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; import { createModuleUiRoutes } from "./module-ui-routes.js"; export interface CreateApiAppAuthorizationOptions { @@ -179,6 +180,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono { // that prefix rather than nested inside it. app.route("/", createModuleUiRoutes({ registry: options.moduleUi })); + // The browser host's bootstrap query, at the collection root of that same prefix. Unlike the + // artifact route above it is authenticated (`core.modules.ui.read`) and per-principal, because it + // is the module enumeration ADR 0025 deliberately withheld from the anonymous artifact route. + app.route( + "/", + createModuleUiInventoryRoutes({ + evaluator: options.authorization.evaluator, + moduleUi: options.moduleUi, + registry: options.authorization.registry, + runtime, + }), + ); + mountModuleHttpRoutes(app, options.moduleRoutes, { evaluator: options.authorization.evaluator, registry: options.authorization.registry, diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts index c8ce3f9..c210970 100644 --- a/apps/api/src/module-http-routes.ts +++ b/apps/api/src/module-http-routes.ts @@ -45,13 +45,30 @@ export class ModuleRouteConfigurationError extends Error { } } +/** + * The root a module's own HTTP API is mounted at — the one place that URL shape is constructed. + * + * Exported because a browser host has to resolve `context.api.request("records")` + * (`ModuleUiApiClient`, `@ekklesia/module-ui-sdk`) to a real Ekklesia URL, and a module UI must + * never build that prefix itself: it is Ekklesia's private deployment detail, and a module that + * hardcoded it could not be hosted anywhere else (ADR 0022). The server hands the resolved base to + * the host through the module UI inventory (`./module-ui-inventory.js`) rather than letting the + * browser re-spell this template, so this prefix can change without touching either. + * + * Percent-encoded for the same reason `moduleUiArtifactUrl` is: a value that somehow did not come + * from a validated manifest can only ever become one segment's worth of text, never a new segment. + * A real module id (lowercase kebab-case, `moduleIdSchema`) is unaffected by the encoding. + */ +export function moduleApiBaseUrl(moduleId: string): string { + return `${MODULE_ROUTE_PREFIX}/${encodeURIComponent(moduleId)}`; +} + /** "/" is the module's own root and maps to the bare module prefix, never a required trailing * slash; every other local path appends directly. Root and non-root therefore never collide, and * `/api/v1/modules//` (the root path with a trailing slash) is never produced here. */ function moduleRoutePath(moduleId: ModuleId, routePath: string): string { - return routePath === "/" - ? `${MODULE_ROUTE_PREFIX}/${moduleId}` - : `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`; + const base = moduleApiBaseUrl(moduleId); + return routePath === "/" ? base : `${base}${routePath}`; } function sanitizedModuleHandlerFailure(c: Context): Response { diff --git a/apps/api/src/module-ui-inventory.ts b/apps/api/src/module-ui-inventory.ts new file mode 100644 index 0000000..4a6575e --- /dev/null +++ b/apps/api/src/module-ui-inventory.ts @@ -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//` (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 }; + /** + * 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 { + 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 { + 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; +} diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index b413e83..035a285 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -2158,7 +2158,9 @@ describe.sequential("live API — installed module permission end-to-end", () => expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(permissionsCheck).toMatchObject({ - details: ["2 permissions registered"], + // Core's own two (`core.authorization.grants.manage`, `core.modules.ui.read`) plus the + // fixture module's one. + details: ["3 permissions registered"], status: "pass", }); // Doctor and permission composition read only the manifest — the fixture's register/start diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts new file mode 100644 index 0000000..2a2e124 --- /dev/null +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -0,0 +1,405 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + type EkklesiaPrincipal, + type ModuleDatabase, + type ModuleRuntimeServices, + parseEkklesiaUserId, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + createAuthorizationEvaluator, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, + discoverModules, + startDiscoveredModuleRuntime, +} from "@ekklesia/core/modules"; +import { loadModuleUi } from "@ekklesia/module-ui-host"; +import { + bootstrapHost, + createModuleUiContext, + type EkklesiaHostConfig, + moduleUiSourceUrl, +} from "@ekklesia/shell"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; + +/** + * The whole chain, with nothing between the halves faked: + * + * ```text + * module fixture on disk -> discovery -> module runtime started -> publication registry + * -> inventory endpoint -> the shell's own bootstrap -> server-produced artifact URL + * -> the generic loader -> mount -> the module calls its own API relatively + * -> the shell resolves it to /api/v1/modules//... -> server permission enforcement + * ``` + * + * Every step runs real code. The module is discovered and *started* by Core's real runtime, its + * routes are mounted by the real `createApiApp`, its artifact is published by the real ADR 0025 + * route, its descriptor comes from the real inventory endpoint, and the browser half is + * `@ekklesia/shell`'s own exported protocol — the same functions the React shell calls — rather + * than a reimplementation of them here. + * + * Two things are deliberately *not* faked-around and are worth naming. The shell's `fetch` is the + * Hono app's own `request`, so requests really traverse routing, CORS, authorization middleware, and + * handlers without needing a socket. And Node cannot `import()` an `http:` URL, so the artifact is + * fetched over that same in-process HTTP path and the **bytes it returned** are handed to the + * generic loader through a `data:` URL — a genuine module URL, the same separation ADR 0025 used. + * No test here reads the artifact off the filesystem and calls that a proof of publication. + */ +const viewerId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222"); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +/** + * A real, complete module: a manifest declaring a restricted permission and a UI entrypoint, a + * server entrypoint that registers a permission-gated route, and a compiled UI artifact that reads + * its host context and calls its own API relatively. + * + * The UI artifact is written as plain ESM rather than compiled from TSX here: ADR 0023's pipeline is + * already proven end to end by `templates/module`'s own tests, and what this test needs is a byte + * sequence whose journey through publication, inventory, and the loader can be asserted exactly. + */ +async function createFixtureModule(modulesDirectory: string): Promise { + const moduleDirectory = path.join(modulesDirectory, "example"); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + + await writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: "@test/module-example", + type: "module", + version: "0.1.0", + }), + ); + await writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, + id: "example", + manifestVersion: 4, + name: "Example module", + permissions: [ + { + baseline: "restricted", + description: "Read example records.", + id: "example.records.read", + }, + ], + version: "0.1.0", + }), + ); + await writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { + id: "example", + version: "0.1.0", + register({ http }) { + http.route({ + method: "GET", + path: "/records", + permission: "example.records.read", + maxBodyBytes: 0, + async handler() { + return Response.json([{ id: "a" }, { id: "b" }]); + }, + }); + }, + };\n`, + ); + // The module UI: host-neutral, asks for a relative path, and renders from the host context. It + // hardcodes no origin, no prefix, and no permission decision of its own. + await writeFile( + path.join(moduleDirectory, "dist/ui/index.js"), + `export default { + id: "example", + version: "0.1.0", + async mount(container, context) { + if (!context.permissions.has("example.records.read")) { + container.textContent = context.moduleId + ":denied"; + return () => { container.textContent = ""; }; + } + const response = await context.api.request("records"); + const records = response.ok ? await response.json() : []; + container.textContent = + context.moduleId + ":" + response.status + ":" + records.length; + return () => { container.textContent = ""; }; + }, +};\n`, + ); +} + +interface HarnessOptions { + readonly principal?: EkklesiaPrincipal; + readonly grants?: readonly string[]; +} + +interface Harness { + readonly config: EkklesiaHostConfig; + readonly moduleApiCalls: readonly string[]; + stop(): Promise; +} + +/** + * Starts a real API over a real discovered-and-started module, and returns the `EkklesiaHostConfig` + * a browser would use — with `fetch` wired to the app itself. + */ +async function startHarness(options: HarnessOptions = {}): Promise { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-")); + temporaryDirectories.push(modulesDirectory); + await createFixtureModule(modulesDirectory); + + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + const services: ModuleRuntimeServices = Object.freeze({ + database: Object.freeze({}) as unknown as ModuleDatabase, + }); + // The module's real runtime: its server entrypoint is imported, registered, and started, and the + // route snapshot below is the one it actually published. + const moduleRuntime = await startDiscoveredModuleRuntime(modules, services); + + const granted = new Set(options.grants ?? []); + const evaluator = createAuthorizationEvaluator({ + grants: { hasActiveGrant: async (query) => granted.has(query.permission) }, + platformOwners: { isPlatformOwner: async () => false }, + }); + const runtime: EkklesiaIdentityRuntime = { + handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), + resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL), + }; + const store = { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + } as unknown as AuthorizationStore; + + const app = createApiApp({ + authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, + moduleRoutes: moduleRuntime.routes, + moduleServices: services, + moduleUi: createModuleUiPublicationRegistry(modules), + runtime, + trustedOrigins: ["https://dashboard.example.test"], + }); + + const moduleApiCalls: string[] = []; + const hostFetch = (async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname.startsWith("/api/v1/modules/")) { + moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`); + } + return app.request(url.href, init); + }) as unknown as typeof fetch; + + return { + config: { apiBaseUrl: "https://ekklesia.example.test", fetch: hostFetch }, + moduleApiCalls, + stop: () => moduleRuntime.stop(), + }; +} + +/** Node cannot `import()` an `http:` URL, so the artifact is fetched over the real route and its + * exact bytes become a `data:` URL. The loader still performs a genuine dynamic import of a real + * module URL; only the transport differs, and the transport itself is asserted separately. */ +async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promise { + const response = await (harness.config.fetch as typeof fetch)(sourceUrl); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + const bytes = Buffer.from(await response.arrayBuffer()); + return `data:text/javascript;base64,${bytes.toString("base64")}`; +} + +/** The loader hands the container straight to the module and never inspects it, so a plain sink is + * enough and no DOM implementation is needed in this Node-environment suite. */ +function containerSink(): { textContent: string } { + return { textContent: "" }; +} + +type LoaderContainer = Parameters[0]["container"]; + +describe("end to end: a discovered module's UI mounts in the Ekklesia host", () => { + it("bootstraps, publishes, loads, mounts, and reaches the module's own API", async () => { + const harness = await startHarness({ + grants: ["example.records.read"], + principal: createAuthenticatedPrincipal(viewerId), + }); + try { + // 1. The shell's own bootstrap: principal, then inventory. + const bootstrap = await bootstrapHost(harness.config); + expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId }); + expect(bootstrap.modules).toHaveLength(1); + + const descriptor = bootstrap.modules[0]; + if (!descriptor) { + throw new Error("the inventory returned no module"); + } + // 2. The URLs came from the server, not from anything the browser composed. + expect(descriptor).toEqual({ + apiBaseUrl: "/api/v1/modules/example", + id: "example", + permissions: ["example.records.read"], + uiUrl: "/api/v1/module-ui/example/0.1.0", + version: "0.1.0", + }); + + // 3. The artifact really is served by the real publication route at that URL. + const sourceUrl = moduleUiSourceUrl(harness.config, descriptor); + expect(sourceUrl).toBe("https://ekklesia.example.test/api/v1/module-ui/example/0.1.0"); + const artifactUrl = await publishedArtifactUrl(harness, sourceUrl); + + // 4. The generic loader mounts it with the context the shell built. + const container = containerSink(); + const mounted = await loadModuleUi({ + container: container as unknown as LoaderContainer, + context: createModuleUiContext({ + config: harness.config, + descriptor, + principal: bootstrap.principal, + }), + source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version }, + }); + + // 5. The module rendered, having reached its own backend through the host. + expect(container.textContent).toBe("example:200:2"); + // 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix. + expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); + + // 7. And it tears down cleanly. + await mounted.unmount(); + expect(container.textContent).toBe(""); + expect(mounted.mounted).toBe(false); + } finally { + await harness.stop(); + } + }); + + it("keeps server authorization authoritative when the UI ignores its own hint", async () => { + // Signed in, but holding no grant for the module's restricted permission. + const harness = await startHarness({ + grants: [], + principal: createAuthenticatedPrincipal(viewerId), + }); + try { + const bootstrap = await bootstrapHost(harness.config); + const descriptor = bootstrap.modules[0]; + if (!descriptor) { + throw new Error("the inventory returned no module"); + } + + // The hint is honest about it... + expect(descriptor.permissions).toEqual([]); + const context = createModuleUiContext({ + config: harness.config, + descriptor, + principal: bootstrap.principal, + }); + expect(context.permissions.has("example.records.read")).toBe(false); + + // ...and the hint is not what protects the route. Calling anyway — exactly what a buggy or + // hostile UI would do — is still refused by the server. + const response = await context.api.request("records"); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]); + } finally { + await harness.stop(); + } + }); + + it("renders the module's own denied state from the hint, without calling the API", async () => { + const harness = await startHarness({ + grants: [], + principal: createAuthenticatedPrincipal(viewerId), + }); + try { + const bootstrap = await bootstrapHost(harness.config); + const descriptor = bootstrap.modules[0]; + if (!descriptor) { + throw new Error("the inventory returned no module"); + } + const artifactUrl = await publishedArtifactUrl( + harness, + moduleUiSourceUrl(harness.config, descriptor), + ); + const container = containerSink(); + + await loadModuleUi({ + container: container as unknown as LoaderContainer, + context: createModuleUiContext({ + config: harness.config, + descriptor, + principal: bootstrap.principal, + }), + source: { moduleId: descriptor.id, url: artifactUrl, version: descriptor.version }, + }); + + // Hiding a control is a usability improvement: the module simply did not ask. + expect(container.textContent).toBe("example:denied"); + expect(harness.moduleApiCalls).toEqual([]); + } finally { + await harness.stop(); + } + }); + + it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => { + const harness = await startHarness(); + try { + const bootstrap = await bootstrapHost(harness.config); + + expect(bootstrap.principal).toEqual({ kind: "anonymous" }); + expect(bootstrap.modules).toEqual([]); + } finally { + await harness.stop(); + } + }); + + it("refuses a module UI that tries to address another module's API through the host", async () => { + const harness = await startHarness({ + grants: ["example.records.read"], + principal: createAuthenticatedPrincipal(viewerId), + }); + try { + const bootstrap = await bootstrapHost(harness.config); + const descriptor = bootstrap.modules[0]; + if (!descriptor) { + throw new Error("the inventory returned no module"); + } + const context = createModuleUiContext({ + config: harness.config, + descriptor, + principal: bootstrap.principal, + }); + + await expect(context.api.request("../../grants?userId=someone")).rejects.toMatchObject({ + code: "MODULE_API_PATH_INVALID", + }); + // It never became a request at all. + expect(harness.moduleApiCalls).toEqual([]); + } finally { + await harness.stop(); + } + }); +}); diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts new file mode 100644 index 0000000..8a26714 --- /dev/null +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -0,0 +1,500 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + type EkklesiaPrincipal, + type ModuleDatabase, + type ModuleRuntimeServices, + parseEkklesiaUserId, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + createAuthorizationEvaluator, + type PermissionRegistry, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, + type DiscoveredModule, + discoverModules, +} from "@ekklesia/core/modules"; +import type { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; +import { moduleApiBaseUrl } from "../src/module-http-routes.js"; +import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; +import { moduleUiArtifactUrl } from "../src/module-ui-routes.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111"); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +interface ModuleFixture { + readonly id: string; + readonly version?: string; + /** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */ + readonly withUi?: boolean; + readonly permissions?: ReadonlyArray<{ + readonly id: string; + readonly baseline: "authenticated" | "public" | "restricted"; + }>; +} + +async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { + const version = fixture.version ?? "0.1.0"; + const moduleDirectory = path.join(modulesDirectory, fixture.id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${fixture.id}`, + type: "module", + version, + }), + ); + await writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { + server: "./dist/server/index.js", + ...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}), + }, + id: fixture.id, + manifestVersion: 4, + name: `${fixture.id} module`, + permissions: (fixture.permissions ?? []).map((permission) => ({ + ...permission, + description: `Test permission for ${fixture.id}.`, + })), + version, + }), + ); + await writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`, + ); + if (fixture.withUi) { + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + await writeFile( + path.join(moduleDirectory, "dist/ui/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`, + ); + } +} + +interface InstallOptions { + readonly fixtures: readonly ModuleFixture[]; + /** Anonymous by default, so every scenario has to opt in to being signed in. */ + readonly principal?: EkklesiaPrincipal; + /** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so + * baselines, the owner bypass, and deny-by-default all behave exactly as in production. */ + readonly grants?: readonly string[]; + readonly platformOwner?: boolean; + /** Makes the grant port fail, so the "a hint that could not be determined is never reported as + * denied" path can be exercised. */ + readonly grantsUnavailable?: boolean; +} + +interface Installation { + readonly app: Hono; + readonly modules: readonly DiscoveredModule[]; + readonly modulesDirectory: string; + readonly permissionRegistry: PermissionRegistry; +} + +/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the + * real publication registry, the real authorization evaluator, and the real `createApiApp`. Only + * identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL + * to answer the questions these tests ask. */ +async function install(options: InstallOptions): Promise { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-")); + temporaryDirectories.push(modulesDirectory); + for (const fixture of options.fixtures) { + await createModule(modulesDirectory, fixture); + } + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + const granted = new Set(options.grants ?? []); + const evaluator = createAuthorizationEvaluator({ + grants: { + hasActiveGrant: async (query) => { + if (options.grantsUnavailable) { + throw new Error("grant store unavailable"); + } + return granted.has(query.permission); + }, + }, + platformOwners: { isPlatformOwner: async () => options.platformOwner === true }, + }); + + const runtime: EkklesiaIdentityRuntime = { + handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), + resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL), + }; + const store = { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + } as unknown as AuthorizationStore; + const services: ModuleRuntimeServices = Object.freeze({ + database: Object.freeze({}) as unknown as ModuleDatabase, + }); + const permissionRegistry = composePlatformPermissionRegistry(modules); + + const app = createApiApp({ + authorization: { evaluator, registry: permissionRegistry, store }, + moduleRoutes: Object.freeze([]), + moduleServices: services, + moduleUi: createModuleUiPublicationRegistry(modules), + runtime, + trustedOrigins: [trustedOrigin], + }); + + return { app, modules, modulesDirectory, permissionRegistry }; +} + +function signedIn(): EkklesiaPrincipal { + return createAuthenticatedPrincipal(viewerId); +} + +async function inventoryOf(app: Hono): Promise<{ + readonly body: string; + readonly modules: ReadonlyArray>; + readonly response: Response; +}> { + const response = await app.request(MODULE_UI_INVENTORY_PATH); + const body = await response.clone().text(); + const parsed = (await response.json()) as { modules?: ReadonlyArray> }; + return { body, modules: parsed.modules ?? [], response }; +} + +describe("module UI inventory — what it lists", () => { + it("describes a publishable module with exactly the fields a host needs", async () => { + const { app } = await install({ + fixtures: [{ id: "example", permissions: [], withUi: true }], + principal: signedIn(), + }); + + const { modules, response } = await inventoryOf(app); + + expect(response.status).toBe(200); + expect(modules).toEqual([ + { + api: { baseUrl: "/api/v1/modules/example" }, + id: "example", + permissions: [], + ui: { url: "/api/v1/module-ui/example/0.1.0" }, + version: "0.1.0", + }, + ]); + }); + + it("builds both URLs with the server's own canonical helpers", async () => { + const { app, modules: discovered } = await install({ + fixtures: [{ id: "example", version: "2.4.1", withUi: true }], + principal: signedIn(), + }); + const module = discovered[0] as DiscoveredModule; + + const { modules } = await inventoryOf(app); + + // Not "looks like the right shape" — byte-identical to what the helpers produce, so a second + // hand-written template string anywhere would fail here. + expect(modules[0]?.ui).toEqual({ + url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }), + }); + expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) }); + }); + + it("omits a module that declares no UI entrypoint", async () => { + const { app } = await install({ + fixtures: [{ id: "calendar" }, { id: "example", withUi: true }], + principal: signedIn(), + }); + + const { modules } = await inventoryOf(app); + + expect(modules.map((module) => module.id)).toEqual(["example"]); + }); + + it("is empty, not an error, when no installed module provides a UI", async () => { + const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() }); + + const { modules, response } = await inventoryOf(app); + + expect(response.status).toBe(200); + expect(modules).toEqual([]); + }); + + it("orders modules deterministically, matching the publication registry", async () => { + const { app } = await install({ + fixtures: [ + { id: "signage", withUi: true }, + { id: "announcements", withUi: true }, + { id: "calendar", withUi: true }, + ], + principal: signedIn(), + }); + + const first = await inventoryOf(app); + const second = await inventoryOf(app); + + // Discovery order, which is sorted; the same on every request. + expect(first.modules.map((module) => module.id)).toEqual([ + "announcements", + "calendar", + "signage", + ]); + expect(second.modules.map((module) => module.id)).toEqual( + first.modules.map((module) => module.id), + ); + }); + + it("leaks no filesystem path, package root, or other manifest internal", async () => { + const { + app, + modules: discovered, + modulesDirectory, + } = await install({ + fixtures: [{ id: "example", permissions: [], withUi: true }], + principal: signedIn(), + }); + const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string; + + const { body, modules } = await inventoryOf(app); + + expect(body).not.toContain(modulesDirectory); + expect(body).not.toContain(artifactPath); + expect(body).not.toContain(tmpdir()); + expect(body).not.toContain("dist"); + expect(body).not.toContain("@test/module-example"); + expect(body).not.toContain("ekklesia.module.json"); + // The exact key set, so a future field cannot be added without a deliberate decision here. + expect(Object.keys(modules[0] ?? {}).sort()).toEqual([ + "api", + "id", + "permissions", + "ui", + "version", + ]); + }); + + it("carries no navigation or presentation metadata", async () => { + const { app } = await install({ + fixtures: [{ id: "example", withUi: true }], + principal: signedIn(), + }); + + const { body } = await inventoryOf(app); + + // ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module + // declaration. The manifest's own `name` and `description` must not leak into it by accident. + for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) { + expect(body, field).not.toContain(`"${field}"`); + } + }); +}); + +describe("module UI inventory — authentication", () => { + it("refuses an anonymous caller", async () => { + const { app } = await install({ fixtures: [{ id: "example", withUi: true }] }); + + const response = await app.request(MODULE_UI_INVENTORY_PATH); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + }); + + it("tells an anonymous caller nothing about what is installed", async () => { + const { app } = await install({ + fixtures: [ + { id: "example", withUi: true }, + { id: "calendar", withUi: true }, + ], + }); + + const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text(); + + expect(body).not.toContain("example"); + expect(body).not.toContain("calendar"); + }); + + it("admits any signed-in user without needing an explicit grant", async () => { + // `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell + // that rendered nothing until an operator granted every user a permission would be unusable. + const { app } = await install({ + fixtures: [{ id: "example", withUi: true }], + grants: [], + principal: signedIn(), + platformOwner: false, + }); + + expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200); + }); + + it("is never cached and varies on the caller's identity", async () => { + const { app } = await install({ + fixtures: [{ id: "example", withUi: true }], + principal: signedIn(), + }); + + const response = await app.request(MODULE_UI_INVENTORY_PATH, { + headers: { origin: trustedOrigin }, + }); + + expect(response.headers.get("cache-control")).toBe("no-store"); + const vary = response.headers.get("vary")?.toLowerCase() ?? ""; + expect(vary).toContain("origin"); + expect(vary).toContain("cookie"); + }); +}); + +describe("module UI inventory — permission hints", () => { + const readPermission = { baseline: "restricted" as const, id: "example.records.read" }; + const writePermission = { baseline: "restricted" as const, id: "example.records.write" }; + + it("returns only the permissions the server actually granted this principal", async () => { + const { app } = await install({ + fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }], + grants: ["example.records.read"], + principal: signedIn(), + }); + + const { modules } = await inventoryOf(app); + + expect(modules[0]?.permissions).toEqual(["example.records.read"]); + }); + + it("returns none of them for a signed-in user holding no grant", async () => { + const { app } = await install({ + fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }], + grants: [], + principal: signedIn(), + }); + + const { modules } = await inventoryOf(app); + + // Never a default of true, and never the module's full declared surface. + expect(modules[0]?.permissions).toEqual([]); + }); + + it("honours a baseline that needs no grant at all", async () => { + const { app } = await install({ + fixtures: [ + { + id: "example", + permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission], + withUi: true, + }, + ], + grants: [], + principal: signedIn(), + }); + + const { modules } = await inventoryOf(app); + + expect(modules[0]?.permissions).toEqual(["example.records.list"]); + }); + + it("honours the platform-owner bypass, exactly like a real request would", async () => { + const { app } = await install({ + fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }], + grants: [], + platformOwner: true, + principal: signedIn(), + }); + + const { modules } = await inventoryOf(app); + + expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]); + }); + + it("scopes hints to each module's own declared permissions", async () => { + const { app } = await install({ + fixtures: [ + { id: "example", permissions: [readPermission], withUi: true }, + { + id: "calendar", + permissions: [{ baseline: "restricted", id: "calendar.events.read" }], + withUi: true, + }, + ], + grants: ["example.records.read", "calendar.events.read"], + principal: signedIn(), + }); + + const { modules } = await inventoryOf(app); + + const byId = new Map(modules.map((module) => [module.id, module.permissions])); + // Neither module learns anything about the other's authorization state, and neither is told + // about Core's own permissions. + expect(byId.get("example")).toEqual(["example.records.read"]); + expect(byId.get("calendar")).toEqual(["calendar.events.read"]); + expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage"); + expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read"); + }); + + it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => { + const { app } = await install({ + fixtures: [{ id: "example", permissions: [readPermission], withUi: true }], + grantsUnavailable: true, + principal: signedIn(), + }); + + const response = await app.request(MODULE_UI_INVENTORY_PATH); + + // A hint that silently read "denied" during an outage would blank the UI and look like a + // permissions problem. + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); + }); +}); + +describe("module UI inventory — it is not a module management API", () => { + it("routes no method other than GET", async () => { + const { app } = await install({ + fixtures: [{ id: "example", withUi: true }], + principal: signedIn(), + }); + + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + const response = await app.request(MODULE_UI_INVENTORY_PATH, { method }); + expect(response.status, method).toBe(404); + expect(await response.json(), method).toEqual({ code: "NOT_FOUND" }); + } + }); + + it("still refuses to serve an artifact under the collection path", async () => { + const { app } = await install({ + fixtures: [{ id: "example", withUi: true }], + principal: signedIn(), + }); + + for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) { + const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`); + expect(response.status, suffix).toBe(404); + } + }); +}); diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index d49690b..c8ad2ae 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -440,9 +440,10 @@ describe("module UI artifact publication — filesystem exposure", () => { // A trailing segment of any kind, including an empty one. `${base}/0.1.0/`, `${base}/0.1.0/anything`, - // Fewer segments than the route declares. + // Fewer segments than the route declares. The collection root itself is deliberately absent + // from this list: it is the authenticated inventory (ADR 0026) rather than a 404, and is + // asserted separately below. base, - MODULE_UI_ASSET_PREFIX, ]; for (const url of unreachable) { @@ -456,6 +457,21 @@ describe("module UI artifact publication — filesystem exposure", () => { } }); + it("serves no artifact bytes from the collection root, which is the authenticated inventory", async () => { + const { app } = await installWithNeighbours(); + + // `/api/v1/module-ui` lists what a host may mount (ADR 0026) and requires + // `core.modules.ui.read`; this installation's caller is anonymous, so it is refused. Either way + // it never yields a byte of any artifact or neighbouring file. + const response = await app.request(MODULE_UI_ASSET_PREFIX); + const body = await response.text(); + + expect(response.status).toBe(403); + expect(body).not.toContain("mount"); + expect(body).not.toContain("secret"); + expect(body).not.toContain("internal"); + }); + it("treats a traversal attempt as an ordinary failed lookup", async () => { const { app } = await installWithNeighbours(); diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index e81e56f..bf08da1 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -83,6 +83,21 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId( "core.authorization.grants.manage", ); +/** + * Authenticated: any signed-in user of this installation may learn which module UIs the platform is + * currently hosting, because that is exactly what a browser shell has to render before it can offer + * anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is + * careful never to become an enumeration oracle, and an anonymous inventory would hand over the very + * listing that route withholds. It is deliberately not `restricted` either: a shell that rendered + * nothing until an operator granted every user this permission would make the platform unusable for + * its ordinary users, and the module UIs themselves already gate their own content on their own + * permissions. + * + * Listing a module UI is not permission to use that module. Every module route remains enforced + * against its own declared permission, unchanged. + */ +export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read"); + /** * Core's own declarations, exported in deeply immutable form so a trusted composer (the module * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from @@ -96,6 +111,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob description: "Create, list, and revoke another user's explicit authorization grants.", id: CORE_AUTHORIZATION_GRANTS_MANAGE, }), + Object.freeze({ + baseline: "authenticated" as const, + description: "List the installed module UIs a browser host may mount.", + id: CORE_MODULES_UI_READ, + }), ]); /** diff --git a/core/src/modules/ui-publication.ts b/core/src/modules/ui-publication.ts index eebea6a..eb34e20 100644 --- a/core/src/modules/ui-publication.ts +++ b/core/src/modules/ui-publication.ts @@ -1,3 +1,4 @@ +import type { PermissionDeclaration } from "@ekklesia/contracts"; import type { DiscoveredModule } from "./discovery.js"; /** @@ -14,6 +15,19 @@ export interface PublishableModuleUi { readonly moduleId: string; readonly version: 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[]; } /** @@ -78,6 +92,9 @@ export function createModuleUiPublicationRegistry( Object.freeze({ artifactPath, moduleId: module.manifest.id, + // 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, }), ); diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index d6574aa..a0ee44e 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts"; import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_REGISTRY, createPermissionRegistry, @@ -89,8 +90,21 @@ describe("CORE_PERMISSION_REGISTRY", () => { expect(declaration?.id).toBe("core.authorization.grants.manage"); }); - it("contains no other permission in this slice", () => { - expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1); + it("registers core.modules.ui.read as authenticated, not restricted", () => { + const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_UI_READ); + expect(declaration).toBeDefined(); + // Any signed-in user may learn which module UIs the browser host can mount; a shell that + // rendered nothing until an operator granted each user a permission would be unusable, and the + // module UIs gate their own content on their own permissions regardless (ADR 0026). + expect(declaration?.baseline).toBe("authenticated"); + expect(declaration?.id).toBe("core.modules.ui.read"); + }); + + it("contains exactly these two permissions and no others", () => { + expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([ + CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_MODULES_UI_READ, + ]); }); }); @@ -102,9 +116,15 @@ describe("CORE_PERMISSION_DECLARATIONS", () => { description: "Create, list, and revoke another user's explicit authorization grants.", id: CORE_AUTHORIZATION_GRANTS_MANAGE, }, + { + baseline: "authenticated", + description: "List the installed module UIs a browser host may mount.", + id: CORE_MODULES_UI_READ, + }, ]); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true); + expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true); }); it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => { diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index ffdda2a..4745f4a 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -78,7 +78,7 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 1 permission registered + 2 permissions registered Data definitions ✓ Schemas validated diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index 5a92641..4c505f6 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -2,6 +2,7 @@ import { type PermissionBaseline, parseModuleManifest } from "@ekklesia/contract import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, PermissionRegistryError, } from "../src/authorization/index.js"; @@ -63,9 +64,11 @@ describe("composePlatformPermissionRegistry", () => { const registry = composePlatformPermissionRegistry([calendar, people]); expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true); + expect(registry.has(CORE_MODULES_UI_READ)).toBe(true); expect(registry.has("calendar.events.read")).toBe(true); expect(registry.has("people.directory.read")).toBe(true); - expect(registry.declarations).toHaveLength(3); + // Core's own two declarations plus one from each module. + expect(registry.declarations).toHaveLength(4); }); it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { @@ -81,6 +84,7 @@ describe("composePlatformPermissionRegistry", () => { expect(registry.declarations.map((declaration) => declaration.id)).toEqual([ CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_MODULES_UI_READ, "calendar.events.read", "calendar.events.write", "people.directory.read", diff --git a/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md new file mode 100644 index 0000000..57e2569 --- /dev/null +++ b/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md @@ -0,0 +1,310 @@ +# 0026: The minimal Ekklesia module UI host + +- Status: Accepted +- Date: 2026-08-22 +- Extends: [0021](0021-modules-as-hostable-products.md), + [0022](0022-module-ui-authoring-and-host-contract.md), + [0024](0024-browser-safe-ui-sdk-and-host-loader.md), + [0025](0025-trusted-module-ui-asset-publication.md) + +## Context + +Every link in the module UI chain existed except the one that closes it. A module could be authored +in React (ADR 0022), compiled to a declared artifact (ADR 0023), validated by discovery (ADR 0021), +published at a browser URL (ADR 0025), and loaded and mounted by a generic loader (ADR 0024) — and +nothing in Ekklesia did any of it. There was no browser application at all: `apps/api` was the only +app, `surfaces/` was a workspace pattern matching an empty set, and no code anywhere called +`loadModuleUi` outside its own tests. + +Two things were missing, and only one of them is the browser. + +**A browser cannot discover what to mount.** ADR 0025 publishes an artifact at +`/api/v1/module-ui//`, but a host has to already know both halves of that +identity to ask for it, and the route is deliberately an oracle for neither. Nothing told a browser +which modules exist. + +**And a browser must not work it out for itself.** A host that scanned, guessed, or hardcoded module +identities — or that rebuilt `/api/v1/modules//…` to resolve a module's relative API calls — +would put Ekklesia's private deployment details in a second place and quietly undo ADR 0022's +portability rule one layer up. + +## Decision + +### The shell is a surface, and it is the first one + +`surfaces/shell` (`@ekklesia/shell`). ADR 0001 gives surfaces "the containing application shell, +layout, navigation, accessibility, interaction, branding, and client state — including the shell +that hosts a module's own UI," which is exactly this and nothing else. No new layer, no new app: it +is a static browser application built from its own source, and `apps/*` remains the home of +deployable *servers*. + +It is called `shell` rather than `dashboard` deliberately. **This is not the Dashboard**, and naming +it after the thing it is not yet would make every later document quietly overstate what exists. A +shell is the honest description of what it is now and stays accurate when navigation is added on top. + +### The host inventory is the smallest thing that makes discovery possible + +```text +GET /api/v1/module-ui → what may be mounted +GET /api/v1/module-ui// → one artifact's bytes (ADR 0025) +``` + +One prefix, one resource, two representations — the collection root of the URL space ADR 0025 +already owns, rather than a second parallel namespace. + +```json +{ + "modules": [ + { + "id": "example", + "version": "0.1.0", + "ui": { "url": "/api/v1/module-ui/example/0.1.0" }, + "api": { "baseUrl": "/api/v1/modules/example" }, + "permissions": ["example.records.read"] + } + ] +} +``` + +Five fields, each because constructing a `ModuleUiHostContext` requires it. No filesystem path, +package root, package name, server/schema/migrations entrypoint, capability, dependency, or storage +bucket. And no `name`, `icon`, `description`, `category`, `folder`, or `order`: those are +*navigation* metadata, and ADR 0021 made Dashboard grouping presentation configuration an +administrator owns rather than something a module declares. A display field added here now would +become module-owned navigation before that design exists, so the temporary selector renders module +ids instead. + +It is not a module management API. There is no create, update, enable, disable, install, or +configure, and no module that declares no UI. Ekklesia has no enable/disable state to report — +`ModuleLifecycleStage` remains vocabulary with no runtime state machine behind it — and reporting one +would mean inventing it. + +### Both URLs are server-produced, and that is the point + +`ui.url` comes from `moduleUiArtifactUrl` (ADR 0025) and `api.baseUrl` from `moduleApiBaseUrl`, a new +export of the module HTTP router that `moduleRoutePath` itself now builds on. Each URL shape is +therefore constructed in exactly one place in the repository, on the server, and the browser is a +consumer of both rather than a second author. + +`moduleApiBaseUrl` is the less obvious half and the more important one. Without it the shell would +hardcode `/api/v1/modules/` to resolve `context.api.request("records")` — the very prefix ADR +0022 forbids a module UI from spelling, reappearing in the host instead. Publication routing and the +module mount prefix can now both change without touching browser or module code. + +### The module set is the publication registry, never a rescan + +The inventory calls `registry.list()` — the exact accepted set ADR 0025 serves artifacts from: +modules that survived discovery and declare `entrypoints.ui`, captured after the module runtime +started. It performs no discovery, reads no manifest, and touches no filesystem, so the inventory and +the artifact route cannot disagree about what exists. Order is the registry's own deterministic +discovery order. + +`PublishableModuleUi` gained the module's manifest-declared `permissions` so the inventory can +evaluate hints without reaching back into the manifest and re-deriving publishability — which would +put the publication policy in two places. + +### The inventory is authenticated; the artifact stays anonymous + +ADR 0025 made artifact bytes anonymous because fetching compiled UI code confers no authority, and +was careful that the route never became an enumeration oracle: unknown module, no UI, and wrong +version collapse into one indistinguishable `404`. + +**This endpoint is that enumeration**, so serving it anonymously would hand over exactly what that +route withholds. It requires a new Core permission, `core.modules.ui.read`, baseline +`authenticated`: any signed-in user of the installation, and nobody else. + +`authenticated` rather than `restricted` on purpose. A shell that rendered nothing until an operator +granted every user a permission would be unusable, and each module UI already gates its own content +on its own permissions. Enforcement goes through the same `requirePermission` middleware every other +protected route uses — no second browser-auth mechanism — which also resolves the principal once and +keeps a denial (`403`) distinct from an infrastructure failure (`503`). + +### Permission hints are server-evaluated, module-scoped, and granted-only + +For each listed module the server evaluates that module's **own declared permissions** against the +caller, through Core's one authorization evaluator, and returns only the granted ids. Baselines, the +platform-owner bypass, grant expiry, and deny-by-default therefore behave exactly as they do on a +real request; there is no second, browser-only interpretation of what a permission means. + +Three properties matter. Nothing defaults to `true`. Nothing is inferred from a client-side role +name. And no module learns anything about the viewer's authorization anywhere else in the platform — +not Core's permissions, not another module's. + +An evaluation failure is a `503`, never an empty hint set: a hint that silently read "denied" during +a database outage would blank the UI and look like a permissions problem. + +**These are presentation hints, not authority.** The end-to-end test proves the distinction directly: +a viewer holding no grant gets `permissions: []`, and calling the module's API anyway — exactly what +a buggy or hostile UI would do — is still refused `403` by the server. + +### The host context is built from server-derived data, and nothing else + +`createModuleUiContext` produces the four members ADR 0022 defined: `moduleId` and the API client +from the inventory descriptor, `principal` from the existing `/api/v1/principal` narrowed to the +browser-owned `ModuleUiPrincipal`, and `permissions` from the granted set. Bootstrap is two requests +against endpoints that already existed or were added here, in one explicit sequence, with no hidden +globals — the API origin is passed in as a plain argument, so exactly one file in the package touches +`window`. + +The principal is narrowed rather than forwarded: a server that later returns an email, a role list, +or a session token adds nothing to what module UI receives. + +### Module-relative API resolution is enforced, not just documented + +`context.api.request("records")` becomes `/api/v1/modules/example/records`, with the module's +own `RequestInit` forwarded whole and `credentials: "include"` imposed — session handling is the +host's job and a module UI never sees or supplies a token. + +Containment is checked with the URL parser rather than string inspection, because the parser is what +a real `fetch` would use: `"../calendar/records"` normalizes into another module's API, and a check +that only looked at the input string would miss it. A traversal, a protocol-relative `//host`, or an +absolute URL is refused before it becomes a request. + +This is a portability boundary, not a security one. Module UI is trusted same-realm code and could +call `fetch` directly; refusing here keeps a module honest about addressing only its own backend, so +the same artifact still runs under a standalone host. Server authorization is unaffected either way. + +### One frame per selection, which is what defeats the stale-load race + +The shell renders `ModuleUiFrame` with a `key` of the module's identity, so selecting a different +module discards the component **and its container element** rather than reusing one across two +modules. A slow import that resolves after the selection changed therefore mounts into a node already +detached from the document, and the frame's own `cancelled` flag unmounts it immediately. + +Both halves are load-bearing, and both are proven by removing them: without the key, a stale load +wipes the newer selection's content; without the `cancelled` unmount, a stale module stays running in +a detached container with no handle to stop it. + +### The host owns the container, the loading state, and every failure + +The mount target is a `
` the shell creates inside its own layout — never the page root, the +header, or the module list. Loading and error states render as siblings, so a module can never own +the shell's pre-mount or failure state. + +Every failure path — inventory, principal, import, export validation, identity mismatch, mount — is +caught and turned into a fixed, host-owned message. Nothing from the caught error reaches the DOM: +not a URL, not a path, not a stack. The error object goes to the browser console, where ADR 0024 +intends the loader's `cause` to be read. A broken module costs its own panel and nothing else; the +shell keeps rendering and another module still mounts. + +Lifecycle semantics are the loader's, not re-implemented: a failed mount registers no cleanup, and a +throwing cleanup is swallowed during teardown and never re-invoked. + +### No static module imports, enforced at two levels + +The shell imports no module, and its bundle is checked for it. At source level, no import may name a +module, a template, or any `@ekklesia/*` package other than `@ekklesia/module-ui-host` and +`@ekklesia/module-ui-sdk`. At bundle level — the level that would fail invisibly — the emitted +application must contain exactly **one** dynamic import, the loader's own `(url) => import(url)`, and +no module id, no `/api/v1/modules`, no `eval`, no `new Function`, and no `createObjectURL`. + +The bundle check exists because the source check cannot catch the dangerous case. A dynamic import a +bundler managed to resolve statically would look correct, pass every other test, and silently make +newly installed modules unloadable until the shell was rebuilt. + +### The surface boundary excludes `@ekklesia/contracts` + +A surface may now depend only on browser-safe shared roles plus dev-time tooling. `contracts` is +deliberately absent even though the boundaries table called it a "public contract" package: its +barrel pulls Drizzle's types, and with them Node's global declarations, into any program that imports +it — measured in ADR 0024, which removed exactly that import from `@ekklesia/module-ui-sdk` to close +the browser boundary. Allowing it for surfaces would reopen the same hole one layer up. `module-sdk` +re-exports that barrel and is excluded for the same reason. + +A surface talks to the server over HTTP and validates what arrives. The shell therefore declares its +own descriptor type and parses the inventory strictly rather than importing the server's type; the +end-to-end test feeds a real server response through that parser, so the two declarations are proven +to agree instead of merely looking similar. + +### The host build is not the module UI build + +Both use esbuild; they share no configuration, contract, or output. `@ekklesia/module-ui-build` +compiles a *module's* declared artifact and is owned by the platform so module authors write no +bundler configuration; `scripts/build-browser.mjs` builds *this application* and knows nothing about +manifests. Installed module UIs are never bundled into the shell — there is no import for a bundler +to follow — and the API never builds frontend source at run time. + +## Alternatives + +- **Putting the shell in `apps/*`.** Rejected: `apps/*` is for deployable composition roots, and ADR + 0001 already assigns the containing application shell to a surface. A static SPA needs no server + composition root. +- **Calling it `surfaces/dashboard`.** Rejected: it would name the thing this pass explicitly does + not build, and every document describing it would then overstate what exists. +- **Nesting the inventory under `/api/v1/modules`.** Rejected: that root reads as a general module + management API and invites exactly the growth this endpoint must not have. +- **A separate per-module context endpoint** returning principal and permissions at mount time. + Rejected: it adds a request and a second endpoint to deliver data the inventory already has to + compute, and the inventory is per-principal regardless. +- **Returning every declared permission with an `allowed` boolean.** Rejected: granted-only is + smaller, answers `has()` exactly, and does not enumerate a module's full permission surface to + someone who holds none of it. +- **Returning the platform's whole granted permission set.** Rejected: no module has any business + learning what the viewer may do elsewhere, and the manifest already scopes the useful answer. +- **An anonymous inventory.** Rejected: it is precisely the enumeration ADR 0025 withheld from the + anonymous artifact route, and serving it would undo that decision by the back door. +- **A hand-rolled "must be signed in" check** instead of a Core permission. Rejected: it would be a + second auth mechanism, and it would have to re-derive `requirePermission`'s own `403`-vs-`503` + distinction correctly. A permission with an `authenticated` baseline is that check, declared. +- **Letting the shell build `/api/v1/modules/` itself.** Rejected as the most tempting wrong + answer: it moves Ekklesia's private prefix into a second codebase and makes the module API mount + point unchangeable in practice. +- **Vite for the host build.** Rejected for now: esbuild is already this repository's browser bundler + and its only allowed build script, and a second toolchain buys HMR that a proof does not need. +- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus + module routing is a design this pass should not settle by accident. +- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs + to the host, and adopting it here for a two-item selector would prejudge that design. +- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one + element shared between two modules, which is exactly the state a stale load exploits. +- **Rendering the loader's error code in the UI.** Rejected for now: the codes are sanitized and + useful, but they are developer diagnostics, and the console already carries the whole error. + +## Consequences + +Ekklesia has a browser host that mounts module-owned UI it knew nothing about at build time. The +chain is proven end to end against real code at every step: a module fixture on disk, Core's real +discovery, its real module runtime *started*, the real publication registry, the real inventory +endpoint, the shell's own bootstrap and context construction, the generic loader, a mount, a +module-relative API call resolved onto Ekklesia's mount prefix, and the server still enforcing +authorization. + +**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home +screen, design system, or routing. The selector is a list of module ids. What exists is the +architecture beneath a Dashboard, not a Dashboard. + +Node cannot `import()` an `http:` URL, so the end-to-end proof is split honestly rather than faked: +the artifact is fetched over the real publication route and the **bytes it returned** are handed to +the loader through a `data:` URL. No test reads the artifact off the filesystem and calls that a +proof of publication. + +Four things changed outside the new code. `createApiApp` mounts one more route. `PublishableModuleUi` +carries declared permissions. `moduleApiBaseUrl` is exported. And Core has a second permission, which +moved five existing assertions about "exactly one Core permission" — each updated to name both +permissions rather than to loosen a count. + +A note on cost: the inventory evaluates every listed module's declared permissions on every request, +and a `restricted` one costs two database reads. For a handful of modules that is unremarkable; a +platform with many modules and many declarations would want the evaluator to answer in batches. It is +measured work, not a hidden one. + +The shell sets no Content-Security-Policy. It needs none of the things a strict one forbids — no +`eval`, no `new Function`, no `blob:`, no inline ` + + diff --git a/surfaces/shell/package.json b/surfaces/shell/package.json new file mode 100644 index 0000000..34f3e11 --- /dev/null +++ b/surfaces/shell/package.json @@ -0,0 +1,42 @@ +{ + "name": "@ekklesia/shell", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json && node scripts/build-browser.mjs", + "dev": "node scripts/build-browser.mjs --serve", + "lint": "biome lint .", + "test": "vitest run", + "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json" + }, + "dependencies": { + "@ekklesia/module-ui-host": "workspace:*", + "@ekklesia/module-ui-sdk": "workspace:*", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@biomejs/biome": "2.5.3", + "@ekklesia/typescript-config": "workspace:*", + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "esbuild": "0.28.1", + "happy-dom": "20.0.11", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "ekklesia": { + "layer": "surface" + } +} diff --git a/surfaces/shell/scripts/build-browser.mjs b/surfaces/shell/scripts/build-browser.mjs new file mode 100644 index 0000000..5396e47 --- /dev/null +++ b/surfaces/shell/scripts/build-browser.mjs @@ -0,0 +1,75 @@ +import { copyFile, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import esbuild from "esbuild"; + +/** + * Builds the Ekklesia shell into a static browser application. + * + * This is **not** the module UI build pipeline, and the two must not be conflated. ADR 0023's + * `@ekklesia/module-ui-build` compiles a *module's* `src/ui/index.tsx` into the artifact its + * manifest declares, is owned by the platform so module authors write no bundler configuration, and + * runs when a module is packaged. This script builds *this application*, is owned by this package + * alone, and knows nothing about manifests. They happen to share a bundler; they share no + * configuration, no contract, and no output. + * + * Installed module UIs are deliberately **not** bundled in. The shell has no import of any module, + * so there is nothing for esbuild to reach: every module artifact is fetched at run time from the + * URL the inventory returned. That is the property that makes modules independently installable, and + * bundling one would silently destroy it. + */ +const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const outputDirectory = path.join(packageDirectory, "dist", "browser"); +const serve = process.argv.includes("--serve"); + +/** + * Empty means "the API is on this page's own origin" — the expected production shape. Set it to an + * absolute origin for split-origin development (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and + * add this page's own origin to `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS + * and the session cookie travels. + */ +const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? ""; + +/** @type {import("esbuild").BuildOptions} */ +const buildOptions = { + absWorkingDir: packageDirectory, + // Everything the shell needs is bundled; a browser resolves nothing at run time except the module + // artifacts it is told about, which are real URLs rather than bare specifiers. + bundle: true, + define: { + __EKKLESIA_API_BASE_URL__: JSON.stringify(apiBaseUrl), + // React reads this at module scope and a browser has no `process`. + "process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"), + }, + entryPoints: [path.join(packageDirectory, "src", "browser", "main.tsx")], + format: "esm", + jsx: "automatic", + logLevel: "info", + // Unminified, matching the module UI build: an operator is expected to be able to read what their + // installation actually serves, and nothing else in this repository minifies. + minify: false, + outfile: path.join(outputDirectory, "app.js"), + platform: "browser", + sourcemap: true, + // Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. + target: "es2022", +}; + +await rm(outputDirectory, { force: true, recursive: true }); +await mkdir(outputDirectory, { recursive: true }); +await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html")); + +if (serve) { + const context = await esbuild.context(buildOptions); + await context.watch(); + const { host, port } = await context.serve({ servedir: outputDirectory }); + console.log(`Ekklesia shell (development) on http://${host}:${port}`); + console.log( + apiBaseUrl === "" + ? "API origin: this page's own origin. Set EKKLESIA_API_BASE_URL for a split-origin API." + : `API origin: ${apiBaseUrl}`, + ); +} else { + await esbuild.build(buildOptions); + console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`); +} diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx new file mode 100644 index 0000000..1afce5e --- /dev/null +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -0,0 +1,175 @@ +import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; +import { type ReactElement, useEffect, useState } from "react"; +import { + bootstrapHost, + type EkklesiaHostConfig, + type HostBootstrap, + HostProtocolError, +} from "../host-protocol.js"; +import { ModuleUiFrame } from "./ModuleUiFrame.js"; + +type BootstrapState = + | { readonly status: "loading" } + | { readonly status: "ready"; readonly bootstrap: HostBootstrap } + | { readonly status: "signed-out" } + | { readonly status: "failed" }; + +export interface HostAppProps { + /** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal + * built during render would re-fetch forever. The browser entry point builds it once at module + * scope. */ + readonly config: EkklesiaHostConfig; + /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */ + readonly importModule?: ModuleUiImporter; +} + +const shellStyles = { + aside: { borderRight: "1px solid #e7e5e4", minWidth: "12rem", padding: "1rem" }, + header: { + borderBottom: "1px solid #e7e5e4", + fontWeight: 600, + gridColumn: "1 / -1", + padding: "0.75rem 1rem", + }, + layout: { + color: "#1c1917", + display: "grid", + fontFamily: "system-ui, sans-serif", + gridTemplateColumns: "auto 1fr", + minHeight: "100vh", + }, + main: { padding: "1rem" }, + message: { color: "#57534e", padding: "1rem" }, +} as const; + +function moduleKey(id: string, version: string): string { + return `${id}@${version}`; +} + +/** + * The minimal Ekklesia browser shell: sign-in awareness, a module list, and one mount target. + * + * **This is not the Dashboard.** There is no navigation model, no grouping or folders, no icons, no + * ordering, no home screen, no design system, and no routing — a selected-module state is the whole + * of it, and the selector renders module ids because the inventory deliberately carries no display + * metadata yet (ADR 0021 keeps that an administrator's presentation configuration, not a module + * declaration). What this proves is the architecture underneath: that a module discovered on the + * server, published as an artifact, and described by the inventory can be mounted here without the + * shell knowing anything about it in advance. + * + * Nothing about any specific module appears in this file, or anywhere in this package. There is no + * import of a module, no module id, no registry, no allowlist, and no fallback bundle. Every module + * the shell can mount arrives at run time from the inventory the server returned. + */ +export function HostApp(props: HostAppProps): ReactElement { + const [state, setState] = useState({ status: "loading" }); + const [selectedKey, setSelectedKey] = useState(undefined); + const { config, importModule } = props; + + useEffect(() => { + const abort = new AbortController(); + setState({ status: "loading" }); + void bootstrapHost(config, abort.signal) + .then((bootstrap) => { + if (abort.signal.aborted) { + return; + } + setState( + bootstrap.principal.kind === "anonymous" + ? { status: "signed-out" } + : { bootstrap, status: "ready" }, + ); + setSelectedKey( + bootstrap.modules[0] + ? moduleKey(bootstrap.modules[0].id, bootstrap.modules[0].version) + : undefined, + ); + }) + .catch((error: unknown) => { + if (abort.signal.aborted) { + return; + } + console.error("[ekklesia] host bootstrap failed", error); + setState( + error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED" + ? { status: "signed-out" } + : { status: "failed" }, + ); + }); + return () => abort.abort(); + }, [config]); + + if (state.status === "loading") { + return

Loading Ekklesia…

; + } + if (state.status === "signed-out") { + return ( +

+ Sign in to Ekklesia to use the module host. +

+ ); + } + if (state.status === "failed") { + return ( +

+ Ekklesia could not be reached. Refresh to try again. +

+ ); + } + + const { bootstrap } = state; + const selected = bootstrap.modules.find( + (module) => moduleKey(module.id, module.version) === selectedKey, + ); + + return ( +
+
Ekklesia
+ +
+ {selected ? ( + // Keyed by module identity, so switching selection discards this frame — and its container + // element — entirely rather than reusing one across two modules. See `ModuleUiFrame` for + // why that is what makes a slow, stale load harmless. + + ) : undefined} +
+
+ ); +} diff --git a/surfaces/shell/src/browser/ModuleUiFrame.tsx b/surfaces/shell/src/browser/ModuleUiFrame.tsx new file mode 100644 index 0000000..10ef3c6 --- /dev/null +++ b/surfaces/shell/src/browser/ModuleUiFrame.tsx @@ -0,0 +1,115 @@ +import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host"; +import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; +import { type ReactElement, useEffect, useRef, useState } from "react"; +import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js"; +import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js"; + +type FrameStatus = "loading" | "ready" | "failed"; + +export interface ModuleUiFrameProps { + readonly config: EkklesiaHostConfig; + readonly descriptor: HostModuleUiDescriptor; + readonly principal: ModuleUiPrincipal; + /** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove + * the stale-load rule. Production composition leaves it unset and the loader's own + * `(url) => import(url)` runs. */ + readonly importModule?: ModuleUiImporter; +} + +/** + * Mounts exactly one module UI into a container this component owns, and tears it down again. + * + * ## Host-owned container, module-owned contents + * + * The `
` below belongs to the shell. The module fills it and must return an unmount that + * empties it; it never receives the shell's header, the module list, the page root, or the document. + * The loading and error states are rendered as siblings, so a module can never own the shell's + * pre-mount or failure state — which is the point of the host owning them. + * + * ## One frame per selection, which is what defeats the stale-load race + * + * The parent renders this with a `key` of the module's identity, so selecting a different module + * unmounts this component entirely and creates a fresh one with a **fresh container element**. A + * slow import that resolves after the selection changed therefore mounts into a node that is already + * detached from the document and is immediately unmounted by this component's own cleanup — it can + * never render over, or into, the newer selection's container. The `cancelled` flag makes that + * explicit rather than incidental: a load that finishes after cleanup ran is torn down at once and + * never reaches `setStatus`, so it also cannot resurrect a stale status on a component React has + * already discarded. + * + * ## Failure is the host's to render + * + * Every way this can fail — the artifact will not fetch, the bundle is not a module, its declared + * identity does not match the manifest, or `mount` throws — is caught here and turned into a fixed, + * host-owned message. Nothing from the caught error reaches the DOM: not a URL, not a path, not a + * stack. The error object goes to the browser console, which is where a developer's diagnostics + * belong and where ADR 0024 intends the loader's `cause` to be read. The rest of the shell keeps + * rendering, so a broken module costs its own panel and nothing else. + */ +export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { + const containerRef = useRef(null); + const [status, setStatus] = useState("loading"); + const { config, descriptor, importModule, principal } = props; + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + let cancelled = false; + let mounted: Awaited> | undefined; + setStatus("loading"); + + void (async () => { + try { + const instance = await loadModuleUi({ + container, + context: createModuleUiContext({ config, descriptor, principal }), + ...(importModule ? { importModule } : {}), + source: { + moduleId: descriptor.id, + url: moduleUiSourceUrl(config, descriptor), + version: descriptor.version, + }, + }); + if (cancelled) { + // Selection already moved on. Release it immediately rather than leaving a module running + // in a detached container with no handle to stop it. + await instance.unmount().catch(() => undefined); + return; + } + mounted = instance; + setStatus("ready"); + } catch (error) { + console.error("[ekklesia] a module UI failed to load", error); + if (!cancelled) { + setStatus("failed"); + } + } + })(); + + return () => { + cancelled = true; + // Idempotent by contract (ADR 0024), and a throwing cleanup is swallowed here: teardown is + // already in progress, and a module that fails to clean up must not take the shell with it. + void mounted?.unmount().catch(() => undefined); + }; + }, [config, descriptor, importModule, principal]); + + return ( +
+ {status === "loading" ? ( +

+ Loading module… +

+ ) : undefined} + {status === "failed" ? ( +

+ This module’s interface could not be loaded. Try another module, or ask an administrator + to check the installation. +

+ ) : undefined} +
+
+ ); +} diff --git a/surfaces/shell/src/browser/main.tsx b/surfaces/shell/src/browser/main.tsx new file mode 100644 index 0000000..8281977 --- /dev/null +++ b/surfaces/shell/src/browser/main.tsx @@ -0,0 +1,31 @@ +import { createRoot } from "react-dom/client"; +import type { EkklesiaHostConfig } from "../host-protocol.js"; +import { HostApp } from "./HostApp.js"; + +/** + * Replaced at build time by `scripts/build-browser.mjs` with a JSON string literal: the API origin + * when `EKKLESIA_API_BASE_URL` was set for the build, and `""` otherwise. Declared rather than read + * from `process.env`, because a browser has no `process` and the substitution must be visible here + * instead of hidden in bundler configuration. + */ +declare const __EKKLESIA_API_BASE_URL__: string; + +/** + * The one place the shell touches a browser global. + * + * An empty build-time value means the API is served from the same origin as this page, which is the + * expected production shape; a non-empty one is the split-origin development shape the platform's + * trusted-origin configuration already supports (`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` must list this + * page's origin for the session cookie to travel). Everything downstream receives the resolved + * origin as a plain argument, so no other file in this package reads `window`. + */ +const config: EkklesiaHostConfig = { + apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin, +}; + +const container = document.getElementById("ekklesia-host"); +if (!container) { + throw new Error("The Ekklesia shell requires a #ekklesia-host element."); +} + +createRoot(container).render(); diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts new file mode 100644 index 0000000..d4101d6 --- /dev/null +++ b/surfaces/shell/src/host-protocol.ts @@ -0,0 +1,247 @@ +import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; + +/** + * How this shell reaches the Ekklesia API. + * + * `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`) + * rather than something read from a global. The browser entry point supplies it — from a build-time + * value when the shell and the API are served from different origins in development, and from + * `window.location.origin` otherwise — so every function below is a pure function of its arguments + * and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global. + */ +export interface EkklesiaHostConfig { + readonly apiBaseUrl: string; + /** Injected so tests can drive the real protocol against a real in-process app without a socket. + * Production composition leaves this unset and the platform `fetch` is used. */ + readonly fetch?: typeof fetch; +} + +/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API + * URL — the server produces both and hands them over in the inventory — so these two are the only + * URLs it knows, and they are the two that describe the platform itself rather than any module. */ +const PRINCIPAL_PATH = "/api/v1/principal"; +const MODULE_UI_INVENTORY_PATH = "/api/v1/module-ui"; + +export type HostProtocolErrorCode = + /** The caller is not signed in, or lacks `core.modules.ui.read`. */ + | "HOST_UNAUTHENTICATED" + /** The API answered, but not with something this shell understands. */ + | "HOST_RESPONSE_INVALID" + /** The API could not be reached, or failed. */ + | "HOST_REQUEST_FAILED" + /** A module UI asked for something outside its own API root. */ + | "MODULE_API_PATH_INVALID"; + +const errorMessages: Readonly> = Object.freeze({ + HOST_REQUEST_FAILED: "Ekklesia could not be reached.", + HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.", + HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.", + MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.", +}); + +/** + * Carries a stable code and a fixed, user-safe message — never a URL, a response body, a server + * path, or a caught cause — matching every other Ekklesia error boundary. The originating error is + * attached as `cause` for browser-console diagnostics and is never rendered. + */ +export class HostProtocolError extends Error { + readonly code: HostProtocolErrorCode; + + constructor(code: HostProtocolErrorCode, options?: { readonly cause?: unknown }) { + super(errorMessages[code], options); + this.name = "HostProtocolError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + } +} + +/** + * One module UI this host may mount, exactly as the server described it. + * + * This mirrors the server's `HostModuleUiDescriptor` (`apps/api/src/module-ui-inventory.ts`) rather + * than importing it: a surface may not depend on an app, and the type on either side of an HTTP + * boundary should be validated on arrival rather than assumed. `parseHostModuleUiInventory` below is + * that validation, and the end-to-end test feeds a real server response through it so the two + * declarations are proven to agree instead of merely looking similar. + */ +export interface HostModuleUiDescriptor { + readonly id: string; + readonly version: string; + /** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader + * unchanged; it never constructs, parses, or pattern-matches this URL. */ + readonly uiUrl: string; + /** Server-produced root of this module's own HTTP API. */ + readonly apiBaseUrl: string; + /** Granted ids only, already evaluated server-side. Presentation hints, never authority. */ + readonly permissions: readonly string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function nestedString(value: unknown, key: string): string | undefined { + return isRecord(value) ? nonEmptyString(value[key]) : undefined; +} + +/** + * Both URLs in a descriptor must be **root-relative** paths, exactly as the server produces them. + * + * Enforced rather than assumed. The shell resolves each against the configured API origin, so an + * absolute or protocol-relative value arriving here would silently redirect a module's script import + * or its API calls to another origin. This does not defend against a compromised server — one could + * serve anything at the real URL — but it does pin the wire contract, so a proxy rewrite, a + * misconfiguration, or a future server-side bug fails loudly here instead of quietly changing where + * browser code is fetched from. + */ +function rootRelativePath(value: unknown, key: string): string | undefined { + const candidate = nestedString(value, key); + if (candidate === undefined || !candidate.startsWith("/") || candidate.startsWith("//")) { + return undefined; + } + return candidate; +} + +/** + * Strict, total validation of the inventory document. Anything unexpected — a missing field, a + * non-array `modules`, a permission entry that is not a string — fails the whole parse rather than + * yielding a half-built descriptor, because a descriptor with a missing URL would surface much later + * as an inexplicable mount failure. + */ +export function parseHostModuleUiInventory(payload: unknown): readonly HostModuleUiDescriptor[] { + const modules = isRecord(payload) ? payload.modules : undefined; + if (!Array.isArray(modules)) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze( + modules.map((entry): HostModuleUiDescriptor => { + const id = nestedString(entry, "id"); + const version = nestedString(entry, "version"); + const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined; + const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined; + const permissions = isRecord(entry) ? entry.permissions : undefined; + if ( + id === undefined || + version === undefined || + uiUrl === undefined || + apiBaseUrl === undefined || + !Array.isArray(permissions) || + permissions.some((permission) => typeof permission !== "string") + ) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + apiBaseUrl, + id, + permissions: Object.freeze([...(permissions as string[])]), + uiUrl, + version, + }); + }), + ); +} + +/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal` + * the UI SDK defines. Nothing else the endpoint might ever return is carried into module UI. */ +export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal { + if (!isRecord(payload)) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + if (payload.kind === "anonymous") { + return { kind: "anonymous" }; + } + const userId = nonEmptyString(payload.userId); + if (payload.kind === "authenticated" && userId !== undefined) { + return { kind: "authenticated", userId }; + } + throw new HostProtocolError("HOST_RESPONSE_INVALID"); +} + +/** Resolves a root-relative platform path against the configured API origin. The one place the + * shell turns a server-produced path into an absolute URL. */ +export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string { + return new URL(rootRelativePath, config.apiBaseUrl).href; +} + +/** + * Every request this shell makes to Ekklesia carries credentials. + * + * Same-origin that is simply the session cookie. Split-origin — the development shape the platform's + * trusted-origin configuration already supports — it is what makes the cookie travel at all, and it + * works only because the API reflects an exact trusted origin with + * `Access-Control-Allow-Credentials: true` and never a wildcard. This does not weaken that policy; + * it is the client half of it. + */ +async function requestPlatformJson( + config: EkklesiaHostConfig, + rootRelativePath: string, + signal?: AbortSignal, +): Promise { + const performRequest = config.fetch ?? fetch; + let response: Response; + try { + response = await performRequest(resolveApiUrl(config, rootRelativePath), { + credentials: "include", + headers: { accept: "application/json" }, + ...(signal ? { signal } : {}), + }); + } catch (error) { + throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error }); + } + if (response.status === 401 || response.status === 403) { + throw new HostProtocolError("HOST_UNAUTHENTICATED"); + } + if (!response.ok) { + throw new HostProtocolError("HOST_REQUEST_FAILED"); + } + try { + return (await response.json()) as unknown; + } catch (error) { + throw new HostProtocolError("HOST_RESPONSE_INVALID", { cause: error }); + } +} + +/** Who is viewing. Anonymous is a normal answer, not a failure: the shell renders a sign-in prompt + * rather than an error, and never asks for the inventory it would be refused. */ +export async function loadHostPrincipal( + config: EkklesiaHostConfig, + signal?: AbortSignal, +): Promise { + return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal)); +} + +/** Which module UIs this viewer may mount, where to fetch each, and what they may do inside it. */ +export async function loadModuleUiInventory( + config: EkklesiaHostConfig, + signal?: AbortSignal, +): Promise { + return parseHostModuleUiInventory( + await requestPlatformJson(config, MODULE_UI_INVENTORY_PATH, signal), + ); +} + +export interface HostBootstrap { + readonly principal: ModuleUiPrincipal; + readonly modules: readonly HostModuleUiDescriptor[]; +} + +/** + * The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask + * what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to + * sign in and the inventory is never requested, so the shell does not depend on a `403` to discover + * something `/api/v1/principal` already said plainly. + */ +export async function bootstrapHost( + config: EkklesiaHostConfig, + signal?: AbortSignal, +): Promise { + const principal = await loadHostPrincipal(config, signal); + if (principal.kind === "anonymous") { + return { modules: Object.freeze([]), principal }; + } + return { modules: await loadModuleUiInventory(config, signal), principal }; +} diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts new file mode 100644 index 0000000..02952f1 --- /dev/null +++ b/surfaces/shell/src/index.ts @@ -0,0 +1,29 @@ +/** + * The framework-free half of the Ekklesia shell: how it talks to the platform and how it builds a + * module UI host context. Deliberately the only public surface of this package. + * + * The React shell itself (`src/browser/`) is not exported. It is an application, bundled straight to + * a browser artifact by this package's own build; exporting components would invite another package + * to depend on Ekklesia's shell internals, which is exactly the coupling the surface boundary + * exists to prevent. What *is* exported is the protocol — validated wire parsing, context + * construction, module API resolution — so the end-to-end test can drive the real host code against + * a real server instead of a reimplementation of it. + */ +export { + bootstrapHost, + type EkklesiaHostConfig, + type HostBootstrap, + type HostModuleUiDescriptor, + HostProtocolError, + type HostProtocolErrorCode, + loadHostPrincipal, + loadModuleUiInventory, + parseHostModuleUiInventory, + parseHostPrincipal, + resolveApiUrl, +} from "./host-protocol.js"; +export { + type CreateModuleUiContextOptions, + createModuleUiContext, + moduleUiSourceUrl, +} from "./module-context.js"; diff --git a/surfaces/shell/src/module-context.ts b/surfaces/shell/src/module-context.ts new file mode 100644 index 0000000..4a908c6 --- /dev/null +++ b/surfaces/shell/src/module-context.ts @@ -0,0 +1,104 @@ +import type { + ModuleUiApiClient, + ModuleUiHostContext, + ModuleUiPrincipal, +} from "@ekklesia/module-ui-sdk"; +import { + type EkklesiaHostConfig, + type HostModuleUiDescriptor, + HostProtocolError, + resolveApiUrl, +} from "./host-protocol.js"; + +const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u; + +/** + * Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything + * that is not the module's own API. + * + * `context.api.request("records")` becomes `/api/v1/modules//records`. The + * `/api/v1/modules/` half is **not** written here: it arrives from the server in the + * inventory descriptor (`apiBaseUrl`), so Ekklesia's module mount prefix is spelled in exactly one + * place in the whole repository (`moduleApiBaseUrl`, `apps/api/src/module-http-routes.ts`). The + * browser is a consumer of that URL, never a second author of it. + * + * Containment is enforced with the URL parser rather than string inspection, because the parser is + * what a real `fetch` would use: `"../calendar/records"` normalizes to another module's API, and a + * check that only looked at the input string would miss it. Anything that resolves outside this + * module's own root — a traversal, a protocol-relative `//host`, an absolute URL, an encoded + * separator — is refused with `MODULE_API_PATH_INVALID`. + * + * This is a portability boundary, not a security one. Module UI is trusted same-realm code (ADR + * 0022) and could call `fetch` directly; refusing here is what keeps a module honest about + * addressing only its own backend, so the same artifact still runs under a standalone host. Server + * authorization is unaffected either way: every module route stays behind `requirePermission`. + */ +function createModuleApiClient( + config: EkklesiaHostConfig, + descriptor: HostModuleUiDescriptor, +): ModuleUiApiClient { + const performRequest = config.fetch ?? fetch; + const moduleRoot = new URL(resolveApiUrl(config, descriptor.apiBaseUrl)); + + return Object.freeze({ + async request(path: string, init?: RequestInit): Promise { + if (ABSOLUTE_URL_PATTERN.test(path) || path.startsWith("//")) { + throw new HostProtocolError("MODULE_API_PATH_INVALID"); + } + const relative = path.replace(/^\/+/u, ""); + const target = new URL( + relative === "" ? moduleRoot.pathname : `${moduleRoot.pathname}/${relative}`, + moduleRoot, + ); + const contained = + target.origin === moduleRoot.origin && + (target.pathname === moduleRoot.pathname || + target.pathname.startsWith(`${moduleRoot.pathname}/`)); + if (!contained) { + throw new HostProtocolError("MODULE_API_PATH_INVALID"); + } + // The module's own `init` is forwarded whole — method, headers, body, signal — and only + // `credentials` is imposed, because session handling is the host's job and a module UI never + // sees or supplies a token (ADR 0022). A module cannot opt out of it. + return performRequest(target.href, { ...init, credentials: "include" }); + }, + }); +} + +export interface CreateModuleUiContextOptions { + readonly config: EkklesiaHostConfig; + readonly descriptor: HostModuleUiDescriptor; + readonly principal: ModuleUiPrincipal; +} + +/** + * Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time. + * + * All four members come from server-derived data: the module id and API root from the inventory + * descriptor, the principal from `/api/v1/principal`, and the permission hints from the server's own + * evaluation of that module's declared permissions against that principal. Nothing is guessed, + * defaulted to `true`, or inferred from a client-side role name. + * + * The permission set is the module's own and only the module's own. A module UI cannot use this to + * discover what the viewer may do elsewhere in the platform, and `has` returning `true` is a + * statement about what the server said a moment ago — a hint for hiding a control, never a + * substitute for the server check that actually decides. + */ +export function createModuleUiContext(options: CreateModuleUiContextOptions): ModuleUiHostContext { + const granted = new Set(options.descriptor.permissions); + return Object.freeze({ + api: createModuleApiClient(options.config, options.descriptor), + moduleId: options.descriptor.id, + permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), + principal: options.principal, + }); +} + +/** The absolute URL the generic loader should import this module's artifact from — the descriptor's + * server-produced path resolved against the configured API origin, and nothing more. */ +export function moduleUiSourceUrl( + config: EkklesiaHostConfig, + descriptor: HostModuleUiDescriptor, +): string { + return resolveApiUrl(config, descriptor.uiUrl); +} diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx new file mode 100644 index 0000000..e769829 --- /dev/null +++ b/surfaces/shell/tests/host-app.test.tsx @@ -0,0 +1,415 @@ +import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostApp } from "../src/browser/HostApp.js"; +import type { EkklesiaHostConfig } from "../src/host-protocol.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; +let consoleError: ReturnType; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + // The shell deliberately reports failures to the browser console; silence them so a passing run + // is quiet, and so a test can assert *that* it reported without the noise. + consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + consoleError.mockRestore(); +}); + +function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) { + return { + api: { baseUrl: `/api/v1/modules/${id}` }, + id, + permissions, + ui: { url: `/api/v1/module-ui/${id}/${version}` }, + version, + }; +} + +interface PlatformStub { + readonly modules?: ReadonlyArray>; + readonly principal?: unknown; + readonly inventoryStatus?: number; + readonly inventoryThrows?: boolean; +} + +function platformFetch(stub: PlatformStub = {}): typeof fetch { + const modules = stub.modules ?? [descriptor("example")]; + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/principal")) { + return new Response( + JSON.stringify(stub.principal ?? { kind: "authenticated", userId: "user-1" }), + { headers: { "content-type": "application/json" }, status: 200 }, + ); + } + if (url.endsWith("/api/v1/module-ui")) { + if (stub.inventoryThrows) { + throw new TypeError("network down"); + } + return new Response(JSON.stringify({ modules }), { + headers: { "content-type": "application/json" }, + status: stub.inventoryStatus ?? 200, + }); + } + return new Response("{}", { status: 404 }); + }) as unknown as typeof fetch; +} + +interface ArtifactOptions { + readonly mountThrows?: boolean; + readonly unmountThrows?: boolean; + /** Records the container each mount received, so a test can prove a stale mount never touched the + * newer selection's element. */ + readonly mountedInto?: Element[]; +} + +/** A module UI artifact namespace, exactly the shape the generic loader parses. */ +function artifact(id: string, version: string, options: ArtifactOptions = {}) { + return { + default: { + id, + mount(element: Element, context: ModuleUiHostContext) { + options.mountedInto?.push(element); + if (options.mountThrows) { + throw new Error("module mount failed"); + } + element.textContent = `${context.moduleId} mounted`; + return () => { + if (options.unmountThrows) { + throw new Error("module cleanup failed"); + } + element.textContent = ""; + }; + }, + version, + }, + }; +} + +async function render( + config: EkklesiaHostConfig, + importModule?: (url: string) => Promise, +): Promise { + await act(async () => { + root.render(); + }); +} + +function text(): string { + return container.textContent ?? ""; +} + +function query(testId: string): Element | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +describe("Ekklesia shell — bootstrap", () => { + it("renders the module list the inventory returned", async () => { + await render({ + apiBaseUrl, + fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + }); + + expect(query("select-example")).not.toBeNull(); + expect(query("select-calendar")).not.toBeNull(); + }); + + it("asks an anonymous viewer to sign in, and mounts nothing", async () => { + await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) }); + + expect(query("host-signed-out")).not.toBeNull(); + expect(query("module-container")).toBeNull(); + }); + + it("treats a refused inventory as signed out rather than as a crash", async () => { + await render({ apiBaseUrl, fetch: platformFetch({ inventoryStatus: 403 }) }); + + expect(query("host-signed-out")).not.toBeNull(); + }); + + it("renders a host-owned error when the platform cannot be reached", async () => { + await render({ apiBaseUrl, fetch: platformFetch({ inventoryThrows: true }) }); + + expect(query("host-error")).not.toBeNull(); + expect(text()).not.toContain("network down"); + }); + + it("says so plainly when no installed module provides a UI", async () => { + await render({ apiBaseUrl, fetch: platformFetch({ modules: [] }) }); + + expect(query("host-empty")).not.toBeNull(); + }); +}); + +describe("Ekklesia shell — mount lifecycle", () => { + it("mounts the first module into the host's own container", async () => { + const mountedInto: Element[] = []; + await render({ apiBaseUrl, fetch: platformFetch() }, async () => + artifact("example", "0.1.0", { mountedInto }), + ); + + expect(query("module-container")?.textContent).toBe("example mounted"); + expect(query("module-loading")).toBeNull(); + // The module was handed the shell's container, never the page root or the shell's own layout. + expect(mountedInto[0]).toBe(query("module-container")); + expect(mountedInto[0]).not.toBe(container); + expect(mountedInto[0]).not.toBe(document.body); + }); + + it("imports exactly the URL the inventory published, resolved against the API origin", async () => { + const requested: string[] = []; + await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => { + requested.push(url); + return artifact("example", "0.1.0"); + }); + + expect(requested).toEqual([`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`]); + }); + + it("shows a host-owned loading state until the module mounts", async () => { + let release: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + + await render({ apiBaseUrl, fetch: platformFetch() }, async () => { + await pending; + return artifact("example", "0.1.0"); + }); + + expect(query("module-loading")).not.toBeNull(); + expect(query("module-container")?.textContent).toBe(""); + + await act(async () => { + release?.(); + await pending; + }); + + expect(query("module-loading")).toBeNull(); + expect(query("module-container")?.textContent).toBe("example mounted"); + }); + + it("unmounts the previous module when the selection changes", async () => { + const unmountedContainers: Element[] = []; + const importModule = async (url: string) => { + const id = url.includes("/calendar/") ? "calendar" : "example"; + return { + default: { + id, + mount(element: Element) { + element.textContent = `${id} mounted`; + return () => { + unmountedContainers.push(element); + element.textContent = ""; + }; + }, + version: id === "calendar" ? "2.0.0" : "0.1.0", + }, + }; + }; + await render( + { + apiBaseUrl, + fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + }, + importModule, + ); + expect(query("module-container")?.textContent).toBe("example mounted"); + + await act(async () => { + (query("select-calendar") as HTMLButtonElement).click(); + }); + + expect(unmountedContainers).toHaveLength(1); + expect(query("module-container")?.textContent).toBe("calendar mounted"); + }); + + it("keeps a stale load from ever reaching the newer selection's container", async () => { + const mountedInto: Element[] = []; + const unmountedContainers: Element[] = []; + let releaseExample: (() => void) | undefined; + const examplePending = new Promise((resolve) => { + releaseExample = resolve; + }); + + const importModule = async (url: string) => { + const id = url.includes("/calendar/") ? "calendar" : "example"; + if (id === "example") { + await examplePending; + } + return { + default: { + id, + mount(element: Element) { + mountedInto.push(element); + element.textContent = `${id} mounted`; + return () => { + unmountedContainers.push(element); + element.textContent = ""; + }; + }, + version: id === "calendar" ? "2.0.0" : "0.1.0", + }, + }; + }; + + await render( + { + apiBaseUrl, + fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + }, + importModule, + ); + // `example` is still importing; nothing has mounted yet. + expect(mountedInto).toHaveLength(0); + + // Switch to `calendar`, which resolves immediately and mounts. + await act(async () => { + (query("select-calendar") as HTMLButtonElement).click(); + }); + const liveContainer = query("module-container"); + expect(liveContainer?.textContent).toBe("calendar mounted"); + + // Only now does the stale `example` import resolve. + await act(async () => { + releaseExample?.(); + await examplePending; + }); + + // It mounted into its own, already-discarded container — never the live one — and was torn + // down immediately, so the newer selection is untouched. + expect(liveContainer?.textContent).toBe("calendar mounted"); + const staleContainer = mountedInto.find((element) => element !== liveContainer); + expect(staleContainer).toBeDefined(); + expect(staleContainer?.isConnected).toBe(false); + expect(unmountedContainers).toContain(staleContainer); + }); +}); + +describe("Ekklesia shell — failure is host-owned", () => { + it("renders its own error when the artifact cannot be imported", async () => { + await render({ apiBaseUrl, fetch: platformFetch() }, async () => { + throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing"); + }); + + expect(query("module-error")).not.toBeNull(); + // Not a path, not a stack, not the caught message. + expect(text()).not.toContain("/srv/ekklesia"); + expect(text()).not.toContain("index.js"); + // The shell itself is still rendered around the failure. + expect(query("select-example")).not.toBeNull(); + // The real error still reaches the browser console for a developer. + expect(consoleError).toHaveBeenCalled(); + }); + + it("renders its own error when the artifact is not a valid module UI", async () => { + await render({ apiBaseUrl, fetch: platformFetch() }, async () => ({ notADefault: true })); + + expect(query("module-error")).not.toBeNull(); + }); + + it("renders its own error when the artifact claims a different identity", async () => { + await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9")); + + expect(query("module-error")).not.toBeNull(); + expect(query("module-container")?.textContent).toBe(""); + }); + + it("renders its own error when the module's mount throws", async () => { + await render({ apiBaseUrl, fetch: platformFetch() }, async () => + artifact("example", "0.1.0", { mountThrows: true }), + ); + + expect(query("module-error")).not.toBeNull(); + }); + + it("stays usable after a failure: another module still mounts", async () => { + const importModule = async (url: string) => { + if (url.includes("/example/")) { + throw new Error("boom"); + } + return artifact("calendar", "2.0.0"); + }; + await render( + { + apiBaseUrl, + fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + }, + importModule, + ); + expect(query("module-error")).not.toBeNull(); + + await act(async () => { + (query("select-calendar") as HTMLButtonElement).click(); + }); + + expect(query("module-error")).toBeNull(); + expect(query("module-container")?.textContent).toBe("calendar mounted"); + }); + + it("survives a module whose cleanup throws, and never re-runs it", async () => { + let cleanupCalls = 0; + const importModule = async (url: string) => { + if (url.includes("/calendar/")) { + return artifact("calendar", "2.0.0"); + } + return { + default: { + id: "example", + mount(element: Element) { + element.textContent = "example mounted"; + return () => { + cleanupCalls += 1; + throw new Error("module cleanup failed"); + }; + }, + version: "0.1.0", + }, + }; + }; + await render( + { + apiBaseUrl, + fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + }, + importModule, + ); + + await act(async () => { + (query("select-calendar") as HTMLButtonElement).click(); + }); + + expect(cleanupCalls).toBe(1); + expect(query("module-container")?.textContent).toBe("calendar mounted"); + + // Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an + // instance already torn down. + await act(async () => { + (query("select-example") as HTMLButtonElement).click(); + }); + await act(async () => { + (query("select-calendar") as HTMLButtonElement).click(); + }); + expect(cleanupCalls).toBe(2); + expect(query("module-container")?.textContent).toBe("calendar mounted"); + }); +}); diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts new file mode 100644 index 0000000..1b930b1 --- /dev/null +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it, vi } from "vitest"; +import { + bootstrapHost, + type EkklesiaHostConfig, + type HostModuleUiDescriptor, + loadHostPrincipal, + loadModuleUiInventory, + parseHostModuleUiInventory, + parseHostPrincipal, +} from "../src/host-protocol.js"; +import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js"; + +const apiBaseUrl = "https://ekklesia.example.test"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }); +} + +/** A fetch double that answers the two platform paths and records every call, so the tests can + * assert both the URL the shell built and the credentials it used. */ +function stubFetch(routes: Readonly Response>>) { + const calls: Array<{ init: RequestInit | undefined; url: string }> = []; + const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ init, url }); + const route = Object.entries(routes).find(([path]) => url === `${apiBaseUrl}${path}`); + if (!route) { + return new Response(JSON.stringify({ code: "NOT_FOUND" }), { status: 404 }); + } + return route[1](); + }); + return { calls, implementation: implementation as unknown as typeof fetch }; +} + +const inventoryPayload = { + modules: [ + { + api: { baseUrl: "/api/v1/modules/example" }, + id: "example", + permissions: ["example.records.read"], + ui: { url: "/api/v1/module-ui/example/0.1.0" }, + version: "0.1.0", + }, + ], +}; + +const exampleDescriptor: HostModuleUiDescriptor = { + apiBaseUrl: "/api/v1/modules/example", + id: "example", + permissions: ["example.records.read"], + uiUrl: "/api/v1/module-ui/example/0.1.0", + version: "0.1.0", +}; + +describe("host protocol — inventory", () => { + it("parses the server's descriptor into exactly what a host context needs", () => { + expect(parseHostModuleUiInventory(inventoryPayload)).toEqual([exampleDescriptor]); + }); + + it("rejects a malformed document rather than yielding a half-built descriptor", () => { + const malformed: unknown[] = [ + {}, + { modules: {} }, + { modules: [{ id: "example", version: "0.1.0" }] }, + { modules: [{ ...inventoryPayload.modules[0], ui: {} }] }, + { modules: [{ ...inventoryPayload.modules[0], api: { baseUrl: "" } }] }, + { modules: [{ ...inventoryPayload.modules[0], permissions: "example.records.read" }] }, + { modules: [{ ...inventoryPayload.modules[0], permissions: [42] }] }, + // Both URLs must be root-relative, so a value that would resolve against another origin is + // refused rather than silently used to fetch browser code. + { + modules: [ + { ...inventoryPayload.modules[0], ui: { url: "https://evil.example.test/x.js" } }, + ], + }, + { modules: [{ ...inventoryPayload.modules[0], ui: { url: "//evil.example.test/x.js" } }] }, + { modules: [{ ...inventoryPayload.modules[0], ui: { url: "relative/x.js" } }] }, + { + modules: [ + { ...inventoryPayload.modules[0], api: { baseUrl: "https://evil.example.test/api" } }, + ], + }, + ]; + for (const payload of malformed) { + expect(() => parseHostModuleUiInventory(payload)).toThrowError( + expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }), + ); + } + }); + + it("fetches the inventory from the platform path with credentials", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/module-ui": () => jsonResponse(inventoryPayload), + }); + const config: EkklesiaHostConfig = { apiBaseUrl, fetch: implementation }; + + await loadModuleUiInventory(config); + + expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/module-ui`); + expect(calls[0]?.init?.credentials).toBe("include"); + }); + + it("reports an unauthenticated caller distinctly from any other failure", async () => { + for (const status of [401, 403]) { + const { implementation } = stubFetch({ + "/api/v1/module-ui": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, status), + }); + await expect( + loadModuleUiInventory({ apiBaseUrl, fetch: implementation }), + ).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" }); + } + + const { implementation } = stubFetch({ + "/api/v1/module-ui": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500), + }); + await expect( + loadModuleUiInventory({ apiBaseUrl, fetch: implementation }), + ).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" }); + }); + + it("never renders a caught cause, a URL, or a server path in its message", async () => { + const failing = vi.fn(async () => { + throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432"); + }) as unknown as typeof fetch; + + const error = await loadModuleUiInventory({ apiBaseUrl, fetch: failing }).catch( + (caught: unknown) => caught, + ); + + expect(error).toMatchObject({ code: "HOST_REQUEST_FAILED" }); + expect((error as Error).message).toBe("Ekklesia could not be reached."); + expect((error as Error).message).not.toContain("PGSQL"); + }); +}); + +describe("host protocol — principal", () => { + it("maps the platform principal onto the browser-safe SDK shape", () => { + expect(parseHostPrincipal({ kind: "anonymous" })).toEqual({ kind: "anonymous" }); + expect(parseHostPrincipal({ kind: "authenticated", userId: "user-1" })).toEqual({ + kind: "authenticated", + userId: "user-1", + }); + }); + + it("carries nothing beyond kind and userId, even when the server sends more", () => { + const principal = parseHostPrincipal({ + email: "someone@example.test", + kind: "authenticated", + roles: ["owner"], + sessionToken: "secret", + userId: "user-1", + }); + + expect(Object.keys(principal).sort()).toEqual(["kind", "userId"]); + expect(JSON.stringify(principal)).not.toContain("secret"); + }); + + it("rejects a principal it does not recognize", () => { + for (const payload of [null, {}, { kind: "authenticated" }, { kind: "owner" }]) { + expect(() => parseHostPrincipal(payload)).toThrowError( + expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }), + ); + } + }); +}); + +describe("host protocol — bootstrap", () => { + it("resolves the principal and then the inventory", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/module-ui": () => jsonResponse(inventoryPayload), + "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), + }); + + const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); + + expect(calls.map((call) => call.url)).toEqual([ + `${apiBaseUrl}/api/v1/principal`, + `${apiBaseUrl}/api/v1/module-ui`, + ]); + expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" }); + expect(bootstrap.modules).toEqual([exampleDescriptor]); + }); + + it("never requests the inventory for an anonymous viewer", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/module-ui": () => jsonResponse(inventoryPayload), + "/api/v1/principal": () => jsonResponse({ kind: "anonymous" }), + }); + + const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); + + expect(bootstrap.principal).toEqual({ kind: "anonymous" }); + expect(bootstrap.modules).toEqual([]); + expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]); + }); + + it("sends the platform principal request with credentials too", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/principal": () => jsonResponse({ kind: "anonymous" }), + }); + + await loadHostPrincipal({ apiBaseUrl, fetch: implementation }); + + expect(calls[0]?.init?.credentials).toBe("include"); + }); +}); + +describe("module UI host context", () => { + function contextFor(fetchImplementation: typeof fetch, descriptor = exampleDescriptor) { + return createModuleUiContext({ + config: { apiBaseUrl, fetch: fetchImplementation }, + descriptor, + principal: { kind: "authenticated", userId: "user-1" }, + }); + } + + function recordingFetch() { + const calls: Array<{ init: RequestInit | undefined; url: string }> = []; + const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ init, url: String(input) }); + return new Response("[]", { status: 200 }); + }); + return { calls, implementation: implementation as unknown as typeof fetch }; + } + + it("resolves a module-relative path onto the server-produced module API root", async () => { + const { calls, implementation } = recordingFetch(); + + await contextFor(implementation).api.request("records"); + + expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/modules/example/records`); + }); + + it("normalizes a leading slash and preserves query strings and nested paths", async () => { + const { calls, implementation } = recordingFetch(); + const api = contextFor(implementation).api; + + await api.request("/records"); + await api.request("records?since=2026-01-01&limit=10"); + await api.request("records/abc/comments"); + await api.request(""); + + expect(calls.map((call) => call.url)).toEqual([ + `${apiBaseUrl}/api/v1/modules/example/records`, + `${apiBaseUrl}/api/v1/modules/example/records?since=2026-01-01&limit=10`, + `${apiBaseUrl}/api/v1/modules/example/records/abc/comments`, + `${apiBaseUrl}/api/v1/modules/example`, + ]); + }); + + it("forwards the module's own RequestInit and imposes credentials", async () => { + const { calls, implementation } = recordingFetch(); + + await contextFor(implementation).api.request("records", { + body: JSON.stringify({ value: "one" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + expect(calls[0]?.init).toMatchObject({ + credentials: "include", + headers: { "content-type": "application/json" }, + method: "POST", + }); + }); + + it("refuses to address anything outside the module's own API", async () => { + const { calls, implementation } = recordingFetch(); + const api = contextFor(implementation).api; + + const escapes = [ + "../calendar/records", + "records/../../calendar/records", + "/../../v1/grants", + "https://evil.example.test/steal", + "//evil.example.test/steal", + "HTTPS://evil.example.test/steal", + "javascript:alert(1)", + ]; + for (const path of escapes) { + await expect(api.request(path), path).rejects.toMatchObject({ + code: "MODULE_API_PATH_INVALID", + }); + } + // Not one of them reached the network. + expect(calls).toEqual([]); + }); + + it("gives each module only its own API root", async () => { + const { calls, implementation } = recordingFetch(); + const calendar: HostModuleUiDescriptor = { + apiBaseUrl: "/api/v1/modules/calendar", + id: "calendar", + permissions: [], + uiUrl: "/api/v1/module-ui/calendar/2.0.0", + version: "2.0.0", + }; + + await contextFor(implementation).api.request("records"); + await contextFor(implementation, calendar).api.request("events"); + + expect(calls.map((call) => call.url)).toEqual([ + `${apiBaseUrl}/api/v1/modules/example/records`, + `${apiBaseUrl}/api/v1/modules/calendar/events`, + ]); + }); + + it("builds permission hints from the server's granted set, never a default of true", () => { + const { implementation } = recordingFetch(); + + const granted = contextFor(implementation).permissions; + const none = contextFor(implementation, { ...exampleDescriptor, permissions: [] }).permissions; + + expect(granted.has("example.records.read")).toBe(true); + expect(granted.has("example.records.write")).toBe(false); + // A module the viewer holds nothing for gets a hint set that answers false to everything. + expect(none.has("example.records.read")).toBe(false); + expect(none.has("core.authorization.grants.manage")).toBe(false); + }); + + it("exposes exactly the four contract members and the browser principal", () => { + const { implementation } = recordingFetch(); + + const context = contextFor(implementation); + + expect(Object.keys(context).sort()).toEqual(["api", "moduleId", "permissions", "principal"]); + expect(context.moduleId).toBe("example"); + expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" }); + }); + + it("resolves the artifact URL from the descriptor the server produced", () => { + expect(moduleUiSourceUrl({ apiBaseUrl }, exampleDescriptor)).toBe( + `${apiBaseUrl}/api/v1/module-ui/example/0.1.0`, + ); + // Same descriptor, a different deployment origin: the shell composes, it never authors. + expect(moduleUiSourceUrl({ apiBaseUrl: "http://127.0.0.1:3000" }, exampleDescriptor)).toBe( + "http://127.0.0.1:3000/api/v1/module-ui/example/0.1.0", + ); + }); +}); diff --git a/surfaces/shell/tests/modularity.test.ts b/surfaces/shell/tests/modularity.test.ts new file mode 100644 index 0000000..acf732a --- /dev/null +++ b/surfaces/shell/tests/modularity.test.ts @@ -0,0 +1,157 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * The proof that this shell is a *generic* host rather than an application that happens to render + * some known modules. + * + * Two levels, because they fail differently. At source level, a static import of a module would be + * an architectural mistake a reviewer should catch. At bundle level, a dynamic import that a bundler + * managed to resolve statically would be an invisible one: the code would look correct, the tests + * would pass, and installing a new module would silently do nothing until the shell was rebuilt. + * + * `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so + * everything here works in plain filesystem paths. + */ +const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const sourceDirectory = path.join(packageDirectory, "src"); + +/** The complete set of packages this shell may reach for. `@ekklesia/module-ui-host` is the generic + * loader and `@ekklesia/module-ui-sdk` is the browser-side contract; no other `@ekklesia/*` package + * — and no module — may appear. */ +const ALLOWED_EKKLESIA_IMPORTS = new Set(["@ekklesia/module-ui-host", "@ekklesia/module-ui-sdk"]); + +async function sourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await sourceFiles(entryPath))); + } else if (/\.tsx?$/u.test(entry.name)) { + files.push(entryPath); + } + } + return files.sort(); +} + +function importSpecifiers(source: string): string[] { + const specifiers = new Set(); + const patterns = [ + /\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu, + /\bimport\s*["']([^"'\n]+)["']/gu, + /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/gu, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + if (match[1]) { + specifiers.add(match[1]); + } + } + } + return [...specifiers]; +} + +let files: string[]; +let sources: Map; + +beforeAll(async () => { + files = await sourceFiles(sourceDirectory); + sources = new Map( + await Promise.all(files.map(async (file) => [file, await readFile(file, "utf8")] as const)), + ); +}); + +describe("the shell is coupled to no module", () => { + it("has source files to check", () => { + expect(files.length).toBeGreaterThan(3); + }); + + it("imports no module, template, or workspace package beyond the two browser contracts", () => { + for (const [file, source] of sources) { + for (const specifier of importSpecifiers(source)) { + const label = `${path.relative(packageDirectory, file)} -> ${specifier}`; + expect(specifier.includes("modules/"), label).toBe(false); + expect(specifier.includes("templates/"), label).toBe(false); + // Nothing may climb out of this package at all. + expect(specifier.startsWith("../../"), label).toBe(false); + if (specifier.startsWith("@ekklesia/")) { + expect(ALLOWED_EKKLESIA_IMPORTS.has(specifier), label).toBe(true); + } + } + } + }); + + it("performs no dynamic import of its own: all module loading goes through the generic loader", () => { + for (const [file, source] of sources) { + // Comment lines are dropped so a doc comment may still *describe* the loader's own + // `(url) => import(url)`. The authoritative check is the bundle-level one below, which counts + // dynamic imports in the emitted output where no comment survives. + const code = source + .split("\n") + .filter((line) => !/^\s*(\*|\/\/)/u.test(line)) + .join("\n"); + const dynamicImports = [...code.matchAll(/\bimport\s*\(/gu)]; + expect(dynamicImports, path.relative(packageDirectory, file)).toEqual([]); + } + }); +}); + +describe("the shell builds no module knowledge into its bundle", () => { + let bundle: string; + + beforeAll(async () => { + // The real production bundle configuration, produced in memory so this test never depends on a + // previous `pnpm build` having run. + const result = await build({ + absWorkingDir: packageDirectory, + bundle: true, + define: { + __EKKLESIA_API_BASE_URL__: '""', + "process.env.NODE_ENV": '"production"', + }, + entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")], + format: "esm", + jsx: "automatic", + logLevel: "silent", + minify: false, + platform: "browser", + target: "es2022", + write: false, + }); + bundle = result.outputFiles.map((file) => file.text).join("\n"); + }, 60_000); + + it("leaves exactly one dynamic import — the loader's — unresolved for run time", () => { + const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)]; + + // Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell + // acquired its own loader; fewer would mean the bundler resolved it away and installed modules + // could never be fetched at run time. + expect(dynamicImports).toHaveLength(1); + expect(bundle).toContain("(url) => import(url)"); + }); + + it("contains no module identity and no module API prefix", () => { + // The shell learns every module id, artifact URL, and API root from the inventory at run time. + // Any of these appearing in the bundle would mean it had been baked in instead. + expect(bundle).not.toContain("/api/v1/modules"); + expect(bundle).not.toContain("/api/v1/module-ui/"); + expect(bundle).not.toContain("ekklesia.module.json"); + expect(bundle).not.toContain("entrypoints"); + }); + + it("spells only the two platform paths it genuinely owns", () => { + expect(bundle).toContain('"/api/v1/principal"'); + expect(bundle).toContain('"/api/v1/module-ui"'); + }); + + it("uses neither eval nor a blob URL to load module code", () => { + expect(bundle).not.toContain("new Function("); + expect(bundle).not.toMatch(/\beval\s*\(/u); + expect(bundle).not.toContain("createObjectURL"); + }); +}); diff --git a/surfaces/shell/tsconfig.build.json b/surfaces/shell/tsconfig.build.json new file mode 100644 index 0000000..75099f4 --- /dev/null +++ b/surfaces/shell/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "types": [] + }, + "include": ["src/index.ts", "src/host-protocol.ts", "src/module-context.ts"] +} diff --git a/surfaces/shell/tsconfig.json b/surfaces/shell/tsconfig.json new file mode 100644 index 0000000..974fa3a --- /dev/null +++ b/surfaces/shell/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/surfaces/shell/tsconfig.test.json b/surfaces/shell/tsconfig.test.json new file mode 100644 index 0000000..52edf68 --- /dev/null +++ b/surfaces/shell/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/browser-react.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["tests/**/*.ts", "tests/**/*.tsx", "scripts/**/*.mjs"] +} diff --git a/surfaces/shell/vitest.config.ts b/surfaces/shell/vitest.config.ts new file mode 100644 index 0000000..83dd5cf --- /dev/null +++ b/surfaces/shell/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "happy-dom", + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], + passWithNoTests: false, + }, +}); diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts index 08b2fc8..b2ce6b2 100644 --- a/tests/boundaries.test.ts +++ b/tests/boundaries.test.ts @@ -31,6 +31,7 @@ describe("repository dependency boundaries", () => { ["cross-module-import", "modules-do-not-import-other-modules"], ["module-schema-imports-module", "modules-do-not-import-other-modules"], ["surface-imports-module", "surfaces-use-public-contracts"], + ["surface-imports-contracts", "surfaces-use-public-contracts"], ["cross-surface-import", "surfaces-are-independent"], ["builder-imports-module", "builder-composes-contracts"], ["contracts-import-core", "contracts-do-not-depend-upward"], diff --git a/tests/fixtures/boundaries/surface-imports-contracts/packages/contracts/package.json b/tests/fixtures/boundaries/surface-imports-contracts/packages/contracts/package.json new file mode 100644 index 0000000..2890856 --- /dev/null +++ b/tests/fixtures/boundaries/surface-imports-contracts/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/contracts", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "contracts" + } +} diff --git a/tests/fixtures/boundaries/surface-imports-contracts/packages/contracts/src/index.ts b/tests/fixtures/boundaries/surface-imports-contracts/packages/contracts/src/index.ts new file mode 100644 index 0000000..7aedc33 --- /dev/null +++ b/tests/fixtures/boundaries/surface-imports-contracts/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const moduleManifestSchema = {}; diff --git a/tests/fixtures/boundaries/surface-imports-contracts/pnpm-workspace.yaml b/tests/fixtures/boundaries/surface-imports-contracts/pnpm-workspace.yaml new file mode 100644 index 0000000..9438e56 --- /dev/null +++ b/tests/fixtures/boundaries/surface-imports-contracts/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*" + - "surfaces/*" diff --git a/tests/fixtures/boundaries/surface-imports-contracts/surfaces/dashboard/package.json b/tests/fixtures/boundaries/surface-imports-contracts/surfaces/dashboard/package.json new file mode 100644 index 0000000..dfb5f59 --- /dev/null +++ b/tests/fixtures/boundaries/surface-imports-contracts/surfaces/dashboard/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ekklesia/surface-dashboard", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/contracts": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "surface" + } +} diff --git a/tests/fixtures/boundaries/surface-imports-contracts/surfaces/dashboard/src/index.ts b/tests/fixtures/boundaries/surface-imports-contracts/surfaces/dashboard/src/index.ts new file mode 100644 index 0000000..1b53107 --- /dev/null +++ b/tests/fixtures/boundaries/surface-imports-contracts/surfaces/dashboard/src/index.ts @@ -0,0 +1,3 @@ +import { moduleManifestSchema } from "@ekklesia/contracts"; + +export const schema = moduleManifestSchema;