feat: publish validated module UI artifacts at a platform-owned URL
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 41s

Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI
build pipeline and the generic host loader: Core resolves entrypoints.ui to an
absolute server filesystem path, and a browser needs a URL. This adds that
publication layer.

Core owns the policy. createModuleUiPublicationRegistry builds, from the
discovered and started module inventory, the answer to "may this be published,
and which exact file is it?" — publishable means the module survived discovery
and declares entrypoints.ui, with no new lifecycle state and no installer.

apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both
segments are lookup keys, not path components. An exact match yields the
canonical artifact path discovery already validated; no request value is ever
composed into a filesystem path, and the route declares no wildcard segment, so
traversal, encoded separators, sibling chunks, package-root files, and source
maps are unreachable by construction. An installed module directory never
becomes a static file root.

The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module
route segment, so a module registering GET /ui/:version would otherwise compete
with the platform for one URL with routing order deciding the winner.

The route requires no authentication, deliberately. Fetching the bundle confers
no authority, and a cookie-gated route would break cross-origin import(), which
sends none. The cost is stated rather than hidden: a caller who knows a module
id and its exact version can download that bundle. The three failure modes
return one identical 404 so the route is not an enumeration oracle.

Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match
handling, not immutable: without an installer nothing stops a version being
rebuilt with different bytes. The server reads bytes and writes bytes — never
imports, evaluates, or transforms the artifact.

Nothing mounts a module UI. A host could now obtain a URL, which was previously
impossible; there is still no Dashboard shell, navigation, or metadata endpoint.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 08:11:53 -05:00
co-authored by Claude Opus 5
parent 82cbeef437
commit a02deb9433
20 changed files with 1898 additions and 30 deletions
@@ -0,0 +1,262 @@
# 0025: Trusted module UI asset publication
- Status: Accepted
- Date: 2026-08-22
- Extends: [0021](0021-modules-as-hostable-products.md),
[0022](0022-module-ui-authoring-and-host-contract.md),
[0023](0023-module-ui-build-pipeline.md),
[0024](0024-browser-safe-ui-sdk-and-host-loader.md)
## Context
ADR 0024 closed the loader and named the one thing still missing, precisely:
> Core's discovery resolves `entrypoints.ui` to an **absolute path on the server's disk**
> (`ResolvedModuleEntrypoints.ui`). A browser cannot import that. […] **Ekklesia has no such route
> today.** The loader is complete; publication is deferred and is the first thing the next pass must
> build.
So the pipeline ran `module UI source → build → compiled browser ESM → entrypoints.ui → discovery
validates a filesystem artifact → ??? → browser URL → loadModuleUi`. This ADR fills in the `???`.
Two constraints shape it, and both come from the repository rather than from general principle.
`docs/development/security.md` already wrote down the obligation this pass inherits: *"the existence
of an artifact on disk must not be what makes it servable. Only installed, reviewed modules may be
published or mounted under this trust model."*
And the platform's module state is genuinely thin. `ModuleLifecycleStage` (`@ekklesia/contracts`)
names nine stages, but nothing implements a state machine over them: there is no installer, no
enable/disable, no trust store, and no per-module status record. What exists is one accepted set —
the modules `discoverModules` validated at startup and `startDiscoveredModuleRuntime` then started.
## Decision
### Publication is derived from validated module identity, never from a request path
The route takes a module id and a version, hands both to a registry as **lookup keys**, and — only
on an exact match — reads the canonical `artifactPath` Core's discovery already resolved.
```text
moduleId + version
↓ exact lookup, no path arithmetic
validated DiscoveredModule.entrypoints.ui
↓ read exactly that path
response bytes
```
No request value is ever joined, resolved, normalized, or concatenated into a filesystem path, and
the route declares no wildcard segment for one to hide in. Path traversal, an encoded separator, a
symlink, a sibling chunk, a source map, and a package-root file are therefore unreachable *by
construction* rather than by filtering — there is no code path that could reach them to filter.
This is the rule the pass exists to establish: **an installed module directory is never a static
file root.** A publisher that accepted `moduleId + relativePath` would be one careless normalization
away from serving `.env`, and it would also silently create an undeclared asset contract that no
manifest field, build step, or host loader knows about.
### Core owns the policy; `apps/api` owns the HTTP
`createModuleUiPublicationRegistry(modules)` (`core/src/modules/ui-publication.ts`) answers "may this
be published, and which exact file is it?". It is pure, synchronous, and consumes only
`DiscoveredModule` records — so a module reaches it having already passed manifest validation,
compiled-artifact validation (ADR 0006, extended to every non-directory entrypoint), and package
containment before *and* after symlink resolution.
"Publishable" therefore means exactly two things: the module is in the discovered set the platform
accepted at startup, **and** its manifest declares `entrypoints.ui`. No new state, no new lifecycle,
no installer. `apps/api/src/server.ts` builds the registry only after
`startDiscoveredModuleRuntime` has succeeded, so the published set is also the running set — module
startup is all-or-nothing, and a module that failed to load, register, or start takes API startup
down rather than lingering as publishable-but-not-running.
Keeping the policy in Core is not filing: it means the decision is expressed once, over already
validated data, where the rest of module discovery and lifecycle lives, rather than being re-derived
inside an HTTP handler that also has to think about headers and status codes.
### The URL is a sibling of the module API prefix, not a child of it
```text
GET /api/v1/module-ui/<module-id>/<version>
```
Nesting it under `/api/v1/modules/<module-id>/ui/...` was the obvious first choice and is wrong.
Core's own route grammar (`moduleRoutePathSchema`) accepts `ui` as an ordinary lowercase static
segment and `:version` as an ordinary parameter, so a module may legitimately register
`GET /ui/:version` — mounted at exactly `/api/v1/modules/<module-id>/ui/<version>`. Publishing there
would put the platform and installed modules in one URL namespace with Hono's registration order
silently deciding which one answers a request. A sibling prefix cannot collide: every module route
begins `/api/v1/modules/`, and nothing published here does.
It stays inside `/api/v1/*` so it inherits the application's single CORS policy — exact reflected
trusted origins, never a wildcard — which a cross-origin browser `import()` of this URL requires. A
new top-level prefix would have meant a second, separately-configured public URL space.
`moduleUiArtifactUrl({ moduleId, version })` (`apps/api/src/module-ui-routes.ts`) is the one
construction of that URL. It accepts **identity only** — no filename, no relative path, no artifact
selector — and percent-encodes both components, so even an unvalidated value can only ever become
one segment's worth of text, never a new segment, a traversal, a query, or a fragment. The result is
root-relative: a same-origin host passes it to `loadModuleUi` unchanged, and a split-origin host
resolves it against the same API base URL it already needs for `ModuleUiApiClient`. Baking in an
absolute origin would have broken reverse proxies and split-origin deployments alike.
### Version is part of the path, and a mismatch fails
A request naming a version other than the installed one resolves to nothing. It is never satisfied
with "the version that happens to be installed", which would let a stale host mount an artifact it
did not ask for — the exact failure `parseModuleUiExport`'s identity check exists to catch, arriving
one layer earlier and more cheaply.
Version in the path also means an upgrade changes the URL, so a cached artifact from the previous
version can never be served for the new one.
### Cache: revalidate always, with a strong `ETag`
`Cache-Control: no-cache` plus a SHA-256 `ETag` over the bytes actually being served, and RFC 9110
`If-None-Match` handling (wildcard, weak comparison, `304`).
`immutable` was tempting and is unearned. The URL carries *identity*, not *immutability*: Ekklesia
has no installer, no content addressing, and no integrity verification, so nothing stops an operator
from rebuilding version `1.2.3` with different bytes and restarting — which is the ordinary inner
loop of developing a module. Under `immutable`, a browser holding a superseded artifact would have
no way to recover.
`no-cache` is not `no-store`: the response is cached and merely revalidated, so an unchanged artifact
costs one conditional request and a `304`. Hashing the bytes rather than trusting an mtime also means
the validator can never describe a different revision than the body does. When an installer can
guarantee that an installed version's bytes never change, one constant moves and the policy becomes
immutable caching.
### No authentication, decided rather than defaulted
Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an operator
reviewed and installed; it carries no credential, and the host — not the artifact — supplies the
principal and permissions at mount time. Every module API stays behind `requirePermission` against
the composed platform registry, and fetching this file changes no authorization decision anywhere.
Requiring a session would also break the thing it purports to protect. A browser's dynamic `import()`
uses `credentials: "same-origin"`, so a cross-origin import — the split-origin deployments
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support — sends no cookie and would fail. That failure
tests green on a server and only appears in a real browser, which is precisely the class of error ADR
0024 was written to prevent. Working around it means fetching bytes and importing a `blob:` URL,
which costs a host its `script-src` CSP.
**The cost, stated plainly: an anonymous caller who knows a module id *and* its exact installed
version can download that module's UI bundle, and thereby learn that the module is installed.** That
is information disclosure, not authority. Two things bound it: the caller must already know both
halves of the identity, and unknown-module, no-UI, and wrong-version all return one byte-identical
`404 MODULE_UI_NOT_PUBLISHED`, so the route is not an enumeration oracle. An operator who needs the
bundle itself confidential must not expose the API to untrusted networks — which
`docs/development/security.md` already assumes for this deployment model. If a future deployment
model needs more, an `authenticated` baseline belongs on this route and nowhere else.
### Only the declared artifact, and never the source map
`@ekklesia/module-ui-build` emits `sourcemap: true`, so a real `.map` file sits next to every built
artifact. It is not published, for the same reason `.css` sidecars are rejected at build time (ADR
0023): the manifest declares the UI entrypoint and nothing else, so nothing else has a publication
contract. A browser's devtools will request `<artifact>.map` and receive a `404`; that is the correct
answer, not a gap. Source maps embed `sourcesContent`, and publishing an installation's module
sources unauthenticated as an accident of "the file was next to the one we meant" is exactly what a
narrow route prevents.
### The response is bytes, and the server never runs them
The route reads the file and writes it back: no import, no evaluation, no transformation, no
minification, no re-bundling, no rewriting. A module UI whose top level throws is served exactly like
any other. ADR 0021's rule that discovery never executes `entrypoints.ui` is unchanged, and
publication does not become a second, quieter way to execute browser code on the server.
`Content-Type: text/javascript; charset=utf-8` — the canonical JavaScript type, which a browser
requires before it will evaluate an ES module — with `X-Content-Type-Options: nosniff`, so the
declared type is the only type.
### Discovery-time validation plus request-time exact-path read is the trust model
Discovery validated the artifact at startup; the file may change or vanish before any HTTP retrieval.
The route does not pretend otherwise. At request time it opens exactly the previously resolved path,
once. A read failure produces a sanitized `503 MODULE_UI_ARTIFACT_UNAVAILABLE` carrying no path, no
`errno`, and no caught error — there is deliberately no fallback path, no re-resolution, and no retry
against a reconstructed location, so a vanished artifact fails rather than quietly resolving to a
neighbour.
No hashing, signing, or integrity pinning is introduced. Under the current model an installed module
package is trusted server code an operator placed on disk (`docs/development/security.md`), and a
platform that cannot verify provenance at install time gains nothing from verifying it at request
time. Symlink safety is likewise not re-implemented: discovery already proved containment before and
after resolution, and this route consumes that canonical result rather than re-deriving it from
request input.
## Alternatives
- **`GET /api/v1/modules/:moduleId/ui`, under the module API prefix.** Rejected on a concrete
collision, not on taste: `ui` is a legal module route segment, so a module could register the same
path and routing order would decide who answers.
- **`GET /modules/:moduleId/assets/*path`.** Rejected outright — it is the generic static root this
ADR exists to forbid, and it would invent an undeclared asset contract with no manifest field, no
build step, and no loader behind it.
- **A new top-level prefix (`/_ekklesia/...`).** Rejected: the repository has exactly one public API
namespace, and a second one would need its own CORS, cache, and error conventions to be
re-decided and kept in sync.
- **Unversioned URLs (`/api/v1/module-ui/:moduleId`).** Rejected: it forces conservative caching for
every deployment to survive an upgrade, and it discards identity the host already has and
`parseModuleUiExport` will check anyway.
- **`Cache-Control: public, max-age=31536000, immutable`.** Rejected: version reuse is possible and
unrecoverable under it. Revisit when an installer makes an installed version's bytes immutable.
- **`Cache-Control: no-store`,** matching every other route in the application. Rejected as
needlessly strict: those routes carry personalized or credentialed data, and this one is the same
bytes for every caller. `no-cache` with a validator is both correct and cheap.
- **Requiring an authenticated principal.** Rejected on the concrete grounds above — no authority is
conferred, and cookie-less cross-origin `import()` would break. It is the change to make first if
the deployment model ever requires confidentiality.
- **Requiring a permission.** Rejected more firmly: it would mean inventing a Core permission whose
only effect is on a file that grants nothing, and it puts an authorization decision in front of an
asset while implying the asset is itself protected.
- **Publishing source maps alongside the artifact.** Rejected: undeclared, and `sourcesContent`
would expose module sources to any caller.
- **Streaming the file instead of buffering it.** Rejected as unjustified complexity for one small
artifact — and the `ETag` has to hash the bytes anyway, so buffering is what makes the validator
describe exactly what was sent.
- **A shared `@ekklesia/module-ui-publication` package for the URL contract.** Rejected: one
producer, one consumer, and a future Dashboard will receive the URL from server metadata rather
than importing a package to rebuild it.
- **Teaching `@ekklesia/module-ui-host` how Ekklesia publishes artifacts.** Rejected, again: it is
exactly the coupling ADR 0024 refused, and it would make the standalone host's problem Ekklesia's.
- **Request-time integrity verification (hash or signature).** Rejected as infrastructure without a
root of trust: nothing produces a trusted digest at install time, so the check would only compare
the file to itself.
## Consequences
A validated installed module's UI artifact can now be fetched by a browser from a platform-controlled
URL, and the bytes that URL returns are the bytes `@ekklesia/module-ui-host` mounts — proven by
feeding the route's own HTTP response into the real loader.
**This is not Dashboard integration.** Nothing in Ekklesia mounts a module UI: there is no shell, no
navigation, no place a module's screen appears, and no code calls `moduleUiArtifactUrl` outside its
own tests. What changed is that a host now *could* obtain a URL, which was previously impossible.
`createApiApp` gained a required `moduleUi` option. Required rather than defaulted, matching
`moduleRoutes`: a caller that publishes no module UI says so with an empty registry instead of
falling through to an implicit one.
Node cannot `import()` an `http:` URL, so the end-to-end chain is proven in two honest halves rather
than one misleading whole: the route's publication behaviour is proven over real HTTP (both through
`app.request` and through a real socket against a started API), and loader compatibility is proven by
taking the bytes **that route returned** and importing them as a `data:` URL — a genuine module URL,
the same technique ADR 0024's own tests use. No test imports the artifact from the filesystem and
calls that a proof of HTTP publication.
## Deferred
Not implemented or promised here: **the Dashboard host** — the shell, routing, navigation, grouping,
and the code that actually calls `loadModuleUi` with a published URL — which is the next pass; a
module metadata endpoint that hands a host the list of installed modules and their UI URLs (the URL
helper exists for it, but no route returns one); a design system, theme tokens, or component library;
stylesheet, image, font, and general module asset publication, along with the manifest contract each
would need; source-map publication; React runtime sharing; a standalone application host; module
installation, upload, marketplace, hot loading, signing, or provenance; enable/disable or trust state
for installed modules; and every real feature module.
The permission-id typing gap carried over from ADR 0022 is likewise unchanged: `permissions.has`
takes a plain string, so a typo silently hides a control.
+1
View File
@@ -33,3 +33,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
- [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
+27 -4
View File
@@ -216,10 +216,33 @@ host will use: dynamic import from a browser URL, identity validated against the
mounting, a host-owned container the loader never mutates, and an idempotent unmount. Modules may not
depend on it.
The distinction that shapes the next pass: Core resolves `entrypoints.ui` to a **server filesystem
path**, and a browser needs a **URL**. Turning an installed artifact into a browser URL is asset
publication — host-specific, deliberately outside the loader, and not implemented. Until it exists,
no host can mount an installed module UI.
**Phase 6C pass 1 publishes a validated module UI artifact at a browser URL.**
[ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md) closes the gap the previous pass
named: Core resolves `entrypoints.ui` to a **server filesystem path**, and a browser needs a **URL**.
Core owns the policy and `apps/api` owns the transport. `createModuleUiPublicationRegistry`
(`core/src/modules/ui-publication.ts`) answers "may this be published, and which exact file is it?"
from the discovered, started module inventory — publishable means the module survived discovery
*and* declares `entrypoints.ui`, no new lifecycle state and no installer. `apps/api` serves
`GET /api/v1/module-ui/<module-id>/<version>`, where both segments are **lookup keys, not path
components**: an exact match yields the canonical artifact path discovery already validated, and no
request value is ever composed into a filesystem path. There is no wildcard segment, so traversal,
sibling chunks, package-root files, and source maps are unreachable by construction — an installed
module directory never becomes a static file root. The prefix is a sibling of `/api/v1/modules/`
rather than a child because `ui` is a legal module route segment and a module could otherwise collide
with the platform.
The route requires no authentication: the bundle confers no authority, every module API stays behind
`requirePermission`, and a cookie-gated route would break cross-origin `import()`, which sends none.
The disclosure that buys — a caller who knows a module id and its exact version can download that
bundle — is stated rather than papered over, and the three failure modes are made indistinguishable
so the route is not an enumeration oracle. Caching is `no-cache` with a strong `ETag`, not
`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.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.
@@ -36,6 +36,17 @@ These rules map platform ownership to filesystem and package boundaries. The exe
Node's global declarations — into browser code. Core still owns the containing
application shell and all platform policy, and module UI reaches the host only through
`@ekklesia/module-ui-sdk`.
- Module UI **asset publication** runs Core → `apps/api`, never the other way
([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy
(`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule`
records); `apps/api` owns the HTTP route and the one canonical URL construction. Neither
`@ekklesia/module-ui-host` nor `@ekklesia/module-ui-sdk` learns anything about how Ekklesia
publishes artifacts — the loader takes a URL and stays host-neutral — and no module may reach
publication internals. This introduced no new package and no boundary relaxation: `apps/*` could
already depend on Core and on shared packages.
- 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.
- Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or
+54
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/<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
deterministic `404 {"code":"NOT_FOUND"}`.
@@ -450,6 +451,59 @@ 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 asset publication
A module may declare `entrypoints.ui`, a compiled browser ESM artifact
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md),
[ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)). Core's discovery resolves it
to an absolute path on the server's filesystem; a browser needs a URL. This route is the bridge
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)).
```text
GET /api/v1/module-ui/<module-id>/<version>
```
- **Identity, not paths.** `:moduleId` and `:version` are lookup keys handed to a publication
registry Core builds from the discovered, started module inventory. Only an exact match on **both**
causes the already-canonical artifact path discovery validated to be read. No request value is ever
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
maps are unreachable by construction. **An installed module directory is never a static file
root**, and only the single artifact its manifest declares is ever served.
- **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route
path `/ui/:version`, so nesting publication inside the module prefix would let a module and the
platform compete for one URL with routing order deciding the winner.
- **Publishable means discovered and started.** A module directory on disk is not enough: the module
must be in the validated set the API accepted at startup *and* declare `entrypoints.ui`. There is
no installer, enable/disable state, or trust store to consult, and none was invented here.
- **No authentication, deliberately.** Fetching the bundle confers no authority — every module API
stays behind `requirePermission`, and the host, not the artifact, supplies the principal and
permissions at mount time. Requiring a cookie would also break cross-origin dynamic `import()`,
which sends none. The cost, stated plainly: a caller who already knows a module id **and** its exact
installed version can download that bundle. Unknown module, no declared UI, and wrong version all
return one byte-identical `404 {"code":"MODULE_UI_NOT_PUBLISHED"}`, so the route is not an
enumeration oracle.
- **Response.** `Content-Type: text/javascript; charset=utf-8` with `X-Content-Type-Options:
nosniff`, and the artifact byte-for-byte — never transformed, re-bundled, minified, rewritten, or
executed. A module UI whose top level throws is served like any other; the server never imports it.
- **Caching.** `Cache-Control: no-cache` plus a strong SHA-256 `ETag` over the bytes actually served,
with RFC 9110 `If-None-Match` handling (wildcard, weak comparison, `304`). Not `immutable`: without
an installer nothing stops a version being rebuilt with different bytes, and an unrecoverable stale
cache is worse than one conditional request. A version bump changes the URL anyway.
- **Artifact changed after discovery.** Discovery validated the artifact at startup; it may vanish
later. The route opens exactly the previously resolved path once, with no fallback and no
re-resolution, and a read failure returns a sanitized `503
{"code":"MODULE_UI_ARTIFACT_UNAVAILABLE"}` carrying no path, `errno`, or caught error.
`moduleUiArtifactUrl({ moduleId, version })` (`apps/api/src/module-ui-routes.ts`) is the one
construction of this URL. It accepts identity only and percent-encodes both components. The result is
root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against
the same API base URL it already needs for `ModuleUiApiClient`.
**Nothing mounts a module UI yet.** A host can now obtain a browser URL that
`@ekklesia/module-ui-host` accepts, which was previously impossible — but Ekklesia has no Dashboard
shell, no navigation, and no code that calls this route outside its own tests.
## Module database access
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
+38 -5
View File
@@ -86,11 +86,44 @@ expected — the right id, the right version, a well-formed default export — a
anything else, but that is an integrity check against a *stale or mismatched* bundle. It is not a
judgement about whether a module is safe, and it must never be described as one.
The publication side carries the real obligation, and it is not built yet. When Ekklesia gains a
route that serves installed module artifacts as browser URLs, **the existence of an artifact on disk
must not be what makes it servable.** Only installed, reviewed modules may be published or mounted
under this trust model. A module UI that mounts has gained no privilege it did not already have —
its server half already runs in the API process with the API's database authority — and server APIs
The publication side carries the real obligation, and it now exists
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)). It honours the
rule this section demanded of it: **the existence of an artifact on disk is not what makes it
servable.** A module is publishable only if it is in the validated set the API accepted at startup
*and* its manifest declares `entrypoints.ui`.
### Publishing a module UI artifact exposes one file, not a directory
`GET /api/v1/module-ui/<module-id>/<version>` serves an installed module's compiled UI artifact. Both
segments are **lookup keys, not path components**: they are matched against a registry Core built
from discovered modules, and only an exact match causes the canonical path discovery already resolved
and containment-checked to be read. No request value is composed into a filesystem path and the route
declares no wildcard segment, so traversal, encoded separators, sibling bundle chunks, package-root
files (`package.json`, `.env`), and **source maps** are unreachable — not filtered out, but with no
code path that could reach them. `@ekklesia/module-ui-build` emits a real `.map` next to every
artifact, and it is deliberately not served: the manifest declares the entrypoint and nothing else,
and a source map embeds `sourcesContent`.
The server never executes what it publishes. The route reads bytes and writes bytes — no import, no
evaluation, no transformation — so a module UI whose top level throws is served like any other, and
ADR 0021's rule that discovery never runs `entrypoints.ui` is unchanged.
**The route requires no authentication, and that is a decision with a stated cost.** Retrieving the
artifact confers no authority: the bundle carries no credential, the host (not the artifact) supplies
the principal and permissions at mount time, and every module API remains behind `requirePermission`.
Requiring a session would also break cross-origin dynamic `import()`, which sends no cookie. The
cost is that **an anonymous caller who already knows a module id *and* its exact installed version can
download that module's UI bundle**, and thereby learn the module is installed. That is information
disclosure, not authority. Unknown module, no declared UI, and wrong version return one identical
`404`, so the route is not an enumeration oracle — but an operator who needs the bundle itself
confidential must keep the API off untrusted networks, exactly as the deployment assumptions below
already require.
Trust in a published artifact rests on discovery-time validation plus a request-time read of that
exact path. There is no signing, hashing, or provenance verification — Ekklesia has none to verify
against — so **publication is not evidence that a module is safe.** Only installed, reviewed modules
may be published or mounted. A module UI that mounts has gained no privilege it did not already have
— 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.
### Client-side permission checks are not a security boundary
+46 -15
View File
@@ -9,11 +9,13 @@ platform context, the application shell, top-level routing, navigation, the glob
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
application's. A module consumes those; it never redefines or bypasses them.
> **What exists today:** a module may *declare* a UI entrypoint in its manifest, and Core validates
> it. **Nothing loads, mounts, or renders it yet** — the host shell, the UI runtime, and the
> standalone host are all later Phase 6 work. Writing a module UI is not yet possible end to end;
> see [Declaring a UI entrypoint](#declaring-a-ui-entrypoint) for exactly what is and is not
> supported.
> **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.
> **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
@@ -383,11 +385,12 @@ 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.
> **Still no host.** You can author, build, and test a module UI end to end, and
> `@ekklesia/module-ui-host` can load and mount a built artifact from a browser URL. **Nothing serves
> your artifact at such a URL yet** — Ekklesia has no module-asset publication route, and the
> Dashboard shell and standalone host do not exist. Your artifact is proven by tests, not mounted in
> production. See [How a host loads your UI](#how-a-host-loads-your-ui).
> **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).
### What this does and does not do today
@@ -409,7 +412,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, NOT YET IMPLEMENTED for Ekklesia
↓ asset publication — host-specific; Ekklesia serves it at
↓ GET /api/v1/module-ui/<module-id>/<version>
browser-loadable module URL
↓ loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest
@@ -439,10 +443,37 @@ What that means for you as a module author:
- **Your UI may be mounted more than once**, into different containers, if your implementation
supports it. Each mount gets its own cleanup.
> **Asset publication is the missing piece.** Core resolves `entrypoints.ui` to a path on the
> *server's filesystem*; a browser cannot import that. Something must serve your compiled artifact at
> a URL first, and Ekklesia has no such route yet. Until it does, no host can mount an installed
> module's UI — the loader is ready, the plumbing to reach it is not.
### How Ekklesia publishes your artifact
Core resolves `entrypoints.ui` to a path on the *server's filesystem*, which a browser cannot import.
Ekklesia turns that into a URL
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
```text
GET /api/v1/module-ui/<module-id>/<version>
```
Four things about it matter to you as a module author:
- **Only the artifact your manifest declares is served.** The module id and version in that URL are
lookup keys, never path components — there is no way to request a second file from your package.
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
stylesheet import still fails the build.
- **The version must match exactly.** A request for any other version is refused rather than served
your installed artifact, so a stale host cannot mount something it did not ask for.
- **Your artifact is public.** The route requires no authentication — fetching it grants no
authority, and your backend routes remain permission-enforced regardless. Never put a secret, a
private endpoint, or anything you would not publish in your UI bundle.
- **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no
evaluation. What `pnpm build` produced is exactly what a browser receives.
The response carries `Content-Type: text/javascript; charset=utf-8`, `X-Content-Type-Options:
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 a module UI will never own