diff --git a/apps/web/src/app/session/[id]/page.test.tsx b/apps/web/src/app/session/[id]/page.test.tsx
index d8d0a2a5..362718b6 100644
--- a/apps/web/src/app/session/[id]/page.test.tsx
+++ b/apps/web/src/app/session/[id]/page.test.tsx
@@ -26,8 +26,25 @@ const mockUseWebRTC = {
toggleMic: mockToggleMic,
};
+// Capture the options each WebRTC hook is called with so we can assert the
+// participantId is a valid UUID (the token/signal routes require z.uuid()).
+const { mockUseWebRTCFn, mockUseWebRTCSFUFn } = vi.hoisted(() => ({
+ mockUseWebRTCFn: vi.fn(),
+ mockUseWebRTCSFUFn: vi.fn(),
+}));
+
vi.mock('@/hooks/useWebRTC', () => ({
- useWebRTC: () => mockUseWebRTC,
+ useWebRTC: (opts: { participantId: string }) => {
+ mockUseWebRTCFn(opts);
+ return mockUseWebRTC;
+ },
+}));
+
+vi.mock('@/hooks/useWebRTCSFU', () => ({
+ useWebRTCSFU: (opts: { participantId: string }) => {
+ mockUseWebRTCSFUFn(opts);
+ return mockUseWebRTC;
+ },
}));
vi.mock('@/hooks/useSessionPresence', () => ({
@@ -504,4 +521,50 @@ describe('SessionViewerPage', () => {
expect(screen.queryByTestId('session-settings-panel')).not.toBeInTheDocument();
});
});
+
+ describe('participant identity', () => {
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ const baseSession = {
+ id: 'session-123',
+ join_code: 'ABC123',
+ status: 'active',
+ settings: { quality: 'medium', allowControl: false, maxParticipants: 5 },
+ created_at: '2024-01-01T00:00:00Z',
+ session_participants: [],
+ };
+
+ it('passes a UUID participantId to the SFU viewer (token route requires uuid)', async () => {
+ vi.mocked(global.fetch).mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ data: { ...baseSession, mode: 'sfu' } }),
+ } as Response);
+
+ await act(async () => {
+ renderWithSuspense();
+ });
+ await waitFor(() => {
+ expect(mockUseWebRTCSFUFn).toHaveBeenCalled();
+ });
+
+ const opts = mockUseWebRTCSFUFn.mock.calls[0]?.[0] as { participantId: string };
+ expect(opts.participantId).toMatch(UUID_RE);
+ });
+
+ it('passes a UUID participantId to the P2P viewer', async () => {
+ vi.mocked(global.fetch).mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ data: { ...baseSession, mode: 'p2p' } }),
+ } as Response);
+
+ await act(async () => {
+ renderWithSuspense();
+ });
+ await waitFor(() => {
+ expect(mockUseWebRTCFn).toHaveBeenCalled();
+ });
+
+ const opts = mockUseWebRTCFn.mock.calls[0]?.[0] as { participantId: string };
+ expect(opts.participantId).toMatch(UUID_RE);
+ });
+ });
});
diff --git a/apps/web/src/app/session/[id]/page.tsx b/apps/web/src/app/session/[id]/page.tsx
index 0ce09d96..1435366e 100644
--- a/apps/web/src/app/session/[id]/page.tsx
+++ b/apps/web/src/app/session/[id]/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect, use, useId, useRef, useCallback, useMemo } from 'react';
+import { useState, useEffect, use, useRef, useCallback, useMemo } from 'react';
import Link from 'next/link';
import {
Users,
@@ -155,7 +155,10 @@ interface SessionViewerWrapperProps {
}
function P2PSessionViewer({ sessionId, session }: SessionViewerWrapperProps) {
- const participantId = useId();
+ // A real UUID — /api/livekit/token and the signal routes validate
+ // participantId with z.string().uuid(). useId() returns React opaque ids
+ // (":r0:"), which the SFU token route rejects ("Invalid participant ID").
+ const [participantId] = useState(() => crypto.randomUUID());
const [remoteCursors, setRemoteCursors] = useState