@xtr-dev/rondevu-server 0.1.5 → 0.2.0

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,9 +1,22 @@
1
1
  import Database from 'better-sqlite3';
2
- import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
2
+ import { randomUUID } from 'crypto';
3
+ import {
4
+ Storage,
5
+ Offer,
6
+ IceCandidate,
7
+ CreateOfferRequest,
8
+ Username,
9
+ ClaimUsernameRequest,
10
+ Service,
11
+ CreateServiceRequest,
12
+ ServiceInfo,
13
+ } from './types.ts';
3
14
  import { generateOfferHash } from './hash-id.ts';
4
15
 
16
+ const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
17
+
5
18
  /**
6
- * SQLite storage adapter for topic-based offer management
19
+ * SQLite storage adapter for rondevu DNS-like system
7
20
  * Supports both file-based and in-memory databases
8
21
  */
9
22
  export class SQLiteStorage implements Storage {
@@ -19,10 +32,11 @@ export class SQLiteStorage implements Storage {
19
32
  }
20
33
 
21
34
  /**
22
- * Initializes database schema with new topic-based structure
35
+ * Initializes database schema with username and service-based structure
23
36
  */
24
37
  private initializeDatabase(): void {
25
38
  this.db.exec(`
39
+ -- Offers table (no topics)
26
40
  CREATE TABLE IF NOT EXISTS offers (
27
41
  id TEXT PRIMARY KEY,
28
42
  peer_id TEXT NOT NULL,
@@ -41,22 +55,13 @@ export class SQLiteStorage implements Storage {
41
55
  CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
42
56
  CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
43
57
 
44
- CREATE TABLE IF NOT EXISTS offer_topics (
45
- offer_id TEXT NOT NULL,
46
- topic TEXT NOT NULL,
47
- PRIMARY KEY (offer_id, topic),
48
- FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
49
- );
50
-
51
- CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
52
- CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
53
-
58
+ -- ICE candidates table
54
59
  CREATE TABLE IF NOT EXISTS ice_candidates (
55
60
  id INTEGER PRIMARY KEY AUTOINCREMENT,
56
61
  offer_id TEXT NOT NULL,
57
62
  peer_id TEXT NOT NULL,
58
63
  role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
59
- candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
64
+ candidate TEXT NOT NULL,
60
65
  created_at INTEGER NOT NULL,
61
66
  FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
62
67
  );
@@ -64,12 +69,62 @@ export class SQLiteStorage implements Storage {
64
69
  CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
65
70
  CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
66
71
  CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
72
+
73
+ -- Usernames table
74
+ CREATE TABLE IF NOT EXISTS usernames (
75
+ username TEXT PRIMARY KEY,
76
+ public_key TEXT NOT NULL UNIQUE,
77
+ claimed_at INTEGER NOT NULL,
78
+ expires_at INTEGER NOT NULL,
79
+ last_used INTEGER NOT NULL,
80
+ metadata TEXT,
81
+ CHECK(length(username) >= 3 AND length(username) <= 32)
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
85
+ CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
86
+
87
+ -- Services table
88
+ CREATE TABLE IF NOT EXISTS services (
89
+ id TEXT PRIMARY KEY,
90
+ username TEXT NOT NULL,
91
+ service_fqn TEXT NOT NULL,
92
+ offer_id TEXT NOT NULL,
93
+ created_at INTEGER NOT NULL,
94
+ expires_at INTEGER NOT NULL,
95
+ is_public INTEGER NOT NULL DEFAULT 0,
96
+ metadata TEXT,
97
+ FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
98
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
99
+ UNIQUE(username, service_fqn)
100
+ );
101
+
102
+ CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
103
+ CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
104
+ CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
105
+ CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
106
+
107
+ -- Service index table (privacy layer)
108
+ CREATE TABLE IF NOT EXISTS service_index (
109
+ uuid TEXT PRIMARY KEY,
110
+ service_id TEXT NOT NULL,
111
+ username TEXT NOT NULL,
112
+ service_fqn TEXT NOT NULL,
113
+ created_at INTEGER NOT NULL,
114
+ expires_at INTEGER NOT NULL,
115
+ FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
116
+ );
117
+
118
+ CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
119
+ CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
67
120
  `);
68
121
 
69
122
  // Enable foreign keys
70
123
  this.db.pragma('foreign_keys = ON');
71
124
  }
72
125
 
126
+ // ===== Offer Management =====
127
+
73
128
  async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
74
129
  const created: Offer[] = [];
75
130
 
@@ -77,7 +132,7 @@ export class SQLiteStorage implements Storage {
77
132
  const offersWithIds = await Promise.all(
78
133
  offers.map(async (offer) => ({
79
134
  ...offer,
80
- id: offer.id || await generateOfferHash(offer.sdp, offer.topics),
135
+ id: offer.id || await generateOfferHash(offer.sdp, []),
81
136
  }))
82
137
  );
83
138
 
@@ -88,11 +143,6 @@ export class SQLiteStorage implements Storage {
88
143
  VALUES (?, ?, ?, ?, ?, ?, ?)
89
144
  `);
90
145
 
91
- const topicStmt = this.db.prepare(`
92
- INSERT INTO offer_topics (offer_id, topic)
93
- VALUES (?, ?)
94
- `);
95
-
96
146
  for (const offer of offersWithIds) {
97
147
  const now = Date.now();
98
148
 
@@ -107,16 +157,10 @@ export class SQLiteStorage implements Storage {
107
157
  offer.secret || null
108
158
  );
109
159
 
110
- // Insert topics
111
- for (const topic of offer.topics) {
112
- topicStmt.run(offer.id, topic);
113
- }
114
-
115
160
  created.push({
116
161
  id: offer.id,
117
162
  peerId: offer.peerId,
118
163
  sdp: offer.sdp,
119
- topics: offer.topics,
120
164
  createdAt: now,
121
165
  expiresAt: offer.expiresAt,
122
166
  lastSeen: now,
@@ -129,30 +173,6 @@ export class SQLiteStorage implements Storage {
129
173
  return created;
130
174
  }
131
175
 
132
- async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
133
- let query = `
134
- SELECT DISTINCT o.*
135
- FROM offers o
136
- INNER JOIN offer_topics ot ON o.id = ot.offer_id
137
- WHERE ot.topic = ? AND o.expires_at > ?
138
- `;
139
-
140
- const params: any[] = [topic, Date.now()];
141
-
142
- if (excludePeerIds && excludePeerIds.length > 0) {
143
- const placeholders = excludePeerIds.map(() => '?').join(',');
144
- query += ` AND o.peer_id NOT IN (${placeholders})`;
145
- params.push(...excludePeerIds);
146
- }
147
-
148
- query += ' ORDER BY o.last_seen DESC';
149
-
150
- const stmt = this.db.prepare(query);
151
- const rows = stmt.all(...params) as any[];
152
-
153
- return Promise.all(rows.map(row => this.rowToOffer(row)));
154
- }
155
-
156
176
  async getOffersByPeerId(peerId: string): Promise<Offer[]> {
157
177
  const stmt = this.db.prepare(`
158
178
  SELECT * FROM offers
@@ -161,7 +181,7 @@ export class SQLiteStorage implements Storage {
161
181
  `);
162
182
 
163
183
  const rows = stmt.all(peerId, Date.now()) as any[];
164
- return Promise.all(rows.map(row => this.rowToOffer(row)));
184
+ return rows.map(row => this.rowToOffer(row));
165
185
  }
166
186
 
167
187
  async getOfferById(offerId: string): Promise<Offer | null> {
@@ -254,9 +274,11 @@ export class SQLiteStorage implements Storage {
254
274
  `);
255
275
 
256
276
  const rows = stmt.all(offererPeerId, Date.now()) as any[];
257
- return Promise.all(rows.map(row => this.rowToOffer(row)));
277
+ return rows.map(row => this.rowToOffer(row));
258
278
  }
259
279
 
280
+ // ===== ICE Candidate Management =====
281
+
260
282
  async addIceCandidates(
261
283
  offerId: string,
262
284
  peerId: string,
@@ -275,8 +297,8 @@ export class SQLiteStorage implements Storage {
275
297
  offerId,
276
298
  peerId,
277
299
  role,
278
- JSON.stringify(candidates[i]), // Store full object as JSON
279
- baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues
300
+ JSON.stringify(candidates[i]),
301
+ baseTimestamp + i
280
302
  );
281
303
  }
282
304
  });
