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
+32 -6
View File
@@ -299,12 +299,38 @@ idempotent `unmount()`. A module may never depend on it — it is host infrastru
checker enforces that. See
[ADR 0024](docs/architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md).
**Still no host, and one specific reason why.** Core resolves `entrypoints.ui` to a path on the
*server's filesystem*; a browser cannot import that. **Ekklesia has no route that serves an installed
module's artifact at a browser URL**, so nothing can mount an installed module UI yet — the loader is
complete, asset publication is not, and that is the next pass's first job. The Dashboard shell,
navigation, a design system, a stylesheet loading contract, and the standalone host also do not
exist.
Phase 6C pass 1 builds the publication layer those passes were missing. Core resolves
`entrypoints.ui` to a path on the *server's filesystem*, and a browser needs a **URL**; the platform
now serves one:
```text
GET /api/v1/module-ui/<module-id>/<version>
```
`:moduleId` and `:version` are **lookup keys, not path components**. They are matched against a
publication registry Core builds from the discovered, started module inventory, and only an exact
match causes the already-canonical artifact path discovery validated to be read. No request value is
ever joined 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**, and only the single artifact its
manifest declares is served. A module that is not installed, one that declares no UI, and a wrong
version return one identical `404`; an artifact that vanished after discovery returns a sanitized
`503` carrying no path. The prefix is a *sibling* of `/api/v1/modules/`, not a child, because `ui` is
a legal module route segment and a module could otherwise collide with the platform.
**The route requires no authentication, deliberately.** Fetching the bundle confers no authority —
every module API stays behind `requirePermission`, and the host, not the artifact, supplies the
principal — while requiring a cookie would break cross-origin `import()`, which sends none. The cost
is stated plainly: a caller who already knows a module id *and* its exact installed version can
download that bundle. Caching is `no-cache` with a strong `ETag` rather than `immutable`, because
without an installer nothing stops a version being rebuilt with different bytes. See
[ADR 0025](docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md).
**This is not Dashboard integration.** A host *could* now obtain a browser URL, which was previously
impossible — but nothing in Ekklesia mounts a module UI. There is no Dashboard shell, no navigation,
no place a module's screen appears, and no code calls the URL helper outside its own tests. A design
system, a stylesheet or general asset publication contract, a module metadata endpoint, and the
standalone host also do not exist.
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission