Files
Ekklesia/surfaces/shell/tests/host-protocol.test.ts
T
ekklesiaandClaude Opus 5 616c1d4834
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s
feat: mount discovered module UI in a minimal Ekklesia browser host
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]>
2026-08-22 08:55:44 -05:00

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",
);
});
});