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