fix(front): retry a call signed out once the refresh is refused #558

Merged
root merged 1 commit from fix/stale-session-public-read into main 2026-10-19 15:21:34 +00:00
root commented 2026-10-19 15:07:00 +00:00 (Migrated from gitlab.core-project.space)

Summary

A browser left with a stale __Host-access cookie opened /games/abc123 on the router's error screen ("Something went wrong!") instead of Game not found. The API was right: it refuses a token it cannot accept, even on an optional read, so the front refreshes (ADR 0030). The refused refresh clears both cookies, but the client rethrew the first UNAUTHORIZED instead of retrying. The malformed id was a red herring: whichever call ran first after the cookie went stale failed, and every later one read anonymously. That is why the UUID link looked fine.

 apiClient interceptor
   call → UNAUTHORIZED
   refreshedOnce()
-  refused → sessionLost; rethrow UNAUTHORIZED
+  refused → sessionLost (cookies now cleared)
+  retry once, signed out  → public read answers; a Session-only call is still refused
   renewed → retry once → still UNAUTHORIZED → sessionLost

ADR 0030 already says "calls the refresh procedure once per tab at a time, then retries the call once". The client now does that whatever the refresh answers.

use-room-changes had its own copy of this ("A refused refresh has cleared the Session's cookies, so one more stream opens signed out"). With the client retrying, that copy doubled the reopen (5 calls instead of 3), so it is removed.

Evidence

  • Before: GET /rpc/games/abc123 401 → POST /rpc/session/refresh 401 → error screen. This happened in 4 of 4 runs on the dev stack, using a Playwright script that seeds a stale access cookie.
    After: GET /rpc/games/abc123 401 → POST /rpc/session/refresh 401 → GET /rpc/games/abc123 404 → Game not found, also in 4 of 4 runs.
  • New route test game-page-route.test.tsx: "mounts the not-found page for a browser left with a Session the refresh cannot save". It fails on main and passes here. The games fake server gains a staleSession option for it.
  • New tests in session-refresh.test.tsx: a public read answers anonymously after a refused refresh, and account.export is still refused UNAUTHORIZED.
  • Two existing assertions changed because they pinned the old rethrow. In session-refresh the account.me count goes from 1 to 2. In route-guards and session-refresh, game.list now resolves instead of rejecting. Both still check that the Player is shown signed out.
  • The full gate passes: lint, typecheck, test, build and format:check.

Merge Danger

Door: two-way

Blast Radius: auth

Question for the owner: every call is now retried once after a refused refresh, writes included. A write that accepts a Guest, such as creating a Room, now succeeds as the browser's Guest. Before, it failed with UNAUTHORIZED when the Player believed they were still signed in. The UI flips to signed out at the same moment, and ADR 0034/0035 let that Guest's Games be claimed later. If you would rather keep writes failing, the retry could be limited to reads (GET procedures). I kept the ADR's wording as it stands.

A smaller change: the Room stream used to reopen after its 2 s pause. The client now retries at once, inside the same open. An UNAUTHORIZED raised mid-stream, after the stream opened, now ends the following instead of reopening it. Auth is only checked when the stream opens, so this should never happen.

