Skip to content

feat(desktop): passkey-registered deep link + preload bridge - #1011

Merged
2witstudios merged 6 commits into
masterfrom
pu/passkey-add-desktop-deeplink
Apr 14, 2026
Merged

2witstudios merged 6 commits into
masterfrom
pu/passkey-add-desktop-deeplink

Conversation

@2witstudios

@2witstudios 2witstudios commented Apr 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

Sub-task Desktop Deep Link + Preload Bridge of the passkey-add-desktop-fix epic.

Wires up Electron so the system browser can complete a Settings → Security passkey registration and return control to the desktop app via pagespace://passkey-registered. The credential is already persisted server-side (by the external register endpoint built in a parallel worktree) — all this task does is focus/create the main window and broadcast a typed IPC event the renderer can subscribe to.

  • apps/desktop/src/main/passkey-deep-link.ts — pure handler handlePasskeyRegistered(url, deps). Host-checks, logs, then asks the wiring to focus the window and broadcast passkey:registered. No fetch, no token exchange, no session mutation.
  • apps/desktop/src/main/deep-links.ts — dispatcher gains the new case after auth-exchange / auth-error, before the generic deep-link fallback. Wiring for focusWindow / sendToRenderer:
    • If mainWindow exists and is loaded → focus + webContents.send immediately.
    • If mainWindow exists but isLoading() → defer the send onto the next did-finish-load via webContents.once(...).
    • If mainWindow is null (macOS: all windows closed while app stays in the dock) → call createWindow(), then defer the send onto did-finish-load of the new window. This is the Codex review fix.
  • apps/desktop/src/preload/index.ts — new top-level passkey: { onRegistered(cb): () => void } bridge using ipcRenderer.on + removeListener for clean unsubscribe. Typed on the local ElectronAPI interface.
  • apps/web/src/types/electron.d.ts — renderer ambient type gains the matching passkey.onRegistered entry so downstream PasskeyManager.tsx can call it type-safely. This is where the downstream PasskeyManager task should import the type from.

Explicitly not touched: auth:open-external IPC handler, ALLOWED_AUTH_HOSTNAMES, ALLOWED_PASSKEY_PATHS — those belong to the parallel "Electron Open-External Allowlist" task. pagespace://auth-exchange behaviour is fully preserved (regression test).

Commits

  • RED ba789d1e — test(desktop): RED passkey-registered deep link + preload bridge
  • GREEN 7d0f115b — feat(desktop): handle pagespace://passkey-registered deep link + preload bridge
  • Tidy ab120b31 — test(desktop): clean up regression assertion in deep-links.test.ts
  • Refactor 5c3616e9 — refactor(desktop): narrow PasskeyDeepLinkDeps.sendToRenderer to single channel
  • Codex fix 9468a34f — fix(desktop): passkey-registered must create window + defer IPC if none exists

Requirement checklist

  • ✅ pagespace://passkey-registered focuses the main BrowserWindow (restore if minimized, show, focus)
  • ✅ pagespace://passkey-registered broadcasts passkey:registered to the renderer via webContents.send
  • ✅ pagespace://passkey-registered with no existing main window creates one and defers the IPC until did-finish-load — no silent drops on macOS cold-open from dock
  • ✅ pagespace://passkey-registered makes no HTTP call, token exchange, or session mutation (asserted by fetchSpy.not.toHaveBeenCalled())
  • ✅ Regression: pagespace://auth-exchange?code=... still POSTs to /api/auth/desktop/exchange and never emits passkey:registered (asserted)
  • ✅ Unknown pagespace://foo-bar still falls through to the generic deep-link IPC channel (asserted)
  • ✅ window.electron.passkey.onRegistered(cb) subscribes via ipcRenderer.on('passkey:registered', …)
  • ✅ Returned unsubscribe calls ipcRenderer.removeListener and stops delivering callbacks (asserted)
  • ✅ Ambient ElectronAPI at apps/web/src/types/electron.d.ts typed, no any
  • ✅ pnpm --filter desktop typecheck clean
  • ✅ pnpm --filter desktop test clean (120 tests passing, including 4 new deep-links dispatcher tests, 3 passkey-deep-link unit tests, 3 preload bridge tests)
  • ✅ pnpm --filter web typecheck clean (after packages/lib + packages/db builds)
  • ✅ pnpm lint clean monorepo-wide

