From a02deb94336ac9814bfcf672b4acb225892a4d02 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 22 Aug 2026 08:11:53 -0500 Subject: [PATCH] feat: publish validated module UI artifacts at a platform-owned URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//. 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 --- README.md | 38 +- apps/api/package.json | 1 + apps/api/src/app.ts | 14 + apps/api/src/module-ui-routes.ts | 219 ++++++ apps/api/src/server.ts | 6 + apps/api/tests/api-http.integration.ts | 146 ++++ apps/api/tests/app.test.ts | 32 + apps/api/tests/module-http-routes.test.ts | 2 + apps/api/tests/module-ui-routes.test.ts | 716 ++++++++++++++++++ core/src/modules/index.ts | 1 + core/src/modules/ui-publication.ts | 101 +++ core/tests/module-ui-publication.test.ts | 186 +++++ ...025-trusted-module-ui-asset-publication.md | 262 +++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 31 +- docs/architecture/repository-boundaries.md | 11 + docs/development/api.md | 54 ++ docs/development/security.md | 43 +- docs/module-development/README.md | 61 +- pnpm-lock.yaml | 3 + 20 files changed, 1898 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/module-ui-routes.ts create mode 100644 apps/api/tests/module-ui-routes.test.ts create mode 100644 core/src/modules/ui-publication.ts create mode 100644 core/tests/module-ui-publication.test.ts create mode 100644 docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md diff --git a/README.md b/README.md index 4c27d34..b0a4bb8 100644 --- a/README.md +++ b/README.md @@ -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// +``` + +`: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 diff --git a/apps/api/package.json b/apps/api/package.json index 603545d..97407da 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@biomejs/biome": "2.5.3", + "@ekklesia/module-ui-host": "workspace:*", "@ekklesia/typescript-config": "workspace:*", "@types/node": "24.13.3", "@types/pg": "8.20.0", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bf24cf6..4d42de8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,12 +9,14 @@ import type { PermissionRegistry, } from "@ekklesia/core/authorization"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; +import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules"; import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; +import { createModuleUiRoutes } from "./module-ui-routes.js"; export interface CreateApiAppAuthorizationOptions { readonly evaluator: AuthorizationEvaluator; @@ -38,6 +40,12 @@ export interface CreateApiAppOptions { * object built here, so a module's HTTP handlers and lifecycle hooks share exactly one * `ModuleDatabase` for the lifetime of this API runtime. */ readonly moduleServices: ModuleRuntimeServices; + /** The platform's module UI publication policy — always the one Core builds from the exact + * discovered inventory this runtime started (`createModuleUiPublicationRegistry`, + * `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes` + * is: a caller that publishes no module UI must say so explicitly with an empty registry, never + * fall through to an implicit one. See `./module-ui-routes.js`. */ + readonly moduleUi: ModuleUiPublicationRegistry; readonly runtime: EkklesiaIdentityRuntime; /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set @@ -165,6 +173,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono { }), ); + // A platform-owned route, mounted exactly like Core's own: it serves only artifacts the injected + // publication registry already approved, and it can never collide with a module's own routes, + // which all live under `/api/v1/modules/`. See `./module-ui-routes.js` for why it is a sibling of + // that prefix rather than nested inside it. + app.route("/", createModuleUiRoutes({ registry: options.moduleUi })); + mountModuleHttpRoutes(app, options.moduleRoutes, { evaluator: options.authorization.evaluator, registry: options.authorization.registry, diff --git a/apps/api/src/module-ui-routes.ts b/apps/api/src/module-ui-routes.ts new file mode 100644 index 0000000..493dfac --- /dev/null +++ b/apps/api/src/module-ui-routes.ts @@ -0,0 +1,219 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules"; +import { Hono } from "hono"; +import type { Context } from "hono"; + +/** + * The platform-owned URL space for published module UI artifacts. + * + * Deliberately **not** nested under `/api/v1/modules/`, and that is a security decision + * rather than a stylistic one. A module may declare any route path matching Core's own grammar + * (`moduleRoutePathSchema`, `@ekklesia/contracts`), and `ui` is a perfectly legal lowercase static + * segment — so a module registering `GET /ui/:version` would be mounted at exactly + * `/api/v1/modules//ui/`. Publishing there would put the platform and installed + * modules in the same URL namespace, with Hono's registration order silently deciding which one + * answers. A sibling prefix can never collide: every module route begins `/api/v1/modules/`, and + * nothing the platform serves here does. + * + * It stays under `/api/v1/*` so it inherits the application's one CORS policy (exact reflected + * trusted origins, never a wildcard) — which a cross-origin browser `import()` of this URL requires + * — instead of introducing a second, separately-configured public URL space. + */ +export const MODULE_UI_ASSET_PREFIX = "/api/v1/module-ui"; + +/** + * Revalidate-always, with a strong `ETag` doing the actual work. + * + * The URL carries module id and exact installed version, which is *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 — the ordinary + * inner-loop of developing a module. `immutable` (or any non-zero `max-age`) would therefore be a + * promise the platform cannot keep, and a browser that cached a superseded artifact under a reused + * version would have no way to recover. + * + * `no-cache` is not `no-store`: the response is still cached, it just has to be revalidated. With + * the `ETag` below, an unchanged artifact costs one conditional request and a `304`, and a changed + * one is picked up immediately. A version bump changes the URL and never collides at all. When a + * real installer can guarantee that an installed version's bytes never change, this is the single + * constant that would move to immutable caching. + */ +export const MODULE_UI_CACHE_CONTROL = "no-cache"; + +/** Modern ESM is served as `text/javascript`, the one JavaScript type the WHATWG MIME sniffing + * standard designates as canonical; `application/javascript` is a legacy alias. A browser refuses + * to evaluate an ES module served as anything else, so this is a correctness requirement, not a + * courtesy. */ +export const MODULE_UI_CONTENT_TYPE = "text/javascript; charset=utf-8"; + +export interface ModuleUiIdentity { + readonly moduleId: string; + readonly version: string; +} + +/** + * The one canonical way to turn an installed module's identity into the browser URL its UI artifact + * is published at. Every producer of that URL — this package's own route registration, a future + * module-metadata endpoint, and a future Dashboard host — goes through here, so the URL shape is + * defined once instead of being re-spelled as a template string in each of them. + * + * The result is a **root-relative browser URL, never a filesystem path** — the distinction ADR 0024 + * built the loader around. A host on the API's own origin passes it to `loadModuleUi` unchanged; a + * host on a different origin resolves it against the same API base URL it already needs for + * `ModuleUiApiClient` (`new URL(moduleUiArtifactUrl(identity), apiBaseUrl)`). It is deliberately not + * absolute: the API's public origin is deployment configuration, and baking one in here would break + * both split-origin deployments and reverse proxies. + * + * This function accepts **identity only**. There is no parameter for a filename, a relative path, + * or any other artifact selector, so no caller — however careless — can use it to reach a second + * file in a module package. Both components are percent-encoded, so even a value that never came + * from a validated manifest can only ever produce one extra path segment's worth of text, never a + * new segment, a traversal, a query, or a fragment. A value that needed encoding will simply fail + * to resolve against the publication registry. + */ +export function moduleUiArtifactUrl(identity: ModuleUiIdentity): string { + return `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(identity.moduleId)}/${encodeURIComponent( + identity.version, + )}`; +} + +/** + * One 404 for every "there is nothing to publish here" case: the module is not installed, the + * installed module declares no `entrypoints.ui`, or the requested version is not the installed one. + * + * They are deliberately indistinguishable. The route requires no authentication (see + * `createModuleUiRoutes`), so a distinguishable response would let an anonymous caller enumerate + * which modules an installation runs and at which versions. Collapsing them means a caller must + * already know both the exact module id and the exact version to learn anything at all. + */ +function notPublished(c: Context): Response { + c.header("Cache-Control", "no-store"); + return c.json({ code: "MODULE_UI_NOT_PUBLISHED" }, 404); +} + +/** + * The artifact was validated at discovery and is not retrievable now — deleted, replaced by a + * directory, or unreadable. That is a server-side condition rather than a bad request, and often a + * transient one (an operator mid-upgrade), so it reports like every other "could not be determined" + * result in this application: a sanitized 503 with a fixed code. Never carries the artifact path, + * the module directory, the `errno`, or the caught error. + */ +function artifactUnavailable(c: Context): Response { + c.header("Cache-Control", "no-store"); + return c.json({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }, 503); +} + +/** + * RFC 9110 §13.1.2 evaluation of `If-None-Match`: `*` matches any existing representation, and + * every listed entity-tag is compared with the **weak** comparison function, so a client echoing + * `W/"…"` still matches the strong tag this route issued. Anything unparsable simply fails to + * match, which costs a full 200 and can never produce a wrongly-fresh 304. + */ +function isNotModified(headerValue: string | undefined, etag: string): boolean { + if (headerValue === undefined) { + return false; + } + if (headerValue.trim() === "*") { + return true; + } + return headerValue.split(",").some((candidate) => { + const value = candidate.trim(); + return (value.startsWith("W/") ? value.slice(2) : value) === etag; + }); +} + +export interface CreateModuleUiRoutesOptions { + /** The platform's publication policy, built by Core from the discovered, started module + * inventory (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route derives + * every byte it serves from this registry and nothing else. */ + readonly registry: ModuleUiPublicationRegistry; +} + +/** + * Publishes installed modules' validated `entrypoints.ui` artifacts at a platform-owned browser + * URL, closing the one gap between Core's server-side artifact path and the generic loader's + * browser-side URL (ADR 0024, ADR 0025). + * + * ## The request never touches the filesystem + * + * `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to + * `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's + * discovery resolved and validated is read directly. No request value is ever joined, resolved, + * normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so + * traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file + * are all unreachable by construction rather than by filtering. An installed module directory never + * becomes a static file root, and only the single artifact its manifest declares is reachable. + * + * ## No authentication, deliberately + * + * 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 nothing about fetching this file + * changes a single authorization decision. + * + * Requiring a session would also break the thing it is meant 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 + * — a failure that tests green on a server and only shows up in a real browser, exactly the class of + * error ADR 0024 exists to prevent. Working around it would mean fetching the bytes and importing a + * `blob:` URL, which costs a host its `script-src` CSP. + * + * The honest cost is stated in ADR 0025: an anonymous caller who already knows a module id **and** + * its exact installed version can download that module's UI bundle. That is information disclosure, + * not authority, and it is the reason the failure responses above are indistinguishable. + * + * ## The artifact is never executed + * + * This route reads bytes and writes bytes. It does not `import()`, evaluate, transform, minify, + * re-bundle, or rewrite the artifact, and a module UI whose top level throws is served exactly like + * any other. Core's rule that discovery never executes `entrypoints.ui` (ADR 0021) is unchanged, and + * publication does not become a second, quieter way to run browser code on the server. + */ +export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono { + const routes = new Hono(); + + routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version`, async (c) => { + const published = options.registry.resolve(c.req.param("moduleId"), c.req.param("version")); + if (!published) { + return notPublished(c); + } + + // Discovery validated this exact path earlier; the file may still have changed or vanished + // since. The response is produced from whatever a single read of that one path returns right + // now — 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 + // something else. + let artifact: Buffer; + try { + artifact = await readFile(published.artifactPath); + } catch { + return artifactUnavailable(c); + } + + // Hashing the bytes actually being served — rather than trusting an mtime or size — means the + // validator can never describe a different revision than the body does. + const etag = `"${createHash("sha256").update(artifact).digest("base64url")}"`; + if (isNotModified(c.req.header("if-none-match"), etag)) { + return new Response(null, { + headers: { "Cache-Control": MODULE_UI_CACHE_CONTROL, ETag: etag }, + status: 304, + }); + } + + return new Response(artifact, { + headers: { + "Cache-Control": MODULE_UI_CACHE_CONTROL, + "Content-Type": MODULE_UI_CONTENT_TYPE, + ETag: etag, + // The declared type is the only type: a browser must not be allowed to sniff a module + // artifact into some other interpretation, and `import()` requires the JavaScript type + // anyway. + "X-Content-Type-Options": "nosniff", + }, + status: 200, + }); + }); + + return routes; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 1a1d831..6ea2b3e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -8,6 +8,7 @@ import { } from "@ekklesia/core/authorization"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { + createModuleUiPublicationRegistry, type DiscoveredModule, type StartedModuleRuntime, startDiscoveredModuleRuntime, @@ -407,6 +408,11 @@ export async function startApi( }, moduleRoutes: moduleRuntime.routes, moduleServices: services, + // Built from the exact same discovered inventory, and only once the module runtime has + // started — module startup is all-or-nothing, so every module in this list is a running + // module, and a module that failed to load, register, or start never reaches this line at + // all. Modules declaring no `entrypoints.ui` are simply absent from the registry. + moduleUi: createModuleUiPublicationRegistry(inventory.modules), runtime, trustedOrigins: config.identity.trustedOrigins, }); diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index d7a463b..b413e83 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -21,6 +21,11 @@ import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; import type { ClientAddressConfigInput } from "../src/client-address.js"; import { createApiRuntimeConfig } from "../src/config.js"; +import { + MODULE_UI_CACHE_CONTROL, + MODULE_UI_CONTENT_TYPE, + moduleUiArtifactUrl, +} from "../src/module-ui-routes.js"; import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js"; const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; @@ -400,6 +405,52 @@ async function createFixtureModule( ]); } +/** A module that ships a real, loadable UI artifact plus the neighbouring files a naive static + * mount would have exposed: a source map, a sibling bundle chunk, and a package-root secret. */ +async function createUiModule( + modulesDirectory: string, + id: string, + version: string, + artifact: string, +): Promise { + const moduleDirectory = path.join(modulesDirectory, id); + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${id}`, + type: "module", + version, + }), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, + id, + manifestVersion: 4, + name: `${id} module`, + version, + }), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };`, + ), + writeFile(path.join(moduleDirectory, "dist/ui/index.js"), artifact), + writeFile( + path.join(moduleDirectory, "dist/ui/index.js.map"), + '{"version":3,"sourcesContent":["map-secret"]}', + ), + writeFile(path.join(moduleDirectory, "dist/ui/chunk.js"), "export const chunkSecret = 1;\n"), + writeFile(path.join(moduleDirectory, ".env"), "EKKLESIA_IDENTITY_SECRET=env-secret\n"), + ]); +} + afterAll(async () => { for (const databaseName of [...trackedDatabases]) { await dropDatabase(databaseName).catch(() => undefined); @@ -2323,6 +2374,101 @@ describe.sequential("live API — installed module permission end-to-end", () => }); }); +/** + * The publication route over a real socket, against a real started API: real Node HTTP, real + * headers, real bytes. The unit suite (`module-ui-routes.test.ts`) covers the security matrix in + * depth; this proves the same route behaves identically once it is behind the actual server rather + * than `app.request`. + */ +describe.sequential("live API — published module UI artifact", () => { + const uiArtifact = `export default { + id: "signage", + version: "2.0.1", + mount(container, context) { + container.textContent = context.moduleId; + return () => { + container.textContent = ""; + }; + }, +}; +`; + + it("serves exactly the declared artifact, and nothing else in the module package", async () => { + const modulesDirectory = await createFixtureModulesDirectory(); + await createUiModule(modulesDirectory, "signage", "2.0.1", uiArtifact); + const database = await createDisposableDatabase("module_ui"); + await applyCoreMigrations(database.url); + const running = await startMigratedApi(database.url, modulesDirectory); + try { + const origin = apiOrigin(running); + const url = `${origin}${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}`; + + const response = await fetch(url); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toBe(uiArtifact); + expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE); + expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + const etag = response.headers.get("etag"); + expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u); + + // Revalidation over the wire, with the validator the server itself just issued. + const revalidated = await fetch(url, { headers: { "if-none-match": etag as string } }); + expect(revalidated.status).toBe(304); + expect(await revalidated.text()).toBe(""); + + // The exact-version rule, and the neighbouring files, over the same real transport. + const unreachable = [ + moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.0" }), + moduleUiArtifactUrl({ moduleId: "announcements", version: "2.0.1" }), + `${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/index.js.map`, + `${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/chunk.js`, + `${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/.env`, + `${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}.map`, + ]; + for (const candidate of unreachable) { + const refused = await fetch(`${origin}${candidate}`); + const refusedBody = await refused.text(); + expect(refused.status, candidate).toBe(404); + expect(refusedBody, candidate).not.toContain("secret"); + expect(refusedBody, candidate).not.toContain("mount"); + } + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("answers a trusted browser origin with CORS, and an untrusted one without", async () => { + const modulesDirectory = await createFixtureModulesDirectory(); + await createUiModule(modulesDirectory, "signage", "2.0.1", uiArtifact); + const database = await createDisposableDatabase("module_ui_cors"); + await applyCoreMigrations(database.url); + const running = await startMigratedApi(database.url, modulesDirectory); + try { + const origin = apiOrigin(running); + const url = `${origin}${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}`; + + // A cross-origin browser `import()` of this URL needs exactly this header, which is why the + // route lives inside the application's one CORS-governed `/api/v1/*` space. + const trusted = await fetch(url, { headers: { origin: dashboardOrigin } }); + expect(trusted.status).toBe(200); + expect(trusted.headers.get("access-control-allow-origin")).toBe(dashboardOrigin); + expect(trusted.headers.get("vary")?.toLowerCase()).toContain("origin"); + + const untrusted = await fetch(url, { headers: { origin: untrustedOrigin } }); + // The bytes are not confidential (ADR 0025), but the platform still never reflects an origin + // it does not trust, so a page on that origin cannot read the response. + expect(untrusted.headers.get("access-control-allow-origin")).toBeNull(); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); +}); + describe.sequential("live API — public signup is disabled by default", () => { it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => { const database = await createDisposableDatabase("signup_disabled"); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 7a5ea2a..76a064b 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -11,6 +11,10 @@ import { type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + createModuleUiPublicationRegistry, + type ModuleUiPublicationRegistry, +} from "@ekklesia/core/modules"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; @@ -43,6 +47,13 @@ function fakeModuleServices(): ModuleRuntimeServices { return Object.freeze({ database }); } +/** No module publishes UI in these wiring tests, so the publication route is mounted with an + * empty registry built by Core's own factory — never a hand-written stub, so the route under test + * always sees the real policy object production hands it. */ +function emptyModuleUiPublication(): ModuleUiPublicationRegistry { + return createModuleUiPublicationRegistry([]); +} + /** A fully stubbed authorization dependency, denying everything by default — tests that need a * specific decision or store behavior override the relevant method explicitly. The registry * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the @@ -75,6 +86,7 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -92,6 +104,7 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -110,6 +123,7 @@ describe("Ekklesia API Hono application", () => { }); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -130,6 +144,7 @@ describe("Ekklesia API Hono application", () => { }); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -152,6 +167,7 @@ describe("Ekklesia API Hono application", () => { }); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -174,6 +190,7 @@ describe("Ekklesia API Hono application", () => { }); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -193,6 +210,7 @@ describe("Ekklesia API Hono application", () => { }); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -214,6 +232,7 @@ describe("Ekklesia API Hono application", () => { }); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -232,6 +251,7 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -252,6 +272,7 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -279,6 +300,7 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -307,6 +329,7 @@ describe("Ekklesia API Hono application", () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -340,6 +363,7 @@ describe("Ekklesia API Hono application", () => { it("has deterministic 404 behavior for an unknown route", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -355,6 +379,7 @@ describe("Ekklesia API Hono application", () => { it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -372,6 +397,7 @@ describe("Ekklesia API Hono application", () => { it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -388,6 +414,7 @@ describe("Ekklesia API Hono application", () => { it("supports requests with no Origin header at all (native/server clients)", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -402,6 +429,7 @@ describe("Ekklesia API Hono application", () => { it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -423,6 +451,7 @@ describe("Ekklesia API Hono application", () => { it("never wildcards Access-Control-Allow-Origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -447,6 +476,7 @@ describe("Ekklesia API Hono application", () => { const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false }); const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), runtime: fakeRuntime(), @@ -474,6 +504,7 @@ describe("Ekklesia API Hono application", () => { }; const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize }, @@ -492,6 +523,7 @@ describe("Ekklesia API Hono application", () => { it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), + moduleUi: emptyModuleUiPublication(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index 73a5334..e158178 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -17,6 +17,7 @@ import { type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { @@ -106,6 +107,7 @@ function buildModuleApp(options: { authorization: { evaluator, registry: options.registry, store: fakeStore() }, moduleRoutes: options.routes, moduleServices: services, + moduleUi: createModuleUiPublicationRegistry([]), runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], }); diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts new file mode 100644 index 0000000..d49690b --- /dev/null +++ b/apps/api/tests/module-ui-routes.test.ts @@ -0,0 +1,716 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ANONYMOUS_PRINCIPAL, + type ModuleDatabase, + type ModuleRuntimeServices, +} from "@ekklesia/contracts"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + CORE_PERMISSION_REGISTRY, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + createModuleUiPublicationRegistry, + type DiscoveredModule, + discoverModules, +} from "@ekklesia/core/modules"; +import { loadModuleUi } from "@ekklesia/module-ui-host"; +import type { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; +import { + MODULE_UI_ASSET_PREFIX, + MODULE_UI_CACHE_CONTROL, + MODULE_UI_CONTENT_TYPE, + moduleUiArtifactUrl, +} from "../src/module-ui-routes.js"; + +const trustedOrigin = "https://dashboard.example.test"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +async function createModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-publication-")); + temporaryDirectories.push(directory); + return directory; +} + +/** A real, loadable module UI artifact: a valid `ModuleUiDefinition` default export, written as + * plain ESM. `@ekklesia/module-ui-build` already proves the esbuild pipeline emits this shape + * (`packages/module-ui-host/tests/real-artifact.test.ts`); what these tests need is a byte + * sequence whose exact publication and loader-compatibility can be asserted. */ +function artifactSource(id: string, version: string): string { + return `export default { + id: ${JSON.stringify(id)}, + version: ${JSON.stringify(version)}, + mount(container, context) { + container.textContent = context.moduleId + ":mounted"; + return () => { + container.textContent = ""; + }; + }, +}; +`; +} + +interface ModuleFixture { + readonly id: string; + readonly version?: string; + /** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only + * module, whose manifest then declares no UI entrypoint at all. */ + readonly ui?: string; + /** Extra package-relative files written alongside the artifact — a source map, a sibling bundle + * chunk, an operator secret — so "only the declared artifact is reachable" is asserted against + * files that genuinely exist on disk rather than against hypothetical ones. */ + readonly extraFiles?: Readonly>; +} + +async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { + const version = fixture.version ?? "0.1.0"; + const moduleDirectory = path.join(modulesDirectory, fixture.id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${fixture.id}`, + type: "module", + version, + }), + ); + await writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { + server: "./dist/server/index.js", + ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), + }, + id: fixture.id, + manifestVersion: 4, + name: `${fixture.id} module`, + version, + }), + ); + await writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`, + ); + if (fixture.ui !== undefined) { + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); + } + for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) { + const target = path.join(moduleDirectory, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, contents); + } +} + +interface Installation { + readonly app: Hono; + readonly modules: readonly DiscoveredModule[]; + readonly modulesDirectory: string; +} + +/** + * A real installation: fixtures on disk, Core's real `discoverModules` (manifest validation, + * compiled-artifact validation, package containment, symlink canonicalization), Core's real + * publication registry, and the real `createApiApp`. Nothing about the publication policy or the + * artifact path is hand-built, so these tests exercise the actual security boundary rather than a + * reconstruction of it. + */ +async function install(fixtures: readonly ModuleFixture[]): Promise { + const modulesDirectory = await createModulesDirectory(); + for (const fixture of fixtures) { + await createModule(modulesDirectory, fixture); + } + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + const database = Object.freeze({}) as unknown as ModuleDatabase; + const services: ModuleRuntimeServices = Object.freeze({ database }); + const runtime: EkklesiaIdentityRuntime = { + handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), + // Anonymous, and every authorization decision denied: whatever this route does, it does it + // without a session and without a single permission. + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + }; + const evaluator: AuthorizationEvaluator = { + authorize: vi.fn().mockResolvedValue({ allowed: false }), + }; + const store = { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + } as unknown as AuthorizationStore; + + const app = createApiApp({ + authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store }, + moduleRoutes: Object.freeze([]), + moduleServices: services, + moduleUi: createModuleUiPublicationRegistry(modules), + runtime, + trustedOrigins: [trustedOrigin], + }); + + return { app, modules, modulesDirectory }; +} + +describe("module UI artifact publication — happy path", () => { + it("serves the declared artifact byte-for-byte at the canonical URL", async () => { + const source = artifactSource("announcements", "1.4.2"); + const { app } = await install([{ id: "announcements", ui: source, version: "1.4.2" }]); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }), + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(source); + }); + + it("serves it as ES module JavaScript, with sniffing disabled", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + ); + + expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + }); + + it("revalidates rather than promising immutability the platform cannot enforce", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + ); + + expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL); + // A reused version can be rebuilt with different bytes, so neither of these may ever appear. + expect(response.headers.get("cache-control")).not.toContain("immutable"); + expect(response.headers.get("cache-control")).not.toContain("max-age"); + // A strong validator (no `W/` prefix), so the revalidation above is actually decidable. + expect(response.headers.get("etag")).toMatch(/^"[A-Za-z0-9_-]+"$/u); + }); + + it("varies on Origin, so a shared cache cannot reuse one origin's CORS response for another", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + { headers: { origin: trustedOrigin } }, + ); + + expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); + expect(response.headers.get("vary")?.toLowerCase()).toContain("origin"); + }); + + it("answers a matching conditional request with 304 and no body", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }); + const etag = (await app.request(url)).headers.get("etag") as string; + + const revalidated = await app.request(url, { headers: { "if-none-match": etag } }); + + expect(revalidated.status).toBe(304); + expect(await revalidated.text()).toBe(""); + expect(revalidated.headers.get("etag")).toBe(etag); + expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL); + }); + + it("applies RFC 9110 weak comparison and the wildcard, and refuses a stale validator", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }); + const etag = (await app.request(url)).headers.get("etag") as string; + + const cases: ReadonlyArray = [ + [`W/${etag}`, 304], + ["*", 304], + [`"someone-elses-tag", ${etag}`, 304], + ['"stale-tag"', 200], + ["not a valid header at all", 200], + ]; + for (const [header, expected] of cases) { + const response = await app.request(url, { headers: { "if-none-match": header } }); + expect(response.status, header).toBe(expected); + } + }); + + it("recomputes the validator from the bytes actually on disk after a same-version rebuild", async () => { + const { app, modulesDirectory } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }); + const first = await app.request(url); + const firstEtag = first.headers.get("etag"); + + // Exactly the develop-in-place case an `immutable` policy would have made unrecoverable. + const rebuilt = `${artifactSource("announcements", "0.1.0")}// rebuilt\n`; + await writeFile(path.join(modulesDirectory, "announcements/dist/ui/index.js"), rebuilt); + + const second = await app.request(url, { headers: { "if-none-match": firstEtag as string } }); + + expect(second.status).toBe(200); + expect(await second.text()).toBe(rebuilt); + expect(second.headers.get("etag")).not.toBe(firstEtag); + }); + + it("serves the artifact without executing it, even when its top level throws", async () => { + const exploding = 'throw new Error("module UI must never be evaluated on the server");\n'; + const { app } = await install([{ id: "announcements", ui: exploding }]); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(exploding); + }); + + it("requires no authentication, by deliberate decision", async () => { + // The installation's identity runtime resolves an anonymous principal and its evaluator denies + // every permission. If this route ever grows an authentication or authorization requirement, + // this test is the one that must be consciously changed. + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + ); + + expect(response.status).toBe(200); + }); + + it("still refuses a protected platform API to the same anonymous caller", async () => { + // Publication grants no authority: the very same caller that just downloaded the bundle is + // still denied every permission-protected route. + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + + expect( + (await app.request(moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }))) + .status, + ).toBe(200); + const grants = await app.request("/api/v1/grants?userId=someone"); + expect(grants.status).toBe(403); + }); +}); + +describe("module UI artifact publication — module identity", () => { + async function expectNotPublished(app: Hono, url: string): Promise { + const response = await app.request(url); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" }); + } + + it("refuses a module that is not installed", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + + await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" })); + }); + + it("refuses an installed module that declares no UI entrypoint", async () => { + const { app } = await install([{ id: "calendar" }]); + + await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" })); + }); + + it("refuses a version other than the installed one rather than serving what exists", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" }, + ]); + + for (const version of ["1.4.1", "1.4.3", "1.4", "latest", "^1.4.2"]) { + await expectNotPublished(app, moduleUiArtifactUrl({ moduleId: "announcements", version })); + } + expect( + (await app.request(moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }))) + .status, + ).toBe(200); + }); + + it("makes unknown module, no UI, and wrong version indistinguishable", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" }, + { id: "calendar" }, + ]); + + const responses = await Promise.all( + [ + moduleUiArtifactUrl({ moduleId: "signage", version: "1.4.2" }), + moduleUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }), + moduleUiArtifactUrl({ moduleId: "announcements", version: "9.9.9" }), + ].map(async (url) => { + const response = await app.request(url); + return { body: await response.text(), status: response.status }; + }), + ); + + // An anonymous caller must not be able to use the distinction to enumerate an installation. + expect(new Set(responses.map((entry) => `${entry.status}:${entry.body}`)).size).toBe(1); + }); + + it("never serves one module's artifact under another module's identity", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + { id: "signage", ui: artifactSource("signage", "0.1.0") }, + ]); + + const announcements = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + ); + const signage = await app.request( + moduleUiArtifactUrl({ moduleId: "signage", version: "0.1.0" }), + ); + + expect(await announcements.text()).toContain('"announcements"'); + expect(await signage.text()).toContain('"signage"'); + }); +}); + +describe("module UI artifact publication — filesystem exposure", () => { + const sourceMap = + '{"version":3,"sources":["../../src/ui/index.tsx"],"sourcesContent":["secret"]}'; + + /** One installation carrying every kind of file a wildcard route would have leaked. */ + async function installWithNeighbours(): Promise { + return install([ + { + extraFiles: { + ".env": "EKKLESIA_IDENTITY_SECRET=super-secret\n", + "dist/server/index.js.map": sourceMap, + "dist/ui/index.js.map": sourceMap, + "dist/ui/internal-chunk.js": "export const internal = 1;\n", + }, + id: "announcements", + ui: artifactSource("announcements", "0.1.0"), + }, + ]); + } + + it("exposes no path shape beyond the two identity segments", async () => { + const { app } = await installWithNeighbours(); + const base = `${MODULE_UI_ASSET_PREFIX}/announcements`; + + const unreachable = [ + // Source maps: real files, sitting right next to the published artifact. + `${base}/0.1.0.map`, + `${base}/0.1.0/index.js.map`, + `${base}/0.1.0/dist/ui/index.js.map`, + // Sibling bundle output and package-root files. + `${base}/0.1.0/internal-chunk.js`, + `${base}/0.1.0/package.json`, + `${base}/0.1.0/ekklesia.module.json`, + `${base}/0.1.0/.env`, + // A trailing segment of any kind, including an empty one. + `${base}/0.1.0/`, + `${base}/0.1.0/anything`, + // Fewer segments than the route declares. + base, + MODULE_UI_ASSET_PREFIX, + ]; + + for (const url of unreachable) { + const response = await app.request(url); + expect(response.status, url).toBe(404); + // Nothing leaks: not the artifact, not a neighbour, not a path. + const body = await response.text(); + expect(body, url).not.toContain("secret"); + expect(body, url).not.toContain("internal"); + expect(body, url).not.toContain("mount"); + } + }); + + it("treats a traversal attempt as an ordinary failed lookup", async () => { + const { app } = await installWithNeighbours(); + + const attempts = [ + `${MODULE_UI_ASSET_PREFIX}/..%2F..%2F..%2Fetc%2Fpasswd/0.1.0`, + `${MODULE_UI_ASSET_PREFIX}/announcements/..%2F..%2Fpackage.json`, + `${MODULE_UI_ASSET_PREFIX}/announcements%2Fdist%2Fui%2Findex.js/0.1.0`, + `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent("../announcements")}/0.1.0`, + `${MODULE_UI_ASSET_PREFIX}/announcements/${encodeURIComponent("0.1.0/../../.env")}`, + `${MODULE_UI_ASSET_PREFIX}/announcements/${encodeURIComponent("0.1.0 .map")}`, + ]; + + for (const url of attempts) { + const response = await app.request(url); + expect(response.status, url).toBe(404); + expect(await response.text(), url).not.toContain("secret"); + } + }); + + it("keeps an absolute filesystem path from ever becoming a request parameter that works", async () => { + const { app, modules } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const artifactPath = (modules[0] as DiscoveredModule).entrypoints.ui as string; + + const response = await app.request( + `${MODULE_UI_ASSET_PREFIX}/${encodeURIComponent(artifactPath)}/0.1.0`, + ); + + expect(response.status).toBe(404); + }); +}); + +describe("module UI artifact publication — request surface", () => { + it("is read-only: no other method is routed at all", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }); + + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + const response = await app.request(url, { method }); + expect(response.status, method).toBe(404); + expect(await response.json(), method).toEqual({ code: "NOT_FOUND" }); + } + }); + + it("answers HEAD with the full header contract and no body", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }); + + const response = await app.request(url, { method: "HEAD" }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE); + expect(response.headers.get("etag")).toBe((await app.request(url)).headers.get("etag")); + expect(await response.text()).toBe(""); + }); + + it("fails closed on malformed percent-encoding instead of throwing", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + + for (const url of [ + `${MODULE_UI_ASSET_PREFIX}/%zz/0.1.0`, + `${MODULE_UI_ASSET_PREFIX}/announcements/%E0%A4%A`, + `${MODULE_UI_ASSET_PREFIX}/a%00b/0.1.0`, + ]) { + const response = await app.request(url); + // An ordinary refused lookup, never a 500 that would reveal a decoding failure. + expect(response.status, url).toBe(404); + expect(await response.json(), url).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" }); + } + }); +}); + +describe("module UI artifact publication — artifact changed after discovery", () => { + it("reports a sanitized 503 when the validated artifact has been deleted", async () => { + const { app, modulesDirectory } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }); + expect((await app.request(url)).status).toBe(200); + + await rm(path.join(modulesDirectory, "announcements/dist/ui/index.js")); + + const response = await app.request(url); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" }); + }); + + it("leaks no filesystem detail when the artifact is unreadable", async () => { + const { app, modulesDirectory } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const artifact = path.join(modulesDirectory, "announcements/dist/ui/index.js"); + await rm(artifact); + // Replaced by a directory: a different `errno` (EISDIR) on the same validated path. + await mkdir(artifact); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + ); + const body = await response.text(); + + expect(response.status).toBe(503); + expect(body).not.toContain(modulesDirectory); + expect(body).not.toContain(tmpdir()); + expect(body).not.toContain("dist"); + expect(body).not.toContain("EISDIR"); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("never falls back to a reconstructed path when the exact artifact is gone", async () => { + const { app, modulesDirectory } = await install([ + { id: "announcements", ui: artifactSource("announcements", "0.1.0") }, + ]); + const moduleDirectory = path.join(modulesDirectory, "announcements"); + await rm(path.join(moduleDirectory, "dist/ui/index.js")); + // Plausible neighbours a path-guessing implementation might land on instead. + await writeFile(path.join(moduleDirectory, "dist/ui/index.mjs"), "export default {};\n"); + await writeFile(path.join(moduleDirectory, "dist/index.js"), "export default {};\n"); + + const response = await app.request( + moduleUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }), + ); + + expect(response.status).toBe(503); + }); +}); + +describe("module UI artifact URL generation", () => { + it("agrees with the route it is generated for", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" }, + ]); + + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }); + + expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`); + expect((await app.request(url)).status).toBe(200); + }); + + it("is root-relative, so a host resolves it against whichever API origin it is configured for", () => { + const url = moduleUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }); + + expect(url.startsWith("/")).toBe(true); + expect(new URL(url, "https://ekklesia.example.test").href).toBe( + `https://ekklesia.example.test${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`, + ); + // A split-origin host resolves the identical value against its own API base. + expect(new URL(url, "http://127.0.0.1:3000").href).toBe( + `http://127.0.0.1:3000${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2`, + ); + }); + + it("encodes hostile input instead of letting it restructure the URL", () => { + const hostile = [ + "../../etc/passwd", + "announcements/dist/ui/index.js", + "announcements?x=1", + "announcements#fragment", + "announcements ", + "a b", + ]; + + for (const value of hostile) { + // Still exactly the prefix's own segments plus one for the module and one for the version. + const expectedSegments = MODULE_UI_ASSET_PREFIX.split("/").length + 2; + const moduleUrl = moduleUiArtifactUrl({ moduleId: value, version: "0.1.0" }); + expect(moduleUrl.split("/"), value).toHaveLength(expectedSegments); + expect(moduleUrl.includes("?"), value).toBe(false); + expect(moduleUrl.includes("#"), value).toBe(false); + + const versionUrl = moduleUiArtifactUrl({ moduleId: "announcements", version: value }); + expect(versionUrl.split("/"), value).toHaveLength(expectedSegments); + expect(versionUrl.includes("?"), value).toBe(false); + expect(versionUrl.includes("#"), value).toBe(false); + } + }); +}); + +describe("module UI artifact publication — the generic loader consumes what is published", () => { + /** + * Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and + * honestly: the tests above prove the route publishes the exact artifact bytes with ES module + * JavaScript semantics over real HTTP routing, and this one takes **those very bytes, as returned + * by the route**, and puts them through the real `@ekklesia/module-ui-host` loader as a `data:` + * URL — a genuine module URL, the same technique ADR 0024's own loader tests use. Nothing here + * reads the artifact from the filesystem. + */ + async function publishedModuleUrl(app: Hono, moduleId: string, version: string): Promise { + const response = await app.request(moduleUiArtifactUrl({ moduleId, version })); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE); + const bytes = Buffer.from(await response.arrayBuffer()); + return `data:text/javascript;base64,${bytes.toString("base64")}`; + } + + /** + * The loader forwards the container untouched to the module's own `mount` and never inspects it, + * so a plain sink is enough and no DOM implementation is required. The container's type is read + * back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles + * against Node libs only — naming a DOM type here would be the same browser/server confusion this + * route exists to keep apart. + */ + type LoaderContainer = Parameters[0]["container"]; + + function containerSink(): { textContent: string } { + return { textContent: "" }; + } + + function hostContext() { + return { + api: { request: async () => new Response(null, { status: 200 }) }, + moduleId: "announcements", + permissions: { has: () => false }, + principal: { kind: "anonymous" as const }, + }; + } + + it("mounts and unmounts an artifact obtained from the publication route", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" }, + ]); + const url = await publishedModuleUrl(app, "announcements", "1.4.2"); + const container = containerSink(); + + const mounted = await loadModuleUi({ + container: container as unknown as LoaderContainer, + context: hostContext(), + source: { moduleId: "announcements", url, version: "1.4.2" }, + }); + + expect(container.textContent).toBe("announcements:mounted"); + await mounted.unmount(); + expect(container.textContent).toBe(""); + }); + + it("still rejects the published artifact when the host expects a different identity", async () => { + const { app } = await install([ + { id: "announcements", ui: artifactSource("announcements", "1.4.2"), version: "1.4.2" }, + ]); + const url = await publishedModuleUrl(app, "announcements", "1.4.2"); + + await expect( + loadModuleUi({ + container: containerSink() as unknown as LoaderContainer, + context: hostContext(), + source: { moduleId: "announcements", url, version: "9.9.9" }, + }), + ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); + }); +}); diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index e2cb6ef..7a010e7 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -4,3 +4,4 @@ export * from "./migration-discovery.js"; export * from "./permissions.js"; export * from "./runtime.js"; export * from "./schema-discovery.js"; +export * from "./ui-publication.js"; diff --git a/core/src/modules/ui-publication.ts b/core/src/modules/ui-publication.ts new file mode 100644 index 0000000..eebea6a --- /dev/null +++ b/core/src/modules/ui-publication.ts @@ -0,0 +1,101 @@ +import type { DiscoveredModule } from "./discovery.js"; + +/** + * One installed module whose UI artifact the platform is willing to publish at a browser URL. + * + * `artifactPath` is the canonical absolute path discovery already resolved and validated for + * `entrypoints.ui` — realpath-resolved, proven to be a compiled `.js`/`.mjs` file, and proven to + * stay inside the module package both before and after symlink resolution + * (`resolveEntrypoints`, `./discovery.js`). A publisher serves **exactly this path** and never + * composes a path of its own from request input; that is the whole reason this record exists + * rather than a module id plus a path-building convention. + */ +export interface PublishableModuleUi { + readonly moduleId: string; + readonly version: string; + readonly artifactPath: string; +} + +/** + * The platform's answer to "may this module's UI artifact be served, and if so, which exact file?" + * + * Lookup is by module **identity**, never by path. There is deliberately no method that accepts a + * filename, a relative path, a glob, or anything else that could turn an installed module + * directory into a static file root. + */ +export interface ModuleUiPublicationRegistry { + /** Every publishable module, in the discovery order it was built from. Stable and frozen. */ + list(): readonly PublishableModuleUi[]; + /** + * The publishable record for an exact module id **and** exact installed version, or `undefined`. + * + * Takes `unknown` for the same reason `PermissionRegistry.get` does: the caller is usually + * handing over decoded request input, and a lookup that fails closed on anything malformed is + * safer than one whose type signature quietly assumes validation already happened. A module that + * is not installed, one that declares no `entrypoints.ui`, and one installed at a different + * version are all indistinguishable here — each returns `undefined`. + */ + resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined; +} + +/** + * Builds the platform's module UI publication policy from an already-discovered module list. + * + * The policy is deliberately small, because the repository's module state is deliberately small: + * Ekklesia has one accepted module set — the modules `discoverModules` validated at startup — and + * no installer, no enable/disable state, and no trust store. `ModuleLifecycleStage` + * (`@ekklesia/contracts`) names stages as a vocabulary but no runtime state machine implements + * them. So "publishable" means exactly: + * + * 1. the module is in the discovered, fully validated set the platform accepted at startup, and + * 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file + * inside the module package. + * + * A module directory sitting on disk satisfies neither on its own — it has to have survived + * discovery — which is the property `docs/development/security.md` requires: *the existence of an + * artifact on disk must not be what makes it servable*. + * + * Pure and synchronous: no filesystem, environment, database, or network access, and it never + * imports, evaluates, or otherwise executes a module's UI artifact. The caller supplies a complete + * discovered list; production composition (`apps/api/src/server.ts`) builds this only after the + * module runtime has started, so the published set is also the running set — module startup is + * all-or-nothing (`startDiscoveredModuleRuntime`, `./runtime.js`), so a module that failed to load, + * register, or start takes the whole API startup down with it rather than lingering as a + * publishable-but-not-running module. + */ +export function createModuleUiPublicationRegistry( + modules: readonly DiscoveredModule[], +): ModuleUiPublicationRegistry { + const byModuleId = new Map(); + + for (const module of modules) { + const artifactPath = module.entrypoints.ui; + if (artifactPath === undefined) { + continue; + } + byModuleId.set( + module.manifest.id, + Object.freeze({ + artifactPath, + moduleId: module.manifest.id, + version: module.manifest.version, + }), + ); + } + + const published = Object.freeze([...byModuleId.values()]); + + return Object.freeze({ + list: (): readonly PublishableModuleUi[] => published, + resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => { + if (typeof moduleId !== "string" || typeof version !== "string") { + return undefined; + } + const candidate = byModuleId.get(moduleId); + // Exact version equality, never a range, a "latest" fallback, or a nearest match. A request + // naming a version the platform does not have installed resolves to nothing at all rather + // than to a different artifact that happens to exist. + return candidate?.version === version ? candidate : undefined; + }, + }); +} diff --git a/core/tests/module-ui-publication.test.ts b/core/tests/module-ui-publication.test.ts new file mode 100644 index 0000000..d85e65c --- /dev/null +++ b/core/tests/module-ui-publication.test.ts @@ -0,0 +1,186 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createModuleUiPublicationRegistry, + type DiscoveredModule, + discoverModules, +} from "../src/modules/index.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function createModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-publication-")); + temporaryDirectories.push(directory); + return directory; +} + +interface ModuleFixture { + readonly id: string; + readonly version?: string; + /** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */ + readonly ui?: string; +} + +async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { + const version = fixture.version ?? "0.1.0"; + const moduleDirectory = path.join(modulesDirectory, fixture.id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${fixture.id}`, + type: "module", + version, + }), + ); + await writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { + server: "./dist/server/index.js", + ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), + }, + id: fixture.id, + manifestVersion: 4, + name: `${fixture.id} module`, + version, + }), + ); + await writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`, + ); + if (fixture.ui !== undefined) { + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); + } +} + +/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that + * it consumes only records that already passed manifest validation, compiled-artifact validation, + * and package-containment/symlink resolution. */ +async function discover(fixtures: readonly ModuleFixture[]): Promise { + const modulesDirectory = await createModulesDirectory(); + for (const fixture of fixtures) { + await createModule(modulesDirectory, fixture); + } + return discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); +} + +describe("module UI publication registry", () => { + it("publishes a discovered module that declares entrypoints.ui", async () => { + const modules = await discover([{ id: "announcements", ui: "export default {};" }]); + const registry = createModuleUiPublicationRegistry(modules); + + const published = registry.resolve("announcements", "0.1.0"); + + expect(published).toMatchObject({ moduleId: "announcements", version: "0.1.0" }); + expect(registry.list()).toHaveLength(1); + }); + + it("exposes exactly the canonical artifact path discovery already validated", async () => { + const modules = await discover([{ id: "announcements", ui: "export default {};" }]); + const discovered = modules[0] as DiscoveredModule; + const registry = createModuleUiPublicationRegistry(modules); + + // Identical to `entrypoints.ui` — not recomposed from the module directory plus a convention, + // which is the failure mode a publisher must never reintroduce. + expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBe( + discovered.entrypoints.ui, + ); + expect(path.isAbsolute(discovered.entrypoints.ui as string)).toBe(true); + }); + + it("omits a discovered module that declares no UI entrypoint", async () => { + const modules = await discover([{ id: "calendar" }]); + const registry = createModuleUiPublicationRegistry(modules); + + expect(registry.list()).toEqual([]); + expect(registry.resolve("calendar", "0.1.0")).toBeUndefined(); + }); + + it("publishes only the modules that declare UI, from a mixed installation", async () => { + const modules = await discover([ + { id: "announcements", ui: "export default {};" }, + { id: "calendar" }, + { id: "signage", ui: "export default {};" }, + ]); + const registry = createModuleUiPublicationRegistry(modules); + + expect(registry.list().map((entry) => entry.moduleId)).toEqual(["announcements", "signage"]); + }); + + it("never resolves a module that is not part of the discovered set", async () => { + const modules = await discover([{ id: "announcements", ui: "export default {};" }]); + const registry = createModuleUiPublicationRegistry(modules); + + expect(registry.resolve("calendar", "0.1.0")).toBeUndefined(); + expect(registry.resolve("", "0.1.0")).toBeUndefined(); + }); + + it("refuses a version other than the exact installed one", async () => { + const modules = await discover([ + { id: "announcements", ui: "export default {};", version: "1.4.2" }, + ]); + const registry = createModuleUiPublicationRegistry(modules); + + expect(registry.resolve("announcements", "1.4.2")).toBeDefined(); + for (const version of ["1.4.1", "1.4.3", "1.4", "^1.4.2", "latest", "1.4.2 ", ""]) { + expect(registry.resolve("announcements", version)).toBeUndefined(); + } + }); + + it("fails closed on non-string lookup input rather than trusting its caller", async () => { + const modules = await discover([{ id: "announcements", ui: "export default {};" }]); + const registry = createModuleUiPublicationRegistry(modules); + + for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) { + expect(registry.resolve(value, "0.1.0")).toBeUndefined(); + expect(registry.resolve("announcements", value)).toBeUndefined(); + } + }); + + it("is an empty, usable registry when nothing is installed", () => { + const registry = createModuleUiPublicationRegistry([]); + + expect(registry.list()).toEqual([]); + expect(registry.resolve("announcements", "0.1.0")).toBeUndefined(); + }); + + it("publishes an immutable snapshot that no caller can extend", async () => { + const modules = await discover([{ id: "announcements", ui: "export default {};" }]); + const registry = createModuleUiPublicationRegistry(modules); + + expect(Object.isFrozen(registry)).toBe(true); + expect(Object.isFrozen(registry.list())).toBe(true); + expect(Object.isFrozen(registry.list()[0])).toBe(true); + // The same array identity every time: nothing recomputes or re-reads the filesystem per call. + expect(registry.list()).toBe(registry.list()); + }); + + it("never reads or evaluates the artifact it publishes", async () => { + // A top-level throw would surface immediately if anything imported this file. Registry + // construction and resolution are pure metadata operations. + const modules = await discover([ + { id: "announcements", ui: 'throw new Error("module UI must never run on the server");' }, + ]); + + const registry = createModuleUiPublicationRegistry(modules); + + expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined(); + }); +}); diff --git a/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md b/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md new file mode 100644 index 0000000..ffc69b2 --- /dev/null +++ b/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md @@ -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// +``` + +Nesting it under `/api/v1/modules//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//ui/`. 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 `.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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 37ce4db..ddc5eb4 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 137bbeb..4deac85 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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//`, 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. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 8b51acd..6802d31 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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 diff --git a/docs/development/api.md b/docs/development/api.md index 83e33a7..35025ec 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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//*` | 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//` | 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/`, 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// +``` + +- **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 diff --git a/docs/development/security.md b/docs/development/security.md index 679fc3f..f4455d8 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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//` 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 diff --git a/docs/module-development/README.md b/docs/module-development/README.md index d7367e2..7303c33 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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//` +> ([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// 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// +``` + +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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa2ecde..fdae7c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: '@biomejs/biome': specifier: 2.5.3 version: 2.5.3 + '@ekklesia/module-ui-host': + specifier: workspace:* + version: link:../../packages/module-ui-host '@ekklesia/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config