feat(tlsync): fetch short-lived token from API before multiplayer connect
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
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:
parent
0db53bfd9c
commit
67e47fc47f
@ -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
|
||||
|
||||
@ -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';
|
||||
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 <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
|
||||
}
|
||||
|
||||
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
|
||||
return (
|
||||
<div style={{
|
||||
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 (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
top: `${HEADER_HEIGHT}px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
flexDirection: 'column',
|
||||
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} />;
|
||||
}
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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)])
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user