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
+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;
});