From 67e47fc47f9a50efca920bac0c84691a1c97868d Mon Sep 17 00:00:00 2001 From: kcar Date: Thu, 28 May 2026 18:00:43 +0100 Subject: [PATCH] feat(tlsync): fetch short-lived token from API before multiplayer connect - Remove VITE_TLSYNC_SECRET from browser env (no longer exposed to bundle) - Add useTlsyncToken hook that fetches /api/tlsync/token with Supabase auth - Extract TldrawCanvas sub-component: only renders after token is ready - Pass API-issued short-lived token to createSyncConnectionOptions - Add vite.config.ts blocklist to prevent secret leak (defense-in-depth) - Remove VITE_TLSYNC_SECRET from .env.example (server-side only now) Related: t_a69128a1 (API token endpoint), t_41a844a7 (this task) --- .env.example | 3 +- src/pages/tldraw/multiplayerUser.tsx | 226 ++++++++++++++++++--------- src/services/tldraw/syncService.ts | 8 +- vite.config.ts | 4 +- 4 files changed, 161 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index 775b7af..53e8294 100644 --- a/.env.example +++ b/.env.example @@ -24,10 +24,9 @@ VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-supabase-anon-key # ============================================================================= -# TLSync (TLDraw Sync) Configuration +# TLSync (TLDraw Sync) Configuration — shared secret is server-side only (API TLSYNC_SECRET) # ============================================================================= VITE_TLSYNC_URL=https://app.classroomcopilot.ai/tldraw -VITE_TLSYNC_SECRET=your-tlsync-secret # ============================================================================= # WhisperLive (Transcription) Configuration diff --git a/src/pages/tldraw/multiplayerUser.tsx b/src/pages/tldraw/multiplayerUser.tsx index f870ddb..4d3c6bb 100644 --- a/src/pages/tldraw/multiplayerUser.tsx +++ b/src/pages/tldraw/multiplayerUser.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useMemo } from 'react'; +import { useEffect, useRef, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Tldraw, @@ -31,34 +31,113 @@ import '../../utils/tldraw/tldraw.css'; // App debug import { logger } from '../../debugConfig'; -const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http') +const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http') ? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw` : `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`; -export default function TldrawMultiUser() { +const apiBase = (import.meta.env.VITE_API_BASE as string) || ''; + +/** + * Fetches a short-lived TLSync token from the API using the current Supabase session. + * Returns { token, error } — exactly one will be non-null. + */ +function useTlsyncToken(accessToken: string | null) { + const [token, setToken] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!accessToken) return; + let cancelled = false; + + logger.debug('multiplayer-page', '🔑 Fetching TLSync token from API'); + fetch(`${apiBase}/api/tlsync/token`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then((r) => { + if (!r.ok) throw new Error(`Token request failed: ${r.status}`); + return r.json(); + }) + .then((data) => { + if (!cancelled) { + logger.debug('multiplayer-page', '✅ TLSync token received', { expiresIn: data.expires_in }); + setToken(data.token); + } + }) + .catch((err) => { + if (!cancelled) { + logger.error('multiplayer-page', '❌ Failed to fetch TLSync token', { err: err?.message }); + setError(err?.message || 'Unknown error'); + } + }); + + return () => { + cancelled = true; + }; + }, [accessToken]); + + return { token, error }; +} + +/** + * Loading / error overlay shown while the TLSync token is being fetched. + */ +function TlsyncStatusOverlay({ message }: { message: string }) { + return ( +
+
+ {message} +
+
+ ); +} + +/** + * Inner component that owns the useSync hook. + * Rendered only once a valid TLSync token has been fetched from the API. + */ +function TldrawCanvas({ + tlsyncToken, + roomId, +}: { + tlsyncToken: string; + roomId: string; +}) { const { user } = useAuth(); - const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute(); const { tldrawPreferences, setTldrawPreferences, initializePreferences, - presentationMode + presentationMode, } = useTLDraw(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute(); const editorRef = useRef(null); - // Get room ID from URL params - const roomId = searchParams.get('room') || 'multiplayer'; + const userInfo = useMemo( + () => ({ + id: user?.id ?? '', + name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User', + color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`, + }), + [user?.id, user?.display_name, user?.email, tldrawPreferences?.color], + ); - // Memoize user information to ensure consistency - const userInfo = useMemo(() => ({ - id: user?.id ?? '', - name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User', - color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)` - }), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]); - - // Create editor user with memoization const editorUser = useTldrawUser({ userPreferences: { id: userInfo.id, @@ -67,18 +146,23 @@ export default function TldrawMultiUser() { locale: tldrawPreferences?.locale, colorScheme: tldrawPreferences?.colorScheme, animationSpeed: tldrawPreferences?.animationSpeed, - isSnapMode: tldrawPreferences?.isSnapMode + isSnapMode: tldrawPreferences?.isSnapMode, }, - setUserPreferences: setTldrawPreferences + setUserPreferences: setTldrawPreferences, }); - const connectionOptions = useMemo(() => createSyncConnectionOptions({ - userId: userInfo.id, - displayName: userInfo.name, - color: userInfo.color, - roomId, - baseUrl: SYNC_WORKER_URL - }), [userInfo, roomId]); + const connectionOptions = useMemo( + () => + createSyncConnectionOptions({ + userId: userInfo.id, + displayName: userInfo.name, + color: userInfo.color, + roomId, + baseUrl: SYNC_WORKER_URL, + token: tlsyncToken, + }), + [userInfo, roomId, tlsyncToken], + ); const store = useSync({ ...connectionOptions, @@ -88,19 +172,17 @@ export default function TldrawMultiUser() { userInfo: { id: userInfo.id, name: userInfo.name, - color: userInfo.color - } + color: userInfo.color, + }, }); - // Log connection status changes useEffect(() => { logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, { status: store.status, - connectionOptions + roomId: connectionOptions.roomId, }); - }, [store.status, connectionOptions]); + }, [store.status, connectionOptions.roomId]); - // Effect for initializing preferences useEffect(() => { if (user?.id && !tldrawPreferences) { logger.info('multiplayer-page', '🔄 Initializing preferences'); @@ -108,20 +190,11 @@ export default function TldrawMultiUser() { } }, [user?.id, tldrawPreferences, initializePreferences]); - // Effect for redirecting if user is not authenticated - useEffect(() => { - if (!user) { - navigate('/'); - } - }, [user, navigate]); - - // Effect for presentation mode useEffect(() => { if (presentationMode && editorRef.current) { const editor = editorRef.current; const presentationService = new PresentationService(editor); const cleanup = presentationService.startPresentationMode(); - return () => { presentationService.stopPresentationMode(); cleanup(); @@ -129,47 +202,24 @@ export default function TldrawMultiUser() { } }, [presentationMode]); - // Memoize UI overrides and components const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]); const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]); - // Render conditionally to avoid unnecessary rerenders - if (!user) { - return null; + if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) { + return ; } - if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) { - return ( -
-
- {isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`} -
-
- ); - } - - return ( -
+ flexDirection: 'column', + overflow: 'hidden', + }} + > ); } + +export default function TldrawMultiUser() { + const { user, accessToken } = useAuth(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const roomId = searchParams.get('room') || 'multiplayer'; + + const { token: tlsyncToken, error: tlsyncError } = useTlsyncToken(accessToken); + + // Redirect unauthenticated users + useEffect(() => { + if (!user) { + navigate('/'); + } + }, [user, navigate]); + + if (!user) { + return null; + } + + if (tlsyncError) { + return ; + } + + if (!tlsyncToken) { + return ; + } + + return ; +} diff --git a/src/services/tldraw/syncService.ts b/src/services/tldraw/syncService.ts index 21c31e2..58d511b 100644 --- a/src/services/tldraw/syncService.ts +++ b/src/services/tldraw/syncService.ts @@ -15,6 +15,7 @@ export interface SyncConnectionOptions { color: string; roomId?: string; baseUrl: string; + token?: string; } export function createSyncConnectionOptions(options: SyncConnectionOptions) { @@ -22,8 +23,8 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) { userId, displayName, roomId = 'multiplayer', - baseUrl - + baseUrl, + token } = options; // Ensure we have valid user info @@ -72,8 +73,7 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) { roomId: effectiveRoomId }); - const token = import.meta.env.VITE_TLSYNC_SECRET ?? '' - const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '' + const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''; return { uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`, diff --git a/vite.config.ts b/vite.config.ts index 121250e..b48799d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,9 +14,11 @@ export default defineConfig(({ mode }) => { // Determine client-side env vars to expose const envPrefix = 'VITE_' + // Defense-in-depth: never expose server-only secrets to the browser bundle + const envBlocklist = new Set(['VITE_TLSYNC_SECRET']) const clientEnv = Object.fromEntries( Object.entries(env) - .filter(([key]) => key.startsWith(envPrefix)) + .filter(([key]) => key.startsWith(envPrefix) && !envBlocklist.has(key)) .map(([key, value]) => [`import.meta.env.${key}`, JSON.stringify(value)]) )