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