# Capability Spec Delta: api-auth-better-auth (api-auth-better-auth) **Package**: `@suaveplan/api-auth-better-auth` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line. ## ADDED Requirements ### Requirement: Ships two independently-wireable capability surfaces The package SHALL export two factory functions — `createBetterAuthSession(opts)` implementing the `auth.session` contract from `@suaveplan/api-auth`, and `createBetterAuthProvider(opts)` implementing the `auth.provider` contract from `@suaveplan/api-auth-provider`. Each MUST be usable independently. Operators MAY wire only the session adapter (keeping their existing account-lifecycle routes elsewhere, e.g. Clerk) OR only the provider adapter (rare) OR both. Wiring only one side MUST NOT mount any routes from the other side. #### Scenario: session-only wiring exposes no provider routes - **WHEN** host config mounts `createBetterAuthSession(opts)` but does NOT mount `createBetterAuthProvider` - **THEN** routes under `routePrefix` (default `/auth/*`) are NOT registered AND `auth.session` is callable #### Scenario: provider-only wiring exposes no session resolver - **WHEN** host config mounts `createBetterAuthProvider(opts)` but does NOT mount `createBetterAuthSession` - **THEN** the provider's account-lifecycle routes are registered AND the `auth.session` slot is filled by whatever other resolver host config supplies (e.g. JWT) **Verified by:** `src/independence/session-without-provider.test.ts::"session adapter mounts without provider routes"` AND `src/independence/session-without-provider.test.ts::"provider adapter mounts without claiming auth.session"` --- ### Requirement: Better-Auth owns its own DB connection The package SHALL construct its own Postgres connection (via `BetterAuthOptions.database`) distinct from the host's main DB and from every plugin's DB. Better-Auth's schema (users, sessions, accounts, verification, oauth state) lives in that connection. The wrapper MUST NOT attempt to share the host's metadata DB or any plugin's DB — Better-Auth's opinionated model forbids it and bridging would defeat upstream migrations. #### Scenario: configured DB is a distinct connection from the host metadata DB - **WHEN** `BetterAuthOptions.database` is set - **THEN** the wrapper instantiates its own connection AND does NOT call into `api-host`'s metadata-DB handle **Verified by:** `tests/integration/postgres-better-auth.test.ts::"Better-Auth uses its own DB connection independent of host metadata DB"` --- ### Requirement: Maps Better-Auth user + session onto our Principal contract `createBetterAuthSession` SHALL resolve a session via Better-Auth's session APIs, then map the resulting user + session into a `Principal` per `@suaveplan/api-types`. The default mapping populates `id` from `user.id`, `scopes` from `user.roles` (translated via `principalMapping.scopeFromRoles`), `tenantId` from `user.tenantId` (when present), and `claims` from `user` minus its sensitive fields. Operators MAY override the mapping via `BetterAuthOptions.principalMapping`. A mapping failure MUST throw `AUTH_BA_PRINCIPAL_MAPPING_FAILED` (not silently emit a partial principal). #### Scenario: well-formed Better-Auth user produces a Principal - **WHEN** Better-Auth returns a session for a known user - **THEN** `auth.session(request)` returns a `Principal` whose `id` equals `user.id` AND whose `scopes` reflect the role mapping #### Scenario: mapping failure surfaces explicitly - **WHEN** the custom `principalMapping` function throws - **THEN** the session resolver propagates `AUTH_BA_PRINCIPAL_MAPPING_FAILED` AND does NOT return a `null` principal **Verified by:** `src/session/create-session.test.ts::"maps well-formed Better-Auth user to Principal"` AND `src/session/create-session.test.ts::"mapping failure throws AUTH_BA_PRINCIPAL_MAPPING_FAILED"` --- ### Requirement: Configurable route prefix with collision detection `createBetterAuthProvider` SHALL mount account-lifecycle routes under `routePrefix` (default `/auth`) using the canonical names from `PROVIDER_ROUTE_SURFACE` (`@suaveplan/api-auth-provider`): `POST /register`, `POST /login`, `POST /logout`, `POST /refresh`, `GET /verify-email/:token`, `GET /oauth/:provider/start`, `GET /oauth/:provider/callback`, `POST /forgot-password`, `POST /reset-password`. Better-Auth's native idiom (`/sign-up`, `/sign-in`, `/sign-out`) is adapted to canonical names inside this package; consumers of the provider see the canonical surface only. The provider MUST refuse to mount if the requested prefix collides with a host-reserved prefix — the reserved-prefix list is sourced from the `@suaveplan/api-host` reserved-prefix contract (NOT hard-coded in this package). Install fails with `AUTH_BA_ROUTE_PREFIX_COLLISION`. #### Scenario: default prefix mounts every documented route - **WHEN** the provider mounts with `routePrefix` left at default - **THEN** every documented route is reachable under `/auth/*` #### Scenario: collision with reserved prefix is refused - **WHEN** `routePrefix: "/_host"` is supplied - **THEN** install fails with `AUTH_BA_ROUTE_PREFIX_COLLISION` **Verified by:** `src/route-prefix/route-prefix.test.ts::"default prefix mounts every documented route"` AND `src/route-prefix/route-prefix.test.ts::"reserved-prefix collision fails with AUTH_BA_ROUTE_PREFIX_COLLISION"` --- ### Requirement: End-to-end GitHub OAuth flow The provider SHALL ship a working GitHub OAuth flow as the canonical reference. The full flow — authorization redirect with state cookie, code exchange, user-info fetch, principal mapping, session cookie issue — MUST be exercised end-to-end against a mock OAuth server in an integration test that the spec pins. OAuth state-mismatch MUST fail with `AUTH_BA_OAUTH_STATE_MISMATCH`; provider rejection MUST fail with `AUTH_BA_OAUTH_PROVIDER_REJECTED`. #### Scenario: full GitHub OAuth flow yields an authenticated session - **WHEN** the user completes `GET /auth/oauth/github/start` → `GET /auth/oauth/github/callback?code=...&state=...` against the mock OAuth IdP from `@suaveplan/testing-integration-oauth-mock` - **THEN** a session cookie is set AND a subsequent request resolves to a `Principal` whose claims include the GitHub user id #### Scenario: state mismatch is rejected - **WHEN** the callback arrives with a `state` that does not match the issued cookie - **THEN** the callback responds with `AUTH_BA_OAUTH_STATE_MISMATCH` **Verified by:** `src/oauth/github-oauth.test.ts::"full GitHub OAuth flow yields authenticated Principal"` AND `src/oauth/github-oauth.test.ts::"state mismatch rejected with AUTH_BA_OAUTH_STATE_MISMATCH"` --- ### Requirement: Conformance suites pass against real Postgres The package SHALL pass `runAuthSessionConformance` (from `@suaveplan/api-auth/testing`) AND `runAuthProviderConformance` (from `@suaveplan/api-auth-provider/testing`) when its factories are backed by a real Postgres container via `@suaveplan/testing-integration-postgres`. #### Scenario: session conformance passes - **WHEN** the suite runs against `createBetterAuthSession(opts)` with Postgres-backed Better-Auth - **THEN** every invariant in `runAuthSessionConformance` passes #### Scenario: provider conformance passes - **WHEN** the suite runs against `createBetterAuthProvider(opts)` with Postgres-backed Better-Auth - **THEN** every invariant in `runAuthProviderConformance` passes **Verified by:** `src/session/create-session.spec-conformance.test.ts::"createBetterAuthSession passes runAuthSessionConformance against Postgres"` AND `src/provider/create-provider.spec-conformance.test.ts::"createBetterAuthProvider passes runAuthProviderConformance against Postgres"` --- ### Requirement: OTel surface The package SHALL emit: - `auth.better_auth.sessions_resolved_total` counter `{ outcome }` (`"resolved"`, `"null"`, `"error"`). - `auth.better_auth.session_resolve_duration_ms` histogram. - `auth.better_auth.oauth_flows_total` counter `{ provider, outcome }`. - `auth.better_auth.principal_mapping_errors_total` counter. #### Scenario: successful session resolution increments the counter - **WHEN** `auth.session(request)` returns a non-null `Principal` - **THEN** an in-memory `MeterProvider` observes `sessions_resolved_total{outcome="resolved"}` increment by 1 **Verified by:** `src/instruments/instruments.test.ts::"sessions_resolved_total increments on successful resolution"`