feat: publish validated module UI artifacts at a platform-owned URL
Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI build pipeline and the generic host loader: Core resolves entrypoints.ui to an absolute server filesystem path, and a browser needs a URL. This adds that publication layer. Core owns the policy. createModuleUiPublicationRegistry builds, from the discovered and started module inventory, the answer to "may this be published, and which exact file is it?" — publishable means the module survived discovery and declares entrypoints.ui, with no new lifecycle state and no installer. apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both segments are lookup keys, not path components. An exact match yields the canonical artifact path discovery already validated; no request value is ever composed into a filesystem path, and the route declares no wildcard segment, so traversal, encoded separators, sibling chunks, package-root files, and source maps are unreachable by construction. An installed module directory never becomes a static file root. The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module route segment, so a module registering GET /ui/:version would otherwise compete with the platform for one URL with routing order deciding the winner. The route requires no authentication, deliberately. Fetching the bundle confers no authority, and a cookie-gated route would break cross-origin import(), which sends none. The cost is stated rather than hidden: a caller who knows a module id and its exact version can download that bundle. The three failure modes return one identical 404 so the route is not an enumeration oracle. Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match handling, not immutable: without an installer nothing stops a version being rebuilt with different bytes. The server reads bytes and writes bytes — never imports, evaluates, or transforms the artifact. Nothing mounts a module UI. A host could now obtain a URL, which was previously impossible; there is still no Dashboard shell, navigation, or metadata endpoint. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
82cbeef437
commit
a02deb9433
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user