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,282 @@
# Design — `@suaveplan/react-auth` (complete-react-auth)
Non-normative. Architecture, lifecycle, prop tables, and rationale. The normative contract lives in `specs/react-auth/spec.md`.
## Package shape
`@suaveplan/react-auth` is a browser-tier React 19 package at `packages/auth/react-auth/`. It composes the existing genesis auth primitives (`webauthn`, `oauth-client`, `srp`, `otp`, `hibp`, `password-strength`, `password-generator`) into hooks and components that React apps can drop in. There is no global singleton; everything is rooted in `<AuthProvider>` and tree-shakeable via subpath exports.
## File layout
```text
packages/auth/react-auth/
README.md
package.json
tsconfig.json
vitest.config.ts
playwright.config.ts
src/
index.ts # explicit named re-exports (rule 6a)
types/
auth-user.ts / auth-user.md
auth-state.ts / auth-state.md
auth-backend.ts / auth-backend.md
session.ts / session.md
idle-config.ts / idle-config.md
permission-resolver.ts / permission-resolver.md
errors/
index.ts
errors.ts / errors.md / errors.test.ts
schemas/
schemas.ts / schemas.md / schemas.test.ts # Zod boundary schemas for every hook option bag
auth-provider/
auth-provider.tsx / .md / .test.tsx / .stories.tsx
auth-context.ts / .md / .test.ts
use-auth-context.ts / .md / .test.ts
load-persisted-session.ts / .md / .test.ts
hooks/
use-auth/ use-user/ use-is-authenticated/ use-session/
use-sign-in/ use-sign-up/ use-sign-out/ use-refresh-token/
use-change-password/ use-password-reset/
use-register-passkey/ use-authenticate-with-passkey/ use-passkey-autofill/
use-oauth-sign-in/
use-enroll-totp/ use-verify-totp/ use-enroll-webauthn-second-factor/ use-recovery-codes/
use-email-verification/ use-phone-verification/
use-permission/ use-auth-guard/
components/
auth-guard/ permission-gate/
password-input/ password-strength-meter/ hibp-pwned-indicator/ password-generator-button/
passkey-autofill-field/ passkey-list/
oauth-provider-button/
totp-enrollment-panel/ recovery-codes-panel/
email-verification-view/ phone-verification-view/
auth-error-boundary/
oauth/
handle-callback.ts / .md / .test.ts
provider-registry.ts / .md / .test.ts
popup.ts / .md / .test.ts
redirect.ts / .md / .test.ts
session/
storage/ # memory / local / session adapters
broadcast-channel.ts / .md / .test.ts
idle-timer.ts / .md / .test.ts
auto-refresh.ts / .md / .test.ts
forced-logout.ts / .md / .test.ts
utils/
compose-signals.ts / .md / .test.ts
is-ssr.ts / .md / .test.ts
announce.ts / .md / .test.ts # ARIA live-region helper
i18n/
en-gb/ cy-gb/ zh-cn/ # message catalogues
parity.test.ts # key-set parity gate
e2e/
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
```
## Context shape
```ts
interface AuthState {
status: "loading" | "authenticated" | "unauthenticated" | "mfa-required";
user: AuthUser | null;
accessToken: string | null;
expiresAt: number | null; // epoch ms
refreshAt: number | null; // epoch ms (= expiresAt - skewMs)
error: SuaveplanError | null;
}
interface AuthContextValue {
state: AuthState;
signIn(opts: PasswordSignInOptions | SrpSignInOptions | PasskeySignInOptions): Promise<void>;
signUp(opts: SignUpOptions): Promise<void>;
signOut(opts?: SignOutOptions): Promise<void>;
refresh(): Promise<void>;
forceLogout(reason: string): void;
backend: AuthBackend;
resolveMessage: MessageResolver;
permissions: PermissionResolver;
}
```
## AuthBackend interface (caller-implemented)
```ts
interface AuthBackend {
passwordSignIn(p: { email: string; password: string }): Promise<SignInResult>;
srpSignIn(p: SrpHandshakeIn): Promise<SignInResult>;
signUp(p: SignUpRequest): Promise<SignUpResult>;
requestPasswordReset(p: { email: string }): Promise<void>;
confirmPasswordReset(p: { token: string; newPassword: string }): Promise<void>;
changePassword(p: { currentPassword: string; newPassword: string }): Promise<void>;
requestEmailVerification(): Promise<void>;
confirmEmailVerification(p: { token: string }): Promise<void>;
requestPhoneVerification(p: { phone: string }): Promise<void>;
confirmPhoneVerification(p: { code: string }): Promise<void>;
oauthExchange(p: { code: string; state: string; provider: string; codeVerifier: string }): Promise<TokenSet>;
oauthRefresh(p: { refreshToken: string }): Promise<TokenSet>;
oauthRevoke(p: { token: string }): Promise<void>;
passkeyRegisterOptions(p: { userId: string }): Promise<WebAuthnRegisterOptions>;
passkeyRegisterFinish(p: { credential: RegisterResult }): Promise<RegisteredPasskey>;
passkeyAuthenticateOptions(p: { userHint?: string; mediation: "required" | "conditional" }): Promise<WebAuthnAuthenticateOptions>;
passkeyAuthenticateFinish(p: { assertion: AuthenticateResult }): Promise<SignInResult>;
listPasskeys(): Promise<RegisteredPasskey[]>;
revokePasskey(p: { credentialId: string }): Promise<void>;
totpEnrollBegin(): Promise<{ secret: string; provisioningUri: string }>;
totpEnrollFinish(p: { code: string }): Promise<{ recoveryCodes: string[] }>;
totpVerify(p: { code: string }): Promise<SignInResult>;
recoveryCodesGenerate(): Promise<{ codes: string[] }>;
recoveryCodesVerify(p: { code: string }): Promise<SignInResult>;
sessionRefresh(): Promise<TokenSet>;
sessionRevoke(): Promise<void>;
}
```
## Hook signatures (summary table)
| Hook | Signature |
|---|---|
| `useAuth` | `() => AuthContextValue` |
| `useUser` | `() => AuthUser \| null` |
| `useIsAuthenticated` | `() => boolean` |
| `useSession` | `() => AuthState` |
| `useSignIn` | `() => MutationHandle<SignInOptions, void>` |
| `useSignUp` | `() => MutationHandle<SignUpOptions, void>` |
| `useSignOut` | `() => MutationHandle<SignOutOptions \| void, void>` |
| `useRefreshToken` | `() => MutationHandle<void, void>` |
| `useChangePassword` | `() => MutationHandle<{ currentPassword: string; newPassword: string }, void>` |
| `usePasswordReset` | `() => { request: MutationHandle<{ email: string }, void>; confirm: MutationHandle<{ token: string; newPassword: string }, void> }` |
| `useRegisterPasskey` | `() => MutationHandle<{ userId: string; friendlyName?: string }, RegisteredPasskey>` |
| `useAuthenticateWithPasskey` | `() => MutationHandle<{ userHint?: string }, void>` |
| `usePasskeyAutofill` | `(opts: UsePasskeyAutofillOptions) => { ready: boolean; error: SuaveplanError \| null; abort: () => void }` |
| `useOAuthSignIn` | `() => MutationHandle<{ provider: string; mode: "popup" \| "redirect"; scopes?: string[] }, void>` |
| `useEnrollTotp` | `() => { begin: MutationHandle<void, { secret: string; provisioningUri: string }>; finish: MutationHandle<{ code: string }, { recoveryCodes: string[] }> }` |
| `useVerifyTotp` | `() => MutationHandle<{ code: string }, void>` |
| `useEnrollWebauthnSecondFactor` | `() => MutationHandle<{ userId: string; selector: "platform" \| "cross-platform" }, RegisteredPasskey>` |
| `useRecoveryCodes` | `() => { generate: MutationHandle<void, { codes: string[] }>; verify: MutationHandle<{ code: string }, void> }` |
| `useEmailVerification` | `() => { request: MutationHandle<void, void>; confirm: MutationHandle<{ token: string }, void> }` |
| `usePhoneVerification` | `() => { request: MutationHandle<{ phone: string }, void>; confirm: MutationHandle<{ code: string }, void> }` |
| `usePermission` | `(name: string) => boolean` |
| `useAuthGuard` | `() => { status: AuthState["status"]; redirectTo?: string }` |
`MutationHandle<TArg, TOut>` = `{ mutate(arg: TArg): Promise<TOut>; status: "idle"\|"pending"\|"success"\|"error"; error: SuaveplanError \| null; data: TOut \| null; reset(): void }`. Stable identity across renders.
## Component prop tables (canonical)
| Component | Required props | Optional props |
|---|---|---|
| `<AuthProvider>` | `backend: AuthBackend`, `children` | `storage?: StorageLike`, `broadcastChannelFactory?`, `idle?: IdleConfig`, `clock?: Clock`, `logger?: LoggerLike`, `resolveMessage?: MessageResolver`, `permissions?: PermissionResolver`, `refreshSkewMs?` |
| `<AuthGuard>` | `children` | `fallback?: ReactNode`, `onUnauthenticated?: () => void` |
| `<PermissionGate>` | `permission: string`, `children` | `fallback?: ReactNode` |
| `<PasswordInput>` | `name: string`, `value: string`, `onChange` | `showStrength?: boolean`, `hibpClient?: HibpClient`, `autoComplete?`, `id?`, `label?: string`, `describedById?` |
| `<PasswordStrengthMeter>` | `password: string` | `userInputs?: string[]`, `minBits?: number` |
| `<HibpPwnedIndicator>` | `password: string`, `client: HibpClient` | `debounceMs?: number` |
| `<PasswordGeneratorButton>` | `onPick: (password: string) => void` | `mode?: "passphrase" \| "chars" \| "pin"`, `length?: number`, `wordlist?: Wordlist` |
| `<PasskeyAutofillField>` | `rpId: string`, `challenge: Uint8Array`, `onCredential` | `inputProps?`, `onError?`, `signal?`, `allowCredentials?`, `userVerification?`, `timeout?`, `expectedOrigin?`, `expectedRpIdHash?` |
| `<PasskeyList>` | — | `onRevoked?: (credentialId: string) => void` |
| `<OAuthProviderButton>` | `provider: string` | `mode?: "popup" \| "redirect"`, `scopes?: string[]`, `iconSlot?: ReactNode`, `labelKey?: string` |
| `<TotpEnrollmentPanel>` | `renderQrCode: (uri: string) => ReactNode` | `onComplete?: (recoveryCodes: string[]) => void` |
| `<RecoveryCodesPanel>` | `codes: string[]` | `onAcknowledged?: () => void`, `downloadFileName?` |
| `<EmailVerificationView>` | — | `cooldownMs?` |
| `<PhoneVerificationView>` | — | — |
| `<AuthErrorBoundary>` | `children` | `fallback?: (err: SuaveplanError) => ReactNode` |
Every prop interface is named (CLAUDE.md rule 11 — no inline shapes in signatures).
## SSR strategy
- `is-ssr.ts` exposes `isServer = typeof window === "undefined"`. Every hook calls it before any DOM access.
- `<AuthProvider>` initialises with `{ status: "loading", user: null, ... }` on first render. The persisted-session load runs inside `useEffect`, deterministic across server and client.
- All `BroadcastChannel`, `addEventListener`, and `navigator.credentials.get` calls live in `useEffect` bodies. The hook return values are stable references during SSR.
- `usePasskeyAutofill` short-circuits to `{ ready: false, error: null, abort: noop }` on the server (re-uses the autofill design from the superseded change).
- Storage adapters export pure-function APIs; the `localStorage` / `sessionStorage` variants throw `REACT_AUTH.SSR_NO_OP` (caught and reported) if invoked on the server — but provider wires them inside an effect, so this only matters for misuse.
## Session-sync model
- A single `BroadcastChannel("@suaveplan/react-auth")` per provider instance (factory-injected for tests + SSR).
- Channel messages are typed `BroadcastEnvelope = { kind: "signed-in" | "signed-out" | "session-refreshed" | "forced-logout"; payload?: ... }`.
- On `signed-in` / `session-refreshed` received from another tab: provider hydrates state from `StorageLike` (single source of truth).
- On `signed-out` / `forced-logout` received: provider clears state immediately, fires the typed-error callback, and re-renders. No backend call is issued (the originating tab already did that).
- Auto-refresh: `auto-refresh.ts` schedules a `setTimeout` against `refreshAt`; cancels on every state update. On success, broadcasts `session-refreshed`.
- Idle timer: listens for `pointerdown`, `keydown`, `visibilitychange`, debounced to ≤ 1 event / 250 ms. Fires `onWarn` at `ttlMs - warnBeforeMs`, then `onTimeout` (default: `forceLogout("idle")`). Listeners attached in an effect; removed on unmount.
## OAuth flow
### Popup mode
1. `useOAuthSignIn().mutate({ provider, mode: "popup", scopes })`.
2. Hook generates `state` + PKCE `code_verifier` via `@suaveplan/oauth-client`'s `generatePkcePair`, persists `{ state, codeVerifier, provider }` to `sessionStorage` keyed by state.
3. Opens centred popup to `buildAuthorizationUrl(provider, ...)` from the provider registry.
4. Listens on `window.message` for `{ type: "@suaveplan/react-auth/oauth-callback", state, code }` from `same-origin` only (origin check enforced).
5. Looks up persisted PKCE state, calls `backend.oauthExchange({ code, state, provider, codeVerifier })`, persists session, broadcasts `signed-in`, closes the popup.
6. Errors: popup blocked → `OAUTH_POPUP_BLOCKED`; state mismatch → `OAUTH_STATE_MISMATCH`; exchange failure → `OAUTH_CALLBACK_FAILED`.
### Redirect mode
1. Same PKCE setup; persists return state to `sessionStorage`.
2. Calls `window.location.assign(authorizationUrl)`.
3. The callback page mounts `handleOAuthCallback()` which performs the same exchange + persist; navigates to the originally-requested URL (or `/`).
### `handleOAuthCallback()`
Called from the consumer's OAuth-callback route component. Reads `code` + `state` from `window.location.search`, picks up persisted PKCE state, runs the exchange, posts a `postMessage` back to `window.opener` (popup mode) or persists session + navigates (redirect mode). Idempotent on remount via a one-shot guard keyed by `state`.
## Error code namespace
`REACT_AUTH` registry is single-source-of-truth at `src/errors/errors.ts`. Every translation preserves the underlying `cause`. Every code is reachable from at least one production call site (verified by `errors.test.ts` exhaustive enumeration). Categories:
- Provider: `CONTEXT_MISSING`, `INVALID_OPTIONS`, `SSR_NO_OP`.
- Auth lifecycle: `UNAUTHENTICATED`, `SIGN_IN_FAILED`, `SIGN_UP_FAILED`, `SIGN_OUT_FAILED`, `REFRESH_FAILED`, `BACKEND_ERROR`.
- Passwords: `PASSWORD_TOO_WEAK`, `PASSWORD_PWNED`, `PASSWORD_RESET_FAILED`, `CHANGE_PASSWORD_FAILED`.
- Passkeys: `PASSKEY_REGISTER_FAILED`, `PASSKEY_AUTHENTICATE_FAILED`, `PASSKEY_AUTOFILL_UNAVAILABLE`, `PASSKEY_AUTOFILL_FAILED`, `PASSKEY_AUTOFILL_ABORTED`.
- OAuth: `OAUTH_POPUP_BLOCKED`, `OAUTH_STATE_MISMATCH`, `OAUTH_CALLBACK_FAILED`.
- MFA: `MFA_REQUIRED`, `MFA_VERIFY_FAILED`, `TOTP_ENROLL_FAILED`, `RECOVERY_CODE_INVALID`.
- Verification: `EMAIL_VERIFY_FAILED`, `PHONE_VERIFY_FAILED`.
- Session: `IDLE_TIMEOUT`, `FORCED_LOGOUT`.
## i18n contract
- Catalogues at `src/i18n/{en-gb,cy-gb,zh-cn}/index.ts` (one folder per locale; one `.md` per file).
- `parity.test.ts` walks `en-GB` keys and asserts the same key set exists on every other locale — fails CI on drift.
- Every visible string in every component is a `react-auth.<group>.<key>` lookup. Tests assert no hardcoded user-facing strings remain.
## Accessibility approach
- Per-component `axe-core` assertions in stories (`*.stories.tsx` includes an `axe` decorator).
- Playwright `accessibility.spec.ts` runs `@axe-core/playwright` across the canonical pages assembled in `e2e/fixtures/app.tsx`.
- Status changes route through `announce()` (ARIA live-region helper) so screen readers hear sign-in/out + idle warnings.
- All dialogs trap focus on open and restore focus on close (uses `@suaveplan/react-ui`'s `<Dialog>` primitive, which already does this).
## Testing approach
- Vitest + `@suaveplan/testing-dom` for hooks & components. Runner imports via `@suaveplan/testing/runner` only (rule 11). No `vi.mock` (rule 2) — backends and adapters are injected.
- `AuthBackend` fixtures live at `src/__fixtures__/in-memory-backend.ts` (real in-memory implementation that exercises every method; no mocks).
- Playwright E2E hits a Docker-mounted stub backend (web app under `e2e/fixtures/app.tsx`).
- Coverage: 100/100/100/100 Istanbul (rule 7).
## Decisions
- **Transport-agnostic.** `AuthBackend` is an interface, never a fetch wrapper. Consumers decide REST / GraphQL / tRPC / Better-Auth / SDK.
- **No global state.** Multiple providers (multi-tenant) coexist; each owns its own context + storage namespace.
- **Browser-tier only.** Server-side session storage lives in `@suaveplan/server-session` (out of scope).
- **Composable, not page-shaped.** Upstream's `<LoginPage>`/`<ProfilePage>` are not re-shipped — they belong to the consuming app. We ship the reusable pieces.
- **WebAuthn primitives stay in `@suaveplan/webauthn`.** This package only wraps them in React lifecycle.
- **Errors are translated, not leaked.** Consumers see `REACT_AUTH.*` codes; `WEBAUTHN.*` / `OAUTH_CLIENT.*` etc. preserved on `cause`.
- **SSR-safe by default.** No hook touches `window` at module load. Provider hydrates in `useEffect`.
- **Accessibility is non-optional.** Every component must pass `axe` in stories and the E2E sweep — not a separate "a11y pass" later.

View File

@@ -0,0 +1,197 @@
# OpenSpec Change: complete-react-auth
- **Change ID**: complete-react-auth
- **Package**: `@suaveplan/react-auth` (NEW)
- **Target path**: `packages/auth/react-auth/`
- **Tier**: browser (React 19 peer)
- **Status**: PROPOSED
- **Date**: 2026-05-17
- **Design**: ./design.md
- **Spec delta**: ./specs/react-auth/spec.md
- **Supersedes**: `openspec/changes/enterprise-react-auth/` (narrow OAuth-only scaffold) AND `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-react-auth-passkey-autofill/` (passkey-autofill-only follow-up). Both are folded in.
- **Upstream reference**: `/mnt/s/web/suaveplan/enterprise/react-auth.md`
- **Upstream source**: `/mnt/s/web/suaveplan/packages/react/react-auth/src/` (Better-Auth-bound; pages, forms, guards, three translation packs, E2E)
---
## Why
`@suaveplan/react-auth` is the React surface for **every** authentication interaction in the genesis stack. The two pre-existing proposals each scoped a single slice (OAuth context-only in `enterprise-react-auth`; conditional-UI passkey autofill in `add-react-auth-passkey-autofill`) and deliberately left every other auth flow — passwords, passkey registration & modal authentication, MFA, OAuth providers beyond a token exchange, session refresh, multi-tab sync, email/phone verification, error boundaries, accessibility, i18n — on a "follow-up" pile. The user has explicitly rejected that pattern: shipping one piece at a time produces a package that consumers cannot adopt without hand-rolling the rest, which defeats the purpose of a `react-auth` package existing.
This change lands the **entire intended React-auth surface in one shot** at enterprise quality (100% coverage, sibling `.md` per file, Storybook, Playwright, accessibility, SSR-safe, i18n-keyed). Every primitive the package needs already ships on genesis: `@suaveplan/webauthn` (register / authenticate / `isConditionalMediationAvailable`), `@suaveplan/oauth-client` (Authorization Code + PKCE, refresh, revoke, discovery, provider presets), `@suaveplan/srp` (zero-knowledge password proof), `@suaveplan/otp` (HOTP / TOTP / recovery codes), `@suaveplan/hibp` (k-anonymity breach check), `@suaveplan/password-strength` (zxcvbn-style estimator), `@suaveplan/password-generator`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/logger`, `@suaveplan/i18n`, `@suaveplan/react-ui`. The React layer is the last missing piece.
Consumers (`@suaveplan/web-app`, downstream tenant apps) need a single import surface that wires those primitives into hooks and components: `<AuthProvider>` for context, hooks for every state read and every lifecycle mutation, components for the canonical UX (password input with strength + HIBP, passkey autofill field, passkey list with revoke, MFA enrollment, recovery codes, OAuth button, verification flows), an error boundary that turns typed `AuthError` codes into friendly i18n-keyed messages, multi-tab session sync via `BroadcastChannel`, and SSR-safe default behaviour everywhere. Shipping anything less forces every consumer to write the same wiring badly.
## What changes
### NEW package: `@suaveplan/react-auth`
Browser-tier React 19 package at `packages/auth/react-auth/`. Single import surface; every feature lives behind a tree-shakeable subpath export. No global state, no required server. Pure-client logic; the caller wires `AuthBackend` (transport adapter) to whatever server they run.
### Context + provider
- ADDED: `<AuthProvider>` — top-level provider. Accepts `AuthBackend` (transport), `StorageLike` (token + session persistence), `BroadcastChannel`-like factory (multi-tab sync), optional `IdleConfig`, optional `Logger`, optional `Clock`. Loads persisted session on mount; subscribes to `BroadcastChannel` for cross-tab forced-logout / refresh events; installs idle-timeout timer; never touches `window` at import time.
- ADDED: `AuthContext` + `useAuthContext()` — raw context handle; throws `REACT_AUTH.CONTEXT_MISSING` outside provider.
- ADDED: `AuthBackend` interface — every server call (password sign-in, sign-up, password-reset request + confirm, change password, email-verify request + confirm, phone-verify request + confirm, OAuth code exchange, OAuth refresh, OAuth revoke, passkey register options + finish, passkey assertion options + finish, list passkeys, revoke passkey, TOTP enroll begin + finish, TOTP verify, recovery-codes generate + verify, SRP exchange, session refresh, session revoke). Caller implements; the package never assumes a wire format.
### State + lifecycle hooks
- ADDED: `useAuth()` — full context value (state + every mutation).
- ADDED: `useUser()` — current `AuthUser | null`.
- ADDED: `useIsAuthenticated()` — boolean.
- ADDED: `useSession()` — `{ user, accessToken, expiresAt, refreshAt, status }`.
- ADDED: `useSignIn()`, `useSignUp()`, `useSignOut()` — async mutation hooks with `{ mutate, status, error, reset }`.
- ADDED: `useRefreshToken()` — manual refresh; the provider also auto-refreshes via timer at `refreshAt`.
### Passwords
- ADDED: `useChangePassword()`, `usePasswordReset()` (returns `requestReset(email)` + `confirmReset(token, newPassword)`).
- ADDED: `<PasswordInput>` — accessible input with show/hide toggle, optional integrated `<PasswordStrengthMeter>` and HIBP-pwned check (debounced, opt-in).
- ADDED: `<PasswordStrengthMeter>` — wraps `@suaveplan/password-strength` `assessReactive` with cancellation on unmount, renders a 5-bucket bar with i18n-keyed feedback strings.
- ADDED: `<HibpPwnedIndicator>` — wraps `@suaveplan/hibp` k-anonymity check (caller injects the client; debounced ≥ 500 ms; no network on SSR).
- ADDED: `<PasswordGeneratorButton>` — opens a `@suaveplan/password-generator` popover; emits chosen password via callback.
### Passkeys (WebAuthn)
- ADDED: `useRegisterPasskey()` — wraps `webauthn.register` with the typed options/result + error translation.
- ADDED: `useAuthenticateWithPasskey()` — wraps `webauthn.authenticate` (modal mediation: `"required"` or unset).
- ADDED: `usePasskeyAutofill()` — wraps `webauthn.authenticate({ mediation: "conditional" })` with the lifecycle from the superseded passkey-autofill design (SSR-gated, `isConditionalMediationAvailable` feature detect, composed `AbortController`, stable `abort` callback).
- ADDED: `<PasskeyAutofillField>` — `<input autocomplete="username webauthn">` wrapper, blur-cancels when no credential delivered.
- ADDED: `<PasskeyList>` — lists the user's registered authenticators with friendly name, last-used timestamp, and a per-row revoke button (accessible confirm dialog).
### OAuth
- ADDED: `useOAuthSignIn()` — `signIn({ provider, mode: "popup" | "redirect", scopes? })`. Popup mode opens a centred window, waits for `postMessage` from the callback page, exchanges code → tokens via the backend, closes the popup. Redirect mode persists return-state and calls `window.location.assign`.
- ADDED: `<OAuthProviderButton>` — accessible button with provider-branded icon + i18n-keyed label; uses `useOAuthSignIn` under the hood.
- ADDED: `OAuthProviderRegistry` — name-keyed registry of `OAuthProviderDescriptor` (label key, icon, scopes). Caller registers providers at provider-setup time.
- ADDED: `handleOAuthCallback()` — page-level helper for the redirect-callback URL; parses query, validates state, exchanges code, posts result back to the opener (popup mode) or persists session + navigates (redirect mode).
### MFA
- ADDED: `useEnrollTotp()` — `begin()` returns secret + provisioning URI (caller renders QR via `@suaveplan/qrcode`); `finish(code)` verifies.
- ADDED: `useVerifyTotp()` — sign-in second factor.
- ADDED: `useEnrollWebauthnSecondFactor()` — re-uses `webauthn.register` with the `"cross-platform" | "platform"` selector.
- ADDED: `useRecoveryCodes()` — `generate()`, `verify(code)`.
- ADDED: `<TotpEnrollmentPanel>` — orchestrates secret display + code entry + recovery-codes reveal in one accessible flow.
- ADDED: `<RecoveryCodesPanel>` — accessible list with copy-to-clipboard + download-as-text + acknowledge-saved checkbox before dismiss.
### Email / phone verification
- ADDED: `useEmailVerification()` — `request()`, `confirm(token)`.
- ADDED: `usePhoneVerification()` — `request()`, `confirm(code)`.
- ADDED: `<EmailVerificationView>` — renders the "check your inbox" UX with resend cooldown timer.
- ADDED: `<PhoneVerificationView>` — code-entry input optimised for mobile (numeric, autocomplete=`one-time-code`).
### Session lifecycle
- ADDED: token persistence via injected `StorageLike` (memory / `localStorage` / `sessionStorage` adapters bundled).
- ADDED: auto-refresh — provider installs a timer firing at `refreshAt = expiresAt - skewMs` via injected `Clock`.
- ADDED: multi-tab sync — provider opens a `BroadcastChannel("@suaveplan/react-auth")` (factory injectable for SSR / test); emits `signed-in`, `signed-out`, `session-refreshed`, `forced-logout`; receivers update local state synchronously.
- ADDED: idle timeout — optional `IdleConfig` ({ ttlMs, warnBeforeMs, onWarn, onTimeout }) triggers automatic sign-out via DOM activity listeners (pointer/key/visibility); accessibility-friendly warning callback so consumers can render a modal.
- ADDED: forced-logout — `<AuthProvider>` exposes `forceLogout(reason)`; broadcasts to other tabs.
### Guards
- ADDED: `<AuthGuard>` — renders children when authenticated; otherwise renders `fallback` (default: throws `REACT_AUTH.UNAUTHENTICATED` for the nearest error boundary).
- ADDED: `<PermissionGate>` — role / permission gating using injected `PermissionResolver`.
- ADDED: `withAuth(Component)`, `usePermission(name)`, `useAuthGuard()` — hook/HOC variants for the two guards.
### Error UX + i18n
- ADDED: `defineCodes("REACT_AUTH", [...])` registry — `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 reachable from a real call site; every translation preserves underlying `cause`.
- ADDED: `<AuthErrorBoundary>` — React error boundary that catches `SuaveplanError<*REACT_AUTH.*>` and renders an i18n-keyed user-facing message via the injected `MessageResolver`; unrecognised errors re-throw.
- ADDED: i18n catalog — `en-GB` (canonical), `cy-GB`, `zh-CN` (parity-checked via `bun scripts/check-i18n-parity.ts`); message keys namespaced under `react-auth.*`.
### SSR safety
- Every hook returns a stable no-op shape during SSR (`typeof window === "undefined"`); zero `window` / `navigator` / `document` access at module load.
- `<AuthProvider>` resolves persisted-session loading inside `useEffect`, so the server render is deterministic.
- Subpath exports stay tree-shakeable; importing the OAuth surface alone does not pull WebAuthn.
### Accessibility
- Every component meets WCAG 2.1 AA: explicit `<label>` association, `aria-describedby` for help/error text, `aria-live="polite"` for status changes, keyboard focus management on dialog open/close, `prefers-reduced-motion` respected on transitions, contrast ratios verified in stories.
- Live-region announcements on sign-in success/failure and on idle-timeout warning.
### Storybook + tests + E2E
- ADDED: `.stories.tsx` for every component (default + playground + variants including unauthenticated / loading / error / RTL / dark).
- ADDED: Vitest tests for every hook and component using `@suaveplan/testing/runner` (rule 11) and `@suaveplan/testing-dom` render helpers.
- ADDED: Playwright `e2e/` covering: password sign-in, sign-up, password reset, change-password, passkey register, passkey autofill, modal passkey authenticate, OAuth popup, OAuth redirect, TOTP enroll + verify, recovery-code redeem, email verification, phone verification, multi-tab sign-out propagation, idle-timeout sign-out, error-boundary fallback rendering, RTL locale rendering.
### Subpath exports
`./auth-provider`, `./hooks/use-auth`, `./hooks/use-user`, … (one per hook), `./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`.
### Dependencies
- `@suaveplan/types workspace:^` — `StorageLike`, `Clock`, `LoggerLike`, `IdGenerator`, `MessageResolver`.
- `@suaveplan/error workspace:^` — `SuaveplanError`, `defineCodes`.
- `@suaveplan/events workspace:^` — internal event bus for auth lifecycle.
- `@suaveplan/logger workspace:^` — structured logging.
- `@suaveplan/i18n workspace:^` — message resolution.
- `@suaveplan/webauthn workspace:^` — passkey ceremonies.
- `@suaveplan/oauth-client workspace:^` — OAuth + OIDC.
- `@suaveplan/srp workspace:^` — SRP-6a password proof (optional sign-in path).
- `@suaveplan/otp workspace:^` — TOTP + recovery codes.
- `@suaveplan/hibp workspace:^` — pwned-password check.
- `@suaveplan/password-strength workspace:^` — strength estimator.
- `@suaveplan/password-generator workspace:^` — passphrase / password generator.
- `@suaveplan/react-ui workspace:^` — button, dialog, input primitives.
- `react ^19.0.0` (peer), `react-dom ^19.0.0` (peer).
- `zod catalog:forms`, `tslib catalog:build`.
## Source mapping (upstream → genesis)
| Upstream (`/mnt/s/web/suaveplan/packages/react/react-auth/src/`) | Genesis (`packages/auth/react-auth/src/`) | Notes |
|---|---|---|
| `client/auth-client.ts` (Better-Auth wrapper) | (replaced) `auth-backend/` interface | Genesis transport-agnostic; consumer wires backend. Removes `@ts-expect-error` (upstream gap §3). |
| `components/auth-provider/` | `auth-provider/` | Re-architected for multi-tab + idle + injectable Clock/Storage. |
| `context/auth-context.tsx` | `auth-provider/auth-context.ts` | Inlined into provider surface; explicit `useAuthContext()` accessor. |
| `components/auth-guard/`, `hooks/use-auth-guard.ts` | `auth-guard/` | Adds typed-error fallback; keeps HOC `withAuth`. |
| `components/permission-gate/`, `hooks/use-permission.ts` | `permission-gate/` | Adds injectable `PermissionResolver`. |
| `components/login-form/`, `register-form/`, `forgot-password-form/`, `reset-password-form/`, `email-verification-view/` | `components/<form>/` | Each gains its own test (upstream gap §3); composed from `<PasswordInput>` etc. |
| `pages/{login,register,forgot-password,reset-password,email-verification,profile}/` | (out) | Page-level orchestration belongs to the consuming app — this package ships the reusable hooks + components only (eliminates the upstream pages/forms duplication noted in `enterprise/react-auth.md` §3). |
| `translations/{en-gb,cy-gb,zh-cn}.ts` | `i18n/{en-gb,cy-gb,zh-cn}/` | Parity-checked at CI. |
| `lib/better-auth-adapter.ts` | (replaced) example in `README.md` | Better Auth is one possible backend; the canonical interface is `AuthBackend`. |
| `types/` | `types/` | Trimmed to active types; `crud.types.ts` moved out (belongs to consuming-app boilerplate, not auth). |
| n/a (new) | `use-passkey-autofill/`, `passkey-autofill-field/` | Folded in from `add-react-auth-passkey-autofill` (superseded). |
| n/a (new) | TOTP / recovery / passkey-register / passkey-list / OAuth popup + redirect / idle timer / broadcast-channel sync / pwned indicator / strength meter / password-generator-button / phone verification | Closes the upstream "feature-rich enhancements" list (`enterprise/react-auth.md` §4) — MFA, passkeys, SSO orchestration, device management, telemetry, accessibility polish — and the user's explicit no-deferral list. |
## Verification approach
Each `### Requirement` in `specs/react-auth/spec.md` carries a `**Verified by:**` line at draft time naming the planned test file and test-name (CLAUDE.md §0c / OpenSpec §12a). Coverage gate: 100/100/100/100 Istanbul (rule 7). Test runner: `@suaveplan/testing/runner` (rule 11). Real-DOM hook + component testing via `@suaveplan/testing-dom`. Playwright E2E gates the multi-tab + popup-OAuth + idle-timeout flows that JSDOM cannot exercise. Accessibility verified per component via `@axe-core/playwright` in the E2E sweep.
## Impact
### Affected dependents
This is a NEW package. Run `bunx turbo ls --filter=...@suaveplan/react-auth` after landing.
- No dependents at landing time (verified by `bunx turbo ls --filter=...@suaveplan/react-auth` — currently empty). Adopters (`@suaveplan/web-app`, downstream tenant apps) will migrate in their own follow-up changes.
### Foundation packages touched
- None modified. Every dependency (`webauthn`, `oauth-client`, `srp`, `otp`, `hibp`, `password-strength`, `password-generator`, `i18n`, `react-ui`, `error`, `events`, `logger`, `types`) is consumed at its current published surface.
## Out of scope
Strictly limited to capabilities that belong to *other* packages, not deferred react-auth UX. The user explicitly rejected scoping any react-auth feature out.
- **Server-side session storage** — lives in `@suaveplan/server-session` (separate package). This package is browser-tier; sessions on the server are the server's responsibility.
- **Server-side OAuth callback handling on a different origin** — handled by the consumer's API route or `@suaveplan/api-auth-*` plugins. This package handles the browser-side callback page only.
- **WebAuthn server verification** — `@suaveplan/webauthn`'s `verifyAttestation` / `verifyAssertion` run on the server. The React hook only invokes the ceremony; the backend (caller-implemented) calls the verifier.
- **Backend transport implementation** — the package defines `AuthBackend`; concrete implementations against Better Auth / Hono routes / tRPC / GraphQL live in consumer code or sibling adapter packages.
- **Page-level orchestration** — `<LoginPage>`, `<ProfilePage>` etc. live in the consuming app. This package ships hooks + composable components; the app stitches them.
- **QR code rendering** — `@suaveplan/qrcode` already exists; `<TotpEnrollmentPanel>` consumes it via an injectable `renderQrCode` prop rather than re-implementing.
## Supersedes
On archival of `complete-react-auth`, the following are folded in / superseded and moved aside:
1. `openspec/changes/enterprise-react-auth/` — narrow OAuth-only scaffold. Every requirement it would have specified is covered here in expanded form. Move to `openspec/changes/_superseded-narrow-deferrals-2026-05-17/enterprise-react-auth/` as part of this proposal landing.
2. `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-react-auth-passkey-autofill/` — already moved aside in a prior superseding wave; its design notes for the autofill lifecycle are reused verbatim in `design.md` and `specs/react-auth/spec.md` here.
## Migration
No prior shipped surface on genesis — additive only. The two superseded change folders never archived, so there is no canonical spec to migrate from. Future consumers adopt `@suaveplan/react-auth` directly.

View File

@@ -0,0 +1,656 @@
<!-- 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 -->

View File

@@ -0,0 +1,179 @@
# Tasks — complete-react-auth
**Change ID**: complete-react-auth
**Package**: `@suaveplan/react-auth`
**Target path**: `packages/auth/react-auth/`
Pre-flight: `@suaveplan/types`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/logger`, `@suaveplan/i18n`, `@suaveplan/webauthn`, `@suaveplan/oauth-client`, `@suaveplan/srp`, `@suaveplan/otp`, `@suaveplan/hibp`, `@suaveplan/password-strength`, `@suaveplan/password-generator`, `@suaveplan/react-ui` — all ✅ on Verdaccio.
---
## 0. Pre-flight (non-negotiable)
- [ ] 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)
- [ ] 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.
- [ ] 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. Scaffold
- [ ] Create directory tree per `design.md` § File layout.
- [ ] Write `package.json` at `0.1.0`, `"sideEffects": false`, `react ^19.0.0` peer, every internal dep as `workspace:^`, every external dep via catalog.
- [ ] Write `tsconfig.json` (extends `@suaveplan/typescript-config/browser-react.json`), `vitest.config.ts` (forks pool, fileParallelism false, istanbul 100/100/100/100), `playwright.config.ts` via `@suaveplan/testing-e2e` factory.
- [ ] Write `README.md` at package root — overview, install, quick-start (provider + sign-in form), Modules section linking every co-located `.md`.
## 2. Types + schemas + errors
- [ ] `src/types/auth-user.ts`, `auth-state.ts`, `auth-backend.ts`, `session.ts`, `idle-config.ts`, `permission-resolver.ts` — each with sibling `.md` (≥ 200 words).
- [ ] `src/schemas/schemas.ts` — Zod schemas for every hook option bag (`SignInOptions`, `SignUpOptions`, `UsePasskeyAutofillOptions`, `UseOAuthSignInOptions`, …). Sibling `.md`, `schemas.test.ts`.
- [ ] `src/errors/errors.ts` — `defineCodes("REACT_AUTH", [...])` for the 29 codes in `design.md` § Error code namespace. Export `ReactAuthError`. Sibling `.md`, `errors.test.ts` covering: every code reachable from a production path; translation helpers preserve `cause`.
## 3. Utilities
- [ ] `src/utils/compose-signals.ts` (+ `.md`, `.test.ts`) — `AbortController` composition (any abort → composed abort).
- [ ] `src/utils/is-ssr.ts` (+ `.md`, `.test.ts`).
- [ ] `src/utils/announce.ts` (+ `.md`, `.test.ts`) — ARIA live-region helper (creates a single shared `aria-live="polite"` node on first call).
## 4. Session — storage, broadcast, idle, refresh, forced-logout
- [ ] `src/session/storage/memory/memory.ts` (+ `.md`, `.test.ts`).
- [ ] `src/session/storage/local/local.ts` (+ `.md`, `.test.ts`).
- [ ] `src/session/storage/session/session.ts` (+ `.md`, `.test.ts`).
- [ ] `src/session/broadcast-channel.ts` (+ `.md`, `.test.ts`) — typed envelope, factory injection, SSR no-op.
- [ ] `src/session/auto-refresh.ts` (+ `.md`, `.test.ts`) — schedule via injected `Clock`; cancels on every state update.
- [ ] `src/session/idle-timer.ts` (+ `.md`, `.test.ts`) — pointer/key/visibility listeners; debounced; `onWarn`/`onTimeout`.
- [ ] `src/session/forced-logout.ts` (+ `.md`, `.test.ts`) — broadcasts + clears storage synchronously.
## 5. Provider + context
- [ ] `src/auth-provider/auth-context.ts` (+ `.md`, `.test.ts`).
- [ ] `src/auth-provider/use-auth-context.ts` (+ `.md`, `.test.ts`) — throws `REACT_AUTH.CONTEXT_MISSING` outside provider.
- [ ] `src/auth-provider/load-persisted-session.ts` (+ `.md`, `.test.ts`).
- [ ] `src/auth-provider/auth-provider.tsx` (+ `.md`, `.test.tsx`, `.stories.tsx`) — wires backend + storage + broadcast + idle + auto-refresh; deterministic SSR render; effect-only DOM access.
## 6. Lifecycle hooks
- [ ] `src/hooks/use-auth/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-user/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-is-authenticated/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-session/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-sign-in/` (+ `.md`, `.test.ts`) — supports password, SRP, passkey result handoff.
- [ ] `src/hooks/use-sign-up/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-sign-out/` (+ `.md`, `.test.ts`) — broadcasts `signed-out`.
- [ ] `src/hooks/use-refresh-token/` (+ `.md`, `.test.ts`).
## 7. Password hooks + components
- [ ] `src/hooks/use-change-password/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-password-reset/` (+ `.md`, `.test.ts`) — `request` + `confirm`.
- [ ] `src/components/password-strength-meter/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — wraps `password-strength.assessReactive`; cancels on unmount.
- [ ] `src/components/hibp-pwned-indicator/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — debounced ≥ 500 ms; SSR no-op; injected `HibpClient`.
- [ ] `src/components/password-input/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — show/hide, label association, `aria-describedby`, optional strength + HIBP slots.
- [ ] `src/components/password-generator-button/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — popover via `@suaveplan/react-ui` `<Popover>`; passphrase/chars/pin modes.
## 8. Passkey hooks + components
- [ ] `src/hooks/use-register-passkey/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-authenticate-with-passkey/` (+ `.md`, `.test.ts`) — modal mediation.
- [ ] `src/hooks/use-passkey-autofill/` (+ `.md`, `.test.tsx`) — folded from superseded autofill change; SSR-safe; `isConditionalMediationAvailable` feature-detect; composed `AbortController`; stable `abort`.
- [ ] `src/components/passkey-autofill-field/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — `<input autocomplete="username webauthn">` wrapper; blur cancels when no credential delivered.
- [ ] `src/components/passkey-list/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — lists from `backend.listPasskeys`; per-row revoke with accessible confirm dialog.
## 9. OAuth
- [ ] `src/oauth/provider-registry.ts` (+ `.md`, `.test.ts`) — `register / get / has / list / unregister`; descriptor includes label key + icon slot + scopes.
- [ ] `src/oauth/popup.ts` (+ `.md`, `.test.ts`) — centred-window helper; `postMessage` origin check; popup-blocked detection.
- [ ] `src/oauth/redirect.ts` (+ `.md`, `.test.ts`) — `sessionStorage` return-state persist; `window.location.assign`.
- [ ] `src/oauth/handle-callback.ts` (+ `.md`, `.test.ts`) — parses query, validates state, exchanges code, dispatches result; idempotent on remount.
- [ ] `src/hooks/use-oauth-sign-in/` (+ `.md`, `.test.ts`) — popup + redirect modes; PKCE via `@suaveplan/oauth-client`.
- [ ] `src/components/oauth-provider-button/` (+ `.md`, `.test.tsx`, `.stories.tsx`).
## 10. MFA
- [ ] `src/hooks/use-enroll-totp/` (+ `.md`, `.test.ts`) — `begin` + `finish`.
- [ ] `src/hooks/use-verify-totp/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-enroll-webauthn-second-factor/` (+ `.md`, `.test.ts`) — wraps `register` with `authenticatorSelection.authenticatorAttachment`.
- [ ] `src/hooks/use-recovery-codes/` (+ `.md`, `.test.ts`) — `generate` + `verify`.
- [ ] `src/components/totp-enrollment-panel/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — orchestrates secret display → caller-provided QR slot → code entry → recovery-codes reveal.
- [ ] `src/components/recovery-codes-panel/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — copy / download / acknowledge before dismiss.
## 11. Verification flows
- [ ] `src/hooks/use-email-verification/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-phone-verification/` (+ `.md`, `.test.ts`).
- [ ] `src/components/email-verification-view/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — resend cooldown timer.
- [ ] `src/components/phone-verification-view/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — `inputMode="numeric"`, `autocomplete="one-time-code"`.
## 12. Guards
- [ ] `src/hooks/use-permission/` (+ `.md`, `.test.ts`).
- [ ] `src/hooks/use-auth-guard/` (+ `.md`, `.test.ts`).
- [ ] `src/components/auth-guard/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — `withAuth` HOC alongside component.
- [ ] `src/components/permission-gate/` (+ `.md`, `.test.tsx`, `.stories.tsx`).
## 13. Error boundary
- [ ] `src/components/auth-error-boundary/` (+ `.md`, `.test.tsx`, `.stories.tsx`) — catches `SuaveplanError` whose code starts `REACT_AUTH.`; renders i18n-keyed fallback; re-throws everything else.
## 14. i18n
- [ ] `src/i18n/en-gb/index.ts` (+ `.md`) — canonical catalogue with every `react-auth.*` key used in the package.
- [ ] `src/i18n/cy-gb/index.ts` (+ `.md`) — full Welsh translation.
- [ ] `src/i18n/zh-cn/index.ts` (+ `.md`) — full Simplified Chinese translation.
- [ ] `src/i18n/parity.test.ts` — asserts every locale has the same key set as `en-GB`; fails CI on drift.
- [ ] `src/i18n/no-hardcoded-strings.test.ts` — scans `components/**/*.tsx` for hardcoded user-visible strings; allowlist limited to test fixtures.
## 15. Index + subpath exports
- [ ] `src/index.ts` — explicit named re-exports (rule 6a); namespace re-exports for `errors` and `schemas`.
- [ ] `package.json` `exports` map — one entry per subpath per `proposal.md`.
- [ ] Type-only re-exports separated via `export { type X }`.
## 16. Playwright E2E
- [ ] `e2e/fixtures/app.tsx` — minimal test app wiring an in-memory `AuthBackend`.
- [ ] `e2e/password-sign-in.spec.ts`, `sign-up.spec.ts`, `password-reset.spec.ts`, `change-password.spec.ts`.
- [ ] `e2e/passkey-register.spec.ts`, `passkey-autofill.spec.ts`, `passkey-modal-authenticate.spec.ts` (virtual-authenticator via Playwright CDP).
- [ ] `e2e/oauth-popup.spec.ts`, `oauth-redirect.spec.ts` — stubbed authorization endpoint.
- [ ] `e2e/totp-enroll-verify.spec.ts`, `recovery-codes.spec.ts`.
- [ ] `e2e/email-verification.spec.ts`, `phone-verification.spec.ts`.
- [ ] `e2e/multi-tab-sync.spec.ts` — two browser contexts share the same backend; sign-out in tab A propagates to tab B.
- [ ] `e2e/idle-timeout.spec.ts` — idle config fires `onTimeout` after configured ms; user is forced out.
- [ ] `e2e/error-boundary.spec.ts` — triggers a `BACKEND_ERROR`; verifies the boundary fallback renders.
- [ ] `e2e/rtl-locale.spec.ts` — renders with an RTL locale (e.g. `ar`) via the parent app; verifies `dir="rtl"` propagates.
- [ ] `e2e/accessibility.spec.ts` — `@axe-core/playwright` sweep across every canonical page; zero serious / critical violations.
## 17. Spec reconciliation (CLAUDE.md §13 step 0)
- [ ] Open `specs/react-auth/spec.md`. Walk every `### Requirement` and `#### Scenario`. For each `**Verified by:**` path, confirm the test file exists and the test name matches verbatim.
- [ ] Confirm every error code in `defineCodes("REACT_AUTH", …)` is reached from a real production path (not just a registry entry).
## 18. Pre-publish gates
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/react-auth` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/react-auth` — 100/100/100/100.
- [ ] `bunx turbo build --filter=@suaveplan/react-auth` — green.
- [ ] Banned-pattern scan zero (`@deprecated|TODO|FIXME|ts-ignore|ts-expect-error|\.skip|\.only|console\.log`).
- [ ] Barrel-shape scan zero (`export *` only in `src/index.ts` namespace forms).
- [ ] Test-runner import scan zero (`from "vitest"` / `from "bun:test"` outside the exempt allowlist).
- [ ] Missing-`.md` scan zero.
- [ ] Bare-version scan zero.
- [ ] Subpath export count matches feature-module count.
- [ ] React version check: `find . -path "*/node_modules/react/package.json"` returns a single version.
## 19. Root-doc refresh
- [ ] `bun scripts/folder-reorg/generate-manifest.ts` (refresh `MANIFEST.md`).
- [ ] `bun scripts/generate-missing.ts` (refresh `MISSING.yml`).
- [ ] Edit `STATUS.md` — move `@suaveplan/react-auth` from `–` to ✅; add summary paragraph.
- [ ] Edit `SUMMARY.md` — add per-package paragraph (features, deps, decisions).
- [ ] `bunx markdownlint-cli2 --fix` — touched files.
## 20. Supersede existing change folders
- [ ] `mv openspec/changes/enterprise-react-auth openspec/changes/_superseded-narrow-deferrals-2026-05-17/enterprise-react-auth` — fold under the existing supersede bucket.
## 21. Fold spec delta + archive
- [ ] Fold `specs/react-auth/spec.md` into `openspec/specs/react-auth/spec.md` (canonical home).
- [ ] `mv openspec/changes/complete-react-auth openspec/archive/<yyyy-mm-dd>-complete-react-auth/`.
- [ ] `bun scripts/publish.ts --package @suaveplan/react-auth`.