feat: expose core identity transport
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 19s
CI / migration-smoke (pull_request) Successful in 26s

This commit is contained in:
ekklesia
2026-07-21 19:15:14 -05:00
parent 82fe338a8d
commit 91d8f5946f
33 changed files with 2672 additions and 224 deletions
+6
View File
@@ -60,6 +60,12 @@ export function createIdentityDrizzleDatabase(
}
const pool = new Pool(poolConfig);
// pg's own documentation requires an error listener on the pool: an idle client can be
// terminated by the server (for example, its backend is killed, or the network drops) after
// being returned to the pool, and the pool surfaces that as an 'error' event on itself rather
// than on any in-flight query. Without a listener, that event is an unhandled Node.js error and
// can crash the process even though no caller is doing anything wrong at the time.
pool.on("error", () => undefined);
const db = drizzle(pool, { schema: identityAdapterSchema });
return Object.freeze({
-58
View File
@@ -1,58 +0,0 @@
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 });
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./config.js";
export * from "./database.js";
export * from "./runtime.js";
+187
View File
@@ -0,0 +1,187 @@
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<Record<IdentityRuntimeErrorCode, string>> = 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<Response>;
/** 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<EkklesiaPrincipal>;
}
async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise<boolean> {
try {
await withTimeout(db.execute(sql`select 1`), identityDatabaseProbeTimeoutMs);
return true;
} catch {
return false;
}
}
function constructBetterAuth<T>(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<Response> => {
try {
return await auth.handler(request);
} catch {
throw new IdentityRuntimeError("AUTH_REQUEST_FAILED");
}
},
resolvePrincipal: async (headers: Headers): Promise<EkklesiaPrincipal> => {
let session: Awaited<ReturnType<typeof auth.api.getSession>>;
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;
},
});
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./diagnostics/index.js";
export * from "./identity/index.js";
export * from "./modules/index.js";
export * from "./migrations/index.js";