From 19561cd4b125fa071e88bb4c89096774739ec3e2 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Fri, 13 Nov 2026 21:55:26 -0500 Subject: [PATCH] fix(club): renew an expired club token on the daily refresh 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. --- e2e/zoom-client/INDEX.md | 2 +- e2e/zoom-client/club-card-sets.md | 35 ++++++++- packages/shared/__tests__/club.test.js | 44 +++++++++++ packages/shared/club.js | 9 ++- worker/auth.js | 8 +- worker/club-token.js | 32 +++++++- worker/club-token.test.js | 45 +++++++++++ worker/club.js | 16 +++- worker/club.test.js | 103 ++++++++++++++++++++++++- 9 files changed, 280 insertions(+), 14 deletions(-) diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 14fea04..35aac7b 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -12,7 +12,7 @@ Read this first. Update it in the same PR as any spec change. | [reauth-landing](reauth-landing.md) | stale | never | install landing: `tt_approved` mark, `?welcome=new` / `?welcome=back`, "You're reconnected" and Open in Zoom; a marked browser's bare `/` redirects to `/reconnect` (`/?home` and the `zoom.` host do not); where Zoom lands a guest after approving (#96) | | [contact-capture](contact-capture.md) | draft | never | Zoom email/name capture: authorize, "Stay in touch" card, browser and web sign-in doors, purge on uninstall (#83) | | [timer-sounds](timer-sounds.md) | draft | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans, audible through Share sound (#105) | -| [club-card-sets](club-card-sets.md) | current | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401) | +| [club-card-sets](club-card-sets.md) | stale | 905d8d1, 2026-11-13 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1); a device more than a day past its last refresh stays in the club (CC-10, also covers `worker/club.js` and `worker/club-token.js` until pro-club is migrated). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401). CC-10 passed on B (web) on 2026-11-14 and is not yet run on A in Zoom | | pro-club | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` P1–P7, plus the internal Club Pro plan (`06-zoom-client-test-plan-club-pro.md`); reference run `test-runs/2026-09-28-club-pro/run.md` | | webhooks-lifecycle | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 4, 5, 6 | diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md index a029f5b..615f8aa 100644 --- a/e2e/zoom-client/club-card-sets.md +++ b/e2e/zoom-client/club-card-sets.md @@ -1,9 +1,11 @@ --- id: club-card-sets title: Club card sets (share card artwork with the club) -status: current +status: stale covers: - worker/club-cards.js + - worker/club.js + - worker/club-token.js - packages/shared/clubCards.js - packages/shared/cardImages.js - packages/shared/club.js @@ -14,6 +16,9 @@ automated: - worker/club-cards.test.js - packages/shared/__tests__/clubCards.test.js - apps/zoom-app/src/components/CardImagesModal.test.jsx + - worker/club.test.js + - worker/club-token.test.js + - packages/shared/__tests__/club.test.js needs: - dev deployed - FLAGS_FORCE "1" on dev (the club doors are behind `pro`) @@ -36,7 +41,9 @@ looks the same as a personal one, and that the image arrives on a second machine ## Purpose Catches a published set that never reaches another club device, a club card that shows on the stage but not on the virtual background, a device whose own chosen cards are silently -replaced by the club's, and a lapsed or left club whose cards keep showing. +replaced by the club's, a lapsed or left club whose cards keep showing, and a club device that +drops out of the club (and its cards) the first time it opens more than a day after its last +refresh. ## Steps @@ -104,6 +111,22 @@ previous set until its next club refresh (see CC-07). Evidence: `PUT /api/club/cards` → 200; KV `club-cards:` names only the new hashes and `ver` went up by 1; `GET /api/club/cards/` with a club token → 404 (pruned). +### CC-10 · auto · A device a day or more past its last refresh stays in the club +Do: open the app on a club device whose last club refresh is over 24 h old, so its club token +(24 h) has expired. In Zoom (A): open the timer more than 24 h after A's last +`GET /api/club` (`lastRefreshAt` in `toastmaster_club`). On B (web), without waiting a day: put +one of B's own earlier club tokens (minted over 24 h ago, for example from an older run's +`tail.jsonl`) back in `toastmaster_club.clubToken`, set `lastRefreshAt` to its `iat`, and +reload `/timer/app`. +Expect: the device stays in the club. The Pro banner and the club badge stay, the plan stays +Pro, and the club's set is still in the picker (selected where it was). Before the fix this +refresh answered 401 and the device left the club. +Evidence: `GET /api/club` → 200 and no `POST /api/club/leave`. `toastmaster_club.clubToken` is +replaced by a new token for the same `deviceId` whose payload has `iat` ≈ now and +`exp` = `iat` + 24 h; `lastRefreshAt` ≈ now. The same expired token replayed with curl (`X-Club`) +→ 200; with one character of the signature changed → 401. A token more than 180 days past its +`exp` (`CLUB_TOKEN_REFRESH_GRACE_MS`) → 401, unit-tested only (worker/club.test.js). + ## Not covered - Sharing from the web timer: same shared module and endpoints, proven by unit tests. - Two publishers racing: the prune grace window and the client's retry are unit-tested. @@ -111,8 +134,16 @@ Evidence: `PUT /api/club/cards` → 200; KV `club-cards:` names only the - The Live tab refusing an agenda row that needs a role (#91, `LiveTab.jsx`). Reviewed 2026-11-13: it changes which speaker loads, not the card artwork or its push; no step here loads an agenda row. See [timer-core](timer-core.md) TC-13. +- A revoked device, or one that left, refused by the refresh even with an expired token: + unit-tested (worker/club.test.js), not run here. Run 2026-11-13 (dev tip 905d8d1, dev 3ce801a6, A = Zoom desktop, B = web app in a browser profile): CC-01…CC-05 and CC-09 passed; CC-02 now labels the set " cards" (null label in KV). CC-06 only from B, a guest with no Zoom session: 401, not the member 403. CC-07 (console sign-in) and CC-08 (lapsing the club) not run. Log: test-runs/2026-11-13-pre-master-specs/run.md. + +Run 2026-11-14 02:53Z (branch fix/club-token-refresh-grace, dev version 766d77cf, B = web app in +the built-in browser pane): CC-10 passed on B with B's own token, expired 81 min earlier and +25.35 h after its last refresh. Not yet run on A in Zoom: A rejoined at 2026-11-14 02:28:59Z, so +its token is over 24 h old only after 2026-11-15 02:29Z. Log: +test-runs/2026-11-13-club-token-refresh-grace/run.md. diff --git a/packages/shared/__tests__/club.test.js b/packages/shared/__tests__/club.test.js index 56a5452..c950a9c 100644 --- a/packages/shared/__tests__/club.test.js +++ b/packages/shared/__tests__/club.test.js @@ -307,6 +307,50 @@ describe('refreshClub', () => { expect(localStorage.getItem(CLUB_STORAGE_KEY)).toBeNull(); expect(getEntitlement()).toEqual(FREE_ENTITLEMENT); }); + + // The weekly club: the token it sends is six days past its 24 hours, and the + // Worker renews it. The device stays in the club on the new token. + it('stays in the club a week later, on the token the server renewed', async () => { + localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached({ lastRefreshAt: NOW }))); + initClubFromCache(); + const nextMeeting = NOW + 7 * CLUB_REFRESH_INTERVAL_MS; + const fetchImpl = respondWith({ clubToken: 'renewed.sig', ...clubState() }); + + const club = await refreshClub({ fetchImpl, now: nextMeeting }); + + expect(fetchImpl.mock.calls[0][1].headers).toMatchObject({ 'X-Club': 'tok.sig' }); + expect(club).toMatchObject({ clubToken: 'renewed.sig', lastRefreshAt: nextMeeting }); + expect(JSON.parse(localStorage.getItem(CLUB_STORAGE_KEY))).toMatchObject({ clubToken: 'renewed.sig' }); + expect(getEntitlement()).toMatchObject({ plan: 'pro', entitled: true, source: 'club' }); + // Nothing told the roster this device left. + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + // A 401 is a token the Worker will never renew (forged, or past its refresh + // grace) and a 404 is a club that is gone. Holding either would leave Pro on + // with no refresh ever able to lapse it. + it('forgets a credential the server will never renew, and a club that is gone', async () => { + for (const [status, error] of [[401, 'Unauthorized'], [404, 'club_not_found']]) { + localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached())); + resetClubForTests(); + initClubFromCache(); + + expect(await refreshClub({ fetchImpl: respondWith({ error }, { ok: false, status }), force: true })).toBeNull(); + expect(localStorage.getItem(CLUB_STORAGE_KEY)).toBeNull(); + expect(getEntitlement()).toEqual(FREE_ENTITLEMENT); + } + }); + + // A misconfigured Worker answers 503, and a 503 is the server's problem. + it('keeps the club when the refresh is not configured', async () => { + localStorage.setItem(CLUB_STORAGE_KEY, JSON.stringify(cached())); + initClubFromCache(); + + const club = await refreshClub({ fetchImpl: respondWith({ error: 'Club refresh is not configured' }, { ok: false, status: 503 }), force: true }); + + expect(club).toMatchObject({ clubToken: 'tok.sig' }); + expect(getEntitlement()).toMatchObject({ plan: 'pro', entitled: true }); + }); }); const DAY = 24 * 60 * 60 * 1000; diff --git a/packages/shared/club.js b/packages/shared/club.js index e81a2cb..2bfd7fc 100644 --- a/packages/shared/club.js +++ b/packages/shared/club.js @@ -887,8 +887,13 @@ export async function refreshClub({ getToken, fetchImpl, force = false, now = Da return club; } - // The device was revoked, or the club is gone. Either way this credential is - // spent, so stop sending it — but leave the rest of the device alone. + // The device was revoked (403), the club is gone (404), or the token is one + // the server will never renew (401: forged, or older than the Worker's + // CLUB_TOKEN_REFRESH_GRACE_MS). An *expired* token is not a 401 here: the + // refresh takes one long past its 24 hours, which is the only kind a device + // that refreshes once a day ever sends. Either way this credential is spent, + // so stop sending it — but leave the rest of the device alone. Holding a 401 + // token instead would keep the cached plan on with nothing left to lapse it. if (response.status === 401 || response.status === 403 || response.status === 404) { leaveClub(); return null; diff --git a/worker/auth.js b/worker/auth.js index edf217f..2c640dd 100644 --- a/worker/auth.js +++ b/worker/auth.js @@ -129,15 +129,19 @@ export function readSession(request, env) { * is needed — the club token travels in a custom header a cross-site form * cannot set, and it is never attached by the browser on its own. * + * `graceMs` is for the refresh alone (see CLUB_TOKEN_REFRESH_GRACE_MS). Every + * other route reads the token strictly. + * * @param {Request} request * @param {Object} env - SESSION_SIGNING_KEY + * @param {{graceMs?: number, now?: number}} [options] * @returns {{clubId: string, deviceId: string, uid: string|null, ver: number, * iat: number|null, exp: number}|null} */ -export function readClub(request, env) { +export function readClub(request, env, { graceMs = 0, now = Date.now() } = {}) { const token = readClubHeader(request); if (!token) return null; - return verifyClubToken(token, env.SESSION_SIGNING_KEY); + return verifyClubToken(token, env.SESSION_SIGNING_KEY, now, { graceMs }); } // --------------------------------------------------------------------------- diff --git a/worker/club-token.js b/worker/club-token.js index 6a8a9ad..a80fe06 100644 --- a/worker/club-token.js +++ b/worker/club-token.js @@ -21,6 +21,25 @@ import crypto from 'node:crypto'; */ const CLUB_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * How long past its `exp` a token can still be traded in at GET /api/club. + * + * The 24-hour TTL above bounds what a token buys on its own: every read that + * does not consult KV. The refresh is different. It reads the device record and + * the club record before it mints anything, so a revoked device (403), a device + * that left (403) and a deleted club (404) are refused there whatever the + * token's age. A strict 24 hours on that one route added no protection and cost + * a club its devices: a device refreshes at most once a day, so the token it + * sends is nearly always already expired. A club that meets weekly lost every + * device every week. + * + * 180 days rather than 90 because a club's timing laptop can sit in a drawer + * through a summer recess, and many clubs break from June to September. Past + * this the device has to type the code again. + */ +const CLUB_TOKEN_REFRESH_GRACE_MS = 180 * 24 * 60 * 60 * 1000; + const SEPARATOR = '.'; function base64url(buf) { @@ -70,14 +89,21 @@ export function mintClubToken({ clubId, deviceId, uid = null, ver = 1 } = {}, se * send on every request. Revocation is enforced separately, on the paths that * were already going to write (see worker/club.js). * + * `graceMs` accepts a token that expired up to that long ago. Only the refresh + * passes it (CLUB_TOKEN_REFRESH_GRACE_MS), because only the refresh checks the + * device and club records before it trusts the claims. Every other caller keeps + * the strict default of zero. + * * @param {string|null|undefined} token * @param {string|undefined} secret - SESSION_SIGNING_KEY * @param {number} [now] - epoch ms, injectable for tests + * @param {{graceMs?: number}} [options] * @returns {{clubId: string, deviceId: string, uid: string|null, ver: number, * iat: number|null, exp: number}|null} null whenever the token cannot be trusted */ -export function verifyClubToken(token, secret, now = Date.now()) { +export function verifyClubToken(token, secret, now = Date.now(), { graceMs = 0 } = {}) { if (!token || typeof token !== 'string' || !secret) return null; + const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0; const separatorAt = token.indexOf(SEPARATOR); if (separatorAt <= 0 || separatorAt === token.length - 1) return null; @@ -105,7 +131,7 @@ export function verifyClubToken(token, secret, now = Date.now()) { if (!payload || typeof payload.clubId !== 'string' || !payload.clubId) return null; if (typeof payload.deviceId !== 'string' || !payload.deviceId) return null; - if (typeof payload.exp !== 'number' || payload.exp <= now) return null; + if (typeof payload.exp !== 'number' || payload.exp + grace <= now) return null; return { clubId: payload.clubId, @@ -142,4 +168,4 @@ export function readClubHeader(request) { return value || null; } -export { CLUB_TOKEN_TTL_MS }; +export { CLUB_TOKEN_TTL_MS, CLUB_TOKEN_REFRESH_GRACE_MS }; diff --git a/worker/club-token.test.js b/worker/club-token.test.js index 083935f..93b25dd 100644 --- a/worker/club-token.test.js +++ b/worker/club-token.test.js @@ -5,6 +5,7 @@ import { clubTokenMatchesVersion, readClubHeader, CLUB_TOKEN_TTL_MS, + CLUB_TOKEN_REFRESH_GRACE_MS, } from './club-token.js'; const KEY = 'test-session-signing-key'; @@ -90,6 +91,50 @@ describe('club tokens', () => { expect(mintClubToken(undefined, KEY, NOW)).toBeNull(); }); + // The refresh's grace window: a signed token past its 24 hours can still be + // traded in, up to CLUB_TOKEN_REFRESH_GRACE_MS late. Nobody else asks for it. + it('accepts an expired token inside a grace window only when asked to', () => { + const token = mintClubToken(claims(), KEY, NOW); + const aWeekLate = NOW + CLUB_TOKEN_TTL_MS + 7 * CLUB_TOKEN_TTL_MS; + const grace = { graceMs: CLUB_TOKEN_REFRESH_GRACE_MS }; + + expect(verifyClubToken(token, KEY, aWeekLate)).toBeNull(); + expect(verifyClubToken(token, KEY, aWeekLate, grace)).toMatchObject({ + clubId: 'club-1', + deviceId: 'dev-1', + exp: NOW + CLUB_TOKEN_TTL_MS, + }); + }); + + it('refuses a token past the grace window', () => { + const token = mintClubToken(claims(), KEY, NOW); + const grace = { graceMs: CLUB_TOKEN_REFRESH_GRACE_MS }; + const lastGoodMoment = NOW + CLUB_TOKEN_TTL_MS + CLUB_TOKEN_REFRESH_GRACE_MS - 1; + + expect(verifyClubToken(token, KEY, lastGoodMoment, grace)).not.toBeNull(); + expect(verifyClubToken(token, KEY, lastGoodMoment + 1, grace)).toBeNull(); + }); + + // The grace widens expiry and nothing else: a forged signature is still forged. + it('still refuses a forged or foreign-key token inside the grace window', () => { + const grace = { graceMs: CLUB_TOKEN_REFRESH_GRACE_MS }; + const later = NOW + 2 * CLUB_TOKEN_TTL_MS; + const token = mintClubToken(claims(), KEY, NOW); + const flipped = `${token.slice(0, -1)}${token.at(-1) === 'A' ? 'B' : 'A'}`; + + expect(verifyClubToken(flipped, KEY, later, grace)).toBeNull(); + expect(verifyClubToken(mintClubToken(claims(), 'another-key', NOW), KEY, later, grace)).toBeNull(); + }); + + it('treats a nonsense grace as none', () => { + const token = mintClubToken(claims(), KEY, NOW); + const later = NOW + CLUB_TOKEN_TTL_MS; + + for (const graceMs of [-5, Number.NaN, Infinity, undefined, null]) { + expect(verifyClubToken(token, KEY, later, { graceMs })).toBeNull(); + } + }); + it('falls back to the default lifetime for a nonsense ttl', () => { const odd = mintClubToken(claims(), KEY, NOW, -5); expect(verifyClubToken(odd, KEY, NOW + CLUB_TOKEN_TTL_MS)).toBeNull(); diff --git a/worker/club.js b/worker/club.js index ebfd71f..6831b02 100644 --- a/worker/club.js +++ b/worker/club.js @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import { readSession, readClub, proReleased } from './auth.js'; import { json, methodNotAllowed, notConfigured, notFound, unauthorized } from './http.js'; import { entitlementStore, readClubRecord, resolveAccess } from './entitlements.js'; -import { mintClubToken } from './club-token.js'; +import { mintClubToken, CLUB_TOKEN_REFRESH_GRACE_MS } from './club-token.js'; import { normalizeCode, formatCode, clubByCodeKey, clubDeviceKey, clubMemberKey, readMemberRole } from './club-admin.js'; import { createClubForSubscriber } from './club-create.js'; import { readClubName } from './billing.js'; @@ -459,11 +459,23 @@ export async function handleClubLeave(request, env) { * that lapsed overnight is off on the next app start. A session is read when * present so the answer is the *combined* one: a buyer who also typed their own * club's code must not be told they are free. + * + * The one route that takes an expired token, up to CLUB_TOKEN_REFRESH_GRACE_MS + * past its `exp`. A device refreshes at most once a day, so the token it sends + * is nearly always past its 24 hours, and the client reads a 401 here as "this + * credential is spent" and leaves the club. Taking the expired token is safe + * because nothing is minted until the club record and the device record have + * both been read: revocation still answers 403 and a deleted club still 404. + * A revoked device's residual reads stay bounded by the 24-hour TTL, because + * the token it holds can no longer be renewed. */ export async function handleClubState(request, env) { if (request.method !== 'GET') return methodNotAllowed(); + // Ahead of the token check: without the key every token reads as forged, and + // a 401 here makes every device in every club leave it. + if (!env.SESSION_SIGNING_KEY) return notConfigured('Club refresh'); - const claims = readClub(request, env); + const claims = readClub(request, env, { graceMs: CLUB_TOKEN_REFRESH_GRACE_MS }); if (!claims) return unauthorized(); const store = entitlementStore(env); diff --git a/worker/club.test.js b/worker/club.test.js index 1345771..36d5281 100644 --- a/worker/club.test.js +++ b/worker/club.test.js @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import worker from './index.js'; import { handleClubActivate, handleClubLeave, handleClubState, verifiedClubId, deviceLabel } from './club.js'; import { createClubFromPending, rotateClubCode, normalizeCode, formatCode, clubPrefix, mintCode } from './club-admin.js'; import { mintSessionToken } from './session-token.js'; -import { verifyClubToken } from './club-token.js'; +import { handleClubMeetings } from './club-meetings.js'; +import { verifyClubToken, CLUB_TOKEN_TTL_MS, CLUB_TOKEN_REFRESH_GRACE_MS } from './club-token.js'; import { clubKey } from './entitlements.js'; /** @@ -362,6 +363,104 @@ describe('GET /api/club', () => { ); expect(res.status).toBe(405); }); + + // A device refreshes at most once a day, so the token it sends is nearly + // always past its 24 hours. A club that meets weekly sends one a week old. + describe('with a token past its 24 hours', () => { + const JOINED_AT = Date.UTC(2026, 10, 6, 0, 30); + const NEXT_MEETING = JOINED_AT + 7 * DAY; + + beforeEach(() => { + vi.setSystemTime(JOINED_AT); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renews it a week later and keeps the device in the club', async () => { + const { clubId, clubToken, claims } = await activated(); + vi.setSystemTime(NEXT_MEETING); + expect(verifyClubToken(clubToken, SIGNING_KEY)).toBeNull(); + + const res = await handleClubState(stateReq(clubToken), env); + expect(res.status).toBe(200); + const body = await res.json(); + + expect(body).toMatchObject({ club: { id: clubId }, entitled: true }); + expect(verifyClubToken(body.clubToken, SIGNING_KEY)).toEqual({ + clubId, + deviceId: claims.deviceId, + uid: null, + ver: 1, + iat: NEXT_MEETING, + exp: NEXT_MEETING + CLUB_TOKEN_TTL_MS, + }); + }); + + it('renews it up to the end of the grace window, and not a moment after', async () => { + const { clubToken } = await activated(); + const expiredAt = JOINED_AT + CLUB_TOKEN_TTL_MS; + + vi.setSystemTime(expiredAt + CLUB_TOKEN_REFRESH_GRACE_MS - 1); + expect((await handleClubState(stateReq(clubToken), env)).status).toBe(200); + + vi.setSystemTime(expiredAt + CLUB_TOKEN_REFRESH_GRACE_MS); + expect((await handleClubState(stateReq(clubToken), env)).status).toBe(401); + }); + + // The grace is safe only because this route reads both records before it + // mints anything. Revocation and deletion must still win. + it('still refuses a revoked device and a deleted club', async () => { + const { clubId, clubToken, claims } = await activated(); + const deviceKey = `club-device:${clubId}:${claims.deviceId}`; + vi.setSystemTime(NEXT_MEETING); + + await kv.put(deviceKey, JSON.stringify({ ...(await kv.get(deviceKey, 'json')), revokedAt: JOINED_AT + DAY })); + const revoked = await handleClubState(stateReq(clubToken), env); + expect(revoked.status).toBe(403); + expect(await revoked.json()).toEqual({ error: 'club_access_revoked' }); + + await kv.delete(clubKey(clubId)); + expect((await handleClubState(stateReq(clubToken), env)).status).toBe(404); + }); + + it('still refuses a device that left', async () => { + const { clubId, clubToken, claims } = await activated(); + await kv.delete(`club-device:${clubId}:${claims.deviceId}`); + vi.setSystemTime(NEXT_MEETING); + + expect((await handleClubState(stateReq(clubToken), env)).status).toBe(403); + }); + + // Only the refresh takes an expired token. Every other club route reads it + // strictly, so what a revoked device keeps stays bounded by 24 hours. + it('is refused by every other route', async () => { + const { clubToken } = await activated(); + vi.setSystemTime(NEXT_MEETING); + + expect((await handleClubLeave(leaveReq(clubToken), env)).status).toBe(401); + const upload = await handleClubMeetings( + new Request('https://x/api/club/meetings/20261113/speeches', { + method: 'POST', + headers: { 'x-club': clubToken, 'content-type': 'application/json' }, + body: JSON.stringify({ speechId: 's1', name: 'Ana', role: 'Standard Speech', duration: '5:50', color: 'green' }), + }), + 'meetings/20261113/speeches', + env + ); + expect(upload.status).toBe(401); + }); + }); + + // Without the key every token reads as forged, and the client leaves on a + // 401. A misconfigured deploy must not empty every club. + it('answers 503 rather than 401 when the signing key is missing', async () => { + const { clubToken } = await activated(); + + const res = await handleClubState(stateReq(clubToken), { ...env, SESSION_SIGNING_KEY: undefined }); + expect(res.status).toBe(503); + }); }); describe('POST /api/club/leave', () => {