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
+43 -15
View File
@@ -10,12 +10,12 @@ design-token contract, error boundaries, and trusted-origin/CORS policy are Core
application's. A module consumes those; it never redefines or bypasses them.
> **What exists today:** you can author a module UI in React, build it into the compiled artifact your
> manifest declares, have Core validate it, and have Ekklesia serve it at a browser URL that
> `@ekklesia/module-ui-host` can load and mount. **What does not exist is a host that does so** — no
> Dashboard shell, no navigation, no standalone host, and nothing in Ekklesia that renders your UI to
> a user. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) and
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact) for exactly what is
> and is not supported.
> manifest declares, have Core validate it, have Ekklesia publish it at a browser URL, and have
> Ekklesia's browser shell discover and mount it — the loop closes end to end. **What does not exist
> is the Dashboard**: no navigation, grouping, icons, ordering, theme, or design system, and no
> standalone host. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint),
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and
> [What actually mounts your UI](#what-actually-mounts-your-ui).
> **Before you publish or install a module:** modules are trusted compiled backend code. Your
> module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
@@ -385,12 +385,14 @@ Layer rules are unchanged: module UI source may not import Core, an app, a surfa
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target.
> **Your artifact is now served, but nothing mounts it.** Ekklesia publishes an installed module's
> compiled UI artifact at `GET /api/v1/module-ui/<module-id>/<version>`
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)), which
> `@ekklesia/module-ui-host` can load. **But no Ekklesia host calls it**: there is no Dashboard shell,
> no navigation, and no standalone host, so your UI is proven by tests and reachable by URL, not
> rendered anywhere in production. See [How a host loads your UI](#how-a-host-loads-your-ui).
> **Your artifact is served, and Ekklesia's shell mounts it.** The platform publishes your compiled UI
> at `GET /api/v1/module-ui/<module-id>/<version>`
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
> shell discovers and mounts it
> ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a
> module list and a mount target, not a Dashboard. See
> [How a host loads your UI](#how-a-host-loads-your-ui) and
> [What actually mounts your UI](#what-actually-mounts-your-ui).
### What this does and does not do today
@@ -413,7 +415,8 @@ You do not write this — a host does — but knowing the shape explains what yo
```text
compiled artifact (dist/ui/index.js)
↓ asset publication — host-specific; Ekklesia serves it at
↓ GET /api/v1/module-ui/<module-id>/<version>
↓ GET /api/v1/module-ui/<module-id>/<version>, and its shell finds it
↓ through GET /api/v1/module-ui
browser-loadable module URL
↓ loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest
@@ -472,8 +475,33 @@ The response carries `Content-Type: text/javascript; charset=utf-8`, `X-Content-
nosniff`, `Cache-Control: no-cache`, and a strong `ETag`, so rebuilding your module at the same
version is picked up on the next request rather than being cached indefinitely.
> **Still nothing mounts it.** The URL exists and the loader accepts it, but Ekklesia has no Dashboard
> shell, no navigation, and no code that requests your UI. That is the next pass.
### What actually mounts your UI
Ekklesia's browser shell (`surfaces/shell`) asks the server which module UIs exist, and mounts them
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)):
```text
GET /api/v1/module-ui → { id, version, ui.url, api.baseUrl, permissions }
↓
loadModuleUi(ui.url) → your default export, identity-checked against your manifest
↓
mount(container, context)
```
Three things follow for you:
- **Your module appears automatically once it is installed and discovered.** The shell has no list of
modules and imports none; yours arrives from the inventory at run time. You register nothing.
- **`context.permissions` reflects a real server decision** about *your* module's declared
permissions for the current viewer — not a default, and not the viewer's permissions elsewhere. It
is still only a hint; your backend route is what actually protects anything.
- **`context.api.request(path)` reaches only your own module's API.** The host resolves it onto your
API root; a path that escapes it (`"../other-module/records"`, an absolute URL) is refused before
it becomes a request. Keep asking for relative paths and your artifact stays hostable elsewhere.
> **This is not the Dashboard.** The shell is a module list and a mount target. There is no
> navigation, grouping, icons, ordering, theme, or design system yet, and the selector shows your
> module's id — the inventory deliberately carries no display metadata until that design exists.
### What a module UI will never own