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 @@ -14,7 +14,7 @@ Read this first. Update it in the same PR as any spec change.
| [timer-sounds](timer-sounds.md) | stale | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans ("Hold tight! Sounds are coming soon." while `pro` is off), audible through Share sound (#105). TS-09 ran green on 60e1b6e (2026-11-14); TS-01 needs a free device (the test device is on club Pro), TS-08 needs per-uid PostHog flags; audio steps never run |
| [pro-launch](pro-launch.md) | stale | eeaa840, 2026-11-13 | one-time "New: Pro for your club" window after FINISH for free devices once `pro` is on; hand-off to the Upgrade dialog; red dot on the footer's Upgrade button until Pro has been looked at. Stale: the Sounds line (ffc3a37) and the merge with dev's locked Pro button (pro-coming-soon) came after the last run; PL-01's free-device half and PL-04 (Free account) not run yet |
| [announcement-banner](announcement-banner.md) | current | cb49176, 2026-11-14 | one-line announcement above the tabs, picked from `ANNOUNCEMENTS` (packages/shared/announcements.js); today the pre-launch notice ("Coming soon: a fresh new look… and Pro features") while `pro` is off; a close is kept per device per announcement |
| [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). CC-07 and CC-08 not run on 905d8d1, and CC-06's member 403 never yet (a guest got 401). Stale since 2026-11-14: covered files changed for the 60-day reminder (#147) and the pro gate (club-pro-gate); rerun before the next release |
| [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). Stale since 2026-11-14: covered files changed for the 60-day reminder (#147) and the pro gate (club-pro-gate); rerun before the next release. CC-10 passed on 3262d37 (2026-11-15) on A in Zoom and on B (web); only CC-10 ran |
| [club-renewal-reminder](club-renewal-reminder.md) | stale | 51534f9, 2026-11-14 | amber "<club>'s Pro ends in N days" line: only in the last 60 days of a club that will not renew; a failed payment at once. CR-01 in Zoom; CR-02/CR-03 on the web app as a second club device (the Zoom client can't be made to refresh its club). CR-04 (nothing while `pro` is off) added 2026-11-14, not run yet (needs the club inside the 60-day window in dev KV) |
| [club-pro-gate](club-pro-gate.md) | current | 32ebeb7, 2026-11-14 | every club feature (badge, club card set, club timings, archive strip / History / share, report branding, renewal line) only while the club is active **and** `pro` is on; nothing deleted while off |
| [pro-coming-soon](pro-coming-soon.md) | current | 4d95d3d, 2026-11-14 | footer Pro button while `pro` is off: grey with a lock; hover or click lists what is coming (sounds, club card sets, voting, and more); the real Pro / Upgrade button once `pro` is on |
Expand Down
34 changes: 33 additions & 1 deletion e2e/zoom-client/club-card-sets.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ title: Club card sets (share card artwork with the club)
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`, and since 2026-11-14 every club
Expand All @@ -37,7 +42,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 @@ -105,6 +112,22 @@ 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.
Expand All @@ -118,8 +141,17 @@ Evidence: `PUT /api/club/cards` → 200; KV `club-cards:<clubId>` 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 "<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/15 (branch fix/club-token-refresh-grace; B = web app in the built-in browser
pane on dev 766d77cf, A = Zoom desktop on dev 5b9a1545 = 3262d37, FLAGS_FORCE "1"): CC-10 passed
on both. B: its own token, expired 81 min earlier, 25.35 h after its last refresh. A: opened 25 h
after its last refresh with a token expired 1 min 49 s earlier; GET /api/club 200, no leave, badge
and club card set kept, and the next requests carried the re-minted token. Only CC-10 ran, so
`verified_at` stays at the last full run. Log: test-runs/2026-11-13-club-token-refresh-grace/run.md.
3 changes: 3 additions & 0 deletions e2e/zoom-client/club-pro-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,6 @@ Evidence: screenshots.
`TimerApp.test.jsx`.
- A club that lapses while `pro` is on: [club-renewal-reminder](club-renewal-reminder.md) and
the lapse steps in [club-card-sets](club-card-sets.md).
- The daily club refresh with a token past 24 h (fix/club-token-refresh-grace). Reviewed
2026-11-14: its only `club.js` change is a comment in `refreshClub`; the gate is untouched.
See [club-card-sets](club-card-sets.md) CC-10.
3 changes: 3 additions & 0 deletions e2e/zoom-client/club-renewal-reminder.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ Evidence: screenshot.
the same banner earlier the same day.
- Other club behaviour in `club.js` (joining, refresh, lapse, card sets). See
[club-card-sets](club-card-sets.md); `pro-club` is not migrated yet.
Reviewed 2026-11-14 against fix/club-token-refresh-grace: its only `club.js` change is the
comment on `refreshClub`'s 401/403/404 branch; the banner and reminder are untouched. The
refresh keeping a device in its club past 24 h is club-card-sets CC-10.
44 changes: 44 additions & 0 deletions packages/shared/__tests__/club.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,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 @@ -934,8 +934,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
Loading
Loading