@xtr-dev/rondevu-server 0.5.0 → 0.5.6

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