Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
319 lines
20 KiB
Markdown
319 lines
20 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.
|
|
|
|
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
|
|
> 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 `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.
|