feat(tlsync): fetch short-lived token from API before multiplayer connect
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled

- 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)
This commit is contained in:
kcar 2026-05-28 18:00:43 +01:00
parent 0db53bfd9c
commit 67e47fc47f
4 changed files with 161 additions and 80 deletions

View File

@ -24,10 +24,9 @@ VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key 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_URL=https://app.classroomcopilot.ai/tldraw
VITE_TLSYNC_SECRET=your-tlsync-secret
# ============================================================================= # =============================================================================
# WhisperLive (Transcription) Configuration # WhisperLive (Transcription) Configuration

View File

@ -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 { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Tldraw, Tldraw,
@ -31,34 +31,113 @@ import '../../utils/tldraw/tldraw.css';
// App debug // App debug
import { logger } from '../../debugConfig'; 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` ? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
: `https://${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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div
style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
}}
>
<div
style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
}}
>
{message}
</div>
</div>
);
}
/**
* 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 { user } = useAuth();
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const { const {
tldrawPreferences, tldrawPreferences,
setTldrawPreferences, setTldrawPreferences,
initializePreferences, initializePreferences,
presentationMode presentationMode,
} = useTLDraw(); } = useTLDraw();
const navigate = useNavigate(); const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const [searchParams] = useSearchParams();
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
// Get room ID from URL params const userInfo = useMemo(
const roomId = searchParams.get('room') || 'multiplayer'; () => ({
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({ const editorUser = useTldrawUser({
userPreferences: { userPreferences: {
id: userInfo.id, id: userInfo.id,
@ -67,18 +146,23 @@ export default function TldrawMultiUser() {
locale: tldrawPreferences?.locale, locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme, colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed, animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode isSnapMode: tldrawPreferences?.isSnapMode,
}, },
setUserPreferences: setTldrawPreferences setUserPreferences: setTldrawPreferences,
}); });
const connectionOptions = useMemo(() => createSyncConnectionOptions({ const connectionOptions = useMemo(
userId: userInfo.id, () =>
displayName: userInfo.name, createSyncConnectionOptions({
color: userInfo.color, userId: userInfo.id,
roomId, displayName: userInfo.name,
baseUrl: SYNC_WORKER_URL color: userInfo.color,
}), [userInfo, roomId]); roomId,
baseUrl: SYNC_WORKER_URL,
token: tlsyncToken,
}),
[userInfo, roomId, tlsyncToken],
);
const store = useSync({ const store = useSync({
...connectionOptions, ...connectionOptions,
@ -88,19 +172,17 @@ export default function TldrawMultiUser() {
userInfo: { userInfo: {
id: userInfo.id, id: userInfo.id,
name: userInfo.name, name: userInfo.name,
color: userInfo.color color: userInfo.color,
} },
}); });
// Log connection status changes
useEffect(() => { useEffect(() => {
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, { logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
status: store.status, status: store.status,
connectionOptions roomId: connectionOptions.roomId,
}); });
}, [store.status, connectionOptions]); }, [store.status, connectionOptions.roomId]);
// Effect for initializing preferences
useEffect(() => { useEffect(() => {
if (user?.id && !tldrawPreferences) { if (user?.id && !tldrawPreferences) {
logger.info('multiplayer-page', '🔄 Initializing preferences'); logger.info('multiplayer-page', '🔄 Initializing preferences');
@ -108,20 +190,11 @@ export default function TldrawMultiUser() {
} }
}, [user?.id, tldrawPreferences, initializePreferences]); }, [user?.id, tldrawPreferences, initializePreferences]);
// Effect for redirecting if user is not authenticated
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
// Effect for presentation mode
useEffect(() => { useEffect(() => {
if (presentationMode && editorRef.current) { if (presentationMode && editorRef.current) {
const editor = editorRef.current; const editor = editorRef.current;
const presentationService = new PresentationService(editor); const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode(); const cleanup = presentationService.startPresentationMode();
return () => { return () => {
presentationService.stopPresentationMode(); presentationService.stopPresentationMode();
cleanup(); cleanup();
@ -129,47 +202,24 @@ export default function TldrawMultiUser() {
} }
}, [presentationMode]); }, [presentationMode]);
// Memoize UI overrides and components
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]); const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]); const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
// Render conditionally to avoid unnecessary rerenders if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
if (!user) { return <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
return null;
} }
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) { return (
return ( <div
<div style={{ style={{
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
top: `${HEADER_HEIGHT}px`, top: `${HEADER_HEIGHT}px`,
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
justifyContent: 'center', overflow: 'hidden',
backgroundColor: 'rgba(0, 0, 0, 0.1)' }}
}}> >
<div style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}>
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
</div>
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Tldraw <Tldraw
user={editorUser} user={editorUser}
store={store.store} store={store.store}
@ -198,3 +248,33 @@ export default function TldrawMultiUser() {
</div> </div>
); );
} }
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 <TlsyncStatusOverlay message={`Failed to connect to collaboration server: ${tlsyncError}`} />;
}
if (!tlsyncToken) {
return <TlsyncStatusOverlay message="Connecting to collaboration server..." />;
}
return <TldrawCanvas tlsyncToken={tlsyncToken} roomId={roomId} />;
}

View File

@ -15,6 +15,7 @@ export interface SyncConnectionOptions {
color: string; color: string;
roomId?: string; roomId?: string;
baseUrl: string; baseUrl: string;
token?: string;
} }
export function createSyncConnectionOptions(options: SyncConnectionOptions) { export function createSyncConnectionOptions(options: SyncConnectionOptions) {
@ -22,8 +23,8 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
userId, userId,
displayName, displayName,
roomId = 'multiplayer', roomId = 'multiplayer',
baseUrl baseUrl,
token
} = options; } = options;
// Ensure we have valid user info // Ensure we have valid user info
@ -72,8 +73,7 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
roomId: effectiveRoomId roomId: effectiveRoomId
}); });
const token = import.meta.env.VITE_TLSYNC_SECRET ?? '' const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
return { return {
uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`, uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`,

View File

@ -14,9 +14,11 @@ export default defineConfig(({ mode }) => {
// Determine client-side env vars to expose // Determine client-side env vars to expose
const envPrefix = 'VITE_' 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( const clientEnv = Object.fromEntries(
Object.entries(env) 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)]) .map(([key, value]) => [`import.meta.env.${key}`, JSON.stringify(value)])
) )