fix(mobile): refresh the expired session instead of losing the capture - #318
Merged
Conversation
Capture stopped working 15 minutes after sign-in and destroyed the user's
text when it did — the worst possible failure for a capture-first app.
Three defects compounded:
1. The refresh token was WRITE-ONLY. AuthContext persisted it to SecureStore
at sign-in and deleted it at sign-out, but nothing ever sent it: there was
no /auth/refresh call anywhere in apps/mobile and no 401 handler. Once the
access token aged past JWT_ACCESS_TTL (900s) every authenticated call
returned 401 forever, recoverable only by signing out and back in.
2. `sendText` cleared the draft BEFORE awaiting the request, so any failure
discarded the text the user had just written. It was not stranded in the
composer — it was gone.
3. `request` threw a bare Error, so a 401 and a flat network were
indistinguishable and both rendered as "Send failed — try again". Advice
that could not work: retrying never refreshed anything.
The fix:
- `request` retries EXACTLY once behind a 401, after asking the auth context
to refresh. One retry, not a loop: refresh tokens are single-use and
rotated server-side, so a retry loop would burn tokens and mask real auth
failures. `getToken()` is re-read per attempt so the replay carries the
new token.
- `captureVoice` bypasses `request` (native multipart uploader), so it gets
the same one-shot re-auth explicitly — otherwise voice captures would keep
dying while text captures recovered. Replay is safe because both attempts
reuse the same idempotencyKey.
- Refresh is SINGLE-FLIGHT. With rotation, two parallel 401s refreshing
independently would spend the same token twice; the loser replays a spent
token and signs the user out mid-capture. All callers await one promise.
- The rotated refresh token replaces the stored one — keeping the old one
would make the NEXT refresh replay a spent token.
- Only a genuine auth rejection (401/403) signs out. A network failure keeps
the session: being briefly offline must not destroy the tokens needed to
recover when connectivity returns.
- The draft is cleared only after the server has the memory; on failure the
composer REOPENS with the text intact, one tap from resending.
- `ApiError` carries the HTTP status so the UI distinguishes "Session
expired — sign in again" from "Send failed — your text is safe".
Verification — stated precisely, because it is incomplete:
`pnpm --filter @thebrain/mobile type-check` and `lint` both pass. The failing
behaviour was reproduced first: with the emulator's stored token expired, the
API log showed `401 /api/memory/capture`, while the admin web client's
`200 /api/auth/refresh` on the same server proved the endpoint and the stored
token were fine — isolating the defect to the mobile client.
I could NOT drive the post-fix capture end-to-end on the emulator: the capture
control is a gesture surface, and an adb swipe registers as hold-to-talk, so
the app entered voice recording (logcat floods `pcm_writei failed` — no mic on
the emulator) rather than opening the text composer. So the refresh-then-retry
path has NOT been exercised against a live 401 on-device. It needs a manual
pass, or a test seam that lets the composer open without a gesture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 1, 2026
Open
jayesh-keychain
added a commit
that referenced
this pull request
Aug 1, 2026
…323) (#329) `msUntilRefresh` clamped to 0: Math.max(0, exp * 1000 - skewSeconds * 1000 - nowMs) Whenever a token's remaining life was shorter than the 60s skew, that returned 0, the scheduler called `setTimeout(…, 0)`, the refresh minted another equally short-lived token, and the next delay was 0 again. Not a retry — an unbounded tight loop. Measured against a dev stack with JWT_ACCESS_TTL=15: ~180 requests/second, 32,915 calls to /auth/refresh in a few minutes, every one returning 200, and single-use rotation churning a refresh-token row each time. Two changes, both about never scheduling zero: - MIN_REFRESH_DELAY_MS floor (5s). A pathological case is now slow instead of hot, whatever the arithmetic does. - The skew ADAPTS: `min(60s, remainingLife / 2)`. Reserving a fixed 60s of a 15s token is meaningless — it marks every refresh permanently overdue, which is what starts the loop. Half the remaining life always leaves real time on the clock. Production timing is UNCHANGED: a 900s token still refreshes at 840s. Only lifetimes at or under the skew window move. CORRECTION to the issue as filed: #323 claimed this bites in production ~15 minutes after sign-in at JWT_ACCESS_TTL=900. That is wrong. At 900s the scheduler fires once at 840s and reschedules normally — no loop. The loop requires a TTL at or below the 60s skew, which I had set myself while testing #318. It is a real robustness defect (any short TTL, clock skew, or a token already near expiry self-DoSes) but NOT the production incident described. The issue is being corrected and downgraded from P0. An existing test asserted `toBe(0)` for a 30s token — it encoded the buggy contract, so it is updated rather than worked around: that token now refreshes at 15s. Five regression tests cover the loop condition, the adaptive skew, an expired token and an unparseable one. 13 tests pass; type-check and lint green. Closes #323. Co-authored-by: Jayesh Bhade <jayeshbhade@Jayeshs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2 tasks
jayesh-keychain
added a commit
that referenced
this pull request
Aug 1, 2026
) First increment of #321. The mobile app implements one of the four verbs: it calls only /auth/login, /memory/capture and the two /notifications endpoints, with NO reference to /memory/ask anywhere. A user can put memories in from their phone but cannot ask anything of them. This adds the transport only — `api.ask()` over the existing `request`, which means it inherits the #318 one-shot refresh-and-retry for free: a question asked more than JWT_ACCESS_TTL after sign-in re-authenticates transparently instead of being lost, exactly like capture now does. The contract worth stating at the seam, because getting it wrong is the obvious way to build this badly: A GROUNDED MISS IS NOT AN ERROR. When retrieval finds nothing above the §05.6 miss band the server still returns 200 with an honest answer, an empty `citations` array and a floored `confidence`. The UI must render that as "no memory about this", never as a failure — only a thrown ApiError is a real failure. The comment says so at the call site so the screen author does not have to infer it. NOT included — the UI and the gesture, which are the bulk of #321: - an ask surface (query input, answer, citations, loading + miss states) - the horizontal swipe that switches modes The axis matters and is recorded in #321: `app/index.tsx` already drives BOTH hold-to-talk and slide-up-to-type from a single VERTICAL Pan, so mode switching must be horizontal or it will fight capture. That is not theoretical — adb vertical swipes during testing repeatedly started voice recordings instead of opening the composer. type-check and lint green. No behaviour change: nothing calls `ask` yet. Refs #321 (does not close it). Co-authored-by: Jayesh Bhade <jayeshbhade@Jayeshs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Capture stopped working 15 minutes after sign-in and destroyed the user's text when it did — the worst possible failure for a capture-first app.
Three compounding defects:
/auth/refreshcall and no 401 handler existed anywhere inapps/mobile, so once the access token aged pastJWT_ACCESS_TTL(900s) every authenticated call returned 401 forever, recoverable only by signing out and back in.sendTextcleared the draft BEFORE awaiting the request, so any failure discarded the text — not stranded in the composer, gone.requestthrew a bareError, so a 401 and a dead network both rendered as "Send failed — try again" — advice that could not work.Fix: one-shot refresh-and-retry behind a 401 (one retry, not a loop — refresh tokens are single-use rotated);
captureVoicegets it explicitly since it bypassesrequest(replay safe via the shared idempotency key); refresh is SINGLE-FLIGHT so parallel 401s can't spend the same token twice; the rotated token replaces the stored one; only a genuine 401/403 signs out, never a network blip; the draft survives failure and the composer reopens with it;ApiErrorcarries the status so the message is honest.Verification — incomplete, stated plainly
pnpm --filter @thebrain/mobile type-checkandlintpass401 /api/memory/capturewhile the admin web client got200 /api/auth/refreshon the same server — isolating the defect to the mobile clientpcm_writei failed— no mic on the emulator) instead of opening the text composer. The refresh-then-retry path has not been exercised against a live 401.Needs a manual pass (type something, wait >15 min, send) or a test seam that opens the composer without a gesture. Merging on the author's explicit instruction with this gap open.
🤖 Generated with Claude Code