Files
skills/docs/api-stack/api/api-auth-better-auth/src/principal/map-user-to-principal.md
2026-05-29 08:20:56 +01:00

3.0 KiB

map-user-to-principal

(no submodules)

Purpose

Default Better-Auth (user, session) → Principal mapping consumed by both createBetterAuthSession and createBetterAuthProvider. Pure function; given the same view it always returns the same Principal. Operators MAY supply a custom principalMapping in BetterAuthOptions to project Better-Auth's user row onto a custom claims topology, but the default is good enough for most deployments — it drops sensitive fields, brands id and scopes, and prefers session.activeOrganizationId over user.tenantId so the organisation plugin's tenant scoping wins automatically when wired.

Features

  • id ← user.id branded as PrincipalId.
  • scopes ← user.roles branded as Scope[] (empty array when absent).
  • tenantId ← session.activeOrganizationId → user.tenantId fallback.
  • claims ← rest of user minus sensitive fields; session_id appended.
  • Throws BetterAuthError(AUTH_BA.PRINCIPAL_MAPPING_FAILED) on missing id.
  • Returned Principal and its claims are frozen.

Usage: Basic

import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-auth-better-auth/principal";

const principal = mapBetterAuthUserToPrincipal({
    user: { id: "u_1", email: "a@b.c", roles: ["admin"] },
    session: { id: "s_1" },
});
// → { id: "u_1", scopes: ["admin"], claims: { id, email, roles, session_id } }

Usage: Advanced

import type { Principal } from "@suaveplan/api-types/principal";
import type { PrincipalMapping } from "@suaveplan/api-auth-better-auth/options";

const customMapping: PrincipalMapping = ({ user, session }): Principal => ({
    id: `tenant_${session.activeOrganizationId}_${user.id}` as never,
    scopes: ((user.roles ?? []) as readonly string[]).flatMap((role) =>
        role === "admin"
            ? ["billing:write", "billing:read", "users:write"]
            : [role]
    ) as never[],
    claims: { email: user.email, source: "better-auth" },
});

// Hand the custom mapping through BetterAuthOptions:
//   { ..., principalMapping: customMapping }

API Reference

Export Shape Purpose
mapBetterAuthUserToPrincipal(view) PrincipalMapping Default mapping consumed by both factories

Implementation Notes

The frozen-object return shape (Object.freeze on both the Principal and its claims bag) defends against handlers downstream mutating a single returned principal and observing the mutation on later requests — the same hardening applied by @suaveplan/api-auth-static-principal. The sensitive field list (password, passwordHash, salt, twoFactorSecret, magicLinkToken, resetPasswordToken) covers Better-Auth's stock plugin set; operators who define additional sensitive fields via a custom Better-Auth plugin SHOULD supply a custom principalMapping to drop those too. The mapping deliberately does NOT translate roles into a stable scope vocabulary — that's a deployment concern; the default is identity so the topology stays predictable.