Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
503 lines
18 KiB
TypeScript
503 lines
18 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
createAuthenticatedPrincipal,
|
|
type EkklesiaPrincipal,
|
|
type ModuleDatabase,
|
|
type ModuleRuntimeServices,
|
|
parseEkklesiaUserId,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationStore,
|
|
createAuthorizationEvaluator,
|
|
type PermissionRegistry,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
discoverModules,
|
|
} from "@ekklesia/core/modules";
|
|
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 { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
|
import { moduleUiArtifactUrl } from "../src/module-ui-routes.js";
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
|
);
|
|
});
|
|
|
|
interface ModuleFixture {
|
|
readonly id: string;
|
|
readonly version?: string;
|
|
/** Omit for a backend-only module: the manifest then declares no `entrypoints.ui` at all. */
|
|
readonly withUi?: boolean;
|
|
readonly permissions?: ReadonlyArray<{
|
|
readonly id: string;
|
|
readonly baseline: "authenticated" | "public" | "restricted";
|
|
}>;
|
|
}
|
|
|
|
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.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
|
},
|
|
id: fixture.id,
|
|
manifestVersion: 4,
|
|
name: `${fixture.id} module`,
|
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
|
...permission,
|
|
description: `Test permission for ${fixture.id}.`,
|
|
})),
|
|
version,
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`,
|
|
);
|
|
if (fixture.withUi) {
|
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
|
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
interface InstallOptions {
|
|
readonly fixtures: readonly ModuleFixture[];
|
|
/** Anonymous by default, so every scenario has to opt in to being signed in. */
|
|
readonly principal?: EkklesiaPrincipal;
|
|
/** Permission ids the viewer holds an active grant for. Evaluated by Core's real evaluator, so
|
|
* baselines, the owner bypass, and deny-by-default all behave exactly as in production. */
|
|
readonly grants?: readonly string[];
|
|
readonly platformOwner?: boolean;
|
|
/** Makes the grant port fail, so the "a hint that could not be determined is never reported as
|
|
* denied" path can be exercised. */
|
|
readonly grantsUnavailable?: boolean;
|
|
}
|
|
|
|
interface Installation {
|
|
readonly app: Hono;
|
|
readonly modules: readonly DiscoveredModule[];
|
|
readonly modulesDirectory: string;
|
|
readonly permissionRegistry: PermissionRegistry;
|
|
}
|
|
|
|
/** A real installation: fixtures on disk, Core's real discovery, real permission composition, the
|
|
* real publication registry, the real authorization evaluator, and the real `createApiApp`. Only
|
|
* identity resolution and the two authorization ports are doubled, because neither needs PostgreSQL
|
|
* to answer the questions these tests ask. */
|
|
async function install(options: InstallOptions): Promise<Installation> {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-ui-inventory-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
for (const fixture of options.fixtures) {
|
|
await createModule(modulesDirectory, fixture);
|
|
}
|
|
const modules = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
const granted = new Set(options.grants ?? []);
|
|
const evaluator = createAuthorizationEvaluator({
|
|
grants: {
|
|
hasActiveGrant: async (query) => {
|
|
if (options.grantsUnavailable) {
|
|
throw new Error("grant store unavailable");
|
|
}
|
|
return granted.has(query.permission);
|
|
},
|
|
},
|
|
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
|
|
});
|
|
|
|
const runtime: EkklesiaIdentityRuntime = {
|
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
|
};
|
|
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 services: ModuleRuntimeServices = Object.freeze({
|
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
|
});
|
|
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
|
|
|
const app = createApiApp({
|
|
authorization: { evaluator, registry: permissionRegistry, store },
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleServices: services,
|
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
|
|
return { app, modules, modulesDirectory, permissionRegistry };
|
|
}
|
|
|
|
function signedIn(): EkklesiaPrincipal {
|
|
return createAuthenticatedPrincipal(viewerId);
|
|
}
|
|
|
|
async function inventoryOf(app: Hono): Promise<{
|
|
readonly body: string;
|
|
readonly modules: ReadonlyArray<Record<string, unknown>>;
|
|
readonly response: Response;
|
|
}> {
|
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
const body = await response.clone().text();
|
|
const parsed = (await response.json()) as { modules?: ReadonlyArray<Record<string, unknown>> };
|
|
return { body, modules: parsed.modules ?? [], response };
|
|
}
|
|
|
|
describe("module UI inventory — what it lists", () => {
|
|
it("describes a publishable module with exactly the fields a host needs", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { modules, response } = await inventoryOf(app);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(modules).toEqual([
|
|
{
|
|
api: { baseUrl: "/api/v1/modules/example" },
|
|
id: "example",
|
|
permissions: [],
|
|
ui: { url: "/api/v1/module-ui/example/0.1.0" },
|
|
version: "0.1.0",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("builds both URLs with the server's own canonical helpers", async () => {
|
|
const { app, modules: discovered } = await install({
|
|
fixtures: [{ id: "example", version: "2.4.1", withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
const module = discovered[0] as DiscoveredModule;
|
|
|
|
const { modules } = await inventoryOf(app);
|
|
|
|
// Not "looks like the right shape" — byte-identical to what the helpers produce, so a second
|
|
// hand-written template string anywhere would fail here.
|
|
expect(modules[0]?.ui).toEqual({
|
|
url: moduleUiArtifactUrl({ moduleId: module.manifest.id, version: module.manifest.version }),
|
|
});
|
|
expect(modules[0]?.api).toEqual({ baseUrl: moduleApiBaseUrl(module.manifest.id) });
|
|
});
|
|
|
|
it("omits a module that declares no UI entrypoint", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "calendar" }, { id: "example", withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { modules } = await inventoryOf(app);
|
|
|
|
expect(modules.map((module) => module.id)).toEqual(["example"]);
|
|
});
|
|
|
|
it("is empty, not an error, when no installed module provides a UI", async () => {
|
|
const { app } = await install({ fixtures: [{ id: "calendar" }], principal: signedIn() });
|
|
|
|
const { modules, response } = await inventoryOf(app);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(modules).toEqual([]);
|
|
});
|
|
|
|
it("orders modules deterministically, matching the publication registry", async () => {
|
|
const { app } = await install({
|
|
fixtures: [
|
|
{ id: "signage", withUi: true },
|
|
{ id: "announcements", withUi: true },
|
|
{ id: "calendar", withUi: true },
|
|
],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const first = await inventoryOf(app);
|
|
const second = await inventoryOf(app);
|
|
|
|
// Discovery order, which is sorted; the same on every request.
|
|
expect(first.modules.map((module) => module.id)).toEqual([
|
|
"announcements",
|
|
"calendar",
|
|
"signage",
|
|
]);
|
|
expect(second.modules.map((module) => module.id)).toEqual(
|
|
first.modules.map((module) => module.id),
|
|
);
|
|
});
|
|
|
|
it("leaks no filesystem path, package root, or other manifest internal", async () => {
|
|
const {
|
|
app,
|
|
modules: discovered,
|
|
modulesDirectory,
|
|
} = await install({
|
|
fixtures: [{ id: "example", permissions: [], withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
const artifactPath = (discovered[0] as DiscoveredModule).entrypoints.ui as string;
|
|
|
|
const { body, modules } = await inventoryOf(app);
|
|
|
|
expect(body).not.toContain(modulesDirectory);
|
|
expect(body).not.toContain(artifactPath);
|
|
expect(body).not.toContain(tmpdir());
|
|
expect(body).not.toContain("dist");
|
|
expect(body).not.toContain("@test/module-example");
|
|
expect(body).not.toContain("ekklesia.module.json");
|
|
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
|
"api",
|
|
"id",
|
|
"permissions",
|
|
"ui",
|
|
"version",
|
|
]);
|
|
});
|
|
|
|
it("carries no navigation or presentation metadata", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { body } = await inventoryOf(app);
|
|
|
|
// ADR 0021 keeps Dashboard grouping an administrator's configuration, never a module
|
|
// declaration. The manifest's own `name` and `description` must not leak into it by accident.
|
|
for (const field of ["name", "description", "icon", "category", "folder", "order", "group"]) {
|
|
expect(body, field).not.toContain(`"${field}"`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("module UI inventory — authentication", () => {
|
|
it("refuses an anonymous caller", async () => {
|
|
const { app } = await install({ fixtures: [{ id: "example", withUi: true }] });
|
|
|
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
});
|
|
|
|
it("tells an anonymous caller nothing about what is installed", async () => {
|
|
const { app } = await install({
|
|
fixtures: [
|
|
{ id: "example", withUi: true },
|
|
{ id: "calendar", withUi: true },
|
|
],
|
|
});
|
|
|
|
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
|
|
|
|
expect(body).not.toContain("example");
|
|
expect(body).not.toContain("calendar");
|
|
});
|
|
|
|
it("admits any signed-in user without needing an explicit grant", async () => {
|
|
// `core.modules.ui.read` is baseline `authenticated`, so being signed in is enough — a shell
|
|
// that rendered nothing until an operator granted every user a permission would be unusable.
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", withUi: true }],
|
|
grants: [],
|
|
principal: signedIn(),
|
|
platformOwner: false,
|
|
});
|
|
|
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
|
});
|
|
|
|
it("is never cached and varies on the caller's identity", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const response = await app.request(MODULE_UI_INVENTORY_PATH, {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
const vary = response.headers.get("vary")?.toLowerCase() ?? "";
|
|
expect(vary).toContain("origin");
|
|
expect(vary).toContain("cookie");
|
|
});
|
|
});
|
|
|
|
describe("module UI inventory — permission hints", () => {
|
|
const readPermission = { baseline: "restricted" as const, id: "example.records.read" };
|
|
const writePermission = { baseline: "restricted" as const, id: "example.records.write" };
|
|
|
|
it("returns only the permissions the server actually granted this principal", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
|
grants: ["example.records.read"],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { modules } = await inventoryOf(app);
|
|
|
|
expect(modules[0]?.permissions).toEqual(["example.records.read"]);
|
|
});
|
|
|
|
it("returns none of them for a signed-in user holding no grant", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
|
grants: [],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { modules } = await inventoryOf(app);
|
|
|
|
// Never a default of true, and never the module's full declared surface.
|
|
expect(modules[0]?.permissions).toEqual([]);
|
|
});
|
|
|
|
it("honours a baseline that needs no grant at all", async () => {
|
|
const { app } = await install({
|
|
fixtures: [
|
|
{
|
|
id: "example",
|
|
permissions: [{ baseline: "authenticated", id: "example.records.list" }, readPermission],
|
|
withUi: true,
|
|
},
|
|
],
|
|
grants: [],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { modules } = await inventoryOf(app);
|
|
|
|
expect(modules[0]?.permissions).toEqual(["example.records.list"]);
|
|
});
|
|
|
|
it("honours the platform-owner bypass, exactly like a real request would", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", permissions: [readPermission, writePermission], withUi: true }],
|
|
grants: [],
|
|
platformOwner: true,
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { modules } = await inventoryOf(app);
|
|
|
|
expect(modules[0]?.permissions).toEqual(["example.records.read", "example.records.write"]);
|
|
});
|
|
|
|
it("scopes hints to each module's own declared permissions", async () => {
|
|
const { app } = await install({
|
|
fixtures: [
|
|
{ id: "example", permissions: [readPermission], withUi: true },
|
|
{
|
|
id: "calendar",
|
|
permissions: [{ baseline: "restricted", id: "calendar.events.read" }],
|
|
withUi: true,
|
|
},
|
|
],
|
|
grants: ["example.records.read", "calendar.events.read"],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const { modules } = await inventoryOf(app);
|
|
|
|
const byId = new Map(modules.map((module) => [module.id, module.permissions]));
|
|
// Neither module learns anything about the other's authorization state, and neither is told
|
|
// about Core's own permissions.
|
|
expect(byId.get("example")).toEqual(["example.records.read"]);
|
|
expect(byId.get("calendar")).toEqual(["calendar.events.read"]);
|
|
expect(JSON.stringify(modules)).not.toContain("core.authorization.grants.manage");
|
|
expect(JSON.stringify(modules)).not.toContain("core.modules.ui.read");
|
|
});
|
|
|
|
it("reports an evaluation outage as unavailable rather than as 'nothing granted'", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", permissions: [readPermission], withUi: true }],
|
|
grantsUnavailable: true,
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const response = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
|
|
// A hint that silently read "denied" during an outage would blank the UI and look like a
|
|
// permissions problem.
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
|
});
|
|
});
|
|
|
|
describe("module UI inventory — it is not a module management API", () => {
|
|
it("routes no method other than GET", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
|
const response = await app.request(MODULE_UI_INVENTORY_PATH, { method });
|
|
expect(response.status, method).toBe(404);
|
|
expect(await response.json(), method).toEqual({ code: "NOT_FOUND" });
|
|
}
|
|
});
|
|
|
|
it("still refuses to serve an artifact under the collection path", async () => {
|
|
const { app } = await install({
|
|
fixtures: [{ id: "example", withUi: true }],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
for (const suffix of ["/example", "/example/", "/example/0.1.0/index.js"]) {
|
|
const response = await app.request(`${MODULE_UI_INVENTORY_PATH}${suffix}`);
|
|
expect(response.status, suffix).toBe(404);
|
|
}
|
|
});
|
|
});
|