Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
isVersionCompatible,
|
|
MODULE_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: 4,
|
|
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 version 4", () => {
|
|
// A backend-only version 3 manifest is otherwise byte-identical to a valid version 4 one:
|
|
// only the version literal separates them, and it is not reinterpreted.
|
|
const version3Manifest = { ...validManifest(), manifestVersion: 3 };
|
|
|
|
expect(safeParseModuleManifest(version3Manifest).success).toBe(false);
|
|
});
|
|
|
|
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
|
|
expect(MODULE_MANIFEST_VERSION).toBe(4);
|
|
for (const manifestVersion of [1, 2, 3, 5, "4", 4.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",
|
|
]);
|
|
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" });
|
|
});
|
|
});
|
|
});
|