Skip to content

fix(ai-client): fail fast when a passkey unlock lacks user activation - #1332

Open
jherr wants to merge 2 commits into
mainfrom
1password-byok-fix
Open

fix(ai-client): fail fast when a passkey unlock lacks user activation#1332
jherr wants to merge 2 commits into
mainfrom
1password-byok-fix

Conversation

@jherr

@jherr jherr commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Passkey BYOK stored a key you could save but not use. Saving worked. Sending a message hung with no prompt and no error. This PR makes the failed unlock throw a clear error instead of hanging forever.

🎯 Changes

passkeyStorage decrypts the keyring with a WebAuthn get() ceremony. Safari and Dia only show that prompt while transient user activation is fresh (right after a click). They suppress it silently otherwise, and get() never resolves. An unlock that runs deep in an async send pipeline is past the activation window, so it hangs.

evaluatePrf now checks navigator.userActivation before the get(). If activation is gone, it throws a clear, catchable error. Callers must trigger unlock (byok.prepare() / byok.unlock()) from the click handler, before awaiting other work.

The companion app fix is TanStack/tanstack.com#1220.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with pnpm run test:pr. See Testing for what I ran instead.
  • I fully understand the code in this pull request.
  • Docs: I updated docs/advanced/byok.md.
  • Changeset: I added a changeset.

🚀 Release Impact

  • This change affects published code, and I added a changeset.
  • This change is docs/CI/dev-only (no release).

Root cause

Issue. On tanstack.com/builder, a saved passkey-encrypted BYOK key never unlocks in Safari and Dia. The send stalls. The user sees repeated key prompts and no way forward.

Cause. evaluatePrf in packages/ai-client/src/byok/passkey.ts calls navigator.credentials.get(). Those browsers gate WebAuthn on transient user activation. The unlock runs after the click's activation expires (~5s, or dropped across async work), so the browser shows no prompt and the promise never settles.

Fix. Check navigator.userActivation.isActive before the get(). If it is false, throw a clear error. The call fails fast instead of hanging, and the app can re-run it from a fresh gesture.

Possible alternatives

  • AbortController timeout on the ceremony. Abort get() after N seconds. Rejected: it also aborts a real prompt that the user is still reading, and it does not tell the caller why.
  • Fix only the builder app. The builder PR does make it work. But any consumer of passkeyStorage can hit the same silent hang, so the library must fail loudly too.

Testing

Commands run:

  • pnpm test:lib in packages/ai-client: 766 passed (49 files), including 2 new tests.
  • pnpm test:types in packages/ai-client: clean.
  • oxlint --type-aware on changed files: clean.
  • pnpm test:docs: no broken links.
  • Skipped the full pnpm test:pr sweep. Ran the affected package checks above instead.

Manual test (repro):

  1. Open tanstack.com/builder in Dia or Safari with a passkey-encrypted BYOK key saved.
  2. Send a message. Before: no passkey prompt, the send hangs.
  3. With the companion app fix, the prompt appears on the click and the send proceeds.

How this PR makes testing easy. tests/byok-passkey.test.ts covers the guard: it throws when activation is gone, and passes when activation is active or the API is absent.

Public API change

@tanstack/ai-client/byok exports a new helper, requireUserActivation. The behavior change that matters: trigger unlock from the click.

Before

async function send() {
  await buildRequest()
  await byok.prepare('openai') // unlock runs here, activation already gone
}

After

async function onSubmit() {
  await byok.prepare('openai') // unlock first, on the click
  await buildRequest()
}

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Passkey-based key unlocking now fails quickly with a clear, catchable error when triggered without recent user interaction, instead of hanging indefinitely.
    • Improved compatibility with browsers that require passkey prompts to start directly from a click.
  • Documentation

    • Added guidance to trigger BYOK setup or unlocking directly within a user action handler.

Passkey-encrypted BYOK stores the keyring in IndexedDB and decrypts it with
a WebAuthn get() ceremony. Browsers that gate WebAuthn on transient user
activation (Safari, Dia) silently suppress that prompt when get() runs
outside a user gesture: no UI, and the call never resolves. An unlock buried
in an async send pipeline therefore hangs forever.

Check navigator.userActivation before the get() and throw a clear, catchable
error instead. Callers should trigger unlock (byok.prepare()/unlock()) from
the click handler, before awaiting other work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: fad59643-4e56-47d8-9fc9-8bee29d366be

📥 Commits

