@xtr-dev/rondevu-server 0.5.18 → 0.5.22

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.
@@ -21,6 +21,7 @@ CREATE TABLE identities (
21
21
  CREATE INDEX idx_identities_expires ON identities(expires_at);
22
22
 
23
23
  -- Offers table (uses public_key instead of username)
24
+ -- Note: No foreign key to identities - auth is stateless (signature-based)
24
25
  CREATE TABLE offers (
25
26
  id TEXT PRIMARY KEY,
26
27
  public_key TEXT NOT NULL, -- Owner's Ed25519 public key
@@ -32,8 +33,7 @@ CREATE TABLE offers (
32
33
  answerer_public_key TEXT,
33
34
  answer_sdp TEXT,
34
35
  answered_at INTEGER,
35
- matched_tags TEXT, -- JSON array: tags the answerer searched for
36
- FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
36
+ matched_tags TEXT -- JSON array: tags the answerer searched for
37
37
  );
38
38
 
39
39
  CREATE INDEX idx_offers_public_key ON offers(public_key);
@@ -42,14 +42,14 @@ CREATE INDEX idx_offers_last_seen ON offers(last_seen);
42
42
  CREATE INDEX idx_offers_answerer ON offers(answerer_public_key);
43
43
 
44
44
  -- ICE candidates table (uses public_key instead of username)
45
+ -- Note: No foreign key - offers may be deleted before candidates are read
45
46
  CREATE TABLE ice_candidates (
46
47
  id INTEGER PRIMARY KEY AUTOINCREMENT,
47
48
  offer_id TEXT NOT NULL,
48
49
  public_key TEXT NOT NULL, -- Sender's Ed25519 public key
49
50
  role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
50
51
  candidate TEXT NOT NULL,
51
- created_at INTEGER NOT NULL,
52
- FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
52
+ created_at INTEGER NOT NULL
53
53
  );
54
54
 
55
55
  CREATE INDEX idx_ice_offer ON ice_candidates(offer_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-server",
3
- "version": "0.5.18",
3
+ "version": "0.5.22",
4
4
  "description": "DNS-like WebRTC signaling server with credential-based authentication and service discovery",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
package/src/rpc.ts CHANGED
@@ -128,6 +128,10 @@ export interface DiscoverParams {
128
128
  offset?: number;
129
129
  }
130
130
 
131
+ export interface CountOffersByTagsParams {
132
+ tags: string[];
133
+ }
134
+
131
135
  export interface PublishOfferParams {
132
136
  tags: string[];
133
137
  offers: Array<{ sdp: string }>;
@@ -300,6 +304,28 @@ const handlers: Record<string, RpcHandler> = {
300
304
  };
301
305
  },
302
306
 
307
+ /**
308
+ * Count available offers by tags
309
+ */
310
+ async countOffersByTags(params: CountOffersByTagsParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
311
+ const { tags } = params;
312
+
313
+ const tagsValidation = validateTags(tags);
314
+ if (!tagsValidation.valid) {
315
+ throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
316
+ }
317
+
318
+ const counts = await storage.countOffersByTags(tags);
319
+
320
+ // Convert Map to object for JSON serialization
321
+ const result: Record<string, number> = {};
322
+ for (const [tag, count] of counts) {
323
+ result[tag] = count;
324
+ }
325
+
326
+ return { counts: result };
327
+ },
328
+
303
329
  /**
304
330
  * Publish offers with tags
305
331
  */
@@ -540,6 +566,7 @@ const handlers: Record<string, RpcHandler> = {
540
566
  answererPublicKey: offer.answererPublicKey,
541
567
  sdp: offer.answerSdp,
542
568
  answeredAt: offer.answeredAt,
569
+ matchedTags: offer.matchedTags,
543
570
  })),
544
571
  iceCandidates: iceCandidatesByOffer,
545
572
  };
