# Capability Spec Delta: react-auth (complete-react-auth) **Package**: `@suaveplan/react-auth` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time naming planned test files. No exceptions. (See `openspec/AGENTS.md` §0c.) ## ADDED Requirements ### Requirement: AuthProvider establishes context, hydrates persisted session, and is SSR-safe The package SHALL export `` that accepts `AuthBackend`, optional `StorageLike`, optional `BroadcastChannel` factory, optional `IdleConfig`, optional `Clock`, optional `LoggerLike`, and optional `MessageResolver`. The provider SHALL render with a deterministic `{ status: "loading", user: null }` state on first paint (including SSR), then hydrate persisted session inside `useEffect` on the client. The provider SHALL never access `window`, `document`, `navigator`, `localStorage`, `sessionStorage`, or `BroadcastChannel` at module load or during server render. #### Scenario: server render contains zero DOM access - **WHEN** `` is rendered in a server-side environment (`typeof window === "undefined"`) - **THEN** no `window`/`document`/`navigator`/`localStorage`/`sessionStorage`/`BroadcastChannel` access occurs - **AND** the rendered children see `state.status === "loading"` #### Scenario: persisted session is loaded on client mount - **WHEN** the provider mounts on the client and the injected `StorageLike` contains a valid persisted session - **THEN** the provider hydrates `state` to `{ status: "authenticated", user, accessToken, expiresAt, refreshAt }` inside `useEffect` - **AND** consumers observe the hydrated state on the second render #### Scenario: useAuthContext throws CONTEXT_MISSING outside provider - **WHEN** `useAuthContext()` is called outside any `` - **THEN** a `SuaveplanError` with `code === "REACT_AUTH.CONTEXT_MISSING"` is thrown synchronously **Verified by:** `src/auth-provider/auth-provider.test.tsx::"renders deterministic loading state on SSR"` AND `src/auth-provider/auth-provider.test.tsx::"hydrates persisted session inside useEffect on client mount"` AND `src/auth-provider/use-auth-context.test.ts::"throws CONTEXT_MISSING when used outside provider"` --- ### Requirement: State read hooks expose context selectors The package SHALL export `useAuth`, `useUser`, `useIsAuthenticated`, and `useSession` hooks. Each SHALL read from `` context, return stable references across renders when the underlying slice is unchanged, and never trigger re-renders for unrelated state changes. #### Scenario: useUser returns null when unauthenticated - **WHEN** the provider state is `{ status: "unauthenticated" }` - **THEN** `useUser()` returns `null` - **AND** `useIsAuthenticated()` returns `false` #### Scenario: useSession returns the full state slice - **WHEN** the provider state is `{ status: "authenticated", user, accessToken, expiresAt, refreshAt }` - **THEN** `useSession()` returns the same shape - **AND** repeated calls with unchanged state return referentially-equal objects **Verified by:** `src/hooks/use-user/use-user.test.ts::"returns null when unauthenticated"` AND `src/hooks/use-is-authenticated/use-is-authenticated.test.ts::"returns false when unauthenticated"` AND `src/hooks/use-session/use-session.test.ts::"returns the full state slice with stable identity"` AND `src/hooks/use-auth/use-auth.test.ts::"exposes mutation methods"` --- ### Requirement: Sign-in / sign-up / sign-out / refresh hooks wrap the backend with typed mutation handles The package SHALL export `useSignIn`, `useSignUp`, `useSignOut`, and `useRefreshToken`. Each SHALL return a `MutationHandle` with `{ mutate, status, error, data, reset }` whose identity is stable across renders. Successful mutations SHALL update provider state and broadcast `signed-in` / `signed-out` / `session-refreshed` via the configured `BroadcastChannel`. Failures SHALL translate `AuthBackend` errors into `REACT_AUTH.SIGN_IN_FAILED` / `SIGN_UP_FAILED` / `SIGN_OUT_FAILED` / `REFRESH_FAILED` respectively, preserving the underlying error on `cause`. #### Scenario: successful sign-in updates context and broadcasts - **WHEN** `useSignIn().mutate({ email, password })` resolves - **THEN** provider state transitions to `{ status: "authenticated", user, accessToken, expiresAt, refreshAt }` - **AND** a `signed-in` envelope is posted on the broadcast channel #### Scenario: backend failure surfaces SIGN_IN_FAILED with cause preserved - **WHEN** `AuthBackend.passwordSignIn` rejects with an error - **THEN** `useSignIn().error` is a `SuaveplanError` with `code === "REACT_AUTH.SIGN_IN_FAILED"` - **AND** the original error is preserved on `error.cause` #### Scenario: sign-out broadcasts and clears storage - **WHEN** `useSignOut().mutate()` resolves - **THEN** provider state transitions to `{ status: "unauthenticated", user: null }` - **AND** the injected `StorageLike` no longer contains the persisted session - **AND** a `signed-out` envelope is posted on the broadcast channel #### Scenario: refresh failure transitions to unauthenticated - **WHEN** `useRefreshToken().mutate()` rejects - **THEN** the provider state transitions to `{ status: "unauthenticated", error: REACT_AUTH.REFRESH_FAILED }` **Verified by:** `src/hooks/use-sign-in/use-sign-in.test.ts::"updates context and broadcasts on success"` AND `src/hooks/use-sign-in/use-sign-in.test.ts::"translates backend failure to SIGN_IN_FAILED with cause"` AND `src/hooks/use-sign-up/use-sign-up.test.ts::"signs the user in on success"` AND `src/hooks/use-sign-out/use-sign-out.test.ts::"clears storage and broadcasts signed-out"` AND `src/hooks/use-refresh-token/use-refresh-token.test.ts::"transitions to unauthenticated on refresh failure"` --- ### Requirement: Password hooks cover change, request-reset, and confirm-reset flows The package SHALL export `useChangePassword` and `usePasswordReset`. `useChangePassword` SHALL return a `MutationHandle<{ currentPassword: string; newPassword: string }, void>`. `usePasswordReset` SHALL return `{ request, confirm }`, each a `MutationHandle`. New-password values SHALL be rejected synchronously with `REACT_AUTH.PASSWORD_TOO_WEAK` if `password-strength.estimate(...).score < minBits` (where `minBits` defaults to 50 bits-equivalent) and SHALL be rejected with `REACT_AUTH.PASSWORD_PWNED` if an injected `HibpClient` is supplied and the password appears in HIBP. #### Scenario: weak password is rejected before backend call - **WHEN** `useChangePassword().mutate({ currentPassword, newPassword: "abc" })` is invoked - **THEN** the mutation rejects with `REACT_AUTH.PASSWORD_TOO_WEAK` - **AND** `AuthBackend.changePassword` is not called #### Scenario: pwned password is rejected before backend call - **WHEN** `useChangePassword({ hibpClient }).mutate({ currentPassword, newPassword })` is invoked AND HIBP reports the password as pwned - **THEN** the mutation rejects with `REACT_AUTH.PASSWORD_PWNED` #### Scenario: request-reset and confirm-reset round-trip - **WHEN** `usePasswordReset().request.mutate({ email })` resolves and the user later calls `confirm.mutate({ token, newPassword })` - **THEN** the backend is called with the supplied token and a strength-validated password - **AND** provider state remains unchanged (reset flow does not authenticate) **Verified by:** `src/hooks/use-change-password/use-change-password.test.ts::"rejects weak passwords before calling backend"` AND `src/hooks/use-change-password/use-change-password.test.ts::"rejects HIBP-pwned passwords when hibpClient is supplied"` AND `src/hooks/use-password-reset/use-password-reset.test.ts::"request and confirm hit the backend in sequence"` --- ### Requirement: PasswordInput composes strength + HIBP slots with accessible markup The package SHALL export `` rendering an `` with optional show/hide toggle, optional `` slot, and optional `` slot. The component SHALL associate the input with its label via `htmlFor` / `id`, link help/error text via `aria-describedby`, set `aria-invalid` on error, and meet WCAG 2.1 AA contrast ratios in the bundled stories. #### Scenario: input is associated with label and described-by id - **WHEN** `` is rendered - **THEN** the rendered `