@xtr-dev/rondevu-server 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.ts CHANGED
@@ -1,23 +1,37 @@
1
1
  import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
3
  import { Storage } from './storage/types.ts';
4
-
5
- export interface AppConfig {
6
- sessionTimeout: number;
7
- corsOrigins: string[];
8
- }
4
+ import { Config } from './config.ts';
5
+ import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
6
+ import { generatePeerId, encryptPeerId } from './crypto.ts';
7
+ import { parseBloomFilter } from './bloom.ts';
8
+ import type { Context } from 'hono';
9
9
 
10
10
  /**
11
- * Creates the Hono application with WebRTC signaling endpoints
11
+ * Creates the Hono application with topic-based WebRTC signaling endpoints
12
12
  */
13
- export function createApp(storage: Storage, config: AppConfig) {
13
+ export function createApp(storage: Storage, config: Config) {
14
14
  const app = new Hono();
15
15
 
16
- // Enable CORS
16
+ // Create auth middleware
17
+ const authMiddleware = createAuthMiddleware(config.authSecret);
18
+
19
+ // Enable CORS with dynamic origin handling
17
20
  app.use('/*', cors({
18
- origin: config.corsOrigins,
19
- allowMethods: ['GET', 'POST', 'OPTIONS'],
20
- allowHeaders: ['Content-Type'],
21
+ origin: (origin) => {
22
+ // If no origin restrictions (wildcard), allow any origin
23
+ if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
24
+ return origin;
25
+ }
26
+ // Otherwise check if origin is in allowed list
27
+ if (config.corsOrigins.includes(origin)) {
28
+ return origin;
29
+ }
30
+ // Default to first allowed origin
31
+ return config.corsOrigins[0];
32
+ },
33
+ allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
34
+ allowHeaders: ['Content-Type', 'Origin', 'Authorization'],
21
35
  exposeHeaders: ['Content-Type'],
22
36
  maxAge: 600,
23
37
  credentials: true,
@@ -25,203 +39,483 @@ export function createApp(storage: Storage, config: AppConfig) {
25
39
 
26
40
  /**
27
41
  * GET /
28
- * Lists all topics with their unanswered session counts (paginated)
29
- * Query params: page (default: 1), limit (default: 100, max: 1000)
42
+ * Returns server version information
30
43
  */
31
- app.get('/', async (c) => {
44
+ app.get('/', (c) => {
45
+ return c.json({
46
+ version: config.version,
47
+ name: 'Rondevu',
48
+ description: 'Topic-based peer discovery and signaling server'
49
+ });
50
+ });
51
+
52
+ /**
53
+ * GET /health
54
+ * Health check endpoint with version
55
+ */
56
+ app.get('/health', (c) => {
57
+ return c.json({
58
+ status: 'ok',
59
+ timestamp: Date.now(),
60
+ version: config.version
61
+ });
62
+ });
63
+
64
+ /**
65
+ * POST /register
66
+ * Register a new peer and receive credentials
67
+ */
68
+ app.post('/register', async (c) => {
32
69
  try {
33
- const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
34
- const page = parseInt(c.req.query('page') || '1', 10);
35
- const limit = parseInt(c.req.query('limit') || '100', 10);
70
+ // Generate new peer ID
71
+ const peerId = generatePeerId();
36
72
 
37
- const result = await storage.listTopics(origin, page, limit);
73
+ // Encrypt peer ID with server secret (async operation)
74
+ const secret = await encryptPeerId(peerId, config.authSecret);
38
75
 
39
- return c.json(result);
76
+ return c.json({
77
+ peerId,
78
+ secret
79
+ }, 200);
40
80
  } catch (err) {
41
- console.error('Error listing topics:', err);
81
+ console.error('Error registering peer:', err);
42
82
  return c.json({ error: 'Internal server error' }, 500);
43
83
  }
44
84
  });
45
85
 
46
86
  /**
47
- * GET /:topic/sessions
48
- * Lists all unanswered sessions for a topic
87
+ * POST /offers
88
+ * Creates one or more offers with topics
89
+ * Requires authentication
49
90
  */
50
- app.get('/:topic/sessions', async (c) => {
91
+ app.post('/offers', authMiddleware, async (c) => {
51
92
  try {
52
- const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
53
- const topic = c.req.param('topic');
93
+ const body = await c.req.json();
94
+ const { offers } = body;
54
95
 
55
- if (!topic) {
56
- return c.json({ error: 'Missing required parameter: topic' }, 400);
96
+ if (!Array.isArray(offers) || offers.length === 0) {
97
+ return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
57
98
  }
58
99
 
59
- if (topic.length > 256) {
60
- return c.json({ error: 'Topic string must be 256 characters or less' }, 400);
100
+ if (offers.length > config.maxOffersPerRequest) {
101
+ return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
61
102
  }
62
103
 
63
- const sessions = await storage.listSessionsByTopic(origin, topic);
104
+ const peerId = getAuthenticatedPeerId(c);
105
+
106
+ // Validate and prepare offers
107
+ const offerRequests = [];
108
+ for (const offer of offers) {
109
+ // Validate SDP
110
+ if (!offer.sdp || typeof offer.sdp !== 'string') {
111
+ return c.json({ error: 'Each offer must have an sdp field' }, 400);
112
+ }
113
+
114
+ if (offer.sdp.length > 65536) {
115
+ return c.json({ error: 'SDP must be 64KB or less' }, 400);
116
+ }
117
+
118
+ // Validate secret if provided
119
+ if (offer.secret !== undefined) {
120
+ if (typeof offer.secret !== 'string') {
121
+ return c.json({ error: 'Secret must be a string' }, 400);
122
+ }
123
+ if (offer.secret.length > 128) {
124
+ return c.json({ error: 'Secret must be 128 characters or less' }, 400);
125
+ }
126
+ }
127
+
128
+ // Validate topics
129
+ if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
130
+ return c.json({ error: 'Each offer must have a non-empty topics array' }, 400);
131
+ }
132
+
133
+ if (offer.topics.length > config.maxTopicsPerOffer) {
134
+ return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
135
+ }
136
+
137
+ for (const topic of offer.topics) {
138
+ if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) {
139
+ return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400);
140
+ }
141
+ }
142
+
143
+ // Validate and clamp TTL
144
+ let ttl = offer.ttl || config.offerDefaultTtl;
145
+ if (ttl < config.offerMinTtl) {
146
+ ttl = config.offerMinTtl;
147
+ }
148
+ if (ttl > config.offerMaxTtl) {
149
+ ttl = config.offerMaxTtl;
150
+ }
64
151
 
152
+ offerRequests.push({
153
+ id: offer.id,
154
+ peerId,
155
+ sdp: offer.sdp,
156
+ topics: offer.topics,
157
+ expiresAt: Date.now() + ttl,
158
+ secret: offer.secret,
159
+ });
160
+ }
161
+
162
+ // Create offers
163
+ const createdOffers = await storage.createOffers(offerRequests);
164
+
165
+ // Return simplified response
65
166
  return c.json({
66
- sessions: sessions.map(s => ({
67
- code: s.code,
68
- info: s.info,
69
- offer: s.offer,
70
- offerCandidates: s.offerCandidates,
71
- createdAt: s.createdAt,
72
- expiresAt: s.expiresAt,
73
- })),
74
- });
167
+ offers: createdOffers.map(o => ({
168
+ id: o.id,
169
+ peerId: o.peerId,
170
+ topics: o.topics,
171
+ expiresAt: o.expiresAt
172
+ }))
173
+ }, 200);
75
174
  } catch (err) {
76
- console.error('Error listing sessions:', err);
175
+ console.error('Error creating offers:', err);
77
176
  return c.json({ error: 'Internal server error' }, 500);
78
177
  }
79
178
  });
80
179
 
81
180
  /**
82
- * POST /:topic/offer
83
- * Creates a new offer and returns a unique session code
84
- * Body: { info: string, offer: string }
181
+ * GET /offers/by-topic/:topic
182
+ * Find offers by topic with optional bloom filter exclusion
183
+ * Public endpoint (no auth required)
85
184
  */
86
- app.post('/:topic/offer', async (c) => {
185
+ app.get('/offers/by-topic/:topic', async (c) => {
87
186
  try {
88
- const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
89
187
  const topic = c.req.param('topic');
90
- const body = await c.req.json();
91
- const { info, offer } = body;
188
+ const bloomParam = c.req.query('bloom');
189
+ const limitParam = c.req.query('limit');
92
190
 
93
- if (!topic || typeof topic !== 'string') {
94
- return c.json({ error: 'Missing or invalid required parameter: topic' }, 400);
95
- }
191
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
96
192
 
97
- if (topic.length > 256) {
98
- return c.json({ error: 'Topic string must be 256 characters or less' }, 400);
99
- }
193
+ // Parse bloom filter if provided
194
+ let excludePeerIds: string[] = [];
195
+ if (bloomParam) {
196
+ const bloom = parseBloomFilter(bloomParam);
197
+ if (!bloom) {
198
+ return c.json({ error: 'Invalid bloom filter format' }, 400);
199
+ }
100
200
 
101
- if (!info || typeof info !== 'string') {
102
- return c.json({ error: 'Missing or invalid required parameter: info' }, 400);
103
- }
201
+ // Get all offers for topic first
202
+ const allOffers = await storage.getOffersByTopic(topic);
104
203
 
105
- if (info.length > 1024) {
106
- return c.json({ error: 'Info string must be 1024 characters or less' }, 400);
107
- }
204
+ // Test each peer ID against bloom filter
205
+ const excludeSet = new Set<string>();
206
+ for (const offer of allOffers) {
207
+ if (bloom.test(offer.peerId)) {
208
+ excludeSet.add(offer.peerId);
209
+ }
210
+ }
108
211
 
109
- if (!offer || typeof offer !== 'string') {
110
- return c.json({ error: 'Missing or invalid required parameter: offer' }, 400);
212
+ excludePeerIds = Array.from(excludeSet);
111
213
  }
112
214
 
113
- const expiresAt = Date.now() + config.sessionTimeout;
114
- const code = await storage.createSession(origin, topic, info, offer, expiresAt);
215
+ // Get filtered offers
216
+ let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined);
115
217
 
116
- return c.json({ code }, 200);
218
+ // Apply limit
219
+ const total = offers.length;
220
+ offers = offers.slice(0, limit);
221
+
222
+ return c.json({
223
+ topic,
224
+ offers: offers.map(o => ({
225
+ id: o.id,
226
+ peerId: o.peerId,
227
+ sdp: o.sdp,
228
+ topics: o.topics,
229
+ expiresAt: o.expiresAt,
230
+ lastSeen: o.lastSeen,
231
+ hasSecret: !!o.secret // Indicate if secret is required without exposing it
232
+ })),
233
+ total: bloomParam ? total + excludePeerIds.length : total,
234
+ returned: offers.length
235
+ }, 200);
117
236
  } catch (err) {
118
- console.error('Error creating offer:', err);
237
+ console.error('Error fetching offers by topic:', err);
119
238
  return c.json({ error: 'Internal server error' }, 500);
120
239
  }
121
240
  });
122
241
 
123
242
  /**
124
- * POST /answer
125
- * Responds to an existing offer or sends ICE candidates
126
- * Body: { code: string, answer?: string, candidate?: string, side: 'offerer' | 'answerer' }
243
+ * GET /topics
244
+ * List all topics with active peer counts (paginated)
245
+ * Public endpoint (no auth required)
246
+ * Query params:
247
+ * - limit: Max topics to return (default 50, max 200)
248
+ * - offset: Number of topics to skip (default 0)
249
+ * - startsWith: Filter topics starting with this prefix (optional)
127
250
  */
128
- app.post('/answer', async (c) => {
251
+ app.get('/topics', async (c) => {
129
252
  try {
130
- const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
131
- const body = await c.req.json();
132
- const { code, answer, candidate, side } = body;
253
+ const limitParam = c.req.query('limit');
254
+ const offsetParam = c.req.query('offset');
255
+ const startsWithParam = c.req.query('startsWith');
133
256
 
134
- if (!code || typeof code !== 'string') {
135
- return c.json({ error: 'Missing or invalid required parameter: code' }, 400);
257
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
258
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
259
+ const startsWith = startsWithParam || undefined;
260
+
261
+ const result = await storage.getTopics(limit, offset, startsWith);
262
+
263
+ return c.json({
264
+ topics: result.topics,
265
+ total: result.total,
266
+ limit,
267
+ offset,
268
+ ...(startsWith && { startsWith })
269
+ }, 200);
270
+ } catch (err) {
271
+ console.error('Error fetching topics:', err);
272
+ return c.json({ error: 'Internal server error' }, 500);
273
+ }
274
+ });
275
+
276
+ /**
277
+ * GET /peers/:peerId/offers
278
+ * View all offers from a specific peer
279
+ * Public endpoint
280
+ */
281
+ app.get('/peers/:peerId/offers', async (c) => {
282
+ try {
283
+ const peerId = c.req.param('peerId');
284
+ const offers = await storage.getOffersByPeerId(peerId);
285
+
286
+ // Collect unique topics
287
+ const topicsSet = new Set<string>();
288
+ offers.forEach(o => o.topics.forEach(t => topicsSet.add(t)));
289
+
290
+ return c.json({
291
+ peerId,
292
+ offers: offers.map(o => ({
293
+ id: o.id,
294
+ sdp: o.sdp,
295
+ topics: o.topics,
296
+ expiresAt: o.expiresAt,
297
+ lastSeen: o.lastSeen,
298
+ hasSecret: !!o.secret // Indicate if secret is required without exposing it
299
+ })),
300
+ topics: Array.from(topicsSet)
301
+ }, 200);
302
+ } catch (err) {
303
+ console.error('Error fetching peer offers:', err);
304
+ return c.json({ error: 'Internal server error' }, 500);
305
+ }
306
+ });
307
+
308
+ /**
309
+ * GET /offers/mine
310
+ * List all offers owned by authenticated peer
311
+ * Requires authentication
312
+ */
313
+ app.get('/offers/mine', authMiddleware, async (c) => {
314
+ try {
315
+ const peerId = getAuthenticatedPeerId(c);
316
+ const offers = await storage.getOffersByPeerId(peerId);
317
+
318
+ return c.json({
319
+ peerId,
320
+ offers: offers.map(o => ({
321
+ id: o.id,
322
+ sdp: o.sdp,
323
+ topics: o.topics,
324
+ createdAt: o.createdAt,
325
+ expiresAt: o.expiresAt,
326
+ lastSeen: o.lastSeen,
327
+ secret: o.secret, // Owner can see the secret
328
+ answererPeerId: o.answererPeerId,
329
+ answeredAt: o.answeredAt
330
+ }))
331
+ }, 200);
332
+ } catch (err) {
333
+ console.error('Error fetching own offers:', err);
334
+ return c.json({ error: 'Internal server error' }, 500);
335
+ }
336
+ });
337
+
338
+ /**
339
+ * DELETE /offers/:offerId
340
+ * Delete a specific offer
341
+ * Requires authentication and ownership
342
+ */
343
+ app.delete('/offers/:offerId', authMiddleware, async (c) => {
344
+ try {
345
+ const offerId = c.req.param('offerId');
346
+ const peerId = getAuthenticatedPeerId(c);
347
+
348
+ const deleted = await storage.deleteOffer(offerId, peerId);
349
+
350
+ if (!deleted) {
351
+ return c.json({ error: 'Offer not found or not authorized' }, 404);
136
352
  }
137
353
 
138
- if (!side || (side !== 'offerer' && side !== 'answerer')) {
139
- return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
354
+ return c.json({ deleted: true }, 200);
355
+ } catch (err) {
356
+ console.error('Error deleting offer:', err);
357
+ return c.json({ error: 'Internal server error' }, 500);
358
+ }
359
+ });
360
+
361
+ /**
362
+ * POST /offers/:offerId/answer
363
+ * Answer a specific offer (locks it to answerer)
364
+ * Requires authentication
365
+ */
366
+ app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
367
+ try {
368
+ const offerId = c.req.param('offerId');
369
+ const peerId = getAuthenticatedPeerId(c);
370
+ const body = await c.req.json();
371
+ const { sdp, secret } = body;
372
+
373
+ if (!sdp || typeof sdp !== 'string') {
374
+ return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400);
140
375
  }
141
376
 
142
- if (!answer && !candidate) {
143
- return c.json({ error: 'Missing required parameter: answer or candidate' }, 400);
377
+ if (sdp.length > 65536) {
378
+ return c.json({ error: 'SDP must be 64KB or less' }, 400);
144
379
  }
145
380
 
146
- if (answer && candidate) {
147
- return c.json({ error: 'Cannot provide both answer and candidate' }, 400);
381
+ // Validate secret if provided
382
+ if (secret !== undefined && typeof secret !== 'string') {
383
+ return c.json({ error: 'Secret must be a string' }, 400);
148
384
  }
149
385
 
150
- const session = await storage.getSession(code, origin);
386
+ const result = await storage.answerOffer(offerId, peerId, sdp, secret);
151
387
 
152
- if (!session) {
153
- return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
388
+ if (!result.success) {
389
+ return c.json({ error: result.error }, 400);
154
390
  }
155
391
 
156
- if (answer) {
157
- await storage.updateSession(code, origin, { answer });
158
- }
392
+ return c.json({
393
+ offerId,
394
+ answererId: peerId,
395
+ answeredAt: Date.now()
396
+ }, 200);
397
+ } catch (err) {
398
+ console.error('Error answering offer:', err);
399
+ return c.json({ error: 'Internal server error' }, 500);
400
+ }
401
+ });
159
402
 
160
- if (candidate) {
161
- if (side === 'offerer') {
162
- const updatedCandidates = [...session.offerCandidates, candidate];
163
- await storage.updateSession(code, origin, { offerCandidates: updatedCandidates });
164
- } else {
165
- const updatedCandidates = [...session.answerCandidates, candidate];
166
- await storage.updateSession(code, origin, { answerCandidates: updatedCandidates });
167
- }
168
- }
403
+ /**
404
+ * GET /offers/answers
405
+ * Poll for answers to all of authenticated peer's offers
406
+ * Requires authentication (offerer)
407
+ */
408
+ app.get('/offers/answers', authMiddleware, async (c) => {
409
+ try {
410
+ const peerId = getAuthenticatedPeerId(c);
411
+ const offers = await storage.getAnsweredOffers(peerId);
169
412
 
170
- return c.json({ success: true }, 200);
413
+ return c.json({
414
+ answers: offers.map(o => ({
415
+ offerId: o.id,
416
+ answererId: o.answererPeerId,
417
+ sdp: o.answerSdp,
418
+ answeredAt: o.answeredAt,
419
+ topics: o.topics
420
+ }))
421
+ }, 200);
171
422
  } catch (err) {
172
- console.error('Error handling answer:', err);
423
+ console.error('Error fetching answers:', err);
173
424
  return c.json({ error: 'Internal server error' }, 500);
174
425
  }
175
426
  });
176
427
 
177
428
  /**
178
- * POST /poll
179
- * Polls for session data (offer, answer, ICE candidates)
180
- * Body: { code: string, side: 'offerer' | 'answerer' }
429
+ * POST /offers/:offerId/ice-candidates
430
+ * Post ICE candidates for an offer
431
+ * Requires authentication (must be offerer or answerer)
181
432
  */
182
- app.post('/poll', async (c) => {
433
+ app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
183
434
  try {
184
- const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
435
+ const offerId = c.req.param('offerId');
436
+ const peerId = getAuthenticatedPeerId(c);
185
437
  const body = await c.req.json();
186
- const { code, side } = body;
187
-
188
- if (!code || typeof code !== 'string') {
189
- return c.json({ error: 'Missing or invalid required parameter: code' }, 400);
190
- }
438
+ const { candidates } = body;
191
439
 
192
- if (!side || (side !== 'offerer' && side !== 'answerer')) {
193
- return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
440
+ if (!Array.isArray(candidates) || candidates.length === 0) {
441
+ return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400);
194
442
  }
195
443
 
196
- const session = await storage.getSession(code, origin);
197
-
198
- if (!session) {
199
- return c.json({ error: 'Session not found, expired, or origin mismatch' }, 404);
444
+ // Verify offer exists and caller is offerer or answerer
445
+ const offer = await storage.getOfferById(offerId);
446
+ if (!offer) {
447
+ return c.json({ error: 'Offer not found or expired' }, 404);
200
448
  }
201
449
 
202
- if (side === 'offerer') {
203
- return c.json({
204
- answer: session.answer || null,
205
- answerCandidates: session.answerCandidates,
206
- });
450
+ let role: 'offerer' | 'answerer';
451
+ if (offer.peerId === peerId) {
452
+ role = 'offerer';
453
+ } else if (offer.answererPeerId === peerId) {
454
+ role = 'answerer';
207
455
  } else {
208
- return c.json({
209
- offer: session.offer,
210
- offerCandidates: session.offerCandidates,
211
- });
456
+ return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
212
457
  }
458
+
459
+ const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
460
+
461
+ return c.json({
462
+ offerId,
463
+ candidatesAdded: added
464
+ }, 200);
213
465
  } catch (err) {
214
- console.error('Error polling session:', err);
466
+ console.error('Error adding ICE candidates:', err);
215
467
  return c.json({ error: 'Internal server error' }, 500);
216
468
  }
217
469
  });
218
470
 
219
471
  /**
220
- * GET /health
221
- * Health check endpoint
472
+ * GET /offers/:offerId/ice-candidates
473
+ * Poll for ICE candidates from the other peer
474
+ * Requires authentication (must be offerer or answerer)
222
475
  */
223
- app.get('/health', (c) => {
224
- return c.json({ status: 'ok', timestamp: Date.now() });
476
+ app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
477
+ try {
478
+ const offerId = c.req.param('offerId');
479
+ const peerId = getAuthenticatedPeerId(c);
480
+ const sinceParam = c.req.query('since');
481
+
482
+ const since = sinceParam ? parseInt(sinceParam, 10) : undefined;
483
+
484
+ // Verify offer exists and caller is offerer or answerer
485
+ const offer = await storage.getOfferById(offerId);
486
+ if (!offer) {
487
+ return c.json({ error: 'Offer not found or expired' }, 404);
488
+ }
489
+
490
+ let targetRole: 'offerer' | 'answerer';
491
+ if (offer.peerId === peerId) {
492
+ // Offerer wants answerer's candidates
493
+ targetRole = 'answerer';
494
+ console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
495
+ } else if (offer.answererPeerId === peerId) {
496
+ // Answerer wants offerer's candidates
497
+ targetRole = 'offerer';
498
+ console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
499
+ } else {
500
+ return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
501
+ }
502
+
503
+ const candidates = await storage.getIceCandidates(offerId, targetRole, since);
504
+ console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
505
+
506
+ return c.json({
507
+ offerId,
508
+ candidates: candidates.map(c => ({
509
+ candidate: c.candidate,
510
+ peerId: c.peerId,
511
+ role: c.role,
512
+ createdAt: c.createdAt
513
+ }))
514
+ }, 200);
515
+ } catch (err) {
516
+ console.error('Error fetching ICE candidates:', err);
517
+ return c.json({ error: 'Internal server error' }, 500);
518
+ }
225
519
  });
226
520
 
227
521
  return app;