@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/rpc.ts ADDED
@@ -0,0 +1,725 @@
1
+ import { Context } from 'hono';
2
+ import { Storage } from './storage/types.ts';
3
+ import { Config } from './config.ts';
4
+ import {
5
+ validateUsernameClaim,
6
+ validateServicePublish,
7
+ validateServiceFqn,
8
+ parseServiceFqn,
9
+ isVersionCompatible,
10
+ verifyEd25519Signature,
11
+ validateAuthMessage,
12
+ validateUsername,
13
+ } from './crypto.ts';
14
+
15
+ // Constants
16
+ const MAX_PAGE_SIZE = 100;
17
+
18
+ /**
19
+ * RPC request format
20
+ */
21
+ export interface RpcRequest {
22
+ method: string;
23
+ message: string;
24
+ signature: string;
25
+ publicKey?: string; // Optional: for auto-claiming usernames
26
+ params?: any;
27
+ }
28
+
29
+ /**
30
+ * RPC response format
31
+ */
32
+ export interface RpcResponse {
33
+ success: boolean;
34
+ result?: any;
35
+ error?: string;
36
+ }
37
+
38
+ /**
39
+ * RPC method handler
40
+ */
41
+ type RpcHandler = (
42
+ params: any,
43
+ message: string,
44
+ signature: string,
45
+ publicKey: string | undefined,
46
+ storage: Storage,
47
+ config: Config
48
+ ) => Promise<any>;
49
+
50
+ /**
51
+ * Verify authentication for a method call
52
+ * Automatically claims username if it doesn't exist
53
+ */
54
+ async function verifyAuth(
55
+ username: string,
56
+ message: string,
57
+ signature: string,
58
+ publicKey: string | undefined,
59
+ storage: Storage
60
+ ): Promise<{ valid: boolean; error?: string }> {
61
+ // Get username record to fetch public key
62
+ let usernameRecord = await storage.getUsername(username);
63
+
64
+ // Auto-claim username if it doesn't exist
65
+ if (!usernameRecord) {
66
+ if (!publicKey) {
67
+ return {
68
+ valid: false,
69
+ error: `Username "${username}" is not claimed and no public key provided for auto-claim.`,
70
+ };
71
+ }
72
+
73
+ // Validate username format before claiming
74
+ const usernameValidation = validateUsername(username);
75
+ if (!usernameValidation.valid) {
76
+ return usernameValidation;
77
+ }
78
+
79
+ // Verify signature against the current message (not a claim message)
80
+ const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
81
+ if (!signatureValid) {
82
+ return { valid: false, error: 'Invalid signature for auto-claim' };
83
+ }
84
+
85
+ // Auto-claim the username
86
+ const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1000; // 365 days
87
+ await storage.claimUsername({
88
+ username,
89
+ publicKey,
90
+ expiresAt,
91
+ });
92
+
93
+ usernameRecord = await storage.getUsername(username);
94
+ if (!usernameRecord) {
95
+ return { valid: false, error: 'Failed to claim username' };
96
+ }
97
+ }
98
+
99
+ // Verify Ed25519 signature
100
+ const isValid = await verifyEd25519Signature(
101
+ usernameRecord.publicKey,
102
+ signature,
103
+ message
104
+ );
105
+ if (!isValid) {
106
+ return { valid: false, error: 'Invalid signature' };
107
+ }
108
+
109
+ // Validate message format and timestamp
110
+ const validation = validateAuthMessage(username, message);
111
+ if (!validation.valid) {
112
+ return { valid: false, error: validation.error };
113
+ }
114
+
115
+ return { valid: true };
116
+ }
117
+
118
+ /**
119
+ * Extract username from message
120
+ */
121
+ function extractUsername(message: string): string | null {
122
+ // Message format: method:username:...
123
+ const parts = message.split(':');
124
+ if (parts.length < 2) return null;
125
+ return parts[1];
126
+ }
127
+
128
+ /**
129
+ * RPC Method Handlers
130
+ */
131
+
132
+ const handlers: Record<string, RpcHandler> = {
133
+ /**
134
+ * Check if username is available
135
+ */
136
+ async getUser(params, message, signature, publicKey, storage, config) {
137
+ const { username } = params;
138
+ const claimed = await storage.getUsername(username);
139
+
140
+ if (!claimed) {
141
+ return {
142
+ username,
143
+ available: true,
144
+ };
145
+ }
146
+
147
+ return {
148
+ username: claimed.username,
149
+ available: false,
150
+ claimedAt: claimed.claimedAt,
151
+ expiresAt: claimed.expiresAt,
152
+ publicKey: claimed.publicKey,
153
+ };
154
+ },
155
+
156
+ /**
157
+ * Get service by FQN - Supports 3 modes:
158
+ * 1. Direct lookup: FQN includes @username
159
+ * 2. Paginated discovery: FQN without @username, with limit/offset
160
+ * 3. Random discovery: FQN without @username, no limit
161
+ */
162
+ async getService(params, message, signature, publicKey, storage, config) {
163
+ const { serviceFqn, limit, offset } = params;
164
+ const username = extractUsername(message);
165
+
166
+ // Verify authentication
167
+ if (username) {
168
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
169
+ if (!auth.valid) {
170
+ throw new Error(auth.error);
171
+ }
172
+ }
173
+
174
+ // Parse and validate FQN
175
+ const fqnValidation = validateServiceFqn(serviceFqn);
176
+ if (!fqnValidation.valid) {
177
+ throw new Error(fqnValidation.error || 'Invalid service FQN');
178
+ }
179
+
180
+ const parsed = parseServiceFqn(serviceFqn);
181
+ if (!parsed) {
182
+ throw new Error('Failed to parse service FQN');
183
+ }
184
+
185
+ // Helper: Filter services by version compatibility
186
+ const filterCompatibleServices = (services) => {
187
+ return services.filter((s) => {
188
+ const serviceVersion = parseServiceFqn(s.serviceFqn);
189
+ return (
190
+ serviceVersion &&
191
+ isVersionCompatible(parsed.version, serviceVersion.version)
192
+ );
193
+ });
194
+ };
195
+
196
+ // Helper: Find available offer for service
197
+ const findAvailableOffer = async (service) => {
198
+ const offers = await storage.getOffersForService(service.id);
199
+ return offers.find((o) => !o.answererUsername);
200
+ };
201
+
202
+ // Helper: Build service response object
203
+ const buildServiceResponse = (service, offer) => ({
204
+ serviceId: service.id,
205
+ username: service.username,
206
+ serviceFqn: service.serviceFqn,
207
+ offerId: offer.id,
208
+ sdp: offer.sdp,
209
+ createdAt: service.createdAt,
210
+ expiresAt: service.expiresAt,
211
+ });
212
+
213
+ // Mode 1: Paginated discovery
214
+ if (limit !== undefined) {
215
+ const pageLimit = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);
216
+ const pageOffset = Math.max(0, offset || 0);
217
+
218
+ const allServices = await storage.getServicesByName(parsed.service, parsed.version);
219
+ const compatibleServices = filterCompatibleServices(allServices);
220
+
221
+ // Get unique services per username with available offers
222
+ const usernameSet = new Set<string>();
223
+ const uniqueServices: any[] = [];
224
+
225
+ for (const service of compatibleServices) {
226
+ if (!usernameSet.has(service.username)) {
227
+ usernameSet.add(service.username);
228
+ const availableOffer = await findAvailableOffer(service);
229
+
230
+ if (availableOffer) {
231
+ uniqueServices.push(buildServiceResponse(service, availableOffer));
232
+ }
233
+ }
234
+ }
235
+
236
+ // Paginate results
237
+ const paginatedServices = uniqueServices.slice(pageOffset, pageOffset + pageLimit);
238
+
239
+ return {
240
+ services: paginatedServices,
241
+ count: paginatedServices.length,
242
+ limit: pageLimit,
243
+ offset: pageOffset,
244
+ };
245
+ }
246
+
247
+ // Mode 2: Direct lookup with username
248
+ if (parsed.username) {
249
+ const service = await storage.getServiceByFqn(serviceFqn);
250
+ if (!service) {
251
+ throw new Error('Service not found');
252
+ }
253
+
254
+ const availableOffer = await findAvailableOffer(service);
255
+ if (!availableOffer) {
256
+ throw new Error('Service has no available offers');
257
+ }
258
+
259
+ return buildServiceResponse(service, availableOffer);
260
+ }
261
+
262
+ // Mode 3: Random discovery without username
263
+ const allServices = await storage.getServicesByName(parsed.service, parsed.version);
264
+ const compatibleServices = filterCompatibleServices(allServices);
265
+
266
+ if (compatibleServices.length === 0) {
267
+ throw new Error('No services found');
268
+ }
269
+
270
+ const randomService = compatibleServices[Math.floor(Math.random() * compatibleServices.length)];
271
+ const availableOffer = await findAvailableOffer(randomService);
272
+
273
+ if (!availableOffer) {
274
+ throw new Error('Service has no available offers');
275
+ }
276
+
277
+ return buildServiceResponse(randomService, availableOffer);
278
+ },
279
+
280
+ /**
281
+ * Publish a service
282
+ */
283
+ async publishService(params, message, signature, publicKey, storage, config) {
284
+ const { serviceFqn, offers, ttl } = params;
285
+ const username = extractUsername(message);
286
+
287
+ if (!username) {
288
+ throw new Error('Username required for service publishing');
289
+ }
290
+
291
+ // Verify authentication
292
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
293
+ if (!auth.valid) {
294
+ throw new Error(auth.error);
295
+ }
296
+
297
+ // Validate service FQN
298
+ const fqnValidation = validateServiceFqn(serviceFqn);
299
+ if (!fqnValidation.valid) {
300
+ throw new Error(fqnValidation.error || 'Invalid service FQN');
301
+ }
302
+
303
+ const parsed = parseServiceFqn(serviceFqn);
304
+ if (!parsed || !parsed.username) {
305
+ throw new Error('Service FQN must include username');
306
+ }
307
+
308
+ if (parsed.username !== username) {
309
+ throw new Error('Service FQN username must match authenticated username');
310
+ }
311
+
312
+ // Validate offers
313
+ if (!offers || !Array.isArray(offers) || offers.length === 0) {
314
+ throw new Error('Must provide at least one offer');
315
+ }
316
+
317
+ if (offers.length > config.maxOffersPerRequest) {
318
+ throw new Error(
319
+ `Too many offers (max ${config.maxOffersPerRequest})`
320
+ );
321
+ }
322
+
323
+ // Validate each offer has valid SDP
324
+ offers.forEach((offer, index) => {
325
+ if (!offer || typeof offer !== 'object') {
326
+ throw new Error(`Invalid offer at index ${index}: must be an object`);
327
+ }
328
+ if (!offer.sdp || typeof offer.sdp !== 'string') {
329
+ throw new Error(`Invalid offer at index ${index}: missing or invalid SDP`);
330
+ }
331
+ if (!offer.sdp.trim()) {
332
+ throw new Error(`Invalid offer at index ${index}: SDP cannot be empty`);
333
+ }
334
+ });
335
+
336
+ // Create service with offers
337
+ const now = Date.now();
338
+ const offerTtl =
339
+ ttl !== undefined
340
+ ? Math.min(
341
+ Math.max(ttl, config.offerMinTtl),
342
+ config.offerMaxTtl
343
+ )
344
+ : config.offerDefaultTtl;
345
+ const expiresAt = now + offerTtl;
346
+
347
+ // Prepare offer requests with TTL
348
+ const offerRequests = offers.map(offer => ({
349
+ username,
350
+ serviceFqn,
351
+ sdp: offer.sdp,
352
+ expiresAt,
353
+ }));
354
+
355
+ const result = await storage.createService({
356
+ serviceFqn,
357
+ expiresAt,
358
+ offers: offerRequests,
359
+ });
360
+
361
+ return {
362
+ serviceId: result.service.id,
363
+ username: result.service.username,
364
+ serviceFqn: result.service.serviceFqn,
365
+ offers: result.offers.map(offer => ({
366
+ offerId: offer.id,
367
+ sdp: offer.sdp,
368
+ createdAt: offer.createdAt,
369
+ expiresAt: offer.expiresAt,
370
+ })),
371
+ createdAt: result.service.createdAt,
372
+ expiresAt: result.service.expiresAt,
373
+ };
374
+ },
375
+
376
+ /**
377
+ * Delete a service
378
+ */
379
+ async deleteService(params, message, signature, publicKey, storage, config) {
380
+ const { serviceFqn } = params;
381
+ const username = extractUsername(message);
382
+
383
+ if (!username) {
384
+ throw new Error('Username required');
385
+ }
386
+
387
+ // Verify authentication
388
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
389
+ if (!auth.valid) {
390
+ throw new Error(auth.error);
391
+ }
392
+
393
+ const parsed = parseServiceFqn(serviceFqn);
394
+ if (!parsed || !parsed.username) {
395
+ throw new Error('Service FQN must include username');
396
+ }
397
+
398
+ const service = await storage.getServiceByFqn(serviceFqn);
399
+ if (!service) {
400
+ throw new Error('Service not found');
401
+ }
402
+
403
+ const deleted = await storage.deleteService(service.id, username);
404
+ if (!deleted) {
405
+ throw new Error('Service not found or not owned by this username');
406
+ }
407
+
408
+ return { success: true };
409
+ },
410
+
411
+ /**
412
+ * Answer an offer
413
+ */
414
+ async answerOffer(params, message, signature, publicKey, storage, config) {
415
+ const { serviceFqn, offerId, sdp } = params;
416
+ const username = extractUsername(message);
417
+
418
+ if (!username) {
419
+ throw new Error('Username required');
420
+ }
421
+
422
+ // Verify authentication
423
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
424
+ if (!auth.valid) {
425
+ throw new Error(auth.error);
426
+ }
427
+
428
+ if (!sdp || typeof sdp !== 'string' || sdp.length === 0) {
429
+ throw new Error('Invalid SDP');
430
+ }
431
+
432
+ if (sdp.length > 64 * 1024) {
433
+ throw new Error('SDP too large (max 64KB)');
434
+ }
435
+
436
+ const offer = await storage.getOfferById(offerId);
437
+ if (!offer) {
438
+ throw new Error('Offer not found');
439
+ }
440
+
441
+ if (offer.answererUsername) {
442
+ throw new Error('Offer already answered');
443
+ }
444
+
445
+ await storage.answerOffer(offerId, username, sdp);
446
+
447
+ return { success: true, offerId };
448
+ },
449
+
450
+ /**
451
+ * Get answer for an offer
452
+ */
453
+ async getOfferAnswer(params, message, signature, publicKey, storage, config) {
454
+ const { serviceFqn, offerId } = params;
455
+ const username = extractUsername(message);
456
+
457
+ if (!username) {
458
+ throw new Error('Username required');
459
+ }
460
+
461
+ // Verify authentication
462
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
463
+ if (!auth.valid) {
464
+ throw new Error(auth.error);
465
+ }
466
+
467
+ const offer = await storage.getOfferById(offerId);
468
+ if (!offer) {
469
+ throw new Error('Offer not found');
470
+ }
471
+
472
+ if (offer.username !== username) {
473
+ throw new Error('Not authorized to access this offer');
474
+ }
475
+
476
+ if (!offer.answererUsername || !offer.answerSdp) {
477
+ throw new Error('Offer not yet answered');
478
+ }
479
+
480
+ return {
481
+ sdp: offer.answerSdp,
482
+ offerId: offer.id,
483
+ answererId: offer.answererUsername,
484
+ answeredAt: offer.answeredAt,
485
+ };
486
+ },
487
+
488
+ /**
489
+ * Combined polling for answers and ICE candidates
490
+ */
491
+ async poll(params, message, signature, publicKey, storage, config) {
492
+ const { since } = params;
493
+ const username = extractUsername(message);
494
+
495
+ if (!username) {
496
+ throw new Error('Username required');
497
+ }
498
+
499
+ // Verify authentication
500
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
501
+ if (!auth.valid) {
502
+ throw new Error(auth.error);
503
+ }
504
+
505
+ const sinceTimestamp = since || 0;
506
+
507
+ // Get all answered offers
508
+ const answeredOffers = await storage.getAnsweredOffers(username);
509
+ const filteredAnswers = answeredOffers.filter(
510
+ (offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
511
+ );
512
+
513
+ // Get all user's offers
514
+ const allOffers = await storage.getOffersByUsername(username);
515
+
516
+ // For each offer, get ICE candidates from both sides
517
+ const iceCandidatesByOffer: Record<string, any[]> = {};
518
+
519
+ for (const offer of allOffers) {
520
+ const offererCandidates = await storage.getIceCandidates(
521
+ offer.id,
522
+ 'offerer',
523
+ sinceTimestamp
524
+ );
525
+ const answererCandidates = await storage.getIceCandidates(
526
+ offer.id,
527
+ 'answerer',
528
+ sinceTimestamp
529
+ );
530
+
531
+ const allCandidates = [
532
+ ...offererCandidates.map((c: any) => ({
533
+ ...c,
534
+ role: 'offerer' as const,
535
+ })),
536
+ ...answererCandidates.map((c: any) => ({
537
+ ...c,
538
+ role: 'answerer' as const,
539
+ })),
540
+ ];
541
+
542
+ if (allCandidates.length > 0) {
543
+ const isOfferer = offer.username === username;
544
+ const filtered = allCandidates.filter((c) =>
545
+ isOfferer ? c.role === 'answerer' : c.role === 'offerer'
546
+ );
547
+
548
+ if (filtered.length > 0) {
549
+ iceCandidatesByOffer[offer.id] = filtered;
550
+ }
551
+ }
552
+ }
553
+
554
+ return {
555
+ answers: filteredAnswers.map((offer) => ({
556
+ offerId: offer.id,
557
+ serviceId: offer.serviceId,
558
+ answererId: offer.answererUsername,
559
+ sdp: offer.answerSdp,
560
+ answeredAt: offer.answeredAt,
561
+ })),
562
+ iceCandidates: iceCandidatesByOffer,
563
+ };
564
+ },
565
+
566
+ /**
567
+ * Add ICE candidates
568
+ */
569
+ async addIceCandidates(params, message, signature, publicKey, storage, config) {
570
+ const { serviceFqn, offerId, candidates } = params;
571
+ const username = extractUsername(message);
572
+
573
+ if (!username) {
574
+ throw new Error('Username required');
575
+ }
576
+
577
+ // Verify authentication
578
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
579
+ if (!auth.valid) {
580
+ throw new Error(auth.error);
581
+ }
582
+
583
+ if (!Array.isArray(candidates) || candidates.length === 0) {
584
+ throw new Error('Missing or invalid required parameter: candidates');
585
+ }
586
+
587
+ // Validate each candidate is an object (don't enforce structure per CLAUDE.md)
588
+ candidates.forEach((candidate, index) => {
589
+ if (!candidate || typeof candidate !== 'object') {
590
+ throw new Error(`Invalid candidate at index ${index}: must be an object`);
591
+ }
592
+ });
593
+
594
+ const offer = await storage.getOfferById(offerId);
595
+ if (!offer) {
596
+ throw new Error('Offer not found');
597
+ }
598
+
599
+ const role = offer.username === username ? 'offerer' : 'answerer';
600
+ const count = await storage.addIceCandidates(
601
+ offerId,
602
+ username,
603
+ role,
604
+ candidates
605
+ );
606
+
607
+ return { count, offerId };
608
+ },
609
+
610
+ /**
611
+ * Get ICE candidates
612
+ */
613
+ async getIceCandidates(params, message, signature, publicKey, storage, config) {
614
+ const { serviceFqn, offerId, since } = params;
615
+ const username = extractUsername(message);
616
+
617
+ if (!username) {
618
+ throw new Error('Username required');
619
+ }
620
+
621
+ // Verify authentication
622
+ const auth = await verifyAuth(username, message, signature, publicKey, storage);
623
+ if (!auth.valid) {
624
+ throw new Error(auth.error);
625
+ }
626
+
627
+ const sinceTimestamp = since || 0;
628
+
629
+ const offer = await storage.getOfferById(offerId);
630
+ if (!offer) {
631
+ throw new Error('Offer not found');
632
+ }
633
+
634
+ const isOfferer = offer.username === username;
635
+ const role = isOfferer ? 'answerer' : 'offerer';
636
+
637
+ const candidates = await storage.getIceCandidates(
638
+ offerId,
639
+ role,
640
+ sinceTimestamp
641
+ );
642
+
643
+ return {
644
+ candidates: candidates.map((c: any) => ({
645
+ candidate: c.candidate,
646
+ createdAt: c.createdAt,
647
+ })),
648
+ offerId,
649
+ };
650
+ },
651
+ };
652
+
653
+ /**
654
+ * Handle RPC batch request
655
+ */
656
+ export async function handleRpc(
657
+ requests: RpcRequest[],
658
+ storage: Storage,
659
+ config: Config
660
+ ): Promise<RpcResponse[]> {
661
+ const responses: RpcResponse[] = [];
662
+
663
+ for (const request of requests) {
664
+ try {
665
+ const { method, message, signature, publicKey, params } = request;
666
+
667
+ // Validate request
668
+ if (!method || typeof method !== 'string') {
669
+ responses.push({
670
+ success: false,
671
+ error: 'Missing or invalid method',
672
+ });
673
+ continue;
674
+ }
675
+
676
+ if (!message || typeof message !== 'string') {
677
+ responses.push({
678
+ success: false,
679
+ error: 'Missing or invalid message',
680
+ });
681
+ continue;
682
+ }
683
+
684
+ if (!signature || typeof signature !== 'string') {
685
+ responses.push({
686
+ success: false,
687
+ error: 'Missing or invalid signature',
688
+ });
689
+ continue;
690
+ }
691
+
692
+ // Get handler
693
+ const handler = handlers[method];
694
+ if (!handler) {
695
+ responses.push({
696
+ success: false,
697
+ error: `Unknown method: ${method}`,
698
+ });
699
+ continue;
700
+ }
701
+
702
+ // Execute handler
703
+ const result = await handler(
704
+ params || {},
705
+ message,
706
+ signature,
707
+ publicKey,
708
+ storage,
709
+ config
710
+ );
711
+
712
+ responses.push({
713
+ success: true,
714
+ result,
715
+ });
716
+ } catch (err) {
717
+ responses.push({
718
+ success: false,
719
+ error: (err as Error).message || 'Internal server error',
720
+ });
721
+ }
722
+ }
723
+
724
+ return responses;
725
+ }