Skip to content

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

Draft
ShuhaoZQGG wants to merge 4 commits into
devfrom
fix/club-token-refresh-grace
Draft

ShuhaoZQGG wants to merge 4 commits into
devfrom
fix/club-token-refresh-grace

Conversation

@ShuhaoZQGG

@ShuhaoZQGG ShuhaoZQGG commented Nov 14, 2026 •

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: 120 files, 2357 tests passed (on the branch merged with dev e28d733).
  • npm run test:e2e: 56 passed.
  • npm run test:specs: 13 specs valid; the pre-push drift check passed.
  • New unit tests:
    • worker/club-token.test.js: the 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 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). New step club-card-sets CC-10 passed on both devices.

  • A, the Zoom desktop client (dev 5b9a1545 = 3262d37, FLAGS_FORCE "1" set locally, ENTITLEMENT_ENFORCE "1" as committed):
    • A opened the timer at 2026-11-15 02:30:47Z, 25 h after its last club refresh. The token it sent had expired 1 min 49 s earlier.
    • GET /api/club answered 200, and there was no POST /api/club/leave.
    • A kept the club badge on the stage, the club's role list, the Pro button, and "Claude Test Club cards" in Card Images.
    • After ⋯ → Refresh app, A's requests carried the re-minted token (iat 02:30:49Z, exp +24 h), and A made no second refresh.
    • Before the fix, the same open answered 401 and A left the club (pro-launch run, 2026-11-14 02:25:09Z).
  • B, the web app in the built-in browser pane (dev 766d77cf):
    • Setup: B was staged with its own token, expired 81 min earlier, and a last refresh 25.35 h old.
    • GET /api/club answered 200 and B stayed in the club on a re-minted token.
    • Shell replay of the same expired token: 200. With the signature tampered: 401. With no token: 401.
  • 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.
  • club-pro-gate and club-renewal-reminder also cover packages/shared/club.js. This PR only rewrites a comment there; I reviewed both specs and noted it in them.
  • Dev has since been restored to the dev tip e28d733 (version 15071666).

Not verified

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
@ShuhaoZQGG ShuhaoZQGG added zoom:testing Factory: the Zoom station holds the dev lock for this PR and removed zoom:testing Factory: the Zoom station holds the dev lock for this PR labels Nov 14, 2026
@ShuhaoZQGG

Copy link
Copy Markdown
Contributor Author

zoom — Waiting, not run (daily run, 2026-11-14 22:05Z). The only Zoom-client step this change affects is club-card-sets CC-10 on A (Zoom desktop). It needs A's club token past its 24 h, and A last refreshed at 2026-11-14 02:28:59Z, so it qualifies only after 2026-11-15 02:29Z. The diff changes only GET /api/club (the refresh), so CC-01…CC-09 (verified on 905d8d1 on 11-13) aren't affected and weren't rerun. Opening A now could refresh it and push CC-10 back another day. Nothing was deployed and Zoom wasn't opened. needs:zoom-test stays, so the next daily run (2026-11-15 17:00) picks this up: deploy this branch, open the timer on A, expect GET /api/club → 200, no POST /api/club/leave, banner and badge kept. Please don't open the dev Timer on A in Zoom before then. If you do, A refreshes (on the old Worker it leaves the club) and CC-10 waits another 24 h.

…-grace

# Conflicts:
#	e2e/zoom-client/INDEX.md
…nst the refresh grace

The fix only rewrites the comment on refreshClub's 401/403/404 branch in
packages/shared/club.js; neither the Pro gate nor the renewal reminder
changes. The behaviour it does change is club-card-sets CC-10.
A (Zoom desktop) opened 25 h after its last club refresh with an expired
token: GET /api/club 200, no leave, club badge and card set kept, and its
next requests carried the re-minted token. Only CC-10 ran, so verified_at
stays at the last full run.
@ShuhaoZQGG ShuhaoZQGG removed the needs:zoom-test Factory: waiting for the real-Zoom station label Nov 15, 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant