Phase 6C pass 2. Every link in the module UI chain existed except the one that closes it: a module could be authored, built, validated, published at a browser URL, and loaded by a generic loader — and nothing in Ekklesia did any of it. There was no browser application at all. Two pieces were missing. A browser could not discover what to mount, and it must not work that out for itself. GET /api/v1/module-ui is the inventory: per publishable module, its id and version, the server-produced artifact URL, the server-produced root of the module's own API, and the subset of that module's declared permissions the caller holds. Five fields, each because building a ModuleUiHostContext needs it — no filesystem paths, and no name/icon/folder/order, because navigation metadata is a design that does not exist yet. Its module set is the ADR 0025 publication registry rather than a rescan, so the inventory and the artifact route cannot disagree. It requires a new Core permission, core.modules.ui.read (baseline authenticated), because it is precisely the enumeration the anonymous artifact route deliberately withholds. Both URLs are server-produced. moduleUiArtifactUrl already existed; moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL shape has exactly one author. Without it the shell would hardcode /api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling, reappearing in the host instead. surfaces/shell (@ekklesia/shell) is the repository's first surface: a small React application built to static files by its own esbuild script. It resolves the principal, fetches the inventory, builds the host context entirely from server-derived data, and hands the artifact URL to @ekklesia/module-ui-host. One frame per selection — keyed by module identity — defeats the stale-load race; the host owns the container, the loading state, and every failure, so a broken module costs its own panel rather than the shell. The shell statically imports no module, and both levels are checked: no source import may name a module, and the emitted bundle must contain exactly one dynamic import (the loader's) with no module id, no /api/v1/modules, no eval, and no blob URL. Permission hints come from Core's own evaluator and stay presentation-only — the end-to-end test has a UI holding no grant call its API anyway and still receive 403. Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts is deliberately excluded, since its barrel pulls Drizzle's types and Node's globals into the browser — the hole ADR 0024 closed one layer down. Not the Dashboard: no navigation, grouping, icons, ordering, design system, or routing. The selector is a list of module ids. Co-Authored-By: Claude Opus 5 <[email protected]>
733 lines
29 KiB
TypeScript
733 lines
29 KiB
TypeScript
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. The collection root itself is deliberately absent
|
|
// from this list: it is the authenticated inventory (ADR 0026) rather than a 404, and is
|
|
// asserted separately below.
|
|
base,
|
|
];
|
|
|
|
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("serves no artifact bytes from the collection root, which is the authenticated inventory", async () => {
|
|
const { app } = await installWithNeighbours();
|
|
|
|
// `/api/v1/module-ui` lists what a host may mount (ADR 0026) and requires
|
|
// `core.modules.ui.read`; this installation's caller is anonymous, so it is refused. Either way
|
|
// it never yields a byte of any artifact or neighbouring file.
|
|
const response = await app.request(MODULE_UI_ASSET_PREFIX);
|
|
const body = await response.text();
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(body).not.toContain("mount");
|
|
expect(body).not.toContain("secret");
|
|
expect(body).not.toContain("internal");
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|