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
+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