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.
|
||||
|
||||
Reference in New Issue
Block a user