Regression proof

apps/desktop/src/main/__tests__/deep-links.test.ts → "given pagespace://auth-exchange (regression) › should still POST to /api/auth/desktop/exchange and never broadcast passkey:registered". Stubs fetch, calls handleDeepLink('pagespace://auth-exchange?code=code123&provider=google'), and asserts:

  1. fetch called exactly once against /api/auth/desktop/exchange with method: 'POST'
  2. webContents.send never called with 'passkey:registered'

Ambient type location for downstream PasskeyManager task

The downstream PasskeyManager.tsx renderer task should import via window.electron.passkey.onRegistered — typed in apps/web/src/types/electron.d.ts on the ElectronAPI interface. The preload-side mirror lives in apps/desktop/src/preload/index.ts on its own ElectronAPI export.

Test plan

  • pnpm --filter desktop test — 120 passed (was 119, +1 null-window regression)
  • pnpm --filter desktop typecheck — clean
  • pnpm --filter web typecheck — clean
  • pnpm lint — clean
  • Manual: after PasskeyManager worktree lands, verify in a packaged Electron build that:
    • cold-open from dock via pagespace://passkey-registered triggers the renderer subscription
    • in-session deep link focuses an existing window + triggers the renderer subscription

2witstudios and others added 2 commits April 14, 2026 09:58
Sub-task: Desktop Deep Link + Preload Bridge (passkey-add-desktop-fix epic)

Covers:
- pure handlePasskeyRegistered host routing + side effects
- full handleDeepLink routing regression (auth-exchange still fetches)
- handleDeepLink focuses window + sends passkey:registered IPC
- unknown pagespace:// host still falls through to generic deep-link
- preload exposes window.electron.passkey.onRegistered with unsubscribe

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…oad bridge

Sub-task: Desktop Deep Link + Preload Bridge (passkey-add-desktop-fix epic)

- New pure handler `handlePasskeyRegistered(url, deps)` in
  `apps/desktop/src/main/passkey-deep-link.ts` — host-checks the URL,
  focuses the main window, and broadcasts `passkey:registered` on the
  renderer IPC channel. No HTTP call, no token exchange, no session
  mutation — the credential is already persisted by the external
  passkey register endpoint before the deep link fires.
- `handleDeepLink` dispatcher in `deep-links.ts` now routes
  `pagespace://passkey-registered` through the new handler, before the
  generic `deep-link` fallback. `pagespace://auth-exchange` behaviour is
  untouched (covered by regression test).
- Preload exposes `window.electron.passkey.onRegistered(cb)` returning
  an unsubscribe function. Typed on both the preload-side `ElectronAPI`
  and the renderer ambient type at `apps/web/src/types/electron.d.ts`
  so downstream `PasskeyManager.tsx` can call it type-safely.

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

vercel Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pagespace-master-plan Ready Ready Preview, Comment Apr 14, 2026 3:17pm

@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 40 minutes and 19 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 40 minutes and 19 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d511f134-de5f-43ec-ba2a-c68c49006bd0

📥 Commits

Reviewing files that changed from the base of the PR and between 372f2b7 and 78d8b4e.

📒 Files selected for processing (7)
  • apps/desktop/src/main/__tests__/deep-links.test.ts
  • apps/desktop/src/main/__tests__/passkey-deep-link.test.ts
  • apps/desktop/src/main/deep-links.ts
  • apps/desktop/src/main/passkey-deep-link.ts
  • apps/desktop/src/preload/__tests__/preload-passkey.test.ts
  • apps/desktop/src/preload/index.ts
  • apps/web/src/types/electron.d.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/passkey-add-desktop-deeplink

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 and usage tips.

