Files
suaveplan/packages/api-stack/api/api-auth-better-auth/src/session/create-session.ts
2026-05-27 23:29:53 +01:00

102 lines
4.3 KiB
TypeScript

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<Principal | null> {
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<void> {
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();
}