Skip to content

fix(club): keep club devices in their club past a day between refreshes - #145

Draft
ShuhaoZQGG wants to merge 1 commit into
devfrom
fix/club-token-refresh-grace
Draft

ShuhaoZQGG wants to merge 1 commit into
devfrom
fix/club-token-refresh-grace

Conversation

@ShuhaoZQGG

Copy link
Copy Markdown
Contributor

No issue. Found live in the pro-launch test run (test-runs/2026-11-13-pro-launch/run.md, local).

Why

A club device leaves its club the first time it opens the app more than 24 hours after its last club refresh. A club that meets weekly would lose its club on every device every week. That defeats the main point of "one Pro account for your whole club". The bug has been on master since 792efa1 (2026-09-27). Production has no clubs yet, so nobody is hurt today. It has to land before clubs exist.

Cause:

  • A club token lives 24 h (CLUB_TOKEN_TTL_MS). Only activate/create and GET /api/club mint one.
  • The client refreshes only when its last refresh is at least 24 h old (CLUB_REFRESH_INTERVAL_MS), so the token it sends is nearly always expired.
  • GET /api/club verified the token strictly and answered 401.
  • refreshClub treats 401 as "credential spent" and calls leaveClub().

Seen live on dev: GET /api/club 401 then POST /api/club/leave 401 at 02:25:09Z on 2026-11-14, 25 h after that Zoom device's last refresh.

What changes

  • GET /api/club accepts a correctly signed token for up to 180 days past its exp and re-mints it (CLUB_TOKEN_REFRESH_GRACE_MS). No other route does. This is safe on this route because it reads the club record and the device record before it mints anything, so:
    • a revoked device still gets 403,
    • a device that left (row deleted) still gets 403,
    • a deleted club still gets 404,
    • a lapsed club still lands on this successful refresh, as before.
  • Every other club route keeps strict 24 h expiry. A revoked device can no longer renew its token, so what it can still read stays bounded by 24 h. The unit tests show leave and the speech upload refusing the same expired token.
  • Why 180 days and not ~90: a club's timing laptop can sit unused through a summer recess, and many clubs break from June to September, which can exceed 90 days. The grace adds no reach that revocation does not already control, because this route checks the device and club records before minting anything.
  • GET /api/club answers 503, not 401, when SESSION_SIGNING_KEY is missing. Without the key every token reads as forged, and a 401 would make every device in every club leave.
  • Client: no behaviour change; it still leaves on 401/403/404. After this fix a 401 means a token the server will never renew (forged, or more than 180 days past its exp). Keeping it would leave the cached Pro plan on with no refresh ever able to lapse it. The comment in refreshClub now says this.
  • Code: verifyClubToken(token, secret, now, { graceMs }) and readClub(request, env, { graceMs, now }). Both default to strict.

Security

  • The only widening is expiry on GET /api/club. The signature check is unchanged, so a forged or foreign-key token is still refused inside the grace (unit-tested, and checked live: a tampered signature gets 401).
  • Revocation, code rotation (which revokes every device) and club deletion are enforced on that route from KV before anything is minted.
  • A leaked token that was never refreshed can be traded for a fresh one for up to 180 days while its device row is active. Before this change, refreshing it once a day achieved the same. Revoking the device stops it either way.
  • No new routes, and no new data exposed.

Production impact

  • Master users: no club exists yet, so there is no visible change today. Once clubs exist, devices stay in their club across days and weeks between meetings.
  • No flag, no migration, no KV change. Old client bundles benefit with no update, because the fix is server-side.
  • Rollback: revert. Devices more than 24 h past their last refresh would then leave on next open again, which is the current behaviour.

