security: remove TLSync shared secret from frontend bundle

- Remove VITE_TLSYNC_SECRET from syncService.ts; token is now
  fetched at runtime via fetchTlsyncToken() from the API backend
- Add token?: string to SyncConnectionOptions interface
- Update multiplayerUser.tsx to fetch TLSync token from API on mount
  and pass it through createSyncConnectionOptions
- Remove VITE_TLSYNC_SECRET from .env.example

The API must implement GET /tlsync/token (authenticated via Supabase
Bearer token) to complete the fix.
This commit is contained in:
kcar 2026-05-28 14:00:52 +01:00
parent 0db53bfd9c
commit 217f393de9
3 changed files with 58 additions and 8 deletions

View File

@ -27,7 +27,6 @@ VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
# TLSync (TLDraw Sync) Configuration
# =============================================================================
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,
@ -15,7 +15,7 @@ import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
// Tldraw services
import { multiplayerOptions } from '../../services/tldraw/optionsService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { createSyncConnectionOptions, handleExternalAsset } from '../../services/tldraw/syncService';
import { createSyncConnectionOptions, handleExternalAsset, fetchTlsyncToken } from '../../services/tldraw/syncService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
@ -48,6 +48,10 @@ export default function TldrawMultiUser() {
const [searchParams] = useSearchParams();
const editorRef = useRef<Editor | null>(null);
// TLSync token fetched at runtime from API (never embedded in bundle)
const [tlsyncTokenReady, setTlsyncTokenReady] = useState(false);
const [tlsyncToken, setTlsyncToken] = useState('');
// Get room ID from URL params
const roomId = searchParams.get('room') || 'multiplayer';
@ -72,13 +76,28 @@ export default function TldrawMultiUser() {
setUserPreferences: setTldrawPreferences
});
// Fetch TLSync token from API on mount / user change
useEffect(() => {
let cancelled = false;
async function loadToken() {
const token = await fetchTlsyncToken();
if (!cancelled) {
setTlsyncToken(token);
setTlsyncTokenReady(true);
}
}
loadToken();
return () => { cancelled = true; };
}, [user?.id]);
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,
@ -134,7 +153,7 @@ export default function TldrawMultiUser() {
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
// Render conditionally to avoid unnecessary rerenders
if (!user) {
if (!user || !tlsyncTokenReady) {
return null;
}

View File

@ -15,6 +15,37 @@ export interface SyncConnectionOptions {
color: string;
roomId?: string;
baseUrl: string;
/** Runtime-fetched TLSync auth token (from API, not baked into bundle). */
token?: string;
}
/**
* Fetch a short-lived TLSync auth token from the API backend.
* The API holds the shared secret server-side and issues tokens
* authenticated via the user's Supabase session.
*/
export async function fetchTlsyncToken(apiBase?: string): Promise<string> {
const base = apiBase || import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai';
try {
const { supabase } = await import('../../supabaseClient');
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
logger.warn('sync-service', 'No Supabase session for TLSync token fetch');
return '';
}
const res = await fetch(`${base}/tlsync/token`, {
headers: { Authorization: `Bearer ${session.access_token}` },
});
if (!res.ok) {
logger.warn('sync-service', `TLSync token endpoint returned ${res.status}`);
return '';
}
const { token } = await res.json();
return token || '';
} catch (err) {
logger.warn('sync-service', 'Failed to fetch TLSync token from API', { err });
return '';
}
}
export function createSyncConnectionOptions(options: SyncConnectionOptions) {
@ -72,8 +103,9 @@ export function createSyncConnectionOptions(options: SyncConnectionOptions) {
roomId: effectiveRoomId
});
const token = import.meta.env.VITE_TLSYNC_SECRET ?? ''
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''
// Token is supplied at runtime via the API (not from build-time env)
const token = options.token ?? '';
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
return {
uri: `${baseUrl}/connect/${effectiveRoomId}${tokenParam}`,