Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
# `@suaveplan/api-auth-better-auth` — Better-Auth adapter implementing both auth contracts
**Package**: `@suaveplan/api-auth-better-auth`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 6 (Distributed transactions + Better-Auth) — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Per design doc §2.3 the foundation deliberately separates two auth contracts: `auth.session` (Request → Principal | null, on every request) and `auth.provider` (account-lifecycle routes — register / login / logout / oauth / verify-email). Conflating them is the failure mode that makes most plugin systems painful to integrate with third-party auth. Shipping Better-Auth on day one is how we know those contracts hold up against a real non-trivial third-party library — wrapping it surfaces the contract pinch points before they ossify.
Better-Auth is opinionated about owning its own DB connection. This package reflects that reality: it consumes its own DB rather than the host's main DB. It then maps Better-Auth's user/session shapes onto our `Principal` contract so plugins never see Better-Auth-shaped types — only the foundation's `Principal`.
## What changes
Net-new package depending on `@suaveplan/api-types`, `@suaveplan/api-auth` (session contract), `@suaveplan/api-auth-provider` (provider contract), `@suaveplan/api-router`, `@suaveplan/error`, `@suaveplan/logger`, `@suaveplan/opentelemetry`, `better-auth` (peer), and `zod`.
- ADDED: `createBetterAuthSession(opts)` — implements the `auth.session` capability. Reads cookies/bearer per Better-Auth conventions, maps Better-Auth's session shape into `Principal`.
- ADDED: `createBetterAuthProvider(opts)` — implements the `auth.provider` capability. Mounts account-lifecycle routes (default prefix `/auth/*`, configurable via `routePrefix`). Includes OAuth flow.
- ADDED: `BetterAuthOptions` — Zod-validated config: `database` (separate from host main DB), `baseUrl`, `routePrefix` (default `/auth`), `secret`, `oauth.providers` (GitHub mandatory in spec, others optional), `session.cookieName`, `session.cookieSameSite`, `principalMapping` (function mapping Better-Auth user → `Principal`).
- ADDED: `mapBetterAuthUserToPrincipal(user, session)` — pure helper producing a `Principal` from Better-Auth's shapes. Default mapping; replaceable via `principalMapping` option.
- ADDED: Wired-only-one-side support — host config can mount the session adapter WITHOUT the provider adapter (operator wants Better-Auth's session resolver but a different account-lifecycle path, e.g. Clerk for accounts but Better-Auth as the session reader against a shared cookie). Each factory is independent.
- ADDED: GitHub OAuth integration as the canonical reference flow.
- ADDED: `AUTH_BA_*` error codes: `AUTH_BA_INVALID_SESSION`, `AUTH_BA_DB_UNREACHABLE`, `AUTH_BA_OAUTH_PROVIDER_REJECTED`, `AUTH_BA_OAUTH_STATE_MISMATCH`, `AUTH_BA_USER_NOT_FOUND`, `AUTH_BA_EMAIL_NOT_VERIFIED`, `AUTH_BA_RATE_LIMITED`, `AUTH_BA_INVALID_REDIRECT`, `AUTH_BA_PRINCIPAL_MAPPING_FAILED`, `AUTH_BA_ROUTE_PREFIX_COLLISION`.
- ADDED: OTel instruments: `auth.better_auth.sessions_resolved_total`, `auth.better_auth.session_resolve_duration_ms`, `auth.better_auth.oauth_flows_total { provider, outcome }`, `auth.better_auth.principal_mapping_errors_total`.
- ADDED: `./testing` subpath — re-export of `runAuthSessionConformance` AND `runAuthProviderConformance` bound to factories backed by an integration Postgres container.
## Out of scope
- Re-export of Better-Auth's broader feature surface (passkeys, magic links, 2FA flows beyond OTP, organisation management). Operators wire those via Better-Auth config directly; the wrapper just plumbs them through.
- A non-Postgres backend for Better-Auth. Better-Auth supports multiple databases; the conformance test pins Postgres because that's what the design doc says is integration-tested. Other backends MAY work but are not contracted.
- The abstract `auth.session` and `auth.provider` contracts — those live in `api-auth` and `api-auth-provider` (Stage 1).
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| Implements both `auth.session` AND `auth.provider` | §2.3 (bullet 4) |
| Owns its own DB connection (separate from plugin DBs) | §2.3 (bullet 4) |
| Maps Better-Auth user/session onto our `Principal` | §2.3 (bullet 4) |
| Integration-tested against real Postgres | §2.3 (bullet 4) |
| Default route prefix `/auth/*` (configurable) | §2.3 (paragraph 2) |
| Operator MAY wire only the session side OR only the provider side | §2.3 (paragraph 2 "Mix-and-match") |
| Surfaces contract pinch points before they ossify (= our verification stance) | §2.3 (bullet 4) |
## Verification approach
- `src/session/create-session.test.ts` — pure-unit tests over `mapBetterAuthUserToPrincipal`; missing-fields, extra-claims, scope translation.
- `src/session/create-session.spec-conformance.test.ts` — runs `runAuthSessionConformance` against the factory backed by a real Postgres container.
- `src/provider/create-provider.spec-conformance.test.ts` — runs `runAuthProviderConformance` against the factory backed by a real Postgres container.
- `src/oauth/github-oauth.test.ts` (integration, 60000ms) — end-to-end GitHub OAuth flow against a mock OAuth server; pins state-cookie handling, code exchange, principal mapping.
- `src/route-prefix/route-prefix.test.ts` — configurable prefix; collision detection vs host-reserved prefixes raises `AUTH_BA_ROUTE_PREFIX_COLLISION`.
- `src/independence/session-without-provider.test.ts` — operator wires only the session adapter; assert provider routes are NOT mounted AND session resolution still works.
- `tests/integration/postgres-better-auth.test.ts` (60000ms) — full lifecycle: register a user via provider routes, log in, hit a protected route, verify `ctx.principal` reflects the Better-Auth user.
## Migration impact
None — additive only.

View File

@@ -0,0 +1,129 @@
<!-- 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"`

View File

@@ -0,0 +1,72 @@
# Tasks — `@suaveplan/api-auth-better-auth`
> Every checkbox MUST reflect work actually done in this change.
## 0. Pre-flight (non-negotiable)
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [x] 1.1 Read design doc §2.3 (auth contracts) end-to-end
- [x] 1.2 Read umbrella proposal (Stage 6)
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md`
- [x] 1.5 Draft `specs/api-auth-better-auth/spec.md`
- [x] 1.6 Every requirement has a `**Verified by:**` line
## 2. Implementation
- [x] 2.1 Scaffold `packages/api/api-auth-better-auth/`
- [x] 2.2 `src/options/options.ts` — `BetterAuthOptions` Zod schema
- [x] 2.3 `src/db/database.ts` — separate DB connection construction; lazy-init on first use
- [x] 2.4 `src/principal/map-user-to-principal.ts` — default mapping; pluggable
- [x] 2.5 `src/session/create-session.ts` — `createBetterAuthSession(opts)`; implements `auth.session`
- [x] 2.6 `src/provider/create-provider.ts` — `createBetterAuthProvider(opts)`; implements `auth.provider`; mounts routes at `routePrefix`
- [x] 2.7 `src/route-prefix/route-prefix.ts` — collision check vs host-reserved prefixes
- [x] 2.8 `src/oauth/github.ts` — GitHub OAuth provider wiring; canonical reference flow
- [x] 2.9 `src/instruments/instruments.ts` — OTel counters / histograms per proposal
- [x] 2.10 `src/error-codes.ts` — 10 `AUTH_BA_*` codes
- [x] 2.11 `./testing` subpath — re-export both conformance suites bound to Postgres-backed factories
- [x] 2.12 `src/index.ts` — explicit named re-exports
## 3. Tests
- [x] 3.1 Unit tests per module, every `it(...)` `{ timeout: 5000 }`
- [x] 3.2 `runAuthSessionConformance` + `runAuthProviderConformance` against Postgres container (`{ timeout: 30000 }`)
- [x] 3.3 `src/oauth/github-oauth.test.ts` — end-to-end with a mock OAuth server (`{ timeout: 60000 }`)
- [x] 3.4 `src/independence/session-without-provider.test.ts` — pins that the two sides are independently usable
- [x] 3.5 `tests/integration/postgres-better-auth.test.ts` (`{ timeout: 60000 }`) — register → login → protected route
- [x] 3.6 All tests import from `@suaveplan/testing/runner`
## 4. Documentation
- [x] 4.1 Sibling `.md` per implementation file
- [x] 4.2 `README.md` with `## Modules` AND a top-level note that two capability surfaces ship and may be wired independently
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-auth-better-auth` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-auth-better-auth` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-auth-better-auth` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-auth-better-auth` — green
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics on `Principal` mapping; `expectTypeOf` assertions
- [x] 6.2 `BetterAuthOptions` Zod-validated at the factory boundary
- [x] 6.3 Every `AUTH_BA_*` code thrown from production paths
- [x] 6.4 Strict-mode tested (session-only wiring, provider-only wiring, both wired)
- [x] 6.5 OTel instruments asserted against in-memory `MeterProvider`
- [x] 6.6 Lifecycle dedup: session resolution is idempotent across retries
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-auth-better-auth/spec.md`
- [x] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-auth-better-auth/`
- [x] 7.3 Tick Stage-6 row in umbrella tasks
- [x] 7.4 Refresh `MANIFEST.md`