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