# Capability Spec Delta: react-auth (add-react-auth-passkey-autofill)
**Package**: `@suaveplan/react-auth`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: usePasskeyAutofill hook dispatches conditional WebAuthn
The package SHALL export a `usePasskeyAutofill` React hook that, on the first client mount, feature-detects WebAuthn Conditional UI via `isConditionalMediationAvailable()` and dispatches `authenticate({ mediation: "conditional" })` via `@suaveplan/webauthn`. The hook SHALL deliver a successful `AuthenticateResult` via the caller's `onCredential` callback.
#### Scenario: conditional authenticate is invoked when supported
- **WHEN** the hook mounts in a browser whose `PublicKeyCredential.isConditionalMediationAvailable()` resolves `true`
- **THEN** the hook calls `authenticate` with `mediation: "conditional"` and the caller-supplied `rpId`, `challenge`, `allowCredentials`, `userVerification`, `timeout`, `expectedOrigin`, and `expectedRpIdHash`
- **AND** the returned `ready` flag flips to `true` once the request is in-flight
#### Scenario: successful credential is delivered to onCredential
- **WHEN** the underlying `authenticate` Promise resolves with an `AuthenticateResult`
- **THEN** `onCredential` is invoked once with that result
- **AND** the hook's `error` state remains `null`
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"dispatches authenticate with mediation conditional when available"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"delivers AuthenticateResult to onCredential on success"`
---
### Requirement: Hook is SSR-safe and feature-detects before dispatch
The hook SHALL perform zero work during server rendering and SHALL never call `navigator.credentials.get` without first confirming `PublicKeyCredential.isConditionalMediationAvailable()` resolves `true`. When Conditional UI is unavailable, the hook SHALL stay `ready === false` and report `REACT_AUTH.AUTOFILL_UNAVAILABLE` via the caller's `onError` (when supplied).
#### Scenario: server render performs no DOM access
- **WHEN** the hook is rendered in an environment where `typeof window === "undefined"`
- **THEN** the hook returns `{ ready: false, error: null, abort }` without touching `navigator`, `document`, or `PublicKeyCredential`
#### Scenario: missing Conditional UI surfaces AUTOFILL_UNAVAILABLE
- **WHEN** the hook mounts in a browser where `PublicKeyCredential.isConditionalMediationAvailable` is undefined OR resolves `false`
- **THEN** the hook never calls `authenticate`
- **AND** `ready` remains `false`
- **AND** `onError` (if supplied) is invoked with a `SuaveplanError` whose `code === "REACT_AUTH.AUTOFILL_UNAVAILABLE"`
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"performs no work during SSR render"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"reports AUTOFILL_UNAVAILABLE when isConditionalMediationAvailable is missing"`
---
### Requirement: AbortController is owned by the hook and aborts on unmount, signal, and abort()
The hook SHALL own exactly one internal `AbortController` per effect run, composed with the caller's `options.signal` if supplied. The composed signal SHALL be forwarded into the underlying `authenticate` call. The hook SHALL abort the in-flight request when the component unmounts, when the caller's `signal` aborts, or when the consumer invokes the returned `abort()` callback.
#### Scenario: unmount aborts the in-flight authenticate call
- **WHEN** the hook is mounted with an in-flight `authenticate` request and the component unmounts
- **THEN** the underlying `navigator.credentials.get` rejects with `AbortError`
- **AND** the hook's `error` (read before unmount completes) carries a `SuaveplanError` whose `code === "REACT_AUTH.ABORTED"` with the `WEBAUTHN.ABORTED` cause preserved
#### Scenario: caller-supplied AbortSignal aborts the request
- **WHEN** the caller passes `options.signal` and that controller aborts after mount
- **THEN** the in-flight `authenticate` rejects with `AbortError`
- **AND** the hook reports `REACT_AUTH.ABORTED` via `onError` if supplied
#### Scenario: returned abort() cancels without unmount
- **WHEN** the consumer calls the returned `abort()` while a request is in-flight
- **THEN** the request is cancelled
- **AND** the component remains mounted
- **AND** `ready` returns to `false`
#### Scenario: option-identity change cancels old request and dispatches new
- **WHEN** `options.challenge` reference changes between renders
- **THEN** the old controller aborts and a new `authenticate` is dispatched against the new challenge
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"aborts the in-flight request on unmount"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"propagates caller AbortSignal to authenticate"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort cancels without unmounting"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"re-dispatches when challenge identity changes"`
---
### Requirement: WebAuthn errors are translated into the REACT_AUTH code registry
The package SHALL export a `defineCodes("REACT_AUTH", ...)` registry covering `AUTOFILL_UNAVAILABLE`, `AUTOFILL_FAILED`, `ABORTED`, `SSR_NO_OP`, and `INVALID_OPTIONS`. Every error surfaced by the hook SHALL be a `SuaveplanError` whose `code` is one of these values; the underlying `WEBAUTHN.*` error SHALL be preserved on `cause` for introspection.
#### Scenario: WEBAUTHN.NOT_SUPPORTED maps to REACT_AUTH.AUTOFILL_UNAVAILABLE
- **WHEN** the underlying `authenticate` rejects with `WEBAUTHN.NOT_SUPPORTED`
- **THEN** the hook surfaces a `SuaveplanError` with `code === "REACT_AUTH.AUTOFILL_UNAVAILABLE"` and `cause.code === "WEBAUTHN.NOT_SUPPORTED"`
#### Scenario: WEBAUTHN.ABORTED maps to REACT_AUTH.ABORTED
- **WHEN** the underlying request rejects with `WEBAUTHN.ABORTED`
- **THEN** the surfaced error has `code === "REACT_AUTH.ABORTED"` with the same cause preserved
#### Scenario: any other WEBAUTHN.* maps to REACT_AUTH.AUTOFILL_FAILED
- **WHEN** the underlying request rejects with `WEBAUTHN.SIGNATURE_INVALID` (or any other non-cancel, non-not-supported `WEBAUTHN.*` code)
- **THEN** the surfaced error has `code === "REACT_AUTH.AUTOFILL_FAILED"` with the underlying cause preserved
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates WEBAUTHN.NOT_SUPPORTED to REACT_AUTH.AUTOFILL_UNAVAILABLE"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates WEBAUTHN.ABORTED to REACT_AUTH.ABORTED"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates other WEBAUTHN errors to REACT_AUTH.AUTOFILL_FAILED"` AND `src/errors/errors.test.ts::"every REACT_AUTH code is reachable from a production path"`
---
### Requirement: Hook options are validated by Zod at entry
The hook SHALL validate `UsePasskeyAutofillOptions` against a Zod schema at the start of every effect run. Schema mismatches SHALL throw `REACT_AUTH.INVALID_OPTIONS` synchronously so that React's nearest error boundary catches the failure at mount time rather than silently ignoring it inside an effect.
#### Scenario: invalid rpId throws INVALID_OPTIONS
- **WHEN** the caller passes `rpId: ""` (empty string) or omits a required field
- **THEN** the hook throws a `SuaveplanError` with `code === "REACT_AUTH.INVALID_OPTIONS"` during render
- **AND** no `authenticate` call is dispatched
#### Scenario: invalid challenge type throws INVALID_OPTIONS
- **WHEN** `challenge` is not a `Uint8Array`
- **THEN** the same error is thrown synchronously
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS when rpId is empty"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS when challenge is not Uint8Array"`
---
### Requirement: Hook does not touch the DOM directly
The hook SHALL NOT call `document.querySelector`, attach event listeners to inputs, or render any DOM. The autofill UI is surfaced by the browser based on an `` element rendered by the consumer; the hook only keeps the conditional `authenticate` request pending so the browser has something to deliver into.
#### Scenario: hook makes no document or input access
- **WHEN** the hook is mounted with a spy installed on `document.querySelector` and `HTMLInputElement.prototype.addEventListener`
- **THEN** neither spy is invoked during the hook's lifecycle
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"does not touch document or input elements"`
---
### Requirement: Returned abort callback has a stable reference
The hook SHALL return an `abort` callback whose reference identity is stable across renders (i.e. wrapped with `useCallback` against a controller ref), so consumers can place it in `useEffect` dependency arrays or memoization keys without triggering re-runs.
#### Scenario: abort reference is identical across re-renders
- **WHEN** the parent component re-renders while the hook's options are unchanged
- **THEN** `Object.is(prevReturn.abort, nextReturn.abort) === true`
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort reference is stable across re-renders"`
---
### Requirement: PasskeyAutofillField component wraps the hook with an input
The package SHALL export an optional `` component that renders an `` element, wires it to `usePasskeyAutofill`, and (by default) calls the hook's `abort()` when the input fires `blur` AND no credential has been delivered yet. Consumers SHALL be able to forward arbitrary `inputProps` to style and label the input.
#### Scenario: input renders with the correct autocomplete token
- **WHEN** `` is rendered
- **THEN** the rendered `` has `autocomplete="username webauthn"`
#### Scenario: blur cancels the request when no credential delivered
- **WHEN** the user blurs the input before any credential is delivered
- **THEN** the hook's `abort()` is invoked and the in-flight request rejects with `REACT_AUTH.ABORTED`
#### Scenario: blur after credential delivery does not abort
- **WHEN** `onCredential` has already fired
- **THEN** a subsequent blur does NOT invoke `abort()`
**Verified by:** `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"renders input with autocomplete username webauthn"` AND `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur cancels in-flight request"` AND `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur after credential delivery is a no-op"`
---
### Requirement: Subpath exports cover hook, component, and errors
The package SHALL expose subpath exports at `.`, `./use-passkey-autofill/use-passkey-autofill`, `./passkey-autofill-field/passkey-autofill-field`, and `./errors`, each tree-shakeable, so consumers can import the hook, the component, or the error registry independently.
#### Scenario: every documented subpath resolves
- **WHEN** a consumer imports each documented subpath
- **THEN** every import resolves to the declared symbols
**Verified by:** `src/index.test.ts::"every documented subpath resolves"`