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
@@ -0,0 +1,310 @@
# 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.
### 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.
+1
View File
@@ -34,3 +34,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.md) — Accepted
- [0024: Browser-safe module UI SDK and the generic host loader](0024-browser-safe-ui-sdk-and-host-loader.md) — Accepted
- [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted
- [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted
+30 -3
View File
@@ -240,9 +240,36 @@ so the route is not an enumeration oracle. Caching is `no-cache` with a strong `
`immutable`, because without an installer a version can be rebuilt with different bytes. The server
reads and writes bytes; it never imports, evaluates, or transforms the artifact.
**A host could now obtain a URL, and nothing uses one.** There is no Dashboard shell, no navigation,
no module metadata endpoint, and no code that calls the URL helper outside its own tests. A design
system, stylesheet and general asset publication, and the standalone host remain future work.
**Phase 6C pass 2 builds the browser host that consumes it.**
[ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md) adds `surfaces/shell`
(`@ekklesia/shell`), the repository's first surface: a small React application, built to static files
by its own esbuild script, that discovers installed module UIs and mounts them.
The missing piece was discovery. `GET /api/v1/module-ui` — the collection root of the same prefix the
artifact route already owns — returns, 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 actually holds. Nothing else: no filesystem paths, and no display or
navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's presentation
configuration. The module set is the publication registry, never a rescan, so the inventory and the
artifact route cannot disagree. It requires `core.modules.ui.read` (baseline `authenticated`) because
it is precisely the enumeration ADR 0025 withheld from the anonymous artifact route.
Both URLs being server-produced is the load-bearing part. `moduleUiArtifactUrl` and the newly exported
`moduleApiBaseUrl` are each the single construction of their URL shape, so the browser is a consumer
of both — it never rebuilds `/api/v1/modules/<id>`, the prefix ADR 0022 forbids a module UI from
spelling, and neither prefix is pinned by having a second author.
The shell builds a `ModuleUiHostContext` entirely from server-derived data, resolves a module's
relative API calls onto its own API root (refusing anything that escapes it), and hands the artifact
URL to `@ekklesia/module-ui-host`. It statically imports no module: one frame per selection defeats
the stale-load race, the host owns the container and every loading and error state, and the bundle is
checked to contain exactly one dynamic import with no module identity baked in. Permission hints come
from Core's own evaluator and stay presentation-only — server authorization is proven unchanged by a
test in which a UI holding no grant calls its API anyway and is refused.
**This is not the Dashboard.** No navigation, grouping, icons, ordering, design system, or routing;
the selector lists module ids. A production CSP and a deployment for the built shell, sign-in UI,
general asset publication, and the standalone host remain future work.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.
+16 -2
View File
@@ -8,7 +8,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
| `templates/module` | A small example backend module | The same dependencies allowed for any module |
@@ -47,7 +47,21 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- A publication helper accepts module **identity** only. Neither Core's registry nor the API's URL
builder takes a filename, relative path, or any other artifact selector, so no caller can use one
to reach a second file inside a module package.
- A surface never imports Core or a feature module. It uses public API/client contracts.
- A surface never imports Core or a feature module. It uses public API/client contracts, and reaches
the server over HTTP.
- A surface may depend only on browser-safe shared roles (`api-client`, `auth-client`,
`design-tokens`, `module-ui-host`, `module-ui-sdk`, `surface-sdk`, `ui`) plus dev-time `testing`
and `tooling`. **`@ekklesia/contracts` is deliberately excluded**, despite being 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](decisions/0024-browser-safe-ui-sdk-and-host-loader.md), which removed exactly that
import from `@ekklesia/module-ui-sdk` to close the browser boundary. `module-sdk` re-exports the
same barrel and is excluded for the same reason. A surface declares its own types for the wire and
validates what arrives ([ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md)).
- The browser host (`surfaces/shell`) statically imports no module. Every installed module UI it can
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
- Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or
surface internals.
+55
View File
@@ -76,6 +76,7 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
| `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's API lives, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
@@ -451,6 +452,60 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
registry: installing, removing, or editing a module's routes takes effect only on the next API
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Module UI inventory
```text
GET /api/v1/module-ui
```
The one query a browser host makes before it can mount anything
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). It is the collection
root of the same resource the artifact route below serves: one prefix, two representations.
```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 a `ModuleUiHostContext` needs 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](../architecture/decisions/0021-modules-as-hostable-products.md) keeps Dashboard
grouping an administrator's presentation configuration rather than a module declaration.
- **Both URLs are server-produced.** `ui.url` comes from `moduleUiArtifactUrl` and `api.baseUrl` from
`moduleApiBaseUrl`, so each URL shape is constructed in exactly one place in the repository. A
browser never rebuilds `/api/v1/module-ui/<id>/<version>` or `/api/v1/modules/<id>`, which is what
lets either change without touching host or module code.
- **The module set is the publication registry**, not a rescan: modules that survived discovery and
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
Order is the registry's own deterministic discovery order.
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
this endpoint *is* that enumeration. `authenticated` rather than `restricted` because a shell that
rendered nothing until an operator granted every user a permission would be unusable.
- **`permissions` is the subset of that module's own declared permissions the caller holds**,
evaluated server-side by Core's one authorization evaluator — so baselines, the platform-owner
bypass, grant expiry, and deny-by-default behave exactly as on a real request. Granted ids only,
scoped to the module: no module learns the viewer's authorization state anywhere else in the
platform. **These are presentation hints, never authority**; every module route stays enforced by
`requirePermission` regardless of what a browser was told.
- **Failures.** Anonymous or unpermitted callers get `403 {"code":"AUTHORIZATION_DENIED"}` from the
shared middleware. An authorization-evaluation outage is `503
{"code":"AUTHORIZATION_EVALUATION_FAILED"}` rather than an empty hint set, because a hint that
silently read "denied" during an outage would blank the UI and look like a permissions problem.
- **Per-principal**, so `Cache-Control: no-store` and `Vary: Origin, Cookie`, exactly like
`/api/v1/principal`. `GET` only; no other method is routed.
## Module UI asset publication
A module may declare `entrypoints.ui`, a compiled browser ESM artifact
+26
View File
@@ -126,6 +126,32 @@ may be published or mounted. A module UI that mounts has gained no privilege it
— its server half already runs in the API process with the API's database authority — and server APIs
remain the sole authority for authorization regardless of what a browser loaded.
### The browser host mounts module UI; it grants nothing
Ekklesia's shell (`surfaces/shell`) discovers installed module UIs through
`GET /api/v1/module-ui` and mounts them with the generic loader
([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). Three properties are
security-relevant.
**The inventory is authenticated, deliberately.** Module UI *bytes* are anonymous because fetching
compiled code confers no authority, and that route is carefully not an enumeration oracle. The
inventory is that enumeration — it lists which module products an installation runs — so it requires
`core.modules.ui.read` (baseline `authenticated`): any signed-in user, and nobody else. It carries no
filesystem path, package root, or entrypoint.
**Permission hints are computed by the server and remain hints.** For each listed module the API
evaluates that module's *own* declared permissions against the caller using Core's one authorization
evaluator, and returns only the granted ids — never a default of `true`, never inferred from a
client-side role, and never another module's or Core's authorization state. Every module route stays
enforced by `requirePermission` regardless; the test suite proves it by having a UI that was told
"denied" call its API anyway and still receive `403`.
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
shell owns the container, the loading state, and every error, so a failing module costs its own panel
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
+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