Reviewing files that changed from the base of the PR and between 9b0db21 and e56ac80.

📒 Files selected for processing (6)
  • .changeset/byok-passkey-user-activation.md
  • docs/advanced/byok.md
  • docs/config.json
  • packages/ai-client/src/byok.ts
  • packages/ai-client/src/byok/passkey.ts
  • packages/ai-client/tests/byok-passkey.test.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The passkey BYOK unlock flow now checks transient user activation before starting WebAuthn, exports the check, tests its behavior, and documents the required click-handler usage.

Changes

Passkey activation handling

Layer / File(s) Summary
Activation guard and unlock integration
packages/ai-client/src/byok/passkey.ts, packages/ai-client/src/byok.ts, packages/ai-client/tests/byok-passkey.test.ts
requireUserActivation throws when activation is inactive, evaluatePrf calls it before unlock, and tests cover active, inactive, and unavailable activation APIs.
Documentation and release metadata
docs/advanced/byok.md, docs/config.json, .changeset/byok-passkey-user-activation.md
The BYOK guide and changeset describe direct click-handler invocation and the patch release. The documentation timestamp is updated.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to e56ac

Passkey BYOK unlocks now fail immediately with a catchable error when a fresh browser user action is required, avoiding suppressed WebAuthn prompts that can hang. The behavior, public API, tests, and documentation are aligned, with no current merge-blocking risk identified.

