import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, } from "@ekklesia/contracts"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { sql } from "drizzle-orm"; import type { IdentityConfig } from "./config.js"; import type { IdentityDrizzleDatabase } from "./database.js"; import { identityAdapterSchema } from "./schema.js"; export type IdentityRuntimeErrorCode = | "AUTH_REQUEST_FAILED" | "IDENTITY_CONSTRUCTION_FAILED" | "PRINCIPAL_RESOLUTION_FAILED"; const runtimeErrorMessages: Readonly> = Object.freeze({ AUTH_REQUEST_FAILED: "The identity authentication request could not be handled.", IDENTITY_CONSTRUCTION_FAILED: "The identity runtime could not be constructed.", PRINCIPAL_RESOLUTION_FAILED: "The request principal could not be resolved.", }); const identityDatabaseProbeTimeoutMs = 3_000; /** Never carries the underlying Better Auth error, database error, cause, stack, secret, or URL. */ export class IdentityRuntimeError extends Error { readonly code: IdentityRuntimeErrorCode; constructor(code: IdentityRuntimeErrorCode) { super(runtimeErrorMessages[code]); this.name = "IdentityRuntimeError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export interface IdentityRateLimitOverride { readonly max?: number; readonly window?: number; } export interface CreateIdentityRuntimeOptions { readonly config: IdentityConfig; readonly db: IdentityDrizzleDatabase; /** * A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test * composition that issues many such requests quickly. Rate limiting itself is always enabled; * production composition must leave this unset so Better Auth's own strict built-in default * (3 requests / 10 seconds on sign-in and sign-up) applies unmodified. */ readonly rateLimitOverride?: IdentityRateLimitOverride; } /** * Deliberately narrow: exposes exactly two Fetch-standard capabilities and nothing else. It never * returns the Better Auth instance, `auth.api`, inferred user/session types, the Drizzle adapter, * the database, a session token, cookie contents, a password hash, or Better Auth configuration. */ export interface EkklesiaIdentityRuntime { /** Delegates directly to Better Auth's Fetch handler. Normal Better Auth HTTP responses * (including its own 4xx error responses) pass through unmodified; only a genuinely unexpected * failure is converted to a sanitized `IdentityRuntimeError`. */ handleAuthRequest(request: Request): Promise; /** Resolves the caller's Ekklesia principal from request headers. Returns the anonymous * principal for a missing, expired, revoked, or malformed session — never for an infrastructure * failure, which instead throws a sanitized `IdentityRuntimeError`. */ resolvePrincipal(headers: Headers): Promise; } async function withTimeout(operation: Promise, timeoutMs: number): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ operation, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error("timed out")), timeoutMs); }), ]); } finally { clearTimeout(timer); } } async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise { try { await withTimeout(db.execute(sql`select 1`), identityDatabaseProbeTimeoutMs); return true; } catch { return false; } } function constructBetterAuth(build: () => T): T { try { return build(); } catch { throw new IdentityRuntimeError("IDENTITY_CONSTRUCTION_FAILED"); } } /** * Core's Better Auth composition boundary and Fetch-compatible identity transport. Constructs * Better Auth explicitly from injected, already-validated configuration and an injected Drizzle * database — it never reads environment variables and enables no provider or plugin beyond the * four core models and email/password sign-up/sign-in required for this slice. */ export function createIdentityRuntime( options: CreateIdentityRuntimeOptions, ): EkklesiaIdentityRuntime { const rateLimitOverride = options.rateLimitOverride; const auth = constructBetterAuth(() => betterAuth({ baseURL: options.config.baseUrl, database: drizzleAdapter(options.db, { provider: "pg", schema: identityAdapterSchema, }), emailAndPassword: { autoSignIn: false, disableSignUp: false, enabled: true, }, rateLimit: { enabled: true, max: 100, window: 10, ...(rateLimitOverride ? { customRules: { "/sign-in/email": { max: rateLimitOverride.max ?? 100, window: rateLimitOverride.window ?? 10, }, "/sign-up/email": { max: rateLimitOverride.max ?? 100, window: rateLimitOverride.window ?? 10, }, }, } : {}), }, secret: options.config.secret, trustedOrigins: [...options.config.trustedOrigins], }), ); return Object.freeze({ handleAuthRequest: async (request: Request): Promise => { try { return await auth.handler(request); } catch { throw new IdentityRuntimeError("AUTH_REQUEST_FAILED"); } }, resolvePrincipal: async (headers: Headers): Promise => { let session: Awaited>; try { session = await auth.api.getSession({ headers }); } catch { // Defense in depth: the pinned Better Auth release already catches internal failures and // resolves to null instead of throwing here, but a future release is not guaranteed to. throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); } const userId = session?.user?.id; if (typeof userId === "string") { return createAuthenticatedPrincipal(userId); } // Better Auth's getSession swallows infrastructure failures internally and returns null // for them exactly like a genuinely missing, expired, or malformed session — it never // throws. A request with no Cookie header at all is unambiguous: Better Auth never queries // the database for that case, so it is always genuinely anonymous. Only when a cookie was // actually presented is the null result ambiguous enough to require disambiguation. if (headers.has("cookie")) { const reachable = await probeIdentityDatabase(options.db); if (!reachable) { throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); } } return ANONYMOUS_PRINCIPAL; }, }); }