{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"passkey-manager","type":"registry:ui","title":"PasskeyManager","description":"Controlled list of a user's passkeys with rename and revoke. Rows are handled by credential ID, revoke stages for human confirmation, and the last-passkey lockout is spelled out rather than implied.","package":"fancy-passkeys-ui","dependencies":["@particle-academy\/react-fancy","@simplewebauthn\/browser"],"registryDependencies":[],"files":[{"path":"components\/fancy\/passkey-manager\/PasskeyManager.tsx","content":"import { useCallback, useRef } from \"react\";\nimport { Badge, Button, Callout, Input, cn } from \"@particle-academy\/react-fancy\";\nimport { normalizeCeremonyError } from \".\/client\";\nimport { activityEvent, isoDate } from \".\/activity\";\nimport type { PasskeyActivityEvent, PasskeyStateError, PasskeySummary } from \".\/types\";\n\nexport interface PasskeyPendingRevoke {\n  \/** Credential ID staged for revocation. *\/\n  id: string;\n  \/**\n   * True when this is the account's only remaining passkey \u2014 i.e. confirming\n   * locks the user out of passkey sign-in entirely. Computed by the component\n   * and rendered in the confirmation copy, because \"are you sure?\" has not\n   * actually warned anyone.\n   *\/\n  isLastPasskey: boolean;\n}\n\nexport interface PasskeyManagerState {\n  passkeys: PasskeySummary[];\n  pendingRevoke: PasskeyPendingRevoke | null;\n  renamingId: string | null;\n  draftName: string;\n  status: \"idle\" | \"busy\" | \"error\";\n  error: PasskeyStateError | null;\n}\n\nexport interface PasskeyManagerProps {\n  value: PasskeyManagerState;\n  \/** Always called with the **full** next state \u2014 never a slice. *\/\n  onChange: (next: PasskeyManagerState) => void;\n  onRename?: (input: { id: string; name: string }) => Promise<void> | void;\n  onRevoke?: (input: { id: string }) => Promise<void> | void;\n  \/**\n   * Start enrolling a new passkey. The ceremony itself belongs to the host (and\n   * to the human at the keyboard) \u2014 this is only the intent.\n   *\/\n  onEnroll?: () => Promise<void> | void;\n  \/**\n   * Stage revokes for human confirmation instead of performing them.\n   * **Defaults to `true`**: revoking is destructive, revoking the last passkey\n   * is a lockout, and this is exactly the action the trust-but-verify hook\n   * exists for. Set `false` only when something else already confirmed.\n   *\/\n  pendingMode?: boolean;\n  \/** Root handle. Default `\"passkey-manager\"`. *\/\n  surfaceId?: string;\n  onActivity?: (event: PasskeyActivityEvent) => void;\n  emptyLabel?: string;\n  className?: string;\n}\n\n\/**\n * A controlled list of a user's passkeys, with rename and revoke.\n *\n * Every row is handled by **credential ID** (`data-fancy-passkey-item`), never\n * by list index. An index-keyed handle points at a different credential after a\n * sort or a revoke, so \"revoke the one the agent named\" silently revokes\n * somebody else's laptop \u2014 and nothing reports it.\n *\/\nexport function PasskeyManager({\n  value,\n  onChange,\n  onRename,\n  onRevoke,\n  onEnroll,\n  pendingMode = true,\n  surfaceId = \"passkey-manager\",\n  onActivity,\n  emptyLabel = \"No passkeys yet.\",\n  className,\n}: PasskeyManagerProps) {\n  \/\/ See PasskeySignIn: read the latest rendered state when an async handler\n  \/\/ resolves, so the full-state emission reflects what the surface now shows.\n  const valueRef = useRef(value);\n  valueRef.current = value;\n\n  const emit = useCallback(\n    (event: PasskeyActivityEvent) => {\n      onActivity?.(event);\n    },\n    [onActivity],\n  );\n\n  const fail = useCallback(\n    (err: unknown, target?: string) => {\n      const failure = normalizeCeremonyError(err);\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\", target, { code: error.code }));\n    },\n    [emit, onChange, surfaceId],\n  );\n\n  const startRename = useCallback(\n    (passkey: PasskeySummary) => {\n      onChange({\n        ...valueRef.current,\n        renamingId: passkey.id,\n        draftName: passkey.name ?? \"\",\n        error: null,\n      });\n    },\n    [onChange],\n  );\n\n  const cancelRename = useCallback(() => {\n    onChange({ ...valueRef.current, renamingId: null, draftName: \"\" });\n  }, [onChange]);\n\n  const commitRename = useCallback(\n    async (id: string) => {\n      const name = valueRef.current.draftName.trim();\n      emit(activityEvent(surfaceId, \"rename\", id, { name }));\n      onChange({ ...valueRef.current, status: \"busy\", error: null });\n      try {\n        await onRename?.({ id, name });\n        const current = valueRef.current;\n        onChange({\n          ...current,\n          passkeys: current.passkeys.map((passkey) =>\n            passkey.id === id ? { ...passkey, name: name.length > 0 ? name : null } : passkey,\n          ),\n          renamingId: null,\n          draftName: \"\",\n          status: \"idle\",\n          error: null,\n        });\n      } catch (err) {\n        fail(err, id);\n      }\n    },\n    [emit, fail, onChange, onRename, surfaceId],\n  );\n\n  const performRevoke = useCallback(\n    async (id: string) => {\n      onChange({ ...valueRef.current, status: \"busy\", error: null });\n      try {\n        await onRevoke?.({ id });\n        const current = valueRef.current;\n        onChange({\n          ...current,\n          passkeys: current.passkeys.filter((passkey) => passkey.id !== id),\n          pendingRevoke: null,\n          renamingId: current.renamingId === id ? null : current.renamingId,\n          status: \"idle\",\n          error: null,\n        });\n      } catch (err) {\n        fail(err, id);\n      }\n    },\n    [fail, onChange, onRevoke],\n  );\n\n  const proposeRevoke = useCallback(\n    (id: string) => {\n      const current = valueRef.current;\n      const isLastPasskey = current.passkeys.length <= 1;\n\n      if (!pendingMode) {\n        emit(activityEvent(surfaceId, \"revoke-confirmed\", id, { isLastPasskey, staged: false }));\n        void performRevoke(id);\n        return;\n      }\n\n      emit(activityEvent(surfaceId, \"revoke-proposed\", id, { isLastPasskey }));\n      onChange({ ...current, pendingRevoke: { id, isLastPasskey }, error: null });\n    },\n    [emit, onChange, pendingMode, performRevoke, surfaceId],\n  );\n\n  const confirmRevoke = useCallback(\n    (id: string) => {\n      const isLastPasskey = valueRef.current.pendingRevoke?.isLastPasskey ?? false;\n      emit(activityEvent(surfaceId, \"revoke-confirmed\", id, { isLastPasskey, staged: true }));\n      void performRevoke(id);\n    },\n    [emit, performRevoke, surfaceId],\n  );\n\n  const cancelRevoke = useCallback(\n    (id: string) => {\n      emit(activityEvent(surfaceId, \"revoke-cancelled\", id));\n      onChange({ ...valueRef.current, pendingRevoke: null });\n    },\n    [emit, onChange, surfaceId],\n  );\n\n  const enroll = useCallback(async () => {\n    emit(activityEvent(surfaceId, \"enroll\"));\n    onChange({ ...valueRef.current, status: \"busy\", error: null });\n    try {\n      await onEnroll?.();\n      onChange({ ...valueRef.current, status: \"idle\", error: null });\n    } catch (err) {\n      fail(err);\n    }\n  }, [emit, fail, onChange, onEnroll, surfaceId]);\n\n  const busy = value.status === \"busy\";\n\n  return (\n    <div\n      data-fancy-passkey-surface={surfaceId}\n      data-fancy-passkey-status={value.status}\n      className={cn(\"fancy-passkey fancy-passkey-manager\", className)}\n    >\n      {\/* `Callout` already carries `role=\"alert\"`; these wrappers carry only the\n          handle, so a surface never nests two alerts. *\/}\n      {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      {value.passkeys.length === 0 ? (\n        <p data-fancy-passkey-empty=\"\" className=\"fancy-passkey__hint\">\n          {emptyLabel}\n        <\/p>\n      ) : (\n        <ul className=\"fancy-passkey-manager__list\">\n          {value.passkeys.map((passkey) => {\n            const renaming = value.renamingId === passkey.id;\n            const pending = value.pendingRevoke?.id === passkey.id ? value.pendingRevoke : null;\n\n            return (\n              <li\n                key={passkey.id}\n                data-fancy-passkey-item={passkey.id}\n                className=\"fancy-passkey-manager__row\"\n              >\n                <div className=\"fancy-passkey-manager__main\">\n                  {renaming ? (\n                    <Input\n                      type=\"text\"\n                      value={value.draftName}\n                      placeholder=\"Name this passkey\"\n                      disabled={busy}\n                      data-fancy-passkey-field=\"name\"\n                      data-fancy-passkey-id={passkey.id}\n                      onChange={(event) =>\n                        onChange({ ...valueRef.current, draftName: event.target.value })\n                      }\n                    \/>\n                  ) : (\n                    <span data-fancy-passkey-name={passkey.id} className=\"fancy-passkey-manager__name\">\n                      {passkey.name ?? \"Unnamed passkey\"}\n                    <\/span>\n                  )}\n\n                  <span className=\"fancy-passkey-manager__meta\">\n                    <Badge variant=\"soft\" data-fancy-passkey-backed-up={String(passkey.backedUp)}>\n                      {passkey.backedUp ? \"Synced\" : \"This device only\"}\n                    <\/Badge>\n                    {passkey.transports.length > 0 ? (\n                      <span data-fancy-passkey-transports={passkey.transports.join(\" \")}>\n                        {passkey.transports.join(\", \")}\n                      <\/span>\n                    ) : null}\n                    <span>\n                      Added <time dateTime={passkey.createdAt}>{isoDate(passkey.createdAt)}<\/time>\n                    <\/span>\n                    <span>\n                      {passkey.lastUsedAt ? (\n                        <>\n                          Last used{\" \"}\n                          <time dateTime={passkey.lastUsedAt}>{isoDate(passkey.lastUsedAt)}<\/time>\n                        <\/>\n                      ) : (\n                        \"Never used\"\n                      )}\n                    <\/span>\n                  <\/span>\n                <\/div>\n\n                {passkey.clonedAt ? (\n                  <div\n                    data-fancy-passkey-cloned={passkey.id}\n                    className=\"fancy-passkey-manager__cloned\"\n                  >\n                    <Callout color=\"red\">\n                      Possible clone detected on{\" \"}\n                      <time dateTime={passkey.clonedAt}>{isoDate(passkey.clonedAt)}<\/time>. This\n                      credential&apos;s signature counter went backwards, which means the private\n                      key may exist on more than one device. Revoke it.\n                    <\/Callout>\n                  <\/div>\n                ) : null}\n\n                <div className=\"fancy-passkey-manager__actions\">\n                  {renaming ? (\n                    <>\n                      <Button\n                        type=\"button\"\n                        size=\"sm\"\n                        color=\"blue\"\n                        disabled={busy}\n                        data-fancy-passkey-action=\"save-rename\"\n                        data-fancy-passkey-id={passkey.id}\n                        onClick={() => void commitRename(passkey.id)}\n                      >\n                        Save\n                      <\/Button>\n                      <Button\n                        type=\"button\"\n                        size=\"sm\"\n                        variant=\"ghost\"\n                        disabled={busy}\n                        data-fancy-passkey-action=\"cancel-rename\"\n                        data-fancy-passkey-id={passkey.id}\n                        onClick={cancelRename}\n                      >\n                        Cancel\n                      <\/Button>\n                    <\/>\n                  ) : (\n                    <Button\n                      type=\"button\"\n                      size=\"sm\"\n                      variant=\"ghost\"\n                      icon=\"pencil\"\n                      disabled={busy}\n                      data-fancy-passkey-action=\"rename\"\n                      data-fancy-passkey-id={passkey.id}\n                      onClick={() => startRename(passkey)}\n                    >\n                      Rename\n                    <\/Button>\n                  )}\n\n                  <Button\n                    type=\"button\"\n                    size=\"sm\"\n                    variant=\"ghost\"\n                    icon=\"trash-2\"\n                    warn\n                    disabled={busy || pending !== null}\n                    data-fancy-passkey-action=\"revoke\"\n                    data-fancy-passkey-id={passkey.id}\n                    onClick={() => proposeRevoke(passkey.id)}\n                  >\n                    Revoke\n                  <\/Button>\n                <\/div>\n\n                {pending ? (\n                  <div\n                    role=\"alertdialog\"\n                    aria-label=\"Confirm passkey revocation\"\n                    data-fancy-passkey-confirm={pending.id}\n                    data-fancy-passkey-last={String(pending.isLastPasskey)}\n                    className=\"fancy-passkey-manager__confirm\"\n                  >\n                    <Callout color={pending.isLastPasskey ? \"red\" : \"amber\"}>\n                      {pending.isLastPasskey\n                        ? `This is your last passkey. Revoking \u201c${passkey.name ?? \"Unnamed passkey\"}\u201d removes the only passkey on this account \u2014 if it is also your only way in, you will be locked out.`\n                        : `Revoke \u201c${passkey.name ?? \"Unnamed passkey\"}\u201d? That authenticator will no longer be able to sign in.`}\n                    <\/Callout>\n                    <div className=\"fancy-passkey-manager__actions\">\n                      <Button\n                        type=\"button\"\n                        size=\"sm\"\n                        color=\"red\"\n                        disabled={busy}\n                        data-fancy-passkey-action=\"confirm-revoke\"\n                        data-fancy-passkey-id={pending.id}\n                        onClick={() => confirmRevoke(pending.id)}\n                      >\n                        Revoke passkey\n                      <\/Button>\n                      <Button\n                        type=\"button\"\n                        size=\"sm\"\n                        variant=\"ghost\"\n                        disabled={busy}\n                        data-fancy-passkey-action=\"cancel-revoke\"\n                        data-fancy-passkey-id={pending.id}\n                        onClick={() => cancelRevoke(pending.id)}\n                      >\n                        Keep it\n                      <\/Button>\n                    <\/div>\n                  <\/div>\n                ) : null}\n              <\/li>\n            );\n          })}\n        <\/ul>\n      )}\n\n      {onEnroll ? (\n        <div className=\"fancy-passkey-manager__footer\">\n          <Button\n            type=\"button\"\n            color=\"blue\"\n            icon=\"plus\"\n            loading={busy}\n            disabled={busy}\n            data-fancy-passkey-action=\"enroll\"\n            data-fancy-passkey-surface-id={surfaceId}\n            onClick={() => void enroll()}\n          >\n            Add a passkey\n          <\/Button>\n        <\/div>\n      ) : null}\n    <\/div>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/passkey-manager\/PasskeyManager.tsx"},{"path":"components\/fancy\/passkey-manager\/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-manager\/client.ts"},{"path":"components\/fancy\/passkey-manager\/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-manager\/activity.ts"},{"path":"components\/fancy\/passkey-manager\/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-manager\/types.ts"}]}