@xtr-dev/rondevu-server 0.5.0 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,58 +1,55 @@
1
1
  import Database from 'better-sqlite3';
2
- import { randomUUID } from 'node:crypto';
3
2
  import {
4
3
  Storage,
5
4
  Offer,
6
5
  IceCandidate,
7
6
  CreateOfferRequest,
8
- Username,
9
- ClaimUsernameRequest,
10
- Service,
11
- CreateServiceRequest,
7
+ Credential,
8
+ GenerateCredentialsRequest,
12
9
  } from './types.ts';
13
10
  import { generateOfferHash } from './hash-id.ts';
14
- import { parseServiceFqn } from '../crypto.ts';
15
11
 
16
12
  const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
17
13
 
18
14
  /**
19
- * SQLite storage adapter for rondevu DNS-like system
15
+ * SQLite storage adapter for rondevu signaling system
20
16
  * Supports both file-based and in-memory databases
21
17
  */
22
18
  export class SQLiteStorage implements Storage {
23
19
  private db: Database.Database;
20
+ private masterEncryptionKey: string;
24
21
 
25
22
  /**
26
23
  * Creates a new SQLite storage instance
27
24
  * @param path Path to SQLite database file, or ':memory:' for in-memory database
25
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets (32 bytes)
28
26
  */
29
- constructor(path: string = ':memory:') {
27
+ constructor(path: string = ':memory:', masterEncryptionKey: string) {
30
28
  this.db = new Database(path);
29
+ this.masterEncryptionKey = masterEncryptionKey;
31
30
  this.initializeDatabase();
32
31
  }
33
32
 
34
33
  /**
35
- * Initializes database schema with username and service-based structure
34
+ * Initializes database schema with tags-based offers
36
35
  */
37
36
  private initializeDatabase(): void {
38
37
  this.db.exec(`
39
- -- WebRTC signaling offers
38
+ -- WebRTC signaling offers with tags
40
39
  CREATE TABLE IF NOT EXISTS offers (
41
40
  id TEXT PRIMARY KEY,
42
41
  username TEXT NOT NULL,
43
- service_id TEXT,
42
+ tags TEXT NOT NULL,
44
43
  sdp TEXT NOT NULL,
45
44
  created_at INTEGER NOT NULL,
46
45
  expires_at INTEGER NOT NULL,
47
46
  last_seen INTEGER NOT NULL,
48
47
  answerer_username TEXT,
49
48
  answer_sdp TEXT,
50
- answered_at INTEGER,
51
- FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
49
+ answered_at INTEGER
52
50
  );
53
51
 
54
52
  CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
55
- CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
56
53
  CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
57
54
  CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
58
55
  CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
@@ -72,37 +69,35 @@ export class SQLiteStorage implements Storage {
72
69
  CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
73
70
  CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
74
71
 
75
- -- Usernames table
76
- CREATE TABLE IF NOT EXISTS usernames (
77
- username TEXT PRIMARY KEY,
78
- public_key TEXT NOT NULL UNIQUE,
79
- claimed_at INTEGER NOT NULL,
72
+ -- Credentials table (replaces usernames with simpler name + secret auth)
73
+ CREATE TABLE IF NOT EXISTS credentials (
74
+ name TEXT PRIMARY KEY,
75
+ secret TEXT NOT NULL UNIQUE,
76
+ created_at INTEGER NOT NULL,
80
77
  expires_at INTEGER NOT NULL,
81
78
  last_used INTEGER NOT NULL,
82
- metadata TEXT,
83
- CHECK(length(username) >= 3 AND length(username) <= 32)
79
+ CHECK(length(name) >= 3 AND length(name) <= 32)
84
80
  );
85
81
 
86
- CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
87
- CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
82
+ CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at);
83
+ CREATE INDEX IF NOT EXISTS idx_credentials_secret ON credentials(secret);
88
84
 
89
- -- Services table (new schema with extracted fields for discovery)
90
- CREATE TABLE IF NOT EXISTS services (
91
- id TEXT PRIMARY KEY,
92
- service_fqn TEXT NOT NULL,
93
- service_name TEXT NOT NULL,
94
- version TEXT NOT NULL,
95
- username TEXT NOT NULL,
96
- created_at INTEGER NOT NULL,
97
- expires_at INTEGER NOT NULL,
98
- FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
99
- UNIQUE(service_fqn)
85
+ -- Rate limits table (for distributed rate limiting)
86
+ CREATE TABLE IF NOT EXISTS rate_limits (
87
+ identifier TEXT PRIMARY KEY,
88
+ count INTEGER NOT NULL,
89
+ reset_time INTEGER NOT NULL
100
90
  );
101
91
 
102
- CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
103
- CREATE INDEX IF NOT EXISTS idx_services_discovery ON services(service_name, version);
104
- CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
105
- CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
92
+ CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time);
93
+
94
+ -- Nonces table (for replay attack prevention)
95
+ CREATE TABLE IF NOT EXISTS nonces (
96
+ nonce_key TEXT PRIMARY KEY,
97
+ expires_at INTEGER NOT NULL
98
+ );
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at);
106
101
  `);
107
102
 
108
103
  // Enable foreign keys
@@ -125,18 +120,18 @@ export class SQLiteStorage implements Storage {
125
120
  // Use transaction for atomic creation
126
121
  const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
127
122
  const offerStmt = this.db.prepare(`
128
- INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen)
123
+ INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
129
124
  VALUES (?, ?, ?, ?, ?, ?, ?)
130
125
  `);
131
126
 
132
127
  for (const offer of offersWithIds) {
133
128
  const now = Date.now();
134
129
 
135
- // Insert offer
130
+ // Insert offer with JSON-serialized tags
136
131
  offerStmt.run(
137
132
  offer.id,
138
133
  offer.username,
139
- offer.serviceId || null,
134
+ JSON.stringify(offer.tags),
140
135
  offer.sdp,
141
136
  now,
142
137
  offer.expiresAt,
@@ -146,8 +141,7 @@ export class SQLiteStorage implements Storage {
146
141
  created.push({
147
142
  id: offer.id,
148
143
  username: offer.username,
149
- serviceId: offer.serviceId || undefined,
150
- serviceFqn: offer.serviceFqn,
144
+ tags: offer.tags,
151
145
  sdp: offer.sdp,
152
146
  createdAt: now,
153
147
  expiresAt: offer.expiresAt,
@@ -255,6 +249,88 @@ export class SQLiteStorage implements Storage {
255
249
  return rows.map(row => this.rowToOffer(row));
256
250
  }
257
251
 
252
+ async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
253
+ const stmt = this.db.prepare(`
254
+ SELECT * FROM offers
255
+ WHERE answerer_username = ? AND expires_at > ?
256
+ ORDER BY answered_at DESC
257
+ `);
258
+
259
+ const rows = stmt.all(answererUsername, Date.now()) as any[];
260
+ return rows.map(row => this.rowToOffer(row));
261
+ }
262
+
263
+ // ===== Discovery =====
264
+
265
+ async discoverOffers(
266
+ tags: string[],
267
+ excludeUsername: string | null,
268
+ limit: number,
269
+ offset: number
270
+ ): Promise<Offer[]> {
271
+ if (tags.length === 0) {
272
+ return [];
273
+ }
274
+
275
+ // Build query with JSON tag matching (OR logic)
276
+ // SQLite: Use json_each() to expand tags array and check if any tag matches
277
+ const placeholders = tags.map(() => '?').join(',');
278
+
279
+ let query = `
280
+ SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
281
+ WHERE t.value IN (${placeholders})
282
+ AND o.expires_at > ?
283
+ AND o.answerer_username IS NULL
284
+ `;
285
+
286
+ const params: any[] = [...tags, Date.now()];
287
+
288
+ if (excludeUsername) {
289
+ query += ' AND o.username != ?';
290
+ params.push(excludeUsername);
291
+ }
292
+
293
+ query += ' ORDER BY o.created_at DESC LIMIT ? OFFSET ?';
294
+ params.push(limit, offset);
295
+
296
+ const stmt = this.db.prepare(query);
297
+ const rows = stmt.all(...params) as any[];
298
+ return rows.map(row => this.rowToOffer(row));
299
+ }
300
+
301
+ async getRandomOffer(
302
+ tags: string[],
303
+ excludeUsername: string | null
304
+ ): Promise<Offer | null> {
305
+ if (tags.length === 0) {
306
+ return null;
307
+ }
308
+
309
+ // Build query with JSON tag matching (OR logic)
310
+ const placeholders = tags.map(() => '?').join(',');
311
+
312
+ let query = `
313
+ SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
314
+ WHERE t.value IN (${placeholders})
315
+ AND o.expires_at > ?
316
+ AND o.answerer_username IS NULL
317
+ `;
318
+
319
+ const params: any[] = [...tags, Date.now()];
320
+
321
+ if (excludeUsername) {
322
+ query += ' AND o.username != ?';
323
+ params.push(excludeUsername);
324
+ }
325
+
326
+ query += ' ORDER BY RANDOM() LIMIT 1';
327
+
328
+ const stmt = this.db.prepare(query);
329
+ const row = stmt.get(...params) as any;
330
+
331
+ return row ? this.rowToOffer(row) : null;
332
+ }
333
+
258
334
  // ===== ICE Candidate Management =====
259
335
 
260
336
  async addIceCandidates(
@@ -317,273 +393,247 @@ export class SQLiteStorage implements Storage {
317
393
  }));
318
394
  }
319
395
 
320
- // ===== Username Management =====
321
-
322
- async claimUsername(request: ClaimUsernameRequest): Promise<Username> {
323
- const now = Date.now();
324
- const expiresAt = now + YEAR_IN_MS;
396
+ async getIceCandidatesForMultipleOffers(
397
+ offerIds: string[],
398
+ username: string,
399
+ since?: number
400
+ ): Promise<Map<string, IceCandidate[]>> {
401
+ const result = new Map<string, IceCandidate[]>();
325
402
 
326
- // Try to insert or update
327
- const stmt = this.db.prepare(`
328
- INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata)
329
- VALUES (?, ?, ?, ?, ?, NULL)
330
- ON CONFLICT(username) DO UPDATE SET
331
- expires_at = ?,
332
- last_used = ?
333
- WHERE public_key = ?
334
- `);
403
+ // Return empty map if no offer IDs provided
404
+ if (offerIds.length === 0) {
405
+ return result;
406
+ }
335
407
 
336
- const result = stmt.run(
337
- request.username,
338
- request.publicKey,
339
- now,
340
- expiresAt,
341
- now,
342
- expiresAt,
343
- now,
344
- request.publicKey
345
- );
408
+ // Validate array contains only strings
409
+ if (!Array.isArray(offerIds) || !offerIds.every(id => typeof id === 'string')) {
410
+ throw new Error('Invalid offer IDs: must be array of strings');
411
+ }
346
412
 
347
- if (result.changes === 0) {
348
- throw new Error('Username already claimed by different public key');
413
+ // Prevent DoS attacks from extremely large IN clauses
414
+ if (offerIds.length > 1000) {
415
+ throw new Error('Too many offer IDs (max 1000)');
349
416
  }
350
417
 
351
- return {
352
- username: request.username,
353
- publicKey: request.publicKey,
354
- claimedAt: now,
355
- expiresAt,
356
- lastUsed: now,
357
- };
358
- }
418
+ // Build query that fetches candidates from the OTHER peer only
419
+ // For each offer, determine if user is offerer or answerer and get opposite role
420
+ const placeholders = offerIds.map(() => '?').join(',');
359
421
 
360
- async getUsername(username: string): Promise<Username | null> {
361
- const stmt = this.db.prepare(`
362
- SELECT * FROM usernames
363
- WHERE username = ? AND expires_at > ?
364
- `);
422
+ let query = `
423
+ SELECT ic.*, o.username as offer_username
424
+ FROM ice_candidates ic
425
+ INNER JOIN offers o ON o.id = ic.offer_id
426
+ WHERE ic.offer_id IN (${placeholders})
427
+ AND (
428
+ (o.username = ? AND ic.role = 'answerer')
429
+ OR (o.answerer_username = ? AND ic.role = 'offerer')
430
+ )
431
+ `;
365
432
 
366
- const row = stmt.get(username, Date.now()) as any;
433
+ const params: any[] = [...offerIds, username, username];
367
434
 
368
- if (!row) {
369
- return null;
435
+ if (since !== undefined) {
436
+ query += ' AND ic.created_at > ?';
437
+ params.push(since);
370
438
  }
371
439
 
372
- return {
373
- username: row.username,
374
- publicKey: row.public_key,
375
- claimedAt: row.claimed_at,
376
- expiresAt: row.expires_at,
377
- lastUsed: row.last_used,
378
- metadata: row.metadata || undefined,
379
- };
380
- }
440
+ query += ' ORDER BY ic.created_at ASC';
381
441
 
382
- async touchUsername(username: string): Promise<boolean> {
383
- const now = Date.now();
384
- const expiresAt = now + YEAR_IN_MS;
442
+ const stmt = this.db.prepare(query);
443
+ const rows = stmt.all(...params) as any[];
385
444
 
386
- const stmt = this.db.prepare(`
387
- UPDATE usernames
388
- SET last_used = ?, expires_at = ?
389
- WHERE username = ? AND expires_at > ?
390
- `);
445
+ // Group candidates by offer_id
446
+ for (const row of rows) {
447
+ const candidate: IceCandidate = {
448
+ id: row.id,
449
+ offerId: row.offer_id,
450
+ username: row.username,
451
+ role: row.role,
452
+ candidate: JSON.parse(row.candidate),
453
+ createdAt: row.created_at,
454
+ };
391
455
 
392
- const result = stmt.run(now, expiresAt, username, now);
393
- return result.changes > 0;
394
- }
456
+ if (!result.has(row.offer_id)) {
457
+ result.set(row.offer_id, []);
458
+ }
459
+ result.get(row.offer_id)!.push(candidate);
460
+ }
395
461
 
396
- async deleteExpiredUsernames(now: number): Promise<number> {
397
- const stmt = this.db.prepare('DELETE FROM usernames WHERE expires_at < ?');
398
- const result = stmt.run(now);
399
- return result.changes;
462
+ return result;
400
463
  }
401
464
 
402
- // ===== Service Management =====
465
+ // ===== Credential Management =====
403
466
 
404
- async createService(request: CreateServiceRequest): Promise<{
405
- service: Service;
406
- offers: Offer[];
407
- }> {
408
- const serviceId = randomUUID();
467
+ async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
409
468
  const now = Date.now();
469
+ const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
410
470
 
411
- // Parse FQN to extract components
412
- const parsed = parseServiceFqn(request.serviceFqn);
413
- if (!parsed) {
414
- throw new Error(`Invalid service FQN: ${request.serviceFqn}`);
415
- }
416
- if (!parsed.username) {
417
- throw new Error(`Service FQN must include username: ${request.serviceFqn}`);
418
- }
471
+ const { generateCredentialName, generateSecret } = await import('../crypto.ts');
419
472
 
420
- const { serviceName, version, username } = parsed;
421
-
422
- const transaction = this.db.transaction(() => {
423
- // Delete existing service with same (service_name, version, username) and its related offers (upsert behavior)
424
- const existingService = this.db.prepare(`
425
- SELECT id FROM services
426
- WHERE service_name = ? AND version = ? AND username = ?
427
- `).get(serviceName, version, username) as any;
428
-
429
- if (existingService) {
430
- // Delete related offers first (no FK cascade from offers to services)
431
- this.db.prepare(`
432
- DELETE FROM offers WHERE service_id = ?
433
- `).run(existingService.id);
434
-
435
- // Delete the service
436
- this.db.prepare(`
437
- DELETE FROM services WHERE id = ?
438
- `).run(existingService.id);
473
+ let name: string;
474
+
475
+ if (request.name) {
476
+ // User requested specific username - check if available
477
+ const existing = this.db.prepare(`
478
+ SELECT name FROM credentials WHERE name = ?
479
+ `).get(request.name);
480
+
481
+ if (existing) {
482
+ throw new Error('Username already taken');
439
483
  }
440
484
 
441
- // Insert new service with extracted fields
442
- this.db.prepare(`
443
- INSERT INTO services (id, service_fqn, service_name, version, username, created_at, expires_at)
444
- VALUES (?, ?, ?, ?, ?, ?, ?)
445
- `).run(
446
- serviceId,
447
- request.serviceFqn,
448
- serviceName,
449
- version,
450
- username,
451
- now,
452
- request.expiresAt
453
- );
485
+ name = request.name;
486
+ } else {
487
+ // Generate random name - retry until unique
488
+ let attempts = 0;
489
+ const maxAttempts = 100;
454
490
 
455
- // Touch username to extend expiry (inline logic)
456
- const expiresAt = now + YEAR_IN_MS;
457
- this.db.prepare(`
458
- UPDATE usernames
459
- SET last_used = ?, expires_at = ?
460
- WHERE username = ? AND expires_at > ?
461
- `).run(now, expiresAt, username, now);
462
- });
491
+ while (attempts < maxAttempts) {
492
+ name = generateCredentialName();
463
493
 
464
- transaction();
494
+ const existing = this.db.prepare(`
495
+ SELECT name FROM credentials WHERE name = ?
496
+ `).get(name);
465
497
 
466
- // Create offers with serviceId (after transaction)
467
- const offerRequests = request.offers.map(offer => ({
468
- ...offer,
469
- serviceId,
470
- }));
471
- const offers = await this.createOffers(offerRequests);
498
+ if (!existing) {
499
+ break;
500
+ }
472
501
 
473
- return {
474
- service: {
475
- id: serviceId,
476
- serviceFqn: request.serviceFqn,
477
- serviceName,
478
- version,
479
- username,
480
- createdAt: now,
481
- expiresAt: request.expiresAt,
482
- },
483
- offers,
484
- };
485
- }
502
+ attempts++;
503
+ }
486
504
 
487
- async getOffersForService(serviceId: string): Promise<Offer[]> {
488
- const stmt = this.db.prepare(`
489
- SELECT * FROM offers
490
- WHERE service_id = ? AND expires_at > ?
491
- ORDER BY created_at ASC
492
- `);
505
+ if (attempts >= maxAttempts) {
506
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
507
+ }
508
+ }
493
509
 
494
- const rows = stmt.all(serviceId, Date.now()) as any[];
495
- return rows.map(row => this.rowToOffer(row));
496
- }
510
+ const secret = generateSecret();
511
+
512
+ // Encrypt secret before storing (AES-256-GCM)
513
+ const { encryptSecret } = await import('../crypto.ts');
514
+ const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
497
515
 
498
- async getServiceById(serviceId: string): Promise<Service | null> {
516
+ // Insert credential with encrypted secret
499
517
  const stmt = this.db.prepare(`
500
- SELECT * FROM services
501
- WHERE id = ? AND expires_at > ?
518
+ INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
519
+ VALUES (?, ?, ?, ?, ?)
502
520
  `);
503
521
 
504
- const row = stmt.get(serviceId, Date.now()) as any;
505
-
506
- if (!row) {
507
- return null;
508
- }
522
+ stmt.run(name!, encryptedSecret, now, expiresAt, now);
509
523
 
510
- return this.rowToService(row);
524
+ // Return plaintext secret to user (only time they'll see it)
525
+ return {
526
+ name: name!,
527
+ secret, // Return plaintext secret, not encrypted
528
+ createdAt: now,
529
+ expiresAt,
530
+ lastUsed: now,
531
+ };
511
532
  }
512
533
 
513
- async getServiceByFqn(serviceFqn: string): Promise<Service | null> {
534
+ async getCredential(name: string): Promise<Credential | null> {
514
535
  const stmt = this.db.prepare(`
515
- SELECT * FROM services
516
- WHERE service_fqn = ? AND expires_at > ?
536
+ SELECT * FROM credentials
537
+ WHERE name = ? AND expires_at > ?
517
538
  `);
518
539
 
519
- const row = stmt.get(serviceFqn, Date.now()) as any;
540
+ const row = stmt.get(name, Date.now()) as any;
520
541
 
521
542
  if (!row) {
522
543
  return null;
523
544
  }
524
545
 
525
- return this.rowToService(row);
546
+ // Decrypt secret before returning
547
+ // If decryption fails (e.g., master key rotated), treat as credential not found
548
+ try {
549
+ const { decryptSecret } = await import('../crypto.ts');
550
+ const decryptedSecret = await decryptSecret(row.secret, this.masterEncryptionKey);
551
+
552
+ return {
553
+ name: row.name,
554
+ secret: decryptedSecret, // Return decrypted secret
555
+ createdAt: row.created_at,
556
+ expiresAt: row.expires_at,
557
+ lastUsed: row.last_used,
558
+ };
559
+ } catch (error) {
560
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
561
+ return null; // Treat as credential not found (fail-safe behavior)
562
+ }
526
563
  }
527
564
 
528
- async discoverServices(
529
- serviceName: string,
530
- version: string,
531
- limit: number,
532
- offset: number
533
- ): Promise<Service[]> {
534
- // Query for unique services with available offers
535
- // We join with offers and filter for available ones (answerer_username IS NULL)
565
+ async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
536
566
  const stmt = this.db.prepare(`
537
- SELECT DISTINCT s.* FROM services s
538
- INNER JOIN offers o ON o.service_id = s.id
539
- WHERE s.service_name = ?
540
- AND s.version = ?
541
- AND s.expires_at > ?
542
- AND o.answerer_username IS NULL
543
- AND o.expires_at > ?
544
- ORDER BY s.created_at DESC
545
- LIMIT ? OFFSET ?
567
+ UPDATE credentials
568
+ SET last_used = ?, expires_at = ?
569
+ WHERE name = ?
546
570
  `);
547
571
 
548
- const rows = stmt.all(serviceName, version, Date.now(), Date.now(), limit, offset) as any[];
549
- return rows.map(row => this.rowToService(row));
572
+ stmt.run(lastUsed, expiresAt, name);
550
573
  }
551
574
 
552
- async getRandomService(serviceName: string, version: string): Promise<Service | null> {
553
- // Get a random service with an available offer
554
- const stmt = this.db.prepare(`
555
- SELECT s.* FROM services s
556
- INNER JOIN offers o ON o.service_id = s.id
557
- WHERE s.service_name = ?
558
- AND s.version = ?
559
- AND s.expires_at > ?
560
- AND o.answerer_username IS NULL
561
- AND o.expires_at > ?
562
- ORDER BY RANDOM()
563
- LIMIT 1
564
- `);
575
+ async deleteExpiredCredentials(now: number): Promise<number> {
576
+ const stmt = this.db.prepare('DELETE FROM credentials WHERE expires_at < ?');
577
+ const result = stmt.run(now);
578
+ return result.changes;
579
+ }
565
580
 
566
- const row = stmt.get(serviceName, version, Date.now(), Date.now()) as any;
581
+ // ===== Rate Limiting =====
567
582
 
568
- if (!row) {
569
- return null;
570
- }
583
+ async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
584
+ const now = Date.now();
585
+ const resetTime = now + windowMs;
586
+
587
+ // Atomic UPSERT: Insert or increment count, reset if expired
588
+ // This prevents TOCTOU race conditions by doing check+increment in single operation
589
+ const result = this.db.prepare(`
590
+ INSERT INTO rate_limits (identifier, count, reset_time)
591
+ VALUES (?, 1, ?)
592
+ ON CONFLICT(identifier) DO UPDATE SET
593
+ count = CASE
594
+ WHEN reset_time < ? THEN 1
595
+ ELSE count + 1
596
+ END,
597
+ reset_time = CASE
598
+ WHEN reset_time < ? THEN ?
599
+ ELSE reset_time
600
+ END
601
+ RETURNING count
602
+ `).get(identifier, resetTime, now, now, resetTime) as { count: number };
603
+
604
+ // Check if limit exceeded
605
+ return result.count <= limit;
606
+ }
571
607
 
572
- return this.rowToService(row);
608
+ async deleteExpiredRateLimits(now: number): Promise<number> {
609
+ const stmt = this.db.prepare('DELETE FROM rate_limits WHERE reset_time < ?');
610
+ const result = stmt.run(now);
611
+ return result.changes;
573
612
  }
574
613
 
575
- async deleteService(serviceId: string, username: string): Promise<boolean> {
576
- const stmt = this.db.prepare(`
577
- DELETE FROM services
578
- WHERE id = ? AND username = ?
579
- `);
614
+ // ===== Nonce Tracking (Replay Protection) =====
580
615
 
581
- const result = stmt.run(serviceId, username);
582
- return result.changes > 0;
616
+ async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
617
+ try {
618
+ // Atomic INSERT - if nonce already exists, this will fail with UNIQUE constraint
619
+ // This prevents replay attacks by ensuring each nonce is only used once
620
+ const stmt = this.db.prepare(`
621
+ INSERT INTO nonces (nonce_key, expires_at)
622
+ VALUES (?, ?)
623
+ `);
624
+ stmt.run(nonceKey, expiresAt);
625
+ return true; // Nonce is new, request allowed
626
+ } catch (error: any) {
627
+ // SQLITE_CONSTRAINT error code for UNIQUE constraint violation
628
+ if (error?.code === 'SQLITE_CONSTRAINT') {
629
+ return false; // Nonce already used, replay attack detected
630
+ }
631
+ throw error; // Other errors should propagate
632
+ }
583
633
  }
584
634
 
585
- async deleteExpiredServices(now: number): Promise<number> {
586
- const stmt = this.db.prepare('DELETE FROM services WHERE expires_at < ?');
635
+ async deleteExpiredNonces(now: number): Promise<number> {
636
+ const stmt = this.db.prepare('DELETE FROM nonces WHERE expires_at < ?');
587
637
  const result = stmt.run(now);
588
638
  return result.changes;
589
639
  }
@@ -601,8 +651,7 @@ export class SQLiteStorage implements Storage {
601
651
  return {
602
652
  id: row.id,
603
653
  username: row.username,
604
- serviceId: row.service_id || undefined,
605
- serviceFqn: row.service_fqn || undefined,
654
+ tags: JSON.parse(row.tags),
606
655
  sdp: row.sdp,
607
656
  createdAt: row.created_at,
608
657
  expiresAt: row.expires_at,
@@ -612,19 +661,4 @@ export class SQLiteStorage implements Storage {
612
661
  answeredAt: row.answered_at || undefined,
613
662
  };
614
663
  }
615
-
616
- /**
617
- * Helper method to convert database row to Service object
618
- */
619
- private rowToService(row: any): Service {
620
- return {
621
- id: row.id,
622
- serviceFqn: row.service_fqn,
623
- serviceName: row.service_name,
624
- version: row.version,
625
- username: row.username,
626
- createdAt: row.created_at,
627
- expiresAt: row.expires_at,
628
- };
629
- }
630
664
  }