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