@xtr-dev/rondevu-server 0.5.12 → 0.5.14

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 CHANGED
@@ -3,45 +3,25 @@ import { Storage } from './storage/types.ts';
3
3
  import { Config } from './config.ts';
4
4
  import {
5
5
  validateTags,
6
- validateUsername,
7
- verifySignature,
6
+ validatePublicKey,
7
+ verifyEd25519Signature,
8
8
  buildSignatureMessage,
9
9
  } from './crypto.ts';
10
10
 
11
11
  // Constants (non-configurable)
12
12
  const MAX_PAGE_SIZE = 100;
13
-
14
- // NOTE: MAX_SDP_SIZE, MAX_CANDIDATE_SIZE, MAX_CANDIDATE_DEPTH, and MAX_CANDIDATES_PER_REQUEST
15
- // are now configurable via environment variables (see config.ts)
16
-
17
- // ===== Rate Limiting =====
18
-
19
- // Rate limiting windows (these are fixed, limits come from config)
20
- // NOTE: Uses fixed-window rate limiting with full window reset on expiry
21
- // - Window starts on first request and expires after window duration
22
- // - When window expires, counter resets to 0 and new window starts
23
- // - This is simpler than sliding windows but may allow bursts at window boundaries
24
- const CREDENTIAL_RATE_WINDOW = 1000; // 1 second in milliseconds
25
13
  const REQUEST_RATE_WINDOW = 1000; // 1 second in milliseconds
26
14
 
27
15
  /**
28
16
  * Check JSON object depth to prevent stack overflow from deeply nested objects
29
- * CRITICAL: Checks depth BEFORE recursing to prevent stack overflow
30
- * @param obj Object to check
31
- * @param maxDepth Maximum allowed depth
32
- * @param currentDepth Current recursion depth
33
- * @returns Actual depth of the object (returns maxDepth + 1 if exceeded)
34
17
  */
