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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a76ff6f26
commit
616c1d4834
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user