Files
Ekklesia/core/src/diagnostics/doctor.ts
T
ekklesia edd7148aa8 feat: integrate module authorization
Wires composePlatformPermissionRegistry into apps/api's production
startup: startApi() discovers every installed module, composes the one
immutable platform permission registry (Core's own declarations plus
every module's), and only then constructs the shared PostgreSQL pool,
Better Auth, and the HTTP server. A discovery or composition failure —
an invalid manifest, a reserved core.* namespace claim, or a duplicate
declaration — propagates before anything exists to close; there is no
Core-only registry fallback. apps/api/src/platform-permissions.ts
resolves the repository root from its own file location, never
process.cwd(), so package-scoped test runs discover the right modules/
directory.

createApiApp/createGrantsRoutes now require the composed registry as
an injected authorization option, replacing the previous hard-coded
CORE_PERMISSION_REGISTRY; requirePermission continues to work
identically for Core-owned and module-owned permissions, since it has
no concept of the distinction.

Extends pnpm doctor with a modules.permissions check (reusing the
already-discovered module list, distinguishing reserved-namespace from
generic-duplicate failures, never re-discovering modules) and pnpm
modules:validate to compose the same registry before a module is
considered installable.

Adds focused coverage: startup-ordering and failure-isolation tests in
apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement
tests in apps/api/tests/authorization-middleware.test.ts, Doctor
composition tests in core/tests/doctor.test.ts, and a live PostgreSQL
end-to-end scenario proving a real installed module's permission is
discoverable, grantable, and deniable once the module is absent again
after a restart, without ever executing its server entrypoint.
2026-07-24 14:24:50 -05:00

569 lines
20 KiB
TypeScript

import path from "node:path";
import {
isVersionCompatible,
moduleIdSchema,
permissionIdSchema,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
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 {
composePlatformPermissionRegistry,
type DiscoveredModule,
discoverModuleSchemas,
discoverModules,
ModulePermissionCompositionError,
type ModuleDiscoveryOptions,
type ModuleMigrationAggregate,
type ModuleSchemaAggregate,
} from "../modules/index.js";
export type DoctorCheckId =
| "platform.runtime"
| "modules.discovery"
| "modules.dependencies"
| "modules.permissions"
| "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 {
/** 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>;
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({
composePermissions: composePlatformPermissionRegistry,
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 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.",
]);
}
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.",
]);
}
// 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);
}
}
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,
permissionsCheck,
schemaCheck,
migrationCheck,
postgresConnectionCheck,
postgresPlanCheck,
identityCheck,
storageCheck,
]);
}