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;
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
||||
section("Modules", ["modules.discovery", "modules.dependencies"]),
|
||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
||||
section("Identity", ["identity.configuration"]),
|
||||
section("Object storage", ["object-storage.integration"]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import {
|
||||
type CreateIdentityConfigOptions,
|
||||
createIdentityConfig,
|
||||
IdentityConfigError,
|
||||
} from "../identity/config.js";
|
||||
import { identityAdapterSchema } from "../identity/schema.js";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
type MigrationDatabase,
|
||||
type MigrationPlanResult,
|
||||
MigrationRunnerError,
|
||||
@@ -10,7 +19,6 @@ import {
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModuleMigrations,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
type ModuleDiscoveryOptions,
|
||||
@@ -26,6 +34,7 @@ export type DoctorCheckId =
|
||||
| "migrations.discovery"
|
||||
| "postgresql.connection"
|
||||
| "postgresql.plan"
|
||||
| "identity.configuration"
|
||||
| "object-storage.integration";
|
||||
|
||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||
@@ -43,8 +52,16 @@ export interface DoctorReport {
|
||||
readonly result: DoctorResult;
|
||||
}
|
||||
|
||||
export interface DoctorIdentityConfigurationOptions {
|
||||
readonly baseUrl?: string | undefined;
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
readonly secret?: string | undefined;
|
||||
readonly trustedOrigins?: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export interface DoctorOptions {
|
||||
readonly databaseUrl?: string | undefined;
|
||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
@@ -74,9 +91,18 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
|
||||
const migrationIdentityPattern =
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
||||
|
||||
async function discoverAllMigrationsForRuntime(
|
||||
options: ModuleDiscoveryOptions,
|
||||
): Promise<ModuleMigrationAggregate> {
|
||||
return discoverAllMigrations({
|
||||
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
|
||||
moduleDiscovery: options,
|
||||
});
|
||||
}
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
discoverMigrations: discoverModuleMigrations,
|
||||
discoverMigrations: discoverAllMigrationsForRuntime,
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
@@ -254,6 +280,56 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
||||
);
|
||||
}
|
||||
|
||||
function identitySchemaSummary(): string {
|
||||
const tableNames = Object.values(identityAdapterSchema)
|
||||
.map((table) => getTableName(table))
|
||||
.sort();
|
||||
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
||||
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
||||
* configuration is a skip, not a failure — Doctor does not require identity to be configured yet.
|
||||
*/
|
||||
function identityConfigurationCheck(
|
||||
input: DoctorIdentityConfigurationOptions | undefined,
|
||||
): DoctorCheck {
|
||||
const schemaDetail = identitySchemaSummary();
|
||||
const hasAnyValue =
|
||||
input?.secret !== undefined ||
|
||||
input?.baseUrl !== undefined ||
|
||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||
|
||||
if (!hasAnyValue) {
|
||||
return freezeCheck("identity.configuration", "skipped", "Identity configuration skipped", [
|
||||
schemaDetail,
|
||||
"Identity runtime configuration (signing key, base URL, trusted origins) is not set.",
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
createIdentityConfig(
|
||||
{
|
||||
baseUrl: input?.baseUrl,
|
||||
secret: input?.secret,
|
||||
trustedOrigins: input?.trustedOrigins,
|
||||
},
|
||||
{ environment: input?.environment ?? "production" },
|
||||
);
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof IdentityConfigError ? error.code : "IDENTITY_CONFIGURATION_INVALID";
|
||||
return freezeCheck("identity.configuration", "failure", "Identity configuration invalid", [
|
||||
schemaDetail,
|
||||
`[${code}] Configured identity values failed validation.`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
||||
@@ -380,6 +456,8 @@ export async function diagnoseEkklesia(
|
||||
}
|
||||
}
|
||||
|
||||
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
||||
|
||||
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
||||
"Core storage integration has not been implemented yet.",
|
||||
]);
|
||||
@@ -392,6 +470,7 @@ export async function diagnoseEkklesia(
|
||||
migrationCheck,
|
||||
postgresConnectionCheck,
|
||||
postgresPlanCheck,
|
||||
identityCheck,
|
||||
storageCheck,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./aggregate.js";
|
||||
export * from "./core-source.js";
|
||||
export * from "./postgres-database.js";
|
||||
export * from "./runner.js";
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user