@xtr-dev/rondevu-server 0.3.0 → 0.5.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.
@@ -9,9 +9,9 @@ import {
9
9
  ClaimUsernameRequest,
10
10
  Service,
11
11
  CreateServiceRequest,
12
- ServiceInfo,
13
12
  } from './types.ts';
14
13
  import { generateOfferHash } from './hash-id.ts';
14
+ import { parseServiceFqn } from '../crypto.ts';
15
15
 
16
16
  const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
17
17
 
@@ -39,30 +39,29 @@ export class SQLiteStorage implements Storage {
39
39
  -- WebRTC signaling offers
40
40
  CREATE TABLE IF NOT EXISTS offers (
41
41
  id TEXT PRIMARY KEY,
42
- peer_id TEXT NOT NULL,
42
+ username TEXT NOT NULL,
43
43
  service_id TEXT,
44
44
  sdp TEXT NOT NULL,
45
45
  created_at INTEGER NOT NULL,
46
46
  expires_at INTEGER NOT NULL,
47
47
  last_seen INTEGER NOT NULL,
48
- secret TEXT,
49
- answerer_peer_id TEXT,
48
+ answerer_username TEXT,
50
49
  answer_sdp TEXT,
51
50
  answered_at INTEGER,
52
51
  FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
53
52
  );
54
53
 
55
- CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
54
+ CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
56
55
  CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
57
56
  CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
58
57
  CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
59
- CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
58
+ CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
60
59
 
61
60
  -- ICE candidates table
62
61
  CREATE TABLE IF NOT EXISTS ice_candidates (
63
62
  id INTEGER PRIMARY KEY AUTOINCREMENT,
64
63
  offer_id TEXT NOT NULL,
65
- peer_id TEXT NOT NULL,
64
+ username TEXT NOT NULL,
66
65
  role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
67
66
  candidate TEXT NOT NULL,
68
67
  created_at INTEGER NOT NULL,
@@ -70,7 +69,7 @@ export class SQLiteStorage implements Storage {
70
69
  );
71
70
 
72
71
  CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
73
- CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
72
+ CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
74
73
  CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
75
74
 
76
75
  -- Usernames table
@@ -87,36 +86,23 @@ export class SQLiteStorage implements Storage {
87
86
  CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
88
87
  CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
89
88
 
90
- -- Services table (one service can have multiple offers)
89
+ -- Services table (new schema with extracted fields for discovery)
91
90
  CREATE TABLE IF NOT EXISTS services (
92
91
  id TEXT PRIMARY KEY,
93
- username TEXT NOT NULL,
94
92
  service_fqn TEXT NOT NULL,
93
+ service_name TEXT NOT NULL,
94
+ version TEXT NOT NULL,
95
+ username TEXT NOT NULL,
95
96
  created_at INTEGER NOT NULL,
96
97
  expires_at INTEGER NOT NULL,
97
- is_public INTEGER NOT NULL DEFAULT 0,
98
- metadata TEXT,
99
98
  FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
100
- UNIQUE(username, service_fqn)
99
+ UNIQUE(service_fqn)
101
100
  );
102
101
 
103
- CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
104
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
105
  CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
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);
120
106
  `);
121
107
 
122
108
  // Enable foreign keys
@@ -139,8 +125,8 @@ export class SQLiteStorage implements Storage {
139
125
  // Use transaction for atomic creation
140
126
  const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
141
127
  const offerStmt = this.db.prepare(`
142
- INSERT INTO offers (id, peer_id, service_id, sdp, created_at, expires_at, last_seen, secret)
143
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
128
+ INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?)
144
130
  `);
145
131
 
146
132
  for (const offer of offersWithIds) {
@@ -149,24 +135,23 @@ export class SQLiteStorage implements Storage {
149
135
  // Insert offer
150
136
  offerStmt.run(
151
137
  offer.id,
152
- offer.peerId,
138
+ offer.username,
153
139
  offer.serviceId || null,
154
140
  offer.sdp,
155
141
  now,
156
142
  offer.expiresAt,
157
- now,
158
- offer.secret || null
143
+ now
159
144
  );
160
145
 
161
146
  created.push({
162
147
  id: offer.id,
163
- peerId: offer.peerId,
148
+ username: offer.username,
164
149
  serviceId: offer.serviceId || undefined,
150
+ serviceFqn: offer.serviceFqn,
165
151
  sdp: offer.sdp,
166
152
  createdAt: now,
167
153
  expiresAt: offer.expiresAt,
168
154
  lastSeen: now,
169
- secret: offer.secret,
170
155
  });
171
156
  }
172
157
  });
@@ -175,14 +160,14 @@ export class SQLiteStorage implements Storage {
175
160
  return created;
176
161
  }
177
162
 
178
- async getOffersByPeerId(peerId: string): Promise<Offer[]> {
163
+ async getOffersByUsername(username: string): Promise<Offer[]> {
179
164
  const stmt = this.db.prepare(`
180
165
  SELECT * FROM offers
181
- WHERE peer_id = ? AND expires_at > ?
166
+ WHERE username = ? AND expires_at > ?
182
167
  ORDER BY last_seen DESC
183
168
  `);
184
169
 
185
- const rows = stmt.all(peerId, Date.now()) as any[];
170
+ const rows = stmt.all(username, Date.now()) as any[];
186
171
  return rows.map(row => this.rowToOffer(row));
187
172
  }
188
173
 
@@ -201,13 +186,13 @@ export class SQLiteStorage implements Storage {
201
186
  return this.rowToOffer(row);
202
187
  }
203
188
 
204
- async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
189
+ async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
205
190
  const stmt = this.db.prepare(`
206
191
  DELETE FROM offers
207
- WHERE id = ? AND peer_id = ?
192
+ WHERE id = ? AND username = ?
208
193
  `);
209
194
 
210
- const result = stmt.run(offerId, ownerPeerId);
195
+ const result = stmt.run(offerId, ownerUsername);
211
196
  return result.changes > 0;
212
197
  }
213
198
 
@@ -219,9 +204,8 @@ export class SQLiteStorage implements Storage {
219
204
 
220
205
  async answerOffer(
221
206
  offerId: string,
222
- answererPeerId: string,
223
- answerSdp: string,
224
- secret?: string
207
+ answererUsername: string,
208
+ answerSdp: string
225
209
  ): Promise<{ success: boolean; error?: string }> {
226
210
  // Check if offer exists and is not expired
227
211
  const offer = await this.getOfferById(offerId);
@@ -233,16 +217,8 @@ export class SQLiteStorage implements Storage {
233
217
  };
234
218
  }
235
219
 
236
- // Verify secret if offer is protected
237
- if (offer.secret && offer.secret !== secret) {
238
- return {
239
- success: false,
240
- error: 'Invalid or missing secret'
241
- };
242
- }
243
-
244
220
  // Check if offer already has an answerer
245
- if (offer.answererPeerId) {
221
+ if (offer.answererUsername) {
246
222
  return {
247
223
  success: false,
248
224
  error: 'Offer already answered'
@@ -252,11 +228,11 @@ export class SQLiteStorage implements Storage {
252
228
  // Update offer with answer
253
229
  const stmt = this.db.prepare(`
254
230
  UPDATE offers
255
- SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
256
- WHERE id = ? AND answerer_peer_id IS NULL
231
+ SET answerer_username = ?, answer_sdp = ?, answered_at = ?
232
+ WHERE id = ? AND answerer_username IS NULL
257
233
  `);
258
234
 
259
- const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId);
235
+ const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
260
236
 
261
237
  if (result.changes === 0) {
262
238
  return {
@@ -268,14 +244,14 @@ export class SQLiteStorage implements Storage {
268
244
  return { success: true };
269
245
  }
270
246
 
271
- async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
247
+ async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
272
248
  const stmt = this.db.prepare(`
273
249
  SELECT * FROM offers
274
- WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
250
+ WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
275
251
  ORDER BY answered_at DESC
276
252
  `);
277
253
 
278
- const rows = stmt.all(offererPeerId, Date.now()) as any[];
254
+ const rows = stmt.all(offererUsername, Date.now()) as any[];
279
255
  return rows.map(row => this.rowToOffer(row));
280
256
  }
281
257
 
@@ -283,12 +259,12 @@ export class SQLiteStorage implements Storage {
283
259
 
284
260
  async addIceCandidates(
285
261
  offerId: string,
286
- peerId: string,
262
+ username: string,
287
263
  role: 'offerer' | 'answerer',
288
264
  candidates: any[]
289
265
  ): Promise<number> {
290
266
  const stmt = this.db.prepare(`
291
- INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
267
+ INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
292
268
  VALUES (?, ?, ?, ?, ?)
293
269
  `);
294
270
 
@@ -297,7 +273,7 @@ export class SQLiteStorage implements Storage {
297
273
  for (let i = 0; i < candidates.length; i++) {
298
274
  stmt.run(
299
275
  offerId,
300
- peerId,
276
+ username,
301
277
  role,
302
278
  JSON.stringify(candidates[i]),
303
279
  baseTimestamp + i
@@ -334,7 +310,7 @@ export class SQLiteStorage implements Storage {
334
310
  return rows.map(row => ({
335
311
  id: row.id,
336
312
  offerId: row.offer_id,
337
- peerId: row.peer_id,
313
+ username: row.username,
338
314
  role: row.role,
339
315
  candidate: JSON.parse(row.candidate),
340
316
  createdAt: row.created_at,
@@ -427,87 +403,96 @@ export class SQLiteStorage implements Storage {
427
403
 
428
404
  async createService(request: CreateServiceRequest): Promise<{
429
405
  service: Service;
430
- indexUuid: string;
431
406
  offers: Offer[];
432
407
  }> {
433
408
  const serviceId = randomUUID();
434
- const indexUuid = randomUUID();
435
409
  const now = Date.now();
436
410
 
437
- // Create offers with serviceId
438
- const offerRequests: CreateOfferRequest[] = request.offers.map(offer => ({
439
- ...offer,
440
- serviceId,
441
- }));
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
+ }
442
419
 
443
- const offers = await this.createOffers(offerRequests);
420
+ const { serviceName, version, username } = parsed;
444
421
 
445
422
  const transaction = this.db.transaction(() => {
446
- // Insert service (no offer_id column anymore)
447
- const serviceStmt = this.db.prepare(`
448
- INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata)
449
- VALUES (?, ?, ?, ?, ?, ?, ?)
450
- `);
451
-
452
- serviceStmt.run(
453
- serviceId,
454
- request.username,
455
- request.serviceFqn,
456
- now,
457
- request.expiresAt,
458
- request.isPublic ? 1 : 0,
459
- request.metadata || null
460
- );
461
-
462
- // Insert service index
463
- const indexStmt = this.db.prepare(`
464
- INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
465
- VALUES (?, ?, ?, ?, ?, ?)
466
- `);
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);
439
+ }
467
440
 
468
- indexStmt.run(
469
- indexUuid,
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(
470
446
  serviceId,
471
- request.username,
472
447
  request.serviceFqn,
448
+ serviceName,
449
+ version,
450
+ username,
473
451
  now,
474
452
  request.expiresAt
475
453
  );
476
454
 
477
- // Touch username to extend expiry
478
- this.touchUsername(request.username);
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);
479
462
  });
480
463
 
481
464
  transaction();
482
465
 
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);
472
+
483
473
  return {
484
474
  service: {
485
475
  id: serviceId,
486
- username: request.username,
487
476
  serviceFqn: request.serviceFqn,
477
+ serviceName,
478
+ version,
479
+ username,
488
480
  createdAt: now,
489
481
  expiresAt: request.expiresAt,
490
- isPublic: request.isPublic || false,
491
- metadata: request.metadata,
492
482
  },
493
- indexUuid,
494
483
  offers,
495
484
  };
496
485
  }
497
486
 
498
- async batchCreateServices(requests: CreateServiceRequest[]): Promise<Array<{
499
- service: Service;
500
- indexUuid: string;
501
- offers: Offer[];
502
- }>> {
503
- const results = [];
504
-
505
- for (const request of requests) {
506
- const result = await this.createService(request);
507
- results.push(result);
508
- }
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
+ `);
509
493
 
