From 25536fee64d2444e181d55f90bece5a30cff7572 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Fri, 6 Nov 2026 13:04:48 -0500 Subject: [PATCH 1/4] feat(club): share a card set with the club An admin or editor presses "Share with my club" on one of their own custom card sets in Card Images. The images go to R2 under club-card// and the set to club-cards:, and the club's ver moves, so every club device downloads it into IndexedDB and lists it in the picker. A device that never chose cards of its own opens on the club's set; one that did keeps its own, with the club's one tap away (toastmaster_card_source). A lapsed or left club drops the set and the device's own choice shows again. Admins can remove the set from /club/admin. The images are private: they live outside club//, which /api/club-assets serves to anyone. Uploads are checked against their SHA-256, sniffed as JPEG/PNG/WebP, capped at 2 MB each and 20 MB per club. Publishing prunes images no set names, sparing uploads under 15 minutes old. Phase 1 of docs/plans/10-pro-is-the-club-plan.md (local plan, not tracked). New Zoom-client spec: e2e/zoom-client/club-card-sets.md (draft, not yet run). --- apps/web/src/components/CardImagesModal.jsx | 118 +++++- apps/web/src/components/LiveTab.jsx | 9 +- apps/web/src/main.jsx | 5 +- apps/web/src/pages/ClubAdmin.jsx | 116 +++++ .../src/components/CardImagesModal.jsx | 118 +++++- .../src/components/CardImagesModal.test.jsx | 58 +++ apps/zoom-app/src/components/LiveTab.jsx | 12 +- apps/zoom-app/src/main.jsx | 6 +- docs/BILLING.md | 2 +- docs/CLUBS.md | 35 +- docs/FEATURE_FLAGS.md | 2 +- e2e/zoom-client/INDEX.md | 3 +- e2e/zoom-client/club-card-sets.md | 98 +++++ packages/shared/__tests__/clubCards.test.js | 284 +++++++++++++ packages/shared/cardImages.js | 150 ++++++- packages/shared/club.js | 117 ++++- packages/shared/clubCards.js | 208 +++++++++ packages/shared/index.js | 1 + packages/shared/package.json | 1 + worker/club-admin-routes.js | 2 + worker/club-admin.js | 1 + worker/club-cards.js | 367 ++++++++++++++++ worker/club-cards.test.js | 401 ++++++++++++++++++ worker/club.js | 8 +- 24 files changed, 2067 insertions(+), 55 deletions(-) create mode 100644 e2e/zoom-client/club-card-sets.md create mode 100644 packages/shared/__tests__/clubCards.test.js create mode 100644 packages/shared/clubCards.js create mode 100644 worker/club-cards.js create mode 100644 worker/club-cards.test.js diff --git a/apps/web/src/components/CardImagesModal.jsx b/apps/web/src/components/CardImagesModal.jsx index 85ea150..85481dc 100644 --- a/apps/web/src/components/CardImagesModal.jsx +++ b/apps/web/src/components/CardImagesModal.jsx @@ -1,17 +1,26 @@ import { useEffect, useRef, useState } from 'react'; -import { X, Plus, Check, Trash2 } from 'lucide-react'; +import { X, Plus, Check, Trash2, Share2 } from 'lucide-react'; import { CARD_COLORS, + CLUB_CARD_SET_ID, DEFAULT_CARD_SETS, getCardImageSettings, + activeCardSetId, + clubCardSetAvailable, + getClubCardImage, selectCardSet, addCustomCardSet, deleteCustomCardSet, getCustomCardImage, fileToCardBlob, initCardImages, + canPublishCards, + publishCardSet, + loadClubCards, + subscribeCardImages, } from '@toastmaster-timer/shared'; import { getCardAssetUrl } from '../utils/cardArtwork'; +import { useClub } from '../hooks/useClub'; import { useToast } from '../context/ToastContext'; const COLOR_LABELS = { @@ -34,12 +43,20 @@ const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files; const STORAGE_FULL_MESSAGE = 'Could not save — browser storage is full. Try smaller images or delete another custom set.'; +const SHARE_ERRORS = { + forbidden: 'Only your club’s admins and editors can share cards with the club. Sign in to share.', + upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.', + storage_limit: 'Your club’s card storage is full.', + image_too_large: 'One of these images is too large to share.', + network: 'Could not reach the server. Check your connection and try again.', +}; + /** * One selectable set: a labeled row of the four cards. The row itself is the * radio; a custom set also carries a delete button, which must not double as * a click on the row. */ -function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete }) { +function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) { return (
} - {onDelete && ( - - )} + + {onShare && ( + + )} + {onDelete && ( + + )} +
{CARD_COLORS.map((color) => ( @@ -97,6 +130,15 @@ function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete }) { export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { const { showToast } = useToast(); const [settings, setSettings] = useState(() => getCardImageSettings()); + // The club's set is not in the metadata, so which set shows is asked of the + // shared module rather than read off `settings`. + const [activeSetId, setActiveSetId] = useState(() => activeCardSetId()); + const { club, clubName } = useClub(); + // The kit carries the name when there is one; the club record always does. + const clubLabel = clubName || club?.club?.name || null; + const [sharingSetId, setSharingSetId] = useState(null); + // Re-render when the club's images arrive, so its thumbnails fill in. + const [, setClubGeneration] = useState(0); // The set being assembled in the uploader row, color -> {blob, url}. Kept // only in this modal until the organizer confirms it into a real set. const [draft, setDraft] = useState({}); @@ -111,10 +153,18 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { useEffect(() => { let cancelled = false; initCardImages().then(() => { - if (!cancelled) setSettings(getCardImageSettings()); + if (!cancelled) { + setSettings(getCardImageSettings()); + setActiveSetId(activeCardSetId()); + } + }); + const unsubscribe = subscribeCardImages(() => { + setActiveSetId(activeCardSetId()); + setClubGeneration((generation) => generation + 1); }); return () => { cancelled = true; + unsubscribe(); Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url)); }; }, []); @@ -123,11 +173,12 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { const changed = () => { setSettings(getCardImageSettings()); + setActiveSetId(activeCardSetId()); onImagesChanged?.(); }; const handleSelect = (setId) => { - if (setId === settings.selectedSetId) return; + if (setId === activeSetId) return; if (selectCardSet(setId)) changed(); else showToast(STORAGE_FULL_MESSAGE, 'error'); }; @@ -168,6 +219,18 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { } }; + const handleShare = async (setId, label) => { + setSharingSetId(setId); + // The web session travels as a cookie, so there is no token to hand over. + const result = await publishCardSet(setId, { label }); + setSharingSetId(null); + if (result.ok) { + showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success'); + } else { + showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error'); + } + }; + const pickDraftFile = (color) => { draftColorRef.current = color; fileInputRef.current?.click(); @@ -190,11 +253,21 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {

+ {clubCardSetAvailable() && ( + handleSelect(CLUB_CARD_SET_ID)} + thumbSrc={(color) => getClubCardImage(color) || getCardAssetUrl(CLASSIC_FILES[color])} + /> + )} + {DEFAULT_CARD_SETS.map((set) => ( handleSelect(set.id)} thumbSrc={(color) => getCardAssetUrl(set.files[color])} /> @@ -204,12 +277,14 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { handleSelect(set.id)} // A color the set has no upload for shows — and times with — // the Classic card. thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardAssetUrl(CLASSIC_FILES[color])} onDelete={() => handleDelete(set.id)} + onShare={club && canPublishCards() ? () => handleShare(set.id, `Custom ${index + 1}`) : undefined} + sharing={sharingSetId === set.id} /> ))} @@ -270,9 +345,10 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { />

- Custom images are stored only in this browser. Please upload only images you have the - right to use — for example your club's own materials. This app is independent and - cannot grant permission for third-party logos or artwork. + Custom images are stored only in this browser. + {club?.entitled && canPublishCards() ? ' Share a set to put it on every club device.' : ''} Please + upload only images you have the right to use — for example your club's own materials. + This app is independent and cannot grant permission for third-party logos or artwork.

diff --git a/apps/web/src/components/LiveTab.jsx b/apps/web/src/components/LiveTab.jsx index 52172d6..b9308f5 100644 --- a/apps/web/src/components/LiveTab.jsx +++ b/apps/web/src/components/LiveTab.jsx @@ -8,7 +8,7 @@ const EditRulesModal = lazy(() => import('./EditRulesModal')); const CardImagesModal = lazy(() => import('./CardImagesModal')); const SoundsModal = lazy(() => import('./SoundsModal')); import TimeInput, { TimeInputModeToggle } from './TimeInput'; -import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, initCardImages } from '@toastmaster-timer/shared'; +import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, initCardImages, subscribeCardImages } from '@toastmaster-timer/shared'; import { setPageBackgroundFromStatus } from '../utils/pageBackground'; import { parseSpeakerFromSearch, stripSpeakerParams } from '../utils/speakerDeepLink'; import { getCardImageUrl, preloadCardImages } from '../utils/cardArtwork'; @@ -81,6 +81,13 @@ export default memo(function LiveTab({ onTimerStart }) { return () => { cancelled = true; }; }, []); + // The club's card set arrives, changes or goes away while the page is open. + useEffect(() => subscribeCardImages(() => { + setCardImagesGeneration((generation) => generation + 1); + setPageBackgroundFromStatus(shownStatusRef.current); + preloadCardImages(); + }), []); + // On mount, seed the speaker. A "Time this" deep link (?role=...&name=...) // wins when nothing is loaded yet and the timer is idle; otherwise the // default role applies. A speaker that is already there (restored state) diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx index 01738e2..16ccd23 100644 --- a/apps/web/src/main.jsx +++ b/apps/web/src/main.jsx @@ -7,7 +7,7 @@ import App from './App.jsx' import './index.css' import { initPostHog, trackEvent } from './utils/posthog.js' import { startWebSession } from './utils/webIdentity.js' -import { initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter } from '@toastmaster-timer/shared' +import { initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter, startClubCardSync } from '@toastmaster-timer/shared' // Before the first paint, and synchronous: a browser that joined a club is Pro, // and reading that out of localStorage now is what stops the plan flashing @@ -44,6 +44,9 @@ deferInit(() => { refreshClub().catch((error) => { console.warn('Failed to refresh the club:', error) }) + // The club's card set, fetched where this browser lacks it, now and whenever + // the club changes. + startClubCardSync() // An upload that does not go out is invisible from here — the device keeps // its own copy and the timer sees nothing wrong — so it has to report itself. setArchiveReporter(trackEvent) diff --git a/apps/web/src/pages/ClubAdmin.jsx b/apps/web/src/pages/ClubAdmin.jsx index adec6d0..5335ce5 100644 --- a/apps/web/src/pages/ClubAdmin.jsx +++ b/apps/web/src/pages/ClubAdmin.jsx @@ -371,6 +371,110 @@ function KitEditor({ kit, clubName, onSaved }) { ) } +const CARD_COLORS = ['blue', 'green', 'yellow', 'red'] +const CARD_LABELS = { blue: 'Idle (blue)', green: 'Green', yellow: 'Yellow', red: 'Red' } + +/** + * The club's card set, as the console shows it. Read-only apart from Remove: + * a set is published from a timer's card picker, where the artwork already + * is, not uploaded here. + * + * The images are private, so they are fetched with the console's credentials + * and shown from object URLs rather than linked. + */ +function ClubCards({ cards, onRemoved }) { + const [urls, setUrls] = useState({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!cards?.images) return undefined + let cancelled = false + const minted = [] + Promise.all( + Object.entries(cards.images).map(async ([color, hash]) => { + try { + const response = await fetch(`/api/club/cards/${hash}`, { + credentials: 'same-origin', + headers: { ...clubHeaders() }, + }) + if (!response.ok) return null + const url = URL.createObjectURL(await response.blob()) + minted.push(url) + return [color, url] + } catch { + return null + } + }) + ).then((entries) => { + if (!cancelled) setUrls(Object.fromEntries(entries.filter(Boolean))) + }) + return () => { + cancelled = true + minted.forEach((url) => URL.revokeObjectURL(url)) + } + }, [cards]) + + if (!cards?.images) { + return ( +

+ No card set yet. An admin or editor shares one from Card Images in the timer, and every + club device shows it. +

+ ) + } + + const remove = async () => { + setBusy(true) + setError(null) + const result = await api('/api/club/cards', { method: 'DELETE' }) + setBusy(false) + if (!result.ok) { + setError('Could not remove the card set. Please try again.') + return + } + trackEvent('club_cards_removed', { surface: 'web' }) + onRemoved?.() + } + + return ( +
+

+ {cards.label || 'Club cards'} + {cards.publishedAt ? · shared {shortDate(cards.publishedAt)} : null} +

+
+ {CARD_COLORS.map((color) => + urls[color] ? ( + {`${CARD_LABELS[color]} + ) : ( +
+ {cards.images[color] ? '…' : 'Classic'} +
+ ) + )} +
+ + {error &&

{error}

} +
+ ) +} + const KIT_ERRORS = { invalid_name: 'A club needs a name, and it has to fit in 80 characters.', invalid_color: 'Pick a colour in #rrggbb form.', @@ -580,6 +684,18 @@ export default function ClubAdmin() { +
+

Card set

+ { + load() + // This browser may be a club device too; let it drop the set now. + refreshClub({ force: true }).catch(() => {}) + }} + /> +
+

Billing

{roster.actor?.type === 'zoom' ? ( diff --git a/apps/zoom-app/src/components/CardImagesModal.jsx b/apps/zoom-app/src/components/CardImagesModal.jsx index a8dc369..7cc2f65 100644 --- a/apps/zoom-app/src/components/CardImagesModal.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.jsx @@ -1,19 +1,29 @@ import { useEffect, useRef, useState, lazy, Suspense } from 'react'; import { useEntitlement } from '../hooks/useEntitlement'; import { useFlag } from '../hooks/useFlag'; -import { X, Plus, Check, Trash2 } from 'lucide-react'; +import { X, Plus, Check, Trash2, Share2 } from 'lucide-react'; import { CARD_COLORS, + CLUB_CARD_SET_ID, DEFAULT_CARD_SETS, getCardImageSettings, + activeCardSetId, + clubCardSetAvailable, + getClubCardImage, selectCardSet, addCustomCardSet, deleteCustomCardSet, getCustomCardImage, fileToCardBlob, initCardImages, + canPublishCards, + publishCardSet, + loadClubCards, + subscribeCardImages, } from '@toastmaster-timer/shared'; import { getCardFileUrl, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk'; +import { getSessionToken } from '../utils/zoomIdentity'; +import { useClub } from '../hooks/useClub'; import OwnBackgroundPicker from './OwnBackgroundPicker'; import { useToast } from '../context/ToastContext'; const UpgradeModal = lazy(() => import('./UpgradeModal')); @@ -38,12 +48,20 @@ const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files; const STORAGE_FULL_MESSAGE = 'Could not save — browser storage is full. Try smaller images or delete another custom set.'; +const SHARE_ERRORS = { + forbidden: 'Only your club’s admins and editors can share cards with the club.', + upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.', + storage_limit: 'Your club’s card storage is full.', + image_too_large: 'One of these images is too large to share.', + network: 'Could not reach the server. Check your connection and try again.', +}; + /** * One selectable set: a labeled row of the four cards. The row itself is the * radio; a custom set also carries a delete button, which must not double as * a click on the row. */ -function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete }) { +function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) { return (
} - {onDelete && ( - - )} + + {onShare && ( + + )} + {onDelete && ( + + )} +
{CARD_COLORS.map((color) => ( @@ -107,6 +141,15 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); const [showUpgrade, setShowUpgrade] = useState(false); const [settings, setSettings] = useState(() => getCardImageSettings()); + // The club's set is not in the metadata, so which set shows is asked of the + // shared module rather than read off `settings`. + const [activeSetId, setActiveSetId] = useState(() => activeCardSetId()); + const { club, clubName } = useClub(); + // The kit carries the name when there is one; the club record always does. + const clubLabel = clubName || club?.club?.name || null; + const [sharingSetId, setSharingSetId] = useState(null); + // Re-render when the club's images arrive, so its thumbnails fill in. + const [, setClubGeneration] = useState(0); // The set being assembled in the uploader row, color -> {blob, url}. Kept // only in this modal until the organizer confirms it into a real set. const [draft, setDraft] = useState({}); @@ -121,10 +164,18 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { useEffect(() => { let cancelled = false; initCardImages().then(() => { - if (!cancelled) setSettings(getCardImageSettings()); + if (!cancelled) { + setSettings(getCardImageSettings()); + setActiveSetId(activeCardSetId()); + } + }); + const unsubscribe = subscribeCardImages(() => { + setActiveSetId(activeCardSetId()); + setClubGeneration((generation) => generation + 1); }); return () => { cancelled = true; + unsubscribe(); Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url)); }; }, []); @@ -133,6 +184,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { const changed = () => { setSettings(getCardImageSettings()); + setActiveSetId(activeCardSetId()); notifyCardImagesChanged(); onImagesChanged?.(); // The notify above dropped every decoded overlay frame; re-decode the new @@ -142,7 +194,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { }; const handleSelect = (setId) => { - if (setId === settings.selectedSetId) return; + if (setId === activeSetId) return; if (selectCardSet(setId)) changed(); else showToast(STORAGE_FULL_MESSAGE, 'error'); }; @@ -183,6 +235,17 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { } }; + const handleShare = async (setId, label) => { + setSharingSetId(setId); + const result = await publishCardSet(setId, { label, getToken: getSessionToken }); + setSharingSetId(null); + if (result.ok) { + showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success'); + } else { + showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error'); + } + }; + const pickDraftFile = (color) => { draftColorRef.current = color; fileInputRef.current?.click(); @@ -205,11 +268,21 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {

+ {clubCardSetAvailable() && ( + handleSelect(CLUB_CARD_SET_ID)} + thumbSrc={(color) => getClubCardImage(color) || getCardFileUrl(CLASSIC_FILES[color])} + /> + )} + {DEFAULT_CARD_SETS.map((set) => ( handleSelect(set.id)} thumbSrc={(color) => getCardFileUrl(set.files[color])} /> @@ -219,12 +292,14 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { handleSelect(set.id)} // A color the set has no upload for shows — and times with — // the Classic card. thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardFileUrl(CLASSIC_FILES[color])} onDelete={() => handleDelete(set.id)} + onShare={club && canPublishCards() ? () => handleShare(set.id, `Custom ${index + 1}`) : undefined} + sharing={sharingSetId === set.id} /> ))} @@ -291,7 +366,12 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { />

- {isPro ? ( + {club?.entitled ? ( + <> + Custom images are stored only in this browser. + {canPublishCards() ? ' Share a set to put it on every club device. ' : ' '} + + ) : isPro ? ( <>Custom images are backed up and follow you to your other devices. ) : known && flagsKnown && proEnabled ? ( <> diff --git a/apps/zoom-app/src/components/CardImagesModal.test.jsx b/apps/zoom-app/src/components/CardImagesModal.test.jsx index 70e6a7f..ec951c0 100644 --- a/apps/zoom-app/src/components/CardImagesModal.test.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.test.jsx @@ -1,6 +1,8 @@ import '@testing-library/jest-dom'; import { act, render, screen } from '@testing-library/react'; import { + resetCardImagesForTests, + resetClubForTests, resetEntitlementForTests, resetFlagsForTests, setEntitlement, @@ -45,6 +47,62 @@ beforeEach(() => { localStorage.clear(); resetEntitlementForTests(); resetFlagsForTests(); + resetClubForTests(); + resetCardImagesForTests(); +}); + +/** Put this device in an entitled club, optionally with a published card set. */ +function joinClub({ role = null, cards = { label: null, images: { blue: 'a'.repeat(64) } } } = {}) { + localStorage.setItem( + 'toastmaster_club', + JSON.stringify({ clubToken: 'club-token', ver: 2, club: { id: 'c1', name: 'Downtown Speakers' }, kit: null, entitled: true, plan: 'pro', role }) + ); + if (cards) localStorage.setItem('toastmaster_club_cards', JSON.stringify(cards)); + resetClubForTests(); +} + +/** + * The club's set: on offer in the picker whenever the club has one, and shown + * by default on a device that never chose cards of its own. + */ +describe('the club card set', () => { + it('is listed, and selected, on a device with no cards of its own', async () => { + joinClub(); + await renderModal(); + const row = screen.getByRole('radio', { name: /Downtown Speakers cards/ }); + expect(row).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: /Classic/ })).toHaveAttribute('aria-checked', 'false'); + }); + + it('is not listed when the club has not published one', async () => { + joinClub({ cards: null }); + await renderModal(); + expect(screen.queryByRole('radio', { name: /Downtown Speakers cards/ })).toBeNull(); + }); + + // Publishing rewrites every club device's cards, so the button is for the + // people the server will accept it from. + it.each([ + ['an editor', 'editor', true], + ['an admin', 'admin', true], + ['a member', 'member', false], + ['a guest device', null, false], + ])('offers Share with my club on a custom set to %s: %s', async (_, role, offered) => { + joinClub({ role }); + localStorage.setItem( + 'toastmaster_custom_card_images', + JSON.stringify({ version: 3, selectedSetId: 'custom-1', customSets: [{ id: 'custom-1', colors: ['blue'] }] }) + ); + await renderModal(); + const share = screen.queryByRole('button', { name: /Share the Custom 1 card set with my club/ }); + expect(Boolean(share)).toBe(offered); + }); + + it('uses the label the publisher gave it', async () => { + joinClub({ cards: { label: 'Contest night', images: { red: 'b'.repeat(64) } } }); + await renderModal(); + expect(screen.getByRole('radio', { name: /Contest night/ })).toBeInTheDocument(); + }); }); /** diff --git a/apps/zoom-app/src/components/LiveTab.jsx b/apps/zoom-app/src/components/LiveTab.jsx index 16ea0b1..fe249e2 100644 --- a/apps/zoom-app/src/components/LiveTab.jsx +++ b/apps/zoom-app/src/components/LiveTab.jsx @@ -10,8 +10,8 @@ const EditRulesModal = lazy(() => import('./EditRulesModal')); const CardImagesModal = lazy(() => import('./CardImagesModal')); const SoundsModal = lazy(() => import('./SoundsModal')); import TimeInput, { TimeInputModeToggle } from './TimeInput'; -import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages } from '@toastmaster-timer/shared'; -import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE } from '../utils/zoomSdk'; +import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages, subscribeCardImages } from '@toastmaster-timer/shared'; +import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk'; import { useClub } from '../hooks/useClub'; import { SOUNDS_LOCK_MESSAGES, useSoundsAccess } from '../hooks/useSounds'; import { formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } from '@toastmaster-timer/shared'; @@ -90,6 +90,14 @@ export default memo(function LiveTab() { return () => { cancelled = true; }; }, []); + // The club's card set arrives, changes or goes away while the app is open. + // Drop the decoded frames and re-push, as the picker does after a change. + useEffect(() => subscribeCardImages(() => { + notifyCardImagesChanged(); + preloadBackgroundImages(); + setCardImagesGeneration((generation) => generation + 1); + }), []); + const [videoState, setVideoStateLocal] = useState(null); // null = unknown, true = on, false = off // A clear is in flight. Disables both the eraser and RESET, because in Timer + // Camera the wait is Zoom's confirmation dialog, and a second press behind the diff --git a/apps/zoom-app/src/main.jsx b/apps/zoom-app/src/main.jsx index b937048..ad87180 100644 --- a/apps/zoom-app/src/main.jsx +++ b/apps/zoom-app/src/main.jsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client' import App from './App.jsx' import './index.css' import { initializeZoomSdk, preloadBackgroundImages, setOverlayTimingReporter } from './utils/zoomSdk' -import { initCardImages, initProfileSync, syncCardAssets, setEntitlement, subscribeEntitlement, FREE_ENTITLEMENT, setFlags, initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter } from '@toastmaster-timer/shared' +import { initCardImages, initProfileSync, syncCardAssets, setEntitlement, subscribeEntitlement, FREE_ENTITLEMENT, setFlags, initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter, startClubCardSync } from '@toastmaster-timer/shared' import { initPostHog, identifyUser, setUserProperties, registerSessionProperties, trackEvent } from './utils/posthog' import { resolveZoomIdentity, getSessionToken } from './utils/zoomIdentity' import posthog from 'posthog-js' @@ -54,6 +54,10 @@ refreshClub({ getToken: getSessionToken }).catch((error) => { console.warn('Failed to refresh the club:', error); }); +// The club's card set: fetched where this device lacks it, now and whenever +// the club changes. Never awaited; a card that has not arrived shows Classic. +startClubCardSync(); + // An upload that does not go out is invisible from here — the device keeps its // own copy and the timer sees nothing wrong — so it has to report itself. setArchiveReporter(trackEvent); diff --git a/docs/BILLING.md b/docs/BILLING.md index 2b7976c..1af70a1 100644 --- a/docs/BILLING.md +++ b/docs/BILLING.md @@ -31,7 +31,7 @@ Zoom app polls GET /api/me until plan === 'pro' payments waiting to become a club under `club-pending:`. Bind a namespace as `ENTITLEMENTS` to move them; no code change. - Club records live in the same namespace under their own prefixes — `club:`, - `club-by-code:`, `club-by-customer:`, `club-by-email:`, `club-presets:`, + `club-by-code:`, `club-by-customer:`, `club-by-email:`, `club-presets:`, `club-cards:`, `club-device:`, `club-member:`, `club-magic:`, `meeting:`, `speech:`, `report-share:`. The full inventory is in [CLUBS.md](./CLUBS.md#kv-inventory). diff --git a/docs/CLUBS.md b/docs/CLUBS.md index 6e17e08..d049c2c 100644 --- a/docs/CLUBS.md +++ b/docs/CLUBS.md @@ -62,8 +62,9 @@ rights to an anonymous device"** true by construction. | | admin | editor | member / guest device | |---|---|---|---| -| Club presets, brand kit, archive | yes | yes | yes | -| "Share with my club" (publish) | yes | yes | no | +| Club presets, brand kit, card set, archive | yes | yes | yes | +| "Share with my club" (presets, card set) | yes | yes | no | +| Remove the card set | yes | no | no | | Roster, grant/revoke, kit editor | yes | no | no | | Billing | yes | no | no | @@ -89,6 +90,7 @@ clubState = { kit: { name, logoUrl, primaryColor, showOnCards, showOnReports } | null, presets: { rules, order, hiddenBuiltins, publishedBy, publishedAt } | null, badge: { x, y, scale }, ← the club's default placement + cards: { label, images: { color: sha256 }, publishedBy, publishedAt } | null, timezone, role, ← this caller's role, or null plan, entitled, status, currentPeriodEnd, cancelAtPeriodEnd, source @@ -110,6 +112,8 @@ mid-meeting. toastmaster_club { clubToken, ver, club, kit, badge, plan, … } toastmaster_club_presets the club's published list toastmaster_preset_source 'club' | 'personal' +toastmaster_club_cards the club's card set: label and image hashes +toastmaster_card_source 'club' | 'own' toastmaster_club_badge this device's badge move, if it made one toastmaster_club_outbox finished speeches not yet uploaded toastmaster_club_grace_dismissed the day the renewal reminder was dismissed @@ -130,6 +134,25 @@ on the club's list; a device with its own presets keeps them. Editing the club's list asks once, then forks it into the timer's own keys (`packages/shared/clubPresets.js`). +## Card set: a switch, like presets + +A club can publish one set of timing-card artwork. An admin or editor presses +**Share with my club** on one of their own custom sets in **Card Images**; the +images go to `club-card//` and the set to `club-cards:`, and +`ver` moves. Every club device downloads what it is missing into IndexedDB +(`packages/shared/clubCards.js`), so the card-switch path never waits on the +network. + +Which set shows follows the preset rule: a device that never chose cards of its +own opens on the club's set; one that did keeps its own, with the club's one tap +away in the picker. Choosing in the picker moves `toastmaster_card_source` +explicitly. A lapsed or left club drops the set from the picker and the device +shows its own choice again; nothing is deleted. An admin can take the set down +from the console. + +Publishing prunes images no set names any more, sparing uploads newer than 15 +minutes so a second publisher's uploads survive until their own publish lands. + ## KV inventory All in the `PROFILES` namespace, alongside the entitlement and Stripe prefixes. @@ -146,6 +169,8 @@ club-pending: { uid, clubName, email, stripeCustomerId, checkoutSessionId, paidAt } club-presets: { rules, order, hiddenBuiltins, badge, publishedBy, publishedAt } +club-cards: { label, images: { color: sha256 }, + publishedBy, publishedAt } club-device:: { label, uid|null, activatedAt, lastSeenAt, revokedAt } club-member::zoom: { role, displayName, addedAt, revokedAt } club-magic: { clubId, email, exp } 15-minute TTL @@ -158,6 +183,8 @@ report-share-by-meeting:: ← so a re-share reuses R2 (CARD_ASSETS) club// the club logo — served publicly, immutable club//r-.png a shared report's OG preview image +club-card// a card set image — private, never under club/ + (which /api/club-assets serves to anyone) ``` `invTs` is an inverted timestamp so `list({prefix:'meeting::'})` comes @@ -170,6 +197,10 @@ History list costs exactly one operation. POST /api/club/activate public, rate-limited GET /api/club X-Club PUT /api/club/presets X-Club + uid, role admin|editor +PUT /api/club/cards/ X-Club + uid, role admin|editor (one image) +PUT /api/club/cards X-Club + uid, role admin|editor (the set) +GET /api/club/cards/ X-Club, or an admin's console session +DELETE /api/club/cards admin POST /api/club/meetings//speeches X-Club, + device revocation read GET /api/club/meetings X-Club GET /api/club/meetings/ X-Club diff --git a/docs/FEATURE_FLAGS.md b/docs/FEATURE_FLAGS.md index 1b9926e..88c2d9d 100644 --- a/docs/FEATURE_FLAGS.md +++ b/docs/FEATURE_FLAGS.md @@ -81,7 +81,7 @@ them. | Billing | `POST /api/billing/checkout`, `POST /api/billing/portal` (after the session check, so no session is still 401). Zoom: the Footer **Upgrade**/**Pro** button and the **Upgrade to Pro** link in Card images, which are the Upgrade modal's only doors. Both apps: **Manage billing** on the club banner. Web: the plan section on `/account` | `GET /api/billing/checkout-status`, the Stripe webhook, the success/cancel pages, entitlement itself | | Web sign-in | `GET /api/auth/zoom/start`. A sign-in callback on `/oauth/redirect` falls through to the install page instead, so Marketplace installs keep working. Web: **Sign in** in the account menu, the signed-out card on `/account` | `POST /api/auth/logout` | | Pro in the UI | `useEntitlement().isPro` in both apps is the plan **and** `pro`, so every Pro feature in the app reads a Pro plan as free while `pro` is off: Sounds shows the "Pro feature" lock (not "coming soon"), Card images says images stay in this browser, the account menu says Account. `known` waits for the flags as well as the plan | the server, which keeps keying on `entitled`: a paying user's sync, card backups and club archive carry on. Club surfaces that read the club record (`club.entitled`): the badge, presets, the archive | -| Clubs | `/api/club/activate`, `/create`, `/magic-link` and `/manage`, whatever the method, checked before anything else so even an unauthenticated create is 404. Zoom: the code field and **Set up my club**, inside the Upgrade modal. Web: the setup card and code field on `/account`; `/club/admin`, `/club/manage` and `/pro/:code` are the not-found view (`FlagGate`) | `GET /api/club` (the daily refresh), presets, `meetings/*` (including queued speeches), the admin routes, `manage/signout`, `/api/club-assets/*` | +| Clubs | `/api/club/activate`, `/create`, `/magic-link` and `/manage`, whatever the method, checked before anything else so even an unauthenticated create is 404. Zoom: the code field and **Set up my club**, inside the Upgrade modal. Web: the setup card and code field on `/account`; `/club/admin`, `/club/manage` and `/pro/:code` are the not-found view (`FlagGate`) | `GET /api/club` (the daily refresh), presets, the card set (`cards/*`), `meetings/*` (including queued speeches), the admin routes, `manage/signout`, `/api/club-assets/*` | A refusal is a bare 404, the same as any unknown URL. The reason is logged server-side only, as `flag off: pro `, so it shows in diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index 65f0f53..db41d97 100644 --- a/e2e/zoom-client/INDEX.md +++ b/e2e/zoom-client/INDEX.md @@ -12,6 +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) | draft | never | 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 (plan 10 Phase 1) | | 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 | @@ -20,5 +21,5 @@ yet. Migrate one at a time, running each once so `verified_at` is real. ## Run order for a full pass Dependencies between specs, so the skill can batch human steps: `auth-identity` → -`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `webhooks-lifecycle` (deauth +`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `webhooks-lifecycle` (deauth last, it removes the app). diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md new file mode 100644 index 0000000..1938255 --- /dev/null +++ b/e2e/zoom-client/club-card-sets.md @@ -0,0 +1,98 @@ +--- +id: club-card-sets +title: Club card sets (share card artwork with the club) +status: draft +covers: + - worker/club-cards.js + - packages/shared/clubCards.js + - packages/shared/cardImages.js + - packages/shared/club.js + - apps/zoom-app/src/components/CardImagesModal.jsx + - apps/zoom-app/src/components/LiveTab.jsx + - apps/zoom-app/src/main.jsx +automated: + - worker/club-cards.test.js + - packages/shared/__tests__/clubCards.test.js + - apps/zoom-app/src/components/CardImagesModal.test.jsx +needs: + - dev deployed + - FLAGS_FORCE "1" on dev (the club doors are behind `pro`) + - a dev club with a code, made with `node scripts/club-cli.mjs create --env dev` + - Zoom account A, an admin or editor of that club (club-member record) + - a second computer or browser profile, B, with no cards of its own + - ENTITLEMENT_ENFORCE "1" on dev for CC-08 only (ask first; revert after) +verified_at: never +verified_on: never +--- + +# Club card sets (share card artwork with the club) + +Phase 1 of `docs/plans/10-pro-is-the-club-plan.md`. An admin or editor publishes one of +their own custom card sets to the club; every device holding the club code downloads it +and shows it on real video. Unit tests prove the server checks, the switch logic and the +download. Only the real client proves that a club image pushed through the SDK bridge +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. + +## Steps + +### CC-01 · auto · A, the publisher, adds a custom set +Do: on A in Zoom, open the timer, Card Images, upload one image each for blue and red, Add set. +Expect: "Custom 1" appears selected. The row shows **Share with my club** (A is admin or editor). +Evidence: localStorage `toastmaster_custom_card_images` lists `custom-1` with `["blue","red"]`. + +### CC-02 · auto · A shares the set +Do: on A, press **Share with my club** on Custom 1. +Expect: toast "Shared with . Every club device gets these cards." A's picker now also +lists "Custom 1" as the club's set at the top. A's own selection does not change. +Evidence: `wrangler tail --env dev` shows two `PUT /api/club/cards/` → 200 and one +`PUT /api/club/cards` → 200. KV `club-cards:` holds both hashes; `club:.ver` +went up by 1. R2 has `club-card//` for each, and nothing under `club//`. + +### CC-03 · human · B joins the club and gets the set +Do: on B (no cards of its own), open the app in Zoom and enter the club code (or open +`/pro/` first). Start the timer in Timer + Camera mode with video on. +Expect: within a few seconds B's picker lists the club's set, selected. The blue card on B's +video is A's blue image; green and yellow are the Classic cards (the set skips them). +Evidence: tail shows `GET /api/club/cards/` → 200 from B, once per image. B's +localStorage has `toastmaster_club_cards`; `toastmaster_card_source` is absent (derived). + +### CC-04 · auto · The club card reaches red on real video +Do: on B, run a speech past red. +Expect: the red card on B's video is A's red image, the same framing as a personal custom +card. Switching blue → green → yellow → red stays smooth (no visible decode stall). +Evidence: screenshot of B's self-view at red. + +### CC-05 · auto · A device with its own cards keeps them +Do: on a device that had picked Modern before joining, open Card Images. +Expect: Modern stays selected; the club's set is listed above it but not selected. Choosing +the club's set shows it; choosing Modern again goes back. +Evidence: `toastmaster_card_source` is `club`, then `own`. + +### CC-06 · auto · Members cannot publish +Do: on a device signed in as a club member (not editor or admin), open Card Images. +Expect: no **Share with my club** button on any custom set. +Evidence: a forced `PUT /api/club/cards` from that device returns 403. + +### CC-07 · human · An admin removes the set from the console +Do: on the web, open `/club/admin` as the club admin. Under Card set, press **Remove card set**. +Expect: the section says there is no card set. On B's next app start, the club's set leaves +the picker and B shows Classic. +Evidence: tail shows `DELETE /api/club/cards` → 200; KV `club-cards:` is gone and +`ver` went up by 1. + +### CC-08 · human · A lapsed club stops showing its set +Do: with ENTITLEMENT_ENFORCE "1", republish a set from A, then set the club record to +`canceled` with `currentPeriodEnd` in the past. Reload B. +Expect: B shows its own choice (Classic) and the picker no longer lists the club's set. A's +**Share with my club** is gone. Revert the club record and ENTITLEMENT_ENFORCE afterwards. +Evidence: `GET /api/club` returns `entitled: false`; B's `toastmaster_club_cards` remains. + +## 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). diff --git a/packages/shared/__tests__/clubCards.test.js b/packages/shared/__tests__/clubCards.test.js new file mode 100644 index 0000000..7296512 --- /dev/null +++ b/packages/shared/__tests__/clubCards.test.js @@ -0,0 +1,284 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { IDBFactory } from 'fake-indexeddb'; +import { Blob as NodeBlob } from 'node:buffer'; + +/** + * The club's card set on a device: which set shows, where its images come + * from, and how a publisher's own set becomes the club's. + */ + +const CLUB_KEY = 'toastmaster_club'; +const CARDS_KEY = 'toastmaster_club_cards'; +const SOURCE_KEY = 'toastmaster_card_source'; +const SETTINGS_KEY = 'toastmaster_custom_card_images'; + +const BLUE = 'a'.repeat(64); +const RED = 'b'.repeat(64); + +// cardImages.js and club.js both keep per-session state, so every test loads +// fresh copies. IndexedDB and localStorage survive the reload, as they would a +// page reload. +async function load() { + vi.resetModules(); + const cardImages = await import('../cardImages.js'); + const club = await import('../club.js'); + const clubCards = await import('../clubCards.js'); + return { ...cardImages, ...club, ...clubCards }; +} + +function joinClub({ entitled = true, role = null, ver = 3, cards = { images: { blue: BLUE, red: RED } } } = {}) { + localStorage.setItem( + CLUB_KEY, + JSON.stringify({ clubToken: 'club-token', ver, club: { id: 'c1', name: 'Downtown' }, entitled, plan: 'pro', role }) + ); + if (cards) localStorage.setItem(CARDS_KEY, JSON.stringify(cards)); +} + +const imageBlob = (text) => new NodeBlob([text], { type: 'image/jpeg' }); + +beforeEach(() => { + localStorage.clear(); + globalThis.indexedDB = new IDBFactory(); + // fake-indexeddb cannot clone jsdom's Blob; Node's round-trips. + globalThis.Blob = NodeBlob; + let n = 0; + URL.createObjectURL = vi.fn(() => `blob:test/${++n}`); + URL.revokeObjectURL = vi.fn(); +}); + +describe('which set shows', () => { + it('is the device\'s own choice when there is no club set', async () => { + const mod = await load(); + expect(mod.activeCardSetId()).toBe('classic'); + expect(mod.clubCardSetAvailable()).toBe(false); + }); + + // The same rule as the club's presets: a device that never chose opens on the club's. + it('is the club\'s set on a device that never chose cards of its own', async () => { + joinClub(); + const mod = await load(); + expect(mod.activeCardSetId()).toBe(mod.CLUB_CARD_SET_ID); + }); + + it('stays on a device\'s own set when it has one', async () => { + joinClub(); + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ version: 3, selectedSetId: 'modern', customSets: [] })); + const mod = await load(); + expect(mod.activeCardSetId()).toBe('modern'); + }); + + it('follows the switch once someone moves it', async () => { + joinClub(); + const mod = await load(); + expect(mod.selectCardSet('classic')).toBe(true); + expect(localStorage.getItem(SOURCE_KEY)).toBe('own'); + expect(mod.activeCardSetId()).toBe('classic'); + expect(mod.selectCardSet(mod.CLUB_CARD_SET_ID)).toBe(true); + expect(mod.activeCardSetId()).toBe(mod.CLUB_CARD_SET_ID); + }); + + // A lapsed club keeps its record; its set just stops being on offer. + it('falls back to the device\'s own set when the club lapses', async () => { + joinClub({ entitled: false }); + localStorage.setItem(SOURCE_KEY, 'club'); + const mod = await load(); + expect(mod.activeCardSetId()).toBe('classic'); + expect(mod.selectCardSet(mod.CLUB_CARD_SET_ID)).toBe(false); + }); + + it('shows a set someone just added, even with a club set on offer', async () => { + joinClub(); + const mod = await load(); + const id = await mod.addCustomCardSet({ green: imageBlob('green') }); + expect(mod.activeCardSetId()).toBe(id); + }); +}); + +describe('resolving a card from the club set', () => { + it('serves a stored image, and Classic for a skipped colour or one not yet here', async () => { + joinClub(); + const mod = await load(); + await mod.initCardImages(); + expect(mod.resolveCardImage('blue')).toEqual({ file: 'timer-blue-background.png' }); + + await mod.storeClubCardBlobs([[BLUE, imageBlob('blue')]]); + expect(mod.resolveCardImage('blue')).toEqual({ url: expect.stringMatching(/^blob:/) }); + expect(mod.resolveCardImage('green')).toEqual({ file: 'timer-green-background.png' }); + expect(mod.resolveCardImage('red')).toEqual({ file: 'timer-red-background.png' }); + }); + + it('keeps the current set\'s images through a reload and sweeps a replaced set\'s', async () => { + joinClub(); + let mod = await load(); + await mod.initCardImages(); + await mod.storeClubCardBlobs([[BLUE, imageBlob('blue')], [RED, imageBlob('red')]]); + + // The club republishes with only a new red card. + const NEW_RED = 'c'.repeat(64); + localStorage.setItem(CARDS_KEY, JSON.stringify({ images: { red: NEW_RED } })); + mod = await load(); + await mod.initCardImages(); + expect(mod.missingClubCardHashes()).toEqual([NEW_RED]); + const stored = await mod.readStoredCardBlobs(); + expect([...stored.keys()]).toEqual([]); + }); + + it('tells subscribers when images arrive', async () => { + joinClub(); + const mod = await load(); + const heard = vi.fn(); + mod.subscribeCardImages(heard); + await mod.storeClubCardBlobs([[BLUE, imageBlob('blue')]]); + expect(heard).toHaveBeenCalledTimes(1); + }); +}); + +describe('syncClubCardImages', () => { + it('fetches only what is missing, with the club credential', async () => { + joinClub(); + const mod = await load(); + const fetchImpl = vi.fn(async (url) => ({ + ok: true, + headers: new Headers({ 'content-type': 'image/jpeg' }), + blob: async () => imageBlob(url), + })); + + expect(await mod.syncClubCardImages({ fetchImpl })).toEqual([BLUE, RED]); + expect(fetchImpl).toHaveBeenCalledWith(`/api/club/cards/${BLUE}`, expect.objectContaining({ + headers: { 'X-Club': 'club-token' }, + })); + + fetchImpl.mockClear(); + expect(await mod.syncClubCardImages({ fetchImpl })).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('does nothing without a club set, and survives a failed download', async () => { + let mod = await load(); + const fetchImpl = vi.fn(); + expect(await mod.syncClubCardImages({ fetchImpl })).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + + joinClub(); + mod = await load(); + const failing = vi.fn(async () => ({ ok: false, status: 404 })); + expect(await mod.syncClubCardImages({ fetchImpl: failing })).toEqual([]); + }); + + // A dev server's SPA fallback, or a captive portal, answers 200 with a page. + it('keeps only responses that are images', async () => { + joinClub(); + const mod = await load(); + const page = vi.fn(async () => ({ + ok: true, + headers: new Headers({ 'content-type': 'text/html' }), + blob: async () => imageBlob(''), + })); + expect(await mod.syncClubCardImages({ fetchImpl: page })).toEqual([]); + expect(mod.missingClubCardHashes()).toEqual([BLUE, RED]); + }); +}); + +describe('the club cache', () => { + const okJson = (body, status = 200) => ({ ok: status < 400, status, json: async () => body }); + + it('takes a new set when ver moves, and one it never had even when ver has not', async () => { + joinClub({ cards: null, ver: 3 }); + let mod = await load(); + await mod.refreshClub({ + force: true, + fetchImpl: async () => okJson({ ver: 3, club: { id: 'c1' }, entitled: true, cards: { images: { blue: BLUE } } }), + }); + expect(mod.loadClubCards().images).toEqual({ blue: BLUE }); + + mod = await load(); + await mod.refreshClub({ + force: true, + fetchImpl: async () => okJson({ ver: 4, club: { id: 'c1' }, entitled: true, cards: null }), + }); + expect(mod.loadClubCards()).toBeNull(); + }); + + it('forgets the set and the switch on leaving', async () => { + joinClub(); + localStorage.setItem(SOURCE_KEY, 'club'); + const mod = await load(); + mod.leaveClub({ fetchImpl: vi.fn(async () => ({})) }); + expect(localStorage.getItem(CARDS_KEY)).toBeNull(); + expect(localStorage.getItem(SOURCE_KEY)).toBeNull(); + expect(mod.activeCardSetId()).toBe('classic'); + }); +}); + +describe('publishCardSet', () => { + async function withCustomSet(role = 'editor') { + joinClub({ role, cards: null }); + const mod = await load(); + const id = await mod.addCustomCardSet({ blue: imageBlob('blue pixels'), red: imageBlob('red pixels') }); + return { mod, id }; + } + + it('is offered to admins and editors only', async () => { + joinClub({ role: 'member' }); + expect((await load()).canPublishCards()).toBe(false); + joinClub({ role: 'editor' }); + expect((await load()).canPublishCards()).toBe(true); + joinClub({ role: 'admin', entitled: false }); + expect((await load()).canPublishCards()).toBe(false); + }); + + it('uploads each image, publishes the set and remembers it as the club\'s', async () => { + const { mod, id } = await withCustomSet(); + const calls = []; + const fetchImpl = vi.fn(async (url, init) => { + calls.push([init.method, url]); + if (url === '/api/club/cards') { + const body = JSON.parse(init.body); + return { ok: true, status: 200, json: async () => ({ ver: 4, cards: { ...body, publishedBy: 'me', publishedAt: 1 } }) }; + } + return { ok: true, status: 200, json: async () => ({ stored: true }) }; + }); + + const result = await mod.publishCardSet(id, { label: 'Custom 1', getToken: () => 'tok', fetchImpl }); + expect(result.ok).toBe(true); + expect(calls.map(([method]) => method)).toEqual(['PUT', 'PUT', 'PUT']); + expect(calls[2][1]).toBe('/api/club/cards'); + const [, uploadInit] = fetchImpl.mock.calls[0]; + expect(uploadInit.headers).toMatchObject({ 'X-Club': 'club-token', Authorization: 'Bearer tok' }); + + expect(mod.loadClub().ver).toBe(4); + expect(Object.keys(mod.loadClubCards().images)).toEqual(['blue', 'red']); + // The publisher already holds the pixels; nothing is left to download. + expect(mod.missingClubCardHashes()).toEqual([]); + }); + + // Another publisher's set may prune an upload between our upload and our publish. + it('re-uploads what the server says is missing, once', async () => { + const { mod, id } = await withCustomSet(); + let publishes = 0; + let missingHash; + const fetchImpl = vi.fn(async (url, init) => { + if (url !== '/api/club/cards') return { ok: true, status: 200, json: async () => ({ stored: true }) }; + publishes += 1; + const body = JSON.parse(init.body); + if (publishes === 1) { + missingHash = body.images.blue; + return { ok: false, status: 409, json: async () => ({ error: 'missing_images', missing: [missingHash] }) }; + } + return { ok: true, status: 200, json: async () => ({ ver: 5, cards: body }) }; + }); + + expect((await mod.publishCardSet(id, { fetchImpl })).ok).toBe(true); + expect(publishes).toBe(2); + expect(fetchImpl.mock.calls.filter(([url]) => url === `/api/club/cards/${missingHash}`)).toHaveLength(2); + }); + + it('says why it was refused', async () => { + const { mod, id } = await withCustomSet('member'); + const fetchImpl = vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ error: 'forbidden' }) })); + expect(await mod.publishCardSet(id, { fetchImpl })).toEqual({ ok: false, error: 'forbidden' }); + expect(await mod.publishCardSet('custom-9', { fetchImpl })).toEqual({ ok: false, error: 'no_set' }); + const offline = vi.fn(async () => { throw new Error('offline'); }); + expect(await mod.publishCardSet(id, { fetchImpl: offline })).toEqual({ ok: false, error: 'network' }); + }); +}); diff --git a/packages/shared/cardImages.js b/packages/shared/cardImages.js index 463e265..8970531 100644 --- a/packages/shared/cardImages.js +++ b/packages/shared/cardImages.js @@ -23,6 +23,7 @@ // rest of the session, so migration is invisible. import { notifyLocalWrite } from './storageEvents.js'; +import { loadClub, loadClubCards, readCardSource, writeCardSource } from './club.js'; const SETTINGS_KEY = 'toastmaster_custom_card_images'; const SETTINGS_VERSION = 3; @@ -50,6 +51,13 @@ const OWN_BACKGROUND_FLAG_KEY = 'toastmaster_own_background'; export const CARD_COLORS = ['blue', 'green', 'yellow', 'red']; +// The club's published set (club.js keeps which hashes it is). It is never one +// of the device's own sets: it is not in the metadata, it cannot be deleted +// here, and its Blobs are keyed by hash, so a republished set replaces the old +// one without a delete step and an unchanged image is never fetched twice. +export const CLUB_CARD_SET_ID = 'club'; +const clubImageKey = (hash) => `club:${hash}`; + // Cache-buster for the built-in files. They are served with // `max-age=31536000, immutable`, so this must be bumped whenever their // content changes or existing clients keep the old asset for a year. @@ -104,6 +112,10 @@ const CARD_JPEG_QUALITY = 0.85; // Metadata: { selectedSetId, customSets: [{ id, colors: [...] }] }. let settingsCache = null; +// Whether the metadata key exists at all. Absent means this device has never +// made a choice of its own, which is what lets the club's set show by default. +let settingsStored = false; +const listeners = new Set(); // `${setId}:${color}` -> a displayable URL: an object URL minted from the // stored Blob, or a legacy data URL still being served out of localStorage. @@ -152,8 +164,10 @@ function normalizeMeta(raw) { function readStoredState() { const inlineImages = new Map(); let meta = normalizeMeta(null); + settingsStored = false; try { const raw = localStorage.getItem(SETTINGS_KEY); + settingsStored = Boolean(raw); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { @@ -212,9 +226,11 @@ function writeSettings(meta) { try { if (normalized.selectedSetId === DEFAULT_SET_ID && normalized.customSets.length === 0) { localStorage.removeItem(SETTINGS_KEY); + settingsStored = false; notifyLocalWrite(SETTINGS_KEY); } else { localStorage.setItem(SETTINGS_KEY, JSON.stringify({ version: SETTINGS_VERSION, ...normalized })); + settingsStored = true; notifyLocalWrite(SETTINGS_KEY); } settingsCache = normalized; @@ -408,6 +424,15 @@ async function loadImagesIntoCache() { } } } + // Only the club's current set is kept. A set it has since replaced, or a + // club this device has left, is swept with the other orphans. + for (const hash of Object.values(loadClubCards()?.images ?? {})) { + const key = clubImageKey(hash); + referenced.add(key); + if (!imageUrlCache.has(key) && isBlobLike(stored.get(key))) { + cacheBlobUrl(key, stored.get(key)); + } + } // A crash between a Blob write and its metadata write leaves orphans; // they are invisible, so sweep them here rather than letting them pile up. @@ -430,7 +455,12 @@ async function loadImagesIntoCache() { * @returns {{url: string} | {file: string}} */ export function resolveCardImage(color) { - const { selectedSetId } = getCardImageSettings(); + const selectedSetId = activeCardSetId(); + if (selectedSetId === CLUB_CARD_SET_ID) { + const url = getClubCardImage(color); + if (url) return { url }; + return { file: CLASSIC_FILES[color] || CLASSIC_FILES.blue }; + } const defaultSet = getDefaultCardSet(selectedSetId); if (defaultSet) { return { file: defaultSet.files[color] || defaultSet.files.blue }; @@ -453,14 +483,120 @@ export function getCustomCardImage(setId, color) { // Writes // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// The club's set +// --------------------------------------------------------------------------- + +/** + * Whether this device has a club set it could show: a club that is still + * entitled, with a set published. A lapsed club keeps its cached record, so + * this is what makes lapse fall back to the device's own cards for free. + */ +export function clubCardSetAvailable() { + return Boolean(loadClub()?.entitled && loadClubCards()); +} + +/** + * The set the timer is showing: the club's, or one of the device's own. + * + * The same switch as the club's presets. A device that has never chosen cards + * of its own opens on the club's set; one that has keeps its own, with the + * club's one tap away in the picker. + * + * @returns {string} CLUB_CARD_SET_ID, a built-in id, or a custom set id + */ +export function activeCardSetId() { + const { selectedSetId } = getCardImageSettings(); + if (!clubCardSetAvailable()) return selectedSetId; + const source = readCardSource(); + if (source === 'club') return CLUB_CARD_SET_ID; + if (source === 'own') return selectedSetId; + return settingsStored ? selectedSetId : CLUB_CARD_SET_ID; +} + +/** + * One color of the club's set, as a displayable URL. Null when the set skips + * that color (the Classic card shows, as for a custom set) or its Blob has not + * arrived yet. + */ +export function getClubCardImage(color) { + const hash = loadClubCards()?.images?.[color]; + return hash ? imageUrlCache.get(clubImageKey(hash)) || null : null; +} + +/** The club set's hashes this device does not hold yet. */ +export function missingClubCardHashes() { + const hashes = new Set(Object.values(loadClubCards()?.images ?? {})); + return [...hashes].filter((hash) => !imageUrlCache.has(clubImageKey(hash))); +} + +/** + * Keep the club set's images, downloaded or just published from here. + * + * @param {Array<[string, Blob]>} entries - [hash, Blob] + * @returns {Promise} hashes actually stored + */ +export async function storeClubCardBlobs(entries) { + const usable = entries.filter(([hash, blob]) => hash && isBlobLike(blob)); + if (!usable.length) return []; + const db = await openImageDb(); + if (!db) return []; + try { + await idbPutMany(db, usable.map(([hash, blob]) => [clubImageKey(hash), blob])); + } catch (error) { + console.error('Failed to store the club card images:', error); + return []; + } + for (const [hash, blob] of usable) { + revokeCached(clubImageKey(hash)); + cacheBlobUrl(clubImageKey(hash), blob); + } + notifyCardImages(); + return usable.map(([hash]) => hash); +} + +/** + * Hear about card artwork that changed without the picker being open: the + * club's set arriving, being replaced, or going away. + * + * @param {() => void} listener + * @returns {() => void} unsubscribe + */ +export function subscribeCardImages(listener) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Tell subscribers the cards the timer shows may have changed. */ +export function notifyCardImages() { + for (const listener of listeners) { + try { + listener(); + } catch { + // One bad subscriber must not stop the others hearing about it. + } + } +} + /** * Select which set the timer shows. Metadata-only, so it is synchronous. + * + * Choosing the club's set, or any other set while the club's is on offer, moves + * the card switch explicitly, so the derived default never decides for someone + * who has already decided. + * * @returns {boolean} false when the id names no existing set or storage * refused the write */ export function selectCardSet(setId) { + if (setId === CLUB_CARD_SET_ID) { + if (!clubCardSetAvailable()) return false; + writeCardSource('club'); + return true; + } const meta = getCardImageSettings(); if (!getDefaultCardSet(setId) && !meta.customSets.some((set) => set.id === setId)) return false; + if (clubCardSetAvailable()) writeCardSource('own'); return writeSettings({ ...meta, selectedSetId: setId }); } @@ -496,6 +632,8 @@ export async function addCustomCardSet(imagesByColor) { customSets: [...meta.customSets, { id, colors }], }; if (!writeSettings(nextMeta)) return null; + // A set just added is a set someone wants to see, club or no club. + if (clubCardSetAvailable()) writeCardSource('own'); for (const color of colors) cacheBlobUrl(imageKey(id, color), imagesByColor[color]); @@ -753,3 +891,13 @@ export async function storeCardBlobs(entries) { } return usable.map(([key]) => key); } + +/** Test seam: forget everything held in memory, as a page reload would. */ +export function resetCardImagesForTests() { + settingsCache = null; + settingsStored = false; + imageUrlCache.clear(); + initPromise = null; + dbPromise = null; + listeners.clear(); +} diff --git a/packages/shared/club.js b/packages/shared/club.js index 71302e7..e81a2cb 100644 --- a/packages/shared/club.js +++ b/packages/shared/club.js @@ -26,6 +26,13 @@ export const CLUB_STORAGE_KEY = 'toastmaster_club'; export const CLUB_PRESETS_STORAGE_KEY = 'toastmaster_club_presets'; /** Which list this device is running: 'club' or 'personal'. */ export const PRESET_SOURCE_STORAGE_KEY = 'toastmaster_preset_source'; +/** The club's published card set, as the device last received it: hashes, not pixels. */ +export const CLUB_CARDS_STORAGE_KEY = 'toastmaster_club_cards'; +/** + * Which cards this device shows: 'club' or 'own'. Unset means derived, as the + * preset switch is: a device that never chose its own cards shows the club's. + */ +export const CARD_SOURCE_STORAGE_KEY = 'toastmaster_card_source'; /** * Where this device puts the club's badge, when it has moved it. * @@ -176,6 +183,82 @@ export function saveClubPresets(presets) { } } +// Parsed once and kept: the card resolver reads this on every status change, +// which sits inside the 25 ms card-switch budget. +let cardsCache; +let cardsLoaded = false; + +function normalizeClubCards(raw) { + if (!raw || typeof raw !== 'object' || !raw.images || typeof raw.images !== 'object') return null; + const images = {}; + for (const [color, hash] of Object.entries(raw.images)) { + if (typeof hash === 'string' && hash) images[color] = hash; + } + if (!Object.keys(images).length) return null; + return { + label: typeof raw.label === 'string' && raw.label ? raw.label : null, + images, + publishedBy: raw.publishedBy ?? null, + publishedAt: typeof raw.publishedAt === 'number' ? raw.publishedAt : null, + }; +} + +/** + * The club's published card set, as this device last received it, or null. + * + * @returns {{label: string|null, images: Object, publishedBy: string|null, + * publishedAt: number|null}|null} + */ +export function loadClubCards() { + if (!cardsLoaded) { + try { + const raw = localStorage.getItem(CLUB_CARDS_STORAGE_KEY); + cardsCache = raw ? normalizeClubCards(JSON.parse(raw)) : null; + } catch { + cardsCache = null; + } + cardsLoaded = true; + } + return cardsCache ?? null; +} + +/** @param {Object|null} cards - null removes the key */ +export function saveClubCards(cards) { + const normalized = normalizeClubCards(cards); + cardsCache = normalized; + cardsLoaded = true; + try { + if (normalized) localStorage.setItem(CLUB_CARDS_STORAGE_KEY, JSON.stringify(normalized)); + else localStorage.removeItem(CLUB_CARDS_STORAGE_KEY); + } catch { + // Private mode. The in-memory copy carries this page load. + } +} + +/** The stored card switch, or null when it has never been set explicitly. */ +export function readCardSource() { + try { + const value = localStorage.getItem(CARD_SOURCE_STORAGE_KEY); + return value === 'club' || value === 'own' ? value : null; + } catch { + return null; + } +} + +/** + * Move the card switch. Explicit only, like the preset switch. + * + * @param {'club'|'own'|null} source - null clears it back to derived + */ +export function writeCardSource(source) { + try { + if (source === 'club' || source === 'own') localStorage.setItem(CARD_SOURCE_STORAGE_KEY, source); + else localStorage.removeItem(CARD_SOURCE_STORAGE_KEY); + } catch { + // The derived default still answers for this page load. + } +} + /** The stored switch position, or null when it has never been set explicitly. */ export function readPresetSource() { try { @@ -683,8 +766,10 @@ export async function activateClub(code, { getToken, fetchImpl } = {}) { // Activation is the one moment a device has no club list at all, so the // published one is taken unconditionally. Whether it is the list that shows - // is a separate question, answered by the switch in clubPresets.js. + // is a separate question, answered by the switch in clubPresets.js. The card + // set lands the same way, behind its own switch in cardImages.js. saveClubPresets(body.presets ?? null); + saveClubCards(body.cards ?? null); const entry = store(toCacheEntry(body, { clubToken: body.clubToken, lastRefreshAt: Date.now() })); applyPlan(clubEntitlementOf(entry)); warmLogoQuietly(); @@ -746,6 +831,7 @@ export async function createClub(details = {}, { getToken, fetchImpl } = {}) { // Same landing as activation: the creator's device is on the club before this // resolves, so nobody is ever shown a code and asked to type it back in. saveClubPresets(body.presets ?? null); + saveClubCards(body.cards ?? null); const entry = store(toCacheEntry(body, { clubToken: body.clubToken, lastRefreshAt: Date.now() })); applyPlan(clubEntitlementOf(entry)); warmLogoQuietly(); @@ -821,7 +907,12 @@ export async function refreshClub({ getToken, fetchImpl, force = false, now = Da // a device's list exactly as it was, and only an actual publish replaces it — // and even then the switch is not touched, so a device sitting on its own // presets stays there with a newer club list waiting behind the toggle. - if ((body.ver ?? 1) !== (club.ver ?? 1)) saveClubPresets(body.presets ?? null); + const verMoved = (body.ver ?? 1) !== (club.ver ?? 1); + if (verMoved) saveClubPresets(body.presets ?? null); + // The card set rides the same version. It is also taken when this device has + // none at all, so a device that last refreshed on a bundle that did not know + // about card sets still receives the one already published. + if (verMoved || (!loadClubCards() && body.cards)) saveClubCards(body.cards ?? null); const entry = store(toCacheEntry(body, { clubToken: body.clubToken ?? club.clubToken, lastRefreshAt: now })); applyPlan(clubEntitlementOf(entry)); @@ -868,6 +959,10 @@ export function leaveClub({ fetchImpl } = {}) { // the club was active, so there is nothing to put back. saveClubPresets(null); writePresetSource(null); + // The card set and its switch go too. The device's own sets were never + // touched, so its own choice is simply what shows again. + saveClubCards(null); + writeCardSource(null); // The badge placement goes with the club: it is a position on the club's // badge, and there is no badge to place once the club is gone. clearClubBadgeOverride(); @@ -905,10 +1000,28 @@ export function rememberPublishedPresets(ver, presets) { return store({ ...club, ver: typeof ver === 'number' ? ver : club.ver }); } +/** + * Take the answer to a card-set publish or removal, for the same reason + * `rememberPublishedPresets` exists: the publisher's own device must not read + * its next refresh as news. + * + * @param {number} ver + * @param {Object|null} cards + * @returns {Object|null} the club as it now stands + */ +export function rememberPublishedCards(ver, cards) { + const club = loadClub(); + if (!club) return null; + saveClubCards(cards ?? null); + return store({ ...club, ver: typeof ver === 'number' ? ver : club.ver }); +} + /** Test seam: drop the in-memory copy so the next read comes from storage. */ export function resetClubForTests() { cached = undefined; loaded = false; + cardsCache = undefined; + cardsLoaded = false; listeners.clear(); logoImage = null; logoImageUrl = null; diff --git a/packages/shared/clubCards.js b/packages/shared/clubCards.js new file mode 100644 index 0000000..924aa57 --- /dev/null +++ b/packages/shared/clubCards.js @@ -0,0 +1,208 @@ +import { loadClub, loadClubCards, subscribeClub, rememberPublishedCards } from './club.js'; +import { + CARD_COLORS, + cardImageKey, + getCardImageSettings, + initCardImages, + missingClubCardHashes, + notifyCardImages, + readStoredCardBlobs, + storeClubCardBlobs, +} from './cardImages.js'; + +/** + * Moving the club's card set between devices: downloading it where it is + * missing, and publishing one of this device's own sets as the club's. + * + * The pixels go to R2 under the club, content-addressed by SHA-256; the club + * state carries only the hashes. A device holds the images in IndexedDB, so + * the card-switch path never waits on the network. + */ + +const CARDS_ENDPOINT = '/api/club/cards'; + +/** + * SHA-256 of a Blob, as lowercase hex: the id the Worker checks the bytes + * against, so it is computed exactly the way the Worker computes it. + * + * @param {Blob} blob + * @returns {Promise} null where WebCrypto is unavailable + */ +export async function hashCardBlob(blob) { + if (typeof crypto === 'undefined' || !crypto.subtle) return null; + try { + const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); + } catch { + return null; + } +} + +/** Whether this device's signed-in user may publish a card set to the club. */ +export function canPublishCards() { + const club = loadClub(); + if (!club?.entitled) return false; + return club.role === 'admin' || club.role === 'editor'; +} + +/** + * Fetch the club set's images this device does not hold yet. Never rejects: + * a card that has not arrived shows the Classic card, which is what a device + * with no club shows anyway. + * + * @param {{fetchImpl?: typeof fetch}} [options] + * @returns {Promise} hashes stored + */ +export async function syncClubCardImages({ fetchImpl } = {}) { + const club = loadClub(); + if (!club?.clubToken || !loadClubCards()) return []; + await initCardImages(); + + const entries = []; + for (const hash of missingClubCardHashes()) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await (fetchImpl ?? fetch)(`${CARDS_ENDPOINT}/${hash}`, { + headers: { 'X-Club': club.clubToken }, + credentials: 'same-origin', + }); + if (!response.ok) continue; + // Only an image is kept. An SPA fallback or a captive portal answers 200 + // with a page, and a page stored as a card would show as a broken image + // until the club republished. + if (!(response.headers?.get?.('content-type') || '').startsWith('image/')) continue; + // eslint-disable-next-line no-await-in-loop + entries.push([hash, await response.blob()]); + } catch { + // Offline. The next app start, or the next club change, tries again. + } + } + return storeClubCardBlobs(entries); +} + +/** What the timer would show from the club, so a change to it can be noticed. */ +function clubCardsSignature() { + const club = loadClub(); + const cards = loadClubCards(); + return `${Boolean(club?.entitled)}|${CARD_COLORS.map((color) => cards?.images?.[color] ?? '').join(',')}`; +} + +/** + * Keep the club's set on this device for as long as the app runs. + * + * Fetches what is missing now, and again whenever the club changes: a new + * publish, a removal, a lapse or leaving. Each of those also tells the timer + * to re-show its card, since the set it should show may have changed. + * + * @param {{fetchImpl?: typeof fetch}} [options] + * @returns {() => void} stop + */ +export function startClubCardSync({ fetchImpl } = {}) { + let last = clubCardsSignature(); + syncClubCardImages({ fetchImpl }).catch(() => {}); + return subscribeClub(() => { + const next = clubCardsSignature(); + if (next === last) return; + last = next; + notifyCardImages(); + syncClubCardImages({ fetchImpl }).catch(() => {}); + }); +} + +function errorFor(status, body) { + if (body?.error) return body.error; + if (status === 401 || status === 403) return 'forbidden'; + if (status === 402) return 'upgrade_required'; + return 'publish_failed'; +} + +async function readBody(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +/** + * Publish one of this device's own sets as the club's. + * + * Refused server-side for anyone but an admin or editor, like a presets + * publish. Uploads go first, then the set; if another publisher's set pruned an + * image in between, the missing ones are uploaded once more and the set is + * sent again. + * + * @param {string} setId - a custom set id, e.g. 'custom-1' + * @param {{label?: string, getToken?: () => string|null, fetchImpl?: typeof fetch}} [options] + * @returns {Promise<{ok: true, ver: number, cards: Object}|{ok: false, error: string}>} + * never rejects + */ +export async function publishCardSet(setId, { label, getToken, fetchImpl } = {}) { + const club = loadClub(); + if (!club?.clubToken) return { ok: false, error: 'no_club' }; + + const set = getCardImageSettings().customSets.find((s) => s.id === setId); + if (!set) return { ok: false, error: 'no_set' }; + + const stored = await readStoredCardBlobs(); + const blobs = new Map(); + const images = {}; + for (const color of set.colors) { + const blob = stored.get(cardImageKey(setId, color)); + if (!blob) continue; + // eslint-disable-next-line no-await-in-loop + const hash = await hashCardBlob(blob); + if (!hash) return { ok: false, error: 'unsupported_browser' }; + images[color] = hash; + blobs.set(hash, blob); + } + if (!Object.keys(images).length) return { ok: false, error: 'no_set' }; + + const token = getToken?.(); + const headers = { + 'X-Club': club.clubToken, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + const send = (url, init) => (fetchImpl ?? fetch)(url, { credentials: 'same-origin', cache: 'no-store', ...init }); + + const upload = async (hashes) => { + for (const hash of hashes) { + const blob = blobs.get(hash); + // eslint-disable-next-line no-await-in-loop + const response = await send(`${CARDS_ENDPOINT}/${hash}`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': blob.type || 'image/jpeg' }, + body: blob, + }); + // eslint-disable-next-line no-await-in-loop + if (!response.ok) return errorFor(response.status, await readBody(response)); + } + return null; + }; + + try { + const uploadError = await upload([...blobs.keys()]); + if (uploadError) return { ok: false, error: uploadError }; + + const body = JSON.stringify({ label: label ?? null, images }); + const put = () => + send(CARDS_ENDPOINT, { method: 'PUT', headers: { ...headers, 'Content-Type': 'application/json' }, body }); + + let response = await put(); + let result = await readBody(response); + if (response.status === 409 && Array.isArray(result?.missing)) { + const retryError = await upload(result.missing.filter((hash) => blobs.has(hash))); + if (retryError) return { ok: false, error: retryError }; + response = await put(); + result = await readBody(response); + } + if (!response.ok || !result?.cards) return { ok: false, error: errorFor(response.status, result) }; + + // The publisher already holds every image, so it never downloads its own set. + await storeClubCardBlobs([...blobs.entries()]); + rememberPublishedCards(result.ver, result.cards); + return { ok: true, ver: result.ver, cards: result.cards }; + } catch { + return { ok: false, error: 'network' }; + } +} diff --git a/packages/shared/index.js b/packages/shared/index.js index e23e94d..eacd07d 100644 --- a/packages/shared/index.js +++ b/packages/shared/index.js @@ -16,6 +16,7 @@ export * from './flags.js'; export * from './club.js'; export * from './clubBadge.js'; export * from './clubPresets.js'; +export * from './clubCards.js'; export * from './clubArchive.js'; export * from './reportImage.js'; export * from './timerSounds.js'; diff --git a/packages/shared/package.json b/packages/shared/package.json index c1a124d..d3ef270 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,7 @@ "./club": "./club.js", "./clubBadge": "./clubBadge.js", "./clubPresets": "./clubPresets.js", + "./clubCards": "./clubCards.js", "./clubArchive": "./clubArchive.js", "./reportImage": "./reportImage.js" } diff --git a/worker/club-admin-routes.js b/worker/club-admin-routes.js index 5787d4c..1c9ab86 100644 --- a/worker/club-admin-routes.js +++ b/worker/club-admin-routes.js @@ -14,6 +14,7 @@ import { writeAdminIndex, } from './club-admin.js'; import { buildKit, DEFAULT_PRIMARY_COLOR } from './club.js'; +import { buildCards, readClubCards } from './club-cards.js'; import { readAdminSession } from './club-magic.js'; /** @@ -262,6 +263,7 @@ export async function handleClubRoster(request, env) { ver: club.ver ?? 1, }, kit: buildKit(clubId, club), + cards: buildCards(await readClubCards(env, clubId)), actor, role: 'admin', plan: access.plan, diff --git a/worker/club-admin.js b/worker/club-admin.js index 28b2639..a9c9b36 100644 --- a/worker/club-admin.js +++ b/worker/club-admin.js @@ -141,6 +141,7 @@ export const clubByEmailKey = (email) => `club-by-email:${normalizeEmail(email)} export const clubDeviceKey = (clubId, deviceId) => `club-device:${clubId}:${deviceId}`; export const clubMemberKey = (clubId, uid) => `club-member:${clubId}:zoom:${uid}`; export const clubPresetsKey = (clubId) => `club-presets:${clubId}`; +export const clubCardsKey = (clubId) => `club-cards:${clubId}`; /** * The club a signed-in person administers, found from their uid alone. diff --git a/worker/club-cards.js b/worker/club-cards.js new file mode 100644 index 0000000..2f82c73 --- /dev/null +++ b/worker/club-cards.js @@ -0,0 +1,367 @@ +import crypto from 'node:crypto'; +import { readSession, readClub } from './auth.js'; +import { json, methodNotAllowed, unauthorized } from './http.js'; +import { entitlementStore, clubKey, readClubRecord, resolveAccess } from './entitlements.js'; +import { canPublish, clubCardsKey, readMemberRole } from './club-admin.js'; +import { verifiedClubId } from './club.js'; +import { readAdminContext } from './club-admin-routes.js'; + +/** + * The club's card set: one set of timing-card artwork every club device can show. + * + * Custom card images used to follow a *person* between their own computers. The + * timer role rotates every week, so the person who uploaded the club's artwork + * was rarely the person timing with it. A set published here reaches every + * device holding the club code instead, the same way the club's presets do. + * + * PUT /api/club/cards/ upload one image (admin or editor) + * GET /api/club/cards/ download one image (any club device, or the console) + * PUT /api/club/cards publish { label, images: { color: hash } } (admin or editor) + * DELETE /api/club/cards take the set down (admin) + * + * Images live at `club-card//`, deliberately outside `club//`: + * `/api/club-assets/*` serves everything under that prefix to anyone, and card + * artwork is private. Devices keep a copy in IndexedDB, so they never need the + * public CDN path the logo needs. + * + * Publishing and removing bump `club:.ver`, which is what makes devices + * replace their copy, exactly as a presets publish does. + */ + +export const CARD_COLORS = Object.freeze(['blue', 'green', 'yellow', 'red']); + +/** The same limit personal card artwork had: a 1280×720 JPEG is a fraction of it. */ +export const MAX_CARD_BYTES = 2 * 1024 * 1024; +/** Per club. Ten full sets of four, which is far more than one published set ever holds. */ +export const MAX_CLUB_CARD_BYTES = 20 * 1024 * 1024; +const MAX_LABEL_LENGTH = 40; +const HASH_PATTERN = /^[0-9a-f]{64}$/; + +/** + * Images uploaded this recently are never pruned. A second publisher's uploads + * land before their publish does; without this window, a publish in between + * would delete them and their publish would fail. + */ +export const PRUNE_GRACE_MS = 15 * 60 * 1000; + +export const clubCardObjectKey = (clubId, hash) => `club-card/${clubId}/${hash}`; +const clubCardPrefix = (clubId) => `club-card/${clubId}/`; + +const forbidden = () => json({ error: 'forbidden' }, 403); + +/** + * The image type from the bytes themselves, never from the header the client + * sent. Raster only, for the reason the logo is: an SVG is a document that can + * carry script, and this route answers on the same origin as every session. + * + * @param {Uint8Array} bytes + * @returns {'image/jpeg'|'image/png'|'image/webp'|null} + */ +export function sniffImageType(bytes) { + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'; + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && + bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a + ) { + return 'image/png'; + } + const ascii = (from, to) => String.fromCharCode(...bytes.slice(from, to)); + if (bytes.length >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'image/webp'; + return null; +} + +/** + * A published body as we store it, or null if it is unusable. A set needs at + * least one colour; the colours it skips show the Classic card on every device, + * the same rule a personal custom set follows. + * + * @param {unknown} body + * @returns {{label: string|null, images: Object}|null} + */ +export function normalizeCardSet(body) { + if (!body || typeof body !== 'object') return null; + const raw = body.images; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + + const images = {}; + for (const [color, hash] of Object.entries(raw)) { + if (!CARD_COLORS.includes(color)) return null; + if (typeof hash !== 'string' || !HASH_PATTERN.test(hash)) return null; + images[color] = hash; + } + if (!Object.keys(images).length) return null; + + const label = typeof body.label === 'string' ? body.label.trim().slice(0, MAX_LABEL_LENGTH) : ''; + return { label: label || null, images }; +} + +/** Whether a publish would change anything a device would see. */ +export function cardSetUnchanged(stored, next) { + if (!stored) return false; + return ( + (stored.label ?? null) === next.label && + CARD_COLORS.every((color) => (stored.images?.[color] ?? null) === (next.images[color] ?? null)) + ); +} + +/** + * The club's published set, or null. Never throws: it runs on the app-start + * refresh, which must not fail because of a card set. + * + * @returns {Promise} + */ +export async function readClubCards(env, clubId) { + if (!clubId) return null; + const store = entitlementStore(env); + if (!store) return null; + try { + return (await store.get(clubCardsKey(clubId), 'json')) ?? null; + } catch { + return null; + } +} + +/** + * The set as a device needs it: hashes, not URLs. A device already knows the + * route, and the hashes are what it compares against what it holds. + * + * @returns {{label: string|null, images: Object, publishedBy: string|null, + * publishedAt: number|null}|null} + */ +export function buildCards(record) { + if (!record?.images || typeof record.images !== 'object') return null; + const images = {}; + for (const color of CARD_COLORS) { + const hash = record.images[color]; + if (typeof hash === 'string' && HASH_PATTERN.test(hash)) images[color] = hash; + } + if (!Object.keys(images).length) return null; + return { + label: record.label ?? null, + images, + publishedBy: record.publishedBy ?? null, + publishedAt: record.publishedAt ?? null, + }; +} + +/** + * Who may upload or publish: a named admin or editor on a device that still + * holds the club. The same checks, in the same order, as a presets publish. + * + * @returns {Promise<{store: Object, clubId: string, club: Object, uid: string}|{response: Response}>} + */ +async function requirePublisher(request, env) { + const session = readSession(request, env); + if (!session?.uid) return { response: unauthorized() }; + const claims = readClub(request, env); + if (!claims) return { response: unauthorized() }; + + const store = entitlementStore(env); + if (!store) return { response: json({ error: 'Club storage is not configured' }, 503) }; + if (!env.CARD_ASSETS) return { response: json({ error: 'Asset storage is not configured' }, 503) }; + + const clubId = await verifiedClubId(env, claims); + if (!clubId) return { response: forbidden() }; + + const club = await readClubRecord(env, clubId); + if (!club) return { response: json({ error: 'club_not_found' }, 404) }; + + const access = await resolveAccess(env, { uid: session.uid, clubId, club }); + if (!access.entitled) return { response: json({ error: 'upgrade_required', entitlement: access }, 402) }; + + if (!canPublish(await readMemberRole(env, clubId, session.uid))) return { response: forbidden() }; + + return { store, clubId, club, uid: session.uid }; +} + +async function usedBytes(env, clubId) { + let total = 0; + let cursor; + do { + // eslint-disable-next-line no-await-in-loop + const listed = await env.CARD_ASSETS.list({ prefix: clubCardPrefix(clubId), cursor }); + for (const object of listed.objects ?? []) total += object.size ?? 0; + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + return total; +} + +/** + * Delete the club's images that no published set names any more. Best effort: + * an orphan costs pennies, and a failed publish costs trust. + */ +async function pruneUnreferenced(env, clubId, keep, now = Date.now()) { + try { + let cursor; + do { + // eslint-disable-next-line no-await-in-loop + const listed = await env.CARD_ASSETS.list({ prefix: clubCardPrefix(clubId), cursor }); + for (const object of listed.objects ?? []) { + const hash = object.key.slice(clubCardPrefix(clubId).length); + if (keep.has(hash)) continue; + const uploaded = object.uploaded ? new Date(object.uploaded).getTime() : 0; + if (now - uploaded < PRUNE_GRACE_MS) continue; + // eslint-disable-next-line no-await-in-loop + await env.CARD_ASSETS.delete(object.key); + } + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + } catch { + // Nothing a publisher can do about it, and nothing they need to know. + } +} + +async function bumpVer(store, clubId, club) { + const ver = (club.ver ?? 1) + 1; + await store.put(clubKey(clubId), JSON.stringify({ ...club, ver })); + return ver; +} + +/** PUT /api/club/cards/ — one image, content-addressed. */ +async function handleCardUpload(request, env, hash) { + const ctx = await requirePublisher(request, env); + if (ctx.response) return ctx.response; + const { clubId } = ctx; + + const declared = Number(request.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_CARD_BYTES) return json({ error: 'image_too_large' }, 413); + + const bytes = new Uint8Array(await request.arrayBuffer()); + if (!bytes.byteLength) return json({ error: 'empty_image' }, 400); + if (bytes.byteLength > MAX_CARD_BYTES) return json({ error: 'image_too_large' }, 413); + + // The name must be the real digest, or the store is not content-addressed: + // a client could park anything under a hash another device is about to fetch. + const actual = crypto.createHash('sha256').update(bytes).digest('hex'); + if (actual !== hash) return json({ error: 'hash_mismatch' }, 400); + + const type = sniffImageType(bytes); + if (!type) return json({ error: 'unsupported_image' }, 415); + + const key = clubCardObjectKey(clubId, hash); + // Idempotent, and a retry never counts twice against the quota. + if (await env.CARD_ASSETS.head(key)) return json({ stored: true, deduped: true }); + + if ((await usedBytes(env, clubId)) + bytes.byteLength > MAX_CLUB_CARD_BYTES) { + return json({ error: 'storage_limit' }, 507); + } + + await env.CARD_ASSETS.put(key, bytes, { httpMetadata: { contentType: type } }); + return json({ stored: true, deduped: false }); +} + +/** + * GET /api/club/cards/ — for any device holding the club, or for the + * console, which may reach it through a sign-in or the billing link and carry + * no club token at all. + * + * A read, so revocation is not consulted: a revoked device keeps reading the + * club it was already reading until its token expires, as it does everywhere else. + */ +async function handleCardDownload(request, env, hash) { + if (!env.CARD_ASSETS) return json({ error: 'Asset storage is not configured' }, 503); + + let clubId = readClub(request, env)?.clubId ?? null; + if (!clubId) { + const admin = await readAdminContext(request, env); + if (admin?.role === 'admin') clubId = admin.clubId; + } + if (!clubId) return unauthorized(); + + const key = clubCardObjectKey(clubId, hash); + const object = request.method === 'HEAD' ? await env.CARD_ASSETS.head(key) : await env.CARD_ASSETS.get(key); + if (!object) return json({ error: 'not_found' }, 404); + + return new Response(request.method === 'HEAD' ? null : object.body, { + headers: { + 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream', + // Content-addressed, so the bytes behind this URL never change — but they + // are one club's, so no shared cache may keep them. + 'Cache-Control': 'private, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} + +/** PUT /api/club/cards — make a set of uploaded images the club's. */ +async function handleCardPublish(request, env) { + const ctx = await requirePublisher(request, env); + if (ctx.response) return ctx.response; + const { store, clubId, club, uid } = ctx; + + let body; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON' }, 400); + } + + const next = normalizeCardSet(body); + if (!next) return json({ error: 'invalid_card_set' }, 400); + + // Every image must already be here. A set naming one that is not would land + // on every device as a card that can never load. + const hashes = [...new Set(Object.values(next.images))]; + const heads = await Promise.all(hashes.map((hash) => env.CARD_ASSETS.head(clubCardObjectKey(clubId, hash)))); + const missing = hashes.filter((_, i) => !heads[i]); + if (missing.length) return json({ error: 'missing_images', missing }, 409); + + const stored = await readClubCards(env, clubId); + // An identical publish writes nothing: bumping `ver` for it would have every + // device in the club re-download the set it already has. + if (cardSetUnchanged(stored, next)) return json({ ver: club.ver ?? 1, cards: buildCards(stored) }); + + const record = { ...next, publishedBy: uid, publishedAt: Date.now() }; + await store.put(clubCardsKey(clubId), JSON.stringify(record)); + const ver = await bumpVer(store, clubId, club); + + await pruneUnreferenced(env, clubId, new Set(hashes)); + return json({ ver, cards: buildCards(record) }); +} + +/** + * DELETE /api/club/cards — admin only, from either door into the console. + * Every device goes back to its own choice of cards on its next refresh. + */ +async function handleCardRemove(request, env) { + const store = entitlementStore(env); + if (!store) return json({ error: 'Club storage is not configured' }, 503); + + const context = await readAdminContext(request, env); + if (!context) return unauthorized(); + if (context.role !== 'admin') return forbidden(); + + const club = await readClubRecord(env, context.clubId); + if (!club) return json({ error: 'club_not_found' }, 404); + + if (!(await readClubCards(env, context.clubId))) return json({ ver: club.ver ?? 1, cards: null }); + + await store.delete(clubCardsKey(context.clubId)); + const ver = await bumpVer(store, context.clubId, club); + if (env.CARD_ASSETS) await pruneUnreferenced(env, context.clubId, new Set()); + return json({ ver, cards: null }); +} + +/** + * The card routes, reached from `handleClub`. + * + * @param {Request} request + * @param {string} route - `cards` or `cards/` + * @param {Object} env + * @returns {Promise} + */ +export async function handleClubCards(request, route, env) { + if (route === 'cards') { + if (request.method === 'PUT') return handleCardPublish(request, env); + if (request.method === 'DELETE') return handleCardRemove(request, env); + return methodNotAllowed(); + } + + const hash = route.slice('cards/'.length); + // A literal digest only, so nothing a client sends can shape the object key. + if (!HASH_PATTERN.test(hash)) return json({ error: 'not_found' }, 404); + if (request.method === 'GET' || request.method === 'HEAD') return handleCardDownload(request, env, hash); + if (request.method === 'PUT') return handleCardUpload(request, env, hash); + return methodNotAllowed(); +} diff --git a/worker/club-cards.test.js b/worker/club-cards.test.js new file mode 100644 index 0000000..54bff5f --- /dev/null +++ b/worker/club-cards.test.js @@ -0,0 +1,401 @@ +import crypto from 'node:crypto'; +import { describe, it, expect, beforeEach } from 'vitest'; +import worker from './index.js'; +import { + handleClubCards, + normalizeCardSet, + cardSetUnchanged, + sniffImageType, + clubCardObjectKey, + PRUNE_GRACE_MS, + MAX_CARD_BYTES, +} from './club-cards.js'; +import { handleClubActivate, handleClubState } from './club.js'; +import { createClubFromPending, clubMemberKey, clubDeviceKey, clubCardsKey } from './club-admin.js'; +import { mintSessionToken } from './session-token.js'; +import { verifyClubToken } from './club-token.js'; +import { clubKey } from './entitlements.js'; + +/** + * The club's card set: artwork an admin or editor publishes once and every club + * device shows. Private on purpose, unlike the logo, which is why the objects + * live outside the prefix `/api/club-assets/*` serves. + */ + +const SIGNING_KEY = 'test-session-signing-key'; + +function makeKv(seed = {}) { + const store = new Map(Object.entries(seed).map(([k, v]) => [k, typeof v === 'string' ? v : JSON.stringify(v)])); + return { + store, + get: async (key, type) => { + const raw = store.get(key); + if (raw === undefined) return null; + return type === 'json' ? JSON.parse(raw) : raw; + }, + put: async (key, value) => { store.set(key, value); }, + delete: async (key) => { store.delete(key); }, + list: async ({ prefix = '' } = {}) => ({ + keys: [...store.keys()].filter((k) => k.startsWith(prefix)).map((name) => ({ name })), + list_complete: true, + }), + }; +} + +function makeBucket() { + const store = new Map(); + return { + store, + head: async (key) => (store.has(key) ? { size: store.get(key).bytes.byteLength, httpMetadata: store.get(key).options?.httpMetadata } : null), + get: async (key) => { + const entry = store.get(key); + if (!entry) return null; + return { body: entry.bytes, httpMetadata: entry.options?.httpMetadata }; + }, + put: async (key, bytes, options) => { store.set(key, { bytes, options, uploaded: new Date(Date.now()) }); }, + delete: async (key) => { store.delete(key); }, + list: async ({ prefix = '' } = {}) => ({ + objects: [...store.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, entry]) => ({ key, size: entry.bytes.byteLength, uploaded: entry.uploaded })), + truncated: false, + }), + }; +} + +const jpeg = (seed = 1, size = 64) => { + const bytes = new Uint8Array(size); + bytes.set([0xff, 0xd8, 0xff, 0xe0]); + bytes[size - 1] = seed; + return bytes; +}; +const sha = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); + +let kv; +let bucket; +let env; +let seeded; + +beforeEach(() => { + kv = makeKv(); + bucket = makeBucket(); + env = { PROFILES: kv, CARD_ASSETS: bucket, SESSION_SIGNING_KEY: SIGNING_KEY, ENTITLEMENT_ENFORCE: '1' }; + seeded = null; +}); + +/** + * Activate one device on the test's club, as `uid` with `role`. The club is + * seeded on the first call, so every device in a test joins the same one. + */ +async function activated({ uid, role } = {}) { + seeded ??= await createClubFromPending( + env, + { clubName: 'Downtown Speakers', uid: 'buyer-uid' }, + { code: 'DTSP7K2QM9' } + ); + const { clubId } = seeded; + if (uid && role) { + await kv.put(clubMemberKey(clubId, uid), JSON.stringify({ role, displayName: null, addedAt: 1, revokedAt: null })); + } + const res = await handleClubActivate( + new Request('https://x/api/club/activate', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(uid ? { authorization: `Bearer ${mintSessionToken(uid, SIGNING_KEY)}` } : {}), + }, + body: JSON.stringify({ code: 'DTSP-7K2QM9' }), + }), + env + ); + const body = await res.json(); + return { clubId, ...body, claims: verifyClubToken(body.clubToken, SIGNING_KEY) }; +} + +const headers = (clubToken, uid) => ({ + ...(clubToken ? { 'x-club': clubToken } : {}), + ...(uid ? { authorization: `Bearer ${mintSessionToken(uid, SIGNING_KEY)}` } : {}), +}); + +const upload = (clubToken, uid, bytes, hash = sha(bytes)) => + handleClubCards( + new Request(`https://x/api/club/cards/${hash}`, { + method: 'PUT', + headers: { 'content-type': 'image/jpeg', ...headers(clubToken, uid) }, + body: bytes, + }), + `cards/${hash}`, + env + ); + +const publish = (clubToken, uid, body) => + handleClubCards( + new Request('https://x/api/club/cards', { + method: 'PUT', + headers: { 'content-type': 'application/json', ...headers(clubToken, uid) }, + body: JSON.stringify(body), + }), + 'cards', + env + ); + +const download = (clubToken, hash, uid) => + handleClubCards(new Request(`https://x/api/club/cards/${hash}`, { headers: headers(clubToken, uid) }), `cards/${hash}`, env); + +const remove = (clubToken, uid) => + handleClubCards(new Request('https://x/api/club/cards', { method: 'DELETE', headers: headers(clubToken, uid) }), 'cards', env); + +const clubState = async (clubToken) => + (await handleClubState(new Request('https://x/api/club', { headers: { 'x-club': clubToken } }), env)).json(); + +// --------------------------------------------------------------------------- + +describe('sniffImageType', () => { + it('reads JPEG, PNG and WebP from their bytes', () => { + expect(sniffImageType(jpeg())).toBe('image/jpeg'); + expect(sniffImageType(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0]))).toBe('image/png'); + expect(sniffImageType(new TextEncoder().encode('RIFF\0\0\0\0WEBPVP8 '))).toBe('image/webp'); + }); + + // Same origin as every session cookie: an SVG is a document that can carry script. + it('refuses SVG and anything else', () => { + expect(sniffImageType(new TextEncoder().encode(''))).toBeNull(); + expect(sniffImageType(new Uint8Array([1, 2, 3]))).toBeNull(); + }); +}); + +describe('normalizeCardSet', () => { + const hash = 'a'.repeat(64); + + it('keeps known colours and a trimmed label', () => { + expect(normalizeCardSet({ label: ' Club cards ', images: { blue: hash, red: hash } })).toEqual({ + label: 'Club cards', + images: { blue: hash, red: hash }, + }); + expect(normalizeCardSet({ images: { green: hash } })).toEqual({ label: null, images: { green: hash } }); + }); + + it('refuses an empty set, an unknown colour, or a hash that is not a digest', () => { + expect(normalizeCardSet({ images: {} })).toBeNull(); + expect(normalizeCardSet({ images: { purple: hash } })).toBeNull(); + expect(normalizeCardSet({ images: { blue: '../../club/x' } })).toBeNull(); + expect(normalizeCardSet(null)).toBeNull(); + }); + + it('caps the label', () => { + expect(normalizeCardSet({ label: 'x'.repeat(100), images: { blue: hash } }).label).toHaveLength(40); + }); +}); + +describe('cardSetUnchanged', () => { + it('compares label and every colour', () => { + const a = { label: null, images: { blue: 'a'.repeat(64) } }; + expect(cardSetUnchanged(null, a)).toBe(false); + expect(cardSetUnchanged({ ...a, publishedAt: 1 }, a)).toBe(true); + expect(cardSetUnchanged(a, { ...a, label: 'New' })).toBe(false); + expect(cardSetUnchanged(a, { label: null, images: { blue: 'a'.repeat(64), red: 'b'.repeat(64) } })).toBe(false); + }); +}); + +describe('PUT /api/club/cards/', () => { + it('stores an editor\'s image under the club-card prefix, typed from its bytes', async () => { + const { clubToken, clubId } = await activated({ uid: 'editor-uid', role: 'editor' }); + const bytes = jpeg(); + const res = await upload(clubToken, 'editor-uid', bytes); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ stored: true, deduped: false }); + const stored = bucket.store.get(clubCardObjectKey(clubId, sha(bytes))); + expect(stored.options.httpMetadata.contentType).toBe('image/jpeg'); + + // A retry is free. + expect(await (await upload(clubToken, 'editor-uid', bytes)).json()).toEqual({ stored: true, deduped: true }); + }); + + // Uploading spends the club's quota and feeds a publish, so it needs the same + // person-level role a publish does. + it('needs a uid, a club, and an admin or editor role', async () => { + const { clubToken } = await activated({ uid: 'member-uid', role: 'member' }); + expect((await upload(clubToken, null, jpeg())).status).toBe(401); + expect((await upload(null, 'member-uid', jpeg())).status).toBe(401); + expect((await upload(clubToken, 'member-uid', jpeg())).status).toBe(403); + }); + + it('refuses a revoked device on the very next request', async () => { + const { clubToken, clubId, claims } = await activated({ uid: 'editor-uid', role: 'editor' }); + const device = JSON.parse(kv.store.get(clubDeviceKey(clubId, claims.deviceId))); + await kv.put(clubDeviceKey(clubId, claims.deviceId), JSON.stringify({ ...device, revokedAt: Date.now() })); + expect((await upload(clubToken, 'editor-uid', jpeg())).status).toBe(403); + }); + + it('refuses a lapsed club with the upgrade answer', async () => { + const { clubToken, clubId } = await activated({ uid: 'editor-uid', role: 'editor' }); + const club = JSON.parse(kv.store.get(clubKey(clubId))); + await kv.put(clubKey(clubId), JSON.stringify({ ...club, status: 'canceled', currentPeriodEnd: Date.now() - 1000 })); + expect((await upload(clubToken, 'editor-uid', jpeg())).status).toBe(402); + }); + + it('refuses bytes that do not match their name, are not an image, or are too large', async () => { + const { clubToken } = await activated({ uid: 'editor-uid', role: 'editor' }); + expect((await upload(clubToken, 'editor-uid', jpeg(1), sha(jpeg(2)))).status).toBe(400); + expect((await upload(clubToken, 'editor-uid', new TextEncoder().encode(''))).status).toBe(415); + expect((await upload(clubToken, 'editor-uid', jpeg(1, MAX_CARD_BYTES + 1))).status).toBe(413); + expect(bucket.store.size).toBe(0); + }); + + it('stops at the club quota', async () => { + const { clubToken, clubId } = await activated({ uid: 'editor-uid', role: 'editor' }); + bucket.store.set(clubCardObjectKey(clubId, 'f'.repeat(64)), { + bytes: new Uint8Array(20 * 1024 * 1024 - 10), + uploaded: new Date(), + }); + expect((await upload(clubToken, 'editor-uid', jpeg())).status).toBe(507); + }); +}); + +describe('PUT /api/club/cards', () => { + it('publishes uploaded images, bumps ver, and every device sees the set', async () => { + const { clubToken, ver } = await activated({ uid: 'editor-uid', role: 'editor' }); + const blue = jpeg(1); + const red = jpeg(2); + await upload(clubToken, 'editor-uid', blue); + await upload(clubToken, 'editor-uid', red); + + const res = await publish(clubToken, 'editor-uid', { label: 'Club cards', images: { blue: sha(blue), red: sha(red) } }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ver).toBe(ver + 1); + expect(body.cards).toMatchObject({ label: 'Club cards', images: { blue: sha(blue), red: sha(red) }, publishedBy: 'editor-uid' }); + + const guest = await activated(); + const state = await clubState(guest.clubToken); + expect(state.ver).toBe(ver + 1); + expect(state.cards.images).toEqual({ blue: sha(blue), red: sha(red) }); + }); + + // A set naming an image that is not stored would be a card no device can load. + it('refuses a set whose images were never uploaded', async () => { + const { clubToken } = await activated({ uid: 'editor-uid', role: 'editor' }); + const res = await publish(clubToken, 'editor-uid', { images: { blue: 'a'.repeat(64) } }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: 'missing_images', missing: ['a'.repeat(64)] }); + }); + + it('writes nothing for an identical publish', async () => { + const { clubToken, clubId } = await activated({ uid: 'editor-uid', role: 'editor' }); + const blue = jpeg(); + await upload(clubToken, 'editor-uid', blue); + const first = await (await publish(clubToken, 'editor-uid', { images: { blue: sha(blue) } })).json(); + const again = await (await publish(clubToken, 'editor-uid', { images: { blue: sha(blue) } })).json(); + expect(again.ver).toBe(first.ver); + expect(JSON.parse(kv.store.get(clubKey(clubId))).ver).toBe(first.ver); + }); + + it('refuses a member, and a body that is not a set', async () => { + const { clubToken } = await activated({ uid: 'member-uid', role: 'member' }); + expect((await publish(clubToken, 'member-uid', { images: { blue: 'a'.repeat(64) } })).status).toBe(403); + const editor = await activated({ uid: 'editor-uid', role: 'editor' }); + expect((await publish(editor.clubToken, 'editor-uid', { images: { purple: 'a'.repeat(64) } })).status).toBe(400); + }); + + // Old sets go; uploads still waiting for someone else's publish stay. + it('prunes images no set names any more, sparing recent uploads', async () => { + const { clubToken, clubId } = await activated({ uid: 'editor-uid', role: 'editor' }); + const old = jpeg(1); + const pending = jpeg(2); + const current = jpeg(3); + for (const bytes of [old, pending, current]) await upload(clubToken, 'editor-uid', bytes); + bucket.store.get(clubCardObjectKey(clubId, sha(old))).uploaded = new Date(Date.now() - PRUNE_GRACE_MS - 1000); + + await publish(clubToken, 'editor-uid', { images: { blue: sha(current) } }); + expect(bucket.store.has(clubCardObjectKey(clubId, sha(old)))).toBe(false); + expect(bucket.store.has(clubCardObjectKey(clubId, sha(pending)))).toBe(true); + expect(bucket.store.has(clubCardObjectKey(clubId, sha(current)))).toBe(true); + }); +}); + +describe('GET /api/club/cards/', () => { + it('serves the image to any device holding the club, privately', async () => { + const { clubToken } = await activated({ uid: 'editor-uid', role: 'editor' }); + const bytes = jpeg(); + await upload(clubToken, 'editor-uid', bytes); + + const guest = await activated(); + const res = await download(guest.clubToken, sha(bytes)); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('image/jpeg'); + expect(res.headers.get('cache-control')).toContain('private'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(bytes); + }); + + it('needs a club, and only reaches that club\'s images', async () => { + const { clubToken, clubId } = await activated({ uid: 'editor-uid', role: 'editor' }); + expect((await download(null, 'a'.repeat(64))).status).toBe(401); + // Someone else's club holds this hash; ours does not. + bucket.store.set(clubCardObjectKey('other-club', 'b'.repeat(64)), { bytes: jpeg(), uploaded: new Date() }); + expect((await download(clubToken, 'b'.repeat(64))).status).toBe(404); + expect(clubId).not.toBe('other-club'); + }); + + // The console may hold no club token: an admin's sign-in is enough. + it('serves the console an admin signed in without a club token', async () => { + const { clubToken } = await activated({ uid: 'buyer-uid' }); + const bytes = jpeg(); + await upload(clubToken, 'buyer-uid', bytes); + expect((await download(null, sha(bytes), 'buyer-uid')).status).toBe(200); + }); + + // `/api/club-assets/*` is public and serves anything under `club//`. + it('is not reachable through the public club-assets route', async () => { + const { clubToken, clubId } = await activated({ uid: 'editor-uid', role: 'editor' }); + const bytes = jpeg(); + await upload(clubToken, 'editor-uid', bytes); + const res = await worker.fetch( + new Request(`https://timer.simple-tech.app/api/club-assets/${clubId}/${sha(bytes)}`, { + headers: { host: 'timer.simple-tech.app' }, + }), + env, + { waitUntil: () => {} } + ); + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /api/club/cards', () => { + it('lets an admin take the set down, bumping ver', async () => { + const { clubToken, clubId } = await activated({ uid: 'buyer-uid' }); + const bytes = jpeg(); + await upload(clubToken, 'buyer-uid', bytes); + const published = await (await publish(clubToken, 'buyer-uid', { images: { blue: sha(bytes) } })).json(); + + const res = await remove(clubToken, 'buyer-uid'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ver: published.ver + 1, cards: null }); + expect(kv.store.has(clubCardsKey(clubId))).toBe(false); + expect((await clubState(clubToken)).cards).toBeNull(); + }); + + it('is admin only', async () => { + const { clubToken } = await activated({ uid: 'editor-uid', role: 'editor' }); + expect((await remove(clubToken, 'editor-uid')).status).toBe(403); + expect((await remove(null, null)).status).toBe(401); + }); +}); + +describe('routing', () => { + it('dispatches /api/club/cards with the other club routes', async () => { + const res = await worker.fetch( + new Request('https://timer.simple-tech.app/api/club/cards', { + method: 'PUT', + headers: { host: 'timer.simple-tech.app', 'content-type': 'application/json' }, + body: '{}', + }), + env, + { waitUntil: () => {} } + ); + expect(res.status).toBe(401); + }); + + it('answers 404 for a name that is not a digest', async () => { + expect((await handleClubCards(new Request('https://x/api/club/cards/..%2Fx'), 'cards/../x', env)).status).toBe(404); + }); +}); diff --git a/worker/club.js b/worker/club.js index 5c90aea..ebfd71f 100644 --- a/worker/club.js +++ b/worker/club.js @@ -8,6 +8,7 @@ import { createClubForSubscriber } from './club-create.js'; import { readClubName } from './billing.js'; import { createStripeClient } from './stripe.js'; import { handleClubPresets, readClubPresets } from './club-presets.js'; +import { handleClubCards, readClubCards, buildCards } from './club-cards.js'; import { handleClubMeetings } from './club-meetings.js'; import { handleClubAdminRoutes } from './club-admin-routes.js'; import { handleMagicLinkRequest, handleClubManage, handleClubManageSignOut } from './club-magic.js'; @@ -161,7 +162,7 @@ export function shareUrlFor(env, code) { * The whole club, as a device needs to see it. * * The split down the middle of this document is the point. Content — the - * presets, the brand kit, and the badge default — is versioned, + * presets, the brand kit, the badge default and the card set — is versioned, * and a device replaces its copy only when `ver` moves, which is what lets a * timer's local list survive a daily refresh and lose only to an actual * publish. Everything below `timezone` carries no version and is applied every @@ -176,8 +177,9 @@ export function shareUrlFor(env, code) { * @param {{uid?: string|null}} [caller] - whose role to report, if anyone's */ export async function buildClubState(env, clubId, club, access, { uid = null } = {}) { - const [presets, role] = await Promise.all([ + const [presets, cards, role] = await Promise.all([ readClubPresets(env, clubId), + readClubCards(env, clubId), readMemberRole(env, clubId, uid), ]); @@ -195,6 +197,7 @@ export async function buildClubState(env, clubId, club, access, { uid = null } = } : null, badge: buildBadge(presets), + cards: buildCards(cards), timezone: club?.timezone ?? null, // Not versioned: a guest device sees null and a promotion takes effect on // the next refresh without anything being republished. @@ -529,6 +532,7 @@ export async function handleClub(request, url, env, { ctx } = {}) { if (route === 'create') return handleClubCreate(request, env); if (route === 'leave') return handleClubLeave(request, env); if (route === 'presets') return handleClubPresets(request, env); + if (route === 'cards' || route.startsWith('cards/')) return handleClubCards(request, route, env); if (route === 'meetings' || route.startsWith('meetings/')) return handleClubMeetings(request, route, env); // The console's second door. Public (and rate-limited) on the way in, so it From f51eb0687801cc06e04608ce26df9d7849081399 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Fri, 6 Nov 2026 13:05:32 -0500 Subject: [PATCH 2/4] test(zoom-client): review timer-core and timer-sounds against club card sets --- e2e/zoom-client/timer-core.md | 3 +++ e2e/zoom-client/timer-sounds.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md index 3d49d91..45bd48e 100644 --- a/e2e/zoom-client/timer-core.md +++ b/e2e/zoom-client/timer-core.md @@ -102,3 +102,6 @@ PostHog carrying that role. the `sounds` flag is on and the account is Pro). See [timer-sounds](timer-sounds.md). - Agenda import, and the roles it assigns to imported lines (`importBulkSpeakers` in `TimerContext.jsx`). See [agenda-import](agenda-import.md). +- A club card set arriving, changing or going away while the app is open. `LiveTab.jsx` + re-pushes the current card then, and only then; a device with no club card set pushes + exactly as above. See [club-card-sets](club-card-sets.md). diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md index 20e4a7f..64c1b11 100644 --- a/e2e/zoom-client/timer-sounds.md +++ b/e2e/zoom-client/timer-sounds.md @@ -113,3 +113,6 @@ log. - The web timer's chime (`apps/web/src/components/TimerSounds.jsx`). It runs in a plain browser, which `e2e/` covers. - Sound on Zoom mobile and web clients. +- A club card set re-pushing the card mid-speech (`LiveTab.jsx`). The chime follows the + status (`cueForStatusChange` in `TimerSounds.jsx`), not the artwork, so it does not + chime. See [club-card-sets](club-card-sets.md). From 57c4ddefe83455592476ac9ac1f89df6e6c175e7 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Thu, 12 Nov 2026 20:51:36 -0500 Subject: [PATCH 3/4] fix(club): don't name a shared card set after the publisher's slot Share with my club sent the row's own label, "Custom 1", as the club set's label. Every other club device has its own "Custom 1", and the publisher's picker showed two rows with the same name. Sending no label lets the set read " cards" in the picker and in the console. --- apps/web/src/components/CardImagesModal.jsx | 8 +++--- apps/web/src/pages/ClubAdmin.jsx | 5 ++-- .../src/components/CardImagesModal.jsx | 8 +++--- .../src/components/CardImagesModal.test.jsx | 27 ++++++++++++++++++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/CardImagesModal.jsx b/apps/web/src/components/CardImagesModal.jsx index 85481dc..75a112e 100644 --- a/apps/web/src/components/CardImagesModal.jsx +++ b/apps/web/src/components/CardImagesModal.jsx @@ -219,10 +219,12 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { } }; - const handleShare = async (setId, label) => { + // No label: "Custom 1" is this device's slot name, and every other club device + // has its own "Custom 1". Unlabelled, the set reads " cards" everywhere. + const handleShare = async (setId) => { setSharingSetId(setId); // The web session travels as a cookie, so there is no token to hand over. - const result = await publishCardSet(setId, { label }); + const result = await publishCardSet(setId); setSharingSetId(null); if (result.ok) { showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success'); @@ -283,7 +285,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { // the Classic card. thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardAssetUrl(CLASSIC_FILES[color])} onDelete={() => handleDelete(set.id)} - onShare={club && canPublishCards() ? () => handleShare(set.id, `Custom ${index + 1}`) : undefined} + onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined} sharing={sharingSetId === set.id} /> ))} diff --git a/apps/web/src/pages/ClubAdmin.jsx b/apps/web/src/pages/ClubAdmin.jsx index 5335ce5..0c6ab14 100644 --- a/apps/web/src/pages/ClubAdmin.jsx +++ b/apps/web/src/pages/ClubAdmin.jsx @@ -382,7 +382,7 @@ const CARD_LABELS = { blue: 'Idle (blue)', green: 'Green', yellow: 'Yellow', red * The images are private, so they are fetched with the console's credentials * and shown from object URLs rather than linked. */ -function ClubCards({ cards, onRemoved }) { +function ClubCards({ cards, clubName, onRemoved }) { const [urls, setUrls] = useState({}) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -440,7 +440,7 @@ function ClubCards({ cards, onRemoved }) { return (

- {cards.label || 'Club cards'} + {cards.label || `${clubName || 'Club'} cards`} {cards.publishedAt ? · shared {shortDate(cards.publishedAt)} : null}

@@ -688,6 +688,7 @@ export default function ClubAdmin() {

Card set

{ load() // This browser may be a club device too; let it drop the set now. diff --git a/apps/zoom-app/src/components/CardImagesModal.jsx b/apps/zoom-app/src/components/CardImagesModal.jsx index 7cc2f65..4e6c803 100644 --- a/apps/zoom-app/src/components/CardImagesModal.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.jsx @@ -235,9 +235,11 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { } }; - const handleShare = async (setId, label) => { + // No label: "Custom 1" is this device's slot name, and every other club device + // has its own "Custom 1". Unlabelled, the set reads " cards" everywhere. + const handleShare = async (setId) => { setSharingSetId(setId); - const result = await publishCardSet(setId, { label, getToken: getSessionToken }); + const result = await publishCardSet(setId, { getToken: getSessionToken }); setSharingSetId(null); if (result.ok) { showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success'); @@ -298,7 +300,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { // the Classic card. thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardFileUrl(CLASSIC_FILES[color])} onDelete={() => handleDelete(set.id)} - onShare={club && canPublishCards() ? () => handleShare(set.id, `Custom ${index + 1}`) : undefined} + onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined} sharing={sharingSetId === set.id} /> ))} diff --git a/apps/zoom-app/src/components/CardImagesModal.test.jsx b/apps/zoom-app/src/components/CardImagesModal.test.jsx index ec951c0..7e55e02 100644 --- a/apps/zoom-app/src/components/CardImagesModal.test.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.test.jsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { resetCardImagesForTests, resetClubForTests, @@ -8,6 +8,7 @@ import { setEntitlement, setFlags, } from '@toastmaster-timer/shared'; +import { publishCardSet } from '@toastmaster-timer/shared'; import CardImagesModal from './CardImagesModal'; import { ToastProvider } from '../context/ToastContext'; @@ -22,6 +23,12 @@ vi.mock('../utils/zoomSdk', () => ({ removeOwnBackground: vi.fn(), })); vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() })); +// Only the publish is stubbed: the network and IndexedDB behind it are covered +// in packages/shared/__tests__/clubCards.test.js. +vi.mock('@toastmaster-timer/shared', async (importOriginal) => ({ + ...(await importOriginal()), + publishCardSet: vi.fn(async () => ({ ok: true, ver: 3, cards: { label: null, images: { blue: 'a'.repeat(64) } } })), +})); vi.mock('../utils/zoomIdentity', () => ({ resolveZoomIdentity: vi.fn(async () => ({ identified: true, uid: 'u1' })), getSessionToken: vi.fn(() => 'session-token'), @@ -98,6 +105,24 @@ describe('the club card set', () => { expect(Boolean(share)).toBe(offered); }); + // "Custom 1" names a slot on the publisher's device; every other club device + // has its own "Custom 1", so sending it would show two rows with one name. + it('publishes without the slot name, so the set reads " cards"', async () => { + joinClub({ role: 'admin', cards: null }); + localStorage.setItem( + 'toastmaster_custom_card_images', + JSON.stringify({ version: 3, selectedSetId: 'custom-1', customSets: [{ id: 'custom-1', colors: ['blue'] }] }) + ); + await renderModal(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Share the Custom 1 card set with my club/ })); + }); + expect(publishCardSet).toHaveBeenCalledTimes(1); + const [setId, options] = publishCardSet.mock.calls[0]; + expect(setId).toBe('custom-1'); + expect(options?.label).toBeUndefined(); + }); + it('uses the label the publisher gave it', async () => { joinClub({ cards: { label: 'Contest night', images: { red: 'b'.repeat(64) } } }); await renderModal(); From 4a549a76eaf26f83bfd47a0cb43370f3494c6be7 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Thu, 12 Nov 2026 20:52:27 -0500 Subject: [PATCH 4/4] test(zoom-client): run club-card-sets on 23091dc CC-01 to CC-07 ran in the real Zoom client (B = the web app). Nothing failed. CC-07 now says the set leaves B on its next club refresh, which is at most daily, and B shows its own choice rather than Classic. New CC-09 covers a republish pruning the old image. CC-02 expects the ' cards' label from the label fix and is marked stale until it is rerun; CC-08 (club lapse) is not run. --- e2e/zoom-client/INDEX.md | 2 +- e2e/zoom-client/club-card-sets.md | 25 ++++++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md index f8ed8c7..5e75ad6 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) | draft | never | 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 (plan 10 Phase 1) | +| [club-card-sets](club-card-sets.md) | stale | 23091dc, 2026-11-12 | 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-08 and CC-06's forced 403 not run yet; CC-02's label changed in the next commit and needs a rerun | | 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 1938255..d3ff50b 100644 --- a/e2e/zoom-client/club-card-sets.md +++ b/e2e/zoom-client/club-card-sets.md @@ -1,7 +1,7 @@ --- id: club-card-sets title: Club card sets (share card artwork with the club) -status: draft +status: stale covers: - worker/club-cards.js - packages/shared/clubCards.js @@ -21,8 +21,8 @@ needs: - Zoom account A, an admin or editor of that club (club-member record) - a second computer or browser profile, B, with no cards of its own - ENTITLEMENT_ENFORCE "1" on dev for CC-08 only (ask first; revert after) -verified_at: never -verified_on: never +verified_at: 23091dc +verified_on: 2026-11-12 --- # Club card sets (share card artwork with the club) @@ -48,7 +48,8 @@ Evidence: localStorage `toastmaster_custom_card_images` lists `custom-1` with `[ ### CC-02 · auto · A shares the set Do: on A, press **Share with my club** on Custom 1. Expect: toast "Shared with . Every club device gets these cards." A's picker now also -lists "Custom 1" as the club's set at the top. A's own selection does not change. +lists " cards" (e.g. "Claude Test Club cards") as the club's set at the top, not +"Custom 1". A's own selection does not change. Evidence: `wrangler tail --env dev` shows two `PUT /api/club/cards/` → 200 and one `PUT /api/club/cards` → 200. KV `club-cards:` holds both hashes; `club:.ver` went up by 1. R2 has `club-card//` for each, and nothing under `club//`. @@ -80,18 +81,28 @@ Evidence: a forced `PUT /api/club/cards` from that device returns 403. ### CC-07 · human · An admin removes the set from the console Do: on the web, open `/club/admin` as the club admin. Under Card set, press **Remove card set**. -Expect: the section says there is no card set. On B's next app start, the club's set leaves -the picker and B shows Classic. +Expect: the section says there is no card set. On B's next club refresh, the club's set leaves +the picker and B shows its own choice (Classic on a device that never chose). A club refresh runs at app start only when the last one is +over 24 h old (`CLUB_REFRESH_INTERVAL_MS`); to see it now, set `lastRefreshAt` to 0 in B's +`toastmaster_club` and reload. Evidence: tail shows `DELETE /api/club/cards` → 200; KV `club-cards:` is gone and `ver` went up by 1. ### CC-08 · human · A lapsed club stops showing its set Do: with ENTITLEMENT_ENFORCE "1", republish a set from A, then set the club record to -`canceled` with `currentPeriodEnd` in the past. Reload B. +`canceled` with `currentPeriodEnd` in the past. Force B's refresh (`lastRefreshAt` 0) and reload. Expect: B shows its own choice (Classic) and the picker no longer lists the club's set. A's **Share with my club** is gone. Revert the club record and ENTITLEMENT_ENFORCE afterwards. Evidence: `GET /api/club` returns `entitled: false`; B's `toastmaster_club_cards` remains. +### CC-09 · auto · A republishes; the old image is pruned +Do: on A, add a second custom set and press **Share with my club** on it, at least 15 minutes +after the first share. +Expect: the club row at the top of A's picker shows the new set. Another club device keeps the +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). + ## 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.