import type { SessionResolver } from "@suaveplan/api-auth"; import type { Principal } from "@suaveplan/api-types/principal"; import { LazyBetterAuth } from "../db/database.js"; import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js"; import { getBetterAuthInstruments } from "../instruments/instruments.js"; import { defaultBetterAuthFactory } from "../oauth/default-factory.js"; import { parseBetterAuthOptions } from "../options/options.js"; import type { BetterAuthOptions } from "../options/options.types.js"; import { mapBetterAuthUserToPrincipal } from "../principal/map-user-to-principal.js"; /** * Construct a `SessionResolver` that asks Better-Auth's `auth.api.getSession` * to materialise the current session from the request's cookie/bearer, then * maps the resulting `(user, session)` view onto a `Principal` per * `@suaveplan/api-types`. Returns `null` when Better-Auth reports no * session — the host's resolver chain then advances to the next adapter * (typically `api-auth-anonymous`). * * Operators MAY share the underlying Better-Auth instance with * `createBetterAuthProvider(opts)` by passing the same options bag — the * factories memoise per-options so both sides resolve through one pool. * Wiring only the session adapter is the documented "Better-Auth for * session reads, Clerk for account lifecycle" topology and works without * standing up the provider routes. */ export function createBetterAuthSession( rawOptions: BetterAuthOptions ): SessionResolver { const opts = parseBetterAuthOptions(rawOptions); const factory = opts.betterAuthFactory ?? defaultBetterAuthFactory; const lazy = new LazyBetterAuth(opts, factory); const map = opts.principalMapping ?? mapBetterAuthUserToPrincipal; let disposed = false; return { async resolve(req: Request): Promise { if (disposed) return null; const instruments = getBetterAuthInstruments(); const started = nowMs(); let outcome: "resolved" | "null" | "error" = "null"; try { const auth = await lazy.get(); const getSession = auth.api.getSession; if (getSession === undefined) { return null; } const sessionView = await getSession({ headers: req.headers, }); if (sessionView === null) { return null; } let principal: Principal; try { principal = map(sessionView); } catch (err) { instruments.principalMappingErrorsTotal.add(1); outcome = "error"; throw err instanceof BetterAuthError ? err : new BetterAuthError( AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED, "principalMapping threw during session resolution", { cause: err } ); } outcome = "resolved"; return principal; } catch (err) { outcome = "error"; if (err instanceof BetterAuthError) { throw err; } throw new BetterAuthError( AUTH_BA_CODES.INVALID_SESSION, "Better-Auth session resolution failed", { cause: err } ); } finally { const duration = nowMs() - started; instruments.sessionsResolvedTotal.add(1, { outcome }); instruments.sessionResolveDurationMs.record(duration, { outcome, }); } }, async dispose(): Promise { if (disposed) return; disposed = true; lazy.reset(); }, }; } /* istanbul ignore next -- `performance` is universally present on Node 18+ + Bun; the Date.now() fallback exists only as a defensive guard for stripped-down runtimes. */ function nowMs(): number { return typeof performance === "object" && typeof performance.now === "function" ? performance.now() : Date.now(); }