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]>
192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
type DoctorDependencies,
|
|
diagnoseEkklesia,
|
|
formatDoctorReport,
|
|
} from "../src/diagnostics/index.js";
|
|
import type { MigrationDatabase } from "../src/migrations/index.js";
|
|
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
|
|
import { composePlatformPermissionRegistry } from "../src/modules/permissions.js";
|
|
|
|
const originalNoColor = process.env.NO_COLOR;
|
|
|
|
afterEach(() => {
|
|
if (originalNoColor === undefined) {
|
|
delete process.env.NO_COLOR;
|
|
} else {
|
|
process.env.NO_COLOR = originalNoColor;
|
|
}
|
|
});
|
|
|
|
function zeroSchemaAggregate(): ModuleSchemaAggregate {
|
|
return Object.freeze({
|
|
discoveredModules: Object.freeze([]),
|
|
drizzleSchema: Object.freeze({}),
|
|
modules: Object.freeze([]),
|
|
tables: Object.freeze([]),
|
|
});
|
|
}
|
|
|
|
function zeroMigrationAggregate(): ModuleMigrationAggregate {
|
|
return Object.freeze({
|
|
discoveredModules: Object.freeze([]),
|
|
migrations: Object.freeze([]),
|
|
modules: Object.freeze([]),
|
|
});
|
|
}
|
|
|
|
function noConfigurationDependencies(): DoctorDependencies {
|
|
return {
|
|
composePermissions: composePlatformPermissionRegistry,
|
|
createMigrationDatabase: () => {
|
|
throw new Error("database must not be constructed");
|
|
},
|
|
discoverMigrations: async () => zeroMigrationAggregate(),
|
|
discoverModules: async () => Object.freeze([]),
|
|
discoverSchemas: async () => zeroSchemaAggregate(),
|
|
planMigrations: async () => {
|
|
throw new Error("database must not be planned");
|
|
},
|
|
readModuleLifecycle: async () => {
|
|
throw new Error("module lifecycle configuration must not be read");
|
|
},
|
|
runtimeSatisfies: () => true,
|
|
};
|
|
}
|
|
|
|
describe("Ekklesia Doctor terminal report", () => {
|
|
it("renders the real zero-module and no-database shape exactly", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
noConfigurationDependencies(),
|
|
);
|
|
|
|
expect(formatDoctorReport(report).join("\n")).toBe(`Ekklesia Doctor
|
|
|
|
Platform
|
|
✓ Runtime version supported
|
|
Active Node.js satisfies the declared supported range.
|
|
|
|
Modules
|
|
✓ Module discovery complete
|
|
0 modules installed
|
|
✓ Dependency graph valid
|
|
Dependency order is empty.
|
|
✓ Permission registry composed
|
|
4 permissions registered
|
|
○ Module lifecycle skipped
|
|
DATABASE_URL is not configured.
|
|
|
|
Data definitions
|
|
✓ Schemas validated
|
|
0 modules · 0 tables
|
|
✓ Migrations validated
|
|
0 migrations · checksums valid
|
|
|
|
PostgreSQL
|
|
✓ Database TLS verified
|
|
TLS required with certificate and hostname verification.
|
|
○ Connection check skipped
|
|
DATABASE_URL is not configured.
|
|
○ Migration plan skipped
|
|
A validated migration inventory and configured PostgreSQL connection are required.
|
|
|
|
Identity
|
|
○ Identity configuration skipped
|
|
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
|
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
|
✓ Direct client-address mode
|
|
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
|
|
|
Object storage
|
|
○ Check unavailable
|
|
Core storage integration has not been implemented yet.
|
|
|
|
Result: READY WITH WARNINGS`);
|
|
});
|
|
|
|
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
noConfigurationDependencies(),
|
|
);
|
|
const baseline = formatDoctorReport(report);
|
|
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
|
|
try {
|
|
process.env.NO_COLOR = "1";
|
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
|
const interactiveWithoutColor = formatDoctorReport(report);
|
|
process.env.NO_COLOR = "";
|
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
|
|
const nonInteractive = formatDoctorReport(report);
|
|
|
|
expect(interactiveWithoutColor).toEqual(baseline);
|
|
expect(nonInteractive).toEqual(baseline);
|
|
expect(interactiveWithoutColor.some((line) => line.includes("\u001B"))).toBe(false);
|
|
expect(Object.isFrozen(interactiveWithoutColor)).toBe(true);
|
|
} finally {
|
|
if (originalIsTtyDescriptor) {
|
|
Object.defineProperty(process.stdout, "isTTY", originalIsTtyDescriptor);
|
|
} else {
|
|
delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never renders secrets from a hostile database exception", async () => {
|
|
const hostile =
|
|
"postgresql://admin:[email protected]/db SELECT password FROM users /home/me/.env";
|
|
const database = Object.freeze({}) as MigrationDatabase;
|
|
const dependencies: DoctorDependencies = {
|
|
...noConfigurationDependencies(),
|
|
createMigrationDatabase: () => database,
|
|
planMigrations: async () => {
|
|
throw new Error(hostile);
|
|
},
|
|
};
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
databaseUrl: "postgresql://another:[email protected]/db",
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
dependencies,
|
|
);
|
|
const output = formatDoctorReport(report).join("\n");
|
|
|
|
for (const forbidden of [
|
|
"admin",
|
|
"secret",
|
|
"host.invalid",
|
|
"SELECT",
|
|
"password",
|
|
"/home/me",
|
|
"postgresql://",
|
|
]) {
|
|
expect(output).not.toContain(forbidden);
|
|
}
|
|
});
|
|
});
|