@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 { Pool, QueryResult } from 'pg';
1
+ import { Pool } from 'pg';
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
  * PostgreSQL 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 PostgreSQLStorage 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 PostgreSQL storage instance with connection pooling
29
23
  * @param connectionString PostgreSQL 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<PostgreSQLStorage> {
26
+ static async create(connectionString: string, poolSize: number = 10): Promise<PostgreSQLStorage> {
38
27
  const pool = new Pool({
39
28
  connectionString,
40
29
  max: poolSize,
@@ -42,7 +31,7 @@ export class PostgreSQLStorage implements Storage {
42
31
  connectionTimeoutMillis: 5000,
43
32
  });
44
33
 
45
- const storage = new PostgreSQLStorage(pool, masterEncryptionKey);
34
+ const storage = new PostgreSQLStorage(pool);
46
35
  await storage.initializeDatabase();
47
36
  return storage;
48
37
  }
@@ -50,33 +39,44 @@ export class PostgreSQLStorage implements Storage {
50
39
  private async initializeDatabase(): Promise<void> {
51
40
  const client = await this.pool.connect();
52
41
  try {
42
+ await client.query(`
43
+ CREATE TABLE IF NOT EXISTS identities (
44
+ public_key CHAR(64) PRIMARY KEY,
45
+ created_at BIGINT NOT NULL,
46
+ expires_at BIGINT NOT NULL,
47
+ last_used BIGINT NOT NULL
48
+ )
49
+ `);
50
+
51
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_identities_expires ON identities(expires_at)`);
52
+
53
53
  await client.query(`
54
54
  CREATE TABLE IF NOT EXISTS offers (
55
55
  id VARCHAR(64) PRIMARY KEY,
56
- username VARCHAR(32) NOT NULL,
56
+ public_key CHAR(64) NOT NULL REFERENCES identities(public_key) ON DELETE CASCADE,
57
57
  tags JSONB NOT NULL,
58
58
  sdp TEXT NOT NULL,
59
59
  created_at BIGINT NOT NULL,
60
60
  expires_at BIGINT NOT NULL,
61
61
  last_seen BIGINT NOT NULL,
62
- answerer_username VARCHAR(32),
62
+ answerer_public_key CHAR(64),
63
63
  answer_sdp TEXT,
64
64
  answered_at BIGINT,
65
65
  matched_tags JSONB
66
66
  )
67
67
  `);
68
68
 
69
- await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username)`);
69
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_public_key ON offers(public_key)`);
70
70
  await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at)`);
71
71
  await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen)`);
72
- await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username)`);
72
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_public_key)`);
73
73
  await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_tags ON offers USING GIN(tags)`);
74
74
 
75
75
  await client.query(`
76
76
  CREATE TABLE IF NOT EXISTS ice_candidates (
77
77
  id BIGSERIAL PRIMARY KEY,
78
78
  offer_id VARCHAR(64) NOT NULL REFERENCES offers(id) ON DELETE CASCADE,
79
- username VARCHAR(32) NOT NULL,
79
+ public_key CHAR(64) NOT NULL,
80
80
  role VARCHAR(8) NOT NULL CHECK (role IN ('offerer', 'answerer')),
81
81
  candidate JSONB NOT NULL,
82
82
  created_at BIGINT NOT NULL
@@ -84,21 +84,9 @@ export class PostgreSQLStorage implements Storage {
84
84
  `);
85
85
 
86
86
  await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id)`);
87
- await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username)`);
87
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_public_key ON ice_candidates(public_key)`);
88
88
  await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at)`);
89
89
 
90
- await client.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
- )
98
- `);
99
-
100
- await client.query(`CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at)`);
101
-
102
90
  await client.query(`