Suggested reviewers: tombeckenham

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 3 files. (3 skipped: 3… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: passkey unlock now fails fast when user activation is unavailable.
Description check ✅ Passed The description is complete and aligned with the template. It explains the change, root cause, fix, alternatives, testing, public API impact, documentation, and changeset. It also records that the ful…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 3 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 1password-byok-fix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit e56ac80

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 1s View ↗

☁️ Nx Cloud last updated this comment at 2026-09-05 04:02:04 UTC

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/@tanstack/ai@1332

@tanstack/ai-acp

npm i https://pkg.pr.new/@tanstack/ai-acp@1332

@tanstack/ai-angular

npm i https://pkg.pr.new/@tanstack/ai-angular@1332

@tanstack/ai-anthropic

npm i https://pkg.pr.new/@tanstack/ai-anthropic@1332

@tanstack/ai-bedrock

npm i https://pkg.pr.new/@tanstack/ai-bedrock@1332

@tanstack/ai-byteplus

npm i https://pkg.pr.new/@tanstack/ai-byteplus@1332

@tanstack/ai-claude-code

npm i https://pkg.pr.new/@tanstack/ai-claude-code@1332

@tanstack/ai-client

npm i https://pkg.pr.new/@tanstack/ai-client@1332

@tanstack/ai-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-cloudflare@1332

@tanstack/ai-code-mode

npm i https://pkg.pr.new/@tanstack/ai-code-mode@1332

@tanstack/ai-code-mode-snippets

npm i https://pkg.pr.new/@tanstack/ai-code-mode-snippets@1332

@tanstack/ai-codex

npm i https://pkg.pr.new/@tanstack/ai-codex@1332

@tanstack/ai-cohere

npm i https://pkg.pr.new/@tanstack/ai-cohere@1332

@tanstack/ai-compaction

npm i https://pkg.pr.new/@tanstack/ai-compaction@1332

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/@tanstack/ai-devtools-core@1332

@tanstack/ai-durable-stream

npm i https://pkg.pr.new/@tanstack/ai-durable-stream@1332

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/@tanstack/ai-elevenlabs@1332

@tanstack/ai-event-client

npm i https://pkg.pr.new/@tanstack/ai-event-client@1332

@tanstack/ai-fal

npm i https://pkg.pr.new/@tanstack/ai-fal@1332

@tanstack/ai-gemini

npm i https://pkg.pr.new/@tanstack/ai-gemini@1332

@tanstack/ai-grok

npm i https://pkg.pr.new/@tanstack/ai-grok@1332

@tanstack/ai-grok-build

npm i https://pkg.pr.new/@tanstack/ai-grok-build@1332

@tanstack/ai-groq

npm i https://pkg.pr.new/@tanstack/ai-groq@1332

@tanstack/ai-isolate-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-isolate-cloudflare@1332

@tanstack/ai-isolate-daytona

npm i https://pkg.pr.new/@tanstack/ai-isolate-daytona@1332

@tanstack/ai-isolate-node

npm i https://pkg.pr.new/@tanstack/ai-isolate-node@1332

@tanstack/ai-isolate-quickjs

npm i https://pkg.pr.new/@tanstack/ai-isolate-quickjs@1332

@tanstack/ai-isolate-quickjs-bun

npm i https://pkg.pr.new/@tanstack/ai-isolate-quickjs-bun@1332

@tanstack/ai-llmgateway

npm i https://pkg.pr.new/@tanstack/ai-llmgateway@1332

@tanstack/ai-lovable

npm i https://pkg.pr.new/@tanstack/ai-lovable@1332

@tanstack/ai-mcp

npm i https://pkg.pr.new/@tanstack/ai-mcp@1332

@tanstack/ai-memory

npm i https://pkg.pr.new/@tanstack/ai-memory@1332

@tanstack/ai-mistral

npm i https://pkg.pr.new/@tanstack/ai-mistral@1332

@tanstack/ai-octane

npm i https://pkg.pr.new/@tanstack/ai-octane@1332

@tanstack/ai-ollama

npm i https://pkg.pr.new/@tanstack/ai-ollama@1332

@tanstack/ai-openai

npm i https://pkg.pr.new/@tanstack/ai-openai@1332

@tanstack/ai-opencode

npm i https://pkg.pr.new/@tanstack/ai-opencode@1332

@tanstack/ai-openrouter

npm i https://pkg.pr.new/@tanstack/ai-openrouter@1332

@tanstack/ai-perplexity

npm i https://pkg.pr.new/@tanstack/ai-perplexity@1332

@tanstack/ai-persistence

npm i https://pkg.pr.new/@tanstack/ai-persistence@1332

@tanstack/ai-preact

npm i https://pkg.pr.new/@tanstack/ai-preact@1332

@tanstack/ai-react

npm i https://pkg.pr.new/@tanstack/ai-react@1332

@tanstack/ai-react-ui

npm i https://pkg.pr.new/@tanstack/ai-react-ui@1332

@tanstack/ai-remix

npm i https://pkg.pr.new/@tanstack/ai-remix@1332

@tanstack/ai-sandbox

npm i https://pkg.pr.new/@tanstack/ai-sandbox@1332

@tanstack/ai-sandbox-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-sandbox-cloudflare@1332

@tanstack/ai-sandbox-daytona

npm i https://pkg.pr.new/@tanstack/ai-sandbox-daytona@1332

@tanstack/ai-sandbox-docker

npm i https://pkg.pr.new/@tanstack/ai-sandbox-docker@1332

@tanstack/ai-sandbox-local-process

npm i https://pkg.pr.new/@tanstack/ai-sandbox-local-process@1332

@tanstack/ai-sandbox-sprites

npm i https://pkg.pr.new/@tanstack/ai-sandbox-sprites@1332

@tanstack/ai-sandbox-upstash-box

npm i https://pkg.pr.new/@tanstack/ai-sandbox-upstash-box@1332

@tanstack/ai-sandbox-vercel

npm i https://pkg.pr.new/@tanstack/ai-sandbox-vercel@1332

@tanstack/ai-skills

npm i https://pkg.pr.new/@tanstack/ai-skills@1332

@tanstack/ai-solid

npm i https://pkg.pr.new/@tanstack/ai-solid@1332

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/@tanstack/ai-solid-ui@1332

@tanstack/ai-svelte

npm i https://pkg.pr.new/@tanstack/ai-svelte@1332

@tanstack/ai-utils

npm i https://pkg.pr.new/@tanstack/ai-utils@1332

@tanstack/ai-vercel-gateway

npm i https://pkg.pr.new/@tanstack/ai-vercel-gateway@1332

@tanstack/ai-vertex

npm i https://pkg.pr.new/@tanstack/ai-vertex@1332

@tanstack/ai-vue

npm i https://pkg.pr.new/@tanstack/ai-vue@1332

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/@tanstack/ai-vue-ui@1332

@tanstack/openai-base

npm i https://pkg.pr.new/@tanstack/openai-base@1332

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/@tanstack/preact-ai-devtools@1332

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/@tanstack/react-ai-devtools@1332

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/@tanstack/solid-ai-devtools@1332

@tanstack/svelte-ai-devtools

npm i https://pkg.pr.new/@tanstack/svelte-ai-devtools@1332

commit: e56ac80

@github-actions github-actions Bot added the waiting-on: maintainer The ball is in the maintainers’ court label Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-on: maintainer The ball is in the maintainers’ court

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant