nostr-bind (browser_fragment_v1): no completion signal, so the desktop dialog can never leave the handoff screen
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
In the `browser_fragment_v1` nostr-bind return mode, the desktop dialog has no way to learn that the browser-side ceremony succeeded, and no inbound route exists for the relying party to tell it. The dialog's final screen is therefore terminal-by-click only: the user must press **Continue** to leave it, no matter what happened in the browser.
The result is that a *successful* pairing ends on a screen whose only interactive affordance is a disclosure labelled **"Pairing didn't finish automatically?"** — which reads as a failure prompt to a user who is looking for confirmation that it worked.
This is a missing-capability report, not a copy complaint. See "Relationship to #4683" below.
## Reproduction
1. From a relying party (in my case a Run402 organization-ownership handoff), open `buzz://nostr-bind?...` with `return_mode=browser_fragment_v1`.
2. Complete consent in the Buzz desktop dialog and enter the six-digit verification code.
3. Buzz signs and opens the browser callback. The browser confirms receipt and the relying party records the completion authoritatively.
4. **The Buzz dialog stays on the handoff screen indefinitely.** It shows "Continue in your browser" plus the collapsed "Pairing didn't finish automatically?" disclosure. It never reflects the successful outcome.
Observed on macOS, `main` as of 2026-08-07.
## What the code does
All references are `main` @ `desktop/`.
After signing, the only action for this return mode is fire-and-forget:
- `src/features/profile/ui/NostrBindConsentDialog.tsx:552-557` sets `signedResponse` then calls `returnSignedResponseToBrowser`
- `:132-143` that function is just `await openUrl(buildNostrBindCallbackUrl(...))`, returning `null` on success
From that point the component is idle. There is no timer, poll, subscription, or awaited network call anywhere in it.
The finish screen renders while `signedResponse !== null`. Every transition that touches it:
| Line | Effect |
|---|---|
| `:552` | set to the signed event — **shows** the screen |
| `:290` | inbound `deep-link-nostr-bind` listener resets it to `null` |
| `:317` | dialog reset |
And the only exit control on that screen is the **Continue** button at `:727` → `handleOpenChange(false)`.
So there is no message of any kind — HTTP, relay, websocket, deep link — that a relying party could send to advance this screen, because nothing is listening for one.
## Why the existing nostr-bind route can't be reused
Re-emitting `buzz://nostr-bind` makes it worse. The inbound listener at `:283-300` hard-resets the dialog:
```ts
setPayload(nextPayload);
setSignedResponse(null);
setVerificationCode(createEmptyVerificationCode());
setIsManualFallbackOpen(false);
```
A second bind link therefore wipes the finish screen and re-prompts the user to sign again, rather than confirming anything.
## Why this looks small
The browser→app transport already exists, is registered, and is proven by the `connect` / `join` / `add-community` / `message` flows. `src-tauri/src/deep_link.rs:312` already dispatches on `url.host_str()`. The relying party is already emitting on this channel to *start* the ceremony. What's missing is one more host on it.
A sketch, each piece mirroring an existing pattern in the same file:
- `src-tauri/src/deep_link.rs` — one more arm beside `Some("nostr-bind")` at `:369`, e.g. `Some("nostr-bind-complete")`, with a parse fn modeled on `parse_nostr_bind_deep_link` (`:246`). Origin validation precedent is already there at `:241` (`callback_url must match origin`), along with ~20 parser rejection tests at `:574-711` to copy the shape from.
- `src/shared/deep-link.ts` — a payload type and `listenForNostrBindCompleteDeepLinks`, mirroring `listenForNostrBindDeepLinks` (`:168-174`).
- `src/features/profile/ui/NostrBindConsentDialog.tsx` — a listener that, on a `challenge_id` matching the attempt this dialog just signed, moves to a verified terminal state (and can auto-dismiss).
## Security constraint this must respect
A custom-scheme deep link is an **unauthenticated local channel** — any process or page on the machine can emit it. This signal must therefore be treated as display-only:
- accept it **only** when `challenge_id` matches the attempt the open dialog just signed (already in memory), and
- never let it grant, imply, or stand in for authority.
The relying party remains the sole authority for whether the binding actually completed. The point of the signal is only to stop making the human infer the outcome from a screen that structurally cannot know it.
## Relationship to #4683
#4683 ("fix(desktop): clarify browser handoff result") was closed unmerged, correctly: relabelling the screen doesn't give the dialog knowledge it doesn't have. This request is the complementary one — supply the knowledge. If a completion route lands, the wording question mostly dissolves, and the existing disclosure stays as the honest fallback for cases where no signal arrives.
## Platform caveat: #2949
#2949 reports that the Windows installer does not register `buzz://` and cold-start deep links are dropped, explicitly blocking the shipped Nostr identity proof flow there. Any fix built on this channel would be a no-op on Windows until that is resolved.
That argues for sequencing rather than blocking: the existing "Pairing didn't finish automatically?" disclosure is exactly the right graceful degradation when no completion signal arrives, so it should stay. A completion route would upgrade the platforms where deep links work, and Windows would continue to land on the current manual path until #2949 is fixed. Worth stating in whatever ships so nobody reads it as a universal fix.
## Environment
- Buzz `main` @ 2026-08-07, macOS
- `return_mode=browser_fragment_v1`
- Relying party: Run402 (`console.run402.com`) organization-ownership handoff
Contributor guide
Research direction
Start with the existing nostr-bind parsing and listener paths in src-tauri/src/deep_link.rs, src/shared/deep-link.ts, and NostrBindConsentDialog.tsx, then run the deep-link parser rejection tests in deep_link.rs. Done means a completion signal matching the signed challenge can update or dismiss the dialog without granting authority, while preserving the manual fallback and existing nostr-bind behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, typescript
- Domain
- authentication, desktop, security
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100