feat: add the module configuration and secrets contract
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fdaad58848
commit
baa689b2a7
@@ -1,8 +1,11 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
isVersionCompatible,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
type ModuleSecretSource,
|
||||
permissionIdSchema,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -41,6 +44,8 @@ import {
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleConfigurationDatabase,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
type DiscoveredModule,
|
||||
@@ -49,6 +54,7 @@ import {
|
||||
type ModuleEligibility,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleLifecycleConfiguration,
|
||||
type ModuleMigrationAggregate,
|
||||
type ModuleSchemaAggregate,
|
||||
@@ -62,6 +68,7 @@ export type DoctorCheckId =
|
||||
| "modules.dependencies"
|
||||
| "modules.permissions"
|
||||
| "modules.lifecycle"
|
||||
| "modules.configuration"
|
||||
| "schemas.discovery"
|
||||
| "migrations.discovery"
|
||||
| "postgresql.connection"
|
||||
@@ -102,6 +109,10 @@ export interface DoctorOptions {
|
||||
readonly databaseUrl?: string | undefined;
|
||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
|
||||
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required
|
||||
* secret as missing, which is the honest answer for a deployment that has not set it. */
|
||||
readonly moduleSecrets?: ModuleSecretSource;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
@@ -128,6 +139,12 @@ export interface DoctorDependencies {
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
) => Promise<ModuleLifecycleConfiguration>;
|
||||
/** Reads the stored module configuration read-only, behind the same dependency boundary as every
|
||||
* other I/O Doctor performs. */
|
||||
readonly readModuleConfiguration: (
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
) => Promise<ModuleConfigurationSettings>;
|
||||
readonly planMigrations: (options: {
|
||||
readonly aggregate: ModuleMigrationAggregate;
|
||||
readonly database: MigrationDatabase;
|
||||
@@ -163,6 +180,20 @@ async function readModuleLifecycleForRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens its own short-lived connection, reads, and always closes it — the same discipline
|
||||
* `readModuleLifecycleForRuntime` follows. */
|
||||
async function readModuleConfigurationForRuntime(
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions,
|
||||
): Promise<ModuleConfigurationSettings> {
|
||||
const { close, db } = createModuleConfigurationDatabase(connectionString, options);
|
||||
try {
|
||||
return await createModuleConfigurationStore(db).readSettings();
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
composePermissions: composePlatformPermissionRegistry,
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
@@ -170,6 +201,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||
runtimeSatisfies: isVersionCompatible,
|
||||
});
|
||||
@@ -301,6 +333,16 @@ function safeLifecycleDetail(entry: ModuleEligibility): string {
|
||||
if (!entry.blocked) {
|
||||
return `${identity} · enabled`;
|
||||
}
|
||||
if (!("dependency" in entry.blocked)) {
|
||||
// A configuration block names the offending **key** and never its value: a key is declared in a
|
||||
// public manifest, while a value may be a credential or arbitrary operator-entered text. The key
|
||||
// is re-validated against the same grammar the manifest accepted it under, for the same
|
||||
// defense-in-depth reason every other interpolation here is.
|
||||
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
}
|
||||
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||
}
|
||||
const dependency = entry.blocked.dependency;
|
||||
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
||||
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
||||
@@ -343,6 +385,79 @@ function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorChec
|
||||
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether each installed module's configuration would actually let it run — and
|
||||
* deliberately reports **nothing about the values themselves**.
|
||||
*
|
||||
* Not one configuration value is printed, secret or otherwise. A value is operator-entered text that
|
||||
* may be an endpoint, a bucket name, or a credential that ended up in the wrong field; a *key* is
|
||||
* declared in a public manifest and is safe. So a healthy module renders as a count, and an unhealthy
|
||||
* one names the reason and the offending key. That is everything an operator needs to act, and
|
||||
* nothing that turns `pnpm doctor` output — which people paste into chat — into a disclosure channel.
|
||||
*/
|
||||
function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number): string {
|
||||
if (
|
||||
!moduleIdSchema.safeParse(entry.moduleId).success ||
|
||||
!semanticVersionSchema.safeParse(entry.version).success
|
||||
) {
|
||||
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||
}
|
||||
const identity = `${entry.moduleId} ${entry.version}`;
|
||||
if (declaredCount === 0) {
|
||||
return `${identity} · no configuration declared`;
|
||||
}
|
||||
const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`;
|
||||
if (entry.blocked && !("dependency" in entry.blocked)) {
|
||||
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
|
||||
throw new TypeError("Unsafe module configuration diagnostic value.");
|
||||
}
|
||||
return `${identity} · ${declared} · ${entry.blocked.reason} · ${entry.blocked.configurationKey}`;
|
||||
}
|
||||
if (!entry.configured.enabled) {
|
||||
// Not evaluated: a disabled module's configuration is not resolved at all, and claiming it is
|
||||
// valid would be asserting something nothing checked.
|
||||
return `${identity} · ${declared} · not evaluated (disabled)`;
|
||||
}
|
||||
return `${identity} · ${declared} · valid`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration counterpart to `modules.lifecycle`: which installed modules could not run
|
||||
* because of their own configuration, and which key is at fault.
|
||||
*
|
||||
* A module whose configuration is fine, or which declares none, is a `pass` line. A module blocked
|
||||
* by configuration is a `warning`, for the same reason a dependency-blocked module is: an enabled
|
||||
* module that cannot run is an anomaly an operator should see without reading logs.
|
||||
*/
|
||||
function moduleConfigurationCheck(
|
||||
entries: readonly ModuleEligibility[],
|
||||
declaredCounts: ReadonlyMap<string, number>,
|
||||
): DoctorCheck {
|
||||
const blockedByConfiguration = entries.filter(
|
||||
(entry) => entry.blocked !== undefined && !("dependency" in entry.blocked),
|
||||
).length;
|
||||
const declaringModules = entries.filter(
|
||||
(entry) => (declaredCounts.get(entry.moduleId) ?? 0) > 0,
|
||||
).length;
|
||||
const details = [
|
||||
`${declaringModules} ${plural(declaringModules, "module")} declaring configuration · ${blockedByConfiguration} blocked`,
|
||||
...entries.map((entry) =>
|
||||
safeConfigurationDetail(entry, declaredCounts.get(entry.moduleId) ?? 0),
|
||||
),
|
||||
];
|
||||
if (blockedByConfiguration > 0) {
|
||||
return freezeCheck(
|
||||
"modules.configuration",
|
||||
"warning",
|
||||
blockedByConfiguration === 1
|
||||
? "1 module cannot run with its current configuration"
|
||||
: `${blockedByConfiguration} modules cannot run with their current configuration`,
|
||||
details,
|
||||
);
|
||||
}
|
||||
return freezeCheck("modules.configuration", "pass", "Module configuration valid", details);
|
||||
}
|
||||
|
||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||
const details =
|
||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
||||
@@ -672,31 +787,61 @@ export async function diagnoseEkklesia(
|
||||
// operator asking "is this module running?" must still get an answer when an unrelated check
|
||||
// failed. It needs only a successful discovery and a configured database.
|
||||
let lifecycleCheck: DoctorCheck;
|
||||
let configurationCheck: DoctorCheck;
|
||||
const skippedConfiguration = (detail: string): DoctorCheck =>
|
||||
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
|
||||
if (!discoveredModules) {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
configurationCheck = skippedConfiguration("Complete module discovery is required.");
|
||||
} else if (typeof options.databaseUrl !== "string" || options.databaseUrl.trim() === "") {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
||||
"DATABASE_URL is not configured.",
|
||||
]);
|
||||
configurationCheck = skippedConfiguration("DATABASE_URL is not configured.");
|
||||
} else {
|
||||
try {
|
||||
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
||||
environment: options.databaseEnvironment ?? "production",
|
||||
});
|
||||
const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, {
|
||||
const poolOptions = {
|
||||
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
||||
operationTimeoutMs: doctorPostgresTimeoutMs,
|
||||
transport: lifecycleTransport,
|
||||
};
|
||||
// Both axes read and resolved in the one pass the platform itself uses, so Doctor can never
|
||||
// report a module as runnable that startup would block, or the reverse.
|
||||
// Sequential for the same reason `startApi` reads them sequentially: a deterministic failure
|
||||
// beats two tiny queries racing.
|
||||
const configuration = await dependencies.readModuleLifecycle(
|
||||
options.databaseUrl,
|
||||
poolOptions,
|
||||
);
|
||||
const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions);
|
||||
const resolution = resolveModuleEligibility(discoveredModules, configuration, {
|
||||
secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE,
|
||||
settings,
|
||||
});
|
||||
lifecycleCheck = moduleLifecycleCheck(
|
||||
resolveModuleEligibility(discoveredModules, configuration).entries,
|
||||
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
||||
configurationCheck = moduleConfigurationCheck(
|
||||
resolution.entries,
|
||||
new Map(
|
||||
discoveredModules.map(
|
||||
(module) => [module.manifest.id, module.manifest.configuration.length] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
||||
"The stored module enablement configuration could not be read safely.",
|
||||
]);
|
||||
configurationCheck = freezeCheck(
|
||||
"modules.configuration",
|
||||
"failure",
|
||||
"Module configuration unavailable",
|
||||
["The stored module configuration could not be read safely."],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +940,7 @@ export async function diagnoseEkklesia(
|
||||
dependencyCheck,
|
||||
permissionsCheck,
|
||||
lifecycleCheck,
|
||||
configurationCheck,
|
||||
schemaCheck,
|
||||
migrationCheck,
|
||||
transportCheck,
|
||||
|
||||
Reference in New Issue
Block a user