feat: establish core identity foundation
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s

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:
ekklesia
2026-07-21 16:39:15 -05:00
co-authored by Claude Sonnet 5
parent df0f20d8af
commit 679a5d225c
43 changed files with 4259 additions and 64 deletions
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/identity/schema.ts",
out: "./drizzle",
migrations: {
prefix: "index",
},
breakpoints: true,
});
@@ -0,0 +1,53 @@
CREATE TABLE "ekklesia_core_account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "ekklesia_core_session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_account" ADD CONSTRAINT "ekklesia_core_account_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_session" ADD CONSTRAINT "ekklesia_core_session_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_account_user_id_idx" ON "ekklesia_core_account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_session_user_id_idx" ON "ekklesia_core_session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_verification_identifier_idx" ON "ekklesia_core_verification" USING btree ("identifier");
+362
View File
@@ -0,0 +1,362 @@
{
"id": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"moduleId": "core",
"entries": [
{
"tag": "0000_identity_core_tables",
"destructive": {
"mode": "none"
}
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784668552522,
"tag": "0000_identity_core_tables",
"breakpoints": true
}
]
}
+19
View File
@@ -0,0 +1,19 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
/**
* Drives the pinned Better Auth CLI's canonical schema generator for
* `pnpm identity:schema:generate` and `pnpm identity:schema:validate`.
*
* This file is a development-time aid only: it is never imported by application, factory, or
* runtime code, never opens a database connection, and intentionally omits our custom table
* names so the generator always produces Better Auth's official default (unrenamed) schema for
* comparison against core/src/identity/schema.ts. Do not add plugins or providers here.
*/
export const auth = betterAuth({
database: drizzleAdapter({} as never, {
provider: "pg",
}),
secret: "0".repeat(32),
baseURL: "https://identity-schema-generator.invalid",
});
+5
View File
@@ -26,12 +26,15 @@
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"identity:migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
"identity:schema:generate": "auth generate --config=identity-schema-generator.config.ts --output=.identity-schema-reference.ts -y",
"lint": "biome lint .",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23",
"drizzle-orm": "0.45.2",
"pg": "8.22.0"
},
@@ -39,6 +42,8 @@
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"@types/pg": "8.20.0",
"auth": "1.6.23",
"drizzle-kit": "0.31.10",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+26 -1
View File
@@ -1,7 +1,12 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { diagnoseEkklesia, doctorExitCode, formatDoctorReport } from "../diagnostics/index.js";
import {
diagnoseEkklesia,
type DoctorOptions,
doctorExitCode,
formatDoctorReport,
} from "../diagnostics/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
@@ -31,6 +36,25 @@ function requiredString(value: unknown): string {
return value;
}
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const trustedOrigins = trustedOriginsValue
? trustedOriginsValue
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0)
: undefined;
return {
...(baseUrl ? { baseUrl } : {}),
environment: process.env.NODE_ENV === "development" ? "development" : "production",
...(secret ? { secret } : {}),
...(trustedOrigins ? { trustedOrigins } : {}),
};
}
export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
const [rootValue, moduleSdkValue] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")),
@@ -41,6 +65,7 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const report = await diagnoseEkklesia({
databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(),
moduleDiscovery: {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
+9 -5
View File
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { discoverModuleMigrations, type ModuleMigrationAggregate } from "../modules/index.js";
import { discoverAllMigrations } from "../migrations/index.js";
import type { ModuleMigrationAggregate } from "../modules/index.js";
interface PackageVersion {
readonly version?: unknown;
@@ -22,9 +23,12 @@ export async function discoverRepositoryMigrations(
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
return discoverModuleMigrations({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
return discoverAllMigrations({
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: {
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
},
});
}
+177
View File
@@ -0,0 +1,177 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { type AnyPgTable, getTableConfig } from "drizzle-orm/pg-core";
import { identityAdapterSchema } from "../identity/schema.js";
const execFileAsync = promisify(execFile);
interface ColumnShape {
readonly dataType: string;
readonly hasDefault: boolean;
readonly notNull: boolean;
readonly primary: boolean;
readonly unique: boolean;
}
interface TableShape {
readonly columns: Readonly<Record<string, ColumnShape>>;
readonly foreignKeyColumnSets: readonly string[];
readonly indexedColumnSets: readonly string[];
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function shapeOfTable(table: AnyPgTable): TableShape {
const config = getTableConfig(table);
const columns: Record<string, ColumnShape> = {};
for (const column of config.columns) {
columns[column.name] = {
dataType: column.dataType,
hasDefault: column.hasDefault,
notNull: column.notNull,
primary: column.primary,
unique: column.isUnique,
};
}
const foreignKeyColumnSets = config.foreignKeys
.map((foreignKey) =>
foreignKey
.reference()
.columns.map((column) => column.name)
.sort(compareText)
.join(","),
)
.sort(compareText);
const indexedColumnSets = config.indexes
.map((index) =>
index.config.columns
.map((column) => ("name" in column ? String(column.name) : "expression"))
.sort(compareText)
.join(","),
)
.sort(compareText);
return { columns: Object.freeze(columns), foreignKeyColumnSets, indexedColumnSets };
}
function diffTableShape(model: string, expected: TableShape, actual: TableShape): string[] {
const issues: string[] = [];
const expectedColumns = new Set(Object.keys(expected.columns));
const actualColumns = new Set(Object.keys(actual.columns));
for (const name of [...expectedColumns].sort(compareText)) {
if (!actualColumns.has(name)) {
issues.push(`${model}: column "${name}" is missing from the generated schema.`);
continue;
}
const expectedColumn = expected.columns[name];
const actualColumn = actual.columns[name];
if (JSON.stringify(expectedColumn) !== JSON.stringify(actualColumn)) {
issues.push(
`${model}: column "${name}" shape differs (checked-in ${JSON.stringify(expectedColumn)} vs generated ${JSON.stringify(actualColumn)}).`,
);
}
}
for (const name of [...actualColumns].sort(compareText)) {
if (!expectedColumns.has(name)) {
issues.push(`${model}: generated column "${name}" is missing from the checked-in schema.`);
}
}
if (
JSON.stringify(expected.foreignKeyColumnSets) !== JSON.stringify(actual.foreignKeyColumnSets)
) {
issues.push(`${model}: foreign key column sets differ from the generated schema.`);
}
if (JSON.stringify(expected.indexedColumnSets) !== JSON.stringify(actual.indexedColumnSets)) {
issues.push(`${model}: indexed column sets differ from the generated schema.`);
}
return issues;
}
async function resolveAuthCliEntry(): Promise<string> {
const corePackageDirectory = path.resolve(fileURLToPath(import.meta.url), "..", "..", "..");
const authPackageDirectory = path.join(corePackageDirectory, "node_modules", "auth");
const authPackageJson = JSON.parse(
await readFile(path.join(authPackageDirectory, "package.json"), "utf8"),
) as { readonly bin?: Readonly<Record<string, string>> };
const binEntry = authPackageJson.bin?.auth;
if (typeof binEntry !== "string" || binEntry.length === 0) {
throw new Error("The pinned Better Auth CLI package does not declare a usable binary.");
}
return path.join(authPackageDirectory, binEntry);
}
async function main(): Promise<void> {
const corePackageDirectory = path.resolve(fileURLToPath(import.meta.url), "..", "..", "..");
const authCliEntry = await resolveAuthCliEntry();
const generatorConfig = path.join(corePackageDirectory, "identity-schema-generator.config.ts");
// Generated inside the core package (not the OS temp directory) so the generated file's
// bare-specifier imports (e.g. "drizzle-orm") resolve against core/node_modules.
const tempDirectory = await mkdtemp(path.join(corePackageDirectory, ".identity-schema-check-"));
try {
const generatedSchemaPath = path.join(tempDirectory, "generated-schema.ts");
await execFileAsync(
process.execPath,
[
authCliEntry,
"generate",
`--config=${generatorConfig}`,
`--output=${generatedSchemaPath}`,
"-y",
],
{ cwd: corePackageDirectory },
);
const generated = (await import(generatedSchemaPath)) as Readonly<
Record<string, AnyPgTable | unknown>
>;
const issues: string[] = [];
for (const model of Object.keys(
identityAdapterSchema,
) as (keyof typeof identityAdapterSchema)[]) {
const generatedTable = generated[model];
if (!generatedTable || typeof generatedTable !== "object") {
issues.push(`${model}: the pinned generator did not produce this core model.`);
continue;
}
issues.push(
...diffTableShape(
model,
shapeOfTable(identityAdapterSchema[model]),
shapeOfTable(generatedTable as AnyPgTable),
),
);
}
if (issues.length > 0) {
console.error("Identity schema drift detected against the pinned Better Auth generator:");
for (const issue of issues) {
console.error(` - ${issue}`);
}
console.error(
"Review the difference, update core/src/identity/schema.ts deliberately, and add a new forward-only Core migration.",
);
process.exitCode = 1;
return;
}
console.log(
"Identity schema matches the pinned Better Auth generator for user, session, account, and verification.",
);
} finally {
await rm(tempDirectory, { recursive: true, force: true });
}
}
main().catch(() => {
console.error("Identity schema validation failed before a comparison could be completed.");
process.exitCode = 1;
});
+1
View File
@@ -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"]),
]);
+81 -2
View File
@@ -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,
]);
}
+184
View File
@@ -0,0 +1,184 @@
export type IdentityEnvironment = "development" | "production";
export type IdentityConfigErrorCode =
| "BASE_URL_INSECURE_IN_PRODUCTION"
| "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING"
| "SECRET_LOW_ENTROPY"
| "SECRET_MISSING"
| "SECRET_TOO_SHORT"
| "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION"
| "TRUSTED_ORIGIN_INVALID"
| "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION"
| "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED"
| "TRUSTED_ORIGINS_EMPTY"
| "TRUSTED_ORIGINS_MISSING";
const MIN_SECRET_LENGTH = 32;
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set([
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]);
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({
BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.",
BASE_URL_INVALID: "The identity base URL must be an absolute http or https URL.",
BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.",
SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.",
SECRET_MISSING: "The identity secret is required.",
SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`,
TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION: "Trusted origins must use https in production.",
TRUSTED_ORIGIN_INVALID:
"Every trusted origin must be an absolute http or https origin with no path, query, or fragment.",
TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION:
"Trusted origins must not resolve to a loopback host in production.",
TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED:
"Wildcard trusted origins are not supported in this release.",
TRUSTED_ORIGINS_EMPTY: "At least one trusted origin is required.",
TRUSTED_ORIGINS_MISSING: "Trusted origins are required.",
});
/**
* Never carries the offending secret, URL, origin, hostname, or any other caller-supplied value.
* Only a stable code and a fixed, generic message are ever exposed.
*/
export class IdentityConfigError extends Error {
readonly code: IdentityConfigErrorCode;
constructor(code: IdentityConfigErrorCode) {
super(errorMessages[code]);
this.name = "IdentityConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface IdentityConfig {
readonly baseUrl: string;
readonly secret: string;
readonly trustedOrigins: readonly string[];
}
export interface IdentityConfigInput {
readonly baseUrl?: unknown;
readonly secret?: unknown;
readonly trustedOrigins?: unknown;
}
export interface CreateIdentityConfigOptions {
/** Defaults to "production" so an omitted environment never silently relaxes validation. */
readonly environment?: IdentityEnvironment;
}
function isLoopbackHostname(hostname: string): boolean {
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
}
function parseAbsoluteHttpUrl(value: unknown): URL | undefined {
if (typeof value !== "string" || value.length === 0) {
return undefined;
}
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
} catch {
return undefined;
}
}
function validateSecret(secret: unknown): string {
if (typeof secret !== "string" || secret.length === 0) {
throw new IdentityConfigError("SECRET_MISSING");
}
if (secret.length < MIN_SECRET_LENGTH) {
throw new IdentityConfigError("SECRET_TOO_SHORT");
}
// A narrow, deterministic guard against trivially degenerate secrets (e.g. one repeated
// character). It cannot measure true entropy; operators remain responsible for generating a
// high-entropy value (for example `openssl rand -base64 32`).
if (/^(.)\1*$/u.test(secret)) {
throw new IdentityConfigError("SECRET_LOW_ENTROPY");
}
return secret;
}
function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): string {
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
throw new IdentityConfigError("BASE_URL_MISSING");
}
const url = parseAbsoluteHttpUrl(baseUrl);
if (!url) {
throw new IdentityConfigError("BASE_URL_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION");
}
if (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION");
}
}
return baseUrl;
}
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
if (typeof origin !== "string" || origin.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
}
// Better Auth itself supports glob-style wildcard trusted origins. Phase 4A deliberately does
// not: cross-subdomain cookies are not enabled yet (see ADR 0010), so a wildcard origin would
// widen the trusted-origin surface without a matching cookie-scope decision. Revisit only
// alongside a future ADR that enables cross-subdomain cookies.
if (origin.includes("*") || origin.includes("?")) {
throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED");
}
const url = parseAbsoluteHttpUrl(origin);
if (!url || url.origin !== origin) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION");
}
if (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION");
}
}
return origin;
}
function validateTrustedOrigins(
trustedOrigins: unknown,
environment: IdentityEnvironment,
): readonly string[] {
if (trustedOrigins === undefined || trustedOrigins === null) {
throw new IdentityConfigError("TRUSTED_ORIGINS_MISSING");
}
if (!Array.isArray(trustedOrigins) || trustedOrigins.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGINS_EMPTY");
}
return Object.freeze(trustedOrigins.map((origin) => validateTrustedOrigin(origin, environment)));
}
/**
* Validates explicit, typed identity configuration. Performs no environment reads, network
* calls, or database access; callers are responsible for sourcing `input` (for example from
* environment variables) before calling this function.
*/
export function createIdentityConfig(
input: IdentityConfigInput,
options: CreateIdentityConfigOptions = {},
): IdentityConfig {
const environment = options.environment ?? "production";
const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
return Object.freeze({ baseUrl, secret, trustedOrigins });
}
+69
View File
@@ -0,0 +1,69 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg";
import { identityAdapterSchema } from "./schema.js";
export type IdentityDrizzleDatabase = NodePgDatabase<typeof identityAdapterSchema>;
export type IdentityDatabaseErrorCode = "IDENTITY_DATABASE_CONFIGURATION_INVALID";
/** Never carries the offending connection string, host, or underlying driver error. */
export class IdentityDatabaseError extends Error {
readonly code: IdentityDatabaseErrorCode;
constructor(code: IdentityDatabaseErrorCode) {
super("Identity database configuration is invalid.");
this.name = "IdentityDatabaseError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface IdentityDatabaseOptions {
readonly connectionTimeoutMs?: number;
}
export interface IdentityDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: IdentityDrizzleDatabase;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
return connectionString;
}
/**
* Explicitly constructs the Core-owned Drizzle database instance used by the identity factory.
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
* only ever invoked by explicit caller code — never during module import.
*/
export function createIdentityDrizzleDatabase(
connectionString: string,
options: IdentityDatabaseOptions = {},
): IdentityDatabaseHandle {
const validatedConnectionString = validateConnectionString(connectionString);
const poolConfig: PoolConfig = { connectionString: validatedConnectionString };
if (options.connectionTimeoutMs !== undefined) {
poolConfig.connectionTimeoutMillis = options.connectionTimeoutMs;
}
const pool = new Pool(poolConfig);
const db = drizzle(pool, { schema: identityAdapterSchema });
return Object.freeze({
close: () => pool.end(),
db,
});
}
+58
View File
@@ -0,0 +1,58 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { IdentityConfig } from "./config.js";
import type { IdentityDrizzleDatabase } from "./database.js";
import { identityAdapterSchema } from "./schema.js";
export type IdentityConstructionErrorCode = "IDENTITY_CONSTRUCTION_FAILED";
/** Never carries the underlying Better Auth error, cause, stack, secret, or URL. */
export class IdentityConstructionError extends Error {
readonly code: IdentityConstructionErrorCode;
constructor() {
super("Identity could not be constructed.");
this.name = "IdentityConstructionError";
this.code = "IDENTITY_CONSTRUCTION_FAILED";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface CreateIdentityAuthOptions {
readonly config: IdentityConfig;
readonly db: IdentityDrizzleDatabase;
}
/**
* Deliberately opaque: Phase 4A never exposes the Better Auth instance, its HTTP handler, the
* injected database, or the Drizzle adapter beyond this module. A later HTTP transport slice
* decides what (if anything) becomes reachable outside core/src/identity.
*/
export interface EkklesiaIdentityInstance {
readonly constructed: true;
}
/**
* Core's Better Auth composition boundary. Constructs Better Auth explicitly from injected,
* already-validated configuration and an injected Drizzle database — it never reads environment
* variables, never resolves a connection string, and enables no provider or plugin beyond the
* four core models required for the identity schema itself.
*/
export function createIdentityAuth(options: CreateIdentityAuthOptions): EkklesiaIdentityInstance {
try {
betterAuth({
baseURL: options.config.baseUrl,
database: drizzleAdapter(options.db, {
provider: "pg",
schema: identityAdapterSchema,
}),
secret: options.config.secret,
trustedOrigins: [...options.config.trustedOrigins],
});
} catch {
throw new IdentityConstructionError();
}
return Object.freeze({ constructed: true });
}
+111
View File
@@ -0,0 +1,111 @@
import { relations } from "drizzle-orm";
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned Better Auth schema for the four required core models: user, session, account,
* and verification. Table and index names are explicit and namespaced so ownership is
* unambiguous inside the shared public PostgreSQL schema. Columns, types, defaults, uniqueness,
* foreign keys, and delete behavior mirror the pinned [email protected] Drizzle/PostgreSQL
* generator output exactly (see docs/development/identity.md for the regeneration workflow).
*/
export const ekklesiaCoreUser = pgTable("ekklesia_core_user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const ekklesiaCoreSession = pgTable(
"ekklesia_core_session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
},
(table) => [index("ekklesia_core_session_user_id_idx").on(table.userId)],
);
export const ekklesiaCoreAccount = pgTable(
"ekklesia_core_account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("ekklesia_core_account_user_id_idx").on(table.userId)],
);
export const ekklesiaCoreVerification = pgTable(
"ekklesia_core_verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("ekklesia_core_verification_identifier_idx").on(table.identifier)],
);
export const ekklesiaCoreUserRelations = relations(ekklesiaCoreUser, ({ many }) => ({
accounts: many(ekklesiaCoreAccount),
sessions: many(ekklesiaCoreSession),
}));
export const ekklesiaCoreSessionRelations = relations(ekklesiaCoreSession, ({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreSession.userId],
references: [ekklesiaCoreUser.id],
}),
}));
export const ekklesiaCoreAccountRelations = relations(ekklesiaCoreAccount, ({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreAccount.userId],
references: [ekklesiaCoreUser.id],
}),
}));
/**
* Explicit mapping from Better Auth's logical core model names to Ekklesia's physical schema
* objects. Passed directly to the Drizzle adapter so table naming is never inferred by accident.
*/
export const identityAdapterSchema = {
account: ekklesiaCoreAccount,
session: ekklesiaCoreSession,
user: ekklesiaCoreUser,
verification: ekklesiaCoreVerification,
} as const;
+47
View File
@@ -0,0 +1,47 @@
import {
discoverModuleMigrations,
type ModuleDiscoveryOptions,
type ModuleMigrationAggregate,
ModuleMigrationDiscoveryError,
} from "../modules/index.js";
import { CORE_MIGRATION_SOURCE_ID, discoverCoreMigrations } from "./core-source.js";
export interface DiscoverAllMigrationsOptions {
readonly coreMigrationsDirectory: string;
readonly moduleDiscovery: ModuleDiscoveryOptions;
}
/**
* Discovers Core's own migrations and every installed module's migrations, then merges them into
* one frozen `ModuleMigrationAggregate` with Core's migrations ordered first. Core has no
* dependency relationship with feature modules, so it is not part of the module dependency graph;
* it simply precedes every module in the aggregate the planner and runner already consume
* unmodified. There remains exactly one migration aggregate and one executor.
*/
export async function discoverAllMigrations(
options: DiscoverAllMigrationsOptions,
): Promise<ModuleMigrationAggregate> {
const [coreMigrations, moduleAggregate] = await Promise.all([
discoverCoreMigrations({ coreMigrationsDirectory: options.coreMigrationsDirectory }),
discoverModuleMigrations(options.moduleDiscovery),
]);
if (moduleAggregate.modules.some((module) => module.moduleId === CORE_MIGRATION_SOURCE_ID)) {
throw new ModuleMigrationDiscoveryError([
{
code: "MODULE_MIGRATION_ID_DUPLICATE",
message: `The identifier "${CORE_MIGRATION_SOURCE_ID}" is reserved for Core-owned migrations and cannot be used by an installed module.`,
moduleId: CORE_MIGRATION_SOURCE_ID,
},
]);
}
return Object.freeze({
discoveredModules: moduleAggregate.discoveredModules,
migrations: Object.freeze([...coreMigrations, ...moduleAggregate.migrations]),
modules: Object.freeze([
Object.freeze({ migrations: coreMigrations, moduleId: CORE_MIGRATION_SOURCE_ID }),
...moduleAggregate.modules,
]),
});
}
+42
View File
@@ -0,0 +1,42 @@
import type { ModuleId } from "@ekklesia/contracts";
import {
type DiscoveredModuleMigration,
inspectMigrationsAtPath,
ModuleMigrationDiscoveryError,
type ModuleMigrationDiscoveryIssue,
} from "../modules/migration-discovery.js";
/**
* The reserved migration-source identifier for Core-owned platform schema (starting with
* identity). It is not a module: Core is never discovered through `discoverModules`, never has
* an `ekklesia.module.json` manifest, and never runs a server entrypoint. It reuses the exact
* same journal/checksum/breakpoint/destructive-policy validation as module migrations so there
* remains exactly one migration format and one executor.
*/
export const CORE_MIGRATION_SOURCE_ID: ModuleId = "core";
export interface CoreMigrationDiscoveryOptions {
readonly coreMigrationsDirectory: string;
}
/**
* Discovers Core's own migration inventory at `coreMigrationsDirectory` (the package-local
* `core/drizzle` directory). Performs no PostgreSQL connection or module discovery; it only
* validates packaged Drizzle migration artifacts, exactly like module migration discovery.
*/
export async function discoverCoreMigrations(
options: CoreMigrationDiscoveryOptions,
): Promise<readonly DiscoveredModuleMigration[]> {
const issues: ModuleMigrationDiscoveryIssue[] = [];
const migrations = await inspectMigrationsAtPath(
CORE_MIGRATION_SOURCE_ID,
options.coreMigrationsDirectory,
issues,
);
if (issues.length > 0) {
throw new ModuleMigrationDiscoveryError(issues);
}
return migrations;
}
+2
View File
@@ -1,2 +1,4 @@
export * from "./aggregate.js";
export * from "./core-source.js";
export * from "./postgres-database.js";
export * from "./runner.js";
+20 -8
View File
@@ -577,16 +577,17 @@ function validateInventory(
});
}
async function inspectModuleMigrations(
discovered: DiscoveredModule,
/**
* Validates and reads the Drizzle migration inventory at `migrationRoot`, attributing every
* migration and issue to `moduleId`. Shared by module-owned discovery (moduleId taken from the
* module manifest) and Core's own migration source (moduleId fixed to "core"), so both reuse the
* exact same journal, checksum, breakpoint, and destructive-policy validation.
*/
export async function inspectMigrationsAtPath(
moduleId: ModuleId,
migrationRoot: string,
issues: ModuleMigrationDiscoveryIssue[],
): Promise<readonly DiscoveredModuleMigration[]> {
const moduleId = discovered.manifest.id;
const migrationRoot = discovered.entrypoints.migrations;
if (!migrationRoot) {
return Object.freeze([]);
}
const issueCountBeforeModule = issues.length;
const inventory = await readInventory(migrationRoot, moduleId, issues);
const [journalBytes, policyBytes] = await Promise.all([
@@ -734,6 +735,17 @@ async function inspectModuleMigrations(
return Object.freeze(migrations);
}
async function inspectModuleMigrations(
discovered: DiscoveredModule,
issues: ModuleMigrationDiscoveryIssue[],
): Promise<readonly DiscoveredModuleMigration[]> {
const migrationRoot = discovered.entrypoints.migrations;
if (!migrationRoot) {
return Object.freeze([]);
}
return inspectMigrationsAtPath(discovered.manifest.id, migrationRoot, issues);
}
export async function discoverModuleMigrations(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
+243
View File
@@ -0,0 +1,243 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
CORE_MIGRATION_SOURCE_ID,
discoverAllMigrations,
discoverCoreMigrations,
} from "../src/migrations/index.js";
import { ModuleMigrationDiscoveryError } from "../src/modules/index.js";
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function createTemporaryDirectory(prefix: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
/** Writes a single-migration Drizzle directory (journal + snapshot + policy + SQL). */
async function writeMigrationsDirectory(
migrationsDirectory: string,
moduleId: string,
tag: string,
sql: string,
): Promise<string> {
await mkdir(path.join(migrationsDirectory, "meta"), { recursive: true });
await Promise.all([
writeFile(
path.join(migrationsDirectory, "meta/_journal.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
entries: [{ idx: 0, version: "7", when: 1_700_000_000_000, tag, breakpoints: true }],
}),
),
writeFile(
path.join(migrationsDirectory, "meta/_ekklesia.json"),
JSON.stringify({
version: 1,
moduleId,
entries: [{ tag, destructive: { mode: "none" } }],
}),
),
writeFile(
path.join(migrationsDirectory, "meta/0000_snapshot.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: "10000000-0000-4000-8000-000000000001",
prevId: originSnapshotId,
}),
),
writeFile(path.join(migrationsDirectory, `${tag}.sql`), sql),
]);
return sql;
}
async function createModule(
modulesDirectory: string,
id: string,
options: { readonly dependsOn?: readonly string[] } = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const migrationsDirectory = path.join(moduleDirectory, "drizzle");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
const requiredDependencies = Object.fromEntries(
(options.dependsOn ?? []).map((dependencyId) => [dependencyId, "^0.1.0"]),
);
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
name: `@test/module-${id}`,
version: "0.1.0",
type: "module",
ekklesia: { layer: "module" },
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 2,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
dependencies: {
modules: { required: requiredDependencies, optional: {} },
capabilities: { required: {}, optional: {} },
},
capabilities: [],
permissions: [],
entrypoints: { server: "./dist/server/index.js", migrations: "./drizzle" },
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
]);
await writeMigrationsDirectory(
migrationsDirectory,
id,
"0000_initial",
`CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
);
}
function sha256(contents: string): string {
return createHash("sha256").update(contents).digest("hex");
}
describe("Core-owned migration discovery", () => {
it("discovers Core's own migrations with the reserved core identity and an exact checksum", async () => {
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
const sql = await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
const migrations = await discoverCoreMigrations({ coreMigrationsDirectory });
expect(CORE_MIGRATION_SOURCE_ID).toBe("core");
expect(migrations).toHaveLength(1);
expect(migrations[0]).toMatchObject({
approvalRequired: false,
checksum: sha256(sql),
identity: "core:0000_identity_core_tables",
index: 0,
moduleId: "core",
tag: "0000_identity_core_tables",
});
});
it("is deterministic across repeated discovery calls", async () => {
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
const first = await discoverCoreMigrations({ coreMigrationsDirectory });
const second = await discoverCoreMigrations({ coreMigrationsDirectory });
expect(first).toEqual(second);
});
it("orders Core migrations before every module migration while preserving dependency-first module order", async () => {
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
// "calendar" depends on "people"; created on disk in the opposite order so only the
// dependency graph (not directory order) can explain the resulting sequence.
await createModule(modulesDirectory, "calendar", { dependsOn: ["people"] });
await createModule(modulesDirectory, "people");
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
});
expect(aggregate.modules.map((module) => module.moduleId)).toEqual([
"core",
"people",
"calendar",
]);
expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([
"core:0000_identity_core_tables",
"people:0000_initial",
"calendar:0000_initial",
]);
});
it("rejects an installed module that reuses the reserved core migration identity", async () => {
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
await createModule(modulesDirectory, "core");
let captured: unknown;
try {
await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModuleMigrationDiscoveryError);
expect((captured as ModuleMigrationDiscoveryError).issues).toEqual([
expect.objectContaining({ code: "MODULE_MIGRATION_ID_DUPLICATE", moduleId: "core" }),
]);
});
it("keeps a fresh, module-free repository honestly reporting only Core's migration", async () => {
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
});
expect(aggregate.discoveredModules).toEqual([]);
expect(aggregate.modules).toHaveLength(1);
expect(aggregate.modules[0]?.moduleId).toBe("core");
expect(aggregate.migrations).toHaveLength(1);
});
});
+5
View File
@@ -88,6 +88,11 @@ PostgreSQL
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
+70
View File
@@ -200,6 +200,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"migrations.discovery",
"postgresql.connection",
"postgresql.plan",
"identity.configuration",
"object-storage.integration",
]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
@@ -438,4 +439,73 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(services.discoverSchemas).toHaveBeenCalledOnce();
expect(services.discoverMigrations).toHaveBeenCalledOnce();
});
it("skips identity configuration (not a failure) when none of it is set", async () => {
const report = await diagnoseEkklesia(options(), dependencies());
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("skipped");
expect(identity.details[0]).toContain("Core identity schema defines 4 tables");
expect(report.result).toBe("ready-with-warnings");
expect(doctorExitCode(report)).toBe(0);
});
it("passes identity configuration when a valid secret, base URL, and trusted origins are provided", async () => {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
environment: "production",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("pass");
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
});
it("fails identity configuration for an invalid secret without leaking it", async () => {
const hostileSecret = "s3cr3t-do-not-log";
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
environment: "production",
secret: hostileSecret,
trustedOrigins: ["https://identity.example.test"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("failure");
expect(identity.details.some((detail) => detail.includes("SECRET_TOO_SHORT"))).toBe(true);
expect(report.result).toBe("not-ready");
expect(doctorExitCode(report)).toBe(1);
expect(JSON.stringify(report)).not.toContain(hostileSecret);
});
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
const services = dependencies();
await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
},
services,
);
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
});
});
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import { createIdentityConfig, IdentityConfigError } from "../src/identity/config.js";
const validSecret = "correct-horse-battery-staple-0123456789";
const validBaseUrl = "https://identity.example.test";
const validTrustedOrigins = ["https://identity.example.test", "https://dashboard.example.test"];
function validInput() {
return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins };
}
describe("Core identity configuration", () => {
it("accepts a valid secret, base URL, and trusted origins", () => {
const config = createIdentityConfig(validInput(), { environment: "production" });
expect(config).toEqual({
baseUrl: validBaseUrl,
secret: validSecret,
trustedOrigins: validTrustedOrigins,
});
expect(Object.isFrozen(config)).toBe(true);
expect(Object.isFrozen(config.trustedOrigins)).toBe(true);
});
it("rejects a missing secret", () => {
expect(() =>
createIdentityConfig({ ...validInput(), secret: undefined }, { environment: "production" }),
).toThrow(IdentityConfigError);
try {
createIdentityConfig({ ...validInput(), secret: undefined });
} catch (error) {
expect(error).toMatchObject({ code: "SECRET_MISSING" });
}
});
it("rejects a secret shorter than 32 characters", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), secret: "x".repeat(31) });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
});
it("accepts a secret of exactly 32 characters", () => {
const secret = "aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV";
expect(secret).toHaveLength(32);
const config = createIdentityConfig({ ...validInput(), secret });
expect(config.secret).toBe(secret);
});
it("rejects a degenerate single-character-repeated secret", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), secret: "a".repeat(40) });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "SECRET_LOW_ENTROPY" });
});
it("rejects an invalid base URL", () => {
for (const baseUrl of [undefined, "", "not-a-url", "ftp://example.test", "example.test"]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
}
});
it("rejects missing trusted origins", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: undefined });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_MISSING" });
});
it("rejects an empty trusted origins array", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_EMPTY" });
});
it("rejects a trusted origin containing a path, query, or fragment", () => {
for (const origin of [
"https://example.test/",
"https://example.test/path",
"https://example.test#fragment",
]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_INVALID" });
}
});
it("rejects wildcard trusted origins", () => {
for (const origin of ["https://*.example.test", "https://app?.example.test"]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED" });
}
});
it("rejects insecure http origins and loopback hosts in production", () => {
let httpError: unknown;
try {
createIdentityConfig(
{ ...validInput(), trustedOrigins: ["http://identity.example.test"] },
{ environment: "production" },
);
} catch (error) {
httpError = error;
}
expect(httpError).toMatchObject({ code: "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION" });
let loopbackError: unknown;
try {
createIdentityConfig(
{ ...validInput(), baseUrl: "https://localhost", trustedOrigins: ["https://localhost"] },
{ environment: "production" },
);
} catch (error) {
loopbackError = error;
}
expect(loopbackError).toMatchObject({ code: "BASE_URL_LOOPBACK_IN_PRODUCTION" });
});
it("defaults to production validation when environment is not specified", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl: "http://identity.example.test" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "BASE_URL_INSECURE_IN_PRODUCTION" });
});
it("explicitly allows local development origins only when environment is development", () => {
const config = createIdentityConfig(
{
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
{ environment: "development" },
);
expect(config.baseUrl).toBe("http://localhost:3000");
});
it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
let captured: unknown;
try {
createIdentityConfig({ baseUrl: "not-a-url", secret: hostileSecret, trustedOrigins: [] });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain(hostileSecret);
expect(publicText).not.toContain(validBaseUrl);
for (const origin of validTrustedOrigins) {
expect(publicText).not.toContain(origin);
}
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createIdentityDrizzleDatabase, IdentityDatabaseError } from "../src/identity/database.js";
describe("Core identity Drizzle database composition", () => {
it("does not construct a pool merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it.each([
"",
" ",
"not-a-database-url",
"https://admin:[email protected]/database",
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
let captured: unknown;
try {
createIdentityDrizzleDatabase(connectionString);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityDatabaseError);
expect(captured).toMatchObject({ code: "IDENTITY_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
if (connectionString) {
expect(publicText).not.toContain(connectionString);
}
expect(poolConstructor).not.toHaveBeenCalled();
});
it("explicitly constructs the pool only when called, with the identity schema attached", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined };
});
const handle = createIdentityDrizzleDatabase(
"postgresql://operator:[email protected]/ekklesia",
);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(handle.db).toBeDefined();
expect(typeof handle.close).toBe("function");
expect(Object.isFrozen(handle)).toBe(true);
});
it("passes the connection timeout through to the pool configuration", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined };
});
poolConstructor.mockClear();
createIdentityDrizzleDatabase("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 5_000,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() =>
vi.fn((db: unknown, options: unknown) => ({ __adapterOptions: options, db })),
);
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
import type { IdentityConfig } from "../src/identity/config.js";
import { createIdentityAuth, IdentityConstructionError } from "../src/identity/factory.js";
import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(): IdentityConfig {
return Object.freeze({
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]),
});
}
describe("Core identity Better Auth construction boundary", () => {
beforeEach(() => {
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
});
it("constructs explicitly with narrow injected dependencies and returns only a success marker", () => {
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
const fakeDb = { __fakeDb: true };
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
expect(result).toEqual({ constructed: true });
expect(Object.keys(result)).toEqual(["constructed"]);
expect(Object.isFrozen(result)).toBe(true);
});
it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => {
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
const fakeDb = { __fakeDb: true };
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
expect(JSON.stringify(result)).not.toContain("__fakeDb");
expect(JSON.stringify(result)).not.toContain("handler");
});
it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => {
betterAuthMock.mockReturnValue({});
const fakeDb = { __fakeDb: true };
createIdentityAuth({ config: validConfig(), db: fakeDb as never });
expect(drizzleAdapterMock).toHaveBeenCalledTimes(1);
const [db, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown];
expect(db).toBe(fakeDb);
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
});
it("enables no provider or plugin beyond the four core models", () => {
betterAuthMock.mockReturnValue({});
createIdentityAuth({ config: validConfig(), db: {} as never });
expect(betterAuthMock).toHaveBeenCalledTimes(1);
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(Object.keys(options).sort()).toEqual([
"baseURL",
"database",
"secret",
"trustedOrigins",
]);
expect(options.baseURL).toBe(validConfig().baseUrl);
expect(options.secret).toBe(validConfig().secret);
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
});
it("sanitizes a construction failure without leaking the underlying error, secret, or URL", () => {
const hostileSecret = validConfig().secret;
betterAuthMock.mockImplementation(() => {
throw new Error(
`Better Auth failed for secret ${hostileSecret} at https://internal.example.test`,
);
});
let captured: unknown;
try {
createIdentityAuth({ config: validConfig(), db: {} as never });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConstructionError);
expect(captured).toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain(hostileSecret);
expect(publicText).not.toContain("internal.example.test");
});
it("sanitizes an adapter construction failure the same way", () => {
drizzleAdapterMock.mockImplementation(() => {
throw new Error("adapter exploded with postgresql://admin:[email protected]/ekklesia");
});
let captured: unknown;
try {
createIdentityAuth({ config: validConfig(), db: {} as never });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConstructionError);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("db.internal");
expect(publicText).not.toContain("admin");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { getTableName } from "drizzle-orm";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
ekklesiaCoreAccount,
ekklesiaCoreSession,
ekklesiaCoreUser,
ekklesiaCoreVerification,
identityAdapterSchema,
} from "../src/identity/schema.js";
describe("Core identity Drizzle schema", () => {
it("uses explicit, unambiguous ekklesia_core_* physical table names", () => {
expect(getTableName(ekklesiaCoreUser)).toBe("ekklesia_core_user");
expect(getTableName(ekklesiaCoreSession)).toBe("ekklesia_core_session");
expect(getTableName(ekklesiaCoreAccount)).toBe("ekklesia_core_account");
expect(getTableName(ekklesiaCoreVerification)).toBe("ekklesia_core_verification");
});
it("explicitly maps every Better Auth logical model to its physical table object", () => {
expect(identityAdapterSchema).toEqual({
account: ekklesiaCoreAccount,
session: ekklesiaCoreSession,
user: ekklesiaCoreUser,
verification: ekklesiaCoreVerification,
});
});
it("gives the user table a unique email and a default-false verification flag", () => {
const config = getTableConfig(ekklesiaCoreUser);
const columns = Object.fromEntries(config.columns.map((column) => [column.name, column]));
expect(columns.id?.primary).toBe(true);
expect(columns.email?.isUnique).toBe(true);
expect(columns.email?.notNull).toBe(true);
expect(columns.email_verified?.hasDefault).toBe(true);
expect(columns.email_verified?.notNull).toBe(true);
});
it("scopes session and account rows to a user with cascading delete", () => {
for (const table of [ekklesiaCoreSession, ekklesiaCoreAccount]) {
const config = getTableConfig(table);
expect(config.foreignKeys).toHaveLength(1);
const foreignKey = config.foreignKeys[0];
if (!foreignKey) {
throw new Error("Expected a foreign key.");
}
const reference = foreignKey.reference();
expect(reference.columns.map((column) => column.name)).toEqual(["user_id"]);
expect(reference.foreignColumns.map((column) => column.name)).toEqual(["id"]);
expect(getTableName(reference.foreignTable)).toBe("ekklesia_core_user");
expect(foreignKey.onDelete).toBe("cascade");
}
});
it("indexes session.user_id, account.user_id, and verification.identifier for lookup", () => {
expect(
getTableConfig(ekklesiaCoreSession).indexes.map((index) =>
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
),
).toEqual([["user_id"]]);
expect(
getTableConfig(ekklesiaCoreAccount).indexes.map((index) =>
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
),
).toEqual([["user_id"]]);
expect(
getTableConfig(ekklesiaCoreVerification).indexes.map((index) =>
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
),
).toEqual([["identifier"]]);
});
it("requires a unique session token", () => {
const config = getTableConfig(ekklesiaCoreSession);
const token = config.columns.find((column) => column.name === "token");
expect(token?.isUnique).toBe(true);
expect(token?.notNull).toBe(true);
});
it("keeps every table in the default public PostgreSQL schema", () => {
for (const table of [
ekklesiaCoreUser,
ekklesiaCoreSession,
ekklesiaCoreAccount,
ekklesiaCoreVerification,
]) {
expect(getTableConfig(table).schema).toBeUndefined();
}
});
});
@@ -1,10 +1,13 @@
import { createHash, randomBytes } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type QueryResult, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverCoreMigrations,
type MigrationApproval,
type MigrationApprovalProvider,
MigrationRunnerError,
@@ -16,6 +19,12 @@ import type {
ModuleMigrationAggregate,
} from "../src/modules/migration-discovery.js";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const historyTable = "public.ekklesia_core_migrations";
const migrationLockNamespace = 1_162_562_380;
@@ -1236,3 +1245,215 @@ $body$;\n`;
});
});
});
const coreIdentityTables = Object.freeze([
"ekklesia_core_user",
"ekklesia_core_session",
"ekklesia_core_account",
"ekklesia_core_verification",
]);
async function loadRealCoreMigrations(): Promise<readonly DiscoveredModuleMigration[]> {
const migrations = await discoverCoreMigrations({ coreMigrationsDirectory });
if (migrations.length === 0) {
throw harnessError("The committed Core identity migration is missing.");
}
return migrations;
}
interface IndexRow extends QueryResultRow {
readonly indexname: string;
readonly tablename: string;
}
async function coreIdentityIndexNames(client: Client): Promise<readonly string[]> {
const result = await safeQuery<IndexRow>(
client,
`SELECT tablename, indexname FROM pg_catalog.pg_indexes
WHERE schemaname = 'public' AND tablename = ANY($1::text[])
ORDER BY tablename, indexname`,
[coreIdentityTables],
);
return Object.freeze(result.rows.map((row) => row.indexname));
}
describe.sequential("live PostgreSQL Core identity migration execution", () => {
it("plans the committed Core identity migration on a fresh database without mutation", async () => {
await withIsolatedDatabase("core_fresh_plan", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const inventory = aggregate(moduleFixture("core", coreMigrations));
const before = await databaseState(database.client);
const plan = await planModuleMigrations({
aggregate: inventory,
database: createPostgresMigrationDatabase(database.url),
});
expect(plan.entries.map((entry) => entry.status)).toEqual(
coreMigrations.map(() => "pending"),
);
expect(await databaseState(database.client)).toEqual(before);
expect(await relationExists(database.client, historyTable)).toBe(false);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(false);
}
});
});
it("applies the committed Core identity migration and creates exactly the expected tables and indexes", async () => {
await withIsolatedDatabase("core_apply", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const inventory = aggregate(moduleFixture("core", coreMigrations));
const result = await applyModuleMigrations({
aggregate: inventory,
database: createPostgresMigrationDatabase(database.url),
});
expect(result.applied.map((entry) => entry.identity)).toEqual(
coreMigrations.map((entry) => entry.identity),
);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
}
expect(await coreIdentityIndexNames(database.client)).toEqual([
"ekklesia_core_account_pkey",
"ekklesia_core_account_user_id_idx",
"ekklesia_core_session_pkey",
"ekklesia_core_session_token_unique",
"ekklesia_core_session_user_id_idx",
"ekklesia_core_user_email_unique",
"ekklesia_core_user_pkey",
"ekklesia_core_verification_identifier_idx",
"ekklesia_core_verification_pkey",
]);
const history = await readHistory(database.client);
expect(history).toHaveLength(coreMigrations.length);
expect(history.map(({ applied_at: _appliedAt, ...row }) => row)).toEqual(
coreMigrations.map((entry, index) => ({
applied_order: index + 1,
checksum: entry.checksum,
identity: entry.identity,
migration_index: entry.index,
migration_tag: entry.tag,
module_id: "core",
})),
);
});
});
it("performs a genuine no-op on a second Core identity apply", async () => {
await withIsolatedDatabase("core_no_op", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const inventory = aggregate(moduleFixture("core", coreMigrations));
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({ aggregate: inventory, database: migrationDatabase });
const before = await databaseState(database.client);
const result = await applyModuleMigrations({
aggregate: inventory,
database: migrationDatabase,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(await databaseState(database.client)).toEqual(before);
});
});
it("coexists with a fixture module migration, executing Core first as documented", async () => {
await withIsolatedDatabase("core_and_module", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const people = peopleInitial();
const inventory = aggregate(
moduleFixture("core", coreMigrations),
moduleFixture("people", [people]),
);
const result = await applyModuleMigrations({
aggregate: inventory,
database: createPostgresMigrationDatabase(database.url),
});
expect(result.applied.map((entry) => entry.identity)).toEqual([
...coreMigrations.map((entry) => entry.identity),
people.identity,
]);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
}
expect(await relationExists(database.client, "public.people_records")).toBe(true);
const history = await readHistory(database.client);
expect(history.map((row) => row.module_id)).toEqual([
...coreMigrations.map(() => "core"),
"people",
]);
});
});
it("does not corrupt previously valid module-only history when Core is introduced later", async () => {
await withIsolatedDatabase("core_added_later", async (database) => {
const people = peopleInitial();
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({
aggregate: aggregate(moduleFixture("people", [people])),
database: migrationDatabase,
});
const historyBeforeCore = await readHistory(database.client);
const coreMigrations = await loadRealCoreMigrations();
const result = await applyModuleMigrations({
aggregate: aggregate(
moduleFixture("core", coreMigrations),
moduleFixture("people", [people]),
),
database: migrationDatabase,
});
expect(result.applied.map((entry) => entry.identity)).toEqual(
coreMigrations.map((entry) => entry.identity),
);
const historyAfterCore = await readHistory(database.client);
const peopleRowBefore = historyBeforeCore.find((row) => row.module_id === "people");
const peopleRowAfter = historyAfterCore.find((row) => row.module_id === "people");
expect(peopleRowAfter).toEqual(peopleRowBefore);
expect(historyAfterCore).toHaveLength(historyBeforeCore.length + coreMigrations.length);
});
});
it("does not damage the committed Core identity migration when a later module migration fails", async () => {
await withIsolatedDatabase("core_rollback_safety", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({
aggregate: aggregate(moduleFixture("core", coreMigrations)),
database: migrationDatabase,
});
const historyAfterCore = await readHistory(database.client);
const failing = migration("broken", 0, {
sql: `CREATE TABLE "broken_records" ("id" bigint PRIMARY KEY);\n--> statement-breakpoint\nSELECT 1 / 0;\n`,
tag: "0000_broken",
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate(
moduleFixture("core", coreMigrations),
moduleFixture("broken", [failing]),
),
database: migrationDatabase,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(error.migrationIdentity).toBe(failing.identity);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
}
expect(await relationExists(database.client, "public.broken_records")).toBe(false);
expect(await readHistory(database.client)).toEqual(historyAfterCore);
await expectMigrationLockAvailable(database.client);
});
});
});
+6 -1
View File
@@ -4,5 +4,10 @@
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
"include": [
"drizzle.config.ts",
"identity-schema-generator.config.ts",
"src/**/*.ts",
"tests/**/*.ts"
]
}