@xtr-dev/rondevu-server 0.1.5 → 0.2.1

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
@@ -1,9 +1,21 @@
1
- import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
1
+ import { randomUUID } from 'crypto';
2
+ import {
3
+ Storage,
4
+ Offer,
5
+ IceCandidate,
6
+ CreateOfferRequest,
7
+ Username,
8
+ ClaimUsernameRequest,
9
+ Service,
10
+ CreateServiceRequest,
11
+ ServiceInfo,
12
+ } from './types.ts';
2
13
  import { generateOfferHash } from './hash-id.ts';
3
14
 
15
+ const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
16
+
4
17
  /**
5
- * D1 storage adapter for topic-based offer management using Cloudflare D1
6
- * NOTE: This implementation is a placeholder and needs to be fully tested
18
+ * D1 storage adapter for rondevu DNS-like system using Cloudflare D1
7
19
  */
8
20
  export class D1Storage implements Storage {
9
21
  private db: D1Database;
@@ -17,11 +29,12 @@ export class D1Storage implements Storage {
17
29
  }
18
30
 
19
31
  /**
20
- * Initializes database schema with new topic-based structure
32
+ * Initializes database schema with username and service-based structure
21
33
  * This should be run once during setup, not on every request
22
34
  */
23
35
  async initializeDatabase(): Promise<void> {
24
36
  await this.db.exec(`
37
+ -- Offers table (no topics)
25
38
  CREATE TABLE IF NOT EXISTS offers (
26
39
  id TEXT PRIMARY KEY,
27
40
  peer_id TEXT NOT NULL,
@@ -40,22 +53,13 @@ export class D1Storage implements Storage {
40
53
  CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
41
54
  CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
42
55
 
43
- CREATE TABLE IF NOT EXISTS offer_topics (
44
- offer_id TEXT NOT NULL,
45
- topic TEXT NOT NULL,
46
- PRIMARY KEY (offer_id, topic),
47
- FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
48
- );
49
-
50
- CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
51
- CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
52
-
56
+ -- ICE candidates table
53
57
  CREATE TABLE IF NOT EXISTS ice_candidates (
54
58
  id INTEGER PRIMARY KEY AUTOINCREMENT,
55
59
  offer_id TEXT NOT NULL,
56
60
  peer_id TEXT NOT NULL,
57
61
  role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
58
- candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
62
+ candidate TEXT NOT NULL,
59
63
  created_at INTEGER NOT NULL,
60
64
  FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
61
65
  );
@@ -63,36 +67,76 @@ export class D1Storage implements Storage {
63
67
  CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
64
68
  CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
65
69
  CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
70
+
71
+ -- Usernames table
72
+ CREATE TABLE IF NOT EXISTS usernames (
73
+ username TEXT PRIMARY KEY,
74
+ public_key TEXT NOT NULL UNIQUE,
75
+ claimed_at INTEGER NOT NULL,
76
+ expires_at INTEGER NOT NULL,
77
+ last_used INTEGER NOT NULL,
78
+ metadata TEXT,
79
+ CHECK(length(username) >= 3 AND length(username) <= 32)
80
+ );
81
+
82
+ CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
83
+ CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
84
+
85
+ -- Services table
86
+ CREATE TABLE IF NOT EXISTS services (
87
+ id TEXT PRIMARY KEY,
88
+ username TEXT NOT NULL,
89
+ service_fqn TEXT NOT NULL,
90
+ offer_id TEXT NOT NULL,
91
+ created_at INTEGER NOT NULL,
92
+ expires_at INTEGER NOT NULL,
93
+ is_public INTEGER NOT NULL DEFAULT 0,
94
+ metadata TEXT,
95
+ FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
96
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
97
+ UNIQUE(username, service_fqn)
98
+ );
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
101
+ CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
102
+ CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
103
+ CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
104
+
105
+ -- Service index table (privacy layer)
106
+ CREATE TABLE IF NOT EXISTS service_index (
107
+ uuid TEXT PRIMARY KEY,
108
+ service_id TEXT NOT NULL,
109
+ username TEXT NOT NULL,
110
+ service_fqn TEXT NOT NULL,
111
+ created_at INTEGER NOT NULL,
112
+ expires_at INTEGER NOT NULL,
113
+ FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
114
+ );
115
+
116
+ CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
117
+ CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
66
118
  `);
67
119
  }
68
120
 
121
+ // ===== Offer Management =====
122
+
69
123
  async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
70
124
  const created: Offer[] = [];
71
125
 
72
126
  // D1 doesn't support true transactions yet, so we do this sequentially
73
127
  for (const offer of offers) {
74
- const id = offer.id || await generateOfferHash(offer.sdp, offer.topics);
128
+ const id = offer.id || await generateOfferHash(offer.sdp, []);
75
129
  const now = Date.now();
76
130
 
77
- // Insert offer
78
131
  await this.db.prepare(`
79
132
  INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
80
133
  VALUES (?, ?, ?, ?, ?, ?, ?)
81
134
  `).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run();
82
135
 
83
- // Insert topics
84
- for (const topic of offer.topics) {
85
- await this.db.prepare(`
86
- INSERT INTO offer_topics (offer_id, topic)
87
- VALUES (?, ?)
88
- `).bind(id, topic).run();
89
- }
90
-
91
136
  created.push({
92
137
  id,
93
138
  peerId: offer.peerId,
94
139
  sdp: offer.sdp,
95
- topics: offer.topics,
96
140
  createdAt: now,
97
141
  expiresAt: offer.expiresAt,
98
142
  lastSeen: now,
@@ -103,33 +147,6 @@ export class D1Storage implements Storage {
103
147
  return created;
104
148
  }
105
149
 
106
- async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
107
- let query = `
108
- SELECT DISTINCT o.*
109
- FROM offers o
110
- INNER JOIN offer_topics ot ON o.id = ot.offer_id
111
- WHERE ot.topic = ? AND o.expires_at > ?
112
- `;
113
-
114
- const params: any[] = [topic, Date.now()];
115
-
116
- if (excludePeerIds && excludePeerIds.length > 0) {
117
- const placeholders = excludePeerIds.map(() => '?').join(',');
118
- query += ` AND o.peer_id NOT IN (${placeholders})`;
119
- params.push(...excludePeerIds);
120
- }
121
-
122
- query += ' ORDER BY o.last_seen DESC';
123
-
124
- const result = await this.db.prepare(query).bind(...params).all();
125
-
126
- if (!result.results) {
127
- return [];
128
- }
129
-
130
- return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
131
- }
132
-
133
150
  async getOffersByPeerId(peerId: string): Promise<Offer[]> {
134
151
  const result = await this.db.prepare(`
135
152
  SELECT * FROM offers
@@ -141,7 +158,7 @@ export class D1Storage implements Storage {
141
158
  return [];
142
159
  }
143
160
 
144
- return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
161
+ return result.results.map(row => this.rowToOffer(row as any));
145
162
  }
146
163
 
147
164
  async getOfferById(offerId: string): Promise<Offer | null> {
@@ -234,21 +251,20 @@ export class D1Storage implements Storage {
234
251
  return [];
235
252
  }
236
253
 
237
- return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
254
+ return result.results.map(row => this.rowToOffer(row as any));
238
255
  }
239
256
 
257
+ // ===== ICE Candidate Management =====
258
+
240
259
  async addIceCandidates(
241
260
  offerId: string,
242
261
  peerId: string,
243
262
  role: 'offerer' | 'answerer',
244
263
  candidates: any[]
245
264
  ): Promise<number> {
246
- console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`);
247
-
248
- // Give each candidate a unique timestamp to avoid "since" filtering issues
249
265
  // D1 doesn't have transactions, so insert one by one
250
266
  for (let i = 0; i < candidates.length; i++) {
251
- const timestamp = Date.now() + i; // Ensure unique timestamps
267
+ const timestamp = Date.now() + i;
252
268
  await this.db.prepare(`
253
269
  INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
254
270
  VALUES (?, ?, ?, ?, ?)
@@ -256,7 +272,7 @@ export class D1Storage implements Storage {
256
272
  offerId,
257
273
  peerId,
258
274
  role,
259
- JSON.stringify(candidates[i]), // Store full object as JSON
275
+ JSON.stringify(candidates[i]),
260
276
  timestamp
261
277
  ).run();
262
278
  }
@@ -283,82 +299,232 @@ export class D1Storage implements Storage {
283
299
 
284
300
  query += ' ORDER BY created_at ASC';
285
301
 
286
- console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`);
287
302
  const result = await this.db.prepare(query).bind(...params).all();
288
- console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`);
289
303
 
290
304
  if (!result.results) {
291
305
  return [];
292
306
  }
293
307
 
294
- const candidates = result.results.map((row: any) => ({
308
+ return result.results.map((row: any) => ({
295
309
  id: row.id,
296
310
  offerId: row.offer_id,
297
311
  peerId: row.peer_id,
298
312
  role: row.role,
299
- candidate: JSON.parse(row.candidate), // Parse JSON back to object
313
+ candidate: JSON.parse(row.candidate),
300
314
  createdAt: row.created_at,
301
315
  }));
316
+ }
317
+
318
+ // ===== Username Management =====
319
+
320
+ async claimUsername(request: ClaimUsernameRequest): Promise<Username> {
321
+ const now = Date.now();
322
+ const expiresAt = now + YEAR_IN_MS;
323
+
324
+ // Try to insert or update
325
+ const result = await this.db.prepare(`
326
+ INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata)
327
+ VALUES (?, ?, ?, ?, ?, NULL)
328
+ ON CONFLICT(username) DO UPDATE SET
329
+ expires_at = ?,
330
+ last_used = ?
331
+ WHERE public_key = ?
332
+ `).bind(
333
+ request.username,
334
+ request.publicKey,
335
+ now,
336
+ expiresAt,
337
+ now,
338
+ expiresAt,
339
+ now,
340
+ request.publicKey
341
+ ).run();
342
+
343
+ if ((result.meta.changes || 0) === 0) {
344
+ throw new Error('Username already claimed by different public key');
345
+ }
346
+
347
+ return {
348
+ username: request.username,
349
+ publicKey: request.publicKey,
350
+ claimedAt: now,
351
+ expiresAt,
352
+ lastUsed: now,
353
+ };
354
+ }
355
+
356
+ async getUsername(username: string): Promise<Username | null> {
357
+ const result = await this.db.prepare(`
358
+ SELECT * FROM usernames
359
+ WHERE username = ? AND expires_at > ?
360
+ `).bind(username, Date.now()).first();
302
361
 
303
- if (candidates.length > 0) {
304
- console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`);
362
+ if (!result) {
363
+ return null;
305
364
  }
306
365
 
307
- return candidates;
366
+ const row = result as any;
367
+
368
+ return {
369
+ username: row.username,
370
+ publicKey: row.public_key,
371
+ claimedAt: row.claimed_at,
372
+ expiresAt: row.expires_at,
373
+ lastUsed: row.last_used,
374
+ metadata: row.metadata || undefined,
375
+ };
376
+ }
377
+
378
+ async touchUsername(username: string): Promise<boolean> {
379
+ const now = Date.now();
380
+ const expiresAt = now + YEAR_IN_MS;
381
+
382
+ const result = await this.db.prepare(`
383
+ UPDATE usernames
384
+ SET last_used = ?, expires_at = ?
385
+ WHERE username = ? AND expires_at > ?
386
+ `).bind(now, expiresAt, username, now).run();
387
+
388
+ return (result.meta.changes || 0) > 0;
389
+ }
390
+
391
+ async deleteExpiredUsernames(now: number): Promise<number> {
392
+ const result = await this.db.prepare(`
393
+ DELETE FROM usernames WHERE expires_at < ?
394
+ `).bind(now).run();
395
+
396
+ return result.meta.changes || 0;
308
397
  }
309
398
 
310
- async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
311
- topics: TopicInfo[];
312
- total: number;
399
+ // ===== Service Management =====
400
+
401
+ async createService(request: CreateServiceRequest): Promise<{
402
+ service: Service;
403
+ indexUuid: string;
313
404
  }> {
405
+ const serviceId = randomUUID();
406
+ const indexUuid = randomUUID();
314
407
  const now = Date.now();
315
408
 
316
- // Build WHERE clause for startsWith filter
317
- const whereClause = startsWith
318
- ? 'o.expires_at > ? AND ot.topic LIKE ?'
319
- : 'o.expires_at > ?';
409
+ // Insert service
410
+ await this.db.prepare(`
411
+ INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
413
+ `).bind(
414
+ serviceId,
415
+ request.username,
416
+ request.serviceFqn,
417
+ request.offerId,
418
+ now,
419
+ request.expiresAt,
420
+ request.isPublic ? 1 : 0,
421
+ request.metadata || null
422
+ ).run();
423
+
424
+ // Insert service index
425
+ await this.db.prepare(`
426
+ INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
427
+ VALUES (?, ?, ?, ?, ?, ?)
428
+ `).bind(
429
+ indexUuid,
430
+ serviceId,
431
+ request.username,
432
+ request.serviceFqn,
433
+ now,
434
+ request.expiresAt
435
+ ).run();
436
+
437
+ // Touch username to extend expiry
438
+ await this.touchUsername(request.username);
320
439
 
321
- const startsWithPattern = startsWith ? `${startsWith}%` : null;
440
+ return {
441
+ service: {
442
+ id: serviceId,
443
+ username: request.username,
444
+ serviceFqn: request.serviceFqn,
445
+ offerId: request.offerId,
446
+ createdAt: now,
447
+ expiresAt: request.expiresAt,
448
+ isPublic: request.isPublic || false,
449
+ metadata: request.metadata,
450
+ },
451
+ indexUuid,
452
+ };
453
+ }
322
454
 
323
- // Get total count of topics with active offers
324
- const countQuery = `
325
- SELECT COUNT(DISTINCT ot.topic) as count
326
- FROM offer_topics ot
327
- INNER JOIN offers o ON ot.offer_id = o.id
328
- WHERE ${whereClause}
329
- `;
455
+ async getServiceById(serviceId: string): Promise<Service | null> {
456
+ const result = await this.db.prepare(`
457
+ SELECT * FROM services
458
+ WHERE id = ? AND expires_at > ?
459
+ `).bind(serviceId, Date.now()).first();
330
460
 
331
- const countStmt = this.db.prepare(countQuery);
332
- const countResult = startsWith
333
- ? await countStmt.bind(now, startsWithPattern).first()
334
- : await countStmt.bind(now).first();
335
-
336
- const total = (countResult as any)?.count || 0;
337
-
338
- // Get topics with peer counts (paginated)
339
- const topicsQuery = `
340
- SELECT
341
- ot.topic,
342
- COUNT(DISTINCT o.peer_id) as active_peers
343
- FROM offer_topics ot
344
- INNER JOIN offers o ON ot.offer_id = o.id
345
- WHERE ${whereClause}
346
- GROUP BY ot.topic
347
- ORDER BY active_peers DESC, ot.topic ASC
348
- LIMIT ? OFFSET ?
349
- `;
461
+ if (!result) {
462
+ return null;
463
+ }
464
+
465
+ return this.rowToService(result as any);
466
+ }
467
+
468
+ async getServiceByUuid(uuid: string): Promise<Service | null> {
469
+ const result = await this.db.prepare(`
470
+ SELECT s.* FROM services s
471
+ INNER JOIN service_index si ON s.id = si.service_id
472
+ WHERE si.uuid = ? AND s.expires_at > ?
473
+ `).bind(uuid, Date.now()).first();
474
+
475
+ if (!result) {
476
+ return null;
477
+ }
478
+
479
+ return this.rowToService(result as any);
480
+ }
350
481
 
351
- const topicsStmt = this.db.prepare(topicsQuery);
352
- const topicsResult = startsWith
353
- ? await topicsStmt.bind(now, startsWithPattern, limit, offset).all()
354
- : await topicsStmt.bind(now, limit, offset).all();
482
+ async listServicesForUsername(username: string): Promise<ServiceInfo[]> {
483
+ const result = await this.db.prepare(`
484
+ SELECT si.uuid, s.is_public, s.service_fqn, s.metadata
485
+ FROM service_index si
486
+ INNER JOIN services s ON si.service_id = s.id
487
+ WHERE si.username = ? AND si.expires_at > ?
488
+ ORDER BY s.created_at DESC
489
+ `).bind(username, Date.now()).all();
490
+
491
+ if (!result.results) {
492
+ return [];
493
+ }
355
494
 
356
- const topics = (topicsResult.results || []).map((row: any) => ({
357
- topic: row.topic,
358
- activePeers: row.active_peers,
495
+ return result.results.map((row: any) => ({
496
+ uuid: row.uuid,
497
+ isPublic: row.is_public === 1,
498
+ serviceFqn: row.is_public === 1 ? row.service_fqn : undefined,
499
+ metadata: row.is_public === 1 ? row.metadata || undefined : undefined,
359
500
  }));
501
+ }
502
+
503
+ async queryService(username: string, serviceFqn: string): Promise<string | null> {
504
+ const result = await this.db.prepare(`
505
+ SELECT si.uuid FROM service_index si
506
+ INNER JOIN services s ON si.service_id = s.id
507
+ WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ?
508
+ `).bind(username, serviceFqn, Date.now()).first();
509
+
510
+ return result ? (result as any).uuid : null;
511
+ }
512
+
513
+ async deleteService(serviceId: string, username: string): Promise<boolean> {
514
+ const result = await this.db.prepare(`
515
+ DELETE FROM services
516
+ WHERE id = ? AND username = ?
517
+ `).bind(serviceId, username).run();
518
+
519
+ return (result.meta.changes || 0) > 0;
520
+ }
521
+
522
+ async deleteExpiredServices(now: number): Promise<number> {
523
+ const result = await this.db.prepare(`
524
+ DELETE FROM services WHERE expires_at < ?
525
+ `).bind(now).run();
360
526
 
361
- return { topics, total };
527
+ return result.meta.changes || 0;
362
528
  }
363
529
 
364
530
  async close(): Promise<void> {
@@ -366,22 +532,16 @@ export class D1Storage implements Storage {
366
532
  // Connections are managed by the Cloudflare Workers runtime
367
533
  }
368
534
 
535
+ // ===== Helper Methods =====
536
+
369
537
  /**
370
- * Helper method to convert database row to Offer object with topics
538
+ * Helper method to convert database row to Offer object
371
539
  */
372
- private async rowToOffer(row: any): Promise<Offer> {
373
- // Get topics for this offer
374
- const topicResult = await this.db.prepare(`
375
- SELECT topic FROM offer_topics WHERE offer_id = ?
376
- `).bind(row.id).all();
377
-
378
- const topics = topicResult.results?.map((t: any) => t.topic) || [];
379
-
540
+ private rowToOffer(row: any): Offer {
380
541
  return {
381
542
  id: row.id,
382
543
  peerId: row.peer_id,
383
544
  sdp: row.sdp,
384
- topics,
385
545
  createdAt: row.created_at,
386
546
  expiresAt: row.expires_at,
387
547
  lastSeen: row.last_seen,
@@ -391,4 +551,20 @@ export class D1Storage implements Storage {
391
551
  answeredAt: row.answered_at || undefined,
392
552
  };
393
553
  }
554
+
555
+ /**
556
+ * Helper method to convert database row to Service object
557
+ */
558
+ private rowToService(row: any): Service {
559
+ return {
560
+ id: row.id,
561
+ username: row.username,
562
+ serviceFqn: row.service_fqn,
563
+ offerId: row.offer_id,
564
+ createdAt: row.created_at,
565
+ expiresAt: row.expires_at,
566
+ isPublic: row.is_public === 1,
567
+ metadata: row.metadata || undefined,
568
+ };
569
+ }
394
570
  }