feat: expose core identity transport
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./runtime.js";
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user