## Summary A browser left with a stale `__Host-access` cookie opened `/games/abc123` on the router's error screen ("Something went wrong!") instead of **Game not found**. The API was right: it refuses a token it cannot accept, even on an optional read, so the front refreshes (ADR 0030). The refused refresh clears both cookies, but the client rethrew the first `UNAUTHORIZED` instead of retrying. The malformed id was a red herring: whichever call ran first after the cookie went stale failed, and every later one read anonymously. That is why the UUID link looked fine. ```diff apiClient interceptor call → UNAUTHORIZED refreshedOnce() - refused → sessionLost; rethrow UNAUTHORIZED + refused → sessionLost (cookies now cleared) + retry once, signed out → public read answers; a Session-only call is still refused renewed → retry once → still UNAUTHORIZED → sessionLost ``` ADR 0030 already says "calls the refresh procedure once per tab at a time, then retries the call once". The client now does that whatever the refresh answers. `use-room-changes` had its own copy of this ("A refused refresh has cleared the Session's cookies, so one more stream opens signed out"). With the client retrying, that copy doubled the reopen (5 calls instead of 3), so it is removed. ## Evidence - **Before:** `GET /rpc/games/abc123 401` → `POST /rpc/session/refresh 401` → error screen. This happened in 4 of 4 runs on the dev stack, using a Playwright script that seeds a stale access cookie. **After:** `GET /rpc/games/abc123 401` → `POST /rpc/session/refresh 401` → `GET /rpc/games/abc123 404` → **Game not found**, also in 4 of 4 runs. - New route test `game-page-route.test.tsx`: "mounts the not-found page for a browser left with a Session the refresh cannot save". It fails on `main` and passes here. The games fake server gains a `staleSession` option for it. - New tests in `session-refresh.test.tsx`: a public read answers anonymously after a refused refresh, and `account.export` is still refused `UNAUTHORIZED`. - Two existing assertions changed because they pinned the old rethrow. In `session-refresh` the `account.me` count goes from 1 to 2. In `route-guards` and `session-refresh`, `game.list` now resolves instead of rejecting. Both still check that the Player is shown signed out. - The full gate passes: lint, typecheck, test, build and format:check. ## Merge Danger **Door:** two-way **Blast Radius:** auth Question for the owner: **every** call is now retried once after a refused refresh, writes included. A write that accepts a Guest, such as creating a Room, now succeeds as the browser's Guest. Before, it failed with `UNAUTHORIZED` when the Player believed they were still signed in. The UI flips to signed out at the same moment, and ADR 0034/0035 let that Guest's Games be claimed later. If you would rather keep writes failing, the retry could be limited to reads (GET procedures). I kept the ADR's wording as it stands. A smaller change: the Room stream used to reopen after its 2 s pause. The client now retries at once, inside the same open. An `UNAUTHORIZED` raised mid-stream, after the stream opened, now ends the following instead of reopening it. Auth is only checked when the stream opens, so this should never happen.
project_1_bot_a8dd9262857d850eef8dca5206e61de5 commented 2026-10-19 15:12:52 +00:00 (Migrated from gitlab.core-project.space)

Screenshots

Before is production, after is this merge request's review app at 1662221.

Desktop

Page Before After
Home
Local game
Room

Mobile

Page Before After
Home
Local game
Room
<!-- chess-screenshots --> ### Screenshots Before is production, after is this merge request's review app at 1662221. #### Desktop | Page | Before | After | | --- | --- | --- | | Home | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/before/home-desktop.png" width="400"> | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/after/home-desktop.png" width="400"> | | Local game | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/before/local-desktop.png" width="400"> | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/after/local-desktop.png" width="400"> | | Room | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/before/room-desktop.png" width="400"> | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/after/room-desktop.png" width="400"> | #### Mobile | Page | Before | After | | --- | --- | --- | | Home | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/before/home-mobile.png" width="200"> | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/after/home-mobile.png" width="200"> | | Local game | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/before/local-mobile.png" width="200"> | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/after/local-mobile.png" width="200"> | | Room | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/before/room-mobile.png" width="200"> | <img src="https://gitlab.core-project.space/amaurycoudr/chess-project/-/jobs/2088/artifacts/raw/review-screenshots/after/room-mobile.png" width="200"> |
root commented 2026-10-19 15:15:09 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #458

mentioned in issue #458
root (Migrated from gitlab.core-project.space) scheduled this pull request to auto merge when all checks succeed 2026-10-19 15:16:26 +00:00
root (Migrated from gitlab.core-project.space) merged commit into main 2026-10-19 15:21:34 +00:00
Sign in to join this conversation.
No description provided.