@xtr-dev/rondevu-server 0.5.12 → 0.5.14

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