Skip to content

Commit 5dd42dc

Browse files
committed
Fix signal auth for authenticated desktop viewers
1 parent 1acfbd0 commit 5dd42dc

2 files changed

Lines changed: 67 additions & 4 deletions

File tree

apps/web/src/app/api/sessions/[sessionId]/signal/route.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,55 @@ describe('POST /api/sessions/[sessionId]/signal', () => {
206206
expect(body.error).toBe('Not authorized to send signals in this session');
207207
});
208208

209+
it('allows authenticated viewer signaling with senderId=user.id when user is an active participant', async () => {
210+
const otherUserSession = { ...mockSession, host_user_id: 'host-user-id' };
211+
let callCount = 0;
212+
213+
const mockChannel = {
214+
subscribe: vi.fn().mockImplementation((callback) => {
215+
callback('SUBSCRIBED');
216+
return mockChannel;
217+
}),
218+
send: vi.fn().mockResolvedValue('ok'),
219+
};
220+
221+
const mockFrom = vi.fn().mockReturnValue({
222+
select: vi.fn().mockReturnThis(),
223+
eq: vi.fn().mockReturnThis(),
224+
is: vi.fn().mockReturnThis(),
225+
single: vi.fn().mockImplementation(() => {
226+
callCount++;
227+
if (callCount === 1) {
228+
// Session lookup
229+
return Promise.resolve({ data: otherUserSession, error: null });
230+
}
231+
if (callCount === 2) {
232+
// Participant lookup by signal.senderId (user.id) misses
233+
return Promise.resolve({ data: null, error: { code: 'PGRST116' } });
234+
}
235+
// Participant lookup by user_id succeeds
236+
return Promise.resolve({ data: { id: 'participant-row-id' }, error: null });
237+
}),
238+
});
239+
240+
const mockSupabase = createMockSupabaseClient({
241+
from: mockFrom,
242+
channel: vi.fn().mockReturnValue(mockChannel),
243+
removeChannel: vi.fn().mockResolvedValue('ok'),
244+
});
245+
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
246+
mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null });
247+
248+
const response = await POST(createRequest('test-session-id', validSignal), {
249+
params: Promise.resolve({ sessionId: 'test-session-id' }),
250+
});
251+
const body = await response.json();
252+
253+
expect(response.status).toBe(200);
254+
expect(body.data.sent).toBe(true);
255+
expect(mockChannel.send).toHaveBeenCalled();
256+
});
257+
209258
it('returns 400 for invalid signal type', async () => {
210259
const invalidSignal = {
211260
type: 'invalid-type',

apps/web/src/app/api/sessions/[sessionId]/signal/route.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,31 @@ export async function POST(
8888
const isHost = user?.id === session.host_user_id;
8989

9090
if (!isHost) {
91-
// Check if sender is a participant
92-
const { data: participant } = await supabase
91+
// Allow either participant-row IDs (legacy/current web paths) or authenticated user IDs
92+
// (desktop SSE subscriber IDs can be the auth user ID).
93+
const { data: participantById } = await supabase
9394
.from('session_participants')
9495
.select('id')
9596
.eq('session_id', sessionId)
9697
.eq('id', signal.senderId)
9798
.is('left_at', null)
9899
.single();
99100

100-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
101-
if (!participant) {
101+
let isAuthorizedParticipant = Boolean(participantById);
102+
103+
if (!isAuthorizedParticipant && user?.id && signal.senderId === user.id) {
104+
const { data: participantByUserId } = await supabase
105+
.from('session_participants')
106+
.select('id')
107+
.eq('session_id', sessionId)
108+
.eq('user_id', user.id)
109+
.is('left_at', null)
110+
.single();
111+
112+
isAuthorizedParticipant = Boolean(participantByUserId);
113+
}
114+
115+
if (!isAuthorizedParticipant) {
102116
return errorResponse('Not authorized to send signals in this session', 403);
103117
}
104118
}

0 commit comments

Comments
 (0)