11 KiB
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. (Seeopenspec/AGENTS.mdSection 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()resolvestrue - THEN the hook calls
authenticatewithmediation: "conditional"and the caller-suppliedrpId,challenge,allowCredentials,userVerification,timeout,expectedOrigin, andexpectedRpIdHash - AND the returned
readyflag flips totrueonce the request is in-flight
Scenario: successful credential is delivered to onCredential
- WHEN the underlying
authenticatePromise resolves with anAuthenticateResult - THEN
onCredentialis invoked once with that result - AND the hook's
errorstate remainsnull
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 touchingnavigator,document, orPublicKeyCredential
Scenario: missing Conditional UI surfaces AUTOFILL_UNAVAILABLE
- WHEN the hook mounts in a browser where
PublicKeyCredential.isConditionalMediationAvailableis undefined OR resolvesfalse - THEN the hook never calls
authenticate - AND
readyremainsfalse - AND
onError(if supplied) is invoked with aSuaveplanErrorwhosecode === "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
authenticaterequest and the component unmounts - THEN the underlying
navigator.credentials.getrejects withAbortError - AND the hook's
error(read before unmount completes) carries aSuaveplanErrorwhosecode === "REACT_AUTH.ABORTED"with theWEBAUTHN.ABORTEDcause preserved
Scenario: caller-supplied AbortSignal aborts the request
- WHEN the caller passes
options.signaland that controller aborts after mount - THEN the in-flight
authenticaterejects withAbortError - AND the hook reports
REACT_AUTH.ABORTEDviaonErrorif 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
readyreturns tofalse
Scenario: option-identity change cancels old request and dispatches new
- WHEN
options.challengereference changes between renders - THEN the old controller aborts and a new
authenticateis 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
authenticaterejects withWEBAUTHN.NOT_SUPPORTED - THEN the hook surfaces a
SuaveplanErrorwithcode === "REACT_AUTH.AUTOFILL_UNAVAILABLE"andcause.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-supportedWEBAUTHN.*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
SuaveplanErrorwithcode === "REACT_AUTH.INVALID_OPTIONS"during render - AND no
authenticatecall is dispatched
Scenario: invalid challenge type throws INVALID_OPTIONS
- WHEN
challengeis not aUint8Array - 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 <input autocomplete="username webauthn"> 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.querySelectorandHTMLInputElement.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 <PasskeyAutofillField> component that renders an <input autocomplete="username webauthn"> 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
<PasskeyAutofillField rpId="example.test" challenge={c} onCredential={...} />is rendered - THEN the rendered
<input>hasautocomplete="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 withREACT_AUTH.ABORTED
Scenario: blur after credential delivery does not abort
- WHEN
onCredentialhas 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"