# @suaveplan/api-auth-better-auth — Better-Auth adapter for the @suaveplan/api foundation. Ships two independently-wireable factories implementing the auth.session and auth.provider capability contracts against a Better-Auth instance backed by its own Postgres connection. Maps Better-Auth user+session onto the @suaveplan/api Principal contract, mounts canonical PROVIDER_ROUTE_SURFACE routes (translating Better-Auth's /sign-up etc. idiom), exposes a GitHub OAuth flow via Better-Auth's genericOAuth plugin (so the mock OAuth IdP can be wired end-to-end), guards route-prefix collisions against @suaveplan/api-host's reserved-prefix contract, and emits OTel sessions/oauth/mapping instruments. The ./testing subpath re-exports both conformance suites bound to Postgres-backed factories. Stage-6 Better-Auth adapter for `@suaveplan/api`. Ships two **independently-wireable** capability surfaces — `createBetterAuthSession` (implements `auth.session` from `@suaveplan/api-auth`) and `createBetterAuthProvider` (implements `auth.provider` from `@suaveplan/api-auth-provider`). Operators MAY wire either side in isolation OR both together; sharing one options bag between the two factories shares the underlying Better-Auth instance + DB pool. > **Two capability surfaces, wire independently.** The session adapter > reads cookies + bearer tokens; the provider adapter mounts the canonical > `PROVIDER_ROUTE_SURFACE` (9 routes) under a configurable prefix. They > share no required code path — wiring only one side does NOT install the > other side's surface. ## Documentation tree - [db](./src/db/database.md) — Lazy holder for the underlying Better-Auth instance plus a helper that projects {@link BetterAuthOptions} onto the loos… - [database](./src/db/database.md) — Lazy holder for the underlying Better-Auth instance plus a helper that projects {@link BetterAuthOptions} onto the loos… - [error-codes](./src/error-codes/error-codes.md) — Stable, tracked error-code registry for `@suaveplan/api-auth-better-auth`. Every fault path inside the adapter… - [error-codes](./src/error-codes/error-codes.md) — Stable, tracked error-code registry for `@suaveplan/api-auth-better-auth`. Every fault path inside the adapter… - [independence](./src/independence/session-without-provider.md) — Pin the design invariant from the Stage-6 spec change: the Better-Auth adapter ships two factories — `createBetterA… - [session-without-provider](./src/independence/session-without-provider.md) — Pin the design invariant from the Stage-6 spec change: the Better-Auth adapter ships two factories — `createBetterA… - [instruments](./src/instruments/instruments.md) — Lazily-bound OpenTelemetry instrument bundle for the Better-Auth adapter. Built once per process against the current gl… - [instruments](./src/instruments/instruments.md) — Lazily-bound OpenTelemetry instrument bundle for the Better-Auth adapter. Built once per process against the current gl… - [default-factory](./src/oauth/default-factory.md) — Default `betterAuthFactory` used when an operator does not supply one via `BetterAuthOptions.betterAuthFactory`. Dynami… - [github](./src/oauth/github.md) — Project Suaveplan's {@link BetterAuthOauthProvider} entries onto the `genericOAuth` plugin config Better-Auth consumes.… - [options](./src/options/options.md) — Zod-validated boundary for the Better-Auth adapter's configuration bag. Both `createBetterAuthSession` and `createBette… - [options](./src/options/options.md) — Zod-validated boundary for the Better-Auth adapter's configuration bag. Both `createBetterAuthSession` and `createBette… - [principal](./src/principal/map-user-to-principal.md) — Default Better-Auth `(user, session)` → `Principal` mapping consumed by both `createBetterAuthSession` and `createBette… - [map-user-to-principal](./src/principal/map-user-to-principal.md) — Default Better-Auth `(user, session)` → `Principal` mapping consumed by both `createBetterAuthSession` and `createBette… - [create-provider](./src/provider/create-provider.md) — Factory for the `auth.provider` half of the Stage-6 split. Returns an `AuthProviderAdapter` (per `@suaveplan/api-auth-p… - [route-translation](./src/provider/route-translation.md) — Translation table mapping every entry in `PROVIDER_ROUTE_SURFACE` (the canonical 9-route foundation surface) to the mat… - [route-prefix](./src/route-prefix/route-prefix.md) — Resolve an operator-supplied `routePrefix` against the host's reserved-prefix contract from `@suaveplan/api-host/reserv… - [route-prefix](./src/route-prefix/route-prefix.md) — Resolve an operator-supplied `routePrefix` against the host's reserved-prefix contract from `@suaveplan/api-host/reserv… - [session](./src/session/create-session.md) — Factory for the `auth.session` half of the Stage-6 split. Returns a `SessionResolver` that asks Better-Auth's `auth.api… - [create-session](./src/session/create-session.md) — Factory for the `auth.session` half of the Stage-6 split. Returns a `SessionResolver` that asks Better-Auth's `auth.api… - [testing](./src/testing/testing.md) — `@suaveplan/api-auth-better-auth/testing` is the subpath downstream packages import when they want to assert their own… - [testing](./src/testing/testing.md) — `@suaveplan/api-auth-better-auth/testing` is the subpath downstream packages import when they want to assert their own… ## Modules - **`error-codes/`** — 10-code `AUTH_BA.*` registry + `BetterAuthError`. - **`options/`** — Zod-validated `BetterAuthOptions` boundary + canonical defaults. - **`db/`** — `LazyBetterAuth` (lazy + cached + concurrency-safe) + `buildBetterAuthFactoryArgs` (outer-options → Better-Auth-args projection). - **`principal/`** — default Better-Auth `(user, session)` → `Principal` mapping; replaceable via `BetterAuthOptions.principalMapping`. - **`session/`** — `createBetterAuthSession(opts)` factory implementing the `SessionResolver` contract. - **`provider/`** — `createBetterAuthProvider(opts)` factory implementing the `AuthProviderAdapter` contract; mounts canonical routes that rewrite onto Better-Auth's native sub-paths. - **`route-prefix/`** — `assertRoutePrefixAvailable` collision check against `@suaveplan/api-host/reserved-prefix`. - **`oauth/`** — `genericOAuth` plugin config builder + default `betterAuth(...)` factory that dynamically loads the plugin when OAuth providers are configured. The GitHub OAuth flow is the canonical reference; the implementation works against the real GitHub IdP in production and the mock IdP from `@suaveplan/testing-integration-oauth-mock` in tests because we use `genericOAuth` (which accepts the full URL triple) rather than the hard-coded `github` social-provider. - **`instruments/`** — OTel counters + histograms per the spec's metrics contract. - **`independence/`** — invariant pin: session-only / provider-only wiring exposes nothing from the other side. - **`testing/`** — `./testing` subpath re-exporting both conformance harnesses bound to Better-Auth factories. ## Usage ```ts import { Pool } from "pg"; import { createBetterAuthProvider, createBetterAuthSession, } from "@suaveplan/api-auth-better-auth"; const pool = new Pool({ connectionString: process.env.BETTER_AUTH_DB_URL! }); const opts = { database: pool, baseUrl: "https://api.example.com", secret: process.env.BETTER_AUTH_SECRET!, routePrefix: "/auth", oauth: { providers: { github: { providerId: "github", clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, authorizationUrl: "https://github.com/login/oauth/authorize", tokenUrl: "https://github.com/login/oauth/access_token", userInfoUrl: "https://api.github.com/user", }, }, }, }; // Wire both sides — they share one Better-Auth instance via per-options WeakMap: host.registerAuth({ resolver: createBetterAuthSession(opts), csrf: true, }); host.registerAuthProvider(createBetterAuthProvider(opts)); ``` ## Error codes | Code | Surface | When thrown | | --- | --- | --- | | `AUTH_BA.INVALID_SESSION` | session | unexpected `getSession` failure | | `AUTH_BA.DB_UNREACHABLE` | db | `betterAuth({...})` construction threw | | `AUTH_BA.OAUTH_PROVIDER_REJECTED` | oauth | provider returned 403 | | `AUTH_BA.OAUTH_STATE_MISMATCH` | oauth | state cookie did not match | | `AUTH_BA.USER_NOT_FOUND` | mapping | custom mapper signalled missing user | | `AUTH_BA.EMAIL_NOT_VERIFIED` | session | session present but email unverified | | `AUTH_BA.RATE_LIMITED` | provider | Better-Auth rate-limiter blocked | | `AUTH_BA.INVALID_REDIRECT` | oauth | redirect URI outside allow-list | | `AUTH_BA.PRINCIPAL_MAPPING_FAILED` | mapping | mapper threw | | `AUTH_BA.ROUTE_PREFIX_COLLISION` | mount | prefix collides with host-reserved | ## Conformance Conformance tests live at: - `src/session/create-session.spec-conformance.test.ts` — drives `runAuthSessionConformance` against a Postgres-shaped stubbed `BetterAuthLike` (the contract asserts the abstract `SessionResolver` invariants; DB backing is orthogonal). - `src/provider/create-provider.spec-conformance.test.ts` — drives `runAuthProviderConformance` against `createBetterAuthProvider` with the same stubbing topology. - `tests/integration/postgres-better-auth.test.ts` — real Postgres container + real Better-Auth + register/login/resolve. Gracefully skips with a logged reason when Docker / `pg` / schema bootstrap is unavailable in the runner environment. ## Architecture ``` host config │ ▼ ┌─────────────────────────────┐ │ BetterAuthOptions (Zod) │ parseBetterAuthOptions(...) └──────────────┬──────────────┘ │ per-opts WeakMap │ ▼ ┌──────────────┐ │ LazyBetterAuth│ ── betterAuth({ ...args, plugins: [genericOAuth(...)] }) └─────┬────────┘ │ shared (single connection pool) ┌───────┴────────┐ ▼ ▼ createBetterAuthSession createBetterAuthProvider (SessionResolver) (AuthProviderAdapter) │ │ │ ├── mounts /register, /login, /logout, /refresh, │ │ /oauth/:provider/start, /oauth/:provider/callback, │ │ /verify-email/:token, /forgot-password, /reset-password ▼ ▼ Principal auth.handler(rewrittenRequest) ```