# Capability Spec Delta: react-auth (add-react-auth-core) **Package**: `@suaveplan/react-auth` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: AuthClient wraps better-auth with site-domain header propagation The package SHALL expose an `AuthClient` class and a `createAuthClient(config)` factory. The class SHALL wrap `better-auth/client` and SHALL forward every request through the underlying client. When `AuthClientConfig.siteDomain` is set, every request SHALL carry an `x-site-domain` header. #### Scenario: site-domain header attached when configured - **WHEN** `createAuthClient({ baseURL, siteDomain: "tenant-a" }).signIn({ email, password })` is called and the underlying client method is observed - **THEN** the call options include `fetchOptions.headers["x-site-domain"] === "tenant-a"` #### Scenario: header omitted when not configured - **WHEN** `createAuthClient({ baseURL }).getSession()` is called - **THEN** the call options omit the `x-site-domain` header #### Scenario: AuthClientConfig validates at the boundary - **WHEN** `createAuthClient` is invoked with a non-URL `baseURL` - **THEN** a `ReactAuthConfigInvalidError` is thrown with code `REACT_AUTH.CONFIG_INVALID` **Verified by:** `src/client/auth-client.test.ts::"propagates x-site-domain header on every method when siteDomain is configured"` AND `src/client/auth-client.test.ts::"omits x-site-domain header when siteDomain is not configured"` AND `src/client/auth-client.test.ts::"throws ReactAuthConfigInvalidError when baseURL is not a valid URL"` --- ### Requirement: AuthClient list-sessions normalises Date fields to ISO strings The `AuthClient.listSessions()` method SHALL return `Session[]` and SHALL convert each session's `createdAt` / `updatedAt` / `expiresAt` fields from `Date` to ISO string (or leave them as the underlying type when already a string). #### Scenario: dates converted - **WHEN** the underlying client returns sessions with `Date` instances - **THEN** the returned `Session[]` contains ISO-string `createdAt` / `updatedAt` **Verified by:** `src/client/auth-client.test.ts::"converts Date fields on listSessions to ISO strings"` --- ### Requirement: AuthProvider exposes auth state and 11 actions through context The package SHALL expose an `AuthProvider` component and a `useAuth()` hook. The context value SHALL include `{ user, session, isLoading, isAuthenticated, error, client }` and the 11 actions `signIn` / `signUp` / `signOut` / `updateProfile` / `changePassword` / `forgotPassword` / `resetPassword` / `verifyEmail` / `resendVerificationEmail` / `deleteAccount` / `refreshSession`. `useAuth()` outside an `AuthProvider` SHALL throw `ReactAuthContextMissingError` with code `REACT_AUTH.CONTEXT_MISSING`. #### Scenario: initial session loaded on mount - **WHEN** `AuthProvider` mounts with `skipInitialCheck=false` and the client's `getSession()` returns `{ data: { user, session } }` - **THEN** the context settles with `isAuthenticated=true`, `user`, `session`, `isLoading=false` #### Scenario: useAuth outside provider throws - **WHEN** `useAuth()` is called outside any `AuthProvider` - **THEN** a `ReactAuthContextMissingError` is thrown with code `REACT_AUTH.CONTEXT_MISSING` #### Scenario: each action settles loading and surfaces errors - **WHEN** any of the 11 actions resolves successfully OR rejects - **THEN** `isLoading` flips false; on reject `error` carries the wrapped `Error` and the action re-throws **Verified by:** `src/context/auth-context.test.tsx::"loads the initial session and settles isAuthenticated=true"` AND `src/context/auth-context.test.tsx::"useAuth outside an AuthProvider throws ReactAuthContextMissingError"` AND `src/context/auth-context.test.tsx::"every action settles isLoading and propagates errors"` --- ### Requirement: AuthGuard gates children by auth / guest / verified / roles The `AuthGuard` component SHALL render `loadingComponent` (or a default spinner) while `isLoading=true`. When loading completes it SHALL render `children` if and only if every configured predicate (`requireAuth` / `requireGuest` / `requireVerified` / `allowedRoles`) is satisfied; otherwise it SHALL render `fallback` (or `null`). #### Scenario: authenticated user passes default requireAuth - **WHEN** an authenticated, verified user enters an `AuthGuard requireAuth` - **THEN** `children` is rendered #### Scenario: unauthenticated user gets fallback when requireAuth - **WHEN** an unauthenticated user enters an `AuthGuard requireAuth fallback={}` - **THEN** `` is rendered and `children` is not #### Scenario: role mismatch denied - **WHEN** an authenticated user with `role="user"` enters `AuthGuard allowedRoles={["admin"]}` - **THEN** the guard does not render `children` **Verified by:** `src/components/auth-guard/auth-guard.test.tsx::"renders children when authenticated and no extra predicates are set"` AND `src/components/auth-guard/auth-guard.test.tsx::"renders fallback when unauthenticated and requireAuth is set"` AND `src/components/auth-guard/auth-guard.test.tsx::"denies when allowedRoles does not include the user role"` --- ### Requirement: useAuthGuard imperatively redirects via react-router-dom The `useAuthGuard(options)` hook SHALL call `react-router-dom`'s `useNavigate()` and SHALL navigate to `redirectTo` (default `/auth/login`) when `requireAuth` fails, to `/` when `requireGuest` fails, to `/auth/verify-email` when `requireVerified` fails, and to `/unauthorized` when `allowedRoles` fails. It SHALL invoke `onUnauthorized` before the auth / role redirects. The hook SHALL also return `{ isLoading, isAuthenticated, user, canAccess }` for inline checks. #### Scenario: redirects to login when not authenticated - **WHEN** an unauthenticated user mounts a component calling `useAuthGuard({ requireAuth: true })` - **THEN** `navigate("/auth/login")` is called once #### Scenario: canAccess reflects every predicate - **WHEN** any predicate fails - **THEN** `canAccess === false`; when all predicates pass, `canAccess === true` **Verified by:** `src/hooks/use-auth-guard.test.tsx::"redirects to /auth/login when requireAuth fails"` AND `src/hooks/use-auth-guard.test.tsx::"canAccess matrix reflects every predicate combination"` --- ### Requirement: usePermission exposes bitwise permission checks The `usePermission()` hook SHALL expose `hasPermission` / `hasAnyPermission` / `hasAllPermissions` / `getPermissionNames` / `userRoles`. Each predicate SHALL accept either a `Permission` string key or a numeric bitmask and SHALL test `(user.roles & bit) === bit`. With no authenticated user, every predicate SHALL return `false` and `getPermissionNames()` SHALL return `[]`. #### Scenario: string and numeric permission accepted - **WHEN** `hasPermission("USER_READ")` and `hasPermission(1)` are called with `user.roles = 0b1` - **THEN** both return `true` #### Scenario: unauthenticated bails out - **WHEN** no user is present - **THEN** `hasPermission`, `hasAnyPermission`, `hasAllPermissions` all return `false` **Verified by:** `src/hooks/use-permission.test.tsx::"accepts string and numeric Permission inputs"` AND `src/hooks/use-permission.test.tsx::"returns false for every predicate when unauthenticated"` --- ### Requirement: better-auth response adapter narrows union to internal shapes The package SHALL expose `isBetterAuthError` / `hasBetterAuthData` / `transformSessionResponse` / `transformAuthResponse` / `transformGenericResponse` / `transformUpdateProfileResponse`. Each transform SHALL accept `unknown` (no `any`), SHALL detect the `error`-tagged and `data`-tagged variants, and SHALL emit the package's internal `User` / `Session` / `AuthResponse` shapes with ISO-string timestamps. #### Scenario: session response with Date timestamps normalised - **WHEN** `transformSessionResponse({ data: { user: { ..., createdAt: Date }, session: { ..., expiresAt: Date } } })` is called - **THEN** the returned `{ user, session }` carries ISO strings #### Scenario: error variant surfaces message - **WHEN** the response carries `{ error, message }` - **THEN** the transform returns `{ session: null, user: null, error: }` **Verified by:** `src/lib/better-auth-adapter.test.ts::"transformSessionResponse normalises Date timestamps to ISO strings"` AND `src/lib/better-auth-adapter.test.ts::"transforms surface the better-auth error message"` --- ### Requirement: REACT_AUTH error code registry covers every thrown error The package SHALL export `REACT_AUTH_CODES = defineCodes("REACT_AUTH", [...])` with at least the 10 codes `CONFIG_INVALID`, `CONTEXT_MISSING`, `GUARD_PROPS_INVALID`, `GUARD_OPTIONS_INVALID`, `CLIENT_NOT_INITIALISED`, `BETTER_AUTH_RESPONSE_INVALID`, `SIGN_IN_FAILED`, `SIGN_UP_FAILED`, `SESSION_FETCH_FAILED`, `PERMISSION_DENIED`. Every code SHALL be thrown by at least one production path and SHALL surface through a typed `ReactAuthError` subclass. #### Scenario: every code has a thrown call site - **WHEN** the test suite enumerates `REACT_AUTH_CODES` values - **THEN** every code is reachable via a typed subclass that ships in `src/error-codes/error-codes.ts` **Verified by:** `src/error-codes/error-codes.test.ts::"every REACT_AUTH code is thrown by at least one production path"` --- ### Requirement: OTel instruments emit through suaveplan.react-auth meter The package SHALL build OTel instruments lazily under `metrics.getMeter("suaveplan.react-auth")` and SHALL emit at minimum: `react_auth.sign_in.total`, `react_auth.sign_up.total`, `react_auth.sign_out.total`, `react_auth.guard.allow.total`, `react_auth.guard.deny.total` counters, and `react_auth.session_fetch.duration_ms` histogram. #### Scenario: in-memory MeterProvider captures every emit - **WHEN** a real `MeterProvider` + `InMemoryMetricExporter` is wired and every production path that emits is executed - **THEN** the corresponding instrument records the expected attributes **Verified by:** `src/observability/react-auth-metrics.test.ts::"emits sign-in / sign-up / sign-out / guard / session-fetch instruments through suaveplan.react-auth"` --- ### Requirement: Locale packs ship for en-GB / cy-GB / zh-CN with key parity The package SHALL export `enGb`, `cyGb`, `zhCn` (default exports) and an aggregate `authTranslations = { "en-gb": enGb, "cy-gb": cyGb, "zh-cn": zhCn }`. Every locale SHALL carry the same set of `auth..` paths so Wave-2 forms can resolve without locale-specific guards. #### Scenario: key parity across locales - **WHEN** the locale-pack test flattens every dotted key in each locale - **THEN** the three sets are equal **Verified by:** `src/translations/translations.test.ts::"every locale pack exposes the same dotted key set"` --- ### Requirement: Root barrel uses explicit named re-exports The root `src/index.ts` and every feature barrel SHALL use explicit `export { … }` re-exports — never `export *`. The barrel SHALL expose the public surface enumerated in `proposal.md` `## What changes` and nothing else. #### Scenario: package roots are explicit - **WHEN** repo-wide `grep -rn '^export \\*' packages/react/react-auth/src/` is run - **THEN** the result is empty **Verified by:** `src/index.test.ts::"the root barrel exports exactly the documented public surface"`