Files
Ekklesia/apps/api/tests/module-ui-inventory.test.ts
T
ekklesiaandClaude Opus 5 b9ca0d560d
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
feat: add host-provided module object storage
`manifest.storage.buckets` has existed since manifest version 1 and meant
nothing — discovery validated it and no code read it. This turns it into a
real capability, which is what stands between Announcements and the module
system: that product currently fetches a hardcoded LAN S3 endpoint, parses
ListObjectsV2 XML by hand, and hands the browser direct object URLs.

- A module declares a logical bucket and receives a scoped facade at
  `context.storage` — list, head, get, put, delete. The contract names no
  provider: no endpoint, region, credential, or physical bucket is reachable
  from it, which is what lets the same module run under a future standalone
  host over its own or a shared S3-compatible service.
- Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the
  deployment target (ADR 0004), never an API name. Probing the pinned image
  found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's
  `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would
  have failed every second boot. A live test pins it.
- Logical → physical mapping is deterministic and collision-safe: an
  installation prefix, the module and bucket ids joined by `--` (which cannot
  occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide),
  and a digest over the full untruncated identity. Two modules declaring
  `assets` always get separate namespaces; neither can see the physical name.
- Declaring a bucket is now a requirement: with no provider configured the
  module is `blocked · storage_unavailable` through ADR 0028's existing
  resolver, propagating to required dependents. An installation with no object
  storage stays a complete deployment.
- Binding availability is not provider health. Eligibility asks whether
  storage is configured, never whether it answered; bucket creation is lazy on
  first use so a storage outage cannot stop the platform booting; a running
  module whose store blinks stays running and retries. Nothing deletes a
  bucket.
- Credentials are deployment-supplied, never persisted, never routed through
  module configuration, and never visible to modules, module UI, diagnostics,
  or API responses. Provider errors translate to a closed code set carrying no
  endpoint, bucket, request id, or XML.
- Retires ADR 0029's transitional `bucket` configuration key. No manifest
  version change. The template declares no bucket, so it still runs without S3.

See ADR 0030.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 08:45:53 -05:00

515 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";
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 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: 5,
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),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(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);
}
});
});