feat: mount discovered module UI in a minimal Ekklesia browser host
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s

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]>
This commit is contained in:
ekklesia
2026-08-22 08:55:44 -05:00
co-authored by Claude Opus 5
parent 7a76ff6f26
commit 616c1d4834
46 changed files with 3629 additions and 36 deletions
+33 -5
View File
@@ -326,11 +326,39 @@ download that bundle. Caching is `no-cache` with a strong `ETag` rather than `im
without an installer nothing stops a version being rebuilt with different bytes. See
[ADR 0025](docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md).
**This is not Dashboard integration.** A host *could* now obtain a browser URL, which was previously
impossible — but nothing in Ekklesia mounts a module UI. There is no Dashboard shell, no navigation,
no place a module's screen appears, and no code calls the URL helper outside its own tests. A design
system, a stylesheet or general asset publication contract, a module metadata endpoint, and the
standalone host also do not exist.
Phase 6C pass 2 connects the chain: **Ekklesia now has a browser host that mounts module-owned UI.**
`surfaces/shell` (`@ekklesia/shell`) is the repository's first surface — a small React application,
built to static files with esbuild — and it does the whole loop at run time:
```text
GET /api/v1/principal → who is viewing
GET /api/v1/module-ui → which module UIs may be mounted, and where
↓ server-produced artifact URL
@ekklesia/module-ui-host → dynamic import, identity check, mount into a host-owned container
↓
context.api.request("records") → /api/v1/modules/example/records
```
The new inventory endpoint returns exactly what building a `ModuleUiHostContext` needs — id, version,
artifact URL, module API root, and the viewer's granted permissions **for that module** — and nothing
else: no filesystem paths, and no `name`/`icon`/`folder`/`order`, because navigation metadata is a
design that does not exist yet. It is authenticated (`core.modules.ui.read`, baseline
`authenticated`) precisely because it is the module enumeration the anonymous artifact route
deliberately withholds. Both URLs are produced by the server's own canonical helpers, so the browser
never rebuilds Ekklesia's module prefixes and either can change without touching host or module code.
**The shell statically imports no module.** Every module it can mount arrives at run time from the
inventory, and its bundle is checked for exactly one dynamic import — the generic loader's — with no
module id, no `/api/v1/modules`, no `eval`, and no blob URLs. Permission hints come from the server's
own evaluator and remain presentation only: the end-to-end test proves a viewer holding no grant is
still refused `403` when the UI calls the API anyway. The host owns the container, the loading state,
and every failure; a broken module costs its own panel, not the shell. See
[ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md).
**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home
screen, design system, or client-side routing — the selector is a list of module ids. Sign-in UI, a
production CSP and deployment for the built shell, asset publication beyond the UI entrypoint, React
runtime sharing, the standalone host, and every real feature module also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission