@xtr-dev/rondevu-server 0.5.1 → 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.
@@ -0,0 +1,69 @@
1
+ import { Storage } from './types.ts';
2
+
3
+ /**
4
+ * Supported storage backend types
5
+ */
6
+ export type StorageType = 'memory' | 'sqlite' | 'mysql' | 'postgres';
7
+
8
+ /**
9
+ * Configuration for creating a storage backend
10
+ */
11
+ export interface StorageConfig {
12
+ type: StorageType;
13
+ /** Master encryption key for secrets (64-char hex string) */
14
+ masterEncryptionKey: string;
15
+ /** SQLite database path (default: ':memory:') */
16
+ sqlitePath?: string;
17
+ /** Connection string for MySQL/PostgreSQL */
18
+ connectionString?: string;
19
+ /** Connection pool size for MySQL/PostgreSQL (default: 10) */
20
+ poolSize?: number;
21
+ }
22
+
23
+ /**
24
+ * Creates a storage backend based on configuration
25
+ * Uses dynamic imports to avoid loading unused dependencies
26
+ */
27
+ export async function createStorage(config: StorageConfig): Promise<Storage> {
28
+ switch (config.type) {
29
+ case 'memory': {
30
+ const { MemoryStorage } = await import('./memory.ts');
31
+ return new MemoryStorage(config.masterEncryptionKey);
32
+ }
33
+
34
+ case 'sqlite': {
35
+ const { SQLiteStorage } = await import('./sqlite.ts');
36
+ return new SQLiteStorage(
37
+ config.sqlitePath || ':memory:',
38
+ config.masterEncryptionKey
39
+ );
40
+ }
41
+
42
+ case 'mysql': {
43
+ if (!config.connectionString) {
44
+ throw new Error('MySQL storage requires DATABASE_URL connection string');
45
+ }
46
+ const { MySQLStorage } = await import('./mysql.ts');
47
+ return MySQLStorage.create(
48
+ config.connectionString,
49
+ config.masterEncryptionKey,
50
+ config.poolSize || 10
51
+ );
52
+ }
53
+
54
+ case 'postgres': {
55
+ if (!config.connectionString) {
56
+ throw new Error('PostgreSQL storage requires DATABASE_URL connection string');
57
+ }
58
+ const { PostgreSQLStorage } = await import('./postgres.ts');
59
+ return PostgreSQLStorage.create(
60
+ config.connectionString,
61
+ config.masterEncryptionKey,
62
+ config.poolSize || 10
63
+ );
64
+ }
65
+
66
+ default:
67
+ throw new Error(`Unsupported storage type: ${config.type}`);
68
+ }
69
+ }
@@ -1,21 +1,25 @@
1
1
  /**
2
- * Generates a content-based offer ID using SHA-256 hash
3
- * Creates deterministic IDs based on offer SDP content
4
- * PeerID is not included as it's inferred from authentication
2
+ * Generates a unique offer ID using SHA-256 hash
3
+ * Combines SDP content with timestamp and random bytes for uniqueness
5
4
  * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
6
5
  *
7
6
  * @param sdp - The WebRTC SDP offer
8
- * @returns SHA-256 hash of the SDP content
7
+ * @returns Unique SHA-256 hash ID
9
8
  */
