# 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 `