feat: publish validated module UI artifacts at a platform-owned URL
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 41s

Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI
build pipeline and the generic host loader: Core resolves entrypoints.ui to an
absolute server filesystem path, and a browser needs a URL. This adds that
publication layer.

Core owns the policy. createModuleUiPublicationRegistry builds, from the
discovered and started module inventory, the answer to "may this be published,
and which exact file is it?" — publishable means the module survived discovery
and declares entrypoints.ui, with no new lifecycle state and no installer.

apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both
segments are lookup keys, not path components. An exact match yields the
canonical artifact path discovery already validated; no request value is ever
composed into a filesystem path, and the route declares no wildcard segment, so
traversal, encoded separators, sibling chunks, package-root files, and source
maps are unreachable by construction. An installed module directory never
becomes a static file root.

The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module
route segment, so a module registering GET /ui/:version would otherwise compete
with the platform for one URL with routing order deciding the winner.

The route requires no authentication, deliberately. Fetching the bundle confers
no authority, and a cookie-gated route would break cross-origin import(), which
sends none. The cost is stated rather than hidden: a caller who knows a module
id and its exact version can download that bundle. The three failure modes
return one identical 404 so the route is not an enumeration oracle.

Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match
handling, not immutable: without an installer nothing stops a version being
rebuilt with different bytes. The server reads bytes and writes bytes — never
imports, evaluates, or transforms the artifact.

Nothing mounts a module UI. A host could now obtain a URL, which was previously
impossible; there is still no Dashboard shell, navigation, or metadata endpoint.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 08:11:53 -05:00
co-authored by Claude Opus 5
parent 82cbeef437
commit a02deb9433
20 changed files with 1898 additions and 30 deletions
+1
View File
@@ -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",
+14
View File
@@ -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,
+219
View File
@@ -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/<module-id>`, 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/<module-id>/ui/<version>`. 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;
}
+6
View File
@@ -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,
});
+146
View File
@@ -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<void> {
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");
+32
View File
@@ -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(),
@@ -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],
});
+716
View File
@@ -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<string> {
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<Record<string, string>>;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
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<Installation> {
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<readonly [string, number]> = [
[`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<void> {
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<Installation> {
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<string> {
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<typeof loadModuleUi>[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" });
});
});