@@ -312,85 +334,249 @@ export class SQLiteStorage implements Storage {
312
334
  offerId: row.offer_id,
313
335
  peerId: row.peer_id,
314
336
  role: row.role,
315
- candidate: JSON.parse(row.candidate), // Parse JSON back to object
337
+ candidate: JSON.parse(row.candidate),
316
338
  createdAt: row.created_at,
317
339
  }));
318
340
  }
319
341
 
320
- async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
321
- topics: TopicInfo[];
322
- total: number;
342
+ // ===== Username Management =====
343
+
344
+ async claimUsername(request: ClaimUsernameRequest): Promise<Username> {
345
+ const now = Date.now();
346
+ const expiresAt = now + YEAR_IN_MS;
347
+
348
+ // Try to insert or update
349
+ const stmt = this.db.prepare(`
350
+ INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata)
351
+ VALUES (?, ?, ?, ?, ?, NULL)
352
+ ON CONFLICT(username) DO UPDATE SET
353
+ expires_at = ?,
354
+ last_used = ?
355
+ WHERE public_key = ?
356
+ `);
357
+
358
+ const result = stmt.run(
359
+ request.username,
360
+ request.publicKey,
361
+ now,
362
+ expiresAt,
363
+ now,
364
+ expiresAt,
365
+ now,
366
+ request.publicKey
367
+ );
368
+
369
+ if (result.changes === 0) {
370
+ throw new Error('Username already claimed by different public key');
371
+ }
372
+
373
+ return {
374
+ username: request.username,
375
+ publicKey: request.publicKey,
376
+ claimedAt: now,
377
+ expiresAt,
378
+ lastUsed: now,
379
+ };
380
+ }
381
+
382
+ async getUsername(username: string): Promise<Username | null> {
383
+ const stmt = this.db.prepare(`
384
+ SELECT * FROM usernames
385
+ WHERE username = ? AND expires_at > ?
386
+ `);
387
+
388
+ const row = stmt.get(username, Date.now()) as any;
389
+
390
+ if (!row) {
391
+ return null;
392
+ }
393
+
394
+ return {
395
+ username: row.username,
396
+ publicKey: row.public_key,
397
+ claimedAt: row.claimed_at,
398
+ expiresAt: row.expires_at,
399
+ lastUsed: row.last_used,
400
+ metadata: row.metadata || undefined,
401
+ };
402
+ }
403
+
404
+ async touchUsername(username: string): Promise<boolean> {
405
+ const now = Date.now();
406
+ const expiresAt = now + YEAR_IN_MS;
407
+
408
+ const stmt = this.db.prepare(`
409
+ UPDATE usernames
410
+ SET last_used = ?, expires_at = ?
411
+ WHERE username = ? AND expires_at > ?
412
+ `);
413
+
414
+ const result = stmt.run(now, expiresAt, username, now);
415
+ return result.changes > 0;
416
+ }
417
+
418
+ async deleteExpiredUsernames(now: number): Promise<number> {
419
+ const stmt = this.db.prepare('DELETE FROM usernames WHERE expires_at < ?');
420
+ const result = stmt.run(now);
421
+ return result.changes;
422
+ }
423
+
424
+ // ===== Service Management =====
425
+
426
+ async createService(request: CreateServiceRequest): Promise<{
427
+ service: Service;
428
+ indexUuid: string;
323
429
  }> {
430
+ const serviceId = randomUUID();
431
+ const indexUuid = randomUUID();
324
432
  const now = Date.now();
325
433
 
326
- // Build WHERE clause for startsWith filter
327
- const whereClause = startsWith
328
- ? 'o.expires_at > ? AND ot.topic LIKE ?'
329
- : 'o.expires_at > ?';
434
+ const transaction = this.db.transaction(() => {
435
+ // Insert service
436
+ const serviceStmt = this.db.prepare(`
437
+ INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata)
438
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
439
+ `);
330
440
 
331
- const startsWithPattern = startsWith ? `${startsWith}%` : null;
441
+ serviceStmt.run(
442
+ serviceId,
443
+ request.username,
444
+ request.serviceFqn,
445
+ request.offerId,
446
+ now,
447
+ request.expiresAt,
448
+ request.isPublic ? 1 : 0,
449
+ request.metadata || null
450
+ );
332
451
 
333
- // Get total count of topics with active offers
334
- const countQuery = `
335
- SELECT COUNT(DISTINCT ot.topic) as count
336
- FROM offer_topics ot
337
- INNER JOIN offers o ON ot.offer_id = o.id
338
- WHERE ${whereClause}
339
- `;
452
+ // Insert service index
453
+ const indexStmt = this.db.prepare(`
454
+ INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
455
+ VALUES (?, ?, ?, ?, ?, ?)
456
+ `);
340
457
 
341
- const countStmt = this.db.prepare(countQuery);
342
- const countParams = startsWith ? [now, startsWithPattern] : [now];
343
- const countRow = countStmt.get(...countParams) as any;
344
- const total = countRow.count;
345
-
346
- // Get topics with peer counts (paginated)
347
- const topicsQuery = `
348
- SELECT
349
- ot.topic,
350
- COUNT(DISTINCT o.peer_id) as active_peers
351
- FROM offer_topics ot
352
- INNER JOIN offers o ON ot.offer_id = o.id
353
- WHERE ${whereClause}
354
- GROUP BY ot.topic
355
- ORDER BY active_peers DESC, ot.topic ASC
356
- LIMIT ? OFFSET ?
357
- `;
458
+ indexStmt.run(
459
+ indexUuid,
460
+ serviceId,
461
+ request.username,
462
+ request.serviceFqn,
463
+ now,
464
+ request.expiresAt
465
+ );
466
+
467
+ // Touch username to extend expiry
468
+ this.touchUsername(request.username);
469
+ });
470
+
471
+ transaction();
472
+
473
+ return {
474
+ service: {
475
+ id: serviceId,
476
+ username: request.username,
477
+ serviceFqn: request.serviceFqn,
478
+ offerId: request.offerId,
479
+ createdAt: now,
480
+ expiresAt: request.expiresAt,
481
+ isPublic: request.isPublic || false,
482
+ metadata: request.metadata,
483
+ },
484
+ indexUuid,
485
+ };
486
+ }
358
487
 
