@xtr-dev/rondevu-server 0.4.0 → 0.5.1
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/ADVANCED.md +502 -0
- package/README.md +136 -282
- package/dist/index.js +694 -733
- package/dist/index.js.map +4 -4
- package/migrations/0006_service_offer_refactor.sql +40 -0
- package/migrations/0007_simplify_schema.sql +54 -0
- package/migrations/0008_peer_id_to_username.sql +67 -0
- package/migrations/fresh_schema.sql +81 -0
- package/package.json +2 -1
- package/src/app.ts +38 -591
- package/src/config.ts +0 -13
- package/src/crypto.ts +103 -139
- package/src/rpc.ts +725 -0
- package/src/storage/d1.ts +169 -182
- package/src/storage/sqlite.ts +142 -168
- package/src/storage/types.ts +51 -95
- package/src/worker.ts +0 -6
- package/wrangler.toml +3 -3
- package/src/middleware/auth.ts +0 -51
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { Context } from 'hono';
|
|
2
|
+
import { Storage } from './storage/types.ts';
|
|
3
|
+
import { Config } from './config.ts';
|
|
4
|
+
import {
|
|
5
|
+
validateUsernameClaim,
|
|
6
|
+
validateServicePublish,
|
|
7
|
+
validateServiceFqn,
|
|
8
|
+
parseServiceFqn,
|
|
9
|
+
isVersionCompatible,
|
|
10
|
+
verifyEd25519Signature,
|
|
11
|
+
validateAuthMessage,
|
|
12
|
+
validateUsername,
|
|
13
|
+
} from './crypto.ts';
|
|
14
|
+
|
|
15
|
+
// Constants
|
|
16
|
+
const MAX_PAGE_SIZE = 100;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* RPC request format
|
|
20
|
+
*/
|
|
21
|
+
export interface RpcRequest {
|
|
22
|
+
method: string;
|
|
23
|
+
message: string;
|
|
24
|
+
signature: string;
|
|
25
|
+
publicKey?: string; // Optional: for auto-claiming usernames
|
|
26
|
+
params?: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* RPC response format
|
|
31
|
+
*/
|
|
32
|
+
export interface RpcResponse {
|
|
33
|
+
success: boolean;
|
|
34
|
+
result?: any;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* RPC method handler
|
|
40
|
+
*/
|
|
41
|
+
type RpcHandler = (
|
|
42
|
+
params: any,
|
|
43
|
+
message: string,
|
|
44
|
+
signature: string,
|
|
45
|
+
publicKey: string | undefined,
|
|
46
|
+
storage: Storage,
|
|
47
|
+
config: Config
|
|
48
|
+
) => Promise<any>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Verify authentication for a method call
|
|
52
|
+
* Automatically claims username if it doesn't exist
|
|
53
|
+
*/
|
|
54
|
+
async function verifyAuth(
|
|
55
|
+
username: string,
|
|
56
|
+
message: string,
|
|
57
|
+
signature: string,
|
|
58
|
+
publicKey: string | undefined,
|
|
59
|
+
storage: Storage
|
|
60
|
+
): Promise<{ valid: boolean; error?: string }> {
|
|
61
|
+
// Get username record to fetch public key
|
|
62
|
+
let usernameRecord = await storage.getUsername(username);
|
|
63
|
+
|
|
64
|
+
// Auto-claim username if it doesn't exist
|
|
65
|
+
if (!usernameRecord) {
|
|
66
|
+
if (!publicKey) {
|
|
67
|
+
return {
|
|
68
|
+
valid: false,
|
|
69
|
+
error: `Username "${username}" is not claimed and no public key provided for auto-claim.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate username format before claiming
|
|
74
|
+
const usernameValidation = validateUsername(username);
|
|
75
|
+
if (!usernameValidation.valid) {
|
|
76
|
+
return usernameValidation;
|
|
77
|
+
}
|
|
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
|
+
}
|
|
84
|
+
|
|
85
|
+
// Auto-claim the username
|
|
86
|
+
const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1000; // 365 days
|
|
87
|
+
await storage.claimUsername({
|
|
88
|
+
username,
|
|
89
|
+
publicKey,
|
|
90
|
+
expiresAt,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
usernameRecord = await storage.getUsername(username);
|
|
94
|
+
if (!usernameRecord) {
|
|
95
|
+
return { valid: false, error: 'Failed to claim username' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Verify Ed25519 signature
|
|
100
|
+
const isValid = await verifyEd25519Signature(
|
|
101
|
+
usernameRecord.publicKey,
|
|
102
|
+
signature,
|
|
103
|
+
message
|
|
104
|
+
);
|
|
105
|
+
if (!isValid) {
|
|
106
|
+
return { valid: false, error: 'Invalid signature' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Validate message format and timestamp
|
|
110
|
+
const validation = validateAuthMessage(username, message);
|
|
111
|
+
if (!validation.valid) {
|
|
112
|
+
return { valid: false, error: validation.error };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { valid: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extract username from message
|
|
120
|
+
*/
|
|
121
|
+
function extractUsername(message: string): string | null {
|
|
122
|
+
// Message format: method:username:...
|
|
123
|
+
const parts = message.split(':');
|
|
124
|
+
if (parts.length < 2) return null;
|
|
125
|
+
return parts[1];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* RPC Method Handlers
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
const handlers: Record<string, RpcHandler> = {
|
|
133
|
+
/**
|
|
134
|
+
* Check if username is available
|
|
135
|
+
*/
|
|
136
|
+
async getUser(params, message, signature, publicKey, storage, config) {
|
|
137
|
+
const { username } = params;
|
|
138
|
+
const claimed = await storage.getUsername(username);
|
|
139
|
+
|
|
140
|
+
if (!claimed) {
|
|
141
|
+
return {
|
|
142
|
+
username,
|
|
143
|
+
available: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
username: claimed.username,
|
|
149
|
+
available: false,
|
|
150
|
+
claimedAt: claimed.claimedAt,
|
|
151
|
+
expiresAt: claimed.expiresAt,
|
|
152
|
+
publicKey: claimed.publicKey,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get service by FQN - Supports 3 modes:
|
|
158
|
+
* 1. Direct lookup: FQN includes @username
|
|
159
|
+
* 2. Paginated discovery: FQN without @username, with limit/offset
|
|
160
|
+
* 3. Random discovery: FQN without @username, no limit
|
|
161
|
+
*/
|
|
162
|
+
async getService(params, message, signature, publicKey, storage, config) {
|
|
163
|
+
const { serviceFqn, limit, offset } = params;
|
|
164
|
+
const username = extractUsername(message);
|
|
165
|
+
|
|
166
|
+
// Verify authentication
|
|
167
|
+
if (username) {
|
|
168
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
169
|
+
if (!auth.valid) {
|
|
170
|
+
throw new Error(auth.error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Parse and validate FQN
|
|
175
|
+
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
176
|
+
if (!fqnValidation.valid) {
|
|
177
|
+
throw new Error(fqnValidation.error || 'Invalid service FQN');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const parsed = parseServiceFqn(serviceFqn);
|
|
181
|
+
if (!parsed) {
|
|
182
|
+
throw new Error('Failed to parse service FQN');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Helper: Filter services by version compatibility
|
|
186
|
+
const filterCompatibleServices = (services) => {
|
|
187
|
+
return services.filter((s) => {
|
|
188
|
+
const serviceVersion = parseServiceFqn(s.serviceFqn);
|
|
189
|
+
return (
|
|
190
|
+
serviceVersion &&
|
|
191
|
+
isVersionCompatible(parsed.version, serviceVersion.version)
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Helper: Find available offer for service
|
|
197
|
+
const findAvailableOffer = async (service) => {
|
|
198
|
+
const offers = await storage.getOffersForService(service.id);
|
|
199
|
+
return offers.find((o) => !o.answererUsername);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Helper: Build service response object
|
|
203
|
+
const buildServiceResponse = (service, offer) => ({
|
|
204
|
+
serviceId: service.id,
|
|
205
|
+
username: service.username,
|
|
206
|
+
serviceFqn: service.serviceFqn,
|
|
207
|
+
offerId: offer.id,
|
|
208
|
+
sdp: offer.sdp,
|
|
209
|
+
createdAt: service.createdAt,
|
|
210
|
+
expiresAt: service.expiresAt,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Mode 1: Paginated discovery
|
|
214
|
+
if (limit !== undefined) {
|
|
215
|
+
const pageLimit = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);
|
|
216
|
+
const pageOffset = Math.max(0, offset || 0);
|
|
217
|
+
|
|
218
|
+
const allServices = await storage.getServicesByName(parsed.service, parsed.version);
|
|
219
|
+
const compatibleServices = filterCompatibleServices(allServices);
|
|
220
|
+
|
|
221
|
+
// Get unique services per username with available offers
|
|
222
|
+
const usernameSet = new Set<string>();
|
|
223
|
+
const uniqueServices: any[] = [];
|
|
224
|
+
|
|
225
|
+
for (const service of compatibleServices) {
|
|
226
|
+
if (!usernameSet.has(service.username)) {
|
|
227
|
+
usernameSet.add(service.username);
|
|
228
|
+
const availableOffer = await findAvailableOffer(service);
|
|
229
|
+
|
|
230
|
+
if (availableOffer) {
|
|
231
|
+
uniqueServices.push(buildServiceResponse(service, availableOffer));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Paginate results
|
|
237
|
+
const paginatedServices = uniqueServices.slice(pageOffset, pageOffset + pageLimit);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
services: paginatedServices,
|
|
241
|
+
count: paginatedServices.length,
|
|
242
|
+
limit: pageLimit,
|
|
243
|
+
offset: pageOffset,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Mode 2: Direct lookup with username
|
|
248
|
+
if (parsed.username) {
|
|
249
|
+
const service = await storage.getServiceByFqn(serviceFqn);
|
|
250
|
+
if (!service) {
|
|
251
|
+
throw new Error('Service not found');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const availableOffer = await findAvailableOffer(service);
|
|
255
|
+
if (!availableOffer) {
|
|
256
|
+
throw new Error('Service has no available offers');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return buildServiceResponse(service, availableOffer);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Mode 3: Random discovery without username
|
|
263
|
+
const allServices = await storage.getServicesByName(parsed.service, parsed.version);
|
|
264
|
+
const compatibleServices = filterCompatibleServices(allServices);
|
|
265
|
+
|
|
266
|
+
if (compatibleServices.length === 0) {
|
|
267
|
+
throw new Error('No services found');
|
|
268
|
+
}
|
|
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');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return buildServiceResponse(randomService, availableOffer);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Publish a service
|
|
282
|
+
*/
|
|
283
|
+
async publishService(params, message, signature, publicKey, storage, config) {
|
|
284
|
+
const { serviceFqn, offers, ttl } = params;
|
|
285
|
+
const username = extractUsername(message);
|
|
286
|
+
|
|
287
|
+
if (!username) {
|
|
288
|
+
throw new Error('Username required for service publishing');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Verify authentication
|
|
292
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
293
|
+
if (!auth.valid) {
|
|
294
|
+
throw new Error(auth.error);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Validate service FQN
|
|
298
|
+
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
299
|
+
if (!fqnValidation.valid) {
|
|
300
|
+
throw new Error(fqnValidation.error || 'Invalid service FQN');
|
|
301
|
+
}
|
|
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');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate offers
|
|
313
|
+
if (!offers || !Array.isArray(offers) || offers.length === 0) {
|
|
314
|
+
throw new Error('Must provide at least one offer');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (offers.length > config.maxOffersPerRequest) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Too many offers (max ${config.maxOffersPerRequest})`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Validate each offer has valid SDP
|
|
324
|
+
offers.forEach((offer, index) => {
|
|
325
|
+
if (!offer || typeof offer !== 'object') {
|
|
326
|
+
throw new Error(`Invalid offer at index ${index}: must be an object`);
|
|
327
|
+
}
|
|
328
|
+
if (!offer.sdp || typeof offer.sdp !== 'string') {
|
|
329
|
+
throw new Error(`Invalid offer at index ${index}: missing or invalid SDP`);
|
|
330
|
+
}
|
|
331
|
+
if (!offer.sdp.trim()) {
|
|
332
|
+
throw new Error(`Invalid offer at index ${index}: SDP cannot be empty`);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Create service with offers
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
const offerTtl =
|
|
339
|
+
ttl !== undefined
|
|
340
|
+
? Math.min(
|
|
341
|
+
Math.max(ttl, config.offerMinTtl),
|
|
342
|
+
config.offerMaxTtl
|
|
343
|
+
)
|
|
344
|
+
: config.offerDefaultTtl;
|
|
345
|
+
const expiresAt = now + offerTtl;
|
|
346
|
+
|
|
347
|
+
// Prepare offer requests with TTL
|
|
348
|
+
const offerRequests = offers.map(offer => ({
|
|
349
|
+
username,
|
|
350
|
+
serviceFqn,
|
|
351
|
+
sdp: offer.sdp,
|
|
352
|
+
expiresAt,
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
const result = await storage.createService({
|
|
356
|
+
serviceFqn,
|
|
357
|
+
expiresAt,
|
|
358
|
+
offers: offerRequests,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
serviceId: result.service.id,
|
|
363
|
+
username: result.service.username,
|
|
364
|
+
serviceFqn: result.service.serviceFqn,
|
|
365
|
+
offers: result.offers.map(offer => ({
|
|
366
|
+
offerId: offer.id,
|
|
367
|
+
sdp: offer.sdp,
|
|
368
|
+
createdAt: offer.createdAt,
|
|
369
|
+
expiresAt: offer.expiresAt,
|
|
370
|
+
})),
|
|
371
|
+
createdAt: result.service.createdAt,
|
|
372
|
+
expiresAt: result.service.expiresAt,
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Delete a service
|
|
378
|
+
*/
|
|
379
|
+
async deleteService(params, message, signature, publicKey, storage, config) {
|
|
380
|
+
const { serviceFqn } = params;
|
|
381
|
+
const username = extractUsername(message);
|
|
382
|
+
|
|
383
|
+
if (!username) {
|
|
384
|
+
throw new Error('Username required');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Verify authentication
|
|
388
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
389
|
+
if (!auth.valid) {
|
|
390
|
+
throw new Error(auth.error);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const parsed = parseServiceFqn(serviceFqn);
|
|
394
|
+
if (!parsed || !parsed.username) {
|
|
395
|
+
throw new Error('Service FQN must include username');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const service = await storage.getServiceByFqn(serviceFqn);
|
|
399
|
+
if (!service) {
|
|
400
|
+
throw new Error('Service not found');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const deleted = await storage.deleteService(service.id, username);
|
|
404
|
+
if (!deleted) {
|
|
405
|
+
throw new Error('Service not found or not owned by this username');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { success: true };
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Answer an offer
|
|
413
|
+
*/
|
|
414
|
+
async answerOffer(params, message, signature, publicKey, storage, config) {
|
|
415
|
+
const { serviceFqn, offerId, sdp } = params;
|
|
416
|
+
const username = extractUsername(message);
|
|
417
|
+
|
|
418
|
+
if (!username) {
|
|
419
|
+
throw new Error('Username required');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Verify authentication
|
|
423
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
424
|
+
if (!auth.valid) {
|
|
425
|
+
throw new Error(auth.error);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!sdp || typeof sdp !== 'string' || sdp.length === 0) {
|
|
429
|
+
throw new Error('Invalid SDP');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (sdp.length > 64 * 1024) {
|
|
433
|
+
throw new Error('SDP too large (max 64KB)');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const offer = await storage.getOfferById(offerId);
|
|
437
|
+
if (!offer) {
|
|
438
|
+
throw new Error('Offer not found');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (offer.answererUsername) {
|
|
442
|
+
throw new Error('Offer already answered');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
await storage.answerOffer(offerId, username, sdp);
|
|
446
|
+
|
|
447
|
+
return { success: true, offerId };
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get answer for an offer
|
|
452
|
+
*/
|
|
453
|
+
async getOfferAnswer(params, message, signature, publicKey, storage, config) {
|
|
454
|
+
const { serviceFqn, offerId } = params;
|
|
455
|
+
const username = extractUsername(message);
|
|
456
|
+
|
|
457
|
+
if (!username) {
|
|
458
|
+
throw new Error('Username required');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Verify authentication
|
|
462
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
463
|
+
if (!auth.valid) {
|
|
464
|
+
throw new Error(auth.error);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const offer = await storage.getOfferById(offerId);
|
|
468
|
+
if (!offer) {
|
|
469
|
+
throw new Error('Offer not found');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (offer.username !== username) {
|
|
473
|
+
throw new Error('Not authorized to access this offer');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!offer.answererUsername || !offer.answerSdp) {
|
|
477
|
+
throw new Error('Offer not yet answered');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
sdp: offer.answerSdp,
|
|
482
|
+
offerId: offer.id,
|
|
483
|
+
answererId: offer.answererUsername,
|
|
484
|
+
answeredAt: offer.answeredAt,
|
|
485
|
+
};
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Combined polling for answers and ICE candidates
|
|
490
|
+
*/
|
|
491
|
+
async poll(params, message, signature, publicKey, storage, config) {
|
|
492
|
+
const { since } = params;
|
|
493
|
+
const username = extractUsername(message);
|
|
494
|
+
|
|
495
|
+
if (!username) {
|
|
496
|
+
throw new Error('Username required');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Verify authentication
|
|
500
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
501
|
+
if (!auth.valid) {
|
|
502
|
+
throw new Error(auth.error);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const sinceTimestamp = since || 0;
|
|
506
|
+
|
|
507
|
+
// Get all answered offers
|
|
508
|
+
const answeredOffers = await storage.getAnsweredOffers(username);
|
|
509
|
+
const filteredAnswers = answeredOffers.filter(
|
|
510
|
+
(offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// Get all user's offers
|
|
514
|
+
const allOffers = await storage.getOffersByUsername(username);
|
|
515
|
+
|
|
516
|
+
// For each offer, get ICE candidates from both sides
|
|
517
|
+
const iceCandidatesByOffer: Record<string, any[]> = {};
|
|
518
|
+
|
|
519
|
+
for (const offer of allOffers) {
|
|
520
|
+
const offererCandidates = await storage.getIceCandidates(
|
|
521
|
+
offer.id,
|
|
522
|
+
'offerer',
|
|
523
|
+
sinceTimestamp
|
|
524
|
+
);
|
|
525
|
+
const answererCandidates = await storage.getIceCandidates(
|
|
526
|
+
offer.id,
|
|
527
|
+
'answerer',
|
|
528
|
+
sinceTimestamp
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const allCandidates = [
|
|
532
|
+
...offererCandidates.map((c: any) => ({
|
|
533
|
+
...c,
|
|
534
|
+
role: 'offerer' as const,
|
|
535
|
+
})),
|
|
536
|
+
...answererCandidates.map((c: any) => ({
|
|
537
|
+
...c,
|
|
538
|
+
role: 'answerer' as const,
|
|
539
|
+
})),
|
|
540
|
+
];
|
|
541
|
+
|
|
542
|
+
if (allCandidates.length > 0) {
|
|
543
|
+
const isOfferer = offer.username === username;
|
|
544
|
+
const filtered = allCandidates.filter((c) =>
|
|
545
|
+
isOfferer ? c.role === 'answerer' : c.role === 'offerer'
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
if (filtered.length > 0) {
|
|
549
|
+
iceCandidatesByOffer[offer.id] = filtered;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
answers: filteredAnswers.map((offer) => ({
|
|
556
|
+
offerId: offer.id,
|
|
557
|
+
serviceId: offer.serviceId,
|
|
558
|
+
answererId: offer.answererUsername,
|
|
559
|
+
sdp: offer.answerSdp,
|
|
560
|
+
answeredAt: offer.answeredAt,
|
|
561
|
+
})),
|
|
562
|
+
iceCandidates: iceCandidatesByOffer,
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Add ICE candidates
|
|
568
|
+
*/
|
|
569
|
+
async addIceCandidates(params, message, signature, publicKey, storage, config) {
|
|
570
|
+
const { serviceFqn, offerId, candidates } = params;
|
|
571
|
+
const username = extractUsername(message);
|
|
572
|
+
|
|
573
|
+
if (!username) {
|
|
574
|
+
throw new Error('Username required');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Verify authentication
|
|
578
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
579
|
+
if (!auth.valid) {
|
|
580
|
+
throw new Error(auth.error);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
584
|
+
throw new Error('Missing or invalid required parameter: candidates');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Validate each candidate is an object (don't enforce structure per CLAUDE.md)
|
|
588
|
+
candidates.forEach((candidate, index) => {
|
|
589
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
590
|
+
throw new Error(`Invalid candidate at index ${index}: must be an object`);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const offer = await storage.getOfferById(offerId);
|
|
595
|
+
if (!offer) {
|
|
596
|
+
throw new Error('Offer not found');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const role = offer.username === username ? 'offerer' : 'answerer';
|
|
600
|
+
const count = await storage.addIceCandidates(
|
|
601
|
+
offerId,
|
|
602
|
+
username,
|
|
603
|
+
role,
|
|
604
|
+
candidates
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
return { count, offerId };
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Get ICE candidates
|
|
612
|
+
*/
|
|
613
|
+
async getIceCandidates(params, message, signature, publicKey, storage, config) {
|
|
614
|
+
const { serviceFqn, offerId, since } = params;
|
|
615
|
+
const username = extractUsername(message);
|
|
616
|
+
|
|
617
|
+
if (!username) {
|
|
618
|
+
throw new Error('Username required');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Verify authentication
|
|
622
|
+
const auth = await verifyAuth(username, message, signature, publicKey, storage);
|
|
623
|
+
if (!auth.valid) {
|
|
624
|
+
throw new Error(auth.error);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const sinceTimestamp = since || 0;
|
|
628
|
+
|
|
629
|
+
const offer = await storage.getOfferById(offerId);
|
|
630
|
+
if (!offer) {
|
|
631
|
+
throw new Error('Offer not found');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const isOfferer = offer.username === username;
|
|
635
|
+
const role = isOfferer ? 'answerer' : 'offerer';
|
|
636
|
+
|
|
637
|
+
const candidates = await storage.getIceCandidates(
|
|
638
|
+
offerId,
|
|
639
|
+
role,
|
|
640
|
+
sinceTimestamp
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
candidates: candidates.map((c: any) => ({
|
|
645
|
+
candidate: c.candidate,
|
|
646
|
+
createdAt: c.createdAt,
|
|
647
|
+
})),
|
|
648
|
+
offerId,
|
|
649
|
+
};
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Handle RPC batch request
|
|
655
|
+
*/
|
|
656
|
+
export async function handleRpc(
|
|
657
|
+
requests: RpcRequest[],
|
|
658
|
+
storage: Storage,
|
|
659
|
+
config: Config
|
|
660
|
+
): Promise<RpcResponse[]> {
|
|
661
|
+
const responses: RpcResponse[] = [];
|
|
662
|
+
|
|
663
|
+
for (const request of requests) {
|
|
664
|
+
try {
|
|
665
|
+
const { method, message, signature, publicKey, params } = request;
|
|
666
|
+
|
|
667
|
+
// Validate request
|
|
668
|
+
if (!method || typeof method !== 'string') {
|
|
669
|
+
responses.push({
|
|
670
|
+
success: false,
|
|
671
|
+
error: 'Missing or invalid method',
|
|
672
|
+
});
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (!message || typeof message !== 'string') {
|
|
677
|
+
responses.push({
|
|
678
|
+
success: false,
|
|
679
|
+
error: 'Missing or invalid message',
|
|
680
|
+
});
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (!signature || typeof signature !== 'string') {
|
|
685
|
+
responses.push({
|
|
686
|
+
success: false,
|
|
687
|
+
error: 'Missing or invalid signature',
|
|
688
|
+
});
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Get handler
|
|
693
|
+
const handler = handlers[method];
|
|
694
|
+
if (!handler) {
|
|
695
|
+
responses.push({
|
|
696
|
+
success: false,
|
|
697
|
+
error: `Unknown method: ${method}`,
|
|
698
|
+
});
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
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
|
+
} catch (err) {
|
|
717
|
+
responses.push({
|
|
718
|
+
success: false,
|
|
719
|
+
error: (err as Error).message || 'Internal server error',
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return responses;
|
|
725
|
+
}
|