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
# =============================================================================
# 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

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 {
Tldraw,
@ -35,30 +35,109 @@ 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<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 { 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<Editor | null>(null);
// Get room ID from URL params
const roomId = searchParams.get('room') || 'multiplayer';
// Memoize user information to ensure consistency
const userInfo = useMemo(() => ({
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]);
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({
const connectionOptions = useMemo(
() =>
createSyncConnectionOptions({
userId: userInfo.id,
displayName: userInfo.name,
color: userInfo.color,
roomId,
baseUrl: SYNC_WORKER_URL
}), [userInfo, 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 (
<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)'
}}>
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
</div>
</div>
);
return <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
}
return (
<div style={{
<div
style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
overflow: 'hidden',
}}
>
<Tldraw
user={editorUser}
store={store.store}
@ -198,3 +248,33 @@ export default function TldrawMultiUser() {
</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;
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}`,

View File

@ -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)])
)