Files
2026-05-27 23:29:53 +01:00

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. (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 <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.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 <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> 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"