10
9
  export async function generateOfferHash(sdp: string): Promise<string> {
11
- // Sanitize and normalize the offer content
12
- // Only include core offer content (not peerId - that's inferred from auth)
13
- const sanitizedOffer = {
14
- sdp
10
+ // Generate random bytes for uniqueness (8 bytes = 64 bits of randomness)
11
+ const randomBytes = crypto.getRandomValues(new Uint8Array(8));
12
+ const randomHex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('');
13
+
14
+ // Include SDP, timestamp, and random bytes for uniqueness
15
+ const hashInput = {
16
+ sdp,
17
+ timestamp: Date.now(),
18
+ nonce: randomHex
15
19
  };
16
20
 
17
21
  // Create non-prettified JSON string
18
- const jsonString = JSON.stringify(sanitizedOffer);
22
+ const jsonString = JSON.stringify(hashInput);
19
23
 
20
24
  // Convert string to Uint8Array for hashing
21
25
  const encoder = new TextEncoder();
@@ -0,0 +1,559 @@
1
+ import {
2
+ Storage,
3
+ Offer,
4
+ IceCandidate,
5
+ CreateOfferRequest,
6
+ Credential,
7
+ GenerateCredentialsRequest,
8
+ } from './types.ts';
9
+ import { generateOfferHash } from './hash-id.ts';
10
+
11
+ const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000;
12
+
13
+ interface RateLimit {
14
+ count: number;
15
+ resetTime: number;
16
+ }
17
+
18
+ interface NonceEntry {
19
+ expiresAt: number;
20
+ }
21
+
22
+ /**
23
+ * In-memory storage adapter for rondevu signaling system
24
+ * Data is not persisted - all data is lost on server restart
25
+ * Best for development, testing, or ephemeral deployments
26
+ */
27
+ export class MemoryStorage implements Storage {
28
+ private masterEncryptionKey: string;
29
+
30
+ // Primary storage
31
+ private credentials = new Map<string, Credential>();
32
+ private offers = new Map<string, Offer>();
33
+ private iceCandidates = new Map<string, IceCandidate[]>(); // offerId → candidates
34
+ private rateLimits = new Map<string, RateLimit>();
35
+ private nonces = new Map<string, NonceEntry>();
36
+
37
+ // Secondary indexes for efficient lookups
38
+ private offersByUsername = new Map<string, Set<string>>(); // username → offer IDs
39
+ private offersByTag = new Map<string, Set<string>>(); // tag → offer IDs
40
+ private offersByAnswerer = new Map<string, Set<string>>(); // answerer username → offer IDs
41
+
42
+ // Auto-increment counter for ICE candidates
43
+ private iceCandidateIdCounter = 0;
44
+
45
+ constructor(masterEncryptionKey: string) {
46
+ this.masterEncryptionKey = masterEncryptionKey;
47
+ }
48
+
49
+ // ===== Offer Management =====
50
+
51
+ async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
52
+ const created: Offer[] = [];
53
+ const now = Date.now();
54
+
55
+ for (const request of offers) {
56
+ const id = request.id || await generateOfferHash(request.sdp);
57
+
58
+ const offer: Offer = {
59
+ id,
60
+ username: request.username,
61
+ tags: request.tags,
62
+ sdp: request.sdp,
63
+ createdAt: now,
64
+ expiresAt: request.expiresAt,
65
+ lastSeen: now,
66
+ };
67
+
68
+ // Store offer
69
+ this.offers.set(id, offer);
70
+
71
+ // Update username index
72
+ if (!this.offersByUsername.has(request.username)) {
73
+ this.offersByUsername.set(request.username, new Set());
74
+ }
75
+ this.offersByUsername.get(request.username)!.add(id);
76
+
77
+ // Update tag indexes
78
+ for (const tag of request.tags) {
79
+ if (!this.offersByTag.has(tag)) {
80
+ this.offersByTag.set(tag, new Set());
81
+ }
82
+ this.offersByTag.get(tag)!.add(id);
83
+ }
84
+
85
+ created.push(offer);
86
+ }
87
+
88
+ return created;
89
+ }
90
+
91
+ async getOffersByUsername(username: string): Promise<Offer[]> {
92
+ const now = Date.now();
93
+ const offerIds = this.offersByUsername.get(username);
94
+ if (!offerIds) return [];
95
+
96
+ const offers: Offer[] = [];
97
+ for (const id of offerIds) {
98
+ const offer = this.offers.get(id);
99
+ if (offer && offer.expiresAt > now) {
100
+ offers.push(offer);
101
+ }
102
+ }
103
+
104
+ return offers.sort((a, b) => b.lastSeen - a.lastSeen);
105
+ }
106
+
107
+ async getOfferById(offerId: string): Promise<Offer | null> {
108
+ const offer = this.offers.get(offerId);
109
+ if (!offer || offer.expiresAt <= Date.now()) {
110
+ return null;
111
+ }
112
+ return offer;
113
+ }
114
+
115
+ async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
116
+ const offer = this.offers.get(offerId);
117
+ if (!offer || offer.username !== ownerUsername) {
118
+ return false;
119
+ }
120
+
121
+ this.removeOfferFromIndexes(offer);
122
+ this.offers.delete(offerId);
123
+ this.iceCandidates.delete(offerId);
124
+
125
+ return true;
126
+ }
127
+
128
+ async deleteExpiredOffers(now: number): Promise<number> {
129
+ let count = 0;
130
+
131
+ for (const [id, offer] of this.offers) {
132
+ if (offer.expiresAt < now) {
133
+ this.removeOfferFromIndexes(offer);
134
+ this.offers.delete(id);
135
+ this.iceCandidates.delete(id);
136
+ count++;
137
+ }
138
+ }
139
+
140
+ return count;
141
+ }
142
+
143
+ async answerOffer(
144
+ offerId: string,
145
+ answererUsername: string,
146
+ answerSdp: string
147
+ ): Promise<{ success: boolean; error?: string }> {
148
+ const offer = await this.getOfferById(offerId);
149
+
150
+ if (!offer) {
151
+ return { success: false, error: 'Offer not found or expired' };
152
+ }
153
+
154
+ if (offer.answererUsername) {
155
+ return { success: false, error: 'Offer already answered' };
156
+ }
157
+
158
+ // Update offer with answer
159
+ const now = Date.now();
160
+ offer.answererUsername = answererUsername;
161
+ offer.answerSdp = answerSdp;
162
+ offer.answeredAt = now;
163
+
164
+ // Update answerer index
165
+ if (!this.offersByAnswerer.has(answererUsername)) {
166
+ this.offersByAnswerer.set(answererUsername, new Set());
167
+ }
168
+ this.offersByAnswerer.get(answererUsername)!.add(offerId);
169
+
170
+ return { success: true };
171
+ }
172
+
173
+ async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
174
+ const now = Date.now();
175
+ const offerIds = this.offersByUsername.get(offererUsername);
176
+ if (!offerIds) return [];
177
+
178
+ const offers: Offer[] = [];
179
+ for (const id of offerIds) {
180
+ const offer = this.offers.get(id);
181
+ if (offer && offer.answererUsername && offer.expiresAt > now) {
182
+ offers.push(offer);
183
+ }
184
+ }
185
+
186
+ return offers.sort((a, b) => (b.answeredAt || 0) - (a.answeredAt || 0));
187
+ }
188
+
189
+ async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
190
+ const now = Date.now();
191
+ const offerIds = this.offersByAnswerer.get(answererUsername);
192
+ if (!offerIds) return [];
193
+
194
+ const offers: Offer[] = [];
195
+ for (const id of offerIds) {
196
+ const offer = this.offers.get(id);
197
+ if (offer && offer.expiresAt > now) {
198
+ offers.push(offer);
199
+ }
200
+ }
201
+
202
+ return offers.sort((a, b) => (b.answeredAt || 0) - (a.answeredAt || 0));
203
+ }
204
+
205
+ // ===== Discovery =====
206
+
207
+ async discoverOffers(
208
+ tags: string[],
209
+ excludeUsername: string | null,
210
+ limit: number,
211
+ offset: number
212
+ ): Promise<Offer[]> {
213
+ if (tags.length === 0) return [];
214
+
215
+ const now = Date.now();
216
+ const matchingOfferIds = new Set<string>();
217
+
218
+ // Find all offers matching any tag (OR logic)
219
+ for (const tag of tags) {
220
+ const offerIds = this.offersByTag.get(tag);
221
+ if (offerIds) {
222
+ for (const id of offerIds) {
223
+ matchingOfferIds.add(id);
224
+ }
225
+ }
226
+ }
227
+
228
+ // Filter and collect matching offers
229
+ const offers: Offer[] = [];
230
+ for (const id of matchingOfferIds) {
231
+ const offer = this.offers.get(id);
232
+ if (
233
+ offer &&
234
+ offer.expiresAt > now &&
235
+ !offer.answererUsername &&
236
+ (!excludeUsername || offer.username !== excludeUsername)
237
+ ) {
238
+ offers.push(offer);
239
+ }
240
+ }
241
+
242
+ // Sort by created_at descending and apply pagination
243
+ offers.sort((a, b) => b.createdAt - a.createdAt);
244
+ return offers.slice(offset, offset + limit);
245
+ }
246
+
247
+ async getRandomOffer(
248
+ tags: string[],
249
+ excludeUsername: string | null
250
+ ): Promise<Offer | null> {
251
+ if (tags.length === 0) return null;
252
+
253
+ const now = Date.now();
254
+ const matchingOffers: Offer[] = [];
255
+
256
+ // Find all offers matching any tag (OR logic)
257
+ const matchingOfferIds = new Set<string>();
258
+ for (const tag of tags) {
259
+ const offerIds = this.offersByTag.get(tag);
260
+ if (offerIds) {
261
+ for (const id of offerIds) {
262
+ matchingOfferIds.add(id);
263
+ }
264
+ }
265
+ }
266
+
267
+ // Collect matching offers
268
+ for (const id of matchingOfferIds) {
269
+ const offer = this.offers.get(id);
270
+ if (
271
+ offer &&
272
+ offer.expiresAt > now &&
273
+ !offer.answererUsername &&
274
+ (!excludeUsername || offer.username !== excludeUsername)
275
+ ) {
276
+ matchingOffers.push(offer);
277
+ }
278
+ }
279
+
280
+ if (matchingOffers.length === 0) return null;
281
+
282
+ // Return random offer
283
+ const randomIndex = Math.floor(Math.random() * matchingOffers.length);
284
+ return matchingOffers[randomIndex];
285
+ }
286
+
287
+ // ===== ICE Candidate Management =====
288
+
289
+ async addIceCandidates(
290
+ offerId: string,
291
+ username: string,
292
+ role: 'offerer' | 'answerer',
293
+ candidates: any[]
294
+ ): Promise<number> {
295
+ const baseTimestamp = Date.now();
296
+
297
+ if (!this.iceCandidates.has(offerId)) {
298
+ this.iceCandidates.set(offerId, []);
299
+ }
300
+
301
+ const candidateList = this.iceCandidates.get(offerId)!;
302
+
303
+ for (let i = 0; i < candidates.length; i++) {
304
+ const candidate: IceCandidate = {
305
+ id: ++this.iceCandidateIdCounter,
306
+ offerId,
307
+ username,
308
+ role,
309
+ candidate: candidates[i],
310
+ createdAt: baseTimestamp + i,
311
+ };
312
+ candidateList.push(candidate);
313
+ }
314
+
315
+ return candidates.length;
316
+ }
317
+
318
+ async getIceCandidates(
319
+ offerId: string,
320
+ targetRole: 'offerer' | 'answerer',
321
+ since?: number
322
+ ): Promise<IceCandidate[]> {
323
+ const candidates = this.iceCandidates.get(offerId) || [];
324
+
325
+ return candidates
326
+ .filter(c => c.role === targetRole && (since === undefined || c.createdAt > since))
327
+ .sort((a, b) => a.createdAt - b.createdAt);
328
+ }
329
+
330
+ async getIceCandidatesForMultipleOffers(
331
+ offerIds: string[],
332
+ username: string,
333
+ since?: number
334
+ ): Promise<Map<string, IceCandidate[]>> {
335
+ const result = new Map<string, IceCandidate[]>();
336
+
337
+ if (offerIds.length === 0) return result;
338
+ if (offerIds.length > 1000) {
339
+ throw new Error('Too many offer IDs (max 1000)');
340
+ }
341
+
342
+ for (const offerId of offerIds) {
343
+ const offer = this.offers.get(offerId);
344
+ if (!offer) continue;
345
+
346
+ const candidates = this.iceCandidates.get(offerId) || [];
347
+
348
+ // Determine which role's candidates to return
349
+ // If user is offerer, return answerer candidates and vice versa
350
+ const isOfferer = offer.username === username;
351
+ const isAnswerer = offer.answererUsername === username;
352
+
353
+ if (!isOfferer && !isAnswerer) continue;
354
+
355
+ const targetRole = isOfferer ? 'answerer' : 'offerer';
356
+
357
+ const filteredCandidates = candidates
358
+ .filter(c => c.role === targetRole && (since === undefined || c.createdAt > since))
359
+ .sort((a, b) => a.createdAt - b.createdAt);
360
+
361
+ if (filteredCandidates.length > 0) {
362
+ result.set(offerId, filteredCandidates);
363
+ }
364
+ }
365
+
366
+ return result;
367
+ }
368
+
369
+ // ===== Credential Management =====
370
+
371
+ async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
372
+ const now = Date.now();
373
+ const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
374
+
375
+ const { generateCredentialName, generateSecret, encryptSecret } = await import('../crypto.ts');
376
+
377
+ let name: string;
378
+
379
+ if (request.name) {
380
+ if (this.credentials.has(request.name)) {
381
+ throw new Error('Username already taken');
382
+ }
383
+ name = request.name;
384
+ } else {
385
+ let attempts = 0;
386
+ const maxAttempts = 100;
387
+
388
+ while (attempts < maxAttempts) {
389
+ name = generateCredentialName();
390
+ if (!this.credentials.has(name)) break;
391
+ attempts++;
392
+ }
393
+
394
+ if (attempts >= maxAttempts) {
395
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
396
+ }
397
+ }
398
+
399
+ const secret = generateSecret();
400
+
401
+ // Encrypt secret before storing
402
+ const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
403
+
404
+ const credential: Credential = {
405
+ name: name!,
406
+ secret: encryptedSecret,
407
+ createdAt: now,
408
+ expiresAt,
409
+ lastUsed: now,
410
+ };
411
+
412
+ this.credentials.set(name!, credential);
413
+
414
+ // Return plaintext secret to user
415
+ return {
416
+ ...credential,
417
+ secret, // Return plaintext, not encrypted
418
+ };
419
+ }
420
+
421
+ async getCredential(name: string): Promise<Credential | null> {
422
+ const credential = this.credentials.get(name);
423
+ if (!credential || credential.expiresAt <= Date.now()) {
424
+ return null;
425
+ }
426
+
427
+ try {
428
+ const { decryptSecret } = await import('../crypto.ts');
429
+ const decryptedSecret = await decryptSecret(credential.secret, this.masterEncryptionKey);
430
+
431
+ return {
432
+ ...credential,
433
+ secret: decryptedSecret,
434
+ };
435
+ } catch (error) {
436
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
437
+ return null;
438
+ }
439
+ }
440
+
441
+ async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
442
+ const credential = this.credentials.get(name);
443
+ if (credential) {
444
+ credential.lastUsed = lastUsed;
445
+ credential.expiresAt = expiresAt;
446
+ }
447
+ }
448
+
449
+ async deleteExpiredCredentials(now: number): Promise<number> {
450
+ let count = 0;
451
+ for (const [name, credential] of this.credentials) {
452
+ if (credential.expiresAt < now) {
453
+ this.credentials.delete(name);
454
+ count++;
455
+ }
456
+ }
457
+ return count;
458
+ }
459
+
460
+ // ===== Rate Limiting =====
461
+
462
+ async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
463
+ const now = Date.now();
464
+ const existing = this.rateLimits.get(identifier);
465
+
466
+ if (!existing || existing.resetTime < now) {
467
+ // New window or expired - reset count
468
+ this.rateLimits.set(identifier, {
469
+ count: 1,
470
+ resetTime: now + windowMs,
471
+ });
472
+ return true;
473
+ }
474
+
475
+ // Increment count in existing window
476
+ existing.count++;
477
+ return existing.count <= limit;
478
+ }
479
+
480
+ async deleteExpiredRateLimits(now: number): Promise<number> {
481
+ let count = 0;
482
+ for (const [identifier, rateLimit] of this.rateLimits) {
483
+ if (rateLimit.resetTime < now) {
484
+ this.rateLimits.delete(identifier);
485
+ count++;
486
+ }
487
+ }
488
+ return count;
489
+ }
490
+
491
+ // ===== Nonce Tracking (Replay Protection) =====
492
+
493
+ async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
494
+ if (this.nonces.has(nonceKey)) {
495
+ return false; // Nonce already used - replay attack
496
+ }
497
+
498
+ this.nonces.set(nonceKey, { expiresAt });
499
+ return true; // Nonce is new - allowed
500
+ }
501
+
502
+ async deleteExpiredNonces(now: number): Promise<number> {
503
+ let count = 0;
504
+ for (const [key, entry] of this.nonces) {
505
+ if (entry.expiresAt < now) {
506
+ this.nonces.delete(key);
507
+ count++;
508
+ }
509
+ }
510
+ return count;
511
+ }
512
+
513
+ async close(): Promise<void> {
514
+ // Clear all data
515
+ this.credentials.clear();
516
+ this.offers.clear();
517
+ this.iceCandidates.clear();
518
+ this.rateLimits.clear();
519
+ this.nonces.clear();
520
+ this.offersByUsername.clear();
521
+ this.offersByTag.clear();
522
+ this.offersByAnswerer.clear();
523
+ }
524
+
525
+ // ===== Helper Methods =====
526
+
527
+ private removeOfferFromIndexes(offer: Offer): void {
528
+ // Remove from username index
529
+ const usernameOffers = this.offersByUsername.get(offer.username);
530
+ if (usernameOffers) {
531
+ usernameOffers.delete(offer.id);
532
+ if (usernameOffers.size === 0) {
533
+ this.offersByUsername.delete(offer.username);
534
+ }
535
+ }
536
+
537
+ // Remove from tag indexes
538
+ for (const tag of offer.tags) {
539
+ const tagOffers = this.offersByTag.get(tag);
540
+ if (tagOffers) {
541
+ tagOffers.delete(offer.id);
542
+ if (tagOffers.size === 0) {
543
+ this.offersByTag.delete(tag);
544
+ }
545
+ }
546
+ }
547
+
548
+ // Remove from answerer index
549
+ if (offer.answererUsername) {
550
+ const answererOffers = this.offersByAnswerer.get(offer.answererUsername);
551
+ if (answererOffers) {
552
+ answererOffers.delete(offer.id);
553
+ if (answererOffers.size === 0) {
554
+ this.offersByAnswerer.delete(offer.answererUsername);
555
+ }
556
+ }
557
+ }
558
+ }
559
+ }