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]>
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
bootstrapHost,
|
|
type EkklesiaHostConfig,
|
|
type HostModuleUiDescriptor,
|
|
loadHostPrincipal,
|
|
loadModuleUiInventory,
|
|
parseHostModuleUiInventory,
|
|
parseHostPrincipal,
|
|
} from "../src/host-protocol.js";
|
|
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
|
|
|
|
const apiBaseUrl = "https://ekklesia.example.test";
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
headers: { "content-type": "application/json" },
|
|
status,
|
|
});
|
|
}
|
|
|
|
/** A fetch double that answers the two platform paths and records every call, so the tests can
|
|
* assert both the URL the shell built and the credentials it used. */
|
|
function stubFetch(routes: Readonly<Record<string, () => Response>>) {
|
|
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
|
|
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
calls.push({ init, url });
|
|
const route = Object.entries(routes).find(([path]) => url === `${apiBaseUrl}${path}`);
|
|
if (!route) {
|
|
return new Response(JSON.stringify({ code: "NOT_FOUND" }), { status: 404 });
|
|
}
|
|
return route[1]();
|
|
});
|
|
return { calls, implementation: implementation as unknown as typeof fetch };
|
|
}
|
|
|
|
const inventoryPayload = {
|
|
modules: [
|
|
{
|
|
api: { baseUrl: "/api/v1/modules/example" },
|
|
id: "example",
|
|
permissions: ["example.records.read"],
|
|
ui: { url: "/api/v1/module-ui/example/0.1.0" },
|
|
version: "0.1.0",
|
|
},
|
|
],
|
|
};
|
|
|
|
const exampleDescriptor: HostModuleUiDescriptor = {
|
|
apiBaseUrl: "/api/v1/modules/example",
|
|
id: "example",
|
|
permissions: ["example.records.read"],
|
|
uiUrl: "/api/v1/module-ui/example/0.1.0",
|
|
version: "0.1.0",
|
|
};
|
|
|
|
describe("host protocol — inventory", () => {
|
|
it("parses the server's descriptor into exactly what a host context needs", () => {
|
|
expect(parseHostModuleUiInventory(inventoryPayload)).toEqual([exampleDescriptor]);
|
|
});
|
|
|
|
it("rejects a malformed document rather than yielding a half-built descriptor", () => {
|
|
const malformed: unknown[] = [
|
|
{},
|
|
{ modules: {} },
|
|
{ modules: [{ id: "example", version: "0.1.0" }] },
|
|
{ modules: [{ ...inventoryPayload.modules[0], ui: {} }] },
|
|
{ modules: [{ ...inventoryPayload.modules[0], api: { baseUrl: "" } }] },
|
|
{ modules: [{ ...inventoryPayload.modules[0], permissions: "example.records.read" }] },
|
|
{ modules: [{ ...inventoryPayload.modules[0], permissions: [42] }] },
|
|
// Both URLs must be root-relative, so a value that would resolve against another origin is
|
|
// refused rather than silently used to fetch browser code.
|
|
{
|
|
modules: [
|
|
{ ...inventoryPayload.modules[0], ui: { url: "https://evil.example.test/x.js" } },
|
|
],
|
|
},
|
|
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "//evil.example.test/x.js" } }] },
|
|
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "relative/x.js" } }] },
|
|
{
|
|
modules: [
|
|
{ ...inventoryPayload.modules[0], api: { baseUrl: "https://evil.example.test/api" } },
|
|
],
|
|
},
|
|
];
|
|
for (const payload of malformed) {
|
|
expect(() => parseHostModuleUiInventory(payload)).toThrowError(
|
|
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("fetches the inventory from the platform path with credentials", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
|
|
});
|
|
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: implementation };
|
|
|
|
await loadModuleUiInventory(config);
|
|
|
|
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/module-ui`);
|
|
expect(calls[0]?.init?.credentials).toBe("include");
|
|
});
|
|
|
|
it("reports an unauthenticated caller distinctly from any other failure", async () => {
|
|
for (const status of [401, 403]) {
|
|
const { implementation } = stubFetch({
|
|
"/api/v1/module-ui": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, status),
|
|
});
|
|
await expect(
|
|
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
|
|
).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" });
|
|
}
|
|
|
|
const { implementation } = stubFetch({
|
|
"/api/v1/module-ui": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
|
|
});
|
|
await expect(
|
|
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
|
|
).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" });
|
|
});
|
|
|
|
it("never renders a caught cause, a URL, or a server path in its message", async () => {
|
|
const failing = vi.fn(async () => {
|
|
throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432");
|
|
}) as unknown as typeof fetch;
|
|
|
|
const error = await loadModuleUiInventory({ apiBaseUrl, fetch: failing }).catch(
|
|
(caught: unknown) => caught,
|
|
);
|
|
|
|
expect(error).toMatchObject({ code: "HOST_REQUEST_FAILED" });
|
|
expect((error as Error).message).toBe("Ekklesia could not be reached.");
|
|
expect((error as Error).message).not.toContain("PGSQL");
|
|
});
|
|
});
|
|
|
|
describe("host protocol — principal", () => {
|
|
it("maps the platform principal onto the browser-safe SDK shape", () => {
|
|
expect(parseHostPrincipal({ kind: "anonymous" })).toEqual({ kind: "anonymous" });
|
|
expect(parseHostPrincipal({ kind: "authenticated", userId: "user-1" })).toEqual({
|
|
kind: "authenticated",
|
|
userId: "user-1",
|
|
});
|
|
});
|
|
|
|
it("carries nothing beyond kind and userId, even when the server sends more", () => {
|
|
const principal = parseHostPrincipal({
|
|
email: "[email protected]",
|
|
kind: "authenticated",
|
|
roles: ["owner"],
|
|
sessionToken: "secret",
|
|
userId: "user-1",
|
|
});
|
|
|
|
expect(Object.keys(principal).sort()).toEqual(["kind", "userId"]);
|
|
expect(JSON.stringify(principal)).not.toContain("secret");
|
|
});
|
|
|
|
it("rejects a principal it does not recognize", () => {
|
|
for (const payload of [null, {}, { kind: "authenticated" }, { kind: "owner" }]) {
|
|
expect(() => parseHostPrincipal(payload)).toThrowError(
|
|
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("host protocol — bootstrap", () => {
|
|
it("resolves the principal and then the inventory", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
|
|
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
|
});
|
|
|
|
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
|
|
|
expect(calls.map((call) => call.url)).toEqual([
|
|
`${apiBaseUrl}/api/v1/principal`,
|
|
`${apiBaseUrl}/api/v1/module-ui`,
|
|
]);
|
|
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
|
expect(bootstrap.modules).toEqual([exampleDescriptor]);
|
|
});
|
|
|
|
it("never requests the inventory for an anonymous viewer", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
|
|
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
|
|
});
|
|
|
|
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
|
|
|
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
|
|
expect(bootstrap.modules).toEqual([]);
|
|
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
|
});
|
|
|
|
it("sends the platform principal request with credentials too", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
|
|
});
|
|
|
|
await loadHostPrincipal({ apiBaseUrl, fetch: implementation });
|
|
|
|
expect(calls[0]?.init?.credentials).toBe("include");
|
|
});
|
|
});
|
|
|
|
describe("module UI host context", () => {
|
|
function contextFor(fetchImplementation: typeof fetch, descriptor = exampleDescriptor) {
|
|
return createModuleUiContext({
|
|
config: { apiBaseUrl, fetch: fetchImplementation },
|
|
descriptor,
|
|
principal: { kind: "authenticated", userId: "user-1" },
|
|
});
|
|
}
|
|
|
|
function recordingFetch() {
|
|
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
|
|
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
calls.push({ init, url: String(input) });
|
|
return new Response("[]", { status: 200 });
|
|
});
|
|
return { calls, implementation: implementation as unknown as typeof fetch };
|
|
}
|
|
|
|
it("resolves a module-relative path onto the server-produced module API root", async () => {
|
|
const { calls, implementation } = recordingFetch();
|
|
|
|
await contextFor(implementation).api.request("records");
|
|
|
|
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/modules/example/records`);
|
|
});
|
|
|
|
it("normalizes a leading slash and preserves query strings and nested paths", async () => {
|
|
const { calls, implementation } = recordingFetch();
|
|
const api = contextFor(implementation).api;
|
|
|
|
await api.request("/records");
|
|
await api.request("records?since=2026-01-01&limit=10");
|
|
await api.request("records/abc/comments");
|
|
await api.request("");
|
|
|
|
expect(calls.map((call) => call.url)).toEqual([
|
|
`${apiBaseUrl}/api/v1/modules/example/records`,
|
|
`${apiBaseUrl}/api/v1/modules/example/records?since=2026-01-01&limit=10`,
|
|
`${apiBaseUrl}/api/v1/modules/example/records/abc/comments`,
|
|
`${apiBaseUrl}/api/v1/modules/example`,
|
|
]);
|
|
});
|
|
|
|
it("forwards the module's own RequestInit and imposes credentials", async () => {
|
|
const { calls, implementation } = recordingFetch();
|
|
|
|
await contextFor(implementation).api.request("records", {
|
|
body: JSON.stringify({ value: "one" }),
|
|
headers: { "content-type": "application/json" },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(calls[0]?.init).toMatchObject({
|
|
credentials: "include",
|
|
headers: { "content-type": "application/json" },
|
|
method: "POST",
|
|
});
|
|
});
|
|
|
|
it("refuses to address anything outside the module's own API", async () => {
|
|
const { calls, implementation } = recordingFetch();
|
|
const api = contextFor(implementation).api;
|
|
|
|
const escapes = [
|
|
"../calendar/records",
|
|
"records/../../calendar/records",
|
|
"/../../v1/grants",
|
|
"https://evil.example.test/steal",
|
|
"//evil.example.test/steal",
|
|
"HTTPS://evil.example.test/steal",
|
|
"javascript:alert(1)",
|
|
];
|
|
for (const path of escapes) {
|
|
await expect(api.request(path), path).rejects.toMatchObject({
|
|
code: "MODULE_API_PATH_INVALID",
|
|
});
|
|
}
|
|
// Not one of them reached the network.
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("gives each module only its own API root", async () => {
|
|
const { calls, implementation } = recordingFetch();
|
|
const calendar: HostModuleUiDescriptor = {
|
|
apiBaseUrl: "/api/v1/modules/calendar",
|
|
id: "calendar",
|
|
permissions: [],
|
|
uiUrl: "/api/v1/module-ui/calendar/2.0.0",
|
|
version: "2.0.0",
|
|
};
|
|
|
|
await contextFor(implementation).api.request("records");
|
|
await contextFor(implementation, calendar).api.request("events");
|
|
|
|
expect(calls.map((call) => call.url)).toEqual([
|
|
`${apiBaseUrl}/api/v1/modules/example/records`,
|
|
`${apiBaseUrl}/api/v1/modules/calendar/events`,
|
|
]);
|
|
});
|
|
|
|
it("builds permission hints from the server's granted set, never a default of true", () => {
|
|
const { implementation } = recordingFetch();
|
|
|
|
const granted = contextFor(implementation).permissions;
|
|
const none = contextFor(implementation, { ...exampleDescriptor, permissions: [] }).permissions;
|
|
|
|
expect(granted.has("example.records.read")).toBe(true);
|
|
expect(granted.has("example.records.write")).toBe(false);
|
|
// A module the viewer holds nothing for gets a hint set that answers false to everything.
|
|
expect(none.has("example.records.read")).toBe(false);
|
|
expect(none.has("core.authorization.grants.manage")).toBe(false);
|
|
});
|
|
|
|
it("exposes exactly the four contract members and the browser principal", () => {
|
|
const { implementation } = recordingFetch();
|
|
|
|
const context = contextFor(implementation);
|
|
|
|
expect(Object.keys(context).sort()).toEqual(["api", "moduleId", "permissions", "principal"]);
|
|
expect(context.moduleId).toBe("example");
|
|
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
|
});
|
|
|
|
it("resolves the artifact URL from the descriptor the server produced", () => {
|
|
expect(moduleUiSourceUrl({ apiBaseUrl }, exampleDescriptor)).toBe(
|
|
`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`,
|
|
);
|
|
// Same descriptor, a different deployment origin: the shell composes, it never authors.
|
|
expect(moduleUiSourceUrl({ apiBaseUrl: "http://127.0.0.1:3000" }, exampleDescriptor)).toBe(
|
|
"http://127.0.0.1:3000/api/v1/module-ui/example/0.1.0",
|
|
);
|
|
});
|
|
});
|