@xtr-dev/rondevu-server 0.4.0 → 0.5.1

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
@@ -2,20 +2,17 @@ import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
3
  import { Storage } from './storage/types.ts';
4
4
  import { Config } from './config.ts';
5
- import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
6
- import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts';
7
- import type { Context } from 'hono';
5
+ import { handleRpc, RpcRequest } from './rpc.ts';
6
+
7
+ // Constants
8
+ const MAX_BATCH_SIZE = 100;
8
9
 
9
10
  /**
10
- * Creates the Hono application with username and service-based WebRTC signaling
11
- * RESTful API design - v0.11.0
11
+ * Creates the Hono application with RPC interface
12
12
  */
13
13
  export function createApp(storage: Storage, config: Config) {
14
14
  const app = new Hono();
15
15
 
16
- // Create auth middleware
17
- const authMiddleware = createAuthMiddleware(config.authSecret);
18
-
19
16
  // Enable CORS
20
17
  app.use('/*', cors({
21
18
  origin: (origin) => {
@@ -27,620 +24,70 @@ export function createApp(storage: Storage, config: Config) {
27
24
  }
28
25
  return config.corsOrigins[0];
29
26
  },
30
- allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
31
- allowHeaders: ['Content-Type', 'Origin', 'Authorization'],
27
+ allowMethods: ['GET', 'POST', 'OPTIONS'],
28
+ allowHeaders: ['Content-Type', 'Origin'],
32
29
  exposeHeaders: ['Content-Type'],
33
- maxAge: 600,
34
- credentials: true,
30
+ credentials: false,
31
+ maxAge: 86400,
35
32
  }));
36
33
 
37
- // ===== General Endpoints =====
38
-
39
- /**
40
- * GET /
41
- * Returns server information
42
- */
34
+ // Root endpoint - server info
43
35
  app.get('/', (c) => {
44
36
  return c.json({
45
37
  version: config.version,
46
38
  name: 'Rondevu',
47
- description: 'DNS-like WebRTC signaling with username claiming and service discovery'
48
- });
39
+ description: 'WebRTC signaling with RPC interface and Ed25519 authentication',
40
+ }, 200);
49
41
  });
50
42
 
