@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.
@@ -1,40 +1,29 @@
1
- import mysql, { Pool, PoolConnection, RowDataPacket, ResultSetHeader } from 'mysql2/promise';
1
+ import mysql, { Pool, RowDataPacket, ResultSetHeader } from 'mysql2/promise';
2
2
  import {
3
3
  Storage,
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;
13
-
14
10
  /**
15
11
  * MySQL storage adapter for rondevu signaling system
16
- * Uses connection pooling for efficient resource management
12
+ * Uses Ed25519 public key as identity (no usernames, no secrets)
17
13
  */
18
14
  export class MySQLStorage implements Storage {
19
15
  private pool: Pool;
20
- private masterEncryptionKey: string;
21
16
 
22
- private constructor(pool: Pool, masterEncryptionKey: string) {
17
+ private constructor(pool: Pool) {
23
18
  this.pool = pool;
24
- this.masterEncryptionKey = masterEncryptionKey;
25
19
  }
26
20
 
27
21
  /**
28
22
  * Creates a new MySQL storage instance with connection pooling
29
23
  * @param connectionString MySQL connection URL
30
- * @param masterEncryptionKey 64-char hex string for encrypting secrets
31
24
  * @param poolSize Maximum number of connections in the pool
32
25
  */
33
- static async create(
34
- connectionString: string,
35
- masterEncryptionKey: string,
36
- poolSize: number = 10
37
- ): Promise<MySQLStorage> {
26
+ static async create(connectionString: string, poolSize: number = 10): Promise<MySQLStorage> {
38
27
  const pool = mysql.createPool({
39
28
  uri: connectionString,
40
29
  waitForConnections: true,
@@ -44,7 +33,7 @@ export class MySQLStorage implements Storage {
44
33
  keepAliveInitialDelay: 10000,
45
34
  });
46
35
 
47
- const storage = new MySQLStorage(pool, masterEncryptionKey);
36
+ const storage = new MySQLStorage(pool);
48
37
  await storage.initializeDatabase();
49
38
  return storage;
50
39
  }
@@ -52,22 +41,34 @@ export class MySQLStorage implements Storage {
52
41
  private async initializeDatabase(): Promise<void> {
53
42
  const conn = await this.pool.getConnection();
54
43
  try {
44
+ await conn.query(`
45
+ CREATE TABLE IF NOT EXISTS identities (
46
+ public_key CHAR(64) PRIMARY KEY,
47
+ created_at BIGINT NOT NULL,
48
+ expires_at BIGINT NOT NULL,
49
+ last_used BIGINT NOT NULL,
50
+ INDEX idx_identities_expires (expires_at)
51
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
52
+ `);
53
+
55
54
  await conn.query(`
56
55
  CREATE TABLE IF NOT EXISTS offers (
57
56
  id VARCHAR(64) PRIMARY KEY,
58
- username VARCHAR(32) NOT NULL,
57
+ public_key CHAR(64) NOT NULL,
59
58
  tags JSON NOT NULL,
60
59
  sdp MEDIUMTEXT NOT NULL,
61
60
  created_at BIGINT NOT NULL,
62
61
  expires_at BIGINT NOT NULL,
63
62
  last_seen BIGINT NOT NULL,
64
- answerer_username VARCHAR(32),
63
+ answerer_public_key CHAR(64),
65
64
  answer_sdp MEDIUMTEXT,
66
65
  answered_at BIGINT,
67
- INDEX idx_offers_username (username),
66
+ matched_tags JSON,
67
+ INDEX idx_offers_public_key (public_key),
68
68
  INDEX idx_offers_expires (expires_at),
69
69
  INDEX idx_offers_last_seen (last_seen),
70
- INDEX idx_offers_answerer (answerer_username)
70
+ INDEX idx_offers_answerer (answerer_public_key),
71
+ FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
71
72
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
72
73
  `);
73
74
 
@@ -75,28 +76,17 @@ export class MySQLStorage implements Storage {
75
76
  CREATE TABLE IF NOT EXISTS ice_candidates (
76
77
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
77
78
  offer_id VARCHAR(64) NOT NULL,
78
- username VARCHAR(32) NOT NULL,
79
+ public_key CHAR(64) NOT NULL,
79
80
  role ENUM('offerer', 'answerer') NOT NULL,
80
81
  candidate JSON NOT NULL,
81
82
  created_at BIGINT NOT NULL,
82
83
  INDEX idx_ice_offer (offer_id),
83
- INDEX idx_ice_username (username),
84
+ INDEX idx_ice_public_key (public_key),
84
85
  INDEX idx_ice_created (created_at),
85
86
  FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
86
87
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
87
88
  `);
88
89
 
89
- await conn.query(`
90
- CREATE TABLE IF NOT EXISTS credentials (
91
- name VARCHAR(32) PRIMARY KEY,
92
- secret VARCHAR(512) NOT NULL UNIQUE,
93
- created_at BIGINT NOT NULL,
94
- expires_at BIGINT NOT NULL,
95
- last_used BIGINT NOT NULL,
96
- INDEX idx_credentials_expires (expires_at)
97
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
98
- `);
99
-
100
90
  await conn.query(`
101
91
  CREATE TABLE IF NOT EXISTS rate_limits (
102
92
  identifier VARCHAR(255) PRIMARY KEY,
@@ -134,14 +124,14 @@ export class MySQLStorage implements Storage {
134
124
  const id = request.id || await generateOfferHash(request.sdp);
135
125
 
136
126
  await conn.query(
137
- `INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
127
+ `INSERT INTO offers (id, public_key, tags, sdp, created_at, expires_at, last_seen)
138
128
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
139
- [id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
129
+ [id, request.publicKey, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
140
130
  );
141
131
 
142
132
  created.push({
143
133
  id,
144
- username: request.username,
134
+ publicKey: request.publicKey,
145
135
  tags: request.tags,
146
136
  sdp: request.sdp,
147
137
  createdAt: now,
@@ -161,10 +151,10 @@ export class MySQLStorage implements Storage {
161
151
  return created;
162
152
  }
163
153
 
164
- async getOffersByUsername(username: string): Promise<Offer[]> {
154
+ async getOffersByPublicKey(publicKey: string): Promise<Offer[]> {
165
155
  const [rows] = await this.pool.query<RowDataPacket[]>(
166
- `SELECT * FROM offers WHERE username = ? AND expires_at > ? ORDER BY last_seen DESC`,
167
- [username, Date.now()]
156
+ `SELECT * FROM offers WHERE public_key = ? AND expires_at > ? ORDER BY last_seen DESC`,
157
+ [publicKey, Date.now()]
168
158
  );
169
159
  return rows.map(row => this.rowToOffer(row));
170
160
  }
@@ -177,10 +167,10 @@ export class MySQLStorage implements Storage {
177
167
  return rows.length > 0 ? this.rowToOffer(rows[0]) : null;
178
168
  }
179
169
 
180
- async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
170
+ async deleteOffer(offerId: string, ownerPublicKey: string): Promise<boolean> {
181
171
  const [result] = await this.pool.query<ResultSetHeader>(
182
- `DELETE FROM offers WHERE id = ? AND username = ?`,
183
- [offerId, ownerUsername]
172
+ `DELETE FROM offers WHERE id = ? AND public_key = ?`,
173
+ [offerId, ownerPublicKey]
184
174
  );
185
175
  return result.affectedRows > 0;
186
176
  }
@@ -195,8 +185,9 @@ export class MySQLStorage implements Storage {
195
185
 
196
186
  async answerOffer(
197
187
  offerId: string,
198
- answererUsername: string,
199
- answerSdp: string
188
+ answererPublicKey: string,
189
+ answerSdp: string,
190
+ matchedTags?: string[]
200
191
  ): Promise<{ success: boolean; error?: string }> {
201
192
  const offer = await this.getOfferById(offerId);
202
193
 
@@ -204,14 +195,15 @@ export class MySQLStorage implements Storage {
204
195
  return { success: false, error: 'Offer not found or expired' };
205
196
  }
206
197
 
207
- if (offer.answererUsername) {
198
+ if (offer.answererPublicKey) {
208
199
  return { success: false, error: 'Offer already answered' };
209
200
  }
210
201
 
202
+ const matchedTagsJson = matchedTags ? JSON.stringify(matchedTags) : null;
211
203
  const [result] = await this.pool.query<ResultSetHeader>(
212
- `UPDATE offers SET answerer_username = ?, answer_sdp = ?, answered_at = ?
213
- WHERE id = ? AND answerer_username IS NULL`,
214
- [answererUsername, answerSdp, Date.now(), offerId]
204
+ `UPDATE offers SET answerer_public_key = ?, answer_sdp = ?, answered_at = ?, matched_tags = ?
205
+ WHERE id = ? AND answerer_public_key IS NULL`,
206
+ [answererPublicKey, answerSdp, Date.now(), matchedTagsJson, offerId]
215
207
  );
216
208
 
217
209
  if (result.affectedRows === 0) {
@@ -221,22 +213,22 @@ export class MySQLStorage implements Storage {
221
213
  return { success: true };
222
214
  }
223
215
 
224
- async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
216
+ async getAnsweredOffers(offererPublicKey: string): Promise<Offer[]> {
225
217
  const [rows] = await this.pool.query<RowDataPacket[]>(
226
218
  `SELECT * FROM offers
227
- WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
219
+ WHERE public_key = ? AND answerer_public_key IS NOT NULL AND expires_at > ?
228
220
  ORDER BY answered_at DESC`,
229
- [offererUsername, Date.now()]
221
+ [offererPublicKey, Date.now()]
230
222
  );
231
223
  return rows.map(row => this.rowToOffer(row));
232
224
  }
233
225
 
234
- async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
226
+ async getOffersAnsweredBy(answererPublicKey: string): Promise<Offer[]> {
235
227
  const [rows] = await this.pool.query<RowDataPacket[]>(
236
228
  `SELECT * FROM offers
237
- WHERE answerer_username = ? AND expires_at > ?
229
+ WHERE answerer_public_key = ? AND expires_at > ?
238
230
  ORDER BY answered_at DESC`,
239
- [answererUsername, Date.now()]
231
+ [answererPublicKey, Date.now()]
240
232
  );
241
233
  return rows.map(row => this.rowToOffer(row));
242
234
  }
@@ -245,27 +237,25 @@ export class MySQLStorage implements Storage {
245
237
 
246
238
  async discoverOffers(
247
239
  tags: string[],
248
- excludeUsername: string | null,
240
+ excludePublicKey: string | null,
249
241
  limit: number,
250
242
  offset: number
251
243
  ): Promise<Offer[]> {
252
244
  if (tags.length === 0) return [];
253
245
 
254
- // Use JSON_OVERLAPS for efficient tag matching (MySQL 8.0.17+)
255
- // Falls back to JSON_CONTAINS for each tag with OR logic
256
246
  const tagArray = JSON.stringify(tags);
257
247
 
258
248
  let query = `
259
249
  SELECT DISTINCT o.* FROM offers o
260
250
  WHERE JSON_OVERLAPS(o.tags, ?)
261
251
  AND o.expires_at > ?
262
- AND o.answerer_username IS NULL
252
+ AND o.answerer_public_key IS NULL
263
253
  `;
264
254
  const params: any[] = [tagArray, Date.now()];
265
255
 
266
- if (excludeUsername) {
267
- query += ' AND o.username != ?';
268
- params.push(excludeUsername);
256
+ if (excludePublicKey) {
257
+ query += ' AND o.public_key != ?';
258
+ params.push(excludePublicKey);
269
259
  }
270
260
 
271
261
  query += ' ORDER BY o.created_at DESC LIMIT ? OFFSET ?';
@@ -277,7 +267,7 @@ export class MySQLStorage implements Storage {
277
267
 
278
268
  async getRandomOffer(
279
269
  tags: string[],
280
- excludeUsername: string | null
270
+ excludePublicKey: string | null
281
271
  ): Promise<Offer | null> {
282
272
  if (tags.length === 0) return null;
283
273
 
@@ -287,13 +277,13 @@ export class MySQLStorage implements Storage {
287
277
  SELECT DISTINCT o.* FROM offers o
288
278
  WHERE JSON_OVERLAPS(o.tags, ?)
289
279
  AND o.expires_at > ?
290
- AND o.answerer_username IS NULL
280
+ AND o.answerer_public_key IS NULL
291
281
  `;
292
282
  const params: any[] = [tagArray, Date.now()];
293
283
 
294
- if (excludeUsername) {
295
- query += ' AND o.username != ?';
296
- params.push(excludeUsername);
284
+ if (excludePublicKey) {
285
+ query += ' AND o.public_key != ?';
286
+ params.push(excludePublicKey);
297
287
  }
298
288
 
299
289
  query += ' ORDER BY RAND() LIMIT 1';
@@ -306,7 +296,7 @@ export class MySQLStorage implements Storage {
306
296
 
307
297
  async addIceCandidates(
308
298
  offerId: string,
309
- username: string,
299
+ publicKey: string,
310
300
  role: 'offerer' | 'answerer',
311
301
  candidates: any[]
312
302
  ): Promise<number> {
@@ -315,14 +305,14 @@ export class MySQLStorage implements Storage {
315
305
  const baseTimestamp = Date.now();
316
306
  const values = candidates.map((c, i) => [
317
307
  offerId,
318
- username,
308
+ publicKey,
319
309
  role,
320
310
  JSON.stringify(c),
321
311
  baseTimestamp + i,
322
312
  ]);
323
313
 
324
314
  await this.pool.query(
325
- `INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
315
+ `INSERT INTO ice_candidates (offer_id, public_key, role, candidate, created_at)
326
316
  VALUES ?`,
327
317
  [values]
328
318
  );
@@ -351,7 +341,7 @@ export class MySQLStorage implements Storage {
351
341
 
352
342
  async getIceCandidatesForMultipleOffers(
353
343
  offerIds: string[],
354
- username: string,
344
+ publicKey: string,
355
345
  since?: number
356
346
  ): Promise<Map<string, IceCandidate[]>> {
357
347
  const result = new Map<string, IceCandidate[]>();
@@ -364,16 +354,16 @@ export class MySQLStorage implements Storage {
364
354
  const placeholders = offerIds.map(() => '?').join(',');
365
355
 
366
356
  let query = `
367
- SELECT ic.*, o.username as offer_username
357
+ SELECT ic.*, o.public_key as offer_public_key
368
358
  FROM ice_candidates ic
369
359
  INNER JOIN offers o ON o.id = ic.offer_id
370
360
  WHERE ic.offer_id IN (${placeholders})
371
361
  AND (
372
- (o.username = ? AND ic.role = 'answerer')
373
- OR (o.answerer_username = ? AND ic.role = 'offerer')
362
+ (o.public_key = ? AND ic.role = 'answerer')
363
+ OR (o.answerer_public_key = ? AND ic.role = 'offerer')
374
364
  )
375
365
  `;
376
- const params: any[] = [...offerIds, username, username];
366
+ const params: any[] = [...offerIds, publicKey, publicKey];
377
367
 
378
368
  if (since !== undefined) {
379
369
  query += ' AND ic.created_at > ?';
@@ -395,113 +385,12 @@ export class MySQLStorage implements Storage {
395
385
  return result;
396
386
  }
397
387
 
398
- // ===== Credential Management =====
399
-
400
- async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
401
- const now = Date.now();
402
- const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
403
-
404
- const { generateCredentialName, generateSecret, encryptSecret } = await import('../crypto.ts');
405
-
406
- let name: string;
407
-
408
- if (request.name) {
409
- const [existing] = await this.pool.query<RowDataPacket[]>(
410
- `SELECT name FROM credentials WHERE name = ?`,
411
- [request.name]
412
- );
413
-
414
- if (existing.length > 0) {
415
- throw new Error('Username already taken');
416
- }
417
-
418
- name = request.name;
419
- } else {
420
- let attempts = 0;
421
- const maxAttempts = 100;
422
-
423
- while (attempts < maxAttempts) {
424
- name = generateCredentialName();
425
-
426
- const [existing] = await this.pool.query<RowDataPacket[]>(
427
- `SELECT name FROM credentials WHERE name = ?`,
428
- [name]
429
- );
430
-
431
- if (existing.length === 0) break;
432
- attempts++;
433
- }
434
-
435
- if (attempts >= maxAttempts) {
436
- throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
437
- }
438
- }
439
-
440
- const secret = generateSecret();
441
- const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
442
-
443
- await this.pool.query(
444
- `INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
445
- VALUES (?, ?, ?, ?, ?)`,
446
- [name!, encryptedSecret, now, expiresAt, now]
447
- );
448
-
449
- return {
450
- name: name!,
451
- secret,
452
- createdAt: now,
453
- expiresAt,
454
- lastUsed: now,
455
- };
456
- }
457
-
458
- async getCredential(name: string): Promise<Credential | null> {
459
- const [rows] = await this.pool.query<RowDataPacket[]>(
460
- `SELECT * FROM credentials WHERE name = ? AND expires_at > ?`,
461
- [name, Date.now()]
462
- );
463
-
464
- if (rows.length === 0) return null;
465
-
466
- try {
467
- const { decryptSecret } = await import('../crypto.ts');
468
- const decryptedSecret = await decryptSecret(rows[0].secret, this.masterEncryptionKey);
469
-
470
- return {
471
- name: rows[0].name,
472
- secret: decryptedSecret,
473
- createdAt: Number(rows[0].created_at),
474
- expiresAt: Number(rows[0].expires_at),
475
- lastUsed: Number(rows[0].last_used),
476
- };
477
- } catch (error) {
478
- console.error(`Failed to decrypt secret for credential '${name}':`, error);
479
- return null;
480
- }
481
- }
482
-
483
- async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
484
- await this.pool.query(
485
- `UPDATE credentials SET last_used = ?, expires_at = ? WHERE name = ?`,
486
- [lastUsed, expiresAt, name]
487
- );
488
- }
489
-
490
- async deleteExpiredCredentials(now: number): Promise<number> {
491
- const [result] = await this.pool.query<ResultSetHeader>(
492
- `DELETE FROM credentials WHERE expires_at < ?`,
493
- [now]
494
- );
495
- return result.affectedRows;
496
- }
497
-
498
388
  // ===== Rate Limiting =====
499
389
 
500
390
  async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
501
391
  const now = Date.now();
502
392
  const resetTime = now + windowMs;
503
393
 
504
- // Use INSERT ... ON DUPLICATE KEY UPDATE for atomic upsert
505
394
  await this.pool.query(
506
395
  `INSERT INTO rate_limits (identifier, count, reset_time)
507
396
  VALUES (?, 1, ?)
@@ -511,7 +400,6 @@ export class MySQLStorage implements Storage {
511
400
  [identifier, resetTime, now, now, resetTime]
512
401
  );
513
402
 
514
- // Get current count
515
403
  const [rows] = await this.pool.query<RowDataPacket[]>(
516
404
  `SELECT count FROM rate_limits WHERE identifier = ?`,
517
405
  [identifier]
@@ -538,7 +426,6 @@ export class MySQLStorage implements Storage {
538
426
  );
539
427
  return true;
540
428
  } catch (error: any) {
541
- // MySQL duplicate key error code
542
429
  if (error.code === 'ER_DUP_ENTRY') {
543
430
  return false;
544
431
  }
@@ -565,19 +452,14 @@ export class MySQLStorage implements Storage {
565
452
  return Number(rows[0].count);
566
453
  }
567
454
 
568
- async getOfferCountByUsername(username: string): Promise<number> {
455
+ async getOfferCountByPublicKey(publicKey: string): Promise<number> {
569
456
  const [rows] = await this.pool.query<RowDataPacket[]>(
570
- 'SELECT COUNT(*) as count FROM offers WHERE username = ?',
571
- [username]
457
+ 'SELECT COUNT(*) as count FROM offers WHERE public_key = ?',
458
+ [publicKey]
572
459
  );
573
460
  return Number(rows[0].count);
574
461
  }
575
462
 
576
- async getCredentialCount(): Promise<number> {
577
- const [rows] = await this.pool.query<RowDataPacket[]>('SELECT COUNT(*) as count FROM credentials');
578
- return Number(rows[0].count);
579
- }
580
-
581
463
  async getIceCandidateCount(offerId: string): Promise<number> {
582
464
  const [rows] = await this.pool.query<RowDataPacket[]>(
583
465
  'SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?',
@@ -591,15 +473,16 @@ export class MySQLStorage implements Storage {
591
473
  private rowToOffer(row: RowDataPacket): Offer {
592
474
  return {
593
475
  id: row.id,
594
- username: row.username,
476
+ publicKey: row.public_key,
595
477
  tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags,
596
478
  sdp: row.sdp,
597
479
  createdAt: Number(row.created_at),
598
480
  expiresAt: Number(row.expires_at),
599
481
  lastSeen: Number(row.last_seen),
600
- answererUsername: row.answerer_username || undefined,
482
+ answererPublicKey: row.answerer_public_key || undefined,
601
483
  answerSdp: row.answer_sdp || undefined,
602
484
  answeredAt: row.answered_at ? Number(row.answered_at) : undefined,
485
+ matchedTags: row.matched_tags ? (typeof row.matched_tags === 'string' ? JSON.parse(row.matched_tags) : row.matched_tags) : undefined,
603
486
  };
604
487
  }
605
488
 
@@ -607,7 +490,7 @@ export class MySQLStorage implements Storage {
607
490
  return {
608
491
  id: Number(row.id),
609
492
  offerId: row.offer_id,
610
- username: row.username,
493
+ publicKey: row.public_key,
611
494
  role: row.role as 'offerer' | 'answerer',
612
495
  candidate: typeof row.candidate === 'string' ? JSON.parse(row.candidate) : row.candidate,
613
496
  createdAt: Number(row.created_at),