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]>
197 lines
6.2 KiB
TypeScript
197 lines
6.2 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");
|
|
},
|
|
readModuleConfiguration: async () => {
|
|
throw new Error("module configuration must not be read");
|
|
},
|
|
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
|
|
5 permissions registered
|
|
○ Module lifecycle skipped
|
|
DATABASE_URL is not configured.
|
|
○ Module configuration 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);
|
|
}
|
|
});
|
|
});
|