Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/zoom-client/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
35 changes: 33 additions & 2 deletions e2e/zoom-client/club-card-sets.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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`)
Expand All @@ -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

Expand Down Expand Up @@ -104,15 +111,39 @@ previous set until its next club refresh (see CC-07).
Evidence: `PUT /api/club/cards` → 200; KV `club-cards:<clubId>` names only the new hashes and
`ver` went up by 1; `GET /api/club/cards/<old hash>` 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.
- Card sets are one per club for now (open question 2 in the plan).
- 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 "<club> 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.
44 changes: 44 additions & 0 deletions packages/shared/__tests__/club.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions packages/shared/club.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions worker/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

// ---------------------------------------------------------------------------
Expand Down
32 changes: 29 additions & 3 deletions worker/club-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
45 changes: 45 additions & 0 deletions worker/club-token.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 14 additions & 2 deletions worker/club.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading