import type { Claims, Principal, PrincipalId, Scope, } from "@suaveplan/api-types/principal"; import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js"; import type { BetterAuthSessionRecord, BetterAuthSessionView, BetterAuthUserView, PrincipalMapping, } from "../options/options.types.js"; const SENSITIVE_USER_FIELDS = new Set([ "password", "passwordHash", "salt", "twoFactorSecret", "magicLinkToken", "resetPasswordToken", ]); /** * Default Better-Auth user/session → `Principal` mapping. Produces a * stable `Principal` carrying: * * - `id` ← `user.id` (branded as `PrincipalId`). * - `scopes` ← `user.roles` (branded as `Scope[]`); empty array when * no roles are attached. Plugins/middleware further upstream * enrich the scope set; the mapper itself does no role-to-scope * translation beyond identity. * - `tenantId` ← `session.activeOrganizationId` (when present), else * `user.tenantId` (when present), else `undefined`. * - `claims` ← `user` minus sensitive fields plus a `session_id` claim * carrying the Better-Auth session id (when present). * * Throws {@link BetterAuthError} with code * `AUTH_BA.PRINCIPAL_MAPPING_FAILED` when `user.id` is missing — without a * stable id the principal cannot be persisted across requests. */ export const mapBetterAuthUserToPrincipal: PrincipalMapping = ( view: BetterAuthSessionView ): Principal => { const { user, session } = view; if (typeof user.id !== "string" || user.id.length === 0) { throw new BetterAuthError( AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED, "Better-Auth user is missing a non-empty `id`", { context: { received: { idType: typeof user.id } } } ); } const id = user.id as unknown as PrincipalId; const scopes = pickScopes(user.roles); const tenantId = pickTenant(user, session); const claims = buildClaims(user, session); if (tenantId !== undefined) { return Object.freeze({ id, scopes, tenantId, claims }) as Principal; } return Object.freeze({ id, scopes, claims }) as Principal; }; function pickScopes( roles: readonly string[] | null | undefined ): readonly Scope[] { if (!Array.isArray(roles)) return []; const out: Scope[] = []; for (const role of roles) { if (typeof role === "string" && role.length > 0) { out.push(role as unknown as Scope); } } return Object.freeze(out); } function pickTenant( user: BetterAuthUserView, session: BetterAuthSessionRecord ): import("@suaveplan/api-types/principal").Principal["tenantId"] | undefined { const fromSession = session.activeOrganizationId; if (typeof fromSession === "string" && fromSession.length > 0) { return fromSession as unknown as import("@suaveplan/api-types/principal").Principal["tenantId"]; } const fromUser = user.tenantId; if (typeof fromUser === "string" && fromUser.length > 0) { return fromUser as unknown as import("@suaveplan/api-types/principal").Principal["tenantId"]; } return undefined; } function buildClaims( user: BetterAuthUserView, session: BetterAuthSessionRecord ): Claims { const claims: Record = {}; for (const [key, value] of Object.entries(user)) { if (SENSITIVE_USER_FIELDS.has(key)) continue; if (value === undefined) continue; claims[key] = value; } if (typeof session.id === "string" && session.id.length > 0) { claims["session_id"] = session.id; } return Object.freeze(claims); }