# 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"`