Files
Ekklesia/docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md
T
ekklesiaandClaude Opus 5 616c1d4834
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s
feat: mount discovered module UI in a minimal Ekklesia browser host
Phase 6C pass 2. Every link in the module UI chain existed except the one
that closes it: a module could be authored, built, validated, published at a
browser URL, and loaded by a generic loader — and nothing in Ekklesia did any
of it. There was no browser application at all.

Two pieces were missing. A browser could not discover what to mount, and it
must not work that out for itself.

GET /api/v1/module-ui is the inventory: per publishable module, its id and
version, the server-produced artifact URL, the server-produced root of the
module's own API, and the subset of that module's declared permissions the
caller holds. Five fields, each because building a ModuleUiHostContext needs
it — no filesystem paths, and no name/icon/folder/order, because navigation
metadata is a design that does not exist yet. Its module set is the ADR 0025
publication registry rather than a rescan, so the inventory and the artifact
route cannot disagree. It requires a new Core permission, core.modules.ui.read
(baseline authenticated), because it is precisely the enumeration the anonymous
artifact route deliberately withholds.

Both URLs are server-produced. moduleUiArtifactUrl already existed;
moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL
shape has exactly one author. Without it the shell would hardcode
/api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling,
reappearing in the host instead.

surfaces/shell (@ekklesia/shell) is the repository's first surface: a small
React application built to static files by its own esbuild script. It resolves
the principal, fetches the inventory, builds the host context entirely from
server-derived data, and hands the artifact URL to @ekklesia/module-ui-host.
One frame per selection — keyed by module identity — defeats the stale-load
race; the host owns the container, the loading state, and every failure, so a
broken module costs its own panel rather than the shell.

The shell statically imports no module, and both levels are checked: no source
import may name a module, and the emitted bundle must contain exactly one
dynamic import (the loader's) with no module id, no /api/v1/modules, no eval,
and no blob URL. Permission hints come from Core's own evaluator and stay
presentation-only — the end-to-end test has a UI holding no grant call its API
anyway and still receive 403.

Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts
is deliberately excluded, since its barrel pulls Drizzle's types and Node's
globals into the browser — the hole ADR 0024 closed one layer down.

Not the Dashboard: no navigation, grouping, icons, ordering, design system, or
routing. The selector is a list of module ids.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 08:55:44 -05:00

311 lines
19 KiB
Markdown

# 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/<module-id>/<version>`, 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/<id>/…` 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/<id>/<version> → 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/<id>` 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 `<origin>/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 `<div>` 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/<id>` 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 `<script>` — and a same-origin `import()` of a
published artifact works under `script-src 'self'`. React inline styles do require
`style-src-attr 'unsafe-inline'`. Choosing and serving the header belongs to a deployment that does
not exist yet.
## Deferred
Not implemented or promised here: **the Dashboard** — navigation, grouping and folders, ordering,
icons, a home screen, module categories, configurable or administrator-owned navigation, and the
display metadata each would need in the inventory; a design system, theme tokens, or component
library; client-side routing; stylesheet, image, and font asset publication; React runtime sharing
between host and modules; a production Content-Security-Policy and the deployment that serves the
built shell; sign-in and sign-out UI (the shell detects an anonymous viewer and says so, but Better
Auth's endpoints are driven by nothing yet); a standalone host; module installation, upload,
marketplace, hot loading, signing, or enable/disable state; and every real feature module.
The permission-id typing gap carried since ADR 0022 is unchanged and now has a second consumer:
`permissions.has` takes a plain string in the SDK, and the inventory's `permissions` array is
`string[]` on the wire, so a typo in either still silently hides a control.