Files
Ekklesia/packages/contracts/tests/module-manifest.test.ts
T
ekklesiaandClaude Opus 5 baa689b2a7
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.

- A module declares what it needs in its manifest, using six bounded types
  (string, integer, boolean, enum, url, secret) with no nesting and no
  regular expressions. Static declaration is what lets the platform validate
  configuration *before* deciding whether a module may start: an enabled
  module missing a required value is reported `blocked ·
  configuration_missing · <key>` rather than taking startup down from inside
  its own `start` hook, and Doctor reports the same while still importing no
  module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
  (module_id, key), stored as text until validated against the declaring
  module's current manifest. Nothing is coerced; an invalid stored value
  blocks the module rather than falling back to the default. A key the
  manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
  repository, so a `secret` key is deployment-supplied and mapped in by the
  host under a name the module never sees. A secret declaration has no
  `default` field, the write API refuses secret material, and the HTTP view
  has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
  and HTTP handler contexts: the same object identity, per module, beside
  `services` rather than inside it (services is deliberately one shared
  instance). No environment access, no database reference, no way to reach
  another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
  three new blocked reasons, each with a distinct operator fix, propagating
  to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
  restartRequired. New restricted permission
  `core.modules.configuration.manage`, separate from lifecycle, at the
  sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
  Endpoints and credentials for shared infrastructure stay platform
  concerns; a module declares which bucket it needs, not an S3 endpoint.

See ADR 0029.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:44:43 -05:00