@@ -668,7 +695,7 @@ const handlers: Record<string, RpcHandler> = {
668
695
  };
669
696
 
670
697
  // Methods that don't require authentication
671
- const UNAUTHENTICATED_METHODS = new Set(['discover']);
698
+ const UNAUTHENTICATED_METHODS = new Set(['discover', 'countOffersByTags']);
672
699
 
673
700
  /**
674
701
  * Handle RPC batch request with header-based authentication
package/src/storage/d1.ts CHANGED
@@ -35,6 +35,7 @@ export class D1Storage implements Storage {
35
35
  CREATE INDEX IF NOT EXISTS idx_identities_expires ON identities(expires_at);
36
36
 
37
37
  -- WebRTC signaling offers with tags
38
+ -- Note: No foreign key to identities - auth is stateless (signature-based)
38
39
  CREATE TABLE IF NOT EXISTS offers (
39
40
  id TEXT PRIMARY KEY,
40
41
  public_key TEXT NOT NULL,
@@ -46,8 +47,7 @@ export class D1Storage implements Storage {
46
47
  answerer_public_key TEXT,
47
48
  answer_sdp TEXT,
48
49
  answered_at INTEGER,
49
- matched_tags TEXT,
50
- FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
50
+ matched_tags TEXT
51
51
  );
52
52
 
53
53
  CREATE INDEX IF NOT EXISTS idx_offers_public_key ON offers(public_key);
@@ -56,14 +56,14 @@ export class D1Storage implements Storage {
56
56
  CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_public_key);
57
57
 
58
58
  -- ICE candidates table
59
+ -- Note: No foreign key - offers may be deleted before candidates are read
59
60
  CREATE TABLE IF NOT EXISTS ice_candidates (
60
61
  id INTEGER PRIMARY KEY AUTOINCREMENT,
61
62
  offer_id TEXT NOT NULL,
62
63
  public_key TEXT NOT NULL,
63
64
  role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
64
65
  candidate TEXT NOT NULL,
65
- created_at INTEGER NOT NULL,
66
- FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
66
+ created_at INTEGER NOT NULL
67
67
  );
68
68
 
69
69
  CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
@@ -508,6 +508,28 @@ export class D1Storage implements Storage {
508
508
  return result?.count ?? 0;
509
509
  }
510
510
 
511
+ async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
512
+ const result = new Map<string, number>();
513
+ if (tags.length === 0) return result;
514
+
515
+ const now = Date.now();
516
+
517
+ // Query each tag individually using json_each
518
+ for (const tag of tags) {
519
+ const queryResult = await this.db.prepare(`
520
+ SELECT COUNT(DISTINCT o.id) as count
521
+ FROM offers o, json_each(o.tags) as t
522
+ WHERE t.value = ?
523
+ AND o.expires_at > ?
524
+ AND o.answerer_public_key IS NULL
525
+ `).bind(tag, now).first() as { count: number } | null;
526
+
527
+ result.set(tag, queryResult?.count ?? 0);
528
+ }
529
+
530
+ return result;
531
+ }
532
+
511
533
  // ===== Helper Methods =====
512
534
 
513
535
  private rowToOffer(row: any): Offer {
@@ -445,6 +445,32 @@ export class MemoryStorage implements Storage {
445
445
  return candidates ? candidates.length : 0;
446
446
  }
447
447
 
448
+ async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
449
+ const result = new Map<string, number>();
450
+ if (tags.length === 0) return result;
451
+
452
+ const now = Date.now();
453
+
454
+ for (const tag of tags) {
455
+ const offerIds = this.offersByTag.get(tag);
456
+ if (!offerIds) {
457
+ result.set(tag, 0);
458
+ continue;
459
+ }
460
+
461
+ let count = 0;
462
+ for (const offerId of offerIds) {
463
+ const offer = this.offers.get(offerId);
464
+ if (offer && offer.expiresAt > now && !offer.answererPublicKey) {
465
+ count++;
466
+ }
467
+ }
468
+ result.set(tag, count);
469
+ }
470
+
471
+ return result;
472
+ }
473
+
448
474
  // ===== Helper Methods =====
449
475
 
450
476
  private removeOfferFromIndexes(offer: Offer): void {
@@ -476,6 +476,28 @@ export class MySQLStorage implements Storage {
476
476
  return Number(rows[0].count);
477
477
  }
478
478
 
479
+ async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
480
+ const result = new Map<string, number>();
481
+ if (tags.length === 0) return result;
482
+
483
+ const now = Date.now();
484
+
485
+ // Query each tag individually using JSON_CONTAINS
486
+ for (const tag of tags) {
487
+ const [rows] = await this.pool.query<RowDataPacket[]>(
488
+ `SELECT COUNT(DISTINCT id) as count
489
+ FROM offers
490
+ WHERE JSON_CONTAINS(tags, ?)
491
+ AND expires_at > ?
492
+ AND answerer_public_key IS NULL`,
493
+ [JSON.stringify(tag), now]
494
+ );
495
+ result.set(tag, Number(rows[0].count));
496
+ }
497
+
498
+ return result;
499
+ }
500
+
479
501
  // ===== Helper Methods =====
480
502
 
481
503
  private rowToOffer(row: RowDataPacket): Offer {
@@ -484,6 +484,28 @@ export class PostgreSQLStorage implements Storage {
484
484
  return Number(result.rows[0].count);
485
485
  }
486
486
 
487
+ async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
488
+ const result = new Map<string, number>();
489
+ if (tags.length === 0) return result;
490
+
491
+ const now = Date.now();
492
+
493
+ // Query each tag individually using JSONB containment
494
+ for (const tag of tags) {
495
+ const queryResult = await this.pool.query(
496
+ `SELECT COUNT(DISTINCT id) as count
497
+ FROM offers
498
+ WHERE tags ? $1
499
+ AND expires_at > $2
500
+ AND answerer_public_key IS NULL`,
501
+ [tag, now]
502
+ );
503
+ result.set(tag, Number(queryResult.rows[0].count));
504
+ }
505
+
506
+ return result;
507
+ }
508
+
487
509
  // ===== Helper Methods =====
488
510
 
489
511
  private rowToOffer(row: any): Offer {
@@ -543,6 +543,30 @@ export class SQLiteStorage implements Storage {
543
543
  return result.count;
544
544
  }
545
545
 
546
+ async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
547
+ const result = new Map<string, number>();
548
+ if (tags.length === 0) return result;
549
+
550
+ const now = Date.now();
551
+
552
+ // Query counts for each tag individually for accuracy
553
+ // (an offer with multiple matching tags should only count once per tag)
554
+ const stmt = this.db.prepare(`
555
+ SELECT COUNT(DISTINCT o.id) as count
556
+ FROM offers o, json_each(o.tags) as t
557
+ WHERE t.value = ?
558
+ AND o.expires_at > ?
559
+ AND o.answerer_public_key IS NULL
560
+ `);
561
+
562
+ for (const tag of tags) {
563
+ const row = stmt.get(tag, now) as { count: number };
564
+ result.set(tag, row.count);
565
+ }
566
+
567
+ return result;
568
+ }
569
+
546
570
  // ===== Helper Methods =====
547
571
 
548
572
  /**
@@ -255,4 +255,11 @@ export interface Storage {
255
255
  * @returns ICE candidate count for offer
256
256
  */
257
257
  getIceCandidateCount(offerId: string): Promise<number>;
258
+
259
+ /**
260
+ * Counts available (unanswered, non-expired) offers for each of the given tags
261
+ * @param tags Array of tags to count offers for
262
+ * @returns Map of tag to offer count
263
+ */
264
+ countOffersByTags(tags: string[]): Promise<Map<string, number>>;
258
265
  }