35
18
  function getJsonDepth(obj: any, maxDepth: number, currentDepth = 0): number {
36
- // Check for primitives/null first
37
19
  if (obj === null || typeof obj !== 'object') {
38
20
  return currentDepth;
39
21
  }
40
22
 
41
- // CRITICAL: Check depth BEFORE recursing to prevent stack overflow
42
- // If we're already at max depth, don't recurse further
43
23
  if (currentDepth >= maxDepth) {
44
- return currentDepth + 1; // Indicate exceeded
24
+ return currentDepth + 1;
45
25
  }
46
26
 
47
27
  let maxChildDepth = currentDepth;
@@ -50,7 +30,6 @@ function getJsonDepth(obj: any, maxDepth: number, currentDepth = 0): number {
50
30
  const childDepth = getJsonDepth(obj[key], maxDepth, currentDepth + 1);
51
31
  maxChildDepth = Math.max(maxChildDepth, childDepth);
52
32
 
53
- // Early exit if exceeded
54
33
  if (maxChildDepth > maxDepth) {
55
34
  return maxChildDepth;
56
35
  }
@@ -62,7 +41,6 @@ function getJsonDepth(obj: any, maxDepth: number, currentDepth = 0): number {
62
41
 
63
42
  /**
64
43
  * Validate parameter is a non-empty string
65
- * Prevents type coercion issues and injection attacks
66
44
  */
67
45
  function validateStringParam(value: any, paramName: string): void {
68
46
  if (typeof value !== 'string' || value.length === 0) {
@@ -79,7 +57,7 @@ export const ErrorCodes = {
79
57
  INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
80
58
 
81
59
  // Validation errors
82
- INVALID_NAME: 'INVALID_NAME',
60
+ INVALID_PUBLIC_KEY: 'INVALID_PUBLIC_KEY',
83
61
  INVALID_TAG: 'INVALID_TAG',
84
62
  INVALID_SDP: 'INVALID_SDP',
85
63
  INVALID_PARAMS: 'INVALID_PARAMS',
@@ -144,11 +122,6 @@ export interface RpcResponse {
144
122
  /**
145
123
  * RPC Method Parameter Interfaces
146
124
  */
147
- export interface GenerateCredentialsParams {
148
- name?: string; // Optional: claim specific username (4-32 chars, alphanumeric + dashes + periods)
149
- expiresAt?: number;
150
- }
151
-
152
125
  export interface DiscoverParams {
153
126
  tags: string[];
154
127
  limit?: number;
@@ -168,7 +141,7 @@ export interface DeleteOfferParams {
168
141
  export interface AnswerOfferParams {
169
142
  offerId: string;
170
143
  sdp: string;
171
- matchedTags?: string[]; // Tags the answerer searched for to find this offer
144
+ matchedTags?: string[];
172
145
  }
173
146
 
174
147
  export interface GetOfferAnswerParams {
@@ -191,11 +164,10 @@ export interface GetIceCandidatesParams {
191
164
 
192
165
  /**
193
166
  * RPC method handler
194
- * Generic type parameter allows individual handlers to specify their param types
195
167
  */
196
168
  type RpcHandler<TParams = any> = (
197
169
  params: TParams,
198
- name: string,
170
+ publicKey: string,
199
171
  timestamp: number,
200
172
  signature: string,
201
173
  storage: Storage,
@@ -205,28 +177,25 @@ type RpcHandler<TParams = any> = (
205
177
 
206
178
  /**
207
179
  * Validate timestamp for replay attack prevention
208
- * Throws RpcError if timestamp is invalid
209
180
  */
210
181
  function validateTimestamp(timestamp: number, config: Config): void {
211
182
  const now = Date.now();
212
183
 
213
- // Check if timestamp is too old (replay attack)
214
184
  if (now - timestamp > config.timestampMaxAge) {
215
185
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Timestamp too old');
216
186
  }
217
187
 
218
- // Check if timestamp is too far in future (clock skew)
219
188
  if (timestamp - now > config.timestampMaxFuture) {
220
189
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Timestamp too far in future');
221
190
  }
222
191
  }
223
192
 
224
193
  /**
225
- * Verify request signature for authentication
226
- * Throws RpcError on authentication failure
194
+ * Verify request signature using Ed25519
195
+ * Stateless verification - no identity registration required
227
196
  */
228
197
  async function verifyRequestSignature(
229
- name: string,
198
+ publicKey: string,
230
199
  timestamp: number,
231
200
  nonce: string,
232
201
  signature: string,
@@ -238,143 +207,40 @@ async function verifyRequestSignature(
238
207
  // Validate timestamp first
239
208
  validateTimestamp(timestamp, config);
240
209
 
241
- // Get credential to retrieve secret
242
- const credential = await storage.getCredential(name);
243
- if (!credential) {
244
- throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Invalid credentials');
210
+ // Validate public key format
211
+ const pkValidation = validatePublicKey(publicKey);
212
+ if (!pkValidation.valid) {
213
+ throw new RpcError(ErrorCodes.INVALID_PUBLIC_KEY, pkValidation.error || 'Invalid public key');
245
214
  }
246
215
 
247
- // Build message and verify signature (includes nonce to prevent signature reuse)
216
+ // Build message and verify Ed25519 signature
248
217
  const message = buildSignatureMessage(timestamp, nonce, method, params);
249
- const isValid = await verifySignature(credential.secret, message, signature);
218
+ const isValid = await verifyEd25519Signature(publicKey, message, signature);
250
219
 
251
220
  if (!isValid) {
252
221
  throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Invalid signature');
253
222
  }
254
223
 
255
224
  // Check nonce uniqueness AFTER successful signature verification
256
- // This prevents DoS where invalid signatures burn nonces
257
- // Only valid authenticated requests can mark nonces as used
258
- const nonceKey = `nonce:${name}:${nonce}`;
225
+ const nonceKey = `nonce:${publicKey}:${nonce}`;
259
226
  const nonceExpiresAt = timestamp + config.timestampMaxAge;
260
227
  const nonceIsNew = await storage.checkAndMarkNonce(nonceKey, nonceExpiresAt);
261
228
 
262
229
  if (!nonceIsNew) {
263
230
  throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Nonce already used (replay attack detected)');
264
231
  }
265
-
266
- // Update last used timestamp
267
- const now = Date.now();
268
- const credentialExpiresAt = now + (365 * 24 * 60 * 60 * 1000); // 1 year
269
- await storage.updateCredentialUsage(name, now, credentialExpiresAt);
270
232
  }
271
233
 
272
234
  /**
273
235
  * RPC Method Handlers
274
236
  */
275
-
276
237
  const handlers: Record<string, RpcHandler> = {
277
238
  /**
278
- * Generate new credentials (name + secret pair)
279
- * No authentication required - this is how users get started
280
- * SECURITY: Rate limited per IP to prevent abuse (database-backed for multi-instance support)
281
- */
282
- async generateCredentials(params: GenerateCredentialsParams, name, timestamp, signature, storage, config, request: RpcRequest & { clientIp?: string }) {
283
- // Check total credentials limit
284
- const credentialCount = await storage.getCredentialCount();
285
- if (credentialCount >= config.maxTotalCredentials) {
286
- throw new RpcError(
287
- ErrorCodes.STORAGE_FULL,
288
- `Server credential limit reached (${config.maxTotalCredentials}). Try again later.`
289
- );
290
- }
291
-
292
- // Rate limiting check (IP-based, stored in database)
293
- // SECURITY: Use stricter global rate limit for requests without identifiable IP
294
- let rateLimitKey: string;
295
- let rateLimit: number;
296
-
297
- if (!request.clientIp) {
298
- // Warn about missing IP (suggests proxy misconfiguration)
299
- console.warn('⚠️ WARNING: Unable to determine client IP for credential generation. Using global rate limit.');
300
- // Use global rate limit with much stricter limit (prevents DoS while allowing basic function)
301
- rateLimitKey = 'cred_gen:global_unknown';
302
- rateLimit = 2; // Only 2 credentials per second globally for all unknown IPs combined
303
- } else {
304
- rateLimitKey = `cred_gen:${request.clientIp}`;
305
- rateLimit = config.credentialsPerIpPerSecond;
306
- }
307
-
308
- const allowed = await storage.checkRateLimit(
309
- rateLimitKey,
310
- rateLimit,
311
- CREDENTIAL_RATE_WINDOW
312
- );
313
-
314
- if (!allowed) {
315
- throw new RpcError(
316
- ErrorCodes.RATE_LIMIT_EXCEEDED,
317
- `Rate limit exceeded. Maximum ${rateLimit} credentials per second${request.clientIp ? ' per IP' : ' (global limit for unidentified IPs)'}.`
318
- );
319
- }
320
-
321
- // Validate username if provided
322
- if (params.name !== undefined) {
323
- if (typeof params.name !== 'string') {
324
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'name must be a string');
325
- }
326
- const usernameValidation = validateUsername(params.name);
327
- if (!usernameValidation.valid) {
328
- throw new RpcError(ErrorCodes.INVALID_PARAMS, usernameValidation.error || 'Invalid username');
329
- }
330
- }
331
-
332
- // Validate expiresAt if provided
333
- if (params.expiresAt !== undefined) {
334
- if (typeof params.expiresAt !== 'number' || isNaN(params.expiresAt) || !Number.isFinite(params.expiresAt)) {
335
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'expiresAt must be a valid timestamp');
336
- }
337
- // Prevent setting expiry in the past (with 1 minute tolerance for clock skew)
338
- const now = Date.now();
339
- if (params.expiresAt < now - 60000) {
340
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'expiresAt cannot be in the past');
341
- }
342
- // Prevent unreasonably far future expiry (max 10 years)
343
- const maxFuture = now + (10 * 365 * 24 * 60 * 60 * 1000);
344
- if (params.expiresAt > maxFuture) {
345
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'expiresAt cannot be more than 10 years in the future');
346
- }
347
- }
348
-
349
- try {
350
- const credential = await storage.generateCredentials({
351
- name: params.name,
352
- expiresAt: params.expiresAt,
353
- });
354
-
355
- return {
356
- name: credential.name,
357
- secret: credential.secret,
358
- createdAt: credential.createdAt,
359
- expiresAt: credential.expiresAt,
360
- };
361
- } catch (error: any) {
362
- if (error.message === 'Username already taken') {
363
- throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Username already taken');
364
- }
365
- throw error;
366
- }
367
- },
368
-
369
- /**
370
- * Discover offers by tags - Supports 2 modes:
371
- * 1. Paginated discovery: tags array with limit/offset
372
- * 2. Random discovery: tags array without limit (returns single random offer)
239
+ * Discover offers by tags
373
240
  */
374
- async discover(params: DiscoverParams, name, timestamp, signature, storage, config, request: RpcRequest) {
241
+ async discover(params: DiscoverParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
375
242
  const { tags, limit, offset } = params;
376
243
 
377
- // Validate tags
378
244
  const tagsValidation = validateTags(tags);
379
245
  if (!tagsValidation.valid) {
380
246
  throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
@@ -382,7 +248,6 @@ const handlers: Record<string, RpcHandler> = {
382
248
 
383
249
  // Mode 1: Paginated discovery
384
250
  if (limit !== undefined) {
385
- // Validate numeric parameters
386
251
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 0) {
387
252
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'limit must be a non-negative integer');
388
253
  }
@@ -393,12 +258,11 @@ const handlers: Record<string, RpcHandler> = {
393
258
  const pageLimit = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);
394
259
  const pageOffset = Math.max(0, offset || 0);
395
260
 
396
- // Exclude self if authenticated
397
- const excludeUsername = name || null;
261
+ const excludePublicKey = publicKey || null;
398
262
 
399
263
  const offers = await storage.discoverOffers(
400
264
  tags,
401
- excludeUsername,
265
+ excludePublicKey,
402
266
  pageLimit,
403
267
  pageOffset
404
268
  );
@@ -406,7 +270,7 @@ const handlers: Record<string, RpcHandler> = {
406
270
  return {
407
271
  offers: offers.map(offer => ({
408
272
  offerId: offer.id,
409
- username: offer.username,
273
+ publicKey: offer.publicKey,
410
274
  tags: offer.tags,
411
275
  sdp: offer.sdp,
412
276
  createdAt: offer.createdAt,
@@ -418,11 +282,9 @@ const handlers: Record<string, RpcHandler> = {
418
282
  };
419
283
  }
420
284
 
421
- // Mode 2: Random discovery (no limit provided)
422
- // Exclude self if authenticated
423
- const excludeUsername = name || null;
424
-
425
- const offer = await storage.getRandomOffer(tags, excludeUsername);
285
+ // Mode 2: Random discovery
286
+ const excludePublicKey = publicKey || null;
287
+ const offer = await storage.getRandomOffer(tags, excludePublicKey);
426
288
 
427
289
  if (!offer) {
428
290
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'No offers found matching tags');
@@ -430,7 +292,7 @@ const handlers: Record<string, RpcHandler> = {
430
292
 
431
293
  return {
432
294
  offerId: offer.id,
433
- username: offer.username,
295
+ publicKey: offer.publicKey,
434
296
  tags: offer.tags,
435
297
  sdp: offer.sdp,
436
298
  createdAt: offer.createdAt,
@@ -441,20 +303,18 @@ const handlers: Record<string, RpcHandler> = {
441
303
  /**
442
304
  * Publish offers with tags
443
305
  */
444
- async publishOffer(params: PublishOfferParams, name, timestamp, signature, storage, config, request: RpcRequest) {
306
+ async publishOffer(params: PublishOfferParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
445
307
  const { tags, offers, ttl } = params;
446
308
 
447
- if (!name) {
448
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required for offer publishing');
309
+ if (!publicKey) {
310
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required for offer publishing');
449
311
  }
450
312
 
451
- // Validate tags
452
313
  const tagsValidation = validateTags(tags);
453
314
  if (!tagsValidation.valid) {
454
315
  throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
455
316
  }
456
317
 
457
- // Validate offers
458
318
  if (!offers || !Array.isArray(offers) || offers.length === 0) {
459
319
  throw new RpcError(ErrorCodes.MISSING_PARAMS, 'Must provide at least one offer');
460
320
  }
@@ -466,8 +326,7 @@ const handlers: Record<string, RpcHandler> = {
466
326
  );
467
327
  }
468
328
 
469
- // Check per-user offer limit
470
- const userOfferCount = await storage.getOfferCountByUsername(name);
329
+ const userOfferCount = await storage.getOfferCountByPublicKey(publicKey);
471
330
  if (userOfferCount + offers.length > config.maxOffersPerUser) {
472
331
  throw new RpcError(
473
332
  ErrorCodes.TOO_MANY_OFFERS_PER_USER,
@@ -475,7 +334,6 @@ const handlers: Record<string, RpcHandler> = {
475
334
  );
476
335
  }
477
336
 
478
- // Check total offers limit
479
337
  const totalOfferCount = await storage.getOfferCount();
480
338
  if (totalOfferCount + offers.length > config.maxTotalOffers) {
481
339
  throw new RpcError(
@@ -484,7 +342,6 @@ const handlers: Record<string, RpcHandler> = {
484
342
  );
485
343
  }
486
344
 
487
- // Validate each offer has valid SDP
488
345
  offers.forEach((offer, index) => {
489
346
  if (!offer || typeof offer !== 'object') {
490
347
  throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid offer at index ${index}: must be an object`);
@@ -500,27 +357,21 @@ const handlers: Record<string, RpcHandler> = {
500
357
  }
501
358
  });
502
359
 
503
- // Validate TTL if provided
504
360
  if (ttl !== undefined) {
505
361
  if (typeof ttl !== 'number' || isNaN(ttl) || ttl < 0) {
506
362
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'TTL must be a non-negative number');
507
363
  }
508
364
  }
509
365
 
510
- // Create offers with tags
511
366
  const now = Date.now();
512
367
  const offerTtl =
513
368
  ttl !== undefined
514
- ? Math.min(
515
- Math.max(ttl, config.offerMinTtl),
516
- config.offerMaxTtl
517
- )
369
+ ? Math.min(Math.max(ttl, config.offerMinTtl), config.offerMaxTtl)
518
370
  : config.offerDefaultTtl;
519
371
  const expiresAt = now + offerTtl;
520
372
 
521
- // Prepare offer requests with tags
522
373
  const offerRequests = offers.map(offer => ({
523
- username: name,
374
+ publicKey,
524
375
  tags,
525
376
  sdp: offer.sdp,
526
377
  expiresAt,
@@ -529,7 +380,7 @@ const handlers: Record<string, RpcHandler> = {
529
380
  const createdOffers = await storage.createOffers(offerRequests);
530
381
 
531
382
  return {
532
- username: name,
383
+ publicKey,
533
384
  tags,
534
385
  offers: createdOffers.map(offer => ({
535
386
  offerId: offer.id,
@@ -545,18 +396,18 @@ const handlers: Record<string, RpcHandler> = {
545
396
  /**
546
397
  * Delete an offer by ID
547
398
  */
548
- async deleteOffer(params: DeleteOfferParams, name, timestamp, signature, storage, config, request: RpcRequest) {
399
+ async deleteOffer(params: DeleteOfferParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
549
400
  const { offerId } = params;
550
401
 
551
- if (!name) {
552
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
402
+ if (!publicKey) {
403
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
553
404
  }
554
405
 
555
406
  validateStringParam(offerId, 'offerId');
556
407
 
557
- const deleted = await storage.deleteOffer(offerId, name);
408
+ const deleted = await storage.deleteOffer(offerId, publicKey);
558
409
  if (!deleted) {
559
- throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Offer not found or not owned by this name');
410
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Offer not found or not owned by this identity');
560
411
  }
561
412
 
562
413
  return { success: true };
@@ -565,14 +416,13 @@ const handlers: Record<string, RpcHandler> = {
565
416
  /**
566
417
  * Answer an offer
567
418
  */
568
- async answerOffer(params: AnswerOfferParams, name, timestamp, signature, storage, config, request: RpcRequest) {
419
+ async answerOffer(params: AnswerOfferParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
569
420
  const { offerId, sdp, matchedTags } = params;
570
421
 
571
- // Validate input parameters
572
422
  validateStringParam(offerId, 'offerId');
573
423
 
574
- if (!name) {
575
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
424
+ if (!publicKey) {
425
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
576
426
  }
577
427
 
578
428
  if (!sdp || typeof sdp !== 'string' || sdp.length === 0) {
@@ -583,7 +433,6 @@ const handlers: Record<string, RpcHandler> = {
583
433
  throw new RpcError(ErrorCodes.SDP_TOO_LARGE, `SDP too large (max ${config.maxSdpSize} bytes)`);
584
434
  }
585
435
 
586
- // Validate matchedTags if provided
587
436
  if (matchedTags !== undefined && !Array.isArray(matchedTags)) {
588
437
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'matchedTags must be an array');
589
438
  }
@@ -593,11 +442,19 @@ const handlers: Record<string, RpcHandler> = {
593
442
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
594
443
  }
595
444
 
596
- if (offer.answererUsername) {
445
+ if (offer.answererPublicKey) {
597
446
  throw new RpcError(ErrorCodes.OFFER_ALREADY_ANSWERED, 'Offer already answered');
598
447
  }
599
448
 
600
- await storage.answerOffer(offerId, name, sdp, matchedTags);
449
+ if (matchedTags && matchedTags.length > 0) {
450
+ const offerTagSet = new Set(offer.tags);
451
+ const invalidTags = matchedTags.filter(tag => !offerTagSet.has(tag));
452
+ if (invalidTags.length > 0) {
453
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `matchedTags contains tags not on offer: ${invalidTags.join(', ')}`);
454
+ }
455
+ }
456
+
457
+ await storage.answerOffer(offerId, publicKey, sdp, matchedTags);
601
458
 
602
459
  return { success: true, offerId };
603
460
  },
@@ -605,14 +462,13 @@ const handlers: Record<string, RpcHandler> = {
605
462
  /**
606
463
  * Get answer for an offer
607
464
  */
608
- async getOfferAnswer(params: GetOfferAnswerParams, name, timestamp, signature, storage, config, request: RpcRequest) {
465
+ async getOfferAnswer(params: GetOfferAnswerParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
609
466
  const { offerId } = params;
610
467
 
611
- // Validate input parameters
612
468
  validateStringParam(offerId, 'offerId');
613
469
 
614
- if (!name) {
615
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
470
+ if (!publicKey) {
471
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
616
472
  }
617
473
 
618
474
  const offer = await storage.getOfferById(offerId);
@@ -620,18 +476,18 @@ const handlers: Record<string, RpcHandler> = {
620
476
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
621
477
  }
622
478
 
623
- if (offer.username !== name) {
479
+ if (offer.publicKey !== publicKey) {
624
480
  throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Not authorized to access this offer');
625
481
  }
626
482
 
627
- if (!offer.answererUsername || !offer.answerSdp) {
483
+ if (!offer.answererPublicKey || !offer.answerSdp) {
628
484
  throw new RpcError(ErrorCodes.OFFER_NOT_ANSWERED, 'Offer not yet answered');
629
485
  }
630
486
 
631
487
  return {
632
488
  sdp: offer.answerSdp,
633
489
  offerId: offer.id,
634
- answererId: offer.answererUsername,
490
+ answererPublicKey: offer.answererPublicKey,
635
491
  answeredAt: offer.answeredAt,
636
492
  };
637
493
  },
@@ -639,49 +495,38 @@ const handlers: Record<string, RpcHandler> = {
639
495
  /**
640
496
  * Combined polling for answers and ICE candidates
641
497
  */
642
- async poll(params: PollParams, name, timestamp, signature, storage, config, request: RpcRequest) {
498
+ async poll(params: PollParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
643
499
  const { since } = params;
644
500
 
645
- if (!name) {
646
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
501
+ if (!publicKey) {
502
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
647
503
  }
648
504
 
649
- // Validate since parameter
650
505
  if (since !== undefined && (typeof since !== 'number' || since < 0 || !Number.isFinite(since))) {
651
506
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Invalid since parameter: must be a non-negative number');
652
507
  }
653
508
  const sinceTimestamp = since !== undefined ? since : 0;
654
509
 
655
- // Get all answered offers (where user is the offerer)
656
- const answeredOffers = await storage.getAnsweredOffers(name);
510
+ const answeredOffers = await storage.getAnsweredOffers(publicKey);
657
511
  const filteredAnswers = answeredOffers.filter(
658
512
  (offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
659
513
  );
660
514
 
661
- // Get all user's offers (where user is offerer)
662
- const ownedOffers = await storage.getOffersByUsername(name);
663
-
664
- // Get all offers the user has answered (where user is answerer)
665
- const answeredByUser = await storage.getOffersAnsweredBy(name);
515
+ const ownedOffers = await storage.getOffersByPublicKey(publicKey);
516
+ const answeredByUser = await storage.getOffersAnsweredBy(publicKey);
666
517
 
667
- // Combine offer IDs from both sources for ICE candidate fetching
668
- // The storage method handles filtering by role automatically
669
518
  const allOfferIds = [
670
519
  ...ownedOffers.map(offer => offer.id),
671
520
  ...answeredByUser.map(offer => offer.id),
672
521
  ];
673
- // Remove duplicates (shouldn't happen, but defensive)
674
522
  const offerIds = [...new Set(allOfferIds)];
675
523
 
676
- // Batch fetch ICE candidates for all offers using JOIN to avoid N+1 query problem
677
- // Server filters by role - offerers get answerer candidates, answerers get offerer candidates
678
524
  const iceCandidatesMap = await storage.getIceCandidatesForMultipleOffers(
679
525
  offerIds,
680
- name,
526
+ publicKey,
681
527
  sinceTimestamp
682
528
  );
683
529
 
684
- // Convert Map to Record for response
685
530
  const iceCandidatesByOffer: Record<string, any[]> = {};
686
531
  for (const [offerId, candidates] of iceCandidatesMap.entries()) {
687
532
  iceCandidatesByOffer[offerId] = candidates;
@@ -690,7 +535,7 @@ const handlers: Record<string, RpcHandler> = {
690
535
  return {
691
536
  answers: filteredAnswers.map((offer) => ({
692
537
  offerId: offer.id,
693
- answererId: offer.answererUsername,
538
+ answererPublicKey: offer.answererPublicKey,
694
539
  sdp: offer.answerSdp,
695
540
  answeredAt: offer.answeredAt,
696
541
  })),
@@ -701,14 +546,13 @@ const handlers: Record<string, RpcHandler> = {
701
546
  /**
702
547
  * Add ICE candidates
703
548
  */
704
- async addIceCandidates(params: AddIceCandidatesParams, name, timestamp, signature, storage, config, request: RpcRequest) {
549
+ async addIceCandidates(params: AddIceCandidatesParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
705
550
  const { offerId, candidates } = params;
706
551
 
707
- // Validate input parameters
708
552
  validateStringParam(offerId, 'offerId');
709
553
 
710
- if (!name) {
711
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
554
+ if (!publicKey) {
555
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
712
556
  }
713
557
 
714
558
  if (!Array.isArray(candidates) || candidates.length === 0) {
@@ -722,13 +566,11 @@ const handlers: Record<string, RpcHandler> = {
722
566
  );
723
567
  }
724
568
 
725
- // Validate each candidate is an object (don't enforce structure per CLAUDE.md)
726
569
  candidates.forEach((candidate, index) => {
727
570
  if (!candidate || typeof candidate !== 'object') {
728
571
  throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid candidate at index ${index}: must be an object`);
729
572
  }
730
573
 
731
- // Check JSON depth to prevent stack overflow from deeply nested objects
732
574
  const depth = getJsonDepth(candidate, config.maxCandidateDepth + 1);
733
575
  if (depth > config.maxCandidateDepth) {
734
576
  throw new RpcError(
@@ -737,7 +579,6 @@ const handlers: Record<string, RpcHandler> = {
737
579
  );
738
580
  }
739
581
 
740
- // Ensure candidate is serializable and check size (will be stored as JSON)
741
582
  let candidateJson: string;
742
583
  try {
743
584
  candidateJson = JSON.stringify(candidate);
@@ -745,7 +586,6 @@ const handlers: Record<string, RpcHandler> = {
745
586
  throw new RpcError(ErrorCodes.INVALID_PARAMS, `Candidate at index ${index} is not serializable`);
746
587
  }
747
588
 
748
- // Validate candidate size to prevent abuse
749
589
  if (candidateJson.length > config.maxCandidateSize) {
750
590
  throw new RpcError(
751
591
  ErrorCodes.INVALID_PARAMS,
@@ -759,7 +599,6 @@ const handlers: Record<string, RpcHandler> = {
759
599
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
760
600
  }
761
601
 
762
- // Check ICE candidates limit per offer
763
602
  const currentCandidateCount = await storage.getIceCandidateCount(offerId);
764
603
  if (currentCandidateCount + candidates.length > config.maxIceCandidatesPerOffer) {
765
604
  throw new RpcError(
@@ -768,10 +607,10 @@ const handlers: Record<string, RpcHandler> = {
768
607
  );
769
608
  }
770
609
 
771
- const role = offer.username === name ? 'offerer' : 'answerer';
610
+ const role = offer.publicKey === publicKey ? 'offerer' : 'answerer';
772
611
  const count = await storage.addIceCandidates(
773
612
  offerId,
774
- name,
613
+ publicKey,
775
614
  role,
776
615
  candidates
777
616
  );
@@ -782,17 +621,15 @@ const handlers: Record<string, RpcHandler> = {
782
621
  /**
783
622
  * Get ICE candidates
784
623
  */
785
- async getIceCandidates(params: GetIceCandidatesParams, name, timestamp, signature, storage, config, request: RpcRequest) {
624
+ async getIceCandidates(params: GetIceCandidatesParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
786
625
  const { offerId, since } = params;
787
626
 
788
- // Validate input parameters
789
627
  validateStringParam(offerId, 'offerId');
790
628
 
791
- if (!name) {
792
- throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
629
+ if (!publicKey) {
630
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Authentication required');
793
631
  }
794
632
 
795
- // Validate since parameter
796
633
  if (since !== undefined && (typeof since !== 'number' || since < 0 || !Number.isFinite(since))) {
797
634
  throw new RpcError(ErrorCodes.INVALID_PARAMS, 'Invalid since parameter: must be a non-negative number');
798
635
  }
@@ -803,10 +640,8 @@ const handlers: Record<string, RpcHandler> = {
803
640
  throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
804
641
  }
805
642
 
806
- // Validate that user is authorized to access this offer's candidates
807
- // Only the offerer and answerer can access ICE candidates
808
- const isOfferer = offer.username === name;
809
- const isAnswerer = offer.answererUsername === name;
643
+ const isOfferer = offer.publicKey === publicKey;
644
+ const isAnswerer = offer.answererPublicKey === publicKey;
810
645
 
811
646
  if (!isOfferer && !isAnswerer) {
812
647
  throw new RpcError(ErrorCodes.NOT_AUTHORIZED, 'Not authorized to access ICE candidates for this offer');
@@ -831,7 +666,7 @@ const handlers: Record<string, RpcHandler> = {
831
666
  };
832
667
 
833
668
  // Methods that don't require authentication
834
- const UNAUTHENTICATED_METHODS = new Set(['generateCredentials', 'discover']);
669
+ const UNAUTHENTICATED_METHODS = new Set(['discover']);
835
670
 
836
671
  /**
837
672
  * Handle RPC batch request with header-based authentication
@@ -844,13 +679,11 @@ export async function handleRpc(
844
679
  ): Promise<RpcResponse[]> {
845
680
  const responses: RpcResponse[] = [];
846
681
 
847
- // Extract client IP for rate limiting
848
- // Try multiple headers for proxy compatibility
849
682
  const clientIp =
850
- ctx.req.header('cf-connecting-ip') || // Cloudflare
851
- ctx.req.header('x-real-ip') || // Nginx
683
+ ctx.req.header('cf-connecting-ip') ||
684
+ ctx.req.header('x-real-ip') ||
852
685
  ctx.req.header('x-forwarded-for')?.split(',')[0].trim() ||
853
- undefined; // Don't use fallback - let handlers decide how to handle missing IP
686
+ undefined;
854
687
 
855
688
  // General request rate limiting (per IP per second)
856
689
  if (clientIp) {
@@ -862,7 +695,6 @@ export async function handleRpc(
862
695
  );
863
696
 
864
697
  if (!allowed) {
865
- // Return error for all requests in the batch
866
698
  return requests.map(() => ({
867
699
  success: false,
868
700
  error: `Rate limit exceeded. Maximum ${config.requestsPerIpPerSecond} requests per second per IP.`,
@@ -871,21 +703,16 @@ export async function handleRpc(
871
703
  }
872
704
  }
873
705
 
874
- // Read auth headers (same for all requests in batch)
875
- const name = ctx.req.header('X-Name');
706
+ // Read auth headers (X-PublicKey instead of X-Name)
707
+ const publicKey = ctx.req.header('X-PublicKey');
876
708
  const timestampHeader = ctx.req.header('X-Timestamp');
877
709
  const nonce = ctx.req.header('X-Nonce');
878
710
  const signature = ctx.req.header('X-Signature');
879
711
 
880
- // Parse timestamp if present
881
712
  const timestamp = timestampHeader ? parseInt(timestampHeader, 10) : 0;
882
713
 
883
- // CRITICAL: Pre-calculate total operations BEFORE processing any requests
884
- // This prevents DoS where first N requests complete before limit triggers
885
- // Example attack prevented: 100 publishOffer × 100 offers = 10,000 operations
714
+ // Pre-calculate total operations
886
715
  let totalOperations = 0;
887
-
888
- // Count all operations across all requests first
889
716
  for (const request of requests) {
890
717
  const { method, params } = request;
891
718
  if (method === 'publishOffer' && params?.offers && Array.isArray(params.offers)) {
@@ -893,13 +720,10 @@ export async function handleRpc(
893
720
  } else if (method === 'addIceCandidates' && params?.candidates && Array.isArray(params.candidates)) {
894
721
  totalOperations += params.candidates.length;
895
722
  } else {
896
- totalOperations += 1; // Single operation
723
+ totalOperations += 1;
897
724
  }
898
725
  }
899
726
 
900
- // Reject entire batch if total operations exceed limit
901
- // This happens BEFORE processing any requests
902
- // Return error for EACH request to maintain response array alignment
903
727
  if (totalOperations > config.maxTotalOperations) {
904
728
  return requests.map(() => ({
905
729
  success: false,
@@ -913,7 +737,6 @@ export async function handleRpc(
913
737
  try {
914
738
  const { method, params } = request;
915
739
 
916
- // Validate request
917
740
  if (!method || typeof method !== 'string') {
918
741
  responses.push({
919
742
  success: false,
@@ -923,7 +746,6 @@ export async function handleRpc(
923
746
  continue;
924
747
  }
925
748
 
926
- // Get handler
927
749
  const handler = handlers[method];
928
750
  if (!handler) {
929
751
  responses.push({
@@ -934,14 +756,13 @@ export async function handleRpc(
934
756
  continue;
935
757
  }
936
758
 
937
- // Validate auth headers only for methods that require authentication
938
759
  const requiresAuth = !UNAUTHENTICATED_METHODS.has(method);
939
760
 
940
761
  if (requiresAuth) {
941
- if (!name || typeof name !== 'string') {
762
+ if (!publicKey || typeof publicKey !== 'string') {
942
763
  responses.push({
943
764
  success: false,
944
- error: 'Missing or invalid X-Name header',
765
+ error: 'Missing or invalid X-PublicKey header',
945
766
  errorCode: ErrorCodes.AUTH_REQUIRED,
946
767
  });
947
768
  continue;
@@ -974,9 +795,9 @@ export async function handleRpc(
974
795
  continue;
975
796
  }
976
797
 
977
- // Verify signature (validates timestamp, nonce, and signature)
798
+ // Verify Ed25519 signature
978
799
  await verifyRequestSignature(
979
- name,
800
+ publicKey,
980
801
  timestamp,
981
802
  nonce,
982
803
  signature,
@@ -986,10 +807,9 @@ export async function handleRpc(
986
807
  config
987
808
  );
988
809
 
989
- // Execute handler with auth
990
810
  const result = await handler(
991
811
  params || {},
992
- name,
812
+ publicKey,
993
813
  timestamp,
994
814
  signature,
995
815
  storage,
@@ -1005,9 +825,9 @@ export async function handleRpc(
1005
825
  // Execute handler without strict auth requirement
1006
826
  const result = await handler(
1007
827
  params || {},
1008
- name || '',
1009
- 0, // timestamp
1010
- '', // signature
828
+ publicKey || '',
829
+ 0,
830
+ '',
1011
831
  storage,
1012
832
  config,
1013
833
  { ...request, clientIp }
@@ -1026,8 +846,6 @@ export async function handleRpc(
1026
846
  errorCode: err.errorCode,
1027
847
  });
1028
848
  } else {
1029
- // Generic error - don't leak internal details
1030
- // Log the actual error for debugging
1031
849
  console.error('Unexpected RPC error:', err);
1032
850
  responses.push({
1033
851
  success: false,