Files
suaveplan/openspec/specs/api-auth-better-auth/spec.md
2026-05-27 23:29:53 +01:00

130 lines
8.3 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`