Files
Ekklesia/core/src/diagnostics/doctor.ts
T
ekklesiaandClaude Opus 5 7729c99e24
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s
feat: add the minimal operational module lifecycle
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]>
2026-08-22 14:45:34 -05:00

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,
]);
}