@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.
- package/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.idea/modules.xml +8 -0
- package/.idea/rondevu-server.iml +8 -0
- package/.idea/workspace.xml +17 -0
- package/README.md +80 -199
- package/build.js +4 -1
- package/dist/index.js +2755 -1448
- package/dist/index.js.map +4 -4
- package/migrations/fresh_schema.sql +36 -41
- package/package.json +10 -4
- package/src/app.ts +38 -18
- package/src/config.ts +155 -9
- package/src/crypto.ts +361 -263
- package/src/index.ts +20 -25
- package/src/rpc.ts +658 -405
- package/src/storage/d1.ts +312 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +559 -0
- package/src/storage/mysql.ts +588 -0
- package/src/storage/postgres.ts +595 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +303 -269
- package/src/storage/types.ts +113 -113
- package/src/worker.ts +15 -34
- package/tests/integration/api.test.ts +395 -0
- package/tests/integration/setup.ts +170 -0
- package/wrangler.toml +25 -26
- package/ADVANCED.md +0 -502
|
@@ -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);
|