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
|
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
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
|||||||
@ -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)])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user