diff --git a/apps/web/src/components/CardImagesModal.jsx b/apps/web/src/components/CardImagesModal.jsx index 85ea150..75a112e 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,20 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { } }; + // 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); + 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 +255,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 +279,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) : undefined} + sharing={sharingSetId === set.id} /> ))} @@ -270,9 +347,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..0c6ab14 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, clubName, 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 || `${clubName || '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,19 @@ 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..4e6c803 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,19 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { } }; + // 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, { 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 +270,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 +294,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) : undefined} + sharing={sharingSetId === set.id} /> ))} @@ -291,7 +368,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..7e55e02 100644 --- a/apps/zoom-app/src/components/CardImagesModal.test.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.test.jsx @@ -1,11 +1,14 @@ 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, resetEntitlementForTests, resetFlagsForTests, setEntitlement, setFlags, } from '@toastmaster-timer/shared'; +import { publishCardSet } from '@toastmaster-timer/shared'; import CardImagesModal from './CardImagesModal'; import { ToastProvider } from '../context/ToastContext'; @@ -20,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'), @@ -45,6 +54,80 @@ 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); + }); + + // "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(); + 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 3fc6dcd..d9dc391 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, getZoomParticipants, 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, getZoomParticipants, 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 ae275b2..5e75ad6 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) | 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 | @@ -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..d3ff50b --- /dev/null +++ b/e2e/zoom-client/club-card-sets.md @@ -0,0 +1,109 @@ +--- +id: club-card-sets +title: Club card sets (share card artwork with the club) +status: stale +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: 23091dc +verified_on: 2026-11-12 +--- + +# 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 " 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//`. + +### 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 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. 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. +- Card sets are one per club for now (open question 2 in the plan). diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md index 4b5a033..0020b06 100644 --- a/e2e/zoom-client/timer-core.md +++ b/e2e/zoom-client/timer-core.md @@ -128,3 +128,6 @@ Human because it needs a second signed-in Zoom account. `TimerContext.jsx`). See [agenda-import](agenda-import.md). - A failed participant read at START (`timer_started` still sent, no `audience_size`). The Zoom client can't be made to fail it on demand; `LiveTab.test.jsx` covers it (#107 AC-5). +- 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 1374bb6..56c10ec 100644 --- a/e2e/zoom-client/timer-sounds.md +++ b/e2e/zoom-client/timer-sounds.md @@ -115,3 +115,6 @@ log. - Sound on Zoom mobile and web clients. - START's `timer_started` analytics, including `audience_size` (#107). See [timer-core](timer-core.md) TC-10 to TC-12. +- 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). 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