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