20 KiB
0026: The minimal Ekklesia module UI host
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
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.
{
"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.
Cross-reference — see ADR 0032. Phase 7A pass 5 extends this original descriptor with a sixth,
realtime: { baseUrl: "/api/v1/module-realtime/example" }, so the browser host can construct the transport-neutralcontext.realtimefacade without inventing a platform URL. The five-field shape above remains the historical decision this ADR recorded; ADR 0032 owns the extension.
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.
Cross-reference — see ADR 0028. Ekklesia now does have enable/disable state, reported by its own resource (
GET /api/v1/module-lifecycle) rather than by this one. This endpoint is unchanged and remains exactly what it was: the host-neutral list of what may be mounted, with no management verbs. What changed is upstream of it — the inventory now derives from the eligible module set, so a disabled or dependency-blocked module is simply absent, and the lifecycle vocabulary this paragraph names has been replaced by the two axes ADR 0028 records.
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
allowedboolean. Rejected: granted-only is smaller, answershas()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 own403-vs-503distinction correctly. A permission with anauthenticatedbaseline 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.