feat: publish validated module UI artifacts at a platform-owned URL
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:
co-authored by
Claude Opus 5
parent
82cbeef437
commit
a02deb9433
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user