51
- /**
52
- * GET /health
53
- * Health check endpoint
54
- */
43
+ // Health check
55
44
  app.get('/health', (c) => {
56
45
  return c.json({
57
46
  status: 'ok',
58
47
  timestamp: Date.now(),
59
- version: config.version
60
- });
61
- });
62
-
63
- /**
64
- * POST /register
65
- * Register a new peer
66
- */
67
- app.post('/register', async (c) => {
68
- try {
69
- const peerId = generatePeerId();
70
- const secret = await encryptPeerId(peerId, config.authSecret);
71
-
72
- return c.json({
73
- peerId,
74
- secret
75
- }, 200);
76
- } catch (err) {
77
- console.error('Error registering peer:', err);
78
- return c.json({ error: 'Internal server error' }, 500);
79
- }
80
- });
81
-
82
- // ===== User Management (RESTful) =====
83
-
84
- /**
85
- * GET /users/:username
86
- * Check if username is available or get claim info
87
- */
88
- app.get('/users/:username', async (c) => {
89
- try {
90
- const username = c.req.param('username');
91
-
92
- const claimed = await storage.getUsername(username);
93
-
94
- if (!claimed) {
95
- return c.json({
96
- username,
97
- available: true
98
- }, 200);
99
- }
100
-
101
- return c.json({
102
- username: claimed.username,
103
- available: false,
104
- claimedAt: claimed.claimedAt,
105
- expiresAt: claimed.expiresAt,
106
- publicKey: claimed.publicKey
107
- }, 200);
108
- } catch (err) {
109
- console.error('Error checking username:', err);
110
- return c.json({ error: 'Internal server error' }, 500);
111
- }
112
- });
113
-
114
- /**
115
- * POST /users/:username
116
- * Claim a username with cryptographic proof
117
- */
118
- app.post('/users/:username', async (c) => {
119
- try {
120
- const username = c.req.param('username');
121
- const body = await c.req.json();
122
- const { publicKey, signature, message } = body;
123
-
124
- if (!publicKey || !signature || !message) {
125
- return c.json({ error: 'Missing required parameters: publicKey, signature, message' }, 400);
126
- }
127
-
128
- // Validate claim
129
- const validation = await validateUsernameClaim(username, publicKey, signature, message);
130
- if (!validation.valid) {
131
- return c.json({ error: validation.error }, 400);
132
- }
133
-
134
- // Attempt to claim username
135
- try {
136
- const claimed = await storage.claimUsername({
137
- username,
138
- publicKey,
139
- signature,
140
- message
141
- });
142
-
143
- return c.json({
144
- username: claimed.username,
145
- claimedAt: claimed.claimedAt,
146
- expiresAt: claimed.expiresAt
147
- }, 201);
148
- } catch (err: any) {
149
- if (err.message?.includes('already claimed')) {
150
- return c.json({ error: 'Username already claimed by different public key' }, 409);
151
- }
152
- throw err;
153
- }
154
- } catch (err) {
155
- console.error('Error claiming username:', err);
156
- return c.json({ error: 'Internal server error' }, 500);
157
- }
158
- });
159
-
160
- /**
161
- * GET /users/:username/services/:fqn
162
- * Get service by username and FQN with semver-compatible matching
163
- */
164
- app.get('/users/:username/services/:fqn', async (c) => {
165
- try {
166
- const username = c.req.param('username');
167
- const serviceFqn = decodeURIComponent(c.req.param('fqn'));
168
-
169
- // Parse the requested FQN
170
- const parsed = parseServiceFqn(serviceFqn);
171
- if (!parsed) {
172
- return c.json({ error: 'Invalid service FQN format' }, 400);
173
- }
174
-
175
- const { serviceName, version: requestedVersion } = parsed;
176
-
177
- // Find all services with matching service name
178
- const matchingServices = await storage.findServicesByName(username, serviceName);
179
-
180
- if (matchingServices.length === 0) {
181
- return c.json({ error: 'Service not found' }, 404);
182
- }
183
-
184
- // Filter to compatible versions
185
- const compatibleServices = matchingServices.filter(service => {
186
- const serviceParsed = parseServiceFqn(service.serviceFqn);
187
- if (!serviceParsed) return false;
188
- return isVersionCompatible(requestedVersion, serviceParsed.version);
189
- });
190
-
191
- if (compatibleServices.length === 0) {
192
- return c.json({
193
- error: 'No compatible version found',
194
- message: `Requested ${serviceFqn}, but no compatible versions available`
195
- }, 404);
196
- }
197
-
198
- // Use the first compatible service (most recently created)
199
- const service = compatibleServices[0];
200
-
201
- // Get the UUID for this service
202
- const uuid = await storage.queryService(username, service.serviceFqn);
203
-
204
- if (!uuid) {
205
- return c.json({ error: 'Service index not found' }, 500);
206
- }
207
-
208
- // Get all offers for this service
209
- const serviceOffers = await storage.getOffersForService(service.id);
210
-
211
- if (serviceOffers.length === 0) {
212
- return c.json({ error: 'No offers found for this service' }, 404);
213
- }
214
-
215
- // Find an unanswered offer
216
- const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
217
-
218
- if (!availableOffer) {
219
- return c.json({
220
- error: 'No available offers',
221
- message: 'All offers from this service are currently in use. Please try again later.'
222
- }, 503);
223
- }
224
-
225
- return c.json({
226
- uuid: uuid,
227
- serviceId: service.id,
228
- username: service.username,
229
- serviceFqn: service.serviceFqn,
230
- offerId: availableOffer.id,
231
- sdp: availableOffer.sdp,
232
- isPublic: service.isPublic,
233
- metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
234
- createdAt: service.createdAt,
235
- expiresAt: service.expiresAt
236
- }, 200);
237
- } catch (err) {
238
- console.error('Error getting service:', err);
239
- return c.json({ error: 'Internal server error' }, 500);
240
- }
241
- });
242
-
243
- /**
244
- * POST /users/:username/services
245
- * Publish a service with one or more offers (RESTful endpoint)
246
- */
247
- app.post('/users/:username/services', authMiddleware, async (c) => {
248
- let serviceFqn: string | undefined;
249
- let createdOffers: any[] = [];
250
-
251
- try {
252
- const username = c.req.param('username');
253
- const body = await c.req.json();
254
- serviceFqn = body.serviceFqn;
255
- const { offers, ttl, isPublic, metadata, signature, message } = body;
256
-
257
- if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) {
258
- return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400);
259
- }
260
-
261
- // Validate service FQN
262
- const fqnValidation = validateServiceFqn(serviceFqn);
263
- if (!fqnValidation.valid) {
264
- return c.json({ error: fqnValidation.error }, 400);
265
- }
266
-
267
- // Verify username ownership (signature required)
268
- if (!signature || !message) {
269
- return c.json({ error: 'Missing signature or message for username verification' }, 400);
270
- }
271
-
272
- const usernameRecord = await storage.getUsername(username);
273
- if (!usernameRecord) {
274
- return c.json({ error: 'Username not claimed' }, 404);
275
- }
276
-
277
- // Verify signature matches username's public key
278
- const signatureValidation = await validateServicePublish(username, serviceFqn, usernameRecord.publicKey, signature, message);
279
- if (!signatureValidation.valid) {
280
- return c.json({ error: 'Invalid signature for username' }, 403);
281
- }
282
-
283
- // Delete existing service if one exists (upsert behavior)
284
- const existingUuid = await storage.queryService(username, serviceFqn);
285
- if (existingUuid) {
286
- const existingService = await storage.getServiceByUuid(existingUuid);
287
- if (existingService) {
288
- await storage.deleteService(existingService.id, username);
289
- }
290
- }
291
-
292
- // Validate all offers
293
- for (const offer of offers) {
294
- if (!offer.sdp || typeof offer.sdp !== 'string' || offer.sdp.length === 0) {
295
- return c.json({ error: 'Invalid SDP in offers array' }, 400);
296
- }
297
-
298
- if (offer.sdp.length > 64 * 1024) {
299
- return c.json({ error: 'SDP too large (max 64KB)' }, 400);
300
- }
301
- }
302
-
303
- // Calculate expiry
304
- const peerId = getAuthenticatedPeerId(c);
305
- const offerTtl = Math.min(
306
- Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
307
- config.offerMaxTtl
308
- );
309
- const expiresAt = Date.now() + offerTtl;
310
-
311
- // Prepare offer requests
312
- const offerRequests = offers.map(offer => ({
313
- peerId,
314
- sdp: offer.sdp,
315
- expiresAt
316
- }));
317
-
318
- // Create service with offers
319
- const result = await storage.createService({
320
- username,
321
- serviceFqn,
322
- expiresAt,
323
- isPublic: isPublic || false,
324
- metadata: metadata ? JSON.stringify(metadata) : undefined,
325
- offers: offerRequests
326
- });
327
-
328
- createdOffers = result.offers;
329
-
330
- // Return full service details with all offers
331
- return c.json({
332
- uuid: result.indexUuid,
333
- serviceFqn: serviceFqn,
334
- username: username,
335
- serviceId: result.service.id,
336
- offers: result.offers.map(o => ({
337
- offerId: o.id,
338
- sdp: o.sdp,
339
- createdAt: o.createdAt,
340
- expiresAt: o.expiresAt
341
- })),
342
- isPublic: result.service.isPublic,
343
- metadata: metadata,
344
- createdAt: result.service.createdAt,
345
- expiresAt: result.service.expiresAt
346
- }, 201);
347
- } catch (err) {
348
- console.error('Error creating service:', err);
349
- console.error('Error details:', {
350
- message: (err as Error).message,
351
- stack: (err as Error).stack,
352
- username: c.req.param('username'),
353
- serviceFqn,
354
- offerIds: createdOffers.map(o => o.id)
355
- });
356
- return c.json({
357
- error: 'Internal server error',
358
- details: (err as Error).message
359
- }, 500);
360
- }
361
- });
362
-
363
- /**
364
- * DELETE /users/:username/services/:fqn
365
- * Delete a service by username and FQN (RESTful)
366
- */
367
- app.delete('/users/:username/services/:fqn', authMiddleware, async (c) => {
368
- try {
369
- const username = c.req.param('username');
370
- const serviceFqn = decodeURIComponent(c.req.param('fqn'));
371
-
372
- // Find service by username and FQN
373
- const uuid = await storage.queryService(username, serviceFqn);
374
- if (!uuid) {
375
- return c.json({ error: 'Service not found' }, 404);
376
- }
377
-
378
- const service = await storage.getServiceByUuid(uuid);
379
- if (!service) {
380
- return c.json({ error: 'Service not found' }, 404);
381
- }
382
-
383
- const deleted = await storage.deleteService(service.id, username);
384
-
385
- if (!deleted) {
386
- return c.json({ error: 'Service not found or not owned by this username' }, 404);
387
- }
388
-
389
- return c.json({ success: true }, 200);
390
- } catch (err) {
391
- console.error('Error deleting service:', err);
392
- return c.json({ error: 'Internal server error' }, 500);
393
- }
394
- });
395
-
396
- // ===== Service Management (Legacy - for UUID-based access) =====
397
-
398
- /**
399
- * GET /services/:uuid
400
- * Get service details by index UUID (kept for privacy)
401
- */
402
- app.get('/services/:uuid', async (c) => {
403
- try {
404
- const uuid = c.req.param('uuid');
405
-
406
- const service = await storage.getServiceByUuid(uuid);
407
-
408
- if (!service) {
409
- return c.json({ error: 'Service not found' }, 404);
410
- }
411
-
412
- // Get all offers for this service
413
- const serviceOffers = await storage.getOffersForService(service.id);
414
-
415
- if (serviceOffers.length === 0) {
416
- return c.json({ error: 'No offers found for this service' }, 404);
417
- }
418
-
419
- // Find an unanswered offer
420
- const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
421
-
422
- if (!availableOffer) {
423
- return c.json({
424
- error: 'No available offers',
425
- message: 'All offers from this service are currently in use. Please try again later.'
426
- }, 503);
427
- }
428
-
429
- return c.json({
430
- uuid: uuid,
431
- serviceId: service.id,
432
- username: service.username,
433
- serviceFqn: service.serviceFqn,
434
- offerId: availableOffer.id,
435
- sdp: availableOffer.sdp,
436
- isPublic: service.isPublic,
437
- metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
438
- createdAt: service.createdAt,
439
- expiresAt: service.expiresAt
440
- }, 200);
441
- } catch (err) {
442
- console.error('Error getting service:', err);
443
- return c.json({ error: 'Internal server error' }, 500);
444
- }
48
+ version: config.version,
49
+ }, 200);
445
50
  });
446
51
 
447
- // ===== Service-Based WebRTC Signaling =====
448
-
449
52
  /**
450
- * POST /services/:uuid/answer
451
- * Answer a service offer
53
+ * POST /rpc
54
+ * RPC endpoint - accepts single or batch method calls
452
55
  */
453
- app.post('/services/:uuid/answer', authMiddleware, async (c) => {
56
+ app.post('/rpc', async (c) => {
454
57
  try {
455
- const uuid = c.req.param('uuid');
456
58
  const body = await c.req.json();
457
- const { sdp } = body;
458
-
459
- if (!sdp) {
460
- return c.json({ error: 'Missing required parameter: sdp' }, 400);
461
- }
462
-
463
- if (typeof sdp !== 'string' || sdp.length === 0) {
464
- return c.json({ error: 'Invalid SDP' }, 400);
465
- }
466
59
 
467
- if (sdp.length > 64 * 1024) {
468
- return c.json({ error: 'SDP too large (max 64KB)' }, 400);
469
- }
60
+ // Support both single request and batch array
61
+ const requests: RpcRequest[] = Array.isArray(body) ? body : [body];
470
62
 
471
- // Get the service by UUID
472
- const service = await storage.getServiceByUuid(uuid);
473
- if (!service) {
474
- return c.json({ error: 'Service not found' }, 404);
63
+ // Validate requests
64
+ if (requests.length === 0) {
65
+ return c.json({ error: 'Empty request array' }, 400);
475
66
  }
476
67
 
477
- // Get available offer from service
478
- const serviceOffers = await storage.getOffersForService(service.id);
479
- const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
480
-
481
- if (!availableOffer) {
482
- return c.json({ error: 'No available offers' }, 503);
68
+ if (requests.length > MAX_BATCH_SIZE) {
69
+ return c.json({ error: `Too many requests in batch (max ${MAX_BATCH_SIZE})` }, 400);
483
70
  }
484
71
 
485
- const answererPeerId = getAuthenticatedPeerId(c);
486
-
487
- const result = await storage.answerOffer(availableOffer.id, answererPeerId, sdp);
72
+ // Handle RPC
73
+ const responses = await handleRpc(requests, storage, config);
488
74
 
489
- if (!result.success) {
490
- return c.json({ error: result.error }, 400);
491
- }
492
-
493
- return c.json({
494
- success: true,
495
- offerId: availableOffer.id
496
- }, 200);
75
+ // Return single response or array based on input
76
+ return c.json(Array.isArray(body) ? responses : responses[0], 200);
497
77
  } catch (err) {
498
- console.error('Error answering service:', err);
499
- return c.json({ error: 'Internal server error' }, 500);
500
- }
501
- });
502
-
503
- /**
504
- * GET /services/:uuid/answer
505
- * Get answer for a service (offerer polls this)
506
- */
507
- app.get('/services/:uuid/answer', authMiddleware, async (c) => {
508
- try {
509
- const uuid = c.req.param('uuid');
510
- const peerId = getAuthenticatedPeerId(c);
511
-
512
- // Get the service by UUID
513
- const service = await storage.getServiceByUuid(uuid);
514
- if (!service) {
515
- return c.json({ error: 'Service not found' }, 404);
516
- }
517
-
518
- // Get offers for this service owned by the requesting peer
519
- const serviceOffers = await storage.getOffersForService(service.id);
520
- const myOffer = serviceOffers.find(offer => offer.peerId === peerId && offer.answererPeerId);
521
-
522
- if (!myOffer || !myOffer.answerSdp) {
523
- return c.json({ error: 'Offer not yet answered' }, 404);
524
- }
525
-
78
+ console.error('RPC error:', err);
526
79
  return c.json({
527
- offerId: myOffer.id,
528
- answererId: myOffer.answererPeerId,
529
- sdp: myOffer.answerSdp,
530
- answeredAt: myOffer.answeredAt
531
- }, 200);
532
- } catch (err) {
533
- console.error('Error getting service answer:', err);
534
- return c.json({ error: 'Internal server error' }, 500);
80
+ success: false,
81
+ error: 'Invalid request format',
82
+ }, 400);
535
83
  }
536
84
  });
537
85
 
538
- /**
539
- * POST /services/:uuid/ice-candidates
540
- * Add ICE candidates for a service
541
- */
542
- app.post('/services/:uuid/ice-candidates', authMiddleware, async (c) => {
543
- try {
544
- const uuid = c.req.param('uuid');
545
- const body = await c.req.json();
546
- const { candidates, offerId } = body;
547
-
548
- if (!Array.isArray(candidates) || candidates.length === 0) {
549
- return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
550
- }
551
-
552
- const peerId = getAuthenticatedPeerId(c);
553
-
554
- // Get the service by UUID
555
- const service = await storage.getServiceByUuid(uuid);
556
- if (!service) {
557
- return c.json({ error: 'Service not found' }, 404);
558
- }
559
-
560
- // If offerId is provided, use it; otherwise find the peer's offer
561
- let targetOfferId = offerId;
562
- if (!targetOfferId) {
563
- const serviceOffers = await storage.getOffersForService(service.id);
564
- const myOffer = serviceOffers.find(offer =>
565
- offer.peerId === peerId || offer.answererPeerId === peerId
566
- );
567
- if (!myOffer) {
568
- return c.json({ error: 'No offer found for this peer' }, 404);
569
- }
570
- targetOfferId = myOffer.id;
571
- }
572
-
573
- // Get offer to determine role
574
- const offer = await storage.getOfferById(targetOfferId);
575
- if (!offer) {
576
- return c.json({ error: 'Offer not found' }, 404);
577
- }
578
-
579
- // Determine role
580
- const role = offer.peerId === peerId ? 'offerer' : 'answerer';
581
-
582
- const count = await storage.addIceCandidates(targetOfferId, peerId, role, candidates);
583
-
584
- return c.json({ count, offerId: targetOfferId }, 200);
585
- } catch (err) {
586
- console.error('Error adding ICE candidates to service:', err);
587
- return c.json({ error: 'Internal server error' }, 500);
588
- }
589
- });
590
-
591
- /**
592
- * GET /services/:uuid/ice-candidates
593
- * Get ICE candidates for a service
594
- */
595
- app.get('/services/:uuid/ice-candidates', authMiddleware, async (c) => {
596
- try {
597
- const uuid = c.req.param('uuid');
598
- const since = c.req.query('since');
599
- const offerId = c.req.query('offerId');
600
- const peerId = getAuthenticatedPeerId(c);
601
-
602
- // Get the service by UUID
603
- const service = await storage.getServiceByUuid(uuid);
604
- if (!service) {
605
- return c.json({ error: 'Service not found' }, 404);
606
- }
607
-
608
- // If offerId is provided, use it; otherwise find the peer's offer
609
- let targetOfferId = offerId;
610
- if (!targetOfferId) {
611
- const serviceOffers = await storage.getOffersForService(service.id);
612
- const myOffer = serviceOffers.find(offer =>
613
- offer.peerId === peerId || offer.answererPeerId === peerId
614
- );
615
- if (!myOffer) {
616
- return c.json({ error: 'No offer found for this peer' }, 404);
617
- }
618
- targetOfferId = myOffer.id;
619
- }
620
-
621
- // Get offer to determine role
622
- const offer = await storage.getOfferById(targetOfferId);
623
- if (!offer) {
624
- return c.json({ error: 'Offer not found' }, 404);
625
- }
626
-
627
- // Get candidates for opposite role
628
- const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
629
- const sinceTimestamp = since ? parseInt(since, 10) : undefined;
630
-
631
- const candidates = await storage.getIceCandidates(targetOfferId, targetRole, sinceTimestamp);
632
-
633
- return c.json({
634
- candidates: candidates.map(c => ({
635
- candidate: c.candidate,
636
- createdAt: c.createdAt
637
- })),
638
- offerId: targetOfferId
639
- }, 200);
640
- } catch (err) {
641
- console.error('Error getting ICE candidates for service:', err);
642
- return c.json({ error: 'Internal server error' }, 500);
643
- }
86
+ // 404 for all other routes
87
+ app.all('*', (c) => {
88
+ return c.json({
89
+ error: 'Not found. Use POST /rpc for all API calls.',
90
+ }, 404);
644
91
  });
645
92
 
646
93
  return app;