fix(client): WHATWG URL polyfill + native-intent for Android OAuth return #298

Merged
james merged 1 commit from 297-android-oauth-url-polyfill into main 2026-06-26 22:40:12 +00:00
Owner

Android OAuth sign-in/link returned to a bare deep link carol:/// and Expo Router showed its "Unmatched Route" 404; sign-in never completed.

Root cause

React Native's built-in global URL (Libraries/Blob/URL.js) hardcodes https?:// in its host/pathname regexes (lines 131, 158). No react-native-url-polyfill was installed and SDK 56's winter runtime is absent, so RN's implementation is authoritative. On-device:

new URL("carol://auth/oauth/complete?init=…&code=…") → host="" pathname="/"

That empties the return deep link and breaks both consumers:

  1. Token exchange never runs — the parsers gate on `${host}${pathname}` (lib/auth/oauthDeepLinkParse.ts:32-33) → """auth/oauth/complete" → null, so the awaiting native flow never gets {init, code} and never calls POST /api/auth/token.
  2. expo-router 404s — empty route → Unmatched, whose label is createURL("/") = carol:///.

Masked in CI because vitest runs on Node's spec-compliant URL.

Fix

  1. Add react-native-url-polyfill@^2.0.0; install it on native only (web keeps the browser's native URL) at the top of app/_layout.tsx, before any deep-link code runs — fixes the parsers and router extraction.
  2. Add app/+native-intent.ts (redirectSystemPath) to redirect the non-routable return deep links to real screens (carol://auth/oauth/complete…/login, carol://account/identities…/account), matched on the raw URL string. The Linking listener still drains the queue and completes the exchange.

Verification

  • pnpm -F @carol/client typecheck — passes
  • pnpm -F @carol/client lint — passes
  • Device verification requires a fresh Android build (native intent + new dep won't reload over JS): sign in with GitHub → returns to app, completes, lands on /notes; account linking → /account. No carol:///.

Known follow-up (separate ticket)

True Android cold-start completion (app killed during the browser handoff) still won't finish the exchange — the in-memory waiter is gone, so the queued {init, code} is never drained; the user lands cleanly on /login but must retry. Out of scope here.

Closes #297

🤖 Generated with Claude Code

Android OAuth sign-in/link returned to a bare deep link **`carol:///`** and Expo Router showed its "Unmatched Route" 404; sign-in never completed. ## Root cause React Native's built-in global `URL` (`Libraries/Blob/URL.js`) hardcodes `https?://` in its host/pathname regexes (lines 131, 158). No `react-native-url-polyfill` was installed and SDK 56's winter runtime is absent, so RN's implementation is authoritative. On-device: ``` new URL("carol://auth/oauth/complete?init=…&code=…") → host="" pathname="/" ``` That empties the return deep link and breaks **both** consumers: 1. **Token exchange never runs** — the parsers gate on `` `${host}${pathname}` `` (`lib/auth/oauthDeepLinkParse.ts:32-33`) → `""` ≠ `"auth/oauth/complete"` → null, so the awaiting native flow never gets `{init, code}` and never calls `POST /api/auth/token`. 2. **expo-router 404s** — empty route → `Unmatched`, whose label is `createURL("/")` = `carol:///`. Masked in CI because vitest runs on Node's spec-compliant `URL`. ## Fix 1. Add `react-native-url-polyfill@^2.0.0`; install it on **native only** (web keeps the browser's native URL) at the top of `app/_layout.tsx`, before any deep-link code runs — fixes the parsers and router extraction. 2. Add `app/+native-intent.ts` (`redirectSystemPath`) to redirect the non-routable return deep links to real screens (`carol://auth/oauth/complete…` → `/login`, `carol://account/identities…` → `/account`), matched on the raw URL string. The `Linking` listener still drains the queue and completes the exchange. ## Verification - `pnpm -F @carol/client typecheck` — passes - `pnpm -F @carol/client lint` — passes - Device verification requires a fresh Android build (native intent + new dep won't reload over JS): sign in with GitHub → returns to app, completes, lands on `/notes`; account linking → `/account`. No `carol:///`. ## Known follow-up (separate ticket) True Android **cold-start** completion (app killed during the browser handoff) still won't finish the exchange — the in-memory waiter is gone, so the queued `{init, code}` is never drained; the user lands cleanly on `/login` but must retry. Out of scope here. Closes #297 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): WHATWG URL polyfill + native-intent for Android OAuth return
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OpenAPI (pull_request) Successful in 2m28s
PR / Static analysis (pull_request) Successful in 2m33s
PR / Lint (pull_request) Successful in 3m0s
PR / Typecheck (pull_request) Successful in 3m24s
PR / Client (web export smoke) (pull_request) Successful in 3m35s
PR / OSV-Scanner (pull_request) Successful in 1m7s
PR / Test (sqlite) (pull_request) Successful in 3m31s
PR / Build (pull_request) Successful in 3m49s
PR / Test (postgres) (pull_request) Successful in 4m3s
PR / pnpm audit (pull_request) Successful in 1m55s
PR / Package age policy (soft) (pull_request) Successful in 45s
Secrets / gitleaks (pull_request) Successful in 33s
PR / Coverage (soft) (pull_request) Successful in 1m43s
PR / Trivy (image) (pull_request) Successful in 8m16s
6a271aa3b2
React Native's built-in global URL (Libraries/Blob/URL.js) hardcodes
`https?://` in its host/pathname regexes, so on-device
`new URL("carol://auth/oauth/complete?…")` yields host="" pathname="/".
That empties the OAuth/link return deep links, which (1) makes the
deep-link parsers return null so the native token exchange never runs,
and (2) makes expo-router extract an empty route and render its Unmatched
404 (the `carol:///` screen). CI missed it because vitest runs on Node's
spec-compliant URL.

Install react-native-url-polyfill on native (web keeps the browser URL)
at the top of app/_layout.tsx before any deep-link code runs, and add
app/+native-intent.ts so expo-router redirects the non-routable return
deep links to real screens (oauth complete → /login, link complete →
/account) instead of 404ing. The Linking listener still drains the queue
and completes the exchange.

Cold-start completion (app killed during the browser handoff) remains a
known follow-up.

Closes #297

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

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 81.6% ≥ 50%
Branches 72.8% ⚠️ ≥ 75%
Functions 91.1% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 81.6% ✅ | ≥ 50% | | Branches | 72.8% ⚠️ | ≥ 75% | | Functions | 91.1% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 76a9d44232 into main 2026-06-26 22:40:12 +00:00
james deleted branch 297-android-oauth-url-polyfill 2026-06-26 22:40:12 +00:00
Sign in to join this conversation.
No description provided.