Skip to content

fix(deeplink): browsers can never launch 4da:// — rename the scheme to fourda:// - #491

Merged
runyourempire merged 3 commits into
mainfrom
worktree-fix-deeplink-scheme
Aug 18, 2026
Merged

fix(deeplink): browsers can never launch 4da:// — rename the scheme to fourda://#491
runyourempire merged 3 commits into
mainfrom
worktree-fix-deeplink-scheme

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

Browsers can never launch 4da:// — the scheme itself was the bug

Operator clicked Open in 4DA on the /activate bridge page and landed on https://4da.ai/4da://activate?key=... — a 404, with the licence key in the server-visible URL path.

A URL scheme must start with a letter (RFC 3986 / WHATWG URL). Browsers therefore parse 4da://... as a relative path — the handoff never reached the OS at all, in any browser. The Windows registry happily registers digit-first scheme names (the installed build had registered 4dafourda.exe), which is why the handler side always looked healthy while no browser could ever reach it.

Verified empirically in Chromium before changing anything: location.href='4da://x' resolves relative and 404s; location.href='fourda://x' attempts a real protocol launch ("scheme does not have a registered handler").

The rename: fourda:// end to end, in one commit

tauri.conf.json · validate_deep_link_url (old scheme now explicitly rejected, with a regression test explaining why) · /activate + /signal/success handoff pages · VS Code extension · NETWORK.md. The paddle-webhook emails stop emitting a raw custom-scheme link entirely and link the https bridge instead, matching the Stripe flow (Gmail strips custom-scheme hrefs anyway).

Two more latent breaks in the same chain, fixed so the button works in every app state

  • App already running: Windows hands the URL to a second instance's argv; the single-instance callback dropped args on the floor. It now uses the plugin's deep-link feature, so the URL flows into the existing validated deep-link://new-url listener.
  • Cold start: the launch URL exists before any listener attaches. app_setup now validates and parks it; the frontend collects it on mount via the new take_pending_deep_link command (consuming — a webview reload cannot re-activate). register_all() runs at startup so dev builds and the renamed scheme self-heal instead of depending on whatever exe the last installer wrote.

The frontend's new URL(payload) parsing — which would itself have thrown on a digit-first scheme — now works exactly as written.

Verification

  • Rust: utils::url suite 20/20 including the new test_reject_retired_digit_scheme.
  • Frontend: full vitest suite green (the browser-mode guard test caught the mount-time IPC probe; now gated on isBrowserMode like its siblings), tsc --noEmit clean.
  • Email tests: 19/19 — the no-custom-scheme assertion now pins both fourda:// and the retired 4da://.
  • Gates: file sizes, ghost-commands (both command registries updated), retired-claims.
  • Post-merge plan: deploy site, rebuild + relaunch the app (registers fourda in HKCU), then live-fire the full email → /activate → Open in 4DA → licence-accepted chain.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EnyENPW2GcG8GU7x1rJ8wN

…o fourda://

A URL scheme must start with a LETTER (RFC 3986 / WHATWG). Browsers therefore
parse `4da://activate?key=...` as a RELATIVE path: the activate page's
"Open in 4DA" button navigated to https://4da.ai/4da://activate?key=... — a
404 that also put the licence key in the server-visible URL path. The OS
registry happily registers digit-first scheme names, which is why the handler
side always looked healthy while no browser could ever reach it. Verified
empirically: location.href='4da://x' resolves relative; 'fourda://x' attempts
a protocol launch.

Renamed everywhere together: tauri.conf.json, validate_deep_link_url (old
scheme now explicitly rejected, with a regression test), the /activate and
/signal/success handoff pages, the VS Code extension, and NETWORK.md. The
paddle-webhook emails stop emitting a raw custom-scheme link entirely and use
the https bridge, matching the Stripe flow.

Two more latent breaks in the same chain, fixed so the button works in every
app state:
- running app: single-instance gains the deep-link feature, so a second
  instance's argv URL is forwarded into the existing validated listener
  (previously the callback dropped args — links only worked when the app was
  closed, via installer registration);
- cold start: the launch URL exists before any listener attaches, so it is
  validated, parked, and collected by the frontend via the new
  take_pending_deep_link command (consumed once; webview reload cannot
  re-activate it). register_all() now runs at startup so dev builds and the
  renamed scheme self-heal instead of depending on the last installer.

The frontend's new URL(payload) parsing — which would itself have thrown on a
digit-first scheme — now works as written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnyENPW2GcG8GU7x1rJ8wN
@runyourempire
runyourempire enabled auto-merge (squash) August 18, 2026 13:53
runyourempire and others added 2 commits August 19, 2026 00:11
…R today

The advisory (unbounded empty DATA frames) published 2026-08-17 and fails the
Security audit step for ANY branch until the lockfile moves; main carries
0.4.14 too. Transitive patch bump, lockfile-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnyENPW2GcG8GU7x1rJ8wN
@runyourempire
runyourempire merged commit c3de336 into main Aug 18, 2026
14 checks passed
@runyourempire
runyourempire deleted the worktree-fix-deeplink-scheme branch August 18, 2026 14:45
runyourempire added a commit that referenced this pull request Aug 18, 2026
… — relay them (#492)

## The file lock ate deep links while the app was running

Found during live end-to-end verification of #491 on the canonical
layout. With 4DA running, "Open in 4DA" spawns a second process carrying
the `fourda://` URL in argv — and the **pre-Tauri single-instance file
lock** exits it long before `tauri_plugin_single_instance` (the designed
warm-path forwarder, wired up in #491) ever initializes. The URL was
dropped silently; the app log showed no "Deep-link received".

The isolated-worktree test in #491's verification had passed only by
accident of layout: its two processes resolved *different* data dirs, so
the file lock never collided and the plugin got its chance. In
production both processes resolve the same data dir — so **every** warm
deep link died at the lock. (The engine coexists by design:
`initialize_pre_tauri(false)` skips the lock, so the lock holder is
always a GUI instance.)

## Fix: relay through the data dir, keep the lock's guarantee

The lock exists to stop a second process reaching the SQLite WAL — that
property is untouched. The rejected instance now validates the URL,
parks it in `data_dir/.deeplink-relay` (atomic temp+rename,
timestamped), and exits without touching the database. The primary polls
the path once per second: consume-at-most-once, stale entries (>60s)
discarded so a crashed primary can't replay an old activation, window
brought to front the way the plugin callback would have, then emitted
through the same validated `deep-link-activate` path.

The plugin's own forwarding stays registered — it still covers the
soft-fail case where the file lock errors on I/O and the second instance
reaches Tauri.

## Verification

- 8/8 `single_instance` tests including 3 new relay tests (roundtrip +
consume-once, stale discard, garbage tolerance)
- clippy clean on the plain lib target; file-size and ghost gates pass
- Post-merge plan: rebuild root binaries, live-fire
`fourda://activate?key=<real key>` against the running canonical app —
the exact scenario that failed — and confirm `activate_license` success
via Victauri IPC trace before calling the feature production-ready.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EnyENPW2GcG8GU7x1rJ8wN

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant