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

130 lines
7.6 KiB
Markdown

# Design — `usePasskeyAutofill` (`@suaveplan/react-auth`)
Non-normative. Architecture, lifecycle, and rationale. The normative contract lives in `specs/react-auth/spec.md`.
## Package shape
`@suaveplan/react-auth` is created by this change. It is a lightweight browser-tier package at `packages/auth/react-auth/`. Tier is `browser` (React 19 peer dep, DOM types fine). It holds exactly this hook and the optional `<PasskeyAutofillField>` wrapper today; future auth-related React hooks land in follow-up changes under the same package.
The hook lives at `src/use-passkey-autofill/use-passkey-autofill.ts`; the component at `src/passkey-autofill-field/passkey-autofill-field.tsx`; errors at `src/errors/errors.ts`. Each has a sibling `.md`, a `.test.ts` (or `.test.tsx`), and stories per CLAUDE.md.
## Conditional UI background
WebAuthn Level 3 §5.1.4 defines `mediation: "conditional"`. Browsers that support it surface available platform credentials in the autocomplete sheet of any `<input>` with `autocomplete="username webauthn"`. The feature is gated on `PublicKeyCredential.isConditionalMediationAvailable()` — a static method that does not exist on Safari < 17, Firefox < 119, or any SSR environment. Calling `navigator.credentials.get({ publicKey, mediation: "conditional" })` without that check throws `NotSupportedError` on older browsers, which `@suaveplan/webauthn` maps to `WEBAUTHN.NOT_SUPPORTED`.
The hook MUST therefore feature-detect before dispatching. Because feature detection is async and DOM-only, it can only run inside a `useEffect`.
## Hook signature
```ts
interface UsePasskeyAutofillOptions {
rpId: string;
challenge: Uint8Array;
onCredential: (result: AuthenticateResult) => void;
onError?: (error: SuaveplanError) => void;
signal?: AbortSignal;
allowCredentials?: PublicKeyCredentialDescriptor[];
userVerification?: UserVerificationRequirement;
timeout?: number;
expectedOrigin?: string;
expectedRpIdHash?: Uint8Array;
}
interface UsePasskeyAutofillReturn {
ready: boolean;
error: SuaveplanError | null;
abort: () => void;
}
function usePasskeyAutofill(opts: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn;
```
`ready` flips to `true` after `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` Promise is in-flight. `error` carries the most recent translated error (cleared when a new attempt starts). `abort` is a stable `useCallback` that fires the internal `AbortController`.
## Lifecycle — AbortController ownership
The hook owns exactly one `AbortController` per "session" (one mount, one option-identity). The controller is composed with the caller's `options.signal` via a small `composeSignals(...signals)` helper (any aborts → composed aborts).
| Event | Behavior |
|---|---|
| Mount (client) | Run feature detect → dispatch `authenticate({ mediation: "conditional", signal: composed })`. |
| Mount (server) | No-op. The `useEffect` never runs. |
| Unmount | Call `controller.abort()`. The underlying `navigator.credentials.get` rejects with `AbortError`, mapped to `WEBAUTHN.ABORTED` → `REACT_AUTH.ABORTED`. |
| Caller `signal` aborts | Composed controller aborts → same as unmount path. |
| Caller calls returned `abort()` | Same as above. State remains mounted; consumer may re-trigger by changing option identity. |
| `<input>` blur (via `<PasskeyAutofillField>`) | Consumer-controlled. The component calls `abort()` on `onBlur` IF no credential has been delivered yet. The hook alone does not subscribe to blur (it does not touch the DOM). |
| Option identity changes (e.g. fresh `challenge`) | Effect re-runs: old controller aborts, new controller created, new request dispatched. |
| `onCredential` fires | Controller is left intact (the request is already settled); `error` cleared. |
| `onError` fires | Controller aborted; `error` stored; consumer may remount or change challenge to retry. |
The hook MUST NOT touch the DOM directly — it works alongside an `<input autocomplete="username webauthn">` rendered by the consumer. The browser surfaces the autofill UI based on the autocomplete token; the hook just keeps the conditional `authenticate` request pending so the browser has something to deliver into.
## SSR safety
- `typeof window === "undefined"` short-circuits before any `navigator` access.
- `isConditionalMediationAvailable()` is called inside `useEffect` (which never runs server-side under React 19 SSR).
- The hook returns `{ ready: false, error: null, abort: noop }` on the server render, then re-renders on hydration.
## Error translation
```text
WEBAUTHN.NOT_SUPPORTED → REACT_AUTH.AUTOFILL_UNAVAILABLE
WEBAUTHN.ABORTED → REACT_AUTH.ABORTED
all other WEBAUTHN.* → REACT_AUTH.AUTOFILL_FAILED (cause-chained)
Zod schema rejection at entry → REACT_AUTH.INVALID_OPTIONS (thrown synchronously)
SSR / non-DOM environment → REACT_AUTH.SSR_NO_OP (surfaced via onError only)
```
Every translation preserves the original `SuaveplanError` via `cause`, so consumers can introspect `error.cause?.code` for the underlying `WEBAUTHN.*` code.
## Testing approach
- Real `navigator.credentials.get` stub at the jsdom layer — install a fake `PublicKeyCredential` with `isConditionalMediationAvailable` and a `navigator.credentials.get` that resolves a fixture `AuthenticateResult` or rejects with a chosen `DOMException`. Tests live in `src/use-passkey-autofill/use-passkey-autofill.test.tsx` using `@suaveplan/testing-dom` render utilities and `@suaveplan/testing/runner` for the runner-neutral imports (CLAUDE.md rule 11).
- For environments lacking `PublicKeyCredential` entirely (deliberately stripped), assert `REACT_AUTH.AUTOFILL_UNAVAILABLE` is reported via `onError` and `ready` stays `false`.
- Lifecycle is verified by mounting, asserting an in-flight request, unmounting, and asserting the request rejected with `REACT_AUTH.ABORTED`.
- 100% coverage per CLAUDE.md / openspec rule 7 — no carve-outs.
## File layout
```text
packages/auth/react-auth/
README.md
package.json
tsconfig.json
vitest.config.ts
src/
index.ts # explicit named re-exports (rule 6a)
use-passkey-autofill/
index.ts
use-passkey-autofill.ts
use-passkey-autofill.md
use-passkey-autofill.test.tsx
use-passkey-autofill.stories.tsx
use-passkey-autofill.types.ts
use-passkey-autofill.schemas.ts
passkey-autofill-field/
index.ts
passkey-autofill-field.tsx
passkey-autofill-field.md
passkey-autofill-field.test.tsx
passkey-autofill-field.stories.tsx
passkey-autofill-field.types.ts
errors/
index.ts
errors.ts
errors.md
errors.test.ts
utils/
compose-signals.ts
compose-signals.md
compose-signals.test.ts
```
## Decisions
- **Hook-first, component-optional.** The `<PasskeyAutofillField>` wrapper exists for the common case but is never a requirement; advanced consumers wire the hook to their own input.
- **No global state, no context provider.** Multiple `usePasskeyAutofill` instances may coexist (e.g. a multi-tenant login page); each owns its own controller.
- **Errors are translated, not leaked.** Consumers see `REACT_AUTH.*` codes. The underlying `WEBAUTHN.*` cause is preserved on `error.cause` for introspection.
- **`onCredential` is required, `onError` is optional.** A consumer that does not pass `onError` accepts that errors silently no-op (with `error` still populated on the return value).
- **Stable `abort` reference.** Returned via `useCallback` so consumers can put it in dependency arrays without thrashing.