359
- const topicsStmt = this.db.prepare(topicsQuery);
360
- const topicsParams = startsWith
361
- ? [now, startsWithPattern, limit, offset]
362
- : [now, limit, offset];
363
- const rows = topicsStmt.all(...topicsParams) as any[];
488
+ async getServiceById(serviceId: string): Promise<Service | null> {
489
+ const stmt = this.db.prepare(`
490
+ SELECT * FROM services
491
+ WHERE id = ? AND expires_at > ?
492
+ `);
493
+
494
+ const row = stmt.get(serviceId, Date.now()) as any;
495
+
496
+ if (!row) {
497
+ return null;
498
+ }
499
+
500
+ return this.rowToService(row);
501
+ }
502
+
503
+ async getServiceByUuid(uuid: string): Promise<Service | null> {
504
+ const stmt = this.db.prepare(`
505
+ SELECT s.* FROM services s
506
+ INNER JOIN service_index si ON s.id = si.service_id
507
+ WHERE si.uuid = ? AND s.expires_at > ?
508
+ `);
509
+
510
+ const row = stmt.get(uuid, Date.now()) as any;
511
+
512
+ if (!row) {
513
+ return null;
514
+ }
364
515
 
365
- const topics = rows.map(row => ({
366
- topic: row.topic,
367
- activePeers: row.active_peers,
516
+ return this.rowToService(row);
517
+ }
518
+
519
+ async listServicesForUsername(username: string): Promise<ServiceInfo[]> {
520
+ const stmt = this.db.prepare(`
521
+ SELECT si.uuid, s.is_public, s.service_fqn, s.metadata
522
+ FROM service_index si
523
+ INNER JOIN services s ON si.service_id = s.id
524
+ WHERE si.username = ? AND si.expires_at > ?
525
+ ORDER BY s.created_at DESC
526
+ `);
527
+
528
+ const rows = stmt.all(username, Date.now()) as any[];
529
+
530
+ return rows.map(row => ({
531
+ uuid: row.uuid,
532
+ isPublic: row.is_public === 1,
533
+ serviceFqn: row.is_public === 1 ? row.service_fqn : undefined,
534
+ metadata: row.is_public === 1 ? row.metadata || undefined : undefined,
368
535
  }));
536
+ }
537
+
538
+ async queryService(username: string, serviceFqn: string): Promise<string | null> {
539
+ const stmt = this.db.prepare(`
540
+ SELECT si.uuid FROM service_index si
541
+ INNER JOIN services s ON si.service_id = s.id
542
+ WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ?
543
+ `);
544
+
545
+ const row = stmt.get(username, serviceFqn, Date.now()) as any;
546
+
547
+ return row ? row.uuid : null;
548
+ }
549
+
550
+ async deleteService(serviceId: string, username: string): Promise<boolean> {
551
+ const stmt = this.db.prepare(`
552
+ DELETE FROM services
553
+ WHERE id = ? AND username = ?
554
+ `);
555
+
556
+ const result = stmt.run(serviceId, username);
557
+ return result.changes > 0;
558
+ }
369
559
 
370
- return { topics, total };
560
+ async deleteExpiredServices(now: number): Promise<number> {
561
+ const stmt = this.db.prepare('DELETE FROM services WHERE expires_at < ?');
562
+ const result = stmt.run(now);
563
+ return result.changes;
371
564
  }
372
565
 
373
566
  async close(): Promise<void> {
374
567
  this.db.close();
375
568
  }
376
569
 
570
+ // ===== Helper Methods =====
571
+
377
572
  /**
378
- * Helper method to convert database row to Offer object with topics
573
+ * Helper method to convert database row to Offer object
379
574
  */
380
- private async rowToOffer(row: any): Promise<Offer> {
381
- // Get topics for this offer
382
- const topicStmt = this.db.prepare(`
383
- SELECT topic FROM offer_topics WHERE offer_id = ?
384
- `);
385
-
386
- const topicRows = topicStmt.all(row.id) as any[];
387
- const topics = topicRows.map(t => t.topic);
388
-
575
+ private rowToOffer(row: any): Offer {
389
576
  return {
390
577
  id: row.id,
391
578
  peerId: row.peer_id,
392
579
  sdp: row.sdp,
393
- topics,
394
580
  createdAt: row.created_at,
395
581
  expiresAt: row.expires_at,
396
582
  lastSeen: row.last_seen,
@@ -400,4 +586,20 @@ export class SQLiteStorage implements Storage {
400
586
  answeredAt: row.answered_at || undefined,
401
587
  };
402
588
  }
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
+ username: row.username,
597
+ serviceFqn: row.service_fqn,
598
+ offerId: row.offer_id,
599
+ createdAt: row.created_at,
600
+ expiresAt: row.expires_at,
601
+ isPublic: row.is_public === 1,
602
+ metadata: row.metadata || undefined,
603
+ };
604
+ }
403
605
  }