@xtr-dev/rondevu-server 0.5.1 → 0.5.7

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