510
- return results;
494
+ const rows = stmt.all(serviceId, Date.now()) as any[];
495
+ return rows.map(row => this.rowToOffer(row));
511
496
  }
512
497
 
513
498
  async getServiceById(serviceId: string): Promise<Service | null> {
@@ -525,14 +510,13 @@ export class SQLiteStorage implements Storage {
525
510
  return this.rowToService(row);
526
511
  }
527
512
 
528
- async getServiceByUuid(uuid: string): Promise<Service | null> {
513
+ async getServiceByFqn(serviceFqn: string): Promise<Service | null> {
529
514
  const stmt = this.db.prepare(`
530
- SELECT s.* FROM services s
531
- INNER JOIN service_index si ON s.id = si.service_id
532
- WHERE si.uuid = ? AND s.expires_at > ?
515
+ SELECT * FROM services
516
+ WHERE service_fqn = ? AND expires_at > ?
533
517
  `);
534
518
 
535
- const row = stmt.get(uuid, Date.now()) as any;
519
+ const row = stmt.get(serviceFqn, Date.now()) as any;
536
520
 
537
521
  if (!row) {
538
522
  return null;
@@ -541,47 +525,51 @@ export class SQLiteStorage implements Storage {
541
525
  return this.rowToService(row);
542
526
  }
543
527
 
544
- async listServicesForUsername(username: string): Promise<ServiceInfo[]> {
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)
545
536
  const stmt = this.db.prepare(`
546
- SELECT si.uuid, s.is_public, s.service_fqn, s.metadata
547
- FROM service_index si
548
- INNER JOIN services s ON si.service_id = s.id
549
- WHERE si.username = ? AND si.expires_at > ?
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 > ?
550
544
  ORDER BY s.created_at DESC
545
+ LIMIT ? OFFSET ?
551
546
  `);
552
547
 
553
- const rows = stmt.all(username, Date.now()) as any[];
554
-
555
- return rows.map(row => ({
556
- uuid: row.uuid,
557
- isPublic: row.is_public === 1,
558
- serviceFqn: row.is_public === 1 ? row.service_fqn : undefined,
559
- metadata: row.is_public === 1 ? row.metadata || undefined : undefined,
560
- }));
548
+ const rows = stmt.all(serviceName, version, Date.now(), Date.now(), limit, offset) as any[];
549
+ return rows.map(row => this.rowToService(row));
561
550
  }
562
551
 
563
- async queryService(username: string, serviceFqn: string): Promise<string | null> {
552
+ async getRandomService(serviceName: string, version: string): Promise<Service | null> {
553
+ // Get a random service with an available offer
564
554
  const stmt = this.db.prepare(`
565
- SELECT si.uuid FROM service_index si
566
- INNER JOIN services s ON si.service_id = s.id
567
- WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ?
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
568
564
  `);
569
565
 
570
- const row = stmt.get(username, serviceFqn, Date.now()) as any;
571
-
572
- return row ? row.uuid : null;
573
- }
574
-
575
- async findServicesByName(username: string, serviceName: string): Promise<Service[]> {
576
- const stmt = this.db.prepare(`
577
- SELECT * FROM services
578
- WHERE username = ? AND service_fqn LIKE ? AND expires_at > ?
579
- ORDER BY created_at DESC
580
- `);
566
+ const row = stmt.get(serviceName, version, Date.now(), Date.now()) as any;
581
567
 
582
- const rows = stmt.all(username, `${serviceName}@%`, Date.now()) as any[];
568
+ if (!row) {
569
+ return null;
570
+ }
583
571
 
584
- return rows.map(row => this.rowToService(row));
572
+ return this.rowToService(row);
585
573
  }
586
574
 
587
575
  async deleteService(serviceId: string, username: string): Promise<boolean> {
@@ -612,14 +600,14 @@ export class SQLiteStorage implements Storage {
612
600
  private rowToOffer(row: any): Offer {
613
601
  return {
614
602
  id: row.id,
615
- peerId: row.peer_id,
603
+ username: row.username,
616
604
  serviceId: row.service_id || undefined,
605
+ serviceFqn: row.service_fqn || undefined,
617
606
  sdp: row.sdp,
618
607
  createdAt: row.created_at,
619
608
  expiresAt: row.expires_at,
620
609
  lastSeen: row.last_seen,
621
- secret: row.secret || undefined,
622
- answererPeerId: row.answerer_peer_id || undefined,
610
+ answererUsername: row.answerer_username || undefined,
623
611
  answerSdp: row.answer_sdp || undefined,
624
612
  answeredAt: row.answered_at || undefined,
625
613
  };
@@ -631,26 +619,12 @@ export class SQLiteStorage implements Storage {
631
619
  private rowToService(row: any): Service {
632
620
  return {
633
621
  id: row.id,
634
- username: row.username,
635
622
  serviceFqn: row.service_fqn,
623
+ serviceName: row.service_name,
624
+ version: row.version,
625
+ username: row.username,
636
626
  createdAt: row.created_at,
637
627
  expiresAt: row.expires_at,
638
- isPublic: row.is_public === 1,
639
- metadata: row.metadata || undefined,
640
628
  };
641
629
  }
642
-
643
- /**
644
- * Get all offers for a service
645
- */
646
- async getOffersForService(serviceId: string): Promise<Offer[]> {
647
- const stmt = this.db.prepare(`
648
- SELECT * FROM offers
649
- WHERE service_id = ? AND expires_at > ?
650
- ORDER BY created_at ASC
651
- `);
652
-
653
- const rows = stmt.all(serviceId, Date.now()) as any[];
654
- return rows.map(row => this.rowToOffer(row));
655
- }
656
630
  }