Use expect.stringContaining/objectContaining instead of an awkward
`as unknown as [string, RequestInit]` cast.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d0f115b9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/deep-links.ts Outdated
…e channel

YAGNI — the only broadcast is `passkey:registered` with no payload.
A variadic signature hints at future flexibility we do not need and
complicates the test doubles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ne exists

Codex PR review flagged that when pagespace://passkey-registered arrives
while mainWindow is null — the macOS case where the user closes all
windows but the app stays in the dock — the handler silently consumed
the event: focusWindow was a no-op and sendToRenderer hit a dead
webContents, so the renderer never got passkey:registered and the
passkey list never refreshed.

Fix:
- If mainWindow is null when the deep link arrives, call createWindow()
  to spawn one.
- If the window exists but is still loading, defer the
  webContents.send('passkey:registered') onto its next did-finish-load
  via webContents.once, so the renderer subscription is guaranteed to
  be installed before we broadcast.
- Otherwise, deliver immediately as before.

Adds a regression test covering the null-window cold-start path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…path

`window` collides with the DOM global (undefined in the Electron main
process, but still a footgun). `target` also lets the deferred
did-finish-load callback reuse the captured binding instead of a
second optional-chained main-window lookup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@2witstudios
2witstudios merged commit ca95823 into master Apr 14, 2026
5 checks passed
@2witstudios
2witstudios deleted the pu/passkey-add-desktop-deeplink branch April 14, 2026 15:51
2witstudios added a commit that referenced this pull request Apr 14, 2026
Tests for the integration layer that wires the foundation PRs (#1008, #1009,
#1010, #1011) into a working end-to-end flow for adding a passkey from the
Settings → Security panel on desktop. Fails until the routes, ceremony helper,
external page, and PasskeyManager desktop branch land in the next commit.

- handoff mint route: session auth + CSRF + rate limit, mints handoffToken
  via createPasskeyRegisterHandoff and audits auth.token.created
- register/options handoffToken branch: peek (non-destructive), skips session
  auth + CSRF, 401 HANDOFF_INVALID + audit on miss, rate limit bound to
  resolved userId, session path regression
- register verify handoffToken branch: consume (one-time-use), second call
  rejected, rate limit bound to resolved userId, session path regression
- runPasskeyRegisterExternalCeremony: options → startRegistration → verify →
  pagespace://passkey-registered, NotAllowedError → cancelled result
- PasskeyManager desktop branch: update-prompt when bridge missing, POST
  handoff → openExternal with /auth/passkey-register-external URL, no
  in-window startRegistration, onRegistered → mutate('/api/auth/passkey')
  + toast, unsubscribe on unmount, web regression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2witstudios added a commit that referenced this pull request Apr 14, 2026
Wires the merged foundation PRs (#1008, #1009, #1010, #1011) into a working
end-to-end flow so Electron users can add a passkey from Settings → Security.
Electron's Chromium cannot drive Touch ID / Windows Hello without entitlements
we don't ship, so the ceremony is delegated to the system browser via the
same `shell.openExternal` + `pagespace://` deep-link pattern PR #1001 used for
sign-in.

New:
- POST /api/auth/passkey/register/handoff — authenticated mint endpoint for
  one-time handoff tokens (session auth + CSRF + per-user rate limit).
- apps/web/src/components/auth/runPasskeyRegisterExternalCeremony.ts —
  options → startRegistration → verify → pagespace://passkey-registered.
- apps/web/src/app/auth/passkey-register-external/page.tsx — system-browser
  landing page that runs the ceremony and deep-links back to the desktop app.

Modified in place (mirroring authenticate/route.ts desktopExchange branch):
- register/options/route.ts — handoffToken branch uses peekPasskeyRegisterHandoff
  (non-destructive; verify step still needs the token), skips session + CSRF,
  401 HANDOFF_INVALID on miss, rate limit bound to resolved userId.
- register/route.ts — handoffToken branch uses consumePasskeyRegisterHandoff
  (atomic one-time-use), 401 on miss, same rate limit binding.
- PasskeyManager.tsx — desktop branch in handleRegister: update-prompt guard
  when bridge missing, POST handoff → openExternal with /auth/passkey-register-
  external URL, no in-window startRegistration. Deep-link subscription via
  window.electron.passkey.onRegistered refreshes the passkey list and toasts
  on return. Web path unchanged.

Completes the Passkey Add Desktop Fix epic (tasks/passkey-add-desktop-fix.md).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2witstudios added a commit that referenced this pull request Apr 14, 2026
* test(auth): RED — passkey add-from-settings desktop integration

Tests for the integration layer that wires the foundation PRs (#1008, #1009,
#1010, #1011) into a working end-to-end flow for adding a passkey from the
Settings → Security panel on desktop. Fails until the routes, ceremony helper,
external page, and PasskeyManager desktop branch land in the next commit.

- handoff mint route: session auth + CSRF + rate limit, mints handoffToken
  via createPasskeyRegisterHandoff and audits auth.token.created
- register/options handoffToken branch: peek (non-destructive), skips session
  auth + CSRF, 401 HANDOFF_INVALID + audit on miss, rate limit bound to
  resolved userId, session path regression
- register verify handoffToken branch: consume (one-time-use), second call
  rejected, rate limit bound to resolved userId, session path regression
- runPasskeyRegisterExternalCeremony: options → startRegistration → verify →
  pagespace://passkey-registered, NotAllowedError → cancelled result
- PasskeyManager desktop branch: update-prompt when bridge missing, POST
  handoff → openExternal with /auth/passkey-register-external URL, no
  in-window startRegistration, onRegistered → mutate('/api/auth/passkey')
  + toast, unsubscribe on unmount, web regression

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

* feat(auth): GREEN — passkey add-from-settings desktop integration

Wires the merged foundation PRs (#1008, #1009, #1010, #1011) into a working
end-to-end flow so Electron users can add a passkey from Settings → Security.
Electron's Chromium cannot drive Touch ID / Windows Hello without entitlements
we don't ship, so the ceremony is delegated to the system browser via the
same `shell.openExternal` + `pagespace://` deep-link pattern PR #1001 used for
sign-in.

New:
- POST /api/auth/passkey/register/handoff — authenticated mint endpoint for
  one-time handoff tokens (session auth + CSRF + per-user rate limit).
- apps/web/src/components/auth/runPasskeyRegisterExternalCeremony.ts —
  options → startRegistration → verify → pagespace://passkey-registered.
- apps/web/src/app/auth/passkey-register-external/page.tsx — system-browser
  landing page that runs the ceremony and deep-links back to the desktop app.

Modified in place (mirroring authenticate/route.ts desktopExchange branch):
- register/options/route.ts — handoffToken branch uses peekPasskeyRegisterHandoff
  (non-destructive; verify step still needs the token), skips session + CSRF,
  401 HANDOFF_INVALID on miss, rate limit bound to resolved userId.
- register/route.ts — handoffToken branch uses consumePasskeyRegisterHandoff
  (atomic one-time-use), 401 on miss, same rate limit binding.
- PasskeyManager.tsx — desktop branch in handleRegister: update-prompt guard
  when bridge missing, POST handoff → openExternal with /auth/passkey-register-
  external URL, no in-window startRegistration. Deep-link subscription via
  window.electron.passkey.onRegistered refreshes the passkey list and toasts
  on return. Web path unchanged.

Completes the Passkey Add Desktop Fix epic (tasks/passkey-add-desktop-fix.md).

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

* fix(auth): address PR #1012 review feedback — fragment handoff, 400 on parse

Addresses Codex and CodeRabbit review feedback on PR #1012:

**Security — move handoff token out of the query string**
(CodeRabbit Major on passkey-register-external/page.tsx)
- buildPasskeyRegisterExternalUrl now places handoffToken in the URL fragment
  (`#handoffToken=...`), never the query. Fragments never hit the network,
  server access logs, CDN logs, or the Referer header, so the capability
  token no longer leaks anywhere a log scraper or a same-origin fetch could
  see it.
- parsePasskeyRegisterExternalParams takes an optional `hash` argument and
  reads the token from there; deviceId/deviceName remain in the query.
- External page clears the fragment via history.replaceState after reading,
  so the token is not left sitting in the browser address bar.
- Round-trip and missing-field tests updated to cover the new transport.

**Robustness — request body parsing**
(Codex P2 + CodeRabbit Minor)
- register/route.ts wraps `await req.json()` in try/catch and returns a
  controlled 400 on malformed bodies instead of falling to the outer catch
  and emitting internal-error noise to unauthenticated callers.
- register/options/route.ts readOptionalJson normalizes non-plain-object
  parses (null, arrays, primitives) to `{}` so `body.handoffToken` access
  never throws. Regression tests for `'null'` and malformed bodies confirm
  the session-auth path still runs.

**Robustness — unhandled promise on external page**
(Codex P2 on passkey-register-external/page.tsx)
- Effect now attaches a .catch that surfaces the error to the status state
  instead of leaving the spinner stuck if runPasskeyRegisterExternalCeremony
  rejects (e.g. browser offline, fetch throw).

**Cleanup — ceremony helper typing**
(CodeRabbit Nit on runPasskeyRegisterExternalCeremony.ts)
- Derived `RegisterOptionsJSON` from `Parameters<typeof startRegistration>`
  and typed the parsed options directly so startRegistration no longer needs
  a double `as unknown as` cast.

**Test hygiene**
(CodeRabbit Nit on PasskeyManager.desktop.test.tsx)
- Replaced a raw `setTimeout(10)` with a `waitFor` on an observable
  assertion.

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

* fix(auth): tighten Bearer check + surface parse errors in options route

Addresses a second round of review findings on PR #1012:

**CSRF bypass must require the Bearer scheme, not any Authorization header**

The three routes touched by this PR (`register/handoff`, `register/options`,
`register`) previously skipped CSRF whenever `authorization` was present, which
would let a non-Bearer scheme (`Basic`, `Digest`, random garbage) bypass CSRF
even though `authenticateSessionRequest` would reject it upstream. Now each
route uses `getBearerToken(req)` (already the canonical scheme check in
`@/lib/auth`, previously private) and only skips CSRF when the header actually
carries a `Bearer ` prefix. `getBearerToken` is now exported as a named symbol.

Regression tests added to all three route suites: an `Authorization: Basic …`
header with a bad `x-csrf-token` must still return 403.

**`options/route.ts` must surface malformed JSON as 400, not fall through**

`readOptionalJson` previously caught `JSON.parse` errors and returned `{}`,
which meant a syntactically invalid body would silently engage the session
path instead of returning a controlled 400 (mismatched with `register/route.ts`
behavior). The helper no longer catches `SyntaxError`; the caller wraps the
call in a try/catch and returns a 400 `{ error: 'Invalid request body' }`.
Empty body still resolves to `{}` (so callers without a body still reach the
session path), and non-object JSON (`null`, arrays, primitives) still
normalizes to `{}`.

The existing "treats malformed JSON as empty body and falls through" test is
inverted to "returns 400 (not 500, not session fallthrough) when body is
malformed JSON" and now also asserts `authenticateSessionRequest` is never
called.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

This branch was previously deployed

1 inactive deployment
Preview — 78d8b4e7 Deployed Apr 14, 2026 by vercel[bot]
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