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]>
808 lines
31 KiB
TypeScript
808 lines
31 KiB
TypeScript
import path from "node:path";
|
|
import {
|
|
capabilityIdSchema,
|
|
isVersionCompatible,
|
|
moduleIdSchema,
|
|
permissionIdSchema,
|
|
semanticVersionSchema,
|
|
} from "@ekklesia/contracts";
|
|
import { getTableName } from "drizzle-orm";
|
|
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
|
import {
|
|
assertProductionDatabaseTransport,
|
|
type DatabaseTransportEnvironment,
|
|
type DatabaseTransportPolicyInput,
|
|
DatabaseTransportError,
|
|
createDatabaseTransportPolicy,
|
|
describeDatabaseTransport,
|
|
} from "../database/transport.js";
|
|
import {
|
|
type ClientAddressConfigInput,
|
|
ClientAddressConfigError,
|
|
createClientAddressConfig,
|
|
} from "../identity/client-address.js";
|
|
import {
|
|
type CreateIdentityConfigOptions,
|
|
createIdentityConfig,
|
|
IdentityConfigError,
|
|
} from "../identity/config.js";
|
|
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
|
import { identityAdapterSchema } from "../identity/schema.js";
|
|
import type { PostgresPoolOptions } from "../database/pool.js";
|
|
import {
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
type MigrationDatabase,
|
|
type MigrationPlanResult,
|
|
MigrationRunnerError,
|
|
PostgresMigrationDatabaseError,
|
|
type PostgresMigrationDatabaseOptions,
|
|
planModuleMigrations,
|
|
} from "../migrations/index.js";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleLifecycleDatabase,
|
|
createModuleLifecycleStore,
|
|
type DiscoveredModule,
|
|
discoverModuleSchemas,
|
|
discoverModules,
|
|
type ModuleEligibility,
|
|
ModulePermissionCompositionError,
|
|
type ModuleDiscoveryOptions,
|
|
type ModuleLifecycleConfiguration,
|
|
type ModuleMigrationAggregate,
|
|
type ModuleSchemaAggregate,
|
|
resolveModuleEligibility,
|
|
} from "../modules/index.js";
|
|
|
|
export type DoctorCheckId =
|
|
| "database.transport"
|
|
| "platform.runtime"
|
|
| "modules.discovery"
|
|
| "modules.dependencies"
|
|
| "modules.permissions"
|
|
| "modules.lifecycle"
|
|
| "schemas.discovery"
|
|
| "migrations.discovery"
|
|
| "postgresql.connection"
|
|
| "postgresql.plan"
|
|
| "identity.configuration"
|
|
| "identity.client-address"
|
|
| "object-storage.integration";
|
|
|
|
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
|
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
|
|
|
export interface DoctorCheck {
|
|
readonly details: readonly string[];
|
|
readonly id: DoctorCheckId;
|
|
readonly status: DoctorCheckStatus;
|
|
readonly summary: string;
|
|
}
|
|
|
|
export interface DoctorReport {
|
|
readonly checks: readonly DoctorCheck[];
|
|
readonly result: DoctorResult;
|
|
}
|
|
|
|
export interface DoctorIdentityConfigurationOptions {
|
|
readonly baseUrl?: string | undefined;
|
|
readonly environment?: CreateIdentityConfigOptions["environment"];
|
|
readonly publicSignupEnabled?: string | undefined;
|
|
readonly secret?: string | undefined;
|
|
readonly trustedOrigins?: readonly string[] | undefined;
|
|
}
|
|
|
|
export interface DoctorOptions {
|
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
|
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
|
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
|
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
|
readonly databaseTransport?: DatabaseTransportPolicyInput | undefined;
|
|
readonly databaseUrl?: string | undefined;
|
|
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
|
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
|
readonly runtimeRange: string;
|
|
readonly runtimeVersion: string;
|
|
}
|
|
|
|
export interface DoctorDependencies {
|
|
/** Synchronous, matching the real implementation: pure in-memory composition over an
|
|
* already-discovered module list, never re-discovering modules itself. */
|
|
readonly composePermissions: (modules: readonly DiscoveredModule[]) => PermissionRegistry;
|
|
readonly createMigrationDatabase: (
|
|
connectionString: string,
|
|
options: PostgresMigrationDatabaseOptions,
|
|
) => MigrationDatabase;
|
|
readonly discoverMigrations: (
|
|
options: ModuleDiscoveryOptions,
|
|
) => Promise<ModuleMigrationAggregate>;
|
|
readonly discoverModules: (
|
|
options: ModuleDiscoveryOptions,
|
|
) => Promise<readonly DiscoveredModule[]>;
|
|
readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise<ModuleSchemaAggregate>;
|
|
/** Reads the stored module enablement configuration read-only. Separated behind the dependency
|
|
* boundary like every other I/O Doctor performs, so the whole report stays exercisable without a
|
|
* database. */
|
|
readonly readModuleLifecycle: (
|
|
connectionString: string,
|
|
options: PostgresPoolOptions,
|
|
) => Promise<ModuleLifecycleConfiguration>;
|
|
readonly planMigrations: (options: {
|
|
readonly aggregate: ModuleMigrationAggregate;
|
|
readonly database: MigrationDatabase;
|
|
}) => Promise<MigrationPlanResult>;
|
|
readonly runtimeSatisfies: (version: string, range: string) => boolean;
|
|
}
|
|
|
|
const doctorPostgresTimeoutMs = 10_000;
|
|
const checksumPattern = /^[a-f0-9]{64}$/u;
|
|
const migrationIdentityPattern =
|
|
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
|
|
|
async function discoverAllMigrationsForRuntime(
|
|
options: ModuleDiscoveryOptions,
|
|
): Promise<ModuleMigrationAggregate> {
|
|
return discoverAllMigrations({
|
|
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
|
|
moduleDiscovery: options,
|
|
});
|
|
}
|
|
|
|
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
|
|
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
|
|
async function readModuleLifecycleForRuntime(
|
|
connectionString: string,
|
|
options: PostgresPoolOptions,
|
|
): Promise<ModuleLifecycleConfiguration> {
|
|
const { close, db } = createModuleLifecycleDatabase(connectionString, options);
|
|
try {
|
|
return await createModuleLifecycleStore(db).readConfiguration();
|
|
} finally {
|
|
await close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
|
composePermissions: composePlatformPermissionRegistry,
|
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
|
discoverMigrations: discoverAllMigrationsForRuntime,
|
|
discoverModules,
|
|
discoverSchemas: discoverModuleSchemas,
|
|
planMigrations: planModuleMigrations,
|
|
readModuleLifecycle: readModuleLifecycleForRuntime,
|
|
runtimeSatisfies: isVersionCompatible,
|
|
});
|
|
|
|
function plural(count: number, singular: string): string {
|
|
return count === 1 ? singular : `${singular}s`;
|
|
}
|
|
|
|
function freezeCheck(
|
|
id: DoctorCheckId,
|
|
status: DoctorCheckStatus,
|
|
summary: string,
|
|
details: readonly string[] = [],
|
|
): DoctorCheck {
|
|
return Object.freeze({
|
|
details: Object.freeze([...details]),
|
|
id,
|
|
status,
|
|
summary,
|
|
});
|
|
}
|
|
|
|
export function deriveDoctorResult(checks: readonly DoctorCheck[]): DoctorResult {
|
|
if (checks.some((check) => check.status === "failure")) {
|
|
return "not-ready";
|
|
}
|
|
if (checks.some((check) => check.status === "warning" || check.status === "skipped")) {
|
|
return "ready-with-warnings";
|
|
}
|
|
return "ready";
|
|
}
|
|
|
|
function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
|
const frozenChecks = Object.freeze([...checks]);
|
|
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
|
}
|
|
|
|
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
|
return modules.map((module) => {
|
|
const id = module.manifest.id;
|
|
const version = module.manifest.version;
|
|
if (
|
|
!moduleIdSchema.safeParse(id).success ||
|
|
!semanticVersionSchema.safeParse(version).success
|
|
) {
|
|
throw new TypeError("Unsafe module diagnostic value.");
|
|
}
|
|
return `${id} ${version}`;
|
|
});
|
|
}
|
|
|
|
function safeMigrationReference(identity: string, checksum: string): string {
|
|
if (!migrationIdentityPattern.test(identity) || !checksumPattern.test(checksum)) {
|
|
throw new TypeError("Unsafe migration diagnostic value.");
|
|
}
|
|
return `${identity} · ${checksum.slice(0, 12)}`;
|
|
}
|
|
|
|
function safeModuleId(value: string): string {
|
|
if (!moduleIdSchema.safeParse(value).success) {
|
|
throw new TypeError("Unsafe module diagnostic value.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function safePermissionId(value: string): string {
|
|
if (!permissionIdSchema.safeParse(value).success) {
|
|
throw new TypeError("Unsafe permission diagnostic value.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Distinguishes the two composition-error families rather than describing a reserved `core.*`
|
|
* declaration as a generic duplicate. Never includes a module directory, manifest path, caught
|
|
* cause, or stack — only the module id and permission id the structured errors already carry,
|
|
* each re-validated before being interpolated (defense in depth against a future drift bug,
|
|
* matching `safeModuleDetails`/`safeMigrationReference` elsewhere in this file); if that
|
|
* re-validation itself fails, this falls back to a fixed generic detail rather than throwing. */
|
|
function permissionCompositionFailure(error: unknown): DoctorCheck {
|
|
if (error instanceof ModulePermissionCompositionError) {
|
|
try {
|
|
const moduleId = safeModuleId(error.moduleId);
|
|
const permissionId = safePermissionId(error.permissionId);
|
|
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
|
|
`Module "${moduleId}" declared permission "${permissionId}" in the reserved core.* namespace.`,
|
|
]);
|
|
} catch {
|
|
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
|
|
"A module declared a permission in the reserved core.* namespace.",
|
|
]);
|
|
}
|
|
}
|
|
if (error instanceof PermissionRegistryError) {
|
|
try {
|
|
const permissionId = safePermissionId(error.permissionId);
|
|
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
|
|
`Permission "${permissionId}" is declared more than once.`,
|
|
]);
|
|
} catch {
|
|
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
|
|
"A permission is declared more than once.",
|
|
]);
|
|
}
|
|
}
|
|
return freezeCheck("modules.permissions", "failure", "Permission composition failed", [
|
|
"The installed permission declarations could not be composed safely.",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Renders one module's operational state in a form that is safe by construction: only a module id, a
|
|
* semantic version, a fixed state word, a fixed reason code from a closed set, and — for a
|
|
* capability edge — a capability id. Identity values are re-validated before interpolation, exactly
|
|
* like `safeModuleDetails` and `safeMigrationReference` do, so a future drift bug cannot turn this
|
|
* into a disclosure channel. No filesystem path, package name, manifest path, or caught error can
|
|
* reach this line, because none of them is on the record it reads.
|
|
*/
|
|
function safeLifecycleDetail(entry: ModuleEligibility): string {
|
|
if (
|
|
!moduleIdSchema.safeParse(entry.moduleId).success ||
|
|
!semanticVersionSchema.safeParse(entry.version).success
|
|
) {
|
|
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
|
}
|
|
const identity = `${entry.moduleId} ${entry.version}`;
|
|
if (!entry.configured.enabled) {
|
|
return `${identity} · disabled${entry.configured.explicit ? "" : " (default)"}`;
|
|
}
|
|
if (!entry.blocked) {
|
|
return `${identity} · enabled`;
|
|
}
|
|
const dependency = entry.blocked.dependency;
|
|
if (!moduleIdSchema.safeParse(dependency.moduleId).success) {
|
|
throw new TypeError("Unsafe module lifecycle diagnostic value.");
|
|
}
|
|
const target =
|
|
dependency.kind === "capability" &&
|
|
dependency.capabilityId !== undefined &&
|
|
capabilityIdSchema.safeParse(dependency.capabilityId).success
|
|
? `${dependency.capabilityId} (${dependency.moduleId})`
|
|
: dependency.moduleId;
|
|
return `${identity} · blocked · ${entry.blocked.reason} · ${target}`;
|
|
}
|
|
|
|
/**
|
|
* Reports what an operator most wants to know before anything else: which installed modules this
|
|
* deployment would actually run, and why any of them would not.
|
|
*
|
|
* Configuration lives in PostgreSQL, so this check is honestly skipped when `DATABASE_URL` is not
|
|
* configured rather than reporting a default that might not be what is stored. A **disabled** module
|
|
* is a `pass` with a detail line: it is a decision somebody made, not a problem. A **blocked** module
|
|
* is a `warning`: an enabled module that cannot run is an anomaly worth surfacing, and it is exactly
|
|
* the question "why is this module unavailable?" that would otherwise send an operator to the logs.
|
|
*/
|
|
function moduleLifecycleCheck(entries: readonly ModuleEligibility[]): DoctorCheck {
|
|
const disabled = entries.filter((entry) => !entry.configured.enabled).length;
|
|
const blocked = entries.filter((entry) => entry.blocked !== undefined).length;
|
|
const eligible = entries.filter((entry) => entry.eligible).length;
|
|
const details = [
|
|
`${eligible} eligible · ${disabled} disabled · ${blocked} blocked`,
|
|
...entries.map(safeLifecycleDetail),
|
|
];
|
|
if (blocked > 0) {
|
|
return freezeCheck(
|
|
"modules.lifecycle",
|
|
"warning",
|
|
blocked === 1 ? "1 enabled module cannot run" : `${blocked} enabled modules cannot run`,
|
|
details,
|
|
);
|
|
}
|
|
return freezeCheck("modules.lifecycle", "pass", "Module lifecycle resolved", details);
|
|
}
|
|
|
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
|
const details =
|
|
error instanceof Error && error.name === "ModuleDiscoveryError"
|
|
? ["The installed module inventory is invalid."]
|
|
: ["Static module discovery could not be completed."];
|
|
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
|
}
|
|
|
|
function schemaDiscoveryFailure(): DoctorCheck {
|
|
return freezeCheck("schemas.discovery", "failure", "Schema validation failed", [
|
|
"Installed module schemas could not be validated.",
|
|
]);
|
|
}
|
|
|
|
function migrationDiscoveryFailure(): DoctorCheck {
|
|
return freezeCheck("migrations.discovery", "failure", "Migration validation failed", [
|
|
"Installed module migrations could not be validated.",
|
|
]);
|
|
}
|
|
|
|
function configuredDatabaseFailure(error: unknown): readonly [DoctorCheck, DoctorCheck] {
|
|
if (error instanceof MigrationRunnerError) {
|
|
if (error.code === "MIGRATION_CONNECTION_FAILED") {
|
|
return [
|
|
freezeCheck("postgresql.connection", "failure", "Connection failed", [
|
|
"The configured PostgreSQL database could not be reached.",
|
|
]),
|
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
|
"A successful PostgreSQL connection is required.",
|
|
]),
|
|
];
|
|
}
|
|
|
|
return [
|
|
freezeCheck("postgresql.connection", "pass", "Connection established"),
|
|
freezeCheck("postgresql.plan", "failure", "Migration history validation failed", [
|
|
migrationPlanFailureDetail(error.code),
|
|
]),
|
|
];
|
|
}
|
|
|
|
if (error instanceof PostgresMigrationDatabaseError) {
|
|
return [
|
|
freezeCheck("postgresql.connection", "failure", "PostgreSQL configuration invalid", [
|
|
"The configured database connection could not be prepared safely.",
|
|
]),
|
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
|
"A valid PostgreSQL configuration is required.",
|
|
]),
|
|
];
|
|
}
|
|
|
|
return [
|
|
freezeCheck("postgresql.connection", "failure", "PostgreSQL diagnostic failed", [
|
|
"The configured database could not be inspected safely.",
|
|
]),
|
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
|
"PostgreSQL connectivity could not be confirmed.",
|
|
]),
|
|
];
|
|
}
|
|
|
|
function migrationPlanFailureDetail(code: MigrationRunnerError["code"]): string {
|
|
switch (code) {
|
|
case "MIGRATION_CHECKSUM_MISMATCH":
|
|
return "An applied migration checksum disagrees with packaged inventory.";
|
|
case "MIGRATION_HISTORY_INVALID":
|
|
return "Applied migration history is invalid.";
|
|
case "MIGRATION_HISTORY_READ_FAILED":
|
|
return "Applied migration history could not be read safely.";
|
|
case "MIGRATION_UNKNOWN_APPLIED":
|
|
return "Applied migration history contains an unknown identity.";
|
|
case "MIGRATION_CLEANUP_FAILED":
|
|
return "The read-only PostgreSQL session did not close cleanly.";
|
|
default:
|
|
return "The read-only migration plan could not be completed safely.";
|
|
}
|
|
}
|
|
|
|
function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
|
const pendingCount = plan.entries.filter((entry) => entry.status === "pending").length;
|
|
const blockedCount = plan.entries.filter((entry) => entry.status === "approval-blocked").length;
|
|
const appliedCount = plan.entries.filter((entry) => entry.status === "applied").length;
|
|
|
|
if (
|
|
appliedCount !== plan.appliedCount ||
|
|
pendingCount + blockedCount !== plan.pendingCount ||
|
|
blockedCount !== plan.approvalRequiredCount
|
|
) {
|
|
throw new TypeError("Inconsistent migration plan diagnostic value.");
|
|
}
|
|
|
|
const details = [
|
|
`${appliedCount} applied · ${pendingCount} pending · ${blockedCount} approval-blocked`,
|
|
...plan.entries.map((entry) => {
|
|
if (!(["applied", "approval-blocked", "pending"] as const).includes(entry.status)) {
|
|
throw new TypeError("Unsafe migration plan diagnostic value.");
|
|
}
|
|
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
|
}),
|
|
];
|
|
|
|
if (pendingCount + blockedCount === 0) {
|
|
return freezeCheck("postgresql.plan", "pass", "Migration history current", details);
|
|
}
|
|
|
|
const attentionCount = pendingCount + blockedCount;
|
|
return freezeCheck(
|
|
"postgresql.plan",
|
|
"warning",
|
|
attentionCount === 1
|
|
? "1 migration requires attention"
|
|
: `${attentionCount} migrations require attention`,
|
|
details,
|
|
);
|
|
}
|
|
|
|
function identitySchemaSummary(): string {
|
|
const tableNames = Object.values(identityAdapterSchema)
|
|
.map((table) => getTableName(table))
|
|
.sort();
|
|
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
|
}
|
|
|
|
/**
|
|
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
|
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
|
* configuration is a skip, not a failure — Doctor does not require identity to be configured yet.
|
|
*/
|
|
function identityConfigurationCheck(
|
|
input: DoctorIdentityConfigurationOptions | undefined,
|
|
): DoctorCheck {
|
|
const schemaDetail = identitySchemaSummary();
|
|
const hasAnyValue =
|
|
input?.secret !== undefined ||
|
|
input?.baseUrl !== undefined ||
|
|
input?.publicSignupEnabled !== undefined ||
|
|
(input?.trustedOrigins?.length ?? 0) > 0;
|
|
|
|
if (!hasAnyValue) {
|
|
return freezeCheck("identity.configuration", "skipped", "Identity configuration skipped", [
|
|
schemaDetail,
|
|
"Identity runtime configuration (signing key, base URL, trusted origins) is not set.",
|
|
]);
|
|
}
|
|
|
|
try {
|
|
const config = createIdentityConfig(
|
|
{
|
|
baseUrl: input?.baseUrl,
|
|
publicSignupEnabled: input?.publicSignupEnabled,
|
|
secret: input?.secret,
|
|
trustedOrigins: input?.trustedOrigins,
|
|
},
|
|
{ environment: input?.environment ?? "production" },
|
|
);
|
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
|
schemaDetail,
|
|
config.publicSignupEnabled
|
|
? "Public signup is explicitly ENABLED."
|
|
: "Public signup is disabled (safe default).",
|
|
]);
|
|
} catch (error) {
|
|
const code =
|
|
error instanceof IdentityConfigError ? error.code : "IDENTITY_CONFIGURATION_INVALID";
|
|
return freezeCheck("identity.configuration", "failure", "Identity configuration invalid", [
|
|
schemaDetail,
|
|
`[${code}] Configured identity values failed validation.`,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pure configuration validation only, mirroring `identityConfigurationCheck`: never resolves a real
|
|
* peer address or reads a real request. Omitted configuration is a `pass`, not a skip — direct mode
|
|
* (trusting only the actual immediate socket peer) is itself a complete, safe default, unlike
|
|
* identity configuration, which has no meaningful default at all. An explicitly configured
|
|
* trusted-proxy mode reports its header name and trusted-proxy count; an invalid or contradictory
|
|
* combination is a `failure`, distinguishing the three states this pass's audit specifically calls
|
|
* for: safe direct mode, explicitly configured proxy mode, and an invalid configuration.
|
|
*/
|
|
/**
|
|
* Pure configuration validation only: never opens a connection, and never prints the database URL,
|
|
* host, port, database name, credentials, certificate, or CA material. An unsafe production
|
|
* transport is a blocking `failure`, not a warning — a production deployment that would reach
|
|
* PostgreSQL without certificate-verified TLS is not "ready with warnings".
|
|
*/
|
|
function databaseTransportCheck(
|
|
input: DatabaseTransportPolicyInput | undefined,
|
|
databaseUrl: string | undefined,
|
|
environment: DatabaseTransportEnvironment | undefined,
|
|
): DoctorCheck {
|
|
const effectiveEnvironment = environment ?? "production";
|
|
try {
|
|
const policy = createDatabaseTransportPolicy(input, { environment: effectiveEnvironment });
|
|
if (typeof databaseUrl === "string" && databaseUrl.trim() !== "") {
|
|
// Also rejects a URL carrying TLS parameters that could override the policy.
|
|
assertProductionDatabaseTransport(databaseUrl, policy, effectiveEnvironment);
|
|
}
|
|
return freezeCheck(
|
|
"database.transport",
|
|
"pass",
|
|
policy.mode === "verify-full"
|
|
? "Database TLS verified"
|
|
: "Database TLS disabled (development)",
|
|
[describeDatabaseTransport(policy)],
|
|
);
|
|
} catch (error) {
|
|
const code =
|
|
error instanceof DatabaseTransportError ? error.code : "DATABASE_TRANSPORT_INVALID";
|
|
return freezeCheck("database.transport", "failure", "Database transport unsafe", [
|
|
`[${code}] The configured database transport is not permitted.`,
|
|
]);
|
|
}
|
|
}
|
|
|
|
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
|
try {
|
|
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
|
if (config.mode === "direct") {
|
|
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
|
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
|
]);
|
|
}
|
|
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
|
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
|
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
|
]);
|
|
} catch (error) {
|
|
const code =
|
|
error instanceof ClientAddressConfigError
|
|
? error.code
|
|
: "CLIENT_ADDRESS_CONFIGURATION_INVALID";
|
|
return freezeCheck(
|
|
"identity.client-address",
|
|
"failure",
|
|
"Client-address configuration invalid",
|
|
[`[${code}] Configured client-address values failed validation.`],
|
|
);
|
|
}
|
|
}
|
|
|
|
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
|
return [
|
|
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
|
freezeCheck("postgresql.plan", "skipped", "Migration plan skipped", [
|
|
"A validated migration inventory and configured PostgreSQL connection are required.",
|
|
]),
|
|
];
|
|
}
|
|
|
|
export function doctorExitCode(report: DoctorReport): 0 | 1 {
|
|
return report.result === "not-ready" ? 1 : 0;
|
|
}
|
|
|
|
export async function diagnoseEkklesia(
|
|
options: DoctorOptions,
|
|
dependencies: DoctorDependencies = productionDependencies,
|
|
): Promise<DoctorReport> {
|
|
let runtimeCheck: DoctorCheck;
|
|
try {
|
|
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
|
? freezeCheck("platform.runtime", "pass", "Runtime version supported", [
|
|
"Active Node.js satisfies the declared supported range.",
|
|
])
|
|
: freezeCheck("platform.runtime", "failure", "Runtime version unsupported", [
|
|
"Install a Node.js version supported by this repository.",
|
|
]);
|
|
} catch {
|
|
runtimeCheck = freezeCheck("platform.runtime", "failure", "Runtime validation failed", [
|
|
"The active Node.js runtime could not be validated safely.",
|
|
]);
|
|
}
|
|
|
|
let discoveredModules: readonly DiscoveredModule[] | undefined;
|
|
let moduleCheck: DoctorCheck;
|
|
let dependencyCheck: DoctorCheck;
|
|
try {
|
|
discoveredModules = await dependencies.discoverModules(options.moduleDiscovery);
|
|
const moduleDetails = safeModuleDetails(discoveredModules);
|
|
moduleCheck = freezeCheck(
|
|
"modules.discovery",
|
|
"pass",
|
|
"Module discovery complete",
|
|
discoveredModules.length === 0 ? ["0 modules installed"] : moduleDetails,
|
|
);
|
|
dependencyCheck = freezeCheck("modules.dependencies", "pass", "Dependency graph valid", [
|
|
discoveredModules.length === 0
|
|
? "Dependency order is empty."
|
|
: `Order: ${discoveredModules.map((module) => module.manifest.id).join(" → ")}`,
|
|
]);
|
|
} catch (error) {
|
|
moduleCheck = moduleDiscoveryFailure(error);
|
|
dependencyCheck = freezeCheck("modules.dependencies", "skipped", "Dependency check skipped", [
|
|
"Complete module discovery is required.",
|
|
]);
|
|
}
|
|
|
|
// Reuses the already-discovered module list above — never re-discovers modules solely for
|
|
// permission composition. Independent of the schema/migration checks below: a permission
|
|
// composition failure here (a reserved-namespace or duplicate declaration) does not skip those,
|
|
// since neither depends on the permission registry, only on discovery having succeeded.
|
|
let permissionsCheck: DoctorCheck;
|
|
if (!discoveredModules) {
|
|
permissionsCheck = freezeCheck(
|
|
"modules.permissions",
|
|
"skipped",
|
|
"Permission composition skipped",
|
|
["Complete module discovery is required."],
|
|
);
|
|
} else {
|
|
try {
|
|
const registry = dependencies.composePermissions(discoveredModules);
|
|
const count = registry.declarations.length;
|
|
permissionsCheck = freezeCheck(
|
|
"modules.permissions",
|
|
"pass",
|
|
"Permission registry composed",
|
|
[`${count} ${plural(count, "permission")} registered`],
|
|
);
|
|
} catch (error) {
|
|
permissionsCheck = permissionCompositionFailure(error);
|
|
}
|
|
}
|
|
|
|
// Deliberately independent of permission composition, schema, and migration discovery: an
|
|
// 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;
|
|
if (!discoveredModules) {
|
|
lifecycleCheck = freezeCheck("modules.lifecycle", "skipped", "Module lifecycle skipped", [
|
|
"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.",
|
|
]);
|
|
} else {
|
|
try {
|
|
const lifecycleTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
|
environment: options.databaseEnvironment ?? "production",
|
|
});
|
|
const configuration = await dependencies.readModuleLifecycle(options.databaseUrl, {
|
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
|
transport: lifecycleTransport,
|
|
});
|
|
lifecycleCheck = moduleLifecycleCheck(
|
|
resolveModuleEligibility(discoveredModules, configuration).entries,
|
|
);
|
|
} catch {
|
|
lifecycleCheck = freezeCheck("modules.lifecycle", "failure", "Module lifecycle unavailable", [
|
|
"The stored module enablement configuration could not be read safely.",
|
|
]);
|
|
}
|
|
}
|
|
|
|
let schemaCheck: DoctorCheck;
|
|
let migrationCheck: DoctorCheck;
|
|
let migrations: ModuleMigrationAggregate | undefined;
|
|
|
|
if (!discoveredModules) {
|
|
schemaCheck = freezeCheck("schemas.discovery", "skipped", "Schema validation skipped", [
|
|
"Complete module discovery is required.",
|
|
]);
|
|
migrationCheck = freezeCheck(
|
|
"migrations.discovery",
|
|
"skipped",
|
|
"Migration validation skipped",
|
|
["Complete module discovery is required."],
|
|
);
|
|
} else {
|
|
try {
|
|
const schemas = await dependencies.discoverSchemas(options.moduleDiscovery);
|
|
schemaCheck = freezeCheck("schemas.discovery", "pass", "Schemas validated", [
|
|
`${schemas.discoveredModules.length} ${plural(schemas.discoveredModules.length, "module")} · ${schemas.tables.length} ${plural(schemas.tables.length, "table")}`,
|
|
]);
|
|
} catch {
|
|
schemaCheck = schemaDiscoveryFailure();
|
|
}
|
|
|
|
try {
|
|
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
|
|
const inventory = migrations.migrations.map((migration) =>
|
|
safeMigrationReference(migration.identity, migration.checksum),
|
|
);
|
|
migrationCheck = freezeCheck("migrations.discovery", "pass", "Migrations validated", [
|
|
`${migrations.migrations.length} ${plural(migrations.migrations.length, "migration")} · checksums valid`,
|
|
...inventory,
|
|
]);
|
|
} catch {
|
|
migrations = undefined;
|
|
migrationCheck = migrationDiscoveryFailure();
|
|
}
|
|
}
|
|
|
|
let postgresConnectionCheck: DoctorCheck;
|
|
let postgresPlanCheck: DoctorCheck;
|
|
const databaseUrl = options.databaseUrl;
|
|
const databaseConfigured = typeof databaseUrl === "string" && databaseUrl.trim() !== "";
|
|
|
|
if (!migrations) {
|
|
[postgresConnectionCheck, postgresPlanCheck] = skippedPostgresChecks(
|
|
"Complete migration discovery is required.",
|
|
);
|
|
} else if (!databaseConfigured) {
|
|
[postgresConnectionCheck, postgresPlanCheck] = skippedPostgresChecks(
|
|
"DATABASE_URL is not configured.",
|
|
);
|
|
} else {
|
|
try {
|
|
// Doctor's own read-only connection uses the exact same validated transport policy every
|
|
// other executable does, so a Doctor "connection established" can never mean something
|
|
// weaker than what `pnpm api:start` or `pnpm migrations:apply` would use.
|
|
const doctorTransport = createDatabaseTransportPolicy(options.databaseTransport, {
|
|
environment: options.databaseEnvironment ?? "production",
|
|
});
|
|
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
|
transport: doctorTransport,
|
|
});
|
|
const plan = await dependencies.planMigrations({ aggregate: migrations, database });
|
|
postgresConnectionCheck = freezeCheck(
|
|
"postgresql.connection",
|
|
"pass",
|
|
"Connection established",
|
|
);
|
|
postgresPlanCheck = migrationPlanCheck(plan);
|
|
} catch (error) {
|
|
[postgresConnectionCheck, postgresPlanCheck] = configuredDatabaseFailure(error);
|
|
}
|
|
}
|
|
|
|
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
|
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
|
|
const transportCheck = databaseTransportCheck(
|
|
options.databaseTransport,
|
|
options.databaseUrl,
|
|
options.databaseEnvironment,
|
|
);
|
|
|
|
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
|
"Core storage integration has not been implemented yet.",
|
|
]);
|
|
|
|
return publishReport([
|
|
runtimeCheck,
|
|
moduleCheck,
|
|
dependencyCheck,
|
|
permissionsCheck,
|
|
lifecycleCheck,
|
|
schemaCheck,
|
|
migrationCheck,
|
|
transportCheck,
|
|
postgresConnectionCheck,
|
|
postgresPlanCheck,
|
|
identityCheck,
|
|
clientAddressDoctorCheck,
|
|
storageCheck,
|
|
]);
|
|
}
|