{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"passkey-sign-in","type":"registry:ui","title":"PasskeySignIn","description":"Controlled sign-in surface \u2014 discoverable (usernameless) by default, username-first with conditional-UI autofill on request. A dismissed prompt reads as \"cancelled\", not as an error.","package":"fancy-passkeys-ui","dependencies":["@particle-academy\/react-fancy","@simplewebauthn\/browser"],"registryDependencies":[],"files":[{"path":"components\/fancy\/passkey-sign-in\/PasskeySignIn.tsx","content":"import { useCallback, useRef } from \"react\";\nimport { Button, Callout, Input, cn } from \"@particle-academy\/react-fancy\";\nimport { normalizeCeremonyError } from \".\/client\";\nimport { activityEvent } from \".\/activity\";\nimport type { PasskeyActivityEvent, PasskeyStateError } from \".\/types\";\n\n\/**\n * Where a sign-in surface is in the ceremony.\n *\n * `cancelled` is a first-class status and **not** an error: the human dismissed\n * the prompt, which is an ordinary thing to do. `checking` and `verifying` are\n * host-set \u2014 the component never runs the ceremony, so it cannot know when the\n * browser prompt closed or when the server finished verifying.\n *\/\nexport type PasskeySignInStatus =\n  | \"idle\"\n  | \"checking\"\n  | \"prompting\"\n  | \"verifying\"\n  | \"success\"\n  | \"error\"\n  | \"cancelled\"\n  | \"unsupported\";\n\nexport interface PasskeySignInState {\n  status: PasskeySignInStatus;\n  \/** Only meaningful in `mode=\"email\"`. Carried always so the state shape is stable. *\/\n  email: string;\n  error: PasskeyStateError | null;\n}\n\nexport type PasskeySignInLabelKey =\n  | \"button\"\n  | \"emailLabel\"\n  | \"emailPlaceholder\"\n  | \"unsupported\"\n  | \"cancelled\"\n  | \"prompting\"\n  | \"verifying\";\n\nexport interface PasskeySignInProps {\n  value: PasskeySignInState;\n  \/** Always called with the **full** next state \u2014 never a slice. *\/\n  onChange: (next: PasskeySignInState) => void;\n  \/**\n   * Run the ceremony. **Resolve means signed in; throw means it failed** \u2014 the\n   * component maps a thrown `NotAllowedError` to `status: \"cancelled\"` and\n   * anything else to `status: \"error\"`.\n   *\n   * The component deliberately does not call `navigator.credentials` itself.\n   * Keeping the ceremony in the host's hands is what makes this surface pure,\n   * testable without an authenticator, and impossible to complete from a bridge.\n   *\/\n  onAuthenticate: (input: { email?: string }) => Promise<void> | void;\n  \/**\n   * `\"discoverable\"` (default) shows only the button and takes no username \u2014\n   * the usernameless flow the plan optimises for, and the one with nothing to\n   * enumerate. `\"email\"` renders the username-first input.\n   *\/\n  mode?: \"discoverable\" | \"email\";\n  \/** Root handle. Default `\"passkey-sign-in\"`. *\/\n  surfaceId?: string;\n  labels?: Partial<Record<PasskeySignInLabelKey, string>>;\n  \/**\n   * Opt into conditional UI (autofill). In `mode=\"email\"` this switches the\n   * input to `autocomplete=\"username webauthn\"`, which is what makes the browser\n   * offer a passkey from the field itself.\n   *\/\n  conditional?: boolean;\n  onActivity?: (event: PasskeyActivityEvent) => void;\n  className?: string;\n}\n\nconst DEFAULT_LABELS: Record<PasskeySignInLabelKey, string> = {\n  button: \"Sign in with a passkey\",\n  emailLabel: \"Email\",\n  emailPlaceholder: \"you@example.com\",\n  unsupported: \"This browser can't use passkeys. Sign in another way, or try a different browser.\",\n  cancelled: \"Passkey sign-in was cancelled. You can try again whenever you like.\",\n  prompting: \"Waiting for your passkey\u2026\",\n  verifying: \"Verifying your passkey\u2026\",\n};\n\nconst BUSY: ReadonlySet<PasskeySignInStatus> = new Set([\"checking\", \"prompting\", \"verifying\"]);\n\n\/**\n * A controlled passkey sign-in surface.\n *\n * Everything an agent can meaningfully do here \u2014 read the status, read the\n * error, fill the email field, press the button \u2014 is state and has a handle.\n * The one thing it cannot do is finish the ceremony, because\n * `navigator.credentials.get()` wants a user gesture and a biometric. That is\n * not a gap in the bridge; it is the property that makes a passkey worth having.\n *\/\nexport function PasskeySignIn({\n  value,\n  onChange,\n  onAuthenticate,\n  mode = \"discoverable\",\n  surfaceId = \"passkey-sign-in\",\n  labels,\n  conditional,\n  onActivity,\n  className,\n}: PasskeySignInProps) {\n  \/\/ The latest rendered state, read at the moment an async handler resolves so a\n  \/\/ full-state emission is built from what the surface currently shows rather\n  \/\/ than from a closure captured before the await.\n  const valueRef = useRef(value);\n  valueRef.current = value;\n\n  const label = (key: PasskeySignInLabelKey): string => labels?.[key] ?? DEFAULT_LABELS[key];\n\n  const emit = useCallback(\n    (event: PasskeyActivityEvent) => {\n      onActivity?.(event);\n    },\n    [onActivity],\n  );\n\n  const authenticate = useCallback(async () => {\n    const current = valueRef.current;\n    if (current.status === \"unsupported\" || BUSY.has(current.status)) return;\n\n    const email = mode === \"email\" ? current.email : undefined;\n\n    emit(activityEvent(surfaceId, \"authenticate\", undefined, { mode, conditional: !!conditional }));\n    onChange({ ...current, status: \"prompting\", error: null });\n\n    try {\n      await onAuthenticate(email === undefined ? {} : { email });\n      onChange({ ...valueRef.current, status: \"success\", error: null });\n    } catch (err) {\n      const failure = normalizeCeremonyError(err);\n      if (failure.code === \"cancelled\") {\n        \/\/ Not a failure. Rendered as a muted hint, never as an alert.\n        onChange({ ...valueRef.current, status: \"cancelled\", error: null });\n        emit(activityEvent(surfaceId, \"cancelled\"));\n        return;\n      }\n      const error: PasskeyStateError = {\n        code: failure.serverCode ?? failure.code,\n        message: failure.message,\n      };\n      onChange({ ...valueRef.current, status: \"error\", error });\n      emit(activityEvent(surfaceId, \"error\", undefined, { code: error.code }));\n    }\n  }, [conditional, emit, mode, onAuthenticate, onChange, surfaceId]);\n\n  const unsupported = value.status === \"unsupported\";\n  const busy = BUSY.has(value.status);\n\n  const hint =\n    value.status === \"unsupported\"\n      ? { key: \"unsupported\" as const, text: label(\"unsupported\") }\n      : value.status === \"cancelled\"\n        ? { key: \"cancelled\" as const, text: label(\"cancelled\") }\n        : value.status === \"prompting\"\n          ? { key: \"prompting\" as const, text: label(\"prompting\") }\n          : value.status === \"verifying\"\n            ? { key: \"verifying\" as const, text: label(\"verifying\") }\n            : null;\n\n  const button = (\n    <Button\n      type=\"button\"\n      color=\"blue\"\n      icon=\"fingerprint\"\n      loading={busy}\n      disabled={unsupported || busy || value.status === \"success\"}\n      onClick={() => void authenticate()}\n      data-fancy-passkey-action=\"authenticate\"\n      data-fancy-passkey-surface-id={surfaceId}\n    >\n      {label(\"button\")}\n    <\/Button>\n  );\n\n  return (\n    <div\n      data-fancy-passkey-surface={surfaceId}\n      data-fancy-passkey-status={value.status}\n      data-fancy-passkey-mode={mode}\n      className={cn(\"fancy-passkey fancy-passkey-signin\", className)}\n    >\n      {mode === \"email\" ? (\n        <form\n          className=\"fancy-passkey-signin__form\"\n          onSubmit={(event) => {\n            event.preventDefault();\n            void authenticate();\n          }}\n        >\n          <Input\n            type=\"email\"\n            name=\"email\"\n            label={label(\"emailLabel\")}\n            placeholder={label(\"emailPlaceholder\")}\n            value={value.email}\n            disabled={unsupported || busy}\n            \/\/ The `webauthn` token is what lets the browser surface a passkey\n            \/\/ from inside the field. Without conditional UI it is a plain\n            \/\/ username field and claiming otherwise would be a lie to the\n            \/\/ autofill engine.\n            autoComplete={conditional ? \"username webauthn\" : \"username\"}\n            data-fancy-passkey-field=\"email\"\n            onChange={(event) => onChange({ ...valueRef.current, email: event.target.value })}\n          \/>\n          {button}\n        <\/form>\n      ) : (\n        button\n      )}\n\n      {\/* `Callout` already carries `role=\"alert\"`, so this wrapper carries only\n          the handle \u2014 nesting a second alert makes the region ambiguous to a\n          screen reader and to `getByRole`. *\/}\n      {value.status === \"error\" && value.error ? (\n        <div data-fancy-passkey-error={value.error.code} className=\"fancy-passkey__error\">\n          <Callout color=\"red\">{value.error.message}<\/Callout>\n        <\/div>\n      ) : null}\n\n      {hint ? (\n        <p data-fancy-passkey-hint={hint.key} className=\"fancy-passkey__hint\">\n          {hint.text}\n        <\/p>\n      ) : null}\n    <\/div>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/passkey-sign-in\/PasskeySignIn.tsx"},{"path":"components\/fancy\/passkey-sign-in\/client.ts","content":"\/**\n * `@particle-academy\/fancy-passkeys-ui\/client` \u2014 the browser half of both\n * WebAuthn ceremonies, with **zero React**.\n *\n * Every frontend needs this code and only some of them are React, so it lives\n * behind its own entry point: importing the React surfaces never costs you a\n * second copy of the ceremony, and importing the ceremony never costs a Vue or\n * Svelte app a React dependency. `packaging.test.ts` asserts the built\n * `dist\/client.js` contains no React import \u2014 if that test fails, something\n * moved into the wrong entry.\n *\n * What this module does NOT contain is anything that lets a caller *complete* a\n * ceremony without the human at the keyboard. `navigator.credentials.get()`\n * needs a user gesture and a biometric or PIN, and both of those are things only\n * a person has. That boundary is the whole reason a passkey is worth having, so\n * it is not softened here and will not grow an escape hatch.\n *\/\n\nimport {\n  startAuthentication,\n  startRegistration,\n  browserSupportsWebAuthnAutofill,\n  platformAuthenticatorIsAvailable,\n  WebAuthnAbortService,\n} from \"@simplewebauthn\/browser\";\nimport type {\n  AuthenticationResponseJSON,\n  PublicKeyCredentialCreationOptionsJSON,\n  PublicKeyCredentialRequestOptionsJSON,\n  RegistrationResponseJSON,\n} from \"@simplewebauthn\/browser\";\nimport type { PasskeyServerErrorCode, PasskeySummary } from \".\/types\";\n\nexport type {\n  PasskeySummary,\n  PasskeyServerErrorCode,\n  PasskeyStateError,\n  PasskeyActivityAction,\n  PasskeyActivityEvent,\n} from \".\/types\";\n\nexport type {\n  AuthenticationResponseJSON,\n  PublicKeyCredentialCreationOptionsJSON,\n  PublicKeyCredentialRequestOptionsJSON,\n  RegistrationResponseJSON,\n};\n\n\/\/ ---------------------------------------------------------------------------\n\/\/ Errors\n\/\/ ---------------------------------------------------------------------------\n\n\/**\n * How a ceremony ended, from the browser's point of view.\n *\n * `cancelled` is deliberately its own code and **is not a failure**. The browser\n * reports \"the human dismissed the prompt\" and \"the prompt timed out\" with the\n * same `NotAllowedError` it would use for a genuine refusal, and a sign-in\n * surface that paints abandonment red teaches people the surface is broken.\n *\/\nexport type PasskeyCeremonyErrorCode =\n  | \"cancelled\"\n  | \"not_supported\"\n  | \"already_registered\"\n  | \"security_error\"\n  | \"ceremony_failed\";\n\nexport interface PasskeyCeremonyErrorOptions {\n  \/** The original `DOMException` \/ `WebAuthnError` \/ transport rejection. *\/\n  cause?: unknown;\n  \/** The backend's own `PasskeyErrorCode`, when the failure came from a response body. *\/\n  serverCode?: string;\n  \/** HTTP status, when the failure came from a response. *\/\n  status?: number;\n}\n\n\/** Every rejection from this module is one of these. *\/\nexport class PasskeyCeremonyError extends Error {\n  readonly code: PasskeyCeremonyErrorCode;\n  \/**\n   * The backend's own error code, preserved verbatim rather than flattened\n   * into `ceremony_failed`.\n   *\n   * Note that the first-party backends deliberately redact\n   * `unknown_credential`, `user_handle_mismatch` and `counter_regressed` to\n   * `verification_failed` before they reach the wire \u2014 each answers a question\n   * about a credential the server holds, and an unauthenticated caller has no\n   * business asking. Do not branch on those three; they will never arrive.\n   *\/\n  readonly serverCode?: string;\n  readonly status?: number;\n  \/\/ Declared here rather than `override`n: the package targets ES2021, whose\n  \/\/ `Error` has no `cause` to override.\n  readonly cause?: unknown;\n\n  constructor(\n    code: PasskeyCeremonyErrorCode,\n    message: string,\n    options: PasskeyCeremonyErrorOptions = {},\n  ) {\n    super(message);\n    this.name = \"PasskeyCeremonyError\";\n    this.code = code;\n    if (options.serverCode !== undefined) this.serverCode = options.serverCode;\n    if (options.status !== undefined) this.status = options.status;\n    if (options.cause !== undefined) this.cause = options.cause;\n  }\n}\n\n\/**\n * A 4xx from either backend, carrying the wire contract's\n * `{ \"error\": { \"code\", \"message\" } }` payload.\n *\n * It extends {@link PasskeyCeremonyError} so `instanceof PasskeyCeremonyError`\n * still catches everything, while `serverCode` stays exact.\n *\/\nexport class PasskeyServerError extends PasskeyCeremonyError {\n  override readonly serverCode: string;\n\n  constructor(\n    serverCode: PasskeyServerErrorCode | string,\n    message: string,\n    options: { status?: number; cause?: unknown } = {},\n  ) {\n    super(ceremonyCodeForServerCode(serverCode), message, { ...options, serverCode });\n    this.name = \"PasskeyServerError\";\n    this.serverCode = serverCode;\n  }\n}\n\nfunction ceremonyCodeForServerCode(serverCode: string): PasskeyCeremonyErrorCode {\n  switch (serverCode) {\n    case \"credential_already_registered\":\n      return \"already_registered\";\n    case \"origin_not_allowed\":\n    case \"rp_id_mismatch\":\n    case \"user_handle_mismatch\":\n      return \"security_error\";\n    case \"not_supported\":\n      return \"not_supported\";\n    default:\n      return \"ceremony_failed\";\n  }\n}\n\nfunction nameOf(err: unknown): string | undefined {\n  if (typeof err !== \"object\" || err === null) return undefined;\n  const name = (err as { name?: unknown }).name;\n  return typeof name === \"string\" ? name : undefined;\n}\n\nfunction messageOf(err: unknown, fallback: string): string {\n  if (typeof err !== \"object\" || err === null) return fallback;\n  const message = (err as { message?: unknown }).message;\n  return typeof message === \"string\" && message.length > 0 ? message : fallback;\n}\n\n\/**\n * Turn anything a ceremony can throw into a typed {@link PasskeyCeremonyError}.\n *\n * The mapping that matters:\n *\n * | thrown | code |\n * |---|---|\n * | `NotAllowedError` | `cancelled` \u2014 the human dismissed it or it timed out |\n * | `AbortError` | `cancelled` \u2014 the host tore the ceremony down (see `signal`) |\n * | `InvalidStateError` | `already_registered` \u2014 this authenticator already holds a credential for this account |\n * | `SecurityError` | `security_error` \u2014 RP ID \/ origin does not match |\n * | `NotSupportedError` | `not_supported` |\n * | anything else | `ceremony_failed` |\n *\n * `@simplewebauthn\/browser` wraps the `DOMException` in a `WebAuthnError` whose\n * `name` defaults to the underlying exception's, so both shapes land in the same\n * rows; the `cause` chain is checked too for the cases where it does not.\n *\n * An error that is already a `PasskeyCeremonyError` (including a\n * {@link PasskeyServerError}) is returned untouched \u2014 that is what stops a\n * server's own code being flattened into `ceremony_failed` by a caller that\n * normalises defensively.\n *\/\nexport function normalizeCeremonyError(err: unknown): PasskeyCeremonyError {\n  if (err instanceof PasskeyCeremonyError) return err;\n\n  const names = [nameOf(err), nameOf((err as { cause?: unknown } | null)?.cause)];\n\n  for (const name of names) {\n    switch (name) {\n      case \"NotAllowedError\":\n        return new PasskeyCeremonyError(\n          \"cancelled\",\n          messageOf(err, \"The passkey prompt was dismissed or timed out.\"),\n          { cause: err },\n        );\n      case \"AbortError\":\n        return new PasskeyCeremonyError(\n          \"cancelled\",\n          messageOf(err, \"The passkey ceremony was aborted.\"),\n          { cause: err },\n        );\n      case \"InvalidStateError\":\n        return new PasskeyCeremonyError(\n          \"already_registered\",\n          messageOf(err, \"This device already holds a passkey for this account.\"),\n          { cause: err },\n        );\n      case \"SecurityError\":\n        return new PasskeyCeremonyError(\n          \"security_error\",\n          messageOf(err, \"The passkey could not be used on this origin.\"),\n          { cause: err },\n        );\n      case \"NotSupportedError\":\n        return new PasskeyCeremonyError(\n          \"not_supported\",\n          messageOf(err, \"This browser or authenticator does not support passkeys.\"),\n          { cause: err },\n        );\n      default:\n        break;\n    }\n  }\n\n  return new PasskeyCeremonyError(\"ceremony_failed\", messageOf(err, \"The passkey ceremony failed.\"), {\n    cause: err,\n  });\n}\n\n\/\/ ---------------------------------------------------------------------------\n\/\/ Feature detection\n\/\/ ---------------------------------------------------------------------------\n\n\/**\n * Whether this browser can do WebAuthn at all.\n *\n * Checked directly rather than delegated, so it is honest in an SSR pass, in a\n * test environment, and in an insecure context \u2014 all three of which are places a\n * surface renders before any authenticator exists.\n *\/\nexport function isPasskeySupported(): boolean {\n  if (typeof window === \"undefined\" || typeof navigator === \"undefined\") return false;\n  if (typeof (globalThis as Record<string, unknown>)[\"PublicKeyCredential\"] !== \"function\") {\n    return false;\n  }\n  const credentials = navigator.credentials as CredentialsContainer | undefined;\n  return typeof credentials?.create === \"function\" && typeof credentials.get === \"function\";\n}\n\n\/**\n * Whether a *platform* authenticator (Touch ID, Windows Hello, Android\n * biometrics) is present. `false` does not mean passkeys are unavailable \u2014 a\n * phone over hybrid, or a hardware key, still works.\n *\/\nexport async function isPlatformAuthenticatorAvailable(): Promise<boolean> {\n  if (!isPasskeySupported()) return false;\n  try {\n    return await platformAuthenticatorIsAvailable();\n  } catch {\n    return false;\n  }\n}\n\n\/**\n * Whether the browser can offer a passkey from inside a username field\n * (conditional UI \/ autofill). Only worth acting on when you render\n * `mode=\"email\"`.\n *\/\nexport async function isConditionalUiAvailable(): Promise<boolean> {\n  if (!isPasskeySupported()) return false;\n  try {\n    return await browserSupportsWebAuthnAutofill();\n  } catch {\n    return false;\n  }\n}\n\n\/\/ ---------------------------------------------------------------------------\n\/\/ Transport\n\/\/ ---------------------------------------------------------------------------\n\n\/**\n * How the ceremony reaches your backend.\n *\n * Deliberately one method. Both backends expose four POST endpoints and nothing\n * else, so an app that routes through Inertia, axios, a fetch wrapper with\n * retries, or its own auth headers implements this in four lines instead of\n * configuring ours.\n *\/\nexport interface PasskeyTransport {\n  \/**\n   * POST `path` (relative to whatever prefix the transport owns) and resolve\n   * with the decoded JSON body. **Reject on a non-2xx** \u2014 ideally with a\n   * {@link PasskeyServerError} so the backend's own error code survives.\n   *\/\n  post(path: string, body?: unknown): Promise<unknown>;\n}\n\nexport interface FetchTransportOptions {\n  \/**\n   * Route prefix both backends mount their four endpoints under.\n   * Default `\"\/passkeys\"`. A trailing slash is trimmed.\n   *\/\n  baseUrl?: string;\n  \/** Extra headers merged over the defaults. *\/\n  headers?: Record<string, string>;\n  \/** Default `\"same-origin\"` \u2014 a passkey session is a cookie session. *\/\n  credentials?: RequestCredentials;\n  \/**\n   * Laravel CSRF token, or a getter for one (useful when the token rotates).\n   *\n   * When omitted the transport reads the `XSRF-TOKEN` cookie, which is what a\n   * Laravel app already sets and what `axios` would have sent.\n   *\/\n  csrfToken?: string | (() => string | null);\n}\n\nfunction readCookie(name: string): string | null {\n  if (typeof document === \"undefined\") return null;\n  const prefix = `${name}=`;\n  for (const part of document.cookie.split(\";\")) {\n    const entry = part.trim();\n    if (entry.startsWith(prefix)) return decodeURIComponent(entry.slice(prefix.length));\n  }\n  return null;\n}\n\n\/**\n * The transport most apps want: `fetch`, JSON in and out, same-origin cookies,\n * and Laravel's CSRF header filled in.\n *\n * A non-2xx carrying the wire contract's `{ error: { code, message } }` becomes\n * a {@link PasskeyServerError} with that exact `code`.\n *\/\nexport function createFetchTransport(options: FetchTransportOptions = {}): PasskeyTransport {\n  \/\/ Scanned, not matched. `\/\\\/+$\/` anchors a greedy run at the end, so on a\n  \/\/ long run of slashes NOT followed by end-of-string the engine restarts the\n  \/\/ run at every position \u2014 quadratic (CodeQL js\/polynomial-redos, #1).\n  \/\/ Measured: 30k slashes took ~590ms through the regex and 0ms through this.\n  let baseUrl = options.baseUrl ?? \"\/passkeys\";\n  while (baseUrl.endsWith(\"\/\")) {\n    baseUrl = baseUrl.slice(0, -1);\n  }\n  const credentials = options.credentials ?? \"same-origin\";\n\n  return {\n    async post(path: string, body?: unknown): Promise<unknown> {\n      const headers: Record<string, string> = {\n        \"Content-Type\": \"application\/json\",\n        Accept: \"application\/json\",\n      };\n\n      const explicitToken =\n        typeof options.csrfToken === \"function\" ? options.csrfToken() : options.csrfToken;\n\n      if (explicitToken) {\n        \/\/ Laravel reads `X-CSRF-TOKEN` first and only falls back to\n        \/\/ `X-XSRF-TOKEN` (the encrypted cookie form) when it is absent, so\n        \/\/ sending both lets the same option carry either kind of token.\n        headers[\"X-CSRF-TOKEN\"] = explicitToken;\n        headers[\"X-XSRF-TOKEN\"] = explicitToken;\n      } else {\n        const cookieToken = readCookie(\"XSRF-TOKEN\");\n        if (cookieToken) headers[\"X-XSRF-TOKEN\"] = cookieToken;\n      }\n\n      Object.assign(headers, options.headers ?? {});\n\n      const response = await fetch(`${baseUrl}${path}`, {\n        method: \"POST\",\n        credentials,\n        headers,\n        body: body === undefined ? undefined : JSON.stringify(body),\n      });\n\n      const text = await response.text();\n      let payload: unknown;\n      if (text.length > 0) {\n        try {\n          payload = JSON.parse(text);\n        } catch {\n          payload = undefined;\n        }\n      }\n\n      if (!response.ok) {\n        const wire = (payload as { error?: { code?: unknown; message?: unknown } } | undefined)\n          ?.error;\n        if (wire && typeof wire.code === \"string\") {\n          throw new PasskeyServerError(\n            wire.code,\n            typeof wire.message === \"string\" ? wire.message : \"The passkey request was rejected.\",\n            { status: response.status },\n          );\n        }\n        throw new PasskeyCeremonyError(\n          \"ceremony_failed\",\n          `The passkey request failed (HTTP ${response.status}).`,\n          { status: response.status },\n        );\n      }\n\n      return payload;\n    },\n  };\n}\n\n\/\/ ---------------------------------------------------------------------------\n\/\/ Ceremonies\n\/\/ ---------------------------------------------------------------------------\n\n\/** `POST {prefix}\/register\/options` and `POST {prefix}\/login\/options` both return this. *\/\ninterface OptionsResponse<T> {\n  state: string;\n  publicKey: T;\n}\n\nexport interface RegisterPasskeyOptions {\n  \/** Label for the new credential, e.g. \"MacBook Touch ID\". *\/\n  name?: string;\n}\n\nexport interface RegisterPasskeyResult {\n  credential: PasskeySummary;\n}\n\n\/**\n * Enroll a new passkey for the **already authenticated** user.\n *\n * Two round trips, exactly as the wire contract specifies:\n *\n * 1. `POST {prefix}\/register\/options` \u2192 `{ state, publicKey }`\n * 2. `navigator.credentials.create()` via `startRegistration`\n * 3. `POST {prefix}\/register` with `{ state, name?, response }` \u2192 `{ credential }`\n *\n * The `state` handle is round-tripped untouched. It is an opaque pointer to the\n * server-side challenge record, which is single-use and pulled before\n * verification \u2014 so a replayed response fails at \"no such challenge\" whether or\n * not its signature is valid.\n *\/\nexport async function registerPasskey(\n  transport: PasskeyTransport,\n  options: RegisterPasskeyOptions = {},\n): Promise<RegisterPasskeyResult> {\n  try {\n    if (!isPasskeySupported()) {\n      throw new PasskeyCeremonyError(\n        \"not_supported\",\n        \"This browser does not support passkeys.\",\n      );\n    }\n\n    const begun = (await transport.post(\n      \"\/register\/options\",\n    )) as OptionsResponse<PublicKeyCredentialCreationOptionsJSON>;\n\n    const response: RegistrationResponseJSON = await startRegistration({\n      optionsJSON: begun.publicKey,\n    });\n\n    const body: { state: string; name?: string; response: RegistrationResponseJSON } = {\n      state: begun.state,\n      response,\n    };\n    if (options.name !== undefined) body.name = options.name;\n\n    return (await transport.post(\"\/register\", body)) as RegisterPasskeyResult;\n  } catch (err) {\n    throw normalizeCeremonyError(err);\n  }\n}\n\nexport interface AuthenticateWithPasskeyOptions {\n  \/**\n   * Username-first flow. Omit for the discoverable (usernameless) flow, which is\n   * the one v1 optimises for and the one that has nothing to enumerate.\n   *\/\n  email?: string;\n  \/**\n   * Start a conditional-UI (autofill) ceremony instead of a modal one. Pair it\n   * with an `autocomplete=\"username webauthn\"` input, and check\n   * {@link isConditionalUiAvailable} first.\n   *\/\n  conditional?: boolean;\n  \/**\n   * Tear the ceremony down \u2014 the case that actually matters is a long-lived\n   * conditional-UI request that outlives the route that started it.\n   *\n   * Aborting calls `WebAuthnAbortService.cancelCeremony()`, so the browser's own\n   * prompt closes rather than being merely ignored, and the promise settles as\n   * `cancelled` (never `ceremony_failed`).\n   *\/\n  signal?: AbortSignal;\n}\n\nexport interface AuthenticateWithPasskeyResult {\n  \/** Whatever the backend considers a user. Opaque here on purpose. *\/\n  user: unknown;\n  credential: PasskeySummary;\n}\n\n\/**\n * Sign in with a passkey.\n *\n * 1. `POST {prefix}\/login\/options` (body `{ email }` only when one was given) \u2192 `{ state, publicKey }`\n * 2. `navigator.credentials.get()` via `startAuthentication`\n * 3. `POST {prefix}\/login` with `{ state, response }` \u2192 `{ user, credential }`\n *\n * There is no variant of this function an agent can complete, and there is not\n * going to be one. Step 2 requires a user gesture and a biometric or PIN.\n *\/\nexport async function authenticateWithPasskey(\n  transport: PasskeyTransport,\n  options: AuthenticateWithPasskeyOptions = {},\n): Promise<AuthenticateWithPasskeyResult> {\n  const { signal } = options;\n\n  try {\n    if (!isPasskeySupported()) {\n      throw new PasskeyCeremonyError(\n        \"not_supported\",\n        \"This browser does not support passkeys.\",\n      );\n    }\n    throwIfAborted(signal);\n\n    const begun = (await transport.post(\n      \"\/login\/options\",\n      options.email === undefined ? undefined : { email: options.email },\n    )) as OptionsResponse<PublicKeyCredentialRequestOptionsJSON>;\n\n    throwIfAborted(signal);\n\n    const response = await withCeremonyAbort(signal, () =>\n      startAuthentication({\n        optionsJSON: begun.publicKey,\n        useBrowserAutofill: options.conditional === true,\n      }),\n    );\n\n    throwIfAborted(signal);\n\n    return (await transport.post(\"\/login\", {\n      state: begun.state,\n      response,\n    })) as AuthenticateWithPasskeyResult;\n  } catch (err) {\n    throw normalizeCeremonyError(err);\n  }\n}\n\nfunction throwIfAborted(signal: AbortSignal | undefined): void {\n  if (signal?.aborted) {\n    throw new PasskeyCeremonyError(\"cancelled\", \"The passkey ceremony was aborted.\");\n  }\n}\n\n\/**\n * Run a ceremony, wiring `signal` to the library's abort singleton so an abort\n * closes the browser prompt instead of leaving it up while we walk away.\n *\/\nasync function withCeremonyAbort<T>(\n  signal: AbortSignal | undefined,\n  run: () => Promise<T>,\n): Promise<T> {\n  if (!signal) return run();\n\n  const cancel = () => {\n    WebAuthnAbortService.cancelCeremony();\n  };\n  signal.addEventListener(\"abort\", cancel, { once: true });\n  try {\n    return await run();\n  } finally {\n    signal.removeEventListener(\"abort\", cancel);\n  }\n}\n","type":"registry:ui","target":"components\/fancy\/passkey-sign-in\/client.ts"},{"path":"components\/fancy\/passkey-sign-in\/activity.ts","content":"import type { PasskeyActivityAction, PasskeyActivityEvent } from \".\/types\";\n\n\/**\n * Build one `AgentActivity`-shaped event.\n *\n * Kept in one place so every surface stamps the same shape \u2014 a presence or undo\n * layer subscribing to these should never have to special-case which component\n * emitted.\n *\/\nexport function activityEvent(\n  surface: string,\n  action: PasskeyActivityAction,\n  target?: string,\n  detail?: Record<string, unknown>,\n): PasskeyActivityEvent {\n  const event: PasskeyActivityEvent = { surface, action, at: new Date().toISOString() };\n  if (target !== undefined) event.target = target;\n  if (detail !== undefined) event.detail = detail;\n  return event;\n}\n\n\/** `2026-07-31T09:12:00Z` \u2192 `2026-07-31`, without dragging in a locale. *\/\nexport function isoDate(value: string): string {\n  return value.length >= 10 ? value.slice(0, 10) : value;\n}\n","type":"registry:ui","target":"components\/fancy\/passkey-sign-in\/activity.ts"},{"path":"components\/fancy\/passkey-sign-in\/types.ts","content":"\/**\n * Shared, **type-only** vocabulary for both entry points.\n *\n * Nothing in this file emits runtime code, which is what lets the React-free\n * `.\/client` entry and the React entry share it without either one dragging the\n * other into a consumer's bundle.\n *\/\n\n\/**\n * One registered credential, exactly as both backends serialise it\n * (`PasskeySummaryJSON` in the wire contract).\n *\n * Every field is JSON-primitive on purpose: `createdAt` is an ISO string rather\n * than a `Date` so a whole surface state survives\n * `JSON.parse(JSON.stringify(state))` unchanged \u2014 the property an agent needs in\n * order to read a surface back out and write one in.\n *\/\nexport interface PasskeySummary {\n  \/** base64url credential ID. Globally unique, and the handle every agent-facing action is keyed by. *\/\n  id: string;\n  \/** Human-chosen label (\"MacBook Touch ID\"). `null` when the user never named it. *\/\n  name: string | null;\n  \/** ISO-8601. *\/\n  createdAt: string;\n  \/** ISO-8601, or `null` when the credential has never been used to sign in. *\/\n  lastUsedAt: string | null;\n  \/** Transport hints reported at registration (\"internal\", \"hybrid\", \"usb\", \u2026). *\/\n  transports: string[];\n  \/** True for a *synced* passkey (the BE\/BS flags). False means this device only. *\/\n  backedUp: boolean;\n  \/** Authenticator model identifier. Stored, never used for a trust decision in v1. *\/\n  aaguid: string;\n  \/**\n   * ISO-8601 when the signature counter regressed for this credential \u2014 i.e. the\n   * server saw evidence the private key may have been cloned. A security event,\n   * and the surfaces render it as one.\n   *\/\n  clonedAt: string | null;\n}\n\n\/**\n * The closed set of error codes both backends emit in\n * `{ \"error\": { \"code\", \"message\" } }`. Kept as a union *plus* `string` at the\n * call sites so a newer backend adding a code does not become a type error in an\n * older UI.\n *\/\nexport type PasskeyServerErrorCode =\n  | \"challenge_expired\"\n  | \"challenge_not_found\"\n  | \"challenge_type_mismatch\"\n  | \"origin_not_allowed\"\n  | \"rp_id_mismatch\"\n  | \"unknown_credential\"\n  | \"credential_already_registered\"\n  | \"counter_regressed\"\n  | \"user_verification_required\"\n  | \"user_handle_mismatch\"\n  | \"verification_failed\"\n  | \"invalid_response\"\n  | \"not_supported\";\n\n\/**\n * The error shape carried *in surface state* \u2014 a plain object, not an `Error`,\n * because state has to be serialisable.\n *\/\nexport interface PasskeyStateError {\n  \/** A `PasskeyServerErrorCode` when the server rejected, otherwise a `PasskeyCeremonyErrorCode`. *\/\n  code: string;\n  message: string;\n}\n\n\/** Every mutation a surface can report. *\/\nexport type PasskeyActivityAction =\n  | \"authenticate\"\n  | \"enroll\"\n  | \"rename\"\n  | \"revoke-proposed\"\n  | \"revoke-confirmed\"\n  | \"revoke-cancelled\"\n  | \"cancelled\"\n  | \"error\";\n\n\/**\n * `AgentActivity`-shaped event emitted on every mutation, so presence, undo and\n * coaching layers compose without this package knowing about them.\n *\/\nexport interface PasskeyActivityEvent {\n  \/** The `surfaceId` of the emitting surface. *\/\n  surface: string;\n  action: PasskeyActivityAction;\n  \/** Credential ID, where the action has one. *\/\n  target?: string;\n  \/** ISO-8601. *\/\n  at: string;\n  detail?: Record<string, unknown>;\n}\n","type":"registry:ui","target":"components\/fancy\/passkey-sign-in\/types.ts"}]}