@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/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.idea/modules.xml +8 -0
- package/.idea/rondevu-server.iml +8 -0
- package/.idea/workspace.xml +17 -0
- package/README.md +80 -199
- package/build.js +4 -1
- package/dist/index.js +2755 -1448
- package/dist/index.js.map +4 -4
- package/migrations/fresh_schema.sql +36 -41
- package/package.json +10 -4
- package/src/app.ts +38 -18
- package/src/config.ts +155 -9
- package/src/crypto.ts +362 -265
- package/src/index.ts +20 -25
- package/src/rpc.ts +658 -405
- package/src/storage/d1.ts +312 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +559 -0
- package/src/storage/mysql.ts +588 -0
- package/src/storage/postgres.ts +595 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +303 -269
- package/src/storage/types.ts +113 -113
- package/src/worker.ts +15 -34
- package/tests/integration/api.test.ts +395 -0
- package/tests/integration/setup.ts +170 -0
- package/wrangler.toml +25 -26
- package/ADVANCED.md +0 -502
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
|
-
|
|
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:
|
|
43
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
203
|
+
* Validate timestamp for replay attack prevention
|
|
204
|
+
* Throws RpcError if timestamp is invalid
|
|
53
205
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
248
|
+
throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Invalid signature');
|
|
107
249
|
}
|
|
108
250
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
258
|
+
if (!nonceIsNew) {
|
|
259
|
+
throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, 'Nonce already used (replay attack detected)');
|
|
260
|
+
}
|
|
117
261
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
*
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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 (!
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
219
|
-
const
|
|
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
|
-
|
|
237
|
-
|
|
386
|
+
const offers = await storage.discoverOffers(
|
|
387
|
+
tags,
|
|
388
|
+
excludeUsername,
|
|
389
|
+
pageLimit,
|
|
390
|
+
pageOffset
|
|
391
|
+
);
|
|
238
392
|
|
|
239
393
|
return {
|
|
240
|
-
|
|
241
|
-
|
|
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:
|
|
248
|
-
if
|
|
249
|
-
|
|
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
|
-
|
|
260
|
-
}
|
|
412
|
+
const offer = await storage.getRandomOffer(tags, excludeUsername);
|
|
261
413
|
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
|
429
|
+
* Publish offers with tags
|
|
282
430
|
*/
|
|
283
|
-
async
|
|
284
|
-
const {
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
490
|
+
// Prepare offer requests with tags
|
|
348
491
|
const offerRequests = offers.map(offer => ({
|
|
349
|
-
username,
|
|
350
|
-
|
|
492
|
+
username: name,
|
|
493
|
+
tags,
|
|
351
494
|
sdp: offer.sdp,
|
|
352
495
|
expiresAt,
|
|
353
496
|
}));
|
|
354
497
|
|
|
355
|
-
const
|
|
356
|
-
serviceFqn,
|
|
357
|
-
expiresAt,
|
|
358
|
-
offers: offerRequests,
|
|
359
|
-
});
|
|
498
|
+
const createdOffers = await storage.createOffers(offerRequests);
|
|
360
499
|
|
|
361
500
|
return {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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:
|
|
372
|
-
expiresAt
|
|
509
|
+
createdAt: now,
|
|
510
|
+
expiresAt,
|
|
373
511
|
};
|
|
374
512
|
},
|
|
375
513
|
|
|
376
514
|
/**
|
|
377
|
-
* Delete
|
|
515
|
+
* Delete an offer by ID
|
|
378
516
|
*/
|
|
379
|
-
async
|
|
380
|
-
const {
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
394
|
-
if (!parsed || !parsed.username) {
|
|
395
|
-
throw new Error('Service FQN must include username');
|
|
396
|
-
}
|
|
524
|
+
validateStringParam(offerId, 'offerId');
|
|
397
525
|
|
|
398
|
-
const
|
|
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
|
|
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,
|
|
415
|
-
const {
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
540
|
+
// Validate input parameters
|
|
541
|
+
validateStringParam(offerId, 'offerId');
|
|
421
542
|
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
548
|
+
throw new RpcError(ErrorCodes.INVALID_SDP, 'Invalid SDP');
|
|
430
549
|
}
|
|
431
550
|
|
|
432
|
-
if (sdp.length >
|
|
433
|
-
throw new
|
|
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
|
|
557
|
+
throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
|
|
439
558
|
}
|
|
440
559
|
|
|
441
560
|
if (offer.answererUsername) {
|
|
442
|
-
throw new
|
|
561
|
+
throw new RpcError(ErrorCodes.OFFER_ALREADY_ANSWERED, 'Offer already answered');
|
|
443
562
|
}
|
|
444
563
|
|
|
445
|
-
await storage.answerOffer(offerId,
|
|
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,
|
|
454
|
-
const {
|
|
455
|
-
const username = extractUsername(message);
|
|
572
|
+
async getOfferAnswer(params: GetOfferAnswerParams, name, timestamp, signature, storage, config, request: RpcRequest) {
|
|
573
|
+
const { offerId } = params;
|
|
456
574
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
575
|
+
// Validate input parameters
|
|
576
|
+
validateStringParam(offerId, 'offerId');
|
|
460
577
|
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
584
|
+
throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
|
|
470
585
|
}
|
|
471
586
|
|
|
472
|
-
if (offer.username !==
|
|
473
|
-
throw new
|
|
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
|
|
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,
|
|
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 (!
|
|
496
|
-
throw new
|
|
609
|
+
if (!name) {
|
|
610
|
+
throw new RpcError(ErrorCodes.AUTH_REQUIRED, 'Name required');
|
|
497
611
|
}
|
|
498
612
|
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
648
|
+
// Convert Map to Record for response
|
|
517
649
|
const iceCandidatesByOffer: Record<string, any[]> = {};
|
|
518
|
-
|
|
519
|
-
|
|
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,
|
|
570
|
-
const {
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
671
|
+
// Validate input parameters
|
|
672
|
+
validateStringParam(offerId, 'offerId');
|
|
576
673
|
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|
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
|
|
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
|
|
723
|
+
throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, 'Offer not found');
|
|
597
724
|
}
|
|
598
725
|
|
|
599
|
-
const role = offer.username ===
|
|
726
|
+
const role = offer.username === name ? 'offerer' : 'answerer';
|
|
600
727
|
const count = await storage.addIceCandidates(
|
|
601
728
|
offerId,
|
|
602
|
-
|
|
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,
|
|
614
|
-
const {
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
743
|
+
// Validate input parameters
|
|
744
|
+
validateStringParam(offerId, 'offerId');
|
|
620
745
|
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
862
|
+
// Get handler
|
|
863
|
+
const handler = handlers[method];
|
|
864
|
+
if (!handler) {
|
|
677
865
|
responses.push({
|
|
678
866
|
success: false,
|
|
679
|
-
error:
|
|
867
|
+
error: `Unknown method: ${method}`,
|
|
868
|
+
errorCode: ErrorCodes.UNKNOWN_METHOD,
|
|
680
869
|
});
|
|
681
870
|
continue;
|
|
682
871
|
}
|
|
683
872
|
|
|
684
|
-
|
|
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:
|
|
687
|
-
|
|
937
|
+
success: true,
|
|
938
|
+
result,
|
|
688
939
|
});
|
|
689
|
-
|
|
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:
|
|
697
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
|