Adds Core-owned identity contracts, a Core-owned Better Auth database schema (ekklesia_core_user/session/account/verification), a first-class Core-owned migration source that coexists with module-owned migrations under the exact existing migration system, and an explicit Better Auth construction boundary. No authentication endpoint or sign-in UI is exposed yet (ADR 0010). Co-Authored-By: Claude Sonnet 5 <[email protected]>
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
import path from "node:path";
|
|
import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts";
|
|
import { getTableName } from "drizzle-orm";
|
|
import {
|
|
type CreateIdentityConfigOptions,
|
|
createIdentityConfig,
|
|
IdentityConfigError,
|
|
} from "../identity/config.js";
|
|
import { identityAdapterSchema } from "../identity/schema.js";
|
|
import {
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
type MigrationDatabase,
|
|
type MigrationPlanResult,
|
|
MigrationRunnerError,
|
|
PostgresMigrationDatabaseError,
|
|
type PostgresMigrationDatabaseOptions,
|
|
planModuleMigrations,
|
|
} from "../migrations/index.js";
|
|
import {
|
|
type DiscoveredModule,
|
|
discoverModuleSchemas,
|
|
discoverModules,
|
|
type ModuleDiscoveryOptions,
|
|
type ModuleMigrationAggregate,
|
|
type ModuleSchemaAggregate,
|
|
} from "../modules/index.js";
|
|
|
|
export type DoctorCheckId =
|
|
| "platform.runtime"
|
|
| "modules.discovery"
|
|
| "modules.dependencies"
|
|
| "schemas.discovery"
|
|
| "migrations.discovery"
|
|
| "postgresql.connection"
|
|
| "postgresql.plan"
|
|
| "identity.configuration"
|
|
| "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 secret?: string | undefined;
|
|
readonly trustedOrigins?: readonly string[] | undefined;
|
|
}
|
|
|
|
export interface DoctorOptions {
|
|
readonly databaseUrl?: string | undefined;
|
|
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
|
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
|
readonly runtimeRange: string;
|
|
readonly runtimeVersion: string;
|
|
}
|
|
|
|
export interface DoctorDependencies {
|
|
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>;
|
|
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,
|
|
});
|
|
}
|
|
|
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
|
discoverMigrations: discoverAllMigrationsForRuntime,
|
|
discoverModules,
|
|
discoverSchemas: discoverModuleSchemas,
|
|
planMigrations: planModuleMigrations,
|
|
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 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?.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 {
|
|
createIdentityConfig(
|
|
{
|
|
baseUrl: input?.baseUrl,
|
|
secret: input?.secret,
|
|
trustedOrigins: input?.trustedOrigins,
|
|
},
|
|
{ environment: input?.environment ?? "production" },
|
|
);
|
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
|
schemaDetail,
|
|
]);
|
|
} 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.`,
|
|
]);
|
|
}
|
|
}
|
|
|
|
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.",
|
|
]);
|
|
}
|
|
|
|
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 {
|
|
const database = dependencies.createMigrationDatabase(databaseUrl, {
|
|
connectionTimeoutMs: doctorPostgresTimeoutMs,
|
|
operationTimeoutMs: doctorPostgresTimeoutMs,
|
|
});
|
|
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 storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
|
"Core storage integration has not been implemented yet.",
|
|
]);
|
|
|
|
return publishReport([
|
|
runtimeCheck,
|
|
moduleCheck,
|
|
dependencyCheck,
|
|
schemaCheck,
|
|
migrationCheck,
|
|
postgresConnectionCheck,
|
|
postgresPlanCheck,
|
|
identityCheck,
|
|
storageCheck,
|
|
]);
|
|
}
|