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
+26
-1
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user