103
91
  CREATE TABLE IF NOT EXISTS rate_limits (
104
92
  identifier VARCHAR(255) PRIMARY KEY,
@@ -138,14 +126,14 @@ export class PostgreSQLStorage implements Storage {
138
126
  const id = request.id || await generateOfferHash(request.sdp);
139
127
 
140
128
  await client.query(
141
- `INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
129
+ `INSERT INTO offers (id, public_key, tags, sdp, created_at, expires_at, last_seen)
142
130
  VALUES ($1, $2, $3, $4, $5, $6, $7)`,
143
- [id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
131
+ [id, request.publicKey, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
144
132
  );
145
133
 
146
134
  created.push({
147
135
  id,
148
- username: request.username,
136
+ publicKey: request.publicKey,
149
137
  tags: request.tags,
150
138
  sdp: request.sdp,
151
139
  createdAt: now,
@@ -165,10 +153,10 @@ export class PostgreSQLStorage implements Storage {
165
153
  return created;
166
154
  }
167
155
 
168
- async getOffersByUsername(username: string): Promise<Offer[]> {
156
+ async getOffersByPublicKey(publicKey: string): Promise<Offer[]> {
169
157
  const result = await this.pool.query(
170
- `SELECT * FROM offers WHERE username = $1 AND expires_at > $2 ORDER BY last_seen DESC`,
171
- [username, Date.now()]
158
+ `SELECT * FROM offers WHERE public_key = $1 AND expires_at > $2 ORDER BY last_seen DESC`,
159
+ [publicKey, Date.now()]
172
160
  );
173
161
  return result.rows.map(row => this.rowToOffer(row));
174
162
  }
@@ -181,10 +169,10 @@ export class PostgreSQLStorage implements Storage {
181
169
  return result.rows.length > 0 ? this.rowToOffer(result.rows[0]) : null;
182
170
  }
183
171
 
184
- async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
172
+ async deleteOffer(offerId: string, ownerPublicKey: string): Promise<boolean> {
185
173
  const result = await this.pool.query(
186
- `DELETE FROM offers WHERE id = $1 AND username = $2`,
187
- [offerId, ownerUsername]
174
+ `DELETE FROM offers WHERE id = $1 AND public_key = $2`,
175
+ [offerId, ownerPublicKey]
188
176
  );
189
177
  return (result.rowCount ?? 0) > 0;
190
178
  }
@@ -199,7 +187,7 @@ export class PostgreSQLStorage implements Storage {
199
187
 
200
188
  async answerOffer(
201
189
  offerId: string,
202
- answererUsername: string,
190
+ answererPublicKey: string,
203
191
  answerSdp: string,
204
192
  matchedTags?: string[]
205
193
  ): Promise<{ success: boolean; error?: string }> {
@@ -209,15 +197,15 @@ export class PostgreSQLStorage implements Storage {
209
197
  return { success: false, error: 'Offer not found or expired' };
210
198
  }
211
199
 
212
- if (offer.answererUsername) {
200
+ if (offer.answererPublicKey) {
213
201
  return { success: false, error: 'Offer already answered' };
214
202
  }
215
203
 
216
204
  const matchedTagsJson = matchedTags ? JSON.stringify(matchedTags) : null;
217
205
  const result = await this.pool.query(
218
- `UPDATE offers SET answerer_username = $1, answer_sdp = $2, answered_at = $3, matched_tags = $4
219
- WHERE id = $5 AND answerer_username IS NULL`,
220
- [answererUsername, answerSdp, Date.now(), matchedTagsJson, offerId]
206
+ `UPDATE offers SET answerer_public_key = $1, answer_sdp = $2, answered_at = $3, matched_tags = $4
207
+ WHERE id = $5 AND answerer_public_key IS NULL`,
208
+ [answererPublicKey, answerSdp, Date.now(), matchedTagsJson, offerId]
221
209
  );
222
210
 
223
211
  if ((result.rowCount ?? 0) === 0) {
@@ -227,22 +215,22 @@ export class PostgreSQLStorage implements Storage {
227
215
  return { success: true };
228
216
  }
229
217
 
230
- async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
218
+ async getAnsweredOffers(offererPublicKey: string): Promise<Offer[]> {
231
219
  const result = await this.pool.query(
232
220
  `SELECT * FROM offers
233
- WHERE username = $1 AND answerer_username IS NOT NULL AND expires_at > $2
221
+ WHERE public_key = $1 AND answerer_public_key IS NOT NULL AND expires_at > $2
234
222
  ORDER BY answered_at DESC`,
235
- [offererUsername, Date.now()]
223
+ [offererPublicKey, Date.now()]
236
224
  );
237
225
  return result.rows.map(row => this.rowToOffer(row));
238
226
  }
239
227
 
240
- async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
228
+ async getOffersAnsweredBy(answererPublicKey: string): Promise<Offer[]> {
241
229
  const result = await this.pool.query(
242
230
  `SELECT * FROM offers
243
- WHERE answerer_username = $1 AND expires_at > $2
231
+ WHERE answerer_public_key = $1 AND expires_at > $2
244
232
  ORDER BY answered_at DESC`,
245
- [answererUsername, Date.now()]
233
+ [answererPublicKey, Date.now()]
246
234
  );
247
235
  return result.rows.map(row => this.rowToOffer(row));
248
236
  }
@@ -251,25 +239,24 @@ export class PostgreSQLStorage implements Storage {
251
239
 
252
240
  async discoverOffers(
253
241
  tags: string[],
254
- excludeUsername: string | null,
242
+ excludePublicKey: string | null,
255
243
  limit: number,
256
244
  offset: number
257
245
  ): Promise<Offer[]> {
258
246
  if (tags.length === 0) return [];
259
247
 
260
- // Use PostgreSQL's ?| operator for JSONB array overlap
261
248
  let query = `
262
249
  SELECT DISTINCT o.* FROM offers o
263
250
  WHERE o.tags ?| $1
264
251
  AND o.expires_at > $2
265
- AND o.answerer_username IS NULL
252
+ AND o.answerer_public_key IS NULL
266
253
  `;
267
254
  const params: any[] = [tags, Date.now()];
268
255
  let paramIndex = 3;
269
256
 
270
- if (excludeUsername) {
271
- query += ` AND o.username != $${paramIndex}`;
272
- params.push(excludeUsername);
257
+ if (excludePublicKey) {
258
+ query += ` AND o.public_key != $${paramIndex}`;
259
+ params.push(excludePublicKey);
273
260
  paramIndex++;
274
261
  }
275
262
 
@@ -282,7 +269,7 @@ export class PostgreSQLStorage implements Storage {
282
269
 
283
270
  async getRandomOffer(
284
271
  tags: string[],
285
- excludeUsername: string | null
272
+ excludePublicKey: string | null
286
273
  ): Promise<Offer | null> {
287
274
  if (tags.length === 0) return null;
288
275
 
@@ -290,14 +277,14 @@ export class PostgreSQLStorage implements Storage {
290
277
  SELECT DISTINCT o.* FROM offers o
291
278
  WHERE o.tags ?| $1
292
279
  AND o.expires_at > $2
293
- AND o.answerer_username IS NULL
280
+ AND o.answerer_public_key IS NULL
294
281
  `;
295
282
  const params: any[] = [tags, Date.now()];
296
283
  let paramIndex = 3;
297
284
 
298
- if (excludeUsername) {
299
- query += ` AND o.username != $${paramIndex}`;
300
- params.push(excludeUsername);
285
+ if (excludePublicKey) {
286
+ query += ` AND o.public_key != $${paramIndex}`;
287
+ params.push(excludePublicKey);
301
288
  }
302
289
 
303
290
  query += ' ORDER BY RANDOM() LIMIT 1';
@@ -310,7 +297,7 @@ export class PostgreSQLStorage implements Storage {
310
297
 
311
298
  async addIceCandidates(
312
299
  offerId: string,
313
- username: string,
300
+ publicKey: string,
314
301
  role: 'offerer' | 'answerer',
315
302
  candidates: any[]
316
303
  ): Promise<number> {
@@ -324,9 +311,9 @@ export class PostgreSQLStorage implements Storage {
324
311
 
325
312
  for (let i = 0; i < candidates.length; i++) {
326
313
  await client.query(
327
- `INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
314
+ `INSERT INTO ice_candidates (offer_id, public_key, role, candidate, created_at)
328
315
  VALUES ($1, $2, $3, $4, $5)`,
329
- [offerId, username, role, JSON.stringify(candidates[i]), baseTimestamp + i]
316
+ [offerId, publicKey, role, JSON.stringify(candidates[i]), baseTimestamp + i]
330
317
  );
331
318
  }
332
319
 
@@ -362,7 +349,7 @@ export class PostgreSQLStorage implements Storage {
362
349
 
363
350
  async getIceCandidatesForMultipleOffers(
364
351
  offerIds: string[],
365
- username: string,
352
+ publicKey: string,
366
353
  since?: number
367
354
  ): Promise<Map<string, IceCandidate[]>> {
368
355
  const resultMap = new Map<string, IceCandidate[]>();
@@ -373,16 +360,16 @@ export class PostgreSQLStorage implements Storage {
373
360
  }
374
361
 
375
362
  let query = `
376
- SELECT ic.*, o.username as offer_username
363
+ SELECT ic.*, o.public_key as offer_public_key
377
364
  FROM ice_candidates ic
378
365
  INNER JOIN offers o ON o.id = ic.offer_id
379
366
  WHERE ic.offer_id = ANY($1)
380
367
  AND (
381
- (o.username = $2 AND ic.role = 'answerer')
382
- OR (o.answerer_username = $2 AND ic.role = 'offerer')
368
+ (o.public_key = $2 AND ic.role = 'answerer')
369
+ OR (o.answerer_public_key = $2 AND ic.role = 'offerer')
383
370
  )
384
371
  `;
385
- const params: any[] = [offerIds, username];
372
+ const params: any[] = [offerIds, publicKey];
386
373
 
387
374
  if (since !== undefined) {
388
375
  query += ' AND ic.created_at > $3';
@@ -404,113 +391,12 @@ export class PostgreSQLStorage implements Storage {
404
391
  return resultMap;
405
392
  }
406
393
 
407
- // ===== Credential Management =====
408
-
409
- async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
410
- const now = Date.now();
411
- const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
412
-
413
- const { generateCredentialName, generateSecret, encryptSecret } = await import('../crypto.ts');
414
-
415
- let name: string;
416
-
417
- if (request.name) {
418
- const existing = await this.pool.query(
419
- `SELECT name FROM credentials WHERE name = $1`,
420
- [request.name]
421
- );
422
-
423
- if (existing.rows.length > 0) {
424
- throw new Error('Username already taken');
425
- }
426
-
427
- name = request.name;
428
- } else {
429
- let attempts = 0;
430
- const maxAttempts = 100;
431
-
432
- while (attempts < maxAttempts) {
433
- name = generateCredentialName();
434
-
435
- const existing = await this.pool.query(
436
- `SELECT name FROM credentials WHERE name = $1`,
437
- [name]
438
- );
439
-
440
- if (existing.rows.length === 0) break;
441
- attempts++;
442
- }
443
-
444
- if (attempts >= maxAttempts) {
445
- throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
446
- }
447
- }
448
-
449
- const secret = generateSecret();
450
- const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
451
-
452
- await this.pool.query(
453
- `INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
454
- VALUES ($1, $2, $3, $4, $5)`,
455
- [name!, encryptedSecret, now, expiresAt, now]
456
- );
457
-
458
- return {
459
- name: name!,
460
- secret,
461
- createdAt: now,
462
- expiresAt,
463
- lastUsed: now,
464
- };
465
- }
466
-
467
- async getCredential(name: string): Promise<Credential | null> {
468
- const result = await this.pool.query(
469
- `SELECT * FROM credentials WHERE name = $1 AND expires_at > $2`,
470
- [name, Date.now()]
471
- );
472
-
473
- if (result.rows.length === 0) return null;
474
-
475
- try {
476
- const { decryptSecret } = await import('../crypto.ts');
477
- const decryptedSecret = await decryptSecret(result.rows[0].secret, this.masterEncryptionKey);
478
-
479
- return {
480
- name: result.rows[0].name,
481
- secret: decryptedSecret,
482
- createdAt: Number(result.rows[0].created_at),
483
- expiresAt: Number(result.rows[0].expires_at),
484
- lastUsed: Number(result.rows[0].last_used),
485
- };
486
- } catch (error) {
487
- console.error(`Failed to decrypt secret for credential '${name}':`, error);
488
- return null;
489
- }
490
- }
491
-
492
- async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
493
- await this.pool.query(
494
- `UPDATE credentials SET last_used = $1, expires_at = $2 WHERE name = $3`,
495
- [lastUsed, expiresAt, name]
496
- );
497
- }
498
-
499
- async deleteExpiredCredentials(now: number): Promise<number> {
500
- const result = await this.pool.query(
501
- `DELETE FROM credentials WHERE expires_at < $1`,
502
- [now]
503
- );
504
- return result.rowCount ?? 0;
505
- }
506
-
507
394
  // ===== Rate Limiting =====
508
395
 
509
396
  async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
510
397
  const now = Date.now();
511
398
  const resetTime = now + windowMs;
512
399
 
513
- // Use INSERT ... ON CONFLICT for atomic upsert
514
400
  const result = await this.pool.query(
515
401
  `INSERT INTO rate_limits (identifier, count, reset_time)
516
402
  VALUES ($1, 1, $2)
@@ -548,7 +434,6 @@ export class PostgreSQLStorage implements Storage {
548
434
  );
549
435
  return true;
550
436
  } catch (error: any) {
551
- // PostgreSQL unique violation error code
552
437
  if (error.code === '23505') {
553
438
  return false;
554
439
  }
@@ -575,19 +460,14 @@ export class PostgreSQLStorage implements Storage {
575
460
  return Number(result.rows[0].count);
576
461
  }
577
462
 
578
- async getOfferCountByUsername(username: string): Promise<number> {
463
+ async getOfferCountByPublicKey(publicKey: string): Promise<number> {
579
464
  const result = await this.pool.query(
580
- 'SELECT COUNT(*) as count FROM offers WHERE username = $1',
581
- [username]
465
+ 'SELECT COUNT(*) as count FROM offers WHERE public_key = $1',
466
+ [publicKey]
582
467
  );
583
468
  return Number(result.rows[0].count);
584
469
  }
585
470
 
586
- async getCredentialCount(): Promise<number> {
587
- const result = await this.pool.query('SELECT COUNT(*) as count FROM credentials');
588
- return Number(result.rows[0].count);
589
- }
590
-
591
471
  async getIceCandidateCount(offerId: string): Promise<number> {
592
472
  const result = await this.pool.query(
593
473
  'SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = $1',
@@ -601,13 +481,13 @@ export class PostgreSQLStorage implements Storage {
601
481
  private rowToOffer(row: any): Offer {
602
482
  return {
603
483
  id: row.id,
604
- username: row.username,
484
+ publicKey: row.public_key.trim(),
605
485
  tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags,
606
486
  sdp: row.sdp,
607
487
  createdAt: Number(row.created_at),
608
488
  expiresAt: Number(row.expires_at),
609
489
  lastSeen: Number(row.last_seen),
610
- answererUsername: row.answerer_username || undefined,
490
+ answererPublicKey: row.answerer_public_key?.trim() || undefined,
611
491
  answerSdp: row.answer_sdp || undefined,
612
492
  answeredAt: row.answered_at ? Number(row.answered_at) : undefined,
613
493
  matchedTags: row.matched_tags || undefined,
@@ -618,7 +498,7 @@ export class PostgreSQLStorage implements Storage {
618
498
  return {
619
499
  id: Number(row.id),
620
500
  offerId: row.offer_id,
621
- username: row.username,
501
+ publicKey: row.public_key.trim(),
622
502
  role: row.role as 'offerer' | 'answerer',
623
503
  candidate: typeof row.candidate === 'string' ? JSON.parse(row.candidate) : row.candidate,
624
504
  createdAt: Number(row.created_at),