feat: add the minimal operational module lifecycle
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s

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]>
This commit is contained in:
ekklesia
2026-08-22 14:45:34 -05:00
co-authored by Claude Opus 5
parent db67cf7700
commit 7729c99e24
52 changed files with 5311 additions and 116 deletions
+21 -4
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
CORE_PERMISSION_DECLARATIONS,
CORE_PERMISSION_REGISTRY,
@@ -110,11 +111,22 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(declaration?.id).toBe("core.dashboard.navigation.manage");
});
it("contains exactly these three permissions and no others", () => {
it("registers core.modules.lifecycle.manage as restricted", () => {
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_LIFECYCLE_MANAGE);
expect(declaration).toBeDefined();
// Deciding which installed modules run is administrative, and so is *reading* that: the
// lifecycle view is the only place a disabled or blocked module is visible at all, unlike the
// merely-authenticated mountable inventory (ADR 0028).
expect(declaration?.baseline).toBe("restricted");
expect(declaration?.id).toBe("core.modules.lifecycle.manage");
});
it("contains exactly these four permissions and no others", () => {
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
]);
});
});
@@ -137,11 +149,16 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
},
{
baseline: "restricted",
description: "Read and change which installed modules are enabled.",
id: CORE_MODULES_LIFECYCLE_MANAGE,
},
]);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[2])).toBe(true);
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
expect(Object.isFrozen(declaration)).toBe(true);
}
});
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
@@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -228,6 +229,7 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_dashboard_placement",
"ekklesia_core_grant",
"ekklesia_core_migrations",
"ekklesia_core_module_state",
"ekklesia_core_platform_owner",
"ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session",
@@ -173,6 +173,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
+6 -1
View File
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
planMigrations: async () => {
throw new Error("database must not be planned");
},
readModuleLifecycle: async () => {
throw new Error("module lifecycle configuration must not be read");
},
runtimeSatisfies: () => true,
};
}
@@ -78,7 +81,9 @@ Modules
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
3 permissions registered
4 permissions registered
○ Module lifecycle skipped
DATABASE_URL is not configured.
Data definitions
✓ Schemas validated
+172
View File
@@ -137,6 +137,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
discoverModules: vi.fn(async () => Object.freeze([])),
discoverSchemas: vi.fn(async () => schemaAggregate()),
planMigrations: vi.fn(async () => plan()),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
runtimeSatisfies: vi.fn(() => true),
...overrides,
};
@@ -204,6 +205,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"modules.discovery",
"modules.dependencies",
"modules.permissions",
"modules.lifecycle",
"schemas.discovery",
"migrations.discovery",
"database.transport",
@@ -844,3 +846,173 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
});
});
const databaseUrl = "postgresql://not-rendered.invalid/ekklesia";
describe("Ekklesia Doctor — module lifecycle", () => {
/** A discovered module double with a real required-dependency edge, so blocking is exercised
* through the same resolver production uses rather than a hand-written eligibility record. */
function dependentModule(id: string, requires: string): DiscoveredModule {
const base = discoveredModule(id);
return {
...base,
manifest: {
...base.manifest,
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: { [requires]: "^0.1.0" } },
},
} as unknown as ModuleManifest,
};
}
it("skips the check, rather than guessing a default, when DATABASE_URL is not configured", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
});
const report = await diagnoseEkklesia(options(), services);
expect(check(report, "modules.lifecycle").status).toBe("skipped");
expect(check(report, "modules.lifecycle").details).toEqual(["DATABASE_URL is not configured."]);
// Configuration lives in PostgreSQL; reporting "everything is enabled" without reading it would
// be a guess an operator could act on.
expect(services.readModuleLifecycle).not.toHaveBeenCalled();
});
it("skips the check when discovery itself failed", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => {
throw new Error("broken inventory");
}),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.lifecycle").status).toBe("skipped");
expect(check(report, "modules.lifecycle").details).toEqual([
"Complete module discovery is required.",
]);
});
it("passes and lists each module when everything enabled can run", async () => {
const services = dependencies({
discoverModules: vi.fn(async () =>
Object.freeze([discoveredModule("storage"), discoveredModule("calendar", "0.2.0")]),
),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.lifecycle").status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toEqual([
"2 eligible · 0 disabled · 0 blocked",
"storage 0.1.0 · enabled",
"calendar 0.2.0 · enabled",
]);
});
it("still passes when a module is deliberately disabled — a decision is not a problem", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
readModuleLifecycle: vi.fn(async () =>
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "calendar" }]) }),
),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.lifecycle").status).toBe("pass");
expect(check(report, "modules.lifecycle").details).toEqual([
"0 eligible · 1 disabled · 0 blocked",
"calendar 0.1.0 · disabled",
]);
});
it("distinguishes the platform default from a decision somebody made", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
// Nothing stored: the module is enabled by the platform default, and the detail says so.
expect(check(report, "modules.lifecycle").details).toContain("calendar 0.1.0 · enabled");
});
it("warns when an enabled module cannot run, naming the dependency an operator must act on", async () => {
const services = dependencies({
discoverModules: vi.fn(async () =>
Object.freeze([discoveredModule("storage"), dependentModule("announcements", "storage")]),
),
readModuleLifecycle: vi.fn(async () =>
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "storage" }]) }),
),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
const lifecycle = check(report, "modules.lifecycle");
// An enabled module that cannot run is the anomaly worth surfacing — and it is exactly the
// question an operator would otherwise take to the logs.
expect(lifecycle.status).toBe("warning");
expect(lifecycle.summary).toBe("1 enabled module cannot run");
expect(lifecycle.details).toEqual([
"0 eligible · 1 disabled · 1 blocked",
"storage 0.1.0 · disabled",
"announcements 0.1.0 · blocked · required_dependency_disabled · storage",
]);
expect(report.result).toBe("ready-with-warnings");
});
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
readModuleLifecycle: vi.fn(async () => {
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
}),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
const lifecycle = check(report, "modules.lifecycle");
expect(lifecycle.status).toBe("failure");
expect(lifecycle.details).toEqual([
"The stored module enablement configuration could not be read safely.",
]);
expect(JSON.stringify(lifecycle)).not.toContain("hunter2");
expect(JSON.stringify(lifecycle)).not.toContain("10.40.0.2");
});
it("reads with finite timeouts and the same validated transport policy every executable uses", async () => {
const services = dependencies({
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
});
await diagnoseEkklesia(options(databaseUrl), services);
expect(services.readModuleLifecycle).toHaveBeenCalledWith(
databaseUrl,
expect.objectContaining({
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
transport: expect.objectContaining({ mode: "verify-full" }),
}),
);
});
it("still reports lifecycle when an unrelated check fails, and never blocks on one", async () => {
const services = dependencies({
composePermissions: vi.fn(() => {
throw new Error("composition exploded");
}),
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
});
const report = await diagnoseEkklesia(options(databaseUrl), services);
expect(check(report, "modules.permissions").status).toBe("failure");
expect(check(report, "modules.lifecycle").status).toBe("pass");
});
});
@@ -0,0 +1,417 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
createModuleLifecycleStore,
createModuleLifecycleView,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "../src/modules/index.js";
import { parseModuleManifest } from "@ekklesia/contracts";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* The module lifecycle configuration store against real PostgreSQL.
*
* `core/tests/module-lifecycle.test.ts` proves the resolution rules with no database at all; this
* proves the things only a database can: that migration `0004` really creates the table with the
* constraints the schema declares, that a decision survives a completely new connection, that the
* upsert really is idempotent on `module_id`, that `enabled` has **no** column default (so the
* platform's default for a new module is genuinely the absence of a row, not a materialized one),
* and that a record for a module that is no longer deployed is retained and simply ignored. The
* in-memory double the API route tests use reproduces none of that, which is why it lives here.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The module lifecycle integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The module lifecycle integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 10);
databaseCounter += 1;
return `ekk_lifecycle_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure stays static when connection cleanup also fails.
}
throw harnessError("The module lifecycle integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(
path.join(tmpdir(), "ekklesia-lifecycle-no-modules-"),
);
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { force: true, recursive: true });
}
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
/** A minimal, in-memory `DiscoveredModule` double, built through `parseModuleManifest` so a fixture
* real discovery would reject can never stand in for one it would accept. Only `manifest` is read
* by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 4,
id,
name: `${id} module`,
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(dependencies ? { dependencies } : {}),
entrypoints: { 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}`,
};
}
describe.sequential("live Core module lifecycle store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: ModuleLifecycleStore;
let close: () => Promise<void>;
it("creates exactly the one lifecycle table, with its declared constraints and no enabled default", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const tables = await safeQuery<{ table_name: string }>(
admin,
`select table_name from information_schema.tables
where table_schema = 'public' and table_name like 'ekklesia_core_module%'
order by table_name`,
);
const columns = await safeQuery<{
column_name: string;
is_nullable: string;
column_default: string | null;
}>(
admin,
`select column_name, is_nullable, column_default
from information_schema.columns
where table_name = 'ekklesia_core_module_state'
order by column_name`,
);
// Only the explicitly declared CHECK constraint: PostgreSQL 17 also materializes a system
// `..._not_null` CHECK per NOT NULL column, which is not something this schema declares.
const checks = await safeQuery<{ constraint_name: string }>(
admin,
`select tc.constraint_name
from information_schema.table_constraints tc
where tc.table_name = 'ekklesia_core_module_state' and tc.constraint_type = 'CHECK'
and tc.constraint_name not like '%_not_null'
order by tc.constraint_name`,
);
expect(tables.map((table) => table.table_name)).toEqual(["ekklesia_core_module_state"]);
// Deliberately minimal: an identity, a decision, and two audit timestamps. No install source,
// download URL, update channel, signature, version, or JSON blob — the columns an installer
// would need are absent, which is what keeps one from being built here by accident.
expect(columns.map((column) => column.column_name)).toEqual([
"created_at",
"enabled",
"module_id",
"updated_at",
]);
const enabled = columns.find((column) => column.column_name === "enabled");
expect(enabled?.is_nullable).toBe("NO");
// The load-bearing assertion: a row exists only because somebody decided. The platform's
// default for a newly discovered module is the *absence* of a row, resolved in
// `resolveModuleEligibility` — never a column default that would materialize a decision
// nobody made.
expect(enabled?.column_default).toBeNull();
expect(checks.map((check) => check.constraint_name)).toEqual([
"ekklesia_core_module_state_module_check",
]);
});
it("reads an empty configuration from a freshly migrated database", async () => {
const view = createValidatedPostgresPool(database.url, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
});
close = view.close;
store = createModuleLifecycleStore(createModuleLifecycleView(view.pool));
expect(await store.readConfiguration()).toEqual({ modules: [] });
});
it("persists an explicit decision and reads it back over a completely new connection", async () => {
await store.setEnabled({ enabled: false, moduleId: "announcements" });
const separate = createValidatedPostgresPool(database.url, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
});
try {
const reread = await createModuleLifecycleStore(
createModuleLifecycleView(separate.pool),
).readConfiguration();
expect(reread.modules).toEqual([{ enabled: false, moduleId: "announcements" }]);
} finally {
await separate.close();
}
});
it("is idempotent on module_id: the same decision twice leaves exactly one row", async () => {
await store.setEnabled({ enabled: false, moduleId: "announcements" });
await store.setEnabled({ enabled: false, moduleId: "announcements" });
const rows = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from ekklesia_core_module_state where module_id = 'announcements'`,
);
expect(rows[0]?.count).toBe("1");
});
it("replaces a decision in place, and bumps updated_at rather than inserting a second row", async () => {
const before = await safeQuery<{ created_at: Date; updated_at: Date }>(
admin,
`select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`,
);
await store.setEnabled({ enabled: true, moduleId: "announcements" });
const after = await safeQuery<{ created_at: Date; updated_at: Date }>(
admin,
`select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`,
);
expect(after).toHaveLength(1);
expect(after[0]?.created_at).toEqual(before[0]?.created_at);
expect(after[0]?.updated_at.getTime()).toBeGreaterThanOrEqual(
before[0]?.updated_at.getTime() ?? 0,
);
expect((await store.readConfiguration()).modules).toEqual([
{ enabled: true, moduleId: "announcements" },
]);
});
it("returns records in deterministic module-id order", async () => {
await store.setEnabled({ enabled: false, moduleId: "video" });
await store.setEnabled({ enabled: true, moduleId: "calendar" });
expect((await store.readConfiguration()).modules.map((record) => record.moduleId)).toEqual([
"announcements",
"calendar",
"video",
]);
});
it("rejects a malformed module identifier before it ever reaches PostgreSQL", async () => {
await expect(store.setEnabled({ enabled: false, moduleId: "Not A Module" })).rejects.toThrow(
ModuleLifecycleStoreError,
);
await expect(store.setEnabled({ enabled: false, moduleId: "" })).rejects.toMatchObject({
code: "MODULE_LIFECYCLE_MODULE_ID_INVALID",
});
const rows = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from ekklesia_core_module_state`,
);
expect(rows[0]?.count).toBe("3");
});
it("rejects an empty module id at the database level too, beneath the application contract", async () => {
await expect(
safeQuery(
admin,
`insert into ekklesia_core_module_state (module_id, enabled) values ('', true)`,
),
).rejects.toThrow();
});
it("retains a record for a module that is no longer deployed, and resolution ignores it", async () => {
// The whole configuration, including the row for `video`, which is not in this inventory.
const configuration = await store.readConfiguration();
const deployed = [fakeModule("announcements"), fakeModule("calendar")];
const resolution = resolveModuleEligibility(deployed, configuration);
expect(resolution.entries.map((entry) => entry.moduleId)).toEqual([
"announcements",
"calendar",
]);
expect(resolution.eligibleModules.map((module) => module.manifest.id)).toEqual([
"announcements",
"calendar",
]);
// Retained, not deleted: redeploying `video` restores the decision with no new write.
expect(configuration.modules.some((record) => record.moduleId === "video")).toBe(true);
const redeployed = resolveModuleEligibility([...deployed, fakeModule("video")], configuration);
expect(redeployed.eligibleModules.map((module) => module.manifest.id)).toEqual([
"announcements",
"calendar",
]);
});
it("drives real dependency blocking from real stored rows", async () => {
await store.setEnabled({ enabled: false, moduleId: "storage" });
const resolution = resolveModuleEligibility(
[
fakeModule("storage"),
fakeModule("announcements", { modules: { required: { storage: "^1.0.0" } } }),
],
await store.readConfiguration(),
);
expect(resolution.eligibleModules).toEqual([]);
expect(resolution.entries.find((entry) => entry.moduleId === "announcements")?.blocked).toEqual(
{
dependency: { kind: "module", moduleId: "storage" },
reason: "required_dependency_disabled",
},
);
await close();
});
});
+529
View File
@@ -0,0 +1,529 @@
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: 4,
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: 4,
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"]);
});
});
+5 -2
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
CORE_PERMISSION_DECLARATIONS,
PermissionRegistryError,
@@ -69,8 +70,9 @@ describe("composePlatformPermissionRegistry", () => {
expect(registry.has("calendar.events.read")).toBe(true);
expect(registry.has("people.directory.read")).toBe(true);
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
// Core's own three declarations plus one from each module.
expect(registry.declarations).toHaveLength(5);
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
// Core's own four declarations plus one from each module.
expect(registry.declarations).toHaveLength(6);
});
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
@@ -88,6 +90,7 @@ describe("composePlatformPermissionRegistry", () => {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ,
CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
"calendar.events.read",
"calendar.events.write",
"people.directory.read",