The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.
Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.
Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.
A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.
The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.
The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.
The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.
Co-Authored-By: Claude Opus 5 <[email protected]>
1095 lines
44 KiB
TypeScript
1095 lines
44 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, AuthorizationStore } from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
discoverModules,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
} from "@ekklesia/core/modules";
|
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
|
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
|
import { Window } from "happy-dom";
|
|
import type { Hono } from "hono";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
import { createApiApp } from "../src/app.js";
|
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
|
import {
|
|
MODULE_UI_ASSET_PREFIX,
|
|
MODULE_UI_CACHE_CONTROL,
|
|
MODULE_UI_CONTENT_TYPE,
|
|
moduleContributionArtifactUrl,
|
|
moduleDisplayUiArtifactUrl,
|
|
moduleUiArtifactUrl,
|
|
} from "../src/module-ui-routes.js";
|
|
import {
|
|
createInMemoryModuleLifecycleStore,
|
|
moduleLifecycleSnapshotFor,
|
|
} from "./support/in-memory-module-lifecycle.js";
|
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.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 {
|
|
abi: ${MODULE_UI_ABI_VERSION},
|
|
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;
|
|
/** Written to a distinct artifact and declared as the protected display entrypoint. */
|
|
readonly display?: 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>>;
|
|
/** Capabilities this module provides — what another module's contributions target. */
|
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
|
/** Browser renderers this module contributes to another module's capability. */
|
|
readonly contributions?: readonly {
|
|
readonly localName: string;
|
|
readonly capability: string;
|
|
readonly capabilityRange: string;
|
|
readonly source: string;
|
|
}[];
|
|
}
|
|
|
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
|
const version = fixture.version ?? "0.1.0";
|
|
const displayPermission = `${fixture.id}.display.view`;
|
|
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" }),
|
|
...(fixture.display === undefined
|
|
? {}
|
|
: {
|
|
display: {
|
|
artifact: "./dist/display/index.js",
|
|
permission: displayPermission,
|
|
},
|
|
}),
|
|
},
|
|
capabilities: fixture.capabilities ?? [],
|
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
|
capability: contribution.capability,
|
|
capabilityRange: contribution.capabilityRange,
|
|
id: `${fixture.id}.${contribution.localName}`,
|
|
version: "1.0.0",
|
|
})),
|
|
id: fixture.id,
|
|
manifestVersion: 7,
|
|
name: `${fixture.id} module`,
|
|
permissions:
|
|
fixture.display === undefined
|
|
? []
|
|
: [
|
|
{
|
|
baseline: "restricted",
|
|
description: `View ${fixture.id}'s display`,
|
|
id: displayPermission,
|
|
},
|
|
],
|
|
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 contribution of fixture.contributions ?? []) {
|
|
const contributionDirectory = path.join(
|
|
moduleDirectory,
|
|
"dist/contributions",
|
|
contribution.localName,
|
|
);
|
|
await mkdir(contributionDirectory, { recursive: true });
|
|
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
|
|
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
|
|
}
|
|
if (fixture.display !== undefined) {
|
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
|
}
|
|
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[],
|
|
publicationModuleIds?: readonly string[],
|
|
): 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 publicationModules =
|
|
publicationModuleIds === undefined
|
|
? modules
|
|
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
|
|
|
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: composePlatformPermissionRegistry(modules), store },
|
|
...(publicationModules.some((module) => module.entrypoints.display !== undefined)
|
|
? {
|
|
displayHostAssets: createDisplayHostAssets(
|
|
'<!doctype html><div id="display-root"></div>',
|
|
"export {};\n",
|
|
),
|
|
}
|
|
: {}),
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
|
moduleServices: services,
|
|
moduleUi: createModuleUiPublicationRegistry(publicationModules),
|
|
moduleLifecycle: {
|
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
|
store: createInMemoryModuleLifecycleStore(),
|
|
},
|
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
|
moduleStorage: moduleStorageOptionFor(modules),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
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 display UI artifact publication", () => {
|
|
it("serves the declared display bytes anonymously at the exact role-specific URL", async () => {
|
|
const source = `${artifactSource("announcements", "1.4.2")}// display\n`;
|
|
const { app } = await install([{ display: source, id: "announcements", version: "1.4.2" }]);
|
|
const url = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
|
|
|
|
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2/display`);
|
|
const response = await app.request(url);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(source);
|
|
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") as string;
|
|
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
|
|
|
|
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("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
|
expect(revalidated.headers.get("etag")).toBe(etag);
|
|
});
|
|
|
|
it("keeps display-only, primary-only, both, and neither roles strictly separate", async () => {
|
|
const primary = `${artifactSource("signage", "0.1.0")}// primary\n`;
|
|
const display = `${artifactSource("signage", "0.1.0")}// display\n`;
|
|
const { app } = await install([
|
|
{ display: `${display}// announcements\n`, id: "announcements" },
|
|
{ id: "calendar", ui: `${primary}// calendar\n` },
|
|
{ display, id: "signage", ui: primary },
|
|
{ id: "backend" },
|
|
]);
|
|
|
|
const primaryUrl = (moduleId: string) => moduleUiArtifactUrl({ moduleId, version: "0.1.0" });
|
|
const displayUrl = (moduleId: string) =>
|
|
moduleDisplayUiArtifactUrl({ moduleId, version: "0.1.0" });
|
|
|
|
expect((await app.request(primaryUrl("announcements"))).status).toBe(404);
|
|
expect((await app.request(displayUrl("announcements"))).status).toBe(200);
|
|
expect((await app.request(primaryUrl("calendar"))).status).toBe(200);
|
|
expect((await app.request(displayUrl("calendar"))).status).toBe(404);
|
|
expect(await (await app.request(primaryUrl("signage"))).text()).toBe(primary);
|
|
expect(await (await app.request(displayUrl("signage"))).text()).toBe(display);
|
|
expect((await app.request(primaryUrl("backend"))).status).toBe(404);
|
|
expect((await app.request(displayUrl("backend"))).status).toBe(404);
|
|
});
|
|
|
|
it("refuses wrong versions with the same sanitized response as an undeclared display", async () => {
|
|
const { app } = await install([
|
|
{
|
|
display: `${artifactSource("announcements", "1.4.2")}// display\n`,
|
|
id: "announcements",
|
|
version: "1.4.2",
|
|
},
|
|
{ id: "calendar", ui: artifactSource("calendar", "0.1.0") },
|
|
]);
|
|
|
|
const responses = await Promise.all(
|
|
[
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "latest" }),
|
|
moduleDisplayUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
|
|
moduleDisplayUiArtifactUrl({ moduleId: "missing", version: "1.4.2" }),
|
|
].map(async (url) => {
|
|
const response = await app.request(url);
|
|
return { body: await response.text(), status: response.status };
|
|
}),
|
|
);
|
|
|
|
expect(responses).toEqual([
|
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
|
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
|
|
]);
|
|
});
|
|
|
|
it("never turns the display URL into a package directory or generic role selector", async () => {
|
|
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
|
const { app } = await install([
|
|
{
|
|
display,
|
|
extraFiles: {
|
|
".env": "DISPLAY_SECRET=do-not-serve\n",
|
|
"dist/display/index.js.map": "display source map secret",
|
|
"dist/display/internal-chunk.js": "export const internal = 'secret';\n",
|
|
},
|
|
id: "announcements",
|
|
},
|
|
]);
|
|
const exact = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
|
|
|
|
for (const url of [
|
|
`${exact}.map`,
|
|
`${exact}/index.js.map`,
|
|
`${exact}/internal-chunk.js`,
|
|
`${exact}/../../.env`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/dashboard`,
|
|
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/arbitrary-role`,
|
|
]) {
|
|
const response = await app.request(url);
|
|
expect(response.status, url).toBe(404);
|
|
const body = await response.text();
|
|
expect(body, url).not.toContain("secret");
|
|
expect(body, url).not.toContain("mount");
|
|
}
|
|
});
|
|
|
|
it("cannot publish a blocked module omitted from Core's eligible registry input", async () => {
|
|
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
|
|
const { app } = await install(
|
|
[
|
|
{ display, id: "announcements" },
|
|
{ display: `${display}// signage\n`, id: "signage" },
|
|
],
|
|
["signage"],
|
|
);
|
|
|
|
expect(
|
|
(
|
|
await app.request(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
)
|
|
).status,
|
|
).toBe(404);
|
|
expect(
|
|
(await app.request(moduleDisplayUiArtifactUrl({ moduleId: "signage", version: "0.1.0" })))
|
|
.status,
|
|
).toBe(200);
|
|
});
|
|
|
|
it("reports the same fixed 503 when the validated display artifact disappears", async () => {
|
|
const { app, modulesDirectory } = await install([
|
|
{
|
|
display: `${artifactSource("announcements", "0.1.0")}// display\n`,
|
|
id: "announcements",
|
|
},
|
|
]);
|
|
await rm(path.join(modulesDirectory, "announcements/dist/display/index.js"));
|
|
|
|
const response = await app.request(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
|
|
);
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
interface HostContainer {
|
|
readonly children: { readonly length: number };
|
|
querySelector(selectors: string): HostContainer | null;
|
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
|
}
|
|
|
|
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")}`;
|
|
}
|
|
|
|
/**
|
|
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
|
* root inside whatever it is given. 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"];
|
|
|
|
/**
|
|
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
|
* real isolated root inside the container, and installing a DOM environment here would replace
|
|
* the module runner's resolution for a server-side suite that dynamically imports real module
|
|
* entrypoints.
|
|
*/
|
|
function containerSink(): HostContainer {
|
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
|
}
|
|
|
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
|
function mountedText(container: HostContainer): string {
|
|
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
|
return (
|
|
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
|
);
|
|
}
|
|
|
|
function hostContext() {
|
|
return {
|
|
api: { request: async () => new Response(null, { status: 200 }) },
|
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
|
moduleId: "announcements",
|
|
permissions: { has: () => false },
|
|
principal: { kind: "anonymous" as const },
|
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
|
};
|
|
}
|
|
|
|
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(mountedText(container)).toBe("announcements:mounted");
|
|
await mounted.unmount();
|
|
expect(container.children).toHaveLength(0);
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
|
|
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
|
|
*/
|
|
describe("module contribution artifact publication", () => {
|
|
const signage = {
|
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
|
id: "signage",
|
|
};
|
|
const contributionSource = "export default { contributed: true };\n";
|
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
capabilityRange,
|
|
localName: "signage-slide",
|
|
source: contributionSource,
|
|
},
|
|
],
|
|
id: "announcements",
|
|
});
|
|
const contributionUrl = moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "0.1.0" },
|
|
"announcements.signage-slide",
|
|
);
|
|
|
|
it("serves the declared contribution artifact byte for byte", async () => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
const response = await app.request(contributionUrl);
|
|
|
|
expect(response.status).toBe(200);
|
|
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");
|
|
expect(await response.text()).toBe(contributionSource);
|
|
});
|
|
|
|
it("builds a URL that cannot select a neighbouring file", () => {
|
|
expect(contributionUrl).toBe(
|
|
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
|
);
|
|
// The contribution id is one segment's worth of text, always — a value that needed encoding
|
|
// simply fails to resolve.
|
|
expect(
|
|
moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "0.1.0" },
|
|
"../../../etc/passwd",
|
|
),
|
|
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
|
|
});
|
|
|
|
it.each([
|
|
["an unknown contribution id", "announcements.nope"],
|
|
["another module's contribution id", "signage.slide-renderer"],
|
|
["a path traversal", "../../../etc/passwd"],
|
|
["the artifact's source map", "announcements.signage-slide.map"],
|
|
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
const response = await app.request(
|
|
moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "0.1.0" },
|
|
contributionId,
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
|
});
|
|
|
|
it("refuses a version the platform does not have installed", async () => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
const response = await app.request(
|
|
moduleContributionArtifactUrl(
|
|
{ moduleId: "announcements", version: "9.9.9" },
|
|
"announcements.signage-slide",
|
|
),
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("publishes nothing when no installed module provides the target capability", async () => {
|
|
const { app } = await install([announcements()]);
|
|
|
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
|
});
|
|
|
|
it("publishes nothing when the provider is outside the declared compatible range", async () => {
|
|
const { app } = await install([signage, announcements("^2.0.0")]);
|
|
|
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
|
});
|
|
|
|
it("keeps the sibling source map and the module's own directory unreachable", async () => {
|
|
const { app } = await install([signage, announcements()]);
|
|
|
|
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
|
|
const response = await app.request(`${contributionUrl}${suffix}`);
|
|
expect(response.status).toBe(404);
|
|
}
|
|
});
|
|
});
|