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]>
530 lines
20 KiB
TypeScript
530 lines
20 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { parseModuleManifest } from "@ekklesia/contracts";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
|
|
import {
|
|
type ModuleLifecycleConfiguration,
|
|
resolveModuleEligibility,
|
|
resolveModuleLifecycleView,
|
|
} from "../src/modules/lifecycle.js";
|
|
|
|
/** A minimal, in-memory `DiscoveredModule` double, built the same way `module-permissions.test.ts`
|
|
* builds one: the manifest still goes through `parseModuleManifest`, so a fixture real discovery
|
|
* would reject can never silently stand in for one it would accept. Only `manifest` is read by
|
|
* eligibility resolution; the rest exist to satisfy the type. */
|
|
function fakeModule(
|
|
id: string,
|
|
options: {
|
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
|
readonly dependencies?: Record<string, unknown>;
|
|
} = {},
|
|
): DiscoveredModule {
|
|
const manifest = parseModuleManifest({
|
|
manifestVersion: 5,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
capabilities: options.capabilities ?? [],
|
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
entrypoints: {
|
|
// Deliberately a path nothing will ever resolve: eligibility resolution must never import,
|
|
// evaluate, or otherwise touch a module's server entrypoint.
|
|
server: "./dist/does-not-exist/index.js",
|
|
},
|
|
});
|
|
return {
|
|
directory: `/modules/${id}`,
|
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
|
manifest,
|
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
|
packageName: `@test/${id}`,
|
|
};
|
|
}
|
|
|
|
function configuration(
|
|
...records: readonly (readonly [string, boolean])[]
|
|
): ModuleLifecycleConfiguration {
|
|
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
|
}
|
|
|
|
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
|
|
return modules.map((module) => module.manifest.id);
|
|
}
|
|
|
|
function entry(
|
|
resolution: ReturnType<typeof resolveModuleEligibility>,
|
|
moduleId: string,
|
|
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
|
|
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
|
|
if (!found) {
|
|
throw new Error(`No eligibility entry for ${moduleId}.`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
describe("resolveModuleEligibility — default policy for a newly discovered module", () => {
|
|
it("treats a module with no stored record as enabled, so deploying a module is enough to run it", () => {
|
|
const resolution = resolveModuleEligibility([fakeModule("announcements")], configuration());
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
expect(entry(resolution, "announcements").configured).toEqual({
|
|
enabled: true,
|
|
explicit: false,
|
|
});
|
|
});
|
|
|
|
it("distinguishes the default from a decision somebody actually made", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements")],
|
|
configuration(["announcements", true]),
|
|
);
|
|
|
|
// Same effect, different provenance — which is exactly what an operator needs to know when
|
|
// they are wondering whether a module is deliberately on.
|
|
expect(entry(resolution, "announcements").configured).toEqual({
|
|
enabled: true,
|
|
explicit: true,
|
|
});
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("never writes, and never enables anything: an explicit disable always wins over the default", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements")],
|
|
configuration(["announcements", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "announcements")).toEqual({
|
|
configured: { enabled: false, explicit: true },
|
|
eligible: false,
|
|
moduleId: "announcements",
|
|
version: "0.1.0",
|
|
});
|
|
// A disabled module is not "blocked" — it is doing exactly what it was told.
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("ignores a stored record for a module that is not deployed, rather than failing startup", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[fakeModule("announcements")],
|
|
configuration(["announcements", true], ["removed-long-ago", false]),
|
|
);
|
|
|
|
expect(resolution.entries.map((candidate) => candidate.moduleId)).toEqual(["announcements"]);
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("restores a retained decision when the module is deployed again", () => {
|
|
const stale = configuration(["announcements", false]);
|
|
|
|
// Absent: the record is inert.
|
|
expect(resolveModuleEligibility([], stale).entries).toEqual([]);
|
|
// Redeployed: the same record decides again, with no new write.
|
|
expect(resolveModuleEligibility([fakeModule("announcements")], stale).eligibleModules).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleEligibility — required dependencies", () => {
|
|
it("keeps a dependent eligible while its required dependency is enabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["storage", "announcements"]);
|
|
});
|
|
|
|
it("blocks a dependent when its required module dependency is disabled, and names the dependency", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
});
|
|
// Administrator intent is never overridden: disabling `storage` does not silently re-enable it
|
|
// because something needs it.
|
|
expect(entry(resolution, "storage").configured.enabled).toBe(false);
|
|
});
|
|
|
|
it("blocks a dependent when the required capability's only provider is disabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage", { capabilities: [{ id: "storage.objects", version: "1.0.0" }] }),
|
|
fakeModule("announcements", {
|
|
dependencies: { capabilities: { required: { "storage.objects": "^1.0.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
// A required capability is exactly as required as a required module, and the reason names the
|
|
// module an administrator would have to enable — not just the capability id.
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { capabilityId: "storage.objects", kind: "capability", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
});
|
|
});
|
|
|
|
it("propagates blocking transitively, and distinguishes the direct cause from the inherited one", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("media", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { media: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(resolution.eligibleModules).toEqual([]);
|
|
expect(entry(resolution, "media").blocked?.reason).toBe("required_dependency_disabled");
|
|
// Following the chain always terminates at a `..._disabled` entry, so an operator can walk from
|
|
// any blocked module to the one decision that caused it.
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "media" },
|
|
reason: "required_dependency_blocked",
|
|
});
|
|
});
|
|
|
|
it("blocks only the dependents of the disabled module, never an unrelated one", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
fakeModule("calendar"),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
|
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("preserves discovery order in the eligible list rather than re-sorting it", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("media", { dependencies: { modules: { required: { storage: "^0.1.0" } } } }),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { required: { media: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(),
|
|
);
|
|
|
|
// Filtering, never re-ordering: a started module's required dependencies still precede it.
|
|
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleEligibility — optional dependencies", () => {
|
|
it("leaves a dependent eligible when its optional module dependency is disabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("analytics"),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["analytics", false]),
|
|
);
|
|
|
|
// A module declaring an optional dependency already accepted that it may be absent — discovery
|
|
// permits an optional dependency to not be installed at all — and "disabled" is that same
|
|
// situation arriving a different way.
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
|
});
|
|
|
|
it("leaves a dependent eligible when its optional capability's provider is disabled", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("analytics", { capabilities: [{ id: "analytics.events", version: "1.0.0" }] }),
|
|
fakeModule("announcements", {
|
|
dependencies: { capabilities: { optional: { "analytics.events": "^1.0.0" } } },
|
|
}),
|
|
],
|
|
configuration(["analytics", false]),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("leaves a dependent eligible when its optional dependency is itself blocked", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("storage"),
|
|
fakeModule("analytics", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
}),
|
|
fakeModule("announcements", {
|
|
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
|
|
}),
|
|
],
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
|
});
|
|
|
|
it("blocks on the required edge only, when a module declares both kinds", () => {
|
|
const resolution = resolveModuleEligibility(
|
|
[
|
|
fakeModule("analytics"),
|
|
fakeModule("storage"),
|
|
fakeModule("announcements", {
|
|
dependencies: {
|
|
modules: { optional: { analytics: "^0.1.0" }, required: { storage: "^0.1.0" } },
|
|
},
|
|
}),
|
|
],
|
|
configuration(["analytics", false], ["storage", false]),
|
|
);
|
|
|
|
expect(entry(resolution, "announcements").blocked).toEqual({
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleEligibility — over a real discovered inventory", () => {
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
|
);
|
|
});
|
|
|
|
async function writeModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
dependencies?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${id}`,
|
|
type: "module",
|
|
version: "0.1.0",
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
manifestVersion: 5,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
...(dependencies ? { dependencies } : {}),
|
|
entrypoints: { server: "./dist/server/index.js" },
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
'throw new Error("eligibility resolution must never import a module server entrypoint");',
|
|
),
|
|
]);
|
|
}
|
|
|
|
it("resolves against the exact list real discovery produces, without importing any entrypoint", async () => {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
await writeModule(modulesDirectory, "storage");
|
|
await writeModule(modulesDirectory, "announcements", {
|
|
modules: { required: { storage: "^0.1.0" } },
|
|
});
|
|
|
|
const discovered = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
// Discovery's own topological order, which eligibility resolution relies on and never redoes.
|
|
expect(ids(discovered)).toEqual(["storage", "announcements"]);
|
|
|
|
const blocked = resolveModuleEligibility(discovered, configuration(["storage", false]));
|
|
expect(blocked.eligibleModules).toEqual([]);
|
|
// Every fixture entrypoint throws on import; reaching this assertion is direct evidence that
|
|
// nothing here imported one.
|
|
expect(entry(blocked, "announcements").blocked?.reason).toBe("required_dependency_disabled");
|
|
});
|
|
|
|
it("leaves a missing required dependency to discovery, rather than modelling it as a lifecycle state", async () => {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
await writeModule(modulesDirectory, "announcements", {
|
|
modules: { required: { storage: "^0.1.0" } },
|
|
});
|
|
|
|
// There is deliberately no eligibility state for "required dependency not installed": discovery
|
|
// rejects the whole inventory long before enablement is consulted, so there is exactly one place
|
|
// the dependency graph is validated.
|
|
await expect(
|
|
discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
}),
|
|
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
|
|
});
|
|
});
|
|
|
|
describe("resolveModuleLifecycleView — configuration versus what this process actually did", () => {
|
|
const storage = fakeModule("storage");
|
|
const announcements = fakeModule("announcements", {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
});
|
|
const discoveredModules = [storage, announcements];
|
|
|
|
function snapshotFor(bootConfiguration: ModuleLifecycleConfiguration) {
|
|
const resolution = resolveModuleEligibility(discoveredModules, bootConfiguration);
|
|
return {
|
|
discoveredModules,
|
|
eligibility: resolution.entries,
|
|
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
|
|
};
|
|
}
|
|
|
|
it("reports a started module as running, with no restart pending", () => {
|
|
const view = resolveModuleLifecycleView(snapshotFor(configuration()), configuration());
|
|
|
|
expect(view).toEqual([
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
moduleId: "storage",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "0.1.0",
|
|
},
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
moduleId: "announcements",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "0.1.0",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not claim a module stopped when configuration changed but the process did not", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration()),
|
|
configuration(["announcements", false]),
|
|
);
|
|
|
|
const changed = view.find((entry) => entry.moduleId === "announcements");
|
|
// The honest triple: configured off, still running, restart pending. Reporting `disabled` here
|
|
// would be a lie about a process that is still serving that module's routes.
|
|
expect(changed?.configured).toEqual({ enabled: false, explicit: true });
|
|
expect(changed?.runtime).toEqual({ state: "running" });
|
|
expect(changed?.restartRequired).toBe(true);
|
|
// An unrelated module is untouched.
|
|
expect(view.find((entry) => entry.moduleId === "storage")?.restartRequired).toBe(false);
|
|
});
|
|
|
|
it("marks restart pending for a dependent too, because it really will stop on the next boot", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration()),
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(view.map((entry) => [entry.moduleId, entry.restartRequired])).toEqual([
|
|
["storage", true],
|
|
["announcements", true],
|
|
]);
|
|
expect(view.every((entry) => entry.runtime.state === "running")).toBe(true);
|
|
});
|
|
|
|
it("marks restart pending in the other direction, without claiming the module started", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration(["announcements", false])),
|
|
configuration(["announcements", true]),
|
|
);
|
|
|
|
const changed = view.find((entry) => entry.moduleId === "announcements");
|
|
expect(changed?.configured).toEqual({ enabled: true, explicit: true });
|
|
expect(changed?.runtime).toEqual({ state: "disabled" });
|
|
expect(changed?.restartRequired).toBe(true);
|
|
});
|
|
|
|
it("explains a blocked module with the boot-time reason, not a recomputed one", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration(["storage", false])),
|
|
configuration(["storage", false]),
|
|
);
|
|
|
|
expect(view.find((entry) => entry.moduleId === "storage")?.runtime).toEqual({
|
|
state: "disabled",
|
|
});
|
|
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime).toEqual({
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
state: "blocked",
|
|
});
|
|
expect(view.every((entry) => entry.restartRequired === false)).toBe(true);
|
|
});
|
|
|
|
it("reports what the runtime actually started, not what eligibility predicted", () => {
|
|
// A snapshot whose started set disagrees with its own eligibility is not reachable while
|
|
// startup stays all-or-nothing. It is constructed here deliberately: the read model must be an
|
|
// observation of the runtime, so if that guarantee ever changes this can never silently report
|
|
// a module as `running` because it was merely eligible.
|
|
const eligible = resolveModuleEligibility(discoveredModules, configuration());
|
|
const view = resolveModuleLifecycleView(
|
|
{
|
|
discoveredModules,
|
|
eligibility: eligible.entries,
|
|
startedModuleIds: ["storage"],
|
|
},
|
|
configuration(),
|
|
);
|
|
|
|
expect(view.find((entry) => entry.moduleId === "storage")?.runtime.state).toBe("running");
|
|
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime.state).toBe("blocked");
|
|
expect(view.find((entry) => entry.moduleId === "announcements")?.restartRequired).toBe(true);
|
|
});
|
|
|
|
it("ignores a stored record for an undeployed module rather than inventing a row for it", () => {
|
|
const view = resolveModuleLifecycleView(
|
|
snapshotFor(configuration()),
|
|
configuration(["removed-long-ago", false]),
|
|
);
|
|
|
|
expect(view.map((entry) => entry.moduleId)).toEqual(["storage", "announcements"]);
|
|
});
|
|
});
|