feat: establish core identity foundation
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]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
df0f20d8af
commit
679a5d225c
@@ -14,6 +14,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
||||
section("Modules", ["modules.discovery", "modules.dependencies"]),
|
||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
||||
section("Identity", ["identity.configuration"]),
|
||||
section("Object storage", ["object-storage.integration"]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
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,
|
||||
@@ -10,7 +19,6 @@ import {
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModuleMigrations,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
type ModuleDiscoveryOptions,
|
||||
@@ -26,6 +34,7 @@ export type DoctorCheckId =
|
||||
| "migrations.discovery"
|
||||
| "postgresql.connection"
|
||||
| "postgresql.plan"
|
||||
| "identity.configuration"
|
||||
| "object-storage.integration";
|
||||
|
||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||
@@ -43,8 +52,16 @@ export interface DoctorReport {
|
||||
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;
|
||||
@@ -74,9 +91,18 @@ 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: discoverModuleMigrations,
|
||||
discoverMigrations: discoverAllMigrationsForRuntime,
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
@@ -254,6 +280,56 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
||||
);
|
||||
}
|
||||
|
||||
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]),
|
||||
@@ -380,6 +456,8 @@ export async function diagnoseEkklesia(
|
||||
}
|
||||
}
|
||||
|
||||
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
||||
|
||||
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
||||
"Core storage integration has not been implemented yet.",
|
||||
]);
|
||||
@@ -392,6 +470,7 @@ export async function diagnoseEkklesia(
|
||||
migrationCheck,
|
||||
postgresConnectionCheck,
|
||||
postgresPlanCheck,
|
||||
identityCheck,
|
||||
storageCheck,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user