Testing

  • npm test: 116 files, 2288 tests passed.
  • npm run test:e2e: 44 passed.
  • npm run test:specs: 8 specs valid. The pre-push drift check passed.
  • New unit tests:
    • worker/club-token.test.js: grace accepts an expired token only when asked; refuses at the end of the window; still refuses forged and foreign-key tokens; nonsense grace values count as none.
    • worker/club.test.js: a token a week old is renewed and re-minted with the right claims; 200 just inside the grace window and 401 at its edge; a revoked device gets 403, a deleted club 404, a departed device 403, all with an expired token; leave and the speech upload refuse the expired token; 503 without a signing key.
    • packages/shared/__tests__/club.test.js: a device a week past its last refresh stays in the club on the renewed token; 401 and 404 still leave; a 503 keeps the club.
    • Without the server change, 4 of the new worker/club.test.js tests fail. I checked this before committing.
  • Oracle: none (not a factory issue).

Evidence (real Zoom client)

Run log: test-runs/2026-11-13-club-token-refresh-grace/run.md (local). This branch was deployed to dev as version 766d77cf, with FLAGS_FORCE "1" set locally and ENTITLEMENT_ENFORCE "1" as committed.

  • New step club-card-sets CC-10, run on B (the web app in the built-in browser pane, guest club device):
    • Setup: B was staged with its own earlier token, which had expired 81 min before, and lastRefreshAt 25.35 h old. That is the state that made the Zoom device leave in the pro-launch run.
    • Opening /timer/app sent GET /api/club, which answered 200. There was no POST /api/club/leave.
    • B stayed in Claude Test Club: ver 14, Pro, the "Pro ends in 316 days" banner and the club badge on the stage.
    • toastmaster_club.clubToken was replaced by a re-minted token for the same device (iat 02:53:09Z, exp +24 h).
    • Shell replay of the same expired token: 200. With the signature tampered: 401. With no token: 401. All three are in the Worker tail.
  • club-card-sets.md now also covers worker/club.js and worker/club-token.js. No other spec covered them, and pro-club is not migrated yet.

Not verified

  • CC-10 on A, the Zoom desktop client. Nothing can set localStorage inside Zoom's webview. A rejoined the club at 2026-11-14 02:28:59Z, so its token is over 24 h old only after 2026-11-15 02:29Z. The spec is marked status: stale until that run. To run it, keep dev on this fix and open the timer on A after that time.
  • Warning: dev currently runs this branch. If dev is redeployed without the fix before A is opened after that time, A leaves the club again.
  • A token more than 180 days past its exp is unit-tested only.

Look here first

  • worker/club.js handleClubState: the grace is applied here and nowhere else, and the 503 guard runs before it.
  • worker/club-token.js: CLUB_TOKEN_REFRESH_GRACE_MS and the exp + grace check in verifyClubToken.
  • packages/shared/club.js refreshClub: why the client still leaves on 401.

Related, not fixed here:

  • drainOutbox runs at app start alongside refreshClub, so its first upload still carries the old, expired token.
  • The speech upload reads the token strictly and answers 401, and the drain drops every non-5xx response.
  • So a speech queued offline that is still waiting a day later is lost on the next app start. This needs its own fix.

A club device refreshes at most once a day and its club token lives 24 h,
so the token it sends to GET /api/club is nearly always already expired.
The route answered 401, and the client treats 401 as "credential spent"
and leaves the club. A club that meets weekly lost every device every week.

GET /api/club now takes a correctly signed token up to 180 days past its
exp (CLUB_TOKEN_REFRESH_GRACE_MS) and re-mints it. Nothing is minted until
the club and device records are read, so a revoked or departed device
still gets 403 and a deleted club 404. Every other club route keeps strict
expiry, so a revoked device's residual reads stay bounded by 24 h. The
route also answers 503 rather than 401 when SESSION_SIGNING_KEY is missing,
so a misconfigured deploy cannot empty every club.

The client still leaves on 401: after this change a 401 is a token the
server will never renew, and holding it would keep Pro on with no refresh
able to lapse it.

Adds club-card-sets CC-10 (passed on the web device against dev 766d77cf;
not yet run on the Zoom device, whose token is fresh) and covers
worker/club.js and worker/club-token.js from that spec.
@ShuhaoZQGG ShuhaoZQGG added the needs:zoom-test Factory: waiting for the real-Zoom station label Nov 14, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs:zoom-test Factory: waiting for the real-Zoom station

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant