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
+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");