@xtr-dev/rondevu-server 0.1.5 → 0.2.0

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
@@ -3,12 +3,11 @@ import { cors } from 'hono/cors';
3
3
  import { Storage } from './storage/types.ts';
4
4
  import { Config } from './config.ts';
5
5
  import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
6
- import { generatePeerId, encryptPeerId } from './crypto.ts';
7
- import { parseBloomFilter } from './bloom.ts';
6
+ import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServiceFqn } from './crypto.ts';
8
7
  import type { Context } from 'hono';
9
8
 
10
9
  /**
11
- * Creates the Hono application with topic-based WebRTC signaling endpoints
10
+ * Creates the Hono application with username and service-based WebRTC signaling
12
11
  */
13
12
  export function createApp(storage: Storage, config: Config) {
14
13
  const app = new Hono();
@@ -16,18 +15,15 @@ export function createApp(storage: Storage, config: Config) {
16
15
  // Create auth middleware
17
16
  const authMiddleware = createAuthMiddleware(config.authSecret);
18
17
 
19
- // Enable CORS with dynamic origin handling
18
+ // Enable CORS
20
19
  app.use('/*', cors({
21
20
  origin: (origin) => {
22
- // If no origin restrictions (wildcard), allow any origin
23
21
  if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
24
22
  return origin;
25
23
  }
26
- // Otherwise check if origin is in allowed list
27
24
  if (config.corsOrigins.includes(origin)) {
28
25
  return origin;
29
26
  }
30
- // Default to first allowed origin
31
27
  return config.corsOrigins[0];
32
28
  },
33
29
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
@@ -37,21 +33,23 @@ export function createApp(storage: Storage, config: Config) {
37
33
  credentials: true,
38
34
  }));
39
35
 
36
+ // ===== General Endpoints =====
37
+
40
38
  /**
41
39
  * GET /
42
- * Returns server version information
40
+ * Returns server information
43
41
  */
44
42
  app.get('/', (c) => {
45
43
  return c.json({
46
44
  version: config.version,
47
45
  name: 'Rondevu',
48
- description: 'Topic-based peer discovery and signaling server'
46
+ description: 'DNS-like WebRTC signaling with username claiming and service discovery'
49
47
  });
50
48
  });
51
49
 
52
50
  /**
53
51
  * GET /health
54
- * Health check endpoint with version
52
+ * Health check endpoint
55
53
  */
56
54
  app.get('/health', (c) => {
57
55
  return c.json({
@@ -63,15 +61,11 @@ export function createApp(storage: Storage, config: Config) {
63
61
 
64
62
  /**
65
63
  * POST /register
66
- * Register a new peer and receive credentials
67
- * Generates a cryptographically random peer ID (128-bit)
64
+ * Register a new peer (still needed for peer ID generation)
68
65
  */
69
66
  app.post('/register', async (c) => {
70
67
  try {
71
- // Always generate a random peer ID
72
68
  const peerId = generatePeerId();
73
-
74
- // Encrypt peer ID with server secret (async operation)
75
69
  const secret = await encryptPeerId(peerId, config.authSecret);
76
70
 
77
71
  return c.json({
@@ -84,245 +78,353 @@ export function createApp(storage: Storage, config: Config) {
84
78
  }
85
79
  });
86
80
 
81
+ // ===== Username Management =====
82
+
87
83
  /**
88
- * POST /offers
89
- * Creates one or more offers with topics
90
- * Requires authentication
84
+ * POST /usernames/claim
85
+ * Claim a username with cryptographic proof
91
86
  */
92
- app.post('/offers', authMiddleware, async (c) => {
87
+ app.post('/usernames/claim', async (c) => {
93
88
  try {
94
89
  const body = await c.req.json();
95
- const { offers } = body;
90
+ const { username, publicKey, signature, message } = body;
96
91
 
97
- if (!Array.isArray(offers) || offers.length === 0) {
98
- return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
92
+ if (!username || !publicKey || !signature || !message) {
93
+ return c.json({ error: 'Missing required parameters: username, publicKey, signature, message' }, 400);
99
94
  }
100
95
 
101
- if (offers.length > config.maxOffersPerRequest) {
102
- return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
96
+ // Validate claim
97
+ const validation = await validateUsernameClaim(username, publicKey, signature, message);
98
+ if (!validation.valid) {
99
+ return c.json({ error: validation.error }, 400);
103
100
  }
104
101
 
105
- const peerId = getAuthenticatedPeerId(c);
102
+ // Attempt to claim username
103
+ try {
104
+ const claimed = await storage.claimUsername({
105
+ username,
106
+ publicKey,
107
+ signature,
108
+ message
109
+ });
106
110
 
107
- // Validate and prepare offers
108
- const offerRequests = [];
109
- for (const offer of offers) {
110
- // Validate SDP
111
- if (!offer.sdp || typeof offer.sdp !== 'string') {
112
- return c.json({ error: 'Each offer must have an sdp field' }, 400);
111
+ return c.json({
112
+ username: claimed.username,
113
+ claimedAt: claimed.claimedAt,
114
+ expiresAt: claimed.expiresAt
115
+ }, 200);
116
+ } catch (err: any) {
117
+ if (err.message?.includes('already claimed')) {
118
+ return c.json({ error: 'Username already claimed by different public key' }, 409);
113
119
  }
120
+ throw err;
121
+ }
122
+ } catch (err) {
123
+ console.error('Error claiming username:', err);
124
+ return c.json({ error: 'Internal server error' }, 500);
125
+ }
126
+ });
114
127
 
115
- if (offer.sdp.length > 65536) {
116
- return c.json({ error: 'SDP must be 64KB or less' }, 400);
117
- }
128
+ /**
129
+ * GET /usernames/:username
130
+ * Check if username is available or get claim info
131
+ */
132
+ app.get('/usernames/:username', async (c) => {
133
+ try {
134
+ const username = c.req.param('username');
118
135
 
119
- // Validate secret if provided
120
- if (offer.secret !== undefined) {
121
- if (typeof offer.secret !== 'string') {
122
- return c.json({ error: 'Secret must be a string' }, 400);
123
- }
124
- if (offer.secret.length > 128) {
125
- return c.json({ error: 'Secret must be 128 characters or less' }, 400);
126
- }
127
- }
136
+ const claimed = await storage.getUsername(username);
128
137
 
129
- // Validate info if provided
130
- if (offer.info !== undefined) {
131
- if (typeof offer.info !== 'string') {
132
- return c.json({ error: 'Info must be a string' }, 400);
133
- }
134
- if (offer.info.length > 128) {
135
- return c.json({ error: 'Info must be 128 characters or less' }, 400);
136
- }
137
- }
138
+ if (!claimed) {
139
+ return c.json({
140
+ username,
141
+ available: true
142
+ }, 200);
143
+ }
138
144
 
139
- // Validate topics
140
- if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
141
- return c.json({ error: 'Each offer must have a non-empty topics array' }, 400);
142
- }
145
+ return c.json({
146
+ username: claimed.username,
147
+ available: false,
148
+ claimedAt: claimed.claimedAt,
149
+ expiresAt: claimed.expiresAt,
150
+ publicKey: claimed.publicKey
151
+ }, 200);
152
+ } catch (err) {
153
+ console.error('Error checking username:', err);
154
+ return c.json({ error: 'Internal server error' }, 500);
155
+ }
156
+ });
143
157
 
144
- if (offer.topics.length > config.maxTopicsPerOffer) {
145
- return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
146
- }
158
+ /**
159
+ * GET /usernames/:username/services
160
+ * List services for a username (privacy-preserving)
161
+ */
162
+ app.get('/usernames/:username/services', async (c) => {
163
+ try {
164
+ const username = c.req.param('username');
147
165
 
148
- for (const topic of offer.topics) {
149
- if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) {
150
- return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400);
151
- }
152
- }
166
+ const services = await storage.listServicesForUsername(username);
153
167
 
154
- // Validate and clamp TTL
155
- let ttl = offer.ttl || config.offerDefaultTtl;
156
- if (ttl < config.offerMinTtl) {
157
- ttl = config.offerMinTtl;
158
- }
159
- if (ttl > config.offerMaxTtl) {
160
- ttl = config.offerMaxTtl;
161
- }
168
+ return c.json({
169
+ username,
170
+ services
171
+ }, 200);
172
+ } catch (err) {
173
+ console.error('Error listing services:', err);
174
+ return c.json({ error: 'Internal server error' }, 500);
175
+ }
176
+ });
162
177
 
163
- offerRequests.push({
164
- id: offer.id,
165
- peerId,
166
- sdp: offer.sdp,
167
- topics: offer.topics,
168
- expiresAt: Date.now() + ttl,
169
- secret: offer.secret,
170
- info: offer.info,
171
- });
178
+ // ===== Service Management =====
179
+
180
+ /**
181
+ * POST /services
182
+ * Publish a service
183
+ */
184
+ app.post('/services', authMiddleware, async (c) => {
185
+ try {
186
+ const body = await c.req.json();
187
+ const { username, serviceFqn, sdp, ttl, isPublic, metadata, signature, message } = body;
188
+
189
+ if (!username || !serviceFqn || !sdp) {
190
+ return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400);
191
+ }
192
+
193
+ // Validate service FQN
194
+ const fqnValidation = validateServiceFqn(serviceFqn);
195
+ if (!fqnValidation.valid) {
196
+ return c.json({ error: fqnValidation.error }, 400);
197
+ }
198
+
199
+ // Verify username ownership (signature required)
200
+ if (!signature || !message) {
201
+ return c.json({ error: 'Missing signature or message for username verification' }, 400);
202
+ }
203
+
204
+ const usernameRecord = await storage.getUsername(username);
205
+ if (!usernameRecord) {
206
+ return c.json({ error: 'Username not claimed' }, 404);
207
+ }
208
+
209
+ // Verify signature matches username's public key
210
+ const signatureValidation = await validateUsernameClaim(username, usernameRecord.publicKey, signature, message);
211
+ if (!signatureValidation.valid) {
212
+ return c.json({ error: 'Invalid signature for username' }, 403);
213
+ }
214
+
215
+ // Validate SDP
216
+ if (typeof sdp !== 'string' || sdp.length === 0) {
217
+ return c.json({ error: 'Invalid SDP' }, 400);
172
218
  }
173
219
 
174
- // Create offers
175
- const createdOffers = await storage.createOffers(offerRequests);
220
+ if (sdp.length > 64 * 1024) {
221
+ return c.json({ error: 'SDP too large (max 64KB)' }, 400);
222
+ }
223
+
224
+ // Calculate expiry
225
+ const peerId = getAuthenticatedPeerId(c);
226
+ const offerTtl = Math.min(
227
+ Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
228
+ config.offerMaxTtl
229
+ );
230
+ const expiresAt = Date.now() + offerTtl;
231
+
232
+ // Create offer first
233
+ const offers = await storage.createOffers([{
234
+ peerId,
235
+ sdp,
236
+ expiresAt
237
+ }]);
238
+
239
+ if (offers.length === 0) {
240
+ return c.json({ error: 'Failed to create offer' }, 500);
241
+ }
242
+
243
+ const offer = offers[0];
244
+
245
+ // Create service
246
+ const result = await storage.createService({
247
+ username,
248
+ serviceFqn,
249
+ offerId: offer.id,
250
+ expiresAt,
251
+ isPublic: isPublic || false,
252
+ metadata: metadata ? JSON.stringify(metadata) : undefined
253
+ });
176
254
 
177
- // Return simplified response
178
255
  return c.json({
179
- offers: createdOffers.map(o => ({
180
- id: o.id,
181
- peerId: o.peerId,
182
- topics: o.topics,
183
- expiresAt: o.expiresAt
184
- }))
185
- }, 200);
256
+ serviceId: result.service.id,
257
+ uuid: result.indexUuid,
258
+ offerId: offer.id,
259
+ expiresAt: result.service.expiresAt
260
+ }, 201);
186
261
  } catch (err) {
187
- console.error('Error creating offers:', err);
262
+ console.error('Error creating service:', err);
188
263
  return c.json({ error: 'Internal server error' }, 500);
189
264
  }
190
265
  });
191
266
 
192
267
  /**
193
- * GET /offers/by-topic/:topic
194
- * Find offers by topic with optional bloom filter exclusion
195
- * Public endpoint (no auth required)
268
+ * GET /services/:uuid
269
+ * Get service details by index UUID
196
270
  */
197
- app.get('/offers/by-topic/:topic', async (c) => {
271
+ app.get('/services/:uuid', async (c) => {
198
272
  try {
199
- const topic = c.req.param('topic');
200
- const bloomParam = c.req.query('bloom');
201
- const limitParam = c.req.query('limit');
202
-
203
- const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
204
-
205
- // Parse bloom filter if provided
206
- let excludePeerIds: string[] = [];
207
- if (bloomParam) {
208
- const bloom = parseBloomFilter(bloomParam);
209
- if (!bloom) {
210
- return c.json({ error: 'Invalid bloom filter format' }, 400);
211
- }
273
+ const uuid = c.req.param('uuid');
212
274
 
213
- // Get all offers for topic first
214
- const allOffers = await storage.getOffersByTopic(topic);
215
-
216
- // Test each peer ID against bloom filter
217
- const excludeSet = new Set<string>();
218
- for (const offer of allOffers) {
219
- if (bloom.test(offer.peerId)) {
220
- excludeSet.add(offer.peerId);
221
- }
222
- }
275
+ const service = await storage.getServiceByUuid(uuid);
223
276
 
224
- excludePeerIds = Array.from(excludeSet);
277
+ if (!service) {
278
+ return c.json({ error: 'Service not found' }, 404);
225
279
  }
226
280
 
227
- // Get filtered offers
228
- let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined);
281
+ // Get associated offer
282
+ const offer = await storage.getOfferById(service.offerId);
229
283
 
230
- // Apply limit
231
- const total = offers.length;
232
- offers = offers.slice(0, limit);
284
+ if (!offer) {
285
+ return c.json({ error: 'Associated offer not found' }, 404);
286
+ }
233
287
 
234
288
  return c.json({
235
- topic,
236
- offers: offers.map(o => ({
237
- id: o.id,
238
- peerId: o.peerId,
239
- sdp: o.sdp,
240
- topics: o.topics,
241
- expiresAt: o.expiresAt,
242
- lastSeen: o.lastSeen,
243
- hasSecret: !!o.secret, // Indicate if secret is required without exposing it
244
- info: o.info // Public info field
245
- })),
246
- total: bloomParam ? total + excludePeerIds.length : total,
247
- returned: offers.length
289
+ serviceId: service.id,
290
+ username: service.username,
291
+ serviceFqn: service.serviceFqn,
292
+ offerId: service.offerId,
293
+ sdp: offer.sdp,
294
+ isPublic: service.isPublic,
295
+ metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
296
+ createdAt: service.createdAt,
297
+ expiresAt: service.expiresAt
248
298
  }, 200);
249
299
  } catch (err) {
250
- console.error('Error fetching offers by topic:', err);
300
+ console.error('Error getting service:', err);
251
301
  return c.json({ error: 'Internal server error' }, 500);
252
302
  }
253
303
  });
254
304
 
255
305
  /**
256
- * GET /topics
257
- * List all topics with active peer counts (paginated)
258
- * Public endpoint (no auth required)
259
- * Query params:
260
- * - limit: Max topics to return (default 50, max 200)
261
- * - offset: Number of topics to skip (default 0)
262
- * - startsWith: Filter topics starting with this prefix (optional)
306
+ * DELETE /services/:serviceId
307
+ * Delete a service (requires ownership)
263
308
  */
264
- app.get('/topics', async (c) => {
309
+ app.delete('/services/:serviceId', authMiddleware, async (c) => {
265
310
  try {
266
- const limitParam = c.req.query('limit');
267
- const offsetParam = c.req.query('offset');
268
- const startsWithParam = c.req.query('startsWith');
311
+ const serviceId = c.req.param('serviceId');
312
+ const body = await c.req.json();
313
+ const { username } = body;
314
+
315
+ if (!username) {
316
+ return c.json({ error: 'Missing required parameter: username' }, 400);
317
+ }
269
318
 
270
- const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
271
- const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
272
- const startsWith = startsWithParam || undefined;
319
+ const deleted = await storage.deleteService(serviceId, username);
273
320
 
274
- const result = await storage.getTopics(limit, offset, startsWith);
321
+ if (!deleted) {
322
+ return c.json({ error: 'Service not found or not owned by this username' }, 404);
323
+ }
275
324
 
276
- return c.json({
277
- topics: result.topics,
278
- total: result.total,
279
- limit,
280
- offset,
281
- ...(startsWith && { startsWith })
282
- }, 200);
325
+ return c.json({ success: true }, 200);
283
326
  } catch (err) {
284
- console.error('Error fetching topics:', err);
327
+ console.error('Error deleting service:', err);
285
328
  return c.json({ error: 'Internal server error' }, 500);
286
329
  }
287
330
  });
288
331
 
289
332
  /**
290
- * GET /peers/:peerId/offers
291
- * View all offers from a specific peer
292
- * Public endpoint
333
+ * POST /index/:username/query
334
+ * Query service by FQN (returns UUID)
293
335
  */
294
- app.get('/peers/:peerId/offers', async (c) => {
336
+ app.post('/index/:username/query', async (c) => {
295
337
  try {
296
- const peerId = c.req.param('peerId');
297
- const offers = await storage.getOffersByPeerId(peerId);
338
+ const username = c.req.param('username');
339
+ const body = await c.req.json();
340
+ const { serviceFqn } = body;
341
+
342
+ if (!serviceFqn) {
343
+ return c.json({ error: 'Missing required parameter: serviceFqn' }, 400);
344
+ }
298
345
 
299
- // Collect unique topics
300
- const topicsSet = new Set<string>();
301
- offers.forEach(o => o.topics.forEach(t => topicsSet.add(t)));
346
+ const uuid = await storage.queryService(username, serviceFqn);
347
+
348
+ if (!uuid) {
349
+ return c.json({ error: 'Service not found' }, 404);
350
+ }
302
351
 
303
352
  return c.json({
304
- peerId,
305
- offers: offers.map(o => ({
306
- id: o.id,
307
- sdp: o.sdp,
308
- topics: o.topics,
309
- expiresAt: o.expiresAt,
310
- lastSeen: o.lastSeen,
311
- hasSecret: !!o.secret, // Indicate if secret is required without exposing it
312
- info: o.info // Public info field
313
- })),
314
- topics: Array.from(topicsSet)
353
+ uuid,
354
+ allowed: true
315
355
  }, 200);
316
356
  } catch (err) {
317
- console.error('Error fetching peer offers:', err);
357
+ console.error('Error querying service:', err);
318
358
  return c.json({ error: 'Internal server error' }, 500);
319
359
  }
320
360
  });
321
361
 
362
+ // ===== Offer Management (Core WebRTC) =====
363
+
364
+ /**
365
+ * POST /offers
366
+ * Create offers (direct, no service - for testing/advanced users)
367
+ */
368
+ app.post('/offers', authMiddleware, async (c) => {
369
+ try {
370
+ const body = await c.req.json();
371
+ const { offers } = body;
372
+
373
+ if (!Array.isArray(offers) || offers.length === 0) {
374
+ return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
375
+ }
376
+
377
+ if (offers.length > config.maxOffersPerRequest) {
378
+ return c.json({ error: `Too many offers (max ${config.maxOffersPerRequest})` }, 400);
379
+ }
380
+
381
+ const peerId = getAuthenticatedPeerId(c);
382
+
383
+ // Validate and prepare offers
384
+ const validated = offers.map((offer: any) => {
385
+ const { sdp, ttl, secret } = offer;
386
+
387
+ if (typeof sdp !== 'string' || sdp.length === 0) {
388
+ throw new Error('Invalid SDP in offer');
389
+ }
390
+
391
+ if (sdp.length > 64 * 1024) {
392
+ throw new Error('SDP too large (max 64KB)');
393
+ }
394
+
395
+ const offerTtl = Math.min(
396
+ Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
397
+ config.offerMaxTtl
398
+ );
399
+
400
+ return {
401
+ peerId,
402
+ sdp,
403
+ expiresAt: Date.now() + offerTtl,
404
+ secret: secret ? String(secret).substring(0, 128) : undefined
405
+ };
406
+ });
407
+
408
+ const created = await storage.createOffers(validated);
409
+
410
+ return c.json({
411
+ offers: created.map(offer => ({
412
+ id: offer.id,
413
+ peerId: offer.peerId,
414
+ expiresAt: offer.expiresAt,
415
+ createdAt: offer.createdAt,
416
+ hasSecret: !!offer.secret
417
+ }))
418
+ }, 201);
419
+ } catch (err: any) {
420
+ console.error('Error creating offers:', err);
421
+ return c.json({ error: err.message || 'Internal server error' }, 500);
422
+ }
423
+ });
424
+
322
425
  /**
323
426
  * GET /offers/mine
324
- * List all offers owned by authenticated peer
325
- * Requires authentication
427
+ * Get authenticated peer's offers
326
428
  */
327
429
  app.get('/offers/mine', authMiddleware, async (c) => {
328
430
  try {
@@ -330,30 +432,26 @@ export function createApp(storage: Storage, config: Config) {
330
432
  const offers = await storage.getOffersByPeerId(peerId);
331
433
 
332
434
  return c.json({
333
- peerId,
334
- offers: offers.map(o => ({
335
- id: o.id,
336
- sdp: o.sdp,
337
- topics: o.topics,
338
- createdAt: o.createdAt,
339
- expiresAt: o.expiresAt,
340
- lastSeen: o.lastSeen,
341
- secret: o.secret, // Owner can see the secret
342
- info: o.info, // Owner can see the info
343
- answererPeerId: o.answererPeerId,
344
- answeredAt: o.answeredAt
435
+ offers: offers.map(offer => ({
436
+ id: offer.id,
437
+ sdp: offer.sdp,
438
+ createdAt: offer.createdAt,
439
+ expiresAt: offer.expiresAt,
440
+ lastSeen: offer.lastSeen,
441
+ hasSecret: !!offer.secret,
442
+ answererPeerId: offer.answererPeerId,
443
+ answered: !!offer.answererPeerId
345
444
  }))
346
445
  }, 200);
347
446
  } catch (err) {
348
- console.error('Error fetching own offers:', err);
447
+ console.error('Error getting offers:', err);
349
448
  return c.json({ error: 'Internal server error' }, 500);
350
449
  }
351
450
  });
352
451
 
353
452
  /**
354
453
  * DELETE /offers/:offerId
355
- * Delete a specific offer
356
- * Requires authentication and ownership
454
+ * Delete an offer
357
455
  */
358
456
  app.delete('/offers/:offerId', authMiddleware, async (c) => {
359
457
  try {
@@ -363,10 +461,10 @@ export function createApp(storage: Storage, config: Config) {
363
461
  const deleted = await storage.deleteOffer(offerId, peerId);
364
462
 
365
463
  if (!deleted) {
366
- return c.json({ error: 'Offer not found or not authorized' }, 404);
464
+ return c.json({ error: 'Offer not found or not owned by this peer' }, 404);
367
465
  }
368
466
 
369
- return c.json({ deleted: true }, 200);
467
+ return c.json({ success: true }, 200);
370
468
  } catch (err) {
371
469
  console.error('Error deleting offer:', err);
372
470
  return c.json({ error: 'Internal server error' }, 500);
@@ -375,40 +473,35 @@ export function createApp(storage: Storage, config: Config) {
375
473
 
376
474
  /**
377
475
  * POST /offers/:offerId/answer
378
- * Answer a specific offer (locks it to answerer)
379
- * Requires authentication
476
+ * Answer an offer
380
477
  */
381
478
  app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
382
479
  try {
383
480
  const offerId = c.req.param('offerId');
384
- const peerId = getAuthenticatedPeerId(c);
385
481
  const body = await c.req.json();
386
482
  const { sdp, secret } = body;
387
483
 
388
- if (!sdp || typeof sdp !== 'string') {
389
- return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400);
484
+ if (!sdp) {
485
+ return c.json({ error: 'Missing required parameter: sdp' }, 400);
390
486
  }
391
487
 
392
- if (sdp.length > 65536) {
393
- return c.json({ error: 'SDP must be 64KB or less' }, 400);
488
+ if (typeof sdp !== 'string' || sdp.length === 0) {
489
+ return c.json({ error: 'Invalid SDP' }, 400);
394
490
  }
395
491
 
396
- // Validate secret if provided
397
- if (secret !== undefined && typeof secret !== 'string') {
398
- return c.json({ error: 'Secret must be a string' }, 400);
492
+ if (sdp.length > 64 * 1024) {
493
+ return c.json({ error: 'SDP too large (max 64KB)' }, 400);
399
494
  }
400
495
 
401
- const result = await storage.answerOffer(offerId, peerId, sdp, secret);
496
+ const answererPeerId = getAuthenticatedPeerId(c);
497
+
498
+ const result = await storage.answerOffer(offerId, answererPeerId, sdp, secret);
402
499
 
403
500
  if (!result.success) {
404
501
  return c.json({ error: result.error }, 400);
405
502
  }
406
503
 
407
- return c.json({
408
- offerId,
409
- answererId: peerId,
410
- answeredAt: Date.now()
411
- }, 200);
504
+ return c.json({ success: true }, 200);
412
505
  } catch (err) {
413
506
  console.error('Error answering offer:', err);
414
507
  return c.json({ error: 'Internal server error' }, 500);
@@ -417,8 +510,7 @@ export function createApp(storage: Storage, config: Config) {
417
510
 
418
511
  /**
419
512
  * GET /offers/answers
420
- * Poll for answers to all of authenticated peer's offers
421
- * Requires authentication (offerer)
513
+ * Get answers for authenticated peer's offers
422
514
  */
423
515
  app.get('/offers/answers', authMiddleware, async (c) => {
424
516
  try {
@@ -426,57 +518,49 @@ export function createApp(storage: Storage, config: Config) {
426
518
  const offers = await storage.getAnsweredOffers(peerId);
427
519
 
428
520
  return c.json({
429
- answers: offers.map(o => ({
430
- offerId: o.id,
431
- answererId: o.answererPeerId,
432
- sdp: o.answerSdp,
433
- answeredAt: o.answeredAt,
434
- topics: o.topics
521
+ answers: offers.map(offer => ({
522
+ offerId: offer.id,
523
+ answererPeerId: offer.answererPeerId,
524
+ answerSdp: offer.answerSdp,
525
+ answeredAt: offer.answeredAt
435
526
  }))
436
527
  }, 200);
437
528
  } catch (err) {
438
- console.error('Error fetching answers:', err);
529
+ console.error('Error getting answers:', err);
439
530
  return c.json({ error: 'Internal server error' }, 500);
440
531
  }
441
532
  });
442
533
 
534
+ // ===== ICE Candidate Exchange =====
535
+
443
536
  /**
444
537
  * POST /offers/:offerId/ice-candidates
445
- * Post ICE candidates for an offer
446
- * Requires authentication (must be offerer or answerer)
538
+ * Add ICE candidates for an offer
447
539
  */
448
540
  app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
449
541
  try {
450
542
  const offerId = c.req.param('offerId');
451
- const peerId = getAuthenticatedPeerId(c);
452
543
  const body = await c.req.json();
453
544
  const { candidates } = body;
454
545
 
455
546
  if (!Array.isArray(candidates) || candidates.length === 0) {
456
- return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400);
547
+ return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
457
548
  }
458
549
 
459
- // Verify offer exists and caller is offerer or answerer
550
+ const peerId = getAuthenticatedPeerId(c);
551
+
552
+ // Get offer to determine role
460
553
  const offer = await storage.getOfferById(offerId);
461
554
  if (!offer) {
462
- return c.json({ error: 'Offer not found or expired' }, 404);
555
+ return c.json({ error: 'Offer not found' }, 404);
463
556
  }
464
557
 
465
- let role: 'offerer' | 'answerer';
466
- if (offer.peerId === peerId) {
467
- role = 'offerer';
468
- } else if (offer.answererPeerId === peerId) {
469
- role = 'answerer';
470
- } else {
471
- return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
472
- }
558
+ // Determine role
559
+ const role = offer.peerId === peerId ? 'offerer' : 'answerer';
473
560
 
474
- const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
561
+ const count = await storage.addIceCandidates(offerId, peerId, role, candidates);
475
562
 
476
- return c.json({
477
- offerId,
478
- candidatesAdded: added
479
- }, 200);
563
+ return c.json({ count }, 200);
480
564
  } catch (err) {
481
565
  console.error('Error adding ICE candidates:', err);
482
566
  return c.json({ error: 'Internal server error' }, 500);
@@ -485,50 +569,34 @@ export function createApp(storage: Storage, config: Config) {
485
569
 
486
570
  /**
487
571
  * GET /offers/:offerId/ice-candidates
488
- * Poll for ICE candidates from the other peer
489
- * Requires authentication (must be offerer or answerer)
572
+ * Get ICE candidates for an offer
490
573
  */
491
574
  app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
492
575
  try {
493
576
  const offerId = c.req.param('offerId');
577
+ const since = c.req.query('since');
494
578
  const peerId = getAuthenticatedPeerId(c);
495
- const sinceParam = c.req.query('since');
496
579
 
497
- const since = sinceParam ? parseInt(sinceParam, 10) : undefined;
498
-
499
- // Verify offer exists and caller is offerer or answerer
580
+ // Get offer to determine role
500
581
  const offer = await storage.getOfferById(offerId);
501
582
  if (!offer) {
502
- return c.json({ error: 'Offer not found or expired' }, 404);
583
+ return c.json({ error: 'Offer not found' }, 404);
503
584
  }
504
585
 
505
- let targetRole: 'offerer' | 'answerer';
506
- if (offer.peerId === peerId) {
507
- // Offerer wants answerer's candidates
508
- targetRole = 'answerer';
509
- console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
510
- } else if (offer.answererPeerId === peerId) {
511
- // Answerer wants offerer's candidates
512
- targetRole = 'offerer';
513
- console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
514
- } else {
515
- return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
516
- }
586
+ // Get candidates for opposite role
587
+ const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
588
+ const sinceTimestamp = since ? parseInt(since, 10) : undefined;
517
589
 
518
- const candidates = await storage.getIceCandidates(offerId, targetRole, since);
519
- console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
590
+ const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
520
591
 
521
592
  return c.json({
522
- offerId,
523
593
  candidates: candidates.map(c => ({
524
594
  candidate: c.candidate,
525
- peerId: c.peerId,
526
- role: c.role,
527
595
  createdAt: c.createdAt
528
596
  }))
529
597
  }, 200);
530
598
  } catch (err) {
531
- console.error('Error fetching ICE candidates:', err);
599
+ console.error('Error getting ICE candidates:', err);
532
600
  return c.json({ error: 'Internal server error' }, 500);
533
601
  }
534
602
  });