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

8.3 KiB

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"