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

657 lines
43 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 `<AuthProvider>` 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** `<AuthProvider>` 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 `<AuthProvider>`
- **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 `<AuthProvider>` 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 `<PasswordInput>` rendering an `<input type="password">` with optional show/hide toggle, optional `<PasswordStrengthMeter>` slot, and optional `<HibpPwnedIndicator>` 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** `<PasswordInput name="pwd" id="pwd" label="Password" describedById="pwd-help" />` is rendered
- **THEN** the rendered `<label>` carries `for="pwd"`
- **AND** the rendered `<input>` carries `aria-describedby="pwd-help"`
#### Scenario: show/hide toggle flips input type and announces state
- **WHEN** the user clicks the show/hide toggle
- **THEN** `<input type>` flips between `"password"` and `"text"`
- **AND** the toggle button's `aria-pressed` state reflects the visibility
#### Scenario: axe-core finds zero serious violations
- **WHEN** `<PasswordInput>` is rendered in the canonical story
- **THEN** `axe.run` reports zero `serious` or `critical` violations
**Verified by:** `src/components/password-input/password-input.test.tsx::"associates label with input via htmlFor"` AND `src/components/password-input/password-input.test.tsx::"toggle flips type and aria-pressed"` AND `e2e/accessibility.spec.ts::"PasswordInput passes axe with zero serious violations"`
---
### Requirement: PasswordStrengthMeter wraps password-strength.assessReactive with cancellation
The package SHALL export `<PasswordStrengthMeter>` that wraps `@suaveplan/password-strength.assessReactive`. On every `password` prop change the component SHALL cancel any in-flight assessment via `AbortController`. On unmount the component SHALL abort the current assessment.
#### Scenario: rapid prop changes do not leak assessments
- **WHEN** the `password` prop changes 100 times within a single tick
- **THEN** at most one `assessReactive` Promise is still pending after settle
- **AND** no `unhandled rejection` is logged
#### Scenario: unmount cancels the in-flight assessment
- **WHEN** the component unmounts while an `assessReactive` Promise is pending
- **THEN** the underlying `AbortController` aborts
- **AND** the Promise rejects with `AbortError` (not surfaced to the consumer)
**Verified by:** `src/components/password-strength-meter/password-strength-meter.test.tsx::"cancels stale assessments when password changes"` AND `src/components/password-strength-meter/password-strength-meter.test.tsx::"aborts on unmount"`
---
### Requirement: HibpPwnedIndicator debounces, SSR-no-ops, and surfaces pwned state
The package SHALL export `<HibpPwnedIndicator>` that wraps an injected `HibpClient` (per `@suaveplan/hibp`), debounces input changes by at least 500 ms, never performs a network request during SSR or when `password` is empty, and surfaces the pwned/clean result via accessible status text in an `aria-live="polite"` region.
#### Scenario: SSR render performs no network access
- **WHEN** the component is rendered server-side
- **THEN** no method on the injected `HibpClient` is called
#### Scenario: rapid keystrokes coalesce to a single request
- **WHEN** the `password` prop changes 10 times in under 500 ms
- **THEN** the `HibpClient.checkPassword` method is called at most once after debounce
#### Scenario: pwned result is announced via aria-live region
- **WHEN** the client reports the password as pwned
- **THEN** an element with `aria-live="polite"` contains the localised pwned message
**Verified by:** `src/components/hibp-pwned-indicator/hibp-pwned-indicator.test.tsx::"performs no HIBP call during SSR render"` AND `src/components/hibp-pwned-indicator/hibp-pwned-indicator.test.tsx::"debounces rapid keystrokes to a single request"` AND `src/components/hibp-pwned-indicator/hibp-pwned-indicator.test.tsx::"announces pwned result via aria-live region"`
---
### Requirement: PasswordGeneratorButton emits passphrase / chars / pin per `@suaveplan/password-generator`
The package SHALL export `<PasswordGeneratorButton>` that, on activation, opens a popover with a generated password from `@suaveplan/password-generator`. The mode SHALL be `"passphrase"` (default), `"chars"`, or `"pin"`. On accept the component SHALL invoke the consumer's `onPick(password)` callback. The popover SHALL be keyboard-accessible (Escape closes, focus traps inside).
#### Scenario: default mode generates a passphrase
- **WHEN** `<PasswordGeneratorButton onPick={...} />` is opened and the user accepts the suggestion
- **THEN** `onPick` is invoked once with a passphrase generated via `@suaveplan/password-generator`
#### Scenario: Escape closes and returns focus to trigger
- **WHEN** the popover is open and the user presses Escape
- **THEN** the popover closes
- **AND** focus returns to the trigger button
**Verified by:** `src/components/password-generator-button/password-generator-button.test.tsx::"emits a passphrase on accept"` AND `src/components/password-generator-button/password-generator-button.test.tsx::"escape closes popover and restores focus"`
---
### Requirement: useRegisterPasskey wraps webauthn.register and persists the result
The package SHALL export `useRegisterPasskey` returning a `MutationHandle<{ userId: string; friendlyName?: string }, RegisteredPasskey>`. The mutation SHALL call `AuthBackend.passkeyRegisterOptions`, invoke `webauthn.register` from `@suaveplan/webauthn`, then call `AuthBackend.passkeyRegisterFinish`. Failures SHALL be translated to `REACT_AUTH.PASSKEY_REGISTER_FAILED` with the underlying `WEBAUTHN.*` cause preserved.
#### Scenario: full register handshake completes successfully
- **WHEN** the mutation runs end-to-end
- **THEN** `passkeyRegisterOptions`, `webauthn.register`, and `passkeyRegisterFinish` are called in order
- **AND** the resolved value is the `RegisteredPasskey` from the backend
#### Scenario: webauthn cancellation surfaces PASSKEY_REGISTER_FAILED
- **WHEN** the user cancels the platform passkey UI (yielding `WEBAUTHN.ABORTED`)
- **THEN** the mutation rejects with `REACT_AUTH.PASSKEY_REGISTER_FAILED`
- **AND** `error.cause.code === "WEBAUTHN.ABORTED"`
**Verified by:** `src/hooks/use-register-passkey/use-register-passkey.test.ts::"runs options → register → finish in order"` AND `src/hooks/use-register-passkey/use-register-passkey.test.ts::"translates WEBAUTHN.ABORTED to PASSKEY_REGISTER_FAILED with cause"`
---
### Requirement: useAuthenticateWithPasskey runs modal mediation and updates context
The package SHALL export `useAuthenticateWithPasskey` returning a `MutationHandle<{ userHint?: string }, void>` that calls `AuthBackend.passkeyAuthenticateOptions({ mediation: "required" })`, invokes `webauthn.authenticate`, then `AuthBackend.passkeyAuthenticateFinish`. On success the provider state SHALL transition to `authenticated`.
#### Scenario: successful modal passkey sign-in updates state
- **WHEN** the mutation resolves
- **THEN** provider state transitions to `{ status: "authenticated", user, accessToken, … }`
#### Scenario: backend failure surfaces PASSKEY_AUTHENTICATE_FAILED
- **WHEN** `passkeyAuthenticateFinish` rejects
- **THEN** the mutation rejects with `REACT_AUTH.PASSKEY_AUTHENTICATE_FAILED` with cause preserved
**Verified by:** `src/hooks/use-authenticate-with-passkey/use-authenticate-with-passkey.test.ts::"transitions to authenticated on success"` AND `src/hooks/use-authenticate-with-passkey/use-authenticate-with-passkey.test.ts::"translates backend failure to PASSKEY_AUTHENTICATE_FAILED"`
---
### Requirement: usePasskeyAutofill dispatches conditional WebAuthn with SSR-safe lifecycle
The package SHALL export `usePasskeyAutofill(options)` that, on the first client mount, feature-detects `PublicKeyCredential.isConditionalMediationAvailable()` via `@suaveplan/webauthn` and dispatches `authenticate({ mediation: "conditional" })` with the caller-supplied `rpId`, `challenge`, `allowCredentials`, `userVerification`, `timeout`, `expectedOrigin`, and `expectedRpIdHash`. The hook SHALL own one `AbortController` per effect run composed with `options.signal`, abort on unmount / `options.signal` / returned `abort()`, perform zero work during SSR, return `{ ready: false, error: null, abort }` on the server, and validate options via Zod, throwing `REACT_AUTH.INVALID_OPTIONS` synchronously on mismatch. The returned `abort` reference SHALL be stable across re-renders.
#### Scenario: conditional authenticate is invoked when supported
- **WHEN** the hook mounts in a browser whose `isConditionalMediationAvailable()` resolves `true`
- **THEN** `webauthn.authenticate` is called with `mediation: "conditional"` and the forwarded option fields
- **AND** `ready` flips to `true` once the request is in-flight
#### Scenario: SSR render performs no DOM access
- **WHEN** the hook is rendered with `typeof window === "undefined"`
- **THEN** no `navigator`/`document`/`PublicKeyCredential` access occurs
- **AND** the return is `{ ready: false, error: null, abort }`
#### Scenario: missing Conditional UI surfaces PASSKEY_AUTOFILL_UNAVAILABLE
- **WHEN** `isConditionalMediationAvailable` is missing or resolves `false`
- **THEN** `authenticate` is never called and `onError` (if supplied) receives `REACT_AUTH.PASSKEY_AUTOFILL_UNAVAILABLE`
#### Scenario: unmount aborts the in-flight request
- **WHEN** the component unmounts while the request is in-flight
- **THEN** the request rejects with `AbortError` translated to `REACT_AUTH.PASSKEY_AUTOFILL_ABORTED` (cause preserved)
#### Scenario: returned abort reference is stable across re-renders
- **WHEN** the parent re-renders with unchanged options
- **THEN** `Object.is(prevReturn.abort, nextReturn.abort) === true`
#### Scenario: invalid options throw INVALID_OPTIONS synchronously
- **WHEN** the caller passes `rpId: ""` or a non-`Uint8Array` challenge
- **THEN** the hook throws `REACT_AUTH.INVALID_OPTIONS` during render
- **AND** no `authenticate` call is dispatched
**Verified by:** `src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"dispatches authenticate with mediation conditional when available"` AND `src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"performs no work during SSR render"` AND `src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"reports PASSKEY_AUTOFILL_UNAVAILABLE when conditional UI missing"` AND `src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"aborts the in-flight request on unmount"` AND `src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort reference is stable across re-renders"` AND `src/hooks/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS for empty rpId"`
---
### Requirement: PasskeyAutofillField renders the autocomplete input and blur-cancels
The package SHALL export `<PasskeyAutofillField>` rendering an `<input autocomplete="username webauthn">` wired to `usePasskeyAutofill`. The component SHALL call the hook's `abort()` on the input's `blur` event when no credential has yet been delivered. After `onCredential` has fired, subsequent blur events SHALL NOT trigger `abort()`. Consumer-supplied `inputProps` SHALL be forwarded to the underlying `<input>` (except `autocomplete`, which is fixed).
#### Scenario: input has autocomplete="username webauthn"
- **WHEN** `<PasskeyAutofillField rpId="example.test" challenge={c} onCredential={...} />` is rendered
- **THEN** the rendered `<input>` has `autocomplete="username webauthn"`
#### Scenario: blur cancels the in-flight request
- **WHEN** the user blurs the input before any credential is delivered
- **THEN** `abort()` is invoked and the in-flight request rejects with `REACT_AUTH.PASSKEY_AUTOFILL_ABORTED`
#### Scenario: blur after credential delivery is a no-op
- **WHEN** `onCredential` has already fired
- **THEN** a subsequent blur does NOT invoke `abort()`
**Verified by:** `src/components/passkey-autofill-field/passkey-autofill-field.test.tsx::"renders input with autocomplete username webauthn"` AND `src/components/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur cancels in-flight request"` AND `src/components/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur after credential delivery is a no-op"`
---
### Requirement: PasskeyList lists registered authenticators and revokes per row
The package SHALL export `<PasskeyList>` that reads from `AuthBackend.listPasskeys()`, renders one row per credential with friendly name + last-used timestamp + revoke button, and on revoke confirmation calls `AuthBackend.revokePasskey({ credentialId })`. The revoke confirmation dialog SHALL trap focus and restore focus on close.
#### Scenario: list renders one row per registered passkey
- **WHEN** the backend returns three registered passkeys
- **THEN** the component renders exactly three rows, each carrying the friendly name and a revoke button
#### Scenario: revoke flow calls backend after confirmation
- **WHEN** the user clicks revoke and confirms
- **THEN** `AuthBackend.revokePasskey` is called with the row's `credentialId`
- **AND** the row is removed from the list after success
**Verified by:** `src/components/passkey-list/passkey-list.test.tsx::"renders one row per registered passkey"` AND `src/components/passkey-list/passkey-list.test.tsx::"revokes the credential after confirmation"`
---
### Requirement: useOAuthSignIn runs popup and redirect modes with PKCE
The package SHALL export `useOAuthSignIn` returning a `MutationHandle<{ provider: string; mode: "popup" | "redirect"; scopes?: string[] }, void>`. The hook SHALL generate `state` and a PKCE pair via `@suaveplan/oauth-client.generatePkcePair`, persist `{ state, codeVerifier, provider }` to a return-state store (sessionStorage by default), and either open a centred popup (popup mode) or call `window.location.assign(authorizationUrl)` (redirect mode). On popup-mode receipt of `postMessage` from a same-origin source, the hook SHALL call `AuthBackend.oauthExchange`, persist the resulting session, broadcast `signed-in`, and close the popup.
#### Scenario: popup-blocked surfaces OAUTH_POPUP_BLOCKED
- **WHEN** `window.open` returns `null`
- **THEN** the mutation rejects with `REACT_AUTH.OAUTH_POPUP_BLOCKED`
#### Scenario: state mismatch surfaces OAUTH_STATE_MISMATCH
- **WHEN** the callback `postMessage` carries a `state` that does not match the persisted PKCE state
- **THEN** the mutation rejects with `REACT_AUTH.OAUTH_STATE_MISMATCH`
- **AND** no `oauthExchange` call is made
#### Scenario: cross-origin postMessage is ignored
- **WHEN** the popup receives a `postMessage` from a different origin than the parent
- **THEN** the message is ignored and the mutation remains pending
#### Scenario: redirect mode persists return-state and navigates
- **WHEN** `mutate({ provider, mode: "redirect" })` is called
- **THEN** the PKCE state is persisted to the return-state store
- **AND** `window.location.assign` is called with the authorization URL
**Verified by:** `src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"surfaces OAUTH_POPUP_BLOCKED when window.open returns null"` AND `src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"surfaces OAUTH_STATE_MISMATCH on state mismatch"` AND `src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"ignores cross-origin postMessage"` AND `src/hooks/use-oauth-sign-in/use-oauth-sign-in.test.ts::"redirect mode persists state and navigates"`
---
### Requirement: handleOAuthCallback parses, validates, exchanges, and is idempotent
The package SHALL export `handleOAuthCallback()` that reads `code` and `state` from `window.location.search`, validates `state` against the persisted PKCE entry, calls `AuthBackend.oauthExchange`, and either `postMessage`s the result back to `window.opener` (popup mode) or persists the session + navigates to the originally-requested URL (redirect mode). The function SHALL be idempotent across remounts via a one-shot guard keyed by `state`.
#### Scenario: missing PKCE state surfaces OAUTH_STATE_MISMATCH
- **WHEN** the persisted return-state store has no entry for the URL `state` parameter
- **THEN** the function rejects with `REACT_AUTH.OAUTH_STATE_MISMATCH`
#### Scenario: idempotent across remounts
- **WHEN** `handleOAuthCallback()` is called twice with the same `state`
- **THEN** `AuthBackend.oauthExchange` is called at most once
**Verified by:** `src/oauth/handle-callback.test.ts::"rejects with OAUTH_STATE_MISMATCH when state is unknown"` AND `src/oauth/handle-callback.test.ts::"is idempotent across remounts"`
---
### Requirement: OAuthProviderButton + OAuthProviderRegistry expose registered providers accessibly
The package SHALL export `OAuthProviderRegistry` with `register`, `get`, `has`, `list`, and `unregister`. The package SHALL export `<OAuthProviderButton provider="...">` that renders a button labelled via `react-auth.oauth.<provider>.label` i18n key, with an icon slot, and on click invokes `useOAuthSignIn`.
#### Scenario: button is keyboard-activatable
- **WHEN** the button receives focus and the user presses Enter
- **THEN** `useOAuthSignIn().mutate` is invoked
#### Scenario: registry rejects duplicate registrations
- **WHEN** `register` is called twice with the same provider name
- **THEN** the second call throws a registry-error code defined in the registry module
**Verified by:** `src/components/oauth-provider-button/oauth-provider-button.test.tsx::"invokes sign-in on Enter"` AND `src/oauth/provider-registry.test.ts::"rejects duplicate registrations"`
---
### Requirement: TOTP and recovery-code hooks cover full MFA lifecycle
The package SHALL export `useEnrollTotp`, `useVerifyTotp`, `useEnrollWebauthnSecondFactor`, and `useRecoveryCodes`. `useEnrollTotp` SHALL provide `{ begin, finish }` mutation handles. `useRecoveryCodes` SHALL provide `{ generate, verify }` mutation handles. `useVerifyTotp` SHALL transition provider state from `mfa-required` to `authenticated` on success. Failures SHALL be translated to `TOTP_ENROLL_FAILED`, `MFA_VERIFY_FAILED`, or `RECOVERY_CODE_INVALID`.
#### Scenario: TOTP enrollment returns secret + provisioning URI then recovery codes
- **WHEN** `useEnrollTotp().begin.mutate()` resolves then `finish.mutate({ code })` resolves
- **THEN** the consumer receives `{ secret, provisioningUri }` from `begin` and `{ recoveryCodes }` from `finish`
#### Scenario: TOTP verification transitions out of mfa-required
- **WHEN** provider state is `mfa-required` and `useVerifyTotp().mutate({ code })` resolves
- **THEN** provider state transitions to `authenticated`
#### Scenario: invalid recovery code rejects with RECOVERY_CODE_INVALID
- **WHEN** `useRecoveryCodes().verify.mutate({ code: "bad" })` rejects from the backend
- **THEN** the surfaced error is `REACT_AUTH.RECOVERY_CODE_INVALID`
**Verified by:** `src/hooks/use-enroll-totp/use-enroll-totp.test.ts::"begin returns secret and uri, finish returns recovery codes"` AND `src/hooks/use-verify-totp/use-verify-totp.test.ts::"transitions out of mfa-required on success"` AND `src/hooks/use-recovery-codes/use-recovery-codes.test.ts::"rejects invalid recovery codes with RECOVERY_CODE_INVALID"` AND `src/hooks/use-enroll-webauthn-second-factor/use-enroll-webauthn-second-factor.test.ts::"forwards authenticatorSelection to webauthn.register"`
---
### Requirement: TotpEnrollmentPanel + RecoveryCodesPanel orchestrate accessible MFA UX
The package SHALL export `<TotpEnrollmentPanel>` that wires `useEnrollTotp` end-to-end and renders a consumer-supplied QR slot. The component SHALL not display recovery codes until enrollment finalises. The package SHALL export `<RecoveryCodesPanel>` that lists generated codes with copy-to-clipboard, download-as-text, and an "I have saved these" acknowledgement checkbox that must be checked before dismissal.
#### Scenario: recovery codes are hidden until finalised
- **WHEN** `<TotpEnrollmentPanel>` is in the `begin` state
- **THEN** no recovery code is rendered in the DOM
#### Scenario: dismiss requires acknowledgement
- **WHEN** the user attempts to dismiss `<RecoveryCodesPanel>` without checking the acknowledgement
- **THEN** the dismiss is prevented and a `react-auth.recovery.must-acknowledge` warning is announced via `aria-live`
#### Scenario: copy and download produce the same payload
- **WHEN** the user clicks copy and then download
- **THEN** the clipboard contents and the downloaded text match exactly
**Verified by:** `src/components/totp-enrollment-panel/totp-enrollment-panel.test.tsx::"hides recovery codes until finalised"` AND `src/components/recovery-codes-panel/recovery-codes-panel.test.tsx::"prevents dismiss without acknowledgement"` AND `src/components/recovery-codes-panel/recovery-codes-panel.test.tsx::"copy and download contents match"`
---
### Requirement: Email and phone verification hooks + views
The package SHALL export `useEmailVerification` and `usePhoneVerification`, each returning `{ request, confirm }` mutation handles wired to the corresponding `AuthBackend` methods. The package SHALL export `<EmailVerificationView>` with a resend cooldown timer and `<PhoneVerificationView>` whose code input carries `inputMode="numeric"` and `autoComplete="one-time-code"`.
#### Scenario: resend cooldown disables the button
- **WHEN** the user clicks "resend" inside `<EmailVerificationView>`
- **THEN** the resend button is disabled for at least `cooldownMs` (default 30000)
- **AND** an `aria-live` region announces the remaining time
#### Scenario: phone code input is optimised for mobile autofill
- **WHEN** `<PhoneVerificationView>` is rendered
- **THEN** the code `<input>` carries `inputMode="numeric"` and `autoComplete="one-time-code"`
**Verified by:** `src/components/email-verification-view/email-verification-view.test.tsx::"resend is disabled during cooldown"` AND `src/components/phone-verification-view/phone-verification-view.test.tsx::"code input carries one-time-code autocomplete"` AND `src/hooks/use-email-verification/use-email-verification.test.ts::"request and confirm hit the backend"` AND `src/hooks/use-phone-verification/use-phone-verification.test.ts::"request and confirm hit the backend"`
---
### Requirement: Guards — AuthGuard, PermissionGate, useAuthGuard, usePermission, withAuth
The package SHALL export `<AuthGuard>`, `<PermissionGate>`, `useAuthGuard`, `usePermission`, and `withAuth(Component)`. `<AuthGuard>` SHALL render `children` when authenticated and otherwise render the `fallback` prop; when no `fallback` is supplied the guard SHALL throw `REACT_AUTH.UNAUTHENTICATED` for the nearest error boundary. `<PermissionGate>` SHALL consult the injected `PermissionResolver`; absent permission SHALL render `fallback` or `null`.
#### Scenario: AuthGuard renders fallback when unauthenticated
- **WHEN** `<AuthGuard fallback={<div data-testid="x" />}>` is mounted with provider state `unauthenticated`
- **THEN** the `data-testid="x"` element renders and children do not
#### Scenario: AuthGuard throws UNAUTHENTICATED when no fallback
- **WHEN** `<AuthGuard>` is mounted with no `fallback` and provider state `unauthenticated`
- **THEN** a `SuaveplanError` with `code === "REACT_AUTH.UNAUTHENTICATED"` is thrown during render
#### Scenario: PermissionGate consults injected resolver
- **WHEN** `<PermissionGate permission="admin">` is mounted and `permissions.has("admin")` returns `false`
- **THEN** children are not rendered
**Verified by:** `src/components/auth-guard/auth-guard.test.tsx::"renders fallback when unauthenticated"` AND `src/components/auth-guard/auth-guard.test.tsx::"throws UNAUTHENTICATED when no fallback supplied"` AND `src/components/permission-gate/permission-gate.test.tsx::"hides children when permission is absent"` AND `src/hooks/use-auth-guard/use-auth-guard.test.ts::"exposes status and redirectTo"` AND `src/hooks/use-permission/use-permission.test.ts::"returns boolean from resolver"`
---
### Requirement: AuthErrorBoundary catches REACT_AUTH errors and renders i18n-keyed fallback
The package SHALL export `<AuthErrorBoundary>` that catches any thrown `SuaveplanError` whose `code` starts with `"REACT_AUTH."` and renders a `fallback(error)` slot (default: an accessible alert resolving `react-auth.errors.<code>` via the injected `MessageResolver`). Non-`REACT_AUTH` errors SHALL be re-thrown so other boundaries handle them.
#### Scenario: REACT_AUTH error is caught and rendered
- **WHEN** a child throws `REACT_AUTH.UNAUTHENTICATED`
- **THEN** the boundary renders the resolved fallback message
- **AND** the alert element carries `role="alert"`
#### Scenario: non-REACT_AUTH error is re-thrown
- **WHEN** a child throws an unrelated `Error`
- **THEN** the boundary re-throws so a parent boundary can handle it
**Verified by:** `src/components/auth-error-boundary/auth-error-boundary.test.tsx::"renders i18n fallback for REACT_AUTH errors"` AND `src/components/auth-error-boundary/auth-error-boundary.test.tsx::"re-throws non-REACT_AUTH errors"`
---
### Requirement: Multi-tab session sync via BroadcastChannel
The provider SHALL open a `BroadcastChannel("@suaveplan/react-auth")` (via injected factory) on mount and SHALL post typed envelopes on every sign-in, sign-out, refresh, and forced-logout. Receivers SHALL hydrate state from `StorageLike` on `signed-in` / `session-refreshed` and SHALL clear state synchronously on `signed-out` / `forced-logout` without re-calling the backend.
#### Scenario: sign-out in tab A clears state in tab B
- **WHEN** tab A signs out and emits the `signed-out` envelope
- **THEN** tab B's provider state transitions to `unauthenticated`
- **AND** no `AuthBackend.sessionRevoke` call is made in tab B
#### Scenario: forced-logout broadcasts to every tab
- **WHEN** `forceLogout("policy-violation")` is invoked
- **THEN** every subscribed tab transitions to `unauthenticated`
- **AND** the reason is observable via the `forced-logout` envelope payload
**Verified by:** `src/session/broadcast-channel.test.ts::"emits typed envelopes for lifecycle events"` AND `src/auth-provider/auth-provider.test.tsx::"receives signed-out from another tab and clears state without backend call"` AND `e2e/multi-tab-sync.spec.ts::"sign-out in one tab propagates to another"`
---
### Requirement: Auto-refresh fires before expiry via injected Clock
The provider SHALL schedule a refresh at `expiresAt - refreshSkewMs` (default `60000`) using the injected `Clock`. The schedule SHALL be cancelled on every state update, every sign-out, and on unmount. On refresh success the provider SHALL broadcast `session-refreshed`. On refresh failure the provider SHALL transition to `unauthenticated` with `REACT_AUTH.REFRESH_FAILED`.
#### Scenario: refresh fires at refreshAt - skew
- **WHEN** the clock advances past `refreshAt`
- **THEN** `AuthBackend.sessionRefresh` is called exactly once
#### Scenario: refresh failure clears state
- **WHEN** the scheduled `sessionRefresh` rejects
- **THEN** provider state transitions to `unauthenticated` with `REACT_AUTH.REFRESH_FAILED`
**Verified by:** `src/session/auto-refresh.test.ts::"fires sessionRefresh at refreshAt minus skew"` AND `src/session/auto-refresh.test.ts::"transitions to unauthenticated on refresh failure"`
---
### Requirement: Idle timeout triggers warning then forced sign-out
When `IdleConfig` is supplied, the provider SHALL listen for `pointerdown`, `keydown`, and `visibilitychange` events on `document`, debounce to ≤ 1 reset / 250 ms, fire `onWarn` at `ttlMs - warnBeforeMs`, and fire `onTimeout` (default: `forceLogout("idle")`) at `ttlMs`. Listeners SHALL be removed on unmount.
#### Scenario: pointer activity resets the idle counter
- **WHEN** a `pointerdown` fires before the warning threshold
- **THEN** the warning + timeout timers are reset
#### Scenario: warning then timeout fire in order
- **WHEN** the clock advances past `ttlMs - warnBeforeMs` then past `ttlMs`
- **THEN** `onWarn` fires first, then `onTimeout`
- **AND** provider state transitions to `unauthenticated` with `REACT_AUTH.IDLE_TIMEOUT`
**Verified by:** `src/session/idle-timer.test.ts::"resets timers on pointerdown"` AND `src/session/idle-timer.test.ts::"fires onWarn then onTimeout and transitions to unauthenticated"` AND `e2e/idle-timeout.spec.ts::"forces sign-out after idle ttl"`
---
### Requirement: REACT_AUTH error registry is complete and every code reachable
The package SHALL export a `defineCodes("REACT_AUTH", [...])` registry whose set equals exactly: `CONTEXT_MISSING`, `UNAUTHENTICATED`, `BACKEND_ERROR`, `SIGN_IN_FAILED`, `SIGN_UP_FAILED`, `SIGN_OUT_FAILED`, `REFRESH_FAILED`, `PASSWORD_TOO_WEAK`, `PASSWORD_PWNED`, `PASSWORD_RESET_FAILED`, `CHANGE_PASSWORD_FAILED`, `PASSKEY_REGISTER_FAILED`, `PASSKEY_AUTHENTICATE_FAILED`, `PASSKEY_AUTOFILL_UNAVAILABLE`, `PASSKEY_AUTOFILL_FAILED`, `PASSKEY_AUTOFILL_ABORTED`, `OAUTH_POPUP_BLOCKED`, `OAUTH_STATE_MISMATCH`, `OAUTH_CALLBACK_FAILED`, `MFA_REQUIRED`, `MFA_VERIFY_FAILED`, `TOTP_ENROLL_FAILED`, `RECOVERY_CODE_INVALID`, `EMAIL_VERIFY_FAILED`, `PHONE_VERIFY_FAILED`, `INVALID_OPTIONS`, `SSR_NO_OP`, `IDLE_TIMEOUT`, `FORCED_LOGOUT`. Every code SHALL be raised by at least one production path; every translation SHALL preserve the underlying cause.
#### Scenario: every declared code is reachable
- **WHEN** the test suite enumerates the registry
- **THEN** for each code there exists a production code path that raises it (verified by call-graph or fixture)
**Verified by:** `src/errors/errors.test.ts::"every REACT_AUTH code is reachable from a production path"` AND `src/errors/errors.test.ts::"every translation preserves cause"`
---
### Requirement: Zod schemas validate every hook option bag at boundary
The package SHALL validate every public hook's option bag through a Zod schema exported from `src/schemas/schemas.ts`. Boundary mismatches SHALL throw `REACT_AUTH.INVALID_OPTIONS` synchronously so React's nearest error boundary catches the failure at mount time.
#### Scenario: invalid sign-in option throws INVALID_OPTIONS
- **WHEN** `useSignIn().mutate({ email: "", password: "" })` is called
- **THEN** the mutation rejects with `REACT_AUTH.INVALID_OPTIONS`
#### Scenario: invalid useOAuthSignIn provider throws INVALID_OPTIONS
- **WHEN** `useOAuthSignIn().mutate({ provider: "", mode: "popup" })` is called
- **THEN** the mutation rejects with `REACT_AUTH.INVALID_OPTIONS`
**Verified by:** `src/schemas/schemas.test.ts::"rejects empty sign-in fields"` AND `src/schemas/schemas.test.ts::"rejects empty oauth provider"`
---
### Requirement: i18n catalogues for en-GB, cy-GB, zh-CN are parity-checked
The package SHALL ship `react-auth.*` message catalogues at `src/i18n/{en-gb,cy-gb,zh-cn}/index.ts`. The catalogue key set SHALL be identical across the three locales. A parity test SHALL fail CI when any locale's key set differs from `en-GB`.
#### Scenario: parity test fails on missing key
- **WHEN** `cy-GB` omits a key that exists in `en-GB`
- **THEN** the parity test fails with the missing key listed
#### Scenario: parity test fails on extra key
- **WHEN** `zh-CN` declares a key not present in `en-GB`
- **THEN** the parity test fails with the orphan key listed
**Verified by:** `src/i18n/parity.test.ts::"every locale has the same keys as en-GB"` AND `src/i18n/parity.test.ts::"no locale declares keys absent from en-GB"`
---
### Requirement: No hardcoded user-visible strings in components
Every user-visible string in any `src/components/**/*.tsx` SHALL resolve via the `react-auth.*` i18n catalogue. A guard test SHALL scan component source for hardcoded English strings and fail on any match outside an allowlist of fixture/identifier strings.
#### Scenario: a hardcoded button label is rejected
- **WHEN** a component literal contains `>` `Sign in` `<` (or equivalent visible English text) in JSX
- **THEN** the guard test fails citing the file and line
**Verified by:** `src/i18n/no-hardcoded-strings.test.ts::"every component string resolves via i18n"`
---
### Requirement: Subpath exports cover every hook, component, oauth helper, session module, errors, and i18n locale
The `package.json` `exports` map SHALL declare one tree-shakeable entry per feature module. At minimum the map SHALL include: `.`, `./auth-provider`, `./hooks/use-auth`, `./hooks/use-user`, `./hooks/use-is-authenticated`, `./hooks/use-session`, `./hooks/use-sign-in`, `./hooks/use-sign-up`, `./hooks/use-sign-out`, `./hooks/use-refresh-token`, `./hooks/use-change-password`, `./hooks/use-password-reset`, `./hooks/use-register-passkey`, `./hooks/use-authenticate-with-passkey`, `./hooks/use-passkey-autofill`, `./hooks/use-oauth-sign-in`, `./hooks/use-enroll-totp`, `./hooks/use-verify-totp`, `./hooks/use-enroll-webauthn-second-factor`, `./hooks/use-recovery-codes`, `./hooks/use-email-verification`, `./hooks/use-phone-verification`, `./hooks/use-permission`, `./hooks/use-auth-guard`, `./components/auth-guard`, `./components/permission-gate`, `./components/password-input`, `./components/password-strength-meter`, `./components/hibp-pwned-indicator`, `./components/password-generator-button`, `./components/passkey-autofill-field`, `./components/passkey-list`, `./components/oauth-provider-button`, `./components/totp-enrollment-panel`, `./components/recovery-codes-panel`, `./components/email-verification-view`, `./components/phone-verification-view`, `./components/auth-error-boundary`, `./oauth/handle-callback`, `./oauth/provider-registry`, `./session/storage`, `./session/broadcast-channel`, `./session/idle-timer`, `./errors`, `./i18n/en-gb`, `./i18n/cy-gb`, `./i18n/zh-cn`.
#### Scenario: every documented subpath resolves to declared symbols
- **WHEN** a consumer imports each documented subpath
- **THEN** every import resolves to the symbols listed in `proposal.md`'s public API
**Verified by:** `src/index.test.ts::"every documented subpath resolves"`
---
### Requirement: 100% Istanbul coverage with no carve-outs
The package SHALL meet 100% statement, 100% branch, 100% function, and 100% line coverage under `@vitest/coverage-istanbul` for `src/`. The pre-publish gate (`bunx turbo test:coverage --filter=@suaveplan/react-auth`) SHALL hard-fail below 100%.
#### Scenario: coverage gate hard-fails below 100%
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/react-auth` reports any metric < 100%
- **THEN** the command exits non-zero
**Verified by:** `vitest.config.ts::"thresholds set to 100/100/100/100"` AND `package.json::"test:coverage script wired"`
---
### Requirement: Playwright E2E covers password, passkey, OAuth, MFA, verification, multi-tab, idle, error-boundary, RTL, and accessibility
The package SHALL ship a Playwright E2E suite under `e2e/` containing at minimum: `password-sign-in.spec.ts`, `sign-up.spec.ts`, `password-reset.spec.ts`, `change-password.spec.ts`, `passkey-register.spec.ts`, `passkey-autofill.spec.ts`, `passkey-modal-authenticate.spec.ts`, `oauth-popup.spec.ts`, `oauth-redirect.spec.ts`, `totp-enroll-verify.spec.ts`, `recovery-codes.spec.ts`, `email-verification.spec.ts`, `phone-verification.spec.ts`, `multi-tab-sync.spec.ts`, `idle-timeout.spec.ts`, `error-boundary.spec.ts`, `rtl-locale.spec.ts`, `accessibility.spec.ts`. The accessibility spec SHALL fail on any `serious` or `critical` axe violation.
#### Scenario: accessibility sweep finds zero serious or critical violations
- **WHEN** `e2e/accessibility.spec.ts` runs against the canonical app
- **THEN** `@axe-core/playwright` reports zero `serious` and zero `critical` violations
#### Scenario: multi-tab sync propagates sign-out
- **WHEN** tab A signs out
- **THEN** within 2 s tab B's UI reflects the unauthenticated state
**Verified by:** `e2e/accessibility.spec.ts::"every canonical page passes axe with zero serious or critical violations"` AND `e2e/multi-tab-sync.spec.ts::"sign-out in one tab propagates within 2 seconds"` AND `e2e/passkey-autofill.spec.ts::"conditional UI delivers credential through browser autofill"`
<!-- markdownlint-enable MD024 -->