Skip to content

fix(mobile): refresh the expired session instead of losing the capture - #318

Merged
jayesh-keychain merged 1 commit into
mainfrom
fix/mobile-token-refresh
Jul 31, 2026
Merged

fix(mobile): refresh the expired session instead of losing the capture#318
jayesh-keychain merged 1 commit into
mainfrom
fix/mobile-token-refresh

Conversation

@jayesh-keychain

Copy link
Copy Markdown
Collaborator

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:

  1. The refresh token was WRITE-ONLY — persisted to SecureStore at sign-in, deleted at sign-out, never sent. No /auth/refresh call and no 401 handler existed anywhere in apps/mobile, so 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 — not stranded in the composer, gone.
  3. request threw a bare Error, 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); captureVoice gets it explicitly since it bypasses request (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; ApiError carries the status so the message is honest.

Verification — incomplete, stated plainly

  • pnpm --filter @thebrain/mobile type-check and lint pass
  • Original failure reproduced: emulator gave 401 /api/memory/capture while the admin web client got 200 /api/auth/refresh on the same server — isolating the defect to the mobile client
  • NOT verified on-device after the fix. The capture control is a gesture surface; an adb swipe registers as hold-to-talk, so the app entered voice recording (logcat floods pcm_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

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>
@jayesh-keychain
jayesh-keychain merged commit e03ae3f into main Jul 31, 2026
6 checks passed
@jayesh-keychain
jayesh-keychain deleted the fix/mobile-token-refresh branch July 31, 2026 21:08
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>
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>
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