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

19 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.

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.