fix(client): complete Android OAuth on cold start (#300) #383

Merged
james merged 1 commit from 300-oauth-cold-start into main 2026-06-29 18:11:49 +00:00
Owner

Closes #300

PR #298 fixed the warm-path OAuth return, but a true cold start — the OS kills the app while the system browser is foreground — lost the in-memory waiter, so the return deep link was queued but never drained. The user landed back on /login unauthenticated.

What changed

  • lib/auth/oauthDeepLink.ts: drainOauthDeepLink() — an atomic read-and-clear of the single pending slot.
  • lib/auth/oauthColdStart.ts (new): pure, node-testable completeOauthColdStart with every side effect injected (drain, POST /api/auth/token, store tokens).
  • app/_layout.tsx: the existing Linking.getInitialURL() handler now runs the cold-start completion and, on success, mirrors login.tsx (invalidate auth.merouter.replace("/chat")). Runs only on the getInitialURL branch, never on the warm addEventListener path.

Idempotency

A live warm waiter consumes the payload inline (pending stays null → drain no-ops); a cold start has no waiter (pending is set → drain + exchange exactly once). The init token is drained before the network call, so a failed exchange leaves nothing wedged. login.tsx (warm path) is deliberately untouched and is never double-exchanged.

Verification

  • typecheck / lint clean, 182 tests pass (incl. 6 new cold-start + 4 drain/idempotency), semgrep 0.
  • Cannot be verified headlessly — needs an Android device. Repro: Developer options → enable "Don't keep activities" → sign in via OAuth → expect the cold relaunch to land on /chat signed in. Re-confirm the warm path is not double-exchanged.

Merge note

Independent of #236/#239 — touches only the OAuth files + _layout.tsx (not login.tsx or server-setup.tsx). No conflicts.

🤖 Generated with Claude Code

Closes #300 PR #298 fixed the **warm**-path OAuth return, but a true **cold start** — the OS kills the app while the system browser is foreground — lost the in-memory waiter, so the return deep link was queued but never drained. The user landed back on `/login` unauthenticated. ## What changed - **`lib/auth/oauthDeepLink.ts`**: `drainOauthDeepLink()` — an atomic read-and-clear of the single pending slot. - **`lib/auth/oauthColdStart.ts`** (new): pure, node-testable `completeOauthColdStart` with every side effect injected (drain, POST `/api/auth/token`, store tokens). - **`app/_layout.tsx`**: the existing `Linking.getInitialURL()` handler now runs the cold-start completion and, on success, mirrors `login.tsx` (invalidate `auth.me` → `router.replace("/chat")`). Runs **only** on the `getInitialURL` branch, never on the warm `addEventListener` path. ## Idempotency A live warm waiter consumes the payload inline (pending stays `null` → drain no-ops); a cold start has no waiter (pending is set → drain + exchange exactly once). The init token is drained **before** the network call, so a failed exchange leaves nothing wedged. `login.tsx` (warm path) is deliberately untouched and is never double-exchanged. ## Verification - typecheck / lint clean, 182 tests pass (incl. 6 new cold-start + 4 drain/idempotency), semgrep 0. - **Cannot be verified headlessly — needs an Android device.** Repro: Developer options → enable "Don't keep activities" → sign in via OAuth → expect the cold relaunch to land on `/chat` signed in. Re-confirm the warm path is not double-exchanged. ## Merge note Independent of #236/#239 — touches only the OAuth files + `_layout.tsx` (not `login.tsx` or `server-setup.tsx`). No conflicts. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(client): complete Android OAuth on cold start (#300)
All checks were successful
Secrets / gitleaks (pull_request) Successful in 25s
Commits / Conventional Commits (pull_request) Successful in 32s
PR / Test (postgres) (pull_request) Successful in 4m57s
PR / Coverage (soft) (pull_request) Successful in 4m22s
PR / Test (sqlite) (pull_request) Successful in 4m36s
PR / E2E (Playwright) (pull_request) Successful in 5m25s
PR / Lint (pull_request) Successful in 2m10s
PR / OpenAPI (pull_request) Successful in 2m20s
PR / Static analysis (pull_request) Successful in 2m14s
PR / Trivy (image) (pull_request) Successful in 5m6s
PR / Typecheck (pull_request) Successful in 3m23s
PR / Client (web export smoke) (pull_request) Successful in 3m2s
PR / Build (pull_request) Successful in 3m59s
PR / OSV-Scanner (pull_request) Successful in 2m23s
PR / pnpm audit (pull_request) Successful in 2m34s
PR / Package age policy (soft) (pull_request) Successful in 1m17s
fe806eee31
PR #298 fixed the warm-path return, but a true cold start (OS kills the
app while the system browser is foreground) lost the in-memory waiter, so
the return deep link was queued but never drained — the user landed on
/login unauthenticated.

Drain the deep-link queue at bootstrap, independent of any login.tsx
waiter:
- lib/auth/oauthDeepLink.ts: drainOauthDeepLink() — an atomic read-and-clear
  of the single pending slot.
- lib/auth/oauthColdStart.ts (new): pure, node-testable completeOauthColdStart
  with all side effects injected (drain, POST /api/auth/token, store tokens).
- app/_layout.tsx: the existing getInitialURL() handler runs the cold-start
  completion and, on success, mirrors login.tsx (invalidate auth.me →
  router.replace("/chat")). Runs ONLY on the getInitialURL branch.

Idempotent: a live warm waiter consumes inline (pending stays null → drain
no-ops); cold start has no waiter (pending set → drain + exchange once).
The init token is drained before the network call, so a failed exchange
leaves nothing wedged. Warm path (login.tsx) untouched, not double-exchanged.

Unit tests: queued payload → exactly one exchange; empty → no-op; warm +
bootstrap don't double-exchange; failed exchange leaves no half-consumed token.

Closes #300

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 79.6% ≥ 50%
Branches 71.5% ⚠️ ≥ 75%
Functions 80.8% 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 | 79.6% ✅ | ≥ 50% | | Branches | 71.5% ⚠️ | ≥ 75% | | Functions | 80.8% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 18f8ab458f into main 2026-06-29 18:11:49 +00:00
james deleted branch 300-oauth-cold-start 2026-06-29 18:11:50 +00:00
Sign in to join this conversation.
No description provided.