493 lines
17 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isVersionCompatible,
isModuleConfigurationBlockedReason,
MODULE_BLOCKED_REASONS,
MODULE_CONFIGURATION_BLOCKED_REASONS,
MODULE_DEPENDENCY_KINDS,
MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES,
packageRelativePathSchema,
parseModuleManifest,
parsePermissionId,
safeParseModuleManifest,
} from "../src/index.js";
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 5,
id: "calendar",
name: "Calendar",
version: "0.1.0",
compatibility: {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
capabilities: [{ id: "calendar.events", version: "2.3.0" }],
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read calendar events.",
},
],
storage: {
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
},
entrypoints: {
server: "./dist/server/index.js",
},
};
}
describe("module manifest contract", () => {
it("normalizes a valid manifest with safe defaults", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.id).toBe("calendar");
expect(manifest.dependencies).toEqual({
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
});
expect(manifest.storage.buckets).toEqual([
{ id: "assets", description: "Calendar-owned event assets." },
]);
});
it("creates isolated default collections for every parsed manifest", () => {
const input = validManifest();
delete input.storage;
delete input.permissions;
const first = parseModuleManifest(input);
const second = parseModuleManifest(input);
first.dependencies.modules.required.people = "^0.1.0";
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
first.storage.buckets.push({ id: "exports" });
first.permissions.push({
id: parsePermissionId("calendar.events.write"),
baseline: "restricted",
description: "Write calendar events.",
});
expect(second.dependencies.modules.required).toEqual({});
expect(second.dependencies.capabilities.required).toEqual({});
expect(second.storage.buckets).toEqual([]);
expect(second.permissions).toEqual([]);
});
it("rejects unknown and presentation-oriented manifest properties", () => {
const manifest = { ...validManifest(), renderers: ["calendar-month"] };
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
});
it("requires module-owned capability and permission namespaces", () => {
const manifest = {
...validManifest(),
capabilities: [{ id: "media.items", version: "1.0.0" }],
permissions: [
{ id: "media.items.read", baseline: "restricted", description: "Read media items." },
],
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["capabilities.0.id", "permissions.0.id"]),
);
}
});
it("rejects duplicate declarations and dependency overlap", () => {
const manifest = {
...validManifest(),
dependencies: {
modules: {
required: { people: "^1.0.0" },
optional: { people: "^1.0.0" },
},
capabilities: {
required: { "people.records": "^1.0.0" },
optional: { "people.records": "^1.0.0" },
},
},
capabilities: [
{ id: "calendar.events", version: "2.3.0" },
{ id: "calendar.events", version: "2.4.0" },
],
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toHaveLength(4);
}
});
it("rejects a duplicate permission id at its exact issue path", () => {
const manifest = {
...validManifest(),
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read calendar events.",
},
{
id: "calendar.events.read",
baseline: "authenticated",
description: "Read calendar events, again.",
},
],
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"permissions.1.id",
);
}
});
it("rejects module and capability self-dependencies", () => {
const manifest = {
...validManifest(),
dependencies: {
modules: { required: { calendar: "^0.1.0" } },
capabilities: { required: { "calendar.events": "^2.0.0" } },
},
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("versions capabilities independently from the module release", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.version).toBe("0.1.0");
expect(manifest.capabilities).toEqual([{ id: "calendar.events", version: "2.3.0" }]);
});
it("requires exact capability versions and dependency ranges", () => {
const manifest = {
...validManifest(),
capabilities: [{ id: "calendar.events", version: "^2.0.0" }],
dependencies: {
capabilities: { required: { "people.records": "not-a-range" } },
},
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining([
"capabilities.0.version",
"dependencies.capabilities.required.people.records",
]),
);
}
});
it("rejects obsolete manifest versions instead of guessing a migration", () => {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
});
it("rejects a version 2 manifest rather than silently interpreting it as a later version", () => {
// manifestVersion 2's own permissions shape (a bare string array) is also what every later
// version must reject — this manifest is invalid twice over, deliberately, since a real
// version 2 manifest is exactly this shape.
const version2Manifest = {
...validManifest(),
manifestVersion: 2,
permissions: ["calendar.events.read"],
};
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
});
it("rejects a version 3 manifest rather than silently interpreting it as a later version", () => {
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
});
it("rejects a version 4 manifest rather than silently interpreting it as version 5", () => {
// A manifest declaring no configuration is otherwise byte-identical between version 4 and
// version 5: only the version literal separates them, and it is not reinterpreted (ADR 0029).
const version4Manifest = { ...validManifest(), manifestVersion: 4 };
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(5);
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
.success,
).toBe(true);
});
it("rejects a bare permission-id string array instead of complete declarations", () => {
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("defaults to an empty permission list when omitted", () => {
const input = validManifest();
delete input.permissions;
const manifest = parseModuleManifest(input);
expect(manifest.permissions).toEqual([]);
});
it.each([
"public",
"authenticated",
"restricted",
] as const)("accepts a %s permission declaration and preserves its exact baseline", (baseline) => {
const manifest = parseModuleManifest({
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline, description: "Read events." }],
});
expect(manifest.permissions).toEqual([
{ id: "calendar.events.read", baseline, description: "Read events." },
]);
});
it("rejects a permission declaration with a missing baseline", () => {
const manifest = {
...validManifest(),
permissions: [{ id: "calendar.events.read", description: "Read events." }],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with an invalid baseline", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "calendar.events.read", baseline: "superuser", description: "Read events." },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with a missing or blank description", () => {
const missingDescription = {
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline: "restricted" }],
};
const blankDescription = {
...validManifest(),
permissions: [{ id: "calendar.events.read", baseline: "restricted", description: " " }],
};
expect(safeParseModuleManifest(missingDescription).success).toBe(false);
expect(safeParseModuleManifest(blankDescription).success).toBe(false);
});
it("rejects a permission declaration with an oversized description", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "calendar.events.read", baseline: "restricted", description: "x".repeat(241) },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a permission declaration with an unknown property", () => {
const manifest = {
...validManifest(),
permissions: [
{
id: "calendar.events.read",
baseline: "restricted",
description: "Read events.",
scope: "calendar-event:123",
},
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("rejects a malformed permission id", () => {
const manifest = {
...validManifest(),
permissions: [
{ id: "Calendar.Events.Read", baseline: "restricted", description: "Read events." },
],
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it.each([
"../outside.ts",
"/absolute.ts",
"file:///server.ts",
"./src/../server.ts",
"./src\\server.ts",
"./src/server.ts?raw",
])("rejects unsafe package entrypoint %s", (entrypoint) => {
expect(packageRelativePathSchema.safeParse(entrypoint).success).toBe(false);
});
it("allows package directories whose names merely begin with two dots", () => {
expect(packageRelativePathSchema.safeParse("./..data/server.ts").success).toBe(true);
});
it("uses full semantic-version compatibility ranges", () => {
expect(isVersionCompatible("0.1.5", "^0.1.0")).toBe(true);
expect(isVersionCompatible("0.2.0", "^0.1.0")).toBe(false);
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
});
it("publishes exactly the operational lifecycle vocabulary the platform implements", () => {
// Every name here is produced by real code and observable through the lifecycle read model
// (ADR 0028). The nine-stage `MODULE_LIFECYCLE_STAGES` vocabulary this replaced named states
// nothing ever entered, including a `failed` state that startup atomicity makes unobservable —
// asserting the exact sets is what keeps this contract from drifting back into aspiration.
expect(MODULE_RUNTIME_STATES).toEqual(["running", "disabled", "blocked"]);
expect(MODULE_RUNTIME_STATES).not.toContain("failed");
expect(MODULE_BLOCKED_REASONS).toEqual([
"required_dependency_disabled",
"required_dependency_blocked",
"configuration_missing",
"configuration_invalid",
"secret_missing",
]);
// The three configuration reasons are a subset, and each corresponds to a real implemented
// check with a distinct operator fix (ADR 0029) — the only justification for a separate code.
expect(MODULE_CONFIGURATION_BLOCKED_REASONS).toEqual([
"configuration_missing",
"configuration_invalid",
"secret_missing",
]);
for (const reason of MODULE_CONFIGURATION_BLOCKED_REASONS) {
expect(isModuleConfigurationBlockedReason(reason)).toBe(true);
}
expect(isModuleConfigurationBlockedReason("required_dependency_disabled")).toBe(false);
expect(MODULE_DEPENDENCY_KINDS).toEqual(["module", "capability"]);
});
describe("module-owned UI entrypoint (ADR 0021)", () => {
it("accepts a backend-only module that declares no UI at all", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.entrypoints.ui).toBeUndefined();
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
it("accepts a declared compiled UI entrypoint alongside the server entrypoint", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
// Declaring UI never alters the backend contract: a product module is one module, not two.
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
it.each([
["../outside/ui.js", "escapes the package boundary"],
["/absolute/ui.js", "is an absolute path"],
["./dist/../../ui.js", "traverses out through a parent segment"],
["file:///dist/ui/index.js", "is a URL rather than a package-relative path"],
["./dist\\ui\\index.js", "uses backslash separators"],
["./dist/ui/index.js?raw", "carries a query string"],
["", "is empty"],
])("rejects a UI entrypoint that %s", (ui) => {
const manifest = {
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui },
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it.each([
42,
null,
true,
["./dist/ui/index.js"],
{ entry: "./dist/ui/index.js" },
])("rejects a non-string UI entrypoint %s", (ui) => {
const manifest = {
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui },
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("still rejects unknown entrypoint keys, so UI is the only new one", () => {
for (const unknownEntrypoint of ["dashboard", "surface", "client", "web", "renderers"]) {
const manifest = {
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
[unknownEntrypoint]: "./dist/ui/index.js",
},
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
}
});
it("declares no grouping, folder, parent, or menu field for a module to own", () => {
// Dashboard organization is presentation configuration, never module architecture
// (ADR 0021). The manifest must offer no place to encode a hierarchy.
for (const presentationField of [
"group",
"folder",
"parent",
"menu",
"navigation",
"icon",
"order",
]) {
const manifest = { ...validManifest(), [presentationField]: "media" };
expect(safeParseModuleManifest(manifest).success).toBe(false);
}
});
it("keeps the module dependency graph flat: UI adds no parent/child relationship", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
// A module that ships UI still expresses relationships only through genuine module and
// capability dependencies — the same two mechanisms a backend-only module uses.
expect(Object.keys(manifest.dependencies.modules.required)).toEqual([]);
expect(Object.keys(manifest.dependencies.modules.optional)).toEqual([]);
expect(Object.keys(manifest)).not.toContain("children");
});
it("still supports a genuine module dependency for a module that ships UI", () => {
const manifest = parseModuleManifest({
...validManifest(),
dependencies: {
modules: { required: { people: "^0.1.0" } },
capabilities: { required: { "people.records": "^2.0.0" } },
},
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
});
expect(manifest.dependencies.modules.required).toEqual({ people: "^0.1.0" });
expect(manifest.dependencies.capabilities.required).toEqual({ "people.records": "^2.0.0" });
});
});
});