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 mountcreateBetterAuthProvider - THEN routes under
routePrefix(default/auth/*) are NOT registered ANDauth.sessionis callable
Scenario: provider-only wiring exposes no session resolver
- WHEN host config mounts
createBetterAuthProvider(opts)but does NOT mountcreateBetterAuthSession - THEN the provider's account-lifecycle routes are registered AND the
auth.sessionslot 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.databaseis 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 aPrincipalwhoseidequalsuser.idAND whosescopesreflect the role mapping
Scenario: mapping failure surfaces explicitly
- WHEN the custom
principalMappingfunction throws - THEN the session resolver propagates
AUTH_BA_PRINCIPAL_MAPPING_FAILEDAND does NOT return anullprincipal
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
routePrefixleft 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
Principalwhose claims include the GitHub user id
Scenario: state mismatch is rejected
- WHEN the callback arrives with a
statethat 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
runAuthSessionConformancepasses
Scenario: provider conformance passes
- WHEN the suite runs against
createBetterAuthProvider(opts)with Postgres-backed Better-Auth - THEN every invariant in
runAuthProviderConformancepasses
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_totalcounter{ outcome }("resolved","null","error").auth.better_auth.session_resolve_duration_mshistogram.auth.better_auth.oauth_flows_totalcounter{ provider, outcome }.auth.better_auth.principal_mapping_errors_totalcounter.
Scenario: successful session resolution increments the counter
- WHEN
auth.session(request)returns a non-nullPrincipal - THEN an in-memory
MeterProviderobservessessions_resolved_total{outcome="resolved"}increment by 1
Verified by: src/instruments/instruments.test.ts::"sessions_resolved_total increments on successful resolution"