@xtr-dev/rondevu-server 0.5.0 → 0.5.6

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