Files
Ekklesia/templates/module/tests/manifest.test.ts
T
ekklesia d9dd409aa3 feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract
(packages/contracts/src/authorization.ts) — id/baseline/description,
strict, unknown-property-rejecting — consumed by both Core's
authorization registry and module manifests, replacing Core's
previously separate interface.

Bumps the canonical module manifest to manifestVersion 3: permissions
become complete declarations instead of bare identifier strings, with
manifest-owned namespace and duplicate-id enforcement. Version 2 and
bare string-array permissions are rejected outright; there is no
compatibility adapter.

Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts),
a pure, synchronous composer that builds one platform permission
registry from Core's own declarations plus every already-discovered
module's, in deterministic order, reusing createPermissionRegistry
and rejecting any module-contributed permission in the reserved
core.* namespace. core/src/authorization remains independent of
core/src/modules; only the module integration layer depends on
authorization.

Updates the module template, module-development docs, authorization
docs, and existing manifest fixtures for manifestVersion 3. Does not
wire the combined registry into apps/api — that is pass 2.
2026-07-24 12:25:00 -05:00

86 lines
3.6 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {
type CapabilityId,
parseDrizzleMigrationJournal,
parseModuleManifest,
parseModuleMigrationPolicy,
type RegisteredCapability,
} from "@ekklesia/module-sdk";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import moduleSchema, { records } from "../src/schema/index.js";
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
describe("module template", () => {
it("ships a valid backend-only manifest", async () => {
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
const manifest = parseModuleManifest(manifestInput);
expect(manifest.id).toBe("example");
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
expect(manifest.permissions).toEqual([
{
id: "example.records.read",
baseline: "restricted",
description: "Read example records.",
},
]);
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
expect(manifest.entrypoints.migrations).toBe("./drizzle");
});
it("packages a generated stable Drizzle migration and matching policy", async () => {
const [journalInput, policyInput, packageInput, sql] = await Promise.all([
readFile(new URL("../drizzle/meta/_journal.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/meta/_ekklesia.json", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0000_initial.sql", import.meta.url)),
]);
const journal = parseDrizzleMigrationJournal(JSON.parse(journalInput) as unknown);
const policy = parseModuleMigrationPolicy(JSON.parse(policyInput) as unknown);
const packageJson = JSON.parse(packageInput) as {
devDependencies?: Record<string, string>;
files?: string[];
};
expect(journal.entries.map((entry) => entry.tag)).toEqual(["0000_initial"]);
expect(policy).toMatchObject({
moduleId: "example",
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
});
expect(packageJson.devDependencies?.["drizzle-kit"]).toBe("0.31.10");
expect(packageJson.files).toContain("drizzle");
expect(createHash("sha256").update(sql).digest("hex")).toBe(
"e09d6c9995fa2c4bd59b815afc9bb6ded6a2d4e1b3e1d97c10457a94a6a4f2a4",
);
expect(sql.toString("utf8")).toContain('CREATE TABLE "example_records"');
});
it("exports the typed runtime definition used by the compiled server entrypoint", async () => {
const provided = new Map<CapabilityId, unknown>();
await moduleRuntime.register?.({
capabilities: {
provide(capabilityId, implementation) {
provided.set(capabilityId, implementation);
},
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
return undefined;
},
},
moduleId: "example",
});
expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
expect(provided.get("example.records")).toBe(recordsCapability);
});
it("exports a namespaced module-owned Drizzle schema", () => {
expect(moduleSchema.moduleId).toBe("example");
expect(moduleSchema.tables.records).toBe(records);
expect(getTableConfig(records)).toMatchObject({ name: "example_records", schema: undefined });
});
});