import { capabilityIdSchema, EMPTY_MODULE_SECRET_SOURCE, isVersionCompatible, logicalBucketIdSchema, type ModuleSecretSource, moduleConfigurationKeySchema, moduleIdSchema, permissionIdSchema, semanticVersionSchema, } from "@ekklesia/contracts"; import { getTableName } from "drizzle-orm"; import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; import type { PostgresPoolOptions } from "../database/pool.js"; import { assertProductionDatabaseTransport, createDatabaseTransportPolicy, type DatabaseTransportEnvironment, DatabaseTransportError, type DatabaseTransportPolicyInput, describeDatabaseTransport, } from "../database/transport.js"; import { ClientAddressConfigError, type ClientAddressConfigInput, 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 { createMailProviderConfig, createSmtpMailProvider, describeMailProvider, MailConfigError, type MailProviderConfig, type MailProviderConfigInput, } from "../mail/index.js"; import { createPostgresMigrationDatabase, type DiscoverAllMigrationsOptions, discoverAllMigrations, type MigrationDatabase, type MigrationPlanResult, MigrationRunnerError, PostgresMigrationDatabaseError, type PostgresMigrationDatabaseOptions, planModuleMigrations, } from "../migrations/index.js"; import { composePlatformPermissionRegistry, createModuleConfigurationDatabase, createModuleConfigurationStore, createModuleLifecycleDatabase, createModuleLifecycleStore, createModuleUiPublicationRegistry, type DiscoveredModule, discoverModuleSchemas, discoverModules, isConfigurationBlockedDetail, isStorageBlockedDetail, type ModuleConfigurationSettings, ModuleDiscoveryError, type ModuleDiscoveryOptions, type ModuleEligibility, type ModuleLifecycleConfiguration, type ModuleMigrationAggregate, ModulePermissionCompositionError, type ModuleSchemaAggregate, resolveModuleEligibility, } from "../modules/index.js"; import { createS3StorageProvider, createStorageProviderConfig, describeStorageProvider, physicalBucketName, StorageConfigError, type StorageProviderConfig, type StorageProviderConfigInput, } from "../storage/index.js"; export type DoctorCheckId = | "database.transport" | "platform.runtime" | "modules.discovery" | "modules.dependencies" | "modules.permissions" | "modules.lifecycle" | "modules.configuration" | "schemas.discovery" | "migrations.discovery" | "postgresql.connection" | "postgresql.plan" | "identity.configuration" | "identity.client-address" | "object-storage.integration" | "object-storage.bindings" | "outbound-email.configuration"; 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 dashboardOrigin?: 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; /** Core's own migrations, where the platform is installed. Never derived from the modules * directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */ readonly coreMigrationsDirectory: string; /** 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; /** 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 every * required secret as missing, which is only the honest answer for a deployment that has actually * set none, so an executable must pass the same source it would give the runtime. */ readonly moduleSecrets?: ModuleSecretSource; /** * The host's object-storage settings as the executable read them, validated here. * * **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider * is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that * validates it and reports the sanitized code. A pre-validated option meant a bad value threw * before a report existed, and — because the executable then had to validate it itself — meant * `pnpm doctor` simply never passed one at all, reporting configured storage as absent. */ readonly storageConfiguration?: StorageProviderConfigInput | undefined; /** * The host's outbound mail settings as the executable read them, validated here. * * Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a * malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to * be the one that validates it and reports the sanitized code. A pre-validated option would mean * a bad value crashed the command instead of appearing in its report. */ readonly mailConfiguration?: MailProviderConfigInput | undefined; 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: DiscoverAllMigrationsOptions, ) => Promise; readonly discoverModules: ( options: ModuleDiscoveryOptions, ) => Promise; readonly discoverSchemas: (options: ModuleDiscoveryOptions) => Promise; /** 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; /** 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; readonly planMigrations: (options: { readonly aggregate: ModuleMigrationAggregate; readonly database: MigrationDatabase; }) => Promise; readonly runtimeSatisfies: (version: string, range: string) => boolean; /** Probes the configured storage provider for reachability. Behind the dependency boundary like * every other I/O Doctor performs, so the whole report stays exercisable without a network. */ readonly probeStorage: (config: StorageProviderConfig) => Promise; /** Opens an SMTP connection, authenticates when credentials are configured, and closes — * **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */ readonly probeMail: (config: MailProviderConfig) => Promise; } 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; /** 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 { const { close, db } = createModuleLifecycleDatabase(connectionString, options); try { return await createModuleLifecycleStore(db).readConfiguration(); } finally { await close().catch(() => undefined); } } /** Opens its own short-lived connection, reads, and always closes it — the same discipline * `readModuleLifecycleForRuntime` follows. */ async function readModuleConfigurationForRuntime( connectionString: string, options: PostgresPoolOptions, ): Promise { const { close, db } = createModuleConfigurationDatabase(connectionString, options); try { return await createModuleConfigurationStore(db).readSettings(); } finally { await close().catch(() => undefined); } } /** Opens a provider client, probes, and always closes it — Doctor never leaves a connection behind. */ async function probeStorageForRuntime(config: StorageProviderConfig): Promise { const provider = createS3StorageProvider(config); try { await provider.probe(); } finally { await provider.close().catch(() => undefined); } } /** * Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind. * * `verify()` performs the SMTP handshake and, where credentials are configured, an authentication * exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every * time an operator ran it would be its own incident. */ async function probeMailForRuntime(config: MailProviderConfig): Promise { const provider = createSmtpMailProvider(config); try { await provider.verify(); } finally { await provider.close().catch(() => undefined); } } const productionDependencies: DoctorDependencies = Object.freeze({ composePermissions: composePlatformPermissionRegistry, createMigrationDatabase: createPostgresMigrationDatabase, discoverMigrations: discoverAllMigrations, discoverModules, discoverSchemas: discoverModuleSchemas, planMigrations: planModuleMigrations, probeMail: probeMailForRuntime, probeStorage: probeStorageForRuntime, readModuleConfiguration: readModuleConfigurationForRuntime, 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) }); } /** * One line per installed module, plus — for a module that declares browser contributions (ADR 0041) * — how many of them have a compatible provider among the installed set. * * Worth reporting because the failure it names is otherwise silent: a contribution whose target * capability nobody provides, or whose provider moved outside its declared range, is simply not * published, and an operator sees a screen that renders nothing with no error anywhere. The counts * are derived from the same publication registry the API serves from, so this can never disagree * with what the running platform would actually publish. * * Static only, like every other Doctor check: it reports what the *manifests* support and never * claims a module is running. Enablement and dependency blocking are the lifecycle check's job. */ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] { const publication = createModuleUiPublicationRegistry(modules); const compatible = new Set( publication .listContributions() .map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`), ); 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."); } const declared = module.contributions.length; if (declared === 0) { return `${id} ${version}`; } const resolved = module.contributions.filter((contribution) => compatible.has(`${id}\u0000${contribution.id}`), ).length; return `${id} ${version} · ${declared} browser contribution${ declared === 1 ? "" : "s" } (${resolved} with a compatible provider)`; }); } 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`; } if (isStorageBlockedDetail(entry.blocked)) { // A storage block names the **logical** bucket the module declared — never an endpoint, a // credential, or the physical bucket the host maps it to. Re-validated against the same grammar // the manifest accepted it under, for the same defense-in-depth reason as every other // interpolation here. if (!logicalBucketIdSchema.safeParse(entry.blocked.storageBucketId).success) { throw new TypeError("Unsafe module lifecycle diagnostic value."); } return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.storageBucketId}`; } if (isConfigurationBlockedDetail(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."); } 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); } /** * 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 && isConfigurationBlockedDetail(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, ): 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); } /** * Reports which installed modules require object storage, and where the host keeps each binding. * * Names the module, the **logical** bucket it declared, and the physical bucket the host maps it to * — the last of which is safe and useful here precisely because Doctor is an operator tool: someone * debugging a permissions problem needs to know which bucket to look at. It is still never reachable * from module-facing code (ADR 0030). No endpoint, access key, or secret key appears in any form. */ function storageBindingsCheck( modules: readonly DiscoveredModule[], entries: readonly ModuleEligibility[], config: StorageProviderConfig | undefined, ): DoctorCheck { const requiring = modules.filter((module) => module.manifest.storage.buckets.length > 0); if (requiring.length === 0) { // An installation whose modules need no object storage is not unhealthy for lacking a provider. return freezeCheck("object-storage.bindings", "pass", "No module requires object storage", [ "0 modules declare a logical storage bucket.", ]); } const blocked = entries.filter( (entry) => entry.blocked !== undefined && isStorageBlockedDetail(entry.blocked), ); const details = requiring.flatMap((module) => { const moduleId = module.manifest.id; if (!moduleIdSchema.safeParse(moduleId).success) { throw new TypeError("Unsafe module storage diagnostic value."); } return module.manifest.storage.buckets.map((bucket) => { if (!logicalBucketIdSchema.safeParse(bucket.id).success) { throw new TypeError("Unsafe module storage diagnostic value."); } return config === undefined ? `${moduleId} · ${bucket.id} · unbound (no storage provider configured)` : `${moduleId} · ${bucket.id} → ${physicalBucketName(config.bucketPrefix, moduleId, bucket.id)}`; }); }); if (config === undefined) { return freezeCheck( "object-storage.bindings", "failure", blocked.length === 1 ? "1 module requires object storage that is not configured" : `${blocked.length} modules require object storage that is not configured`, [ "Configure an S3-compatible Ekklesia storage provider (S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY).", ...details, ], ); } return freezeCheck( "object-storage.bindings", "pass", `${details.length} ${plural(details.length, "storage binding")} resolved`, details, ); } function moduleDiscoveryFailureDetail(error: unknown): string { if (!(error instanceof ModuleDiscoveryError)) { return "Static module discovery could not be completed."; } // The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the // configured value: a path is machine detail. if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) { return "The configured modules directory is not usable."; } if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) { return "The configured modules directory could not be read."; } return "The installed module inventory is invalid."; } function moduleDiscoveryFailure(error: unknown): DoctorCheck { const details = [moduleDiscoveryFailureDetail(error)]; 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)}`; }), // An absent module's applied history (ADR 0048) is information, never attention: removing a // module's code keeps its data, and nothing here is pending. ...plan.retained.map((module) => { if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) { throw new TypeError("Unsafe migration plan diagnostic value."); } return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`; }), ]; 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(", ")}).`; } /** * A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045). * * The same hostname on another port is the same site. Otherwise the two hostnames must be siblings: * identical after their first label, with that parent itself holding at least one dot * (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in * both directions for unusual names, which is why its result is a warning and never a failure. */ function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean { const dashboard = new URL(dashboardOrigin).hostname; const api = new URL(apiOrigin).hostname; if (dashboard === api) { return true; } // An IP address is a site of its own; two different ones never share one. const isIpAddress = (hostname: string): boolean => hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname); if (isIpAddress(dashboard) || isIpAddress(api)) { return false; } const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1); return ( dashboard.includes(".") && api.includes(".") && parent(dashboard) === parent(api) && parent(dashboard).includes(".") ); } /** * 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?.dashboardOrigin ?? "") !== "" || 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, // Validated exactly as the API validates it at startup, so a Dashboard origin the API would // refuse (untrusted, insecure, malformed) fails here first. dashboardOrigin: input?.dashboardOrigin, publicSignupEnabled: input?.publicSignupEnabled, secret: input?.secret, trustedOrigins: input?.trustedOrigins, }, { environment: input?.environment ?? "production" }, ); const signupDetail = config.publicSignupEnabled ? "Public signup is explicitly ENABLED." : "Public signup is disabled (safe default)."; if (config.dashboardOrigin === config.baseUrl) { return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [ schemaDetail, signupDetail, "Topology: same-origin (the Dashboard is served from the API's origin).", ]); } const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins)."; if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) { return freezeCheck( "identity.configuration", "warning", "Identity configuration valid; split origins may not share a site", [ schemaDetail, signupDetail, topologyDetail, "The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.", ], ); } return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [ schemaDetail, signupDetail, topologyDetail, ]); } 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.`, ]); } } /** Doctor reads only its own process's environment, so it says so: run as the reference * deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way * it may be checking a configuration the API is not running. */ const CLIENT_ADDRESS_SCOPE = "Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`."; 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.", CLIENT_ADDRESS_SCOPE, ]); } 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.", CLIENT_ADDRESS_SCOPE, ]); } 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.`, CLIENT_ADDRESS_SCOPE], ); } } 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 { // Resolved first, before module eligibility is decided: whether a provider is configured is an // input to whether a module that declares a bucket can run, so validating it later would mean // deciding eligibility against a question that had not been answered yet. const hostEnvironment = options.databaseEnvironment === "development" ? "development" : "production"; let storageConfig: StorageProviderConfig | undefined; let storageConfigurationError: string | undefined; try { storageConfig = createStorageProviderConfig(options.storageConfiguration, { environment: hostEnvironment, }); } catch (error) { // The sanitized code only — never the endpoint, the access key, or the secret key. storageConfigurationError = error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID"; } 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; let configurationCheck: DoctorCheck; // Hoisted so the storage-bindings check below reports the same eligibility this pass resolved, // rather than resolving a second time and risking a different answer. let eligibilityEntries: readonly ModuleEligibility[] = []; 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 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 }, { providerConfigured: storageConfig !== undefined }, ); eligibilityEntries = resolution.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."], ); } } 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({ coreMigrationsDirectory: options.coreMigrationsDirectory, moduleDiscovery: 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, ); // Two separate questions, deliberately. "Is a provider configured and reachable?" is // infrastructure diagnostics; "can every module's declared bucket be bound?" is what decides // whether modules run. Conflating them would let a momentary network blip read as a module // problem (ADR 0030). let storageCheck: DoctorCheck; if (storageConfigurationError !== undefined) { // A failure, unlike an unreachable provider: this is not a network condition an operator can // wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is // genuinely not ready, and saying so is what stops the operator from discovering it at boot. storageCheck = freezeCheck( "object-storage.integration", "failure", "Object storage configuration invalid", [ `The object-storage settings were rejected: ${storageConfigurationError}.`, "Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.", ], ); } else if (!storageConfig) { storageCheck = freezeCheck( "object-storage.integration", "skipped", "Object storage not configured", ["No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage."], ); } else { try { await dependencies.probeStorage(storageConfig); storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [ describeStorageProvider(storageConfig), // Said plainly rather than implied: this probe used the endpoint Core talks to, and a // separate browser-facing origin is by definition something a browser reaches and this // process may not. Reporting "reachable" without that caveat would let an operator conclude // direct delivery works when only half of it was tested. ...(storageConfig.deliveryEndpoint === storageConfig.endpoint ? [] : [ "The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.", ]), ]); } catch { // A warning, never a failure: the provider is configured, and a module that is already // running keeps running through a transient outage. Reporting this as `not-ready` would make // the platform look broken because a network hiccup happened while Doctor ran. storageCheck = freezeCheck( "object-storage.integration", "warning", "Object storage unreachable", [ describeStorageProvider(storageConfig), "The configured storage service did not respond. Modules already running are unaffected.", ], ); } } const storageBindings = discoveredModules ? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig) : freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [ "Complete module discovery is required.", ]); // Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not // answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in // the platform sends email yet, so an installation without SMTP is complete rather than degraded // (ADR 0037). let mailCheck: DoctorCheck; let mailConfig: MailProviderConfig | undefined; let mailConfigurationError: string | undefined; try { mailConfig = createMailProviderConfig(options.mailConfiguration, { environment: hostEnvironment, }); } catch (error) { // The sanitized code only — never the host, the username, or the password. mailConfigurationError = error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID"; } if (mailConfigurationError !== undefined) { mailCheck = freezeCheck( "outbound-email.configuration", "failure", "Outbound email configuration invalid", [ `The SMTP settings were rejected: ${mailConfigurationError}.`, "Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.", ], ); } else if (!mailConfig) { mailCheck = freezeCheck( "outbound-email.configuration", "skipped", "Outbound email not configured", [ "No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.", ], ); } else { try { await dependencies.probeMail(mailConfig); mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [ describeMailProvider(mailConfig), // Said plainly: this connected and, where credentials are set, authenticated. It did not // send anything, so it proves the relay accepts this deployment — not that a message would // reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization // policy and cannot be established from here. "Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.", ]); } catch { // A warning, never a failure, exactly as for storage: the configuration is valid and nothing // in the platform depends on mail yet, so a relay that is briefly unreachable — or one that // declines a bare connectivity check — must not make the deployment read as broken. mailCheck = freezeCheck( "outbound-email.configuration", "warning", "Outbound email unreachable", [ describeMailProvider(mailConfig), "The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.", ], ); } } return publishReport([ runtimeCheck, moduleCheck, dependencyCheck, permissionsCheck, lifecycleCheck, configurationCheck, schemaCheck, migrationCheck, transportCheck, postgresConnectionCheck, postgresPlanCheck, identityCheck, clientAddressDoctorCheck, storageCheck, storageBindings, mailCheck, ]); }