@xtr-dev/rondevu-server 0.5.11 → 0.5.13

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.
@@ -4,61 +4,67 @@ import {
4
4
  Offer,
5
5
  IceCandidate,
6
6
  CreateOfferRequest,
7
- Credential,
8
- GenerateCredentialsRequest,
9
7
  } from './types.ts';
10
8
  import { generateOfferHash } from './hash-id.ts';
11
9
 
12
- const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
13
-
14
10
  /**
15
11
  * SQLite storage adapter for rondevu signaling system
16
12
  * Supports both file-based and in-memory databases
17
13
  */
18
14
  export class SQLiteStorage implements Storage {
19
15
  private db: Database.Database;
20
- private masterEncryptionKey: string;
21
16
 
22
17
  /**
23
18
  * Creates a new SQLite storage instance
24
19
  * @param path Path to SQLite database file, or ':memory:' for in-memory database
25
- * @param masterEncryptionKey 64-char hex string for encrypting secrets (32 bytes)
26
20
  */
27
- constructor(path: string = ':memory:', masterEncryptionKey: string) {
21
+ constructor(path: string = ':memory:') {
28
22
  this.db = new Database(path);
29
- this.masterEncryptionKey = masterEncryptionKey;
30
23
  this.initializeDatabase();
31
24
  }
32
25
 
33
26
  /**
34
- * Initializes database schema with tags-based offers
27
+ * Initializes database schema with Ed25519 public key identity
35
28
  */
36
29
  private initializeDatabase(): void {
37
30
  this.db.exec(`
31
+ -- Identities table (Ed25519 public key as identity)
32
+ CREATE TABLE IF NOT EXISTS identities (
33
+ public_key TEXT PRIMARY KEY,
34
+ created_at INTEGER NOT NULL,
35
+ expires_at INTEGER NOT NULL,
36
+ last_used INTEGER NOT NULL,
37
+ CHECK(length(public_key) = 64)
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_identities_expires ON identities(expires_at);
41
+
38
42
  -- WebRTC signaling offers with tags
39
43
  CREATE TABLE IF NOT EXISTS offers (
40
44
  id TEXT PRIMARY KEY,
41
- username TEXT NOT NULL,
45
+ public_key TEXT NOT NULL,
42
46
  tags TEXT NOT NULL,
43
47
  sdp TEXT NOT NULL,
44
48
  created_at INTEGER NOT NULL,
45
49
  expires_at INTEGER NOT NULL,
46
50
  last_seen INTEGER NOT NULL,
47
- answerer_username TEXT,
51
+ answerer_public_key TEXT,
48
52
  answer_sdp TEXT,
49
- answered_at INTEGER
53
+ answered_at INTEGER,
54
+ matched_tags TEXT,
55
+ FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
50
56
  );
51
57
 
52
- CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
58
+ CREATE INDEX IF NOT EXISTS idx_offers_public_key ON offers(public_key);
53
59
  CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
54
60
  CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
55
- CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
61
+ CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_public_key);
56
62
 
57
63
  -- ICE candidates table
58
64
  CREATE TABLE IF NOT EXISTS ice_candidates (
59
65
  id INTEGER PRIMARY KEY AUTOINCREMENT,
60
66
  offer_id TEXT NOT NULL,
61
- username TEXT NOT NULL,
67
+ public_key TEXT NOT NULL,
62
68
  role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
63
69
  candidate TEXT NOT NULL,
64
70
  created_at INTEGER NOT NULL,
@@ -66,22 +72,9 @@ export class SQLiteStorage implements Storage {
66
72
  );
67
73
 
68
74
  CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
69
- CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
75
+ CREATE INDEX IF NOT EXISTS idx_ice_public_key ON ice_candidates(public_key);
70
76
  CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
71
77
 
72
- -- Credentials table (replaces usernames with simpler name + secret auth)
73
- CREATE TABLE IF NOT EXISTS credentials (
74
- name TEXT PRIMARY KEY,
75
- secret TEXT NOT NULL UNIQUE,
76
- created_at INTEGER NOT NULL,
77
- expires_at INTEGER NOT NULL,
78
- last_used INTEGER NOT NULL,
79
- CHECK(length(name) >= 3 AND length(name) <= 32)
80
- );
81
-
82
- CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at);
83
- CREATE INDEX IF NOT EXISTS idx_credentials_secret ON credentials(secret);
84
-
85
78
  -- Rate limits table (for distributed rate limiting)
86
79
  CREATE TABLE IF NOT EXISTS rate_limits (
87
80
  identifier TEXT PRIMARY KEY,
@@ -120,7 +113,7 @@ export class SQLiteStorage implements Storage {
120
113
  // Use transaction for atomic creation
121
114
  const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => {
122
115
  const offerStmt = this.db.prepare(`
123
- INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
116
+ INSERT INTO offers (id, public_key, tags, sdp, created_at, expires_at, last_seen)
124
117
  VALUES (?, ?, ?, ?, ?, ?, ?)
125
118
  `);
126
119
 
@@ -130,7 +123,7 @@ export class SQLiteStorage implements Storage {
130
123
  // Insert offer with JSON-serialized tags
131
124
  offerStmt.run(
132
125
  offer.id,
133
- offer.username,
126
+ offer.publicKey,
134
127
  JSON.stringify(offer.tags),
135
128
  offer.sdp,
136
129
  now,
@@ -140,7 +133,7 @@ export class SQLiteStorage implements Storage {
140
133
 
141
134
  created.push({
142
135
  id: offer.id,
143
- username: offer.username,
136
+ publicKey: offer.publicKey,
144
137
  tags: offer.tags,
145
138
  sdp: offer.sdp,
146
139
  createdAt: now,
@@ -154,14 +147,14 @@ export class SQLiteStorage implements Storage {
154
147
  return created;
155
148
  }
156
149
 
157
- async getOffersByUsername(username: string): Promise<Offer[]> {
150
+ async getOffersByPublicKey(publicKey: string): Promise<Offer[]> {
158
151
  const stmt = this.db.prepare(`
159
152
  SELECT * FROM offers
160
- WHERE username = ? AND expires_at > ?
153
+ WHERE public_key = ? AND expires_at > ?
161
154
  ORDER BY last_seen DESC
162
155
  `);
163
156
 
164
- const rows = stmt.all(username, Date.now()) as any[];
157
+ const rows = stmt.all(publicKey, Date.now()) as any[];
165
158
  return rows.map(row => this.rowToOffer(row));
166
159
  }
167
160
 
@@ -180,13 +173,13 @@ export class SQLiteStorage implements Storage {
180
173
  return this.rowToOffer(row);
181
174
  }
182
175
 
183
- async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
176
+ async deleteOffer(offerId: string, ownerPublicKey: string): Promise<boolean> {
184
177
  const stmt = this.db.prepare(`
185
178
  DELETE FROM offers
186
- WHERE id = ? AND username = ?
179
+ WHERE id = ? AND public_key = ?
187
180
  `);
188
181
 
189
- const result = stmt.run(offerId, ownerUsername);
182
+ const result = stmt.run(offerId, ownerPublicKey);
190
183
  return result.changes > 0;
191
184
  }
192
185
 
@@ -198,8 +191,9 @@ export class SQLiteStorage implements Storage {
198
191
 
199
192
  async answerOffer(
200
193
  offerId: string,
201
- answererUsername: string,
202
- answerSdp: string
194
+ answererPublicKey: string,
195
+ answerSdp: string,
196
+ matchedTags?: string[]
203
197
  ): Promise<{ success: boolean; error?: string }> {
204
198
  // Check if offer exists and is not expired
205
199
  const offer = await this.getOfferById(offerId);
@@ -212,7 +206,7 @@ export class SQLiteStorage implements Storage {
212
206
  }
213
207
 
214
208
  // Check if offer already has an answerer
215
- if (offer.answererUsername) {
209
+ if (offer.answererPublicKey) {
216
210
  return {
217
211
  success: false,
218
212
  error: 'Offer already answered'
@@ -222,11 +216,12 @@ export class SQLiteStorage implements Storage {
222
216
  // Update offer with answer
223
217
  const stmt = this.db.prepare(`
224
218
  UPDATE offers
225
- SET answerer_username = ?, answer_sdp = ?, answered_at = ?
226
- WHERE id = ? AND answerer_username IS NULL
219
+ SET answerer_public_key = ?, answer_sdp = ?, answered_at = ?, matched_tags = ?
220
+ WHERE id = ? AND answerer_public_key IS NULL
227
221
  `);
228
222
 
229
- const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
223
+ const matchedTagsJson = matchedTags ? JSON.stringify(matchedTags) : null;
224
+ const result = stmt.run(answererPublicKey, answerSdp, Date.now(), matchedTagsJson, offerId);
230
225
 
231
226
  if (result.changes === 0) {
232
227
  return {
@@ -238,25 +233,25 @@ export class SQLiteStorage implements Storage {
238
233
  return { success: true };
239
234
  }
240
235
 
241
- async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
236
+ async getAnsweredOffers(offererPublicKey: string): Promise<Offer[]> {
242
237
  const stmt = this.db.prepare(`
243
238
  SELECT * FROM offers
244
- WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
239
+ WHERE public_key = ? AND answerer_public_key IS NOT NULL AND expires_at > ?
245
240
  ORDER BY answered_at DESC
246
241
  `);
247
242
 
248
- const rows = stmt.all(offererUsername, Date.now()) as any[];
243
+ const rows = stmt.all(offererPublicKey, Date.now()) as any[];
249
244
  return rows.map(row => this.rowToOffer(row));
250
245
  }
251
246
 
252
- async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
247
+ async getOffersAnsweredBy(answererPublicKey: string): Promise<Offer[]> {
253
248
  const stmt = this.db.prepare(`
254
249
  SELECT * FROM offers
255
- WHERE answerer_username = ? AND expires_at > ?
250
+ WHERE answerer_public_key = ? AND expires_at > ?
256
251
  ORDER BY answered_at DESC
257
252
  `);
258
253
 
259
- const rows = stmt.all(answererUsername, Date.now()) as any[];
254
+ const rows = stmt.all(answererPublicKey, Date.now()) as any[];
260
255
  return rows.map(row => this.rowToOffer(row));
261
256
  }
262
257
 
@@ -264,7 +259,7 @@ export class SQLiteStorage implements Storage {
264
259
 
265
260
  async discoverOffers(
266
261
  tags: string[],
267
- excludeUsername: string | null,
262
+ excludePublicKey: string | null,
268
263
  limit: number,
269
264
  offset: number
270
265
  ): Promise<Offer[]> {
@@ -280,14 +275,14 @@ export class SQLiteStorage implements Storage {
280
275
  SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
281
276
  WHERE t.value IN (${placeholders})
282
277
  AND o.expires_at > ?
283
- AND o.answerer_username IS NULL
278
+ AND o.answerer_public_key IS NULL
284
279
  `;
285
280
 
286
281
  const params: any[] = [...tags, Date.now()];
287
282
 
288
- if (excludeUsername) {
289
- query += ' AND o.username != ?';
290
- params.push(excludeUsername);
283
+ if (excludePublicKey) {
284
+ query += ' AND o.public_key != ?';
285
+ params.push(excludePublicKey);
291
286
  }
292
287
 
293
288
  query += ' ORDER BY o.created_at DESC LIMIT ? OFFSET ?';
@@ -300,7 +295,7 @@ export class SQLiteStorage implements Storage {
300
295
 
301
296
  async getRandomOffer(
302
297
  tags: string[],
303
- excludeUsername: string | null
298
+ excludePublicKey: string | null
304
299
  ): Promise<Offer | null> {
305
300
  if (tags.length === 0) {
306
301
  return null;
@@ -313,14 +308,14 @@ export class SQLiteStorage implements Storage {
313
308
  SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
314
309
  WHERE t.value IN (${placeholders})
315
310
  AND o.expires_at > ?
316
- AND o.answerer_username IS NULL
311
+ AND o.answerer_public_key IS NULL
317
312
  `;
318
313
 
319
314
  const params: any[] = [...tags, Date.now()];
320
315
 
321
- if (excludeUsername) {
322
- query += ' AND o.username != ?';
323
- params.push(excludeUsername);
316
+ if (excludePublicKey) {
317
+ query += ' AND o.public_key != ?';
318
+ params.push(excludePublicKey);
324
319
  }
325
320
 
326
321
  query += ' ORDER BY RANDOM() LIMIT 1';
@@ -335,12 +330,12 @@ export class SQLiteStorage implements Storage {
335
330
 
336
331
  async addIceCandidates(
337
332
  offerId: string,
338
- username: string,
333
+ publicKey: string,
339
334
  role: 'offerer' | 'answerer',
340
335
  candidates: any[]
341
336
  ): Promise<number> {
342
337
  const stmt = this.db.prepare(`
343
- INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
338
+ INSERT INTO ice_candidates (offer_id, public_key, role, candidate, created_at)
344
339
  VALUES (?, ?, ?, ?, ?)
345
340
  `);
346
341
 
@@ -349,7 +344,7 @@ export class SQLiteStorage implements Storage {
349
344
  for (let i = 0; i < candidates.length; i++) {
350
345
  stmt.run(
351
346
  offerId,
352
- username,
347
+ publicKey,
353
348
  role,
354
349
  JSON.stringify(candidates[i]),
355
350
  baseTimestamp + i
@@ -386,7 +381,7 @@ export class SQLiteStorage implements Storage {
386
381
  return rows.map(row => ({
387
382
  id: row.id,
388
383
  offerId: row.offer_id,
389
- username: row.username,
384
+ publicKey: row.public_key,
390
385
  role: row.role,
391
386
  candidate: JSON.parse(row.candidate),
392
387
  createdAt: row.created_at,
@@ -395,7 +390,7 @@ export class SQLiteStorage implements Storage {
395
390
 
396
391
  async getIceCandidatesForMultipleOffers(
397
392
  offerIds: string[],
398
- username: string,
393
+ publicKey: string,
399
394
  since?: number
400
395
  ): Promise<Map<string, IceCandidate[]>> {
401
396
  const result = new Map<string, IceCandidate[]>();
@@ -420,17 +415,17 @@ export class SQLiteStorage implements Storage {
420
415
  const placeholders = offerIds.map(() => '?').join(',');
421
416
 
422
417
  let query = `
423
- SELECT ic.*, o.username as offer_username
418
+ SELECT ic.*, o.public_key as offer_public_key
424
419
  FROM ice_candidates ic
425
420
  INNER JOIN offers o ON o.id = ic.offer_id
426
421
  WHERE ic.offer_id IN (${placeholders})
427
422
  AND (
428
- (o.username = ? AND ic.role = 'answerer')
429
- OR (o.answerer_username = ? AND ic.role = 'offerer')
423
+ (o.public_key = ? AND ic.role = 'answerer')
424
+ OR (o.answerer_public_key = ? AND ic.role = 'offerer')
430
425
  )
431
426
  `;
432
427
 
433
- const params: any[] = [...offerIds, username, username];
428
+ const params: any[] = [...offerIds, publicKey, publicKey];
434
429
 
435
430
  if (since !== undefined) {
436
431
  query += ' AND ic.created_at > ?';
@@ -447,7 +442,7 @@ export class SQLiteStorage implements Storage {
447
442
  const candidate: IceCandidate = {
448
443
  id: row.id,
449
444
  offerId: row.offer_id,
450
- username: row.username,
445
+ publicKey: row.public_key,
451
446
  role: row.role,
452
447
  candidate: JSON.parse(row.candidate),
453
448
  createdAt: row.created_at,
@@ -462,122 +457,6 @@ export class SQLiteStorage implements Storage {
462
457
  return result;
463
458
  }
464
459
 
465
- // ===== Credential Management =====
466
-
467
- async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
468
- const now = Date.now();
469
- const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
470
-
471
- const { generateCredentialName, generateSecret } = await import('../crypto.ts');
472
-
473
- let name: string;
474
-
475
- if (request.name) {
476
- // User requested specific username - check if available
477
- const existing = this.db.prepare(`
478
- SELECT name FROM credentials WHERE name = ?
479
- `).get(request.name);
480
-
481
- if (existing) {
482
- throw new Error('Username already taken');
483
- }
484
-
485
- name = request.name;
486
- } else {
487
- // Generate random name - retry until unique
488
- let attempts = 0;
489
- const maxAttempts = 100;
490
-
491
- while (attempts < maxAttempts) {
492
- name = generateCredentialName();
493
-
494
- const existing = this.db.prepare(`
495
- SELECT name FROM credentials WHERE name = ?
496
- `).get(name);
497
-
498
- if (!existing) {
499
- break;
500
- }
501
-
502
- attempts++;
503
- }
504
-
505
- if (attempts >= maxAttempts) {
506
- throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
507
- }
508
- }
509
-
510
- const secret = generateSecret();
511
-
512
- // Encrypt secret before storing (AES-256-GCM)
513
- const { encryptSecret } = await import('../crypto.ts');
514
- const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
515
-
516
- // Insert credential with encrypted secret
517
- const stmt = this.db.prepare(`
518
- INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
519
- VALUES (?, ?, ?, ?, ?)
520
- `);
521
-
522
- stmt.run(name!, encryptedSecret, now, expiresAt, now);
523
-
524
- // Return plaintext secret to user (only time they'll see it)
525
- return {
526
- name: name!,
527
- secret, // Return plaintext secret, not encrypted
528
- createdAt: now,
529
- expiresAt,
530
- lastUsed: now,
531
- };
532
- }
533
-
534
- async getCredential(name: string): Promise<Credential | null> {
535
- const stmt = this.db.prepare(`
536
- SELECT * FROM credentials
537
- WHERE name = ? AND expires_at > ?
538
- `);
539
-
540
- const row = stmt.get(name, Date.now()) as any;
541
-
542
- if (!row) {
543
- return null;
544
- }
545
-
546
- // Decrypt secret before returning
547
- // If decryption fails (e.g., master key rotated), treat as credential not found
548
- try {
549
- const { decryptSecret } = await import('../crypto.ts');
550
- const decryptedSecret = await decryptSecret(row.secret, this.masterEncryptionKey);
551
-
552
- return {
553
- name: row.name,
554
- secret: decryptedSecret, // Return decrypted secret
555
- createdAt: row.created_at,
556
- expiresAt: row.expires_at,
557
- lastUsed: row.last_used,
558
- };
559
- } catch (error) {
560
- console.error(`Failed to decrypt secret for credential '${name}':`, error);
561
- return null; // Treat as credential not found (fail-safe behavior)
562
- }
563
- }
564
-
565
- async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
566
- const stmt = this.db.prepare(`
567
- UPDATE credentials
568
- SET last_used = ?, expires_at = ?
569
- WHERE name = ?
570
- `);
571
-
572
- stmt.run(lastUsed, expiresAt, name);
573
- }
574
-
575
- async deleteExpiredCredentials(now: number): Promise<number> {
576
- const stmt = this.db.prepare('DELETE FROM credentials WHERE expires_at < ?');
577
- const result = stmt.run(now);
578
- return result.changes;
579
- }
580
-
581
460
  // ===== Rate Limiting =====
582
461
 
583
462
  async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
@@ -649,13 +528,8 @@ export class SQLiteStorage implements Storage {
649
528
  return result.count;
650
529
  }
651
530
 
652
- async getOfferCountByUsername(username: string): Promise<number> {
653
- const result = this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE username = ?').get(username) as { count: number };
654
- return result.count;
655
- }
656
-
657
- async getCredentialCount(): Promise<number> {
658
- const result = this.db.prepare('SELECT COUNT(*) as count FROM credentials').get() as { count: number };
531
+ async getOfferCountByPublicKey(publicKey: string): Promise<number> {
532
+ const result = this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE public_key = ?').get(publicKey) as { count: number };
659
533
  return result.count;
660
534
  }
661
535
 
@@ -672,15 +546,16 @@ export class SQLiteStorage implements Storage {
672
546
  private rowToOffer(row: any): Offer {
673
547
  return {
674
548
  id: row.id,
675
- username: row.username,
549
+ publicKey: row.public_key,
676
550
  tags: JSON.parse(row.tags),
677
551
  sdp: row.sdp,
678
552
  createdAt: row.created_at,
679
553
  expiresAt: row.expires_at,
680
554
  lastSeen: row.last_seen,
681
- answererUsername: row.answerer_username || undefined,
555
+ answererPublicKey: row.answerer_public_key || undefined,
682
556
  answerSdp: row.answer_sdp || undefined,
683
557
  answeredAt: row.answered_at || undefined,
558
+ matchedTags: row.matched_tags ? JSON.parse(row.matched_tags) : undefined,
684
559
  };
685
560
  }
686
561
  }