@xtr-dev/rondevu-server 0.5.1 → 0.5.6

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.
@@ -0,0 +1,595 @@
1
+ import { Pool, QueryResult } from 'pg';
2
+ import {
3
+ Storage,
4
+ Offer,
5
+ IceCandidate,
6
+ CreateOfferRequest,
7
+ Credential,
8
+ GenerateCredentialsRequest,
9
+ } from './types.ts';
10
+ import { generateOfferHash } from './hash-id.ts';
11
+
12
+ const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000;
13
+
14
+ /**
15
+ * PostgreSQL storage adapter for rondevu signaling system
16
+ * Uses connection pooling for efficient resource management
17
+ */
18
+ export class PostgreSQLStorage implements Storage {
19
+ private pool: Pool;
20
+ private masterEncryptionKey: string;
21
+
22
+ private constructor(pool: Pool, masterEncryptionKey: string) {
23
+ this.pool = pool;
24
+ this.masterEncryptionKey = masterEncryptionKey;
25
+ }
26
+
27
+ /**
28
+ * Creates a new PostgreSQL storage instance with connection pooling
29
+ * @param connectionString PostgreSQL connection URL
30
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets
31
+ * @param poolSize Maximum number of connections in the pool
32
+ */
33
+ static async create(
34
+ connectionString: string,
35
+ masterEncryptionKey: string,
36
+ poolSize: number = 10
37
+ ): Promise<PostgreSQLStorage> {
38
+ const pool = new Pool({
39
+ connectionString,
40
+ max: poolSize,
41
+ idleTimeoutMillis: 30000,
42
+ connectionTimeoutMillis: 5000,
43
+ });
44
+
45
+ const storage = new PostgreSQLStorage(pool, masterEncryptionKey);
46
+ await storage.initializeDatabase();
47
+ return storage;
48
+ }
49
+
50
+ private async initializeDatabase(): Promise<void> {
51
+ const client = await this.pool.connect();
52
+ try {
53
+ await client.query(`
54
+ CREATE TABLE IF NOT EXISTS offers (
55
+ id VARCHAR(64) PRIMARY KEY,
56
+ username VARCHAR(32) NOT NULL,
57
+ tags JSONB NOT NULL,
58
+ sdp TEXT NOT NULL,
59
+ created_at BIGINT NOT NULL,
60
+ expires_at BIGINT NOT NULL,
61
+ last_seen BIGINT NOT NULL,
62
+ answerer_username VARCHAR(32),
63
+ answer_sdp TEXT,
64
+ answered_at BIGINT
65
+ )
66
+ `);
67
+
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_expires ON offers(expires_at)`);
70
+ 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_tags ON offers USING GIN(tags)`);
73
+
74
+ await client.query(`
75
+ CREATE TABLE IF NOT EXISTS ice_candidates (
76
+ id BIGSERIAL PRIMARY KEY,
77
+ offer_id VARCHAR(64) NOT NULL REFERENCES offers(id) ON DELETE CASCADE,
78
+ username VARCHAR(32) NOT NULL,
79
+ role VARCHAR(8) NOT NULL CHECK (role IN ('offerer', 'answerer')),
80
+ candidate JSONB NOT NULL,
81
+ created_at BIGINT NOT NULL
82
+ )
83
+ `);
84
+
85
+ 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_created ON ice_candidates(created_at)`);
88
+
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
+ await client.query(`
102
+ CREATE TABLE IF NOT EXISTS rate_limits (
103
+ identifier VARCHAR(255) PRIMARY KEY,
104
+ count INT NOT NULL,
105
+ reset_time BIGINT NOT NULL
106
+ )
107
+ `);
108
+
109
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time)`);
110
+
111
+ await client.query(`
112
+ CREATE TABLE IF NOT EXISTS nonces (
113
+ nonce_key VARCHAR(255) PRIMARY KEY,
114
+ expires_at BIGINT NOT NULL
115
+ )
116
+ `);
117
+
118
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at)`);
119
+ } finally {
120
+ client.release();
121
+ }
122
+ }
123
+
124
+ // ===== Offer Management =====
125
+
126
+ async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
127
+ if (offers.length === 0) return [];
128
+
129
+ const created: Offer[] = [];
130
+ const now = Date.now();
131
+
132
+ const client = await this.pool.connect();
133
+ try {
134
+ await client.query('BEGIN');
135
+
136
+ for (const request of offers) {
137
+ const id = request.id || await generateOfferHash(request.sdp);
138
+
139
+ await client.query(
140
+ `INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
141
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
142
+ [id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
143
+ );
144
+
145
+ created.push({
146
+ id,
147
+ username: request.username,
148
+ tags: request.tags,
149
+ sdp: request.sdp,
150
+ createdAt: now,
151
+ expiresAt: request.expiresAt,
152
+ lastSeen: now,
153
+ });
154
+ }
155
+
156
+ await client.query('COMMIT');
157
+ } catch (error) {
158
+ await client.query('ROLLBACK');
159
+ throw error;
160
+ } finally {
161
+ client.release();
162
+ }
163
+
164
+ return created;
165
+ }
166
+
167
+ async getOffersByUsername(username: string): Promise<Offer[]> {
168
+ 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()]
171
+ );
172
+ return result.rows.map(row => this.rowToOffer(row));
173
+ }
174
+
175
+ async getOfferById(offerId: string): Promise<Offer | null> {
176
+ const result = await this.pool.query(
177
+ `SELECT * FROM offers WHERE id = $1 AND expires_at > $2`,
178
+ [offerId, Date.now()]
179
+ );
180
+ return result.rows.length > 0 ? this.rowToOffer(result.rows[0]) : null;
181
+ }
182
+
183
+ async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
184
+ const result = await this.pool.query(
185
+ `DELETE FROM offers WHERE id = $1 AND username = $2`,
186
+ [offerId, ownerUsername]
187
+ );
188
+ return (result.rowCount ?? 0) > 0;
189
+ }
190
+
191
+ async deleteExpiredOffers(now: number): Promise<number> {
192
+ const result = await this.pool.query(
193
+ `DELETE FROM offers WHERE expires_at < $1`,
194
+ [now]
195
+ );
196
+ return result.rowCount ?? 0;
197
+ }
198
+
199
+ async answerOffer(
200
+ offerId: string,
201
+ answererUsername: string,
202
+ answerSdp: string
203
+ ): Promise<{ success: boolean; error?: string }> {
204
+ const offer = await this.getOfferById(offerId);
205
+
206
+ if (!offer) {
207
+ return { success: false, error: 'Offer not found or expired' };
208
+ }
209
+
210
+ if (offer.answererUsername) {
211
+ return { success: false, error: 'Offer already answered' };
212
+ }
213
+
214
+ 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]
218
+ );
219
+
220
+ if ((result.rowCount ?? 0) === 0) {
221
+ return { success: false, error: 'Offer already answered (race condition)' };
222
+ }
223
+
224
+ return { success: true };
225
+ }
226
+
227
+ async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
228
+ const result = await this.pool.query(
229
+ `SELECT * FROM offers
230
+ WHERE username = $1 AND answerer_username IS NOT NULL AND expires_at > $2
231
+ ORDER BY answered_at DESC`,
232
+ [offererUsername, Date.now()]
233
+ );
234
+ return result.rows.map(row => this.rowToOffer(row));
235
+ }
236
+
237
+ async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
238
+ const result = await this.pool.query(
239
+ `SELECT * FROM offers
240
+ WHERE answerer_username = $1 AND expires_at > $2
241
+ ORDER BY answered_at DESC`,
242
+ [answererUsername, Date.now()]
243
+ );
244
+ return result.rows.map(row => this.rowToOffer(row));
245
+ }
246
+
247
+ // ===== Discovery =====
248
+
249
+ async discoverOffers(
250
+ tags: string[],
251
+ excludeUsername: string | null,
252
+ limit: number,
253
+ offset: number
254
+ ): Promise<Offer[]> {
255
+ if (tags.length === 0) return [];
256
+
257
+ // Use PostgreSQL's ?| operator for JSONB array overlap
258
+ let query = `
259
+ SELECT DISTINCT o.* FROM offers o
260
+ WHERE o.tags ?| $1
261
+ AND o.expires_at > $2
262
+ AND o.answerer_username IS NULL
263
+ `;
264
+ const params: any[] = [tags, Date.now()];
265
+ let paramIndex = 3;
266
+
267
+ if (excludeUsername) {
268
+ query += ` AND o.username != $${paramIndex}`;
269
+ params.push(excludeUsername);
270
+ paramIndex++;
271
+ }
272
+
273
+ query += ` ORDER BY o.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
274
+ params.push(limit, offset);
275
+
276
+ const result = await this.pool.query(query, params);
277
+ return result.rows.map(row => this.rowToOffer(row));
278
+ }
279
+
280
+ async getRandomOffer(
281
+ tags: string[],
282
+ excludeUsername: string | null
283
+ ): Promise<Offer | null> {
284
+ if (tags.length === 0) return null;
285
+
286
+ let query = `
287
+ SELECT DISTINCT o.* FROM offers o
288
+ WHERE o.tags ?| $1
289
+ AND o.expires_at > $2
290
+ AND o.answerer_username IS NULL
291
+ `;
292
+ const params: any[] = [tags, Date.now()];
293
+ let paramIndex = 3;
294
+
295
+ if (excludeUsername) {
296
+ query += ` AND o.username != $${paramIndex}`;
297
+ params.push(excludeUsername);
298
+ }
299
+
300
+ query += ' ORDER BY RANDOM() LIMIT 1';
301
+
302
+ const result = await this.pool.query(query, params);
303
+ return result.rows.length > 0 ? this.rowToOffer(result.rows[0]) : null;
304
+ }
305
+
306
+ // ===== ICE Candidate Management =====
307
+
308
+ async addIceCandidates(
309
+ offerId: string,
310
+ username: string,
311
+ role: 'offerer' | 'answerer',
312
+ candidates: any[]
313
+ ): Promise<number> {
314
+ if (candidates.length === 0) return 0;
315
+
316
+ const baseTimestamp = Date.now();
317
+ const client = await this.pool.connect();
318
+
319
+ try {
320
+ await client.query('BEGIN');
321
+
322
+ for (let i = 0; i < candidates.length; i++) {
323
+ await client.query(
324
+ `INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
325
+ VALUES ($1, $2, $3, $4, $5)`,
326
+ [offerId, username, role, JSON.stringify(candidates[i]), baseTimestamp + i]
327
+ );
328
+ }
329
+
330
+ await client.query('COMMIT');
331
+ } catch (error) {
332
+ await client.query('ROLLBACK');
333
+ throw error;
334
+ } finally {
335
+ client.release();
336
+ }
337
+
338
+ return candidates.length;
339
+ }
340
+
341
+ async getIceCandidates(
342
+ offerId: string,
343
+ targetRole: 'offerer' | 'answerer',
344
+ since?: number
345
+ ): Promise<IceCandidate[]> {
346
+ let query = `SELECT * FROM ice_candidates WHERE offer_id = $1 AND role = $2`;
347
+ const params: any[] = [offerId, targetRole];
348
+
349
+ if (since !== undefined) {
350
+ query += ' AND created_at > $3';
351
+ params.push(since);
352
+ }
353
+
354
+ query += ' ORDER BY created_at ASC';
355
+
356
+ const result = await this.pool.query(query, params);
357
+ return result.rows.map(row => this.rowToIceCandidate(row));
358
+ }
359
+
360
+ async getIceCandidatesForMultipleOffers(
361
+ offerIds: string[],
362
+ username: string,
363
+ since?: number
364
+ ): Promise<Map<string, IceCandidate[]>> {
365
+ const resultMap = new Map<string, IceCandidate[]>();
366
+
367
+ if (offerIds.length === 0) return resultMap;
368
+ if (offerIds.length > 1000) {
369
+ throw new Error('Too many offer IDs (max 1000)');
370
+ }
371
+
372
+ let query = `
373
+ SELECT ic.*, o.username as offer_username
374
+ FROM ice_candidates ic
375
+ INNER JOIN offers o ON o.id = ic.offer_id
376
+ WHERE ic.offer_id = ANY($1)
377
+ AND (
378
+ (o.username = $2 AND ic.role = 'answerer')
379
+ OR (o.answerer_username = $2 AND ic.role = 'offerer')
380
+ )
381
+ `;
382
+ const params: any[] = [offerIds, username];
383
+
384
+ if (since !== undefined) {
385
+ query += ' AND ic.created_at > $3';
386
+ params.push(since);
387
+ }
388
+
389
+ query += ' ORDER BY ic.created_at ASC';
390
+
391
+ const result = await this.pool.query(query, params);
392
+
393
+ for (const row of result.rows) {
394
+ const candidate = this.rowToIceCandidate(row);
395
+ if (!resultMap.has(row.offer_id)) {
396
+ resultMap.set(row.offer_id, []);
397
+ }
398
+ resultMap.get(row.offer_id)!.push(candidate);
399
+ }
400
+
401
+ return resultMap;
402
+ }
403
+
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
+ // ===== Rate Limiting =====
505
+
506
+ async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
507
+ const now = Date.now();
508
+ const resetTime = now + windowMs;
509
+
510
+ // Use INSERT ... ON CONFLICT for atomic upsert
511
+ const result = await this.pool.query(
512
+ `INSERT INTO rate_limits (identifier, count, reset_time)
513
+ VALUES ($1, 1, $2)
514
+ ON CONFLICT (identifier) DO UPDATE SET
515
+ count = CASE
516
+ WHEN rate_limits.reset_time < $3 THEN 1
517
+ ELSE rate_limits.count + 1
518
+ END,
519
+ reset_time = CASE
520
+ WHEN rate_limits.reset_time < $3 THEN $2
521
+ ELSE rate_limits.reset_time
522
+ END
523
+ RETURNING count`,
524
+ [identifier, resetTime, now]
525
+ );
526
+
527
+ return result.rows[0].count <= limit;
528
+ }
529
+
530
+ async deleteExpiredRateLimits(now: number): Promise<number> {
531
+ const result = await this.pool.query(
532
+ `DELETE FROM rate_limits WHERE reset_time < $1`,
533
+ [now]
534
+ );
535
+ return result.rowCount ?? 0;
536
+ }
537
+
538
+ // ===== Nonce Tracking (Replay Protection) =====
539
+
540
+ async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
541
+ try {
542
+ await this.pool.query(
543
+ `INSERT INTO nonces (nonce_key, expires_at) VALUES ($1, $2)`,
544
+ [nonceKey, expiresAt]
545
+ );
546
+ return true;
547
+ } catch (error: any) {
548
+ // PostgreSQL unique violation error code
549
+ if (error.code === '23505') {
550
+ return false;
551
+ }
552
+ throw error;
553
+ }
554
+ }
555
+
556
+ async deleteExpiredNonces(now: number): Promise<number> {
557
+ const result = await this.pool.query(
558
+ `DELETE FROM nonces WHERE expires_at < $1`,
559
+ [now]
560
+ );
561
+ return result.rowCount ?? 0;
562
+ }
563
+
564
+ async close(): Promise<void> {
565
+ await this.pool.end();
566
+ }
567
+
568
+ // ===== Helper Methods =====
569
+
570
+ private rowToOffer(row: any): Offer {
571
+ return {
572
+ id: row.id,
573
+ username: row.username,
574
+ tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags,
575
+ sdp: row.sdp,
576
+ createdAt: Number(row.created_at),
577
+ expiresAt: Number(row.expires_at),
578
+ lastSeen: Number(row.last_seen),
579
+ answererUsername: row.answerer_username || undefined,
580
+ answerSdp: row.answer_sdp || undefined,
581
+ answeredAt: row.answered_at ? Number(row.answered_at) : undefined,
582
+ };
583
+ }
584
+
585
+ private rowToIceCandidate(row: any): IceCandidate {
586
+ return {
587
+ id: Number(row.id),
588
+ offerId: row.offer_id,
589
+ username: row.username,
590
+ role: row.role as 'offerer' | 'answerer',
591
+ candidate: typeof row.candidate === 'string' ? JSON.parse(row.candidate) : row.candidate,
592
+ createdAt: Number(row.created_at),
593
+ };
594
+ }
595
+ }
@@ -0,0 +1,59 @@
1
+ -- MySQL schema for rondevu signaling system
2
+ -- Compatible with MySQL 8.0+
3
+
4
+ -- WebRTC signaling offers with tags
5
+ CREATE TABLE IF NOT EXISTS offers (
6
+ id VARCHAR(64) PRIMARY KEY,
7
+ username VARCHAR(32) NOT NULL,
8
+ tags JSON NOT NULL,
9
+ sdp MEDIUMTEXT NOT NULL,
10
+ created_at BIGINT NOT NULL,
11
+ expires_at BIGINT NOT NULL,
12
+ last_seen BIGINT NOT NULL,
13
+ answerer_username VARCHAR(32),
14
+ answer_sdp MEDIUMTEXT,
15
+ answered_at BIGINT,
16
+ INDEX idx_offers_username (username),
17
+ INDEX idx_offers_expires (expires_at),
18
+ INDEX idx_offers_last_seen (last_seen),
19
+ INDEX idx_offers_answerer (answerer_username)
20
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
21
+
22
+ -- ICE candidates table
23
+ CREATE TABLE IF NOT EXISTS ice_candidates (
24
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
25
+ offer_id VARCHAR(64) NOT NULL,
26
+ username VARCHAR(32) NOT NULL,
27
+ role ENUM('offerer', 'answerer') NOT NULL,
28
+ candidate JSON NOT NULL,
29
+ created_at BIGINT NOT NULL,
30
+ INDEX idx_ice_offer (offer_id),
31
+ INDEX idx_ice_username (username),
32
+ INDEX idx_ice_created (created_at),
33
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
34
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
35
+
36
+ -- Credentials table
37
+ CREATE TABLE IF NOT EXISTS credentials (
38
+ name VARCHAR(32) PRIMARY KEY,
39
+ secret VARCHAR(512) NOT NULL UNIQUE,
40
+ created_at BIGINT NOT NULL,
41
+ expires_at BIGINT NOT NULL,
42
+ last_used BIGINT NOT NULL,
43
+ INDEX idx_credentials_expires (expires_at)
44
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
45
+
46
+ -- Rate limits table
47
+ CREATE TABLE IF NOT EXISTS rate_limits (
48
+ identifier VARCHAR(255) PRIMARY KEY,
49
+ count INT NOT NULL,
50
+ reset_time BIGINT NOT NULL,
51
+ INDEX idx_rate_limits_reset (reset_time)
52
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
53
+
54
+ -- Nonces table (replay attack prevention)
55
+ CREATE TABLE IF NOT EXISTS nonces (
56
+ nonce_key VARCHAR(255) PRIMARY KEY,
57
+ expires_at BIGINT NOT NULL,
58
+ INDEX idx_nonces_expires (expires_at)
59
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,64 @@
1
+ -- PostgreSQL schema for rondevu signaling system
2
+ -- Compatible with PostgreSQL 12+
3
+
4
+ -- WebRTC signaling offers with tags
5
+ CREATE TABLE IF NOT EXISTS offers (
6
+ id VARCHAR(64) PRIMARY KEY,
7
+ username VARCHAR(32) NOT NULL,
8
+ tags JSONB NOT NULL,
9
+ sdp TEXT NOT NULL,
10
+ created_at BIGINT NOT NULL,
11
+ expires_at BIGINT NOT NULL,
12
+ last_seen BIGINT NOT NULL,
13
+ answerer_username VARCHAR(32),
14
+ answer_sdp TEXT,
15
+ answered_at BIGINT
16
+ );
17
+
18
+ CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
19
+ CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
20
+ CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
21
+ CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
22
+ CREATE INDEX IF NOT EXISTS idx_offers_tags ON offers USING GIN(tags);
23
+
24
+ -- ICE candidates table
25
+ CREATE TABLE IF NOT EXISTS ice_candidates (
26
+ id BIGSERIAL PRIMARY KEY,
27
+ offer_id VARCHAR(64) NOT NULL REFERENCES offers(id) ON DELETE CASCADE,
28
+ username VARCHAR(32) NOT NULL,
29
+ role VARCHAR(8) NOT NULL CHECK (role IN ('offerer', 'answerer')),
30
+ candidate JSONB NOT NULL,
31
+ created_at BIGINT NOT NULL
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
35
+ CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
36
+ CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
37
+
38
+ -- Credentials table
39
+ CREATE TABLE IF NOT EXISTS credentials (
40
+ name VARCHAR(32) PRIMARY KEY,
41
+ secret VARCHAR(512) NOT NULL UNIQUE,
42
+ created_at BIGINT NOT NULL,
43
+ expires_at BIGINT NOT NULL,
44
+ last_used BIGINT NOT NULL
45
+ );
46
+
47
+ CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at);
48
+
49
+ -- Rate limits table
50
+ CREATE TABLE IF NOT EXISTS rate_limits (
51
+ identifier VARCHAR(255) PRIMARY KEY,
52
+ count INT NOT NULL,
53
+ reset_time BIGINT NOT NULL
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time);
57
+
58
+ -- Nonces table (replay attack prevention)
59
+ CREATE TABLE IF NOT EXISTS nonces (
60
+ nonce_key VARCHAR(255) PRIMARY KEY,
61
+ expires_at BIGINT NOT NULL
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at);