@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,588 @@
|
|
|
1
|
+
import mysql, { Pool, PoolConnection, RowDataPacket, ResultSetHeader } from 'mysql2/promise';
|
|
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
|
+
* MySQL storage adapter for rondevu signaling system
|
|
16
|
+
* Uses connection pooling for efficient resource management
|
|
17
|
+
*/
|
|
18
|
+
export class MySQLStorage 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 MySQL storage instance with connection pooling
|
|
29
|
+
* @param connectionString MySQL 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<MySQLStorage> {
|
|
38
|
+
const pool = mysql.createPool({
|
|
39
|
+
uri: connectionString,
|
|
40
|
+
waitForConnections: true,
|
|
41
|
+
connectionLimit: poolSize,
|
|
42
|
+
queueLimit: 0,
|
|
43
|
+
enableKeepAlive: true,
|
|
44
|
+
keepAliveInitialDelay: 10000,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const storage = new MySQLStorage(pool, masterEncryptionKey);
|
|
48
|
+
await storage.initializeDatabase();
|
|
49
|
+
return storage;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async initializeDatabase(): Promise<void> {
|
|
53
|
+
const conn = await this.pool.getConnection();
|
|
54
|
+
try {
|
|
55
|
+
await conn.query(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS offers (
|
|
57
|
+
id VARCHAR(64) PRIMARY KEY,
|
|
58
|
+
username VARCHAR(32) NOT NULL,
|
|
59
|
+
tags JSON NOT NULL,
|
|
60
|
+
sdp MEDIUMTEXT NOT NULL,
|
|
61
|
+
created_at BIGINT NOT NULL,
|
|
62
|
+
expires_at BIGINT NOT NULL,
|
|
63
|
+
last_seen BIGINT NOT NULL,
|
|
64
|
+
answerer_username VARCHAR(32),
|
|
65
|
+
answer_sdp MEDIUMTEXT,
|
|
66
|
+
answered_at BIGINT,
|
|
67
|
+
INDEX idx_offers_username (username),
|
|
68
|
+
INDEX idx_offers_expires (expires_at),
|
|
69
|
+
INDEX idx_offers_last_seen (last_seen),
|
|
70
|
+
INDEX idx_offers_answerer (answerer_username)
|
|
71
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
72
|
+
`);
|
|
73
|
+
|
|
74
|
+
await conn.query(`
|
|
75
|
+
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
76
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
77
|
+
offer_id VARCHAR(64) NOT NULL,
|
|
78
|
+
username VARCHAR(32) NOT NULL,
|
|
79
|
+
role ENUM('offerer', 'answerer') NOT NULL,
|
|
80
|
+
candidate JSON NOT NULL,
|
|
81
|
+
created_at BIGINT NOT NULL,
|
|
82
|
+
INDEX idx_ice_offer (offer_id),
|
|
83
|
+
INDEX idx_ice_username (username),
|
|
84
|
+
INDEX idx_ice_created (created_at),
|
|
85
|
+
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
86
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
await conn.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
|
+
INDEX idx_credentials_expires (expires_at)
|
|
97
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
await conn.query(`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
102
|
+
identifier VARCHAR(255) PRIMARY KEY,
|
|
103
|
+
count INT NOT NULL,
|
|
104
|
+
reset_time BIGINT NOT NULL,
|
|
105
|
+
INDEX idx_rate_limits_reset (reset_time)
|
|
106
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
107
|
+
`);
|
|
108
|
+
|
|
109
|
+
await conn.query(`
|
|
110
|
+
CREATE TABLE IF NOT EXISTS nonces (
|
|
111
|
+
nonce_key VARCHAR(255) PRIMARY KEY,
|
|
112
|
+
expires_at BIGINT NOT NULL,
|
|
113
|
+
INDEX idx_nonces_expires (expires_at)
|
|
114
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
115
|
+
`);
|
|
116
|
+
} finally {
|
|
117
|
+
conn.release();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ===== Offer Management =====
|
|
122
|
+
|
|
123
|
+
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
|
124
|
+
if (offers.length === 0) return [];
|
|
125
|
+
|
|
126
|
+
const created: Offer[] = [];
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
|
|
129
|
+
const conn = await this.pool.getConnection();
|
|
130
|
+
try {
|
|
131
|
+
await conn.beginTransaction();
|
|
132
|
+
|
|
133
|
+
for (const request of offers) {
|
|
134
|
+
const id = request.id || await generateOfferHash(request.sdp);
|
|
135
|
+
|
|
136
|
+
await conn.query(
|
|
137
|
+
`INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
|
|
138
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
139
|
+
[id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
created.push({
|
|
143
|
+
id,
|
|
144
|
+
username: request.username,
|
|
145
|
+
tags: request.tags,
|
|
146
|
+
sdp: request.sdp,
|
|
147
|
+
createdAt: now,
|
|
148
|
+
expiresAt: request.expiresAt,
|
|
149
|
+
lastSeen: now,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await conn.commit();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
await conn.rollback();
|
|
156
|
+
throw error;
|
|
157
|
+
} finally {
|
|
158
|
+
conn.release();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return created;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getOffersByUsername(username: string): Promise<Offer[]> {
|
|
165
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
166
|
+
`SELECT * FROM offers WHERE username = ? AND expires_at > ? ORDER BY last_seen DESC`,
|
|
167
|
+
[username, Date.now()]
|
|
168
|
+
);
|
|
169
|
+
return rows.map(row => this.rowToOffer(row));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async getOfferById(offerId: string): Promise<Offer | null> {
|
|
173
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
174
|
+
`SELECT * FROM offers WHERE id = ? AND expires_at > ?`,
|
|
175
|
+
[offerId, Date.now()]
|
|
176
|
+
);
|
|
177
|
+
return rows.length > 0 ? this.rowToOffer(rows[0]) : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
|
|
181
|
+
const [result] = await this.pool.query<ResultSetHeader>(
|
|
182
|
+
`DELETE FROM offers WHERE id = ? AND username = ?`,
|
|
183
|
+
[offerId, ownerUsername]
|
|
184
|
+
);
|
|
185
|
+
return result.affectedRows > 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async deleteExpiredOffers(now: number): Promise<number> {
|
|
189
|
+
const [result] = await this.pool.query<ResultSetHeader>(
|
|
190
|
+
`DELETE FROM offers WHERE expires_at < ?`,
|
|
191
|
+
[now]
|
|
192
|
+
);
|
|
193
|
+
return result.affectedRows;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async answerOffer(
|
|
197
|
+
offerId: string,
|
|
198
|
+
answererUsername: string,
|
|
199
|
+
answerSdp: string
|
|
200
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
201
|
+
const offer = await this.getOfferById(offerId);
|
|
202
|
+
|
|
203
|
+
if (!offer) {
|
|
204
|
+
return { success: false, error: 'Offer not found or expired' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (offer.answererUsername) {
|
|
208
|
+
return { success: false, error: 'Offer already answered' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const [result] = await this.pool.query<ResultSetHeader>(
|
|
212
|
+
`UPDATE offers SET answerer_username = ?, answer_sdp = ?, answered_at = ?
|
|
213
|
+
WHERE id = ? AND answerer_username IS NULL`,
|
|
214
|
+
[answererUsername, answerSdp, Date.now(), offerId]
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (result.affectedRows === 0) {
|
|
218
|
+
return { success: false, error: 'Offer already answered (race condition)' };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { success: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
|
|
225
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
226
|
+
`SELECT * FROM offers
|
|
227
|
+
WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
|
|
228
|
+
ORDER BY answered_at DESC`,
|
|
229
|
+
[offererUsername, Date.now()]
|
|
230
|
+
);
|
|
231
|
+
return rows.map(row => this.rowToOffer(row));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
|
|
235
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
236
|
+
`SELECT * FROM offers
|
|
237
|
+
WHERE answerer_username = ? AND expires_at > ?
|
|
238
|
+
ORDER BY answered_at DESC`,
|
|
239
|
+
[answererUsername, Date.now()]
|
|
240
|
+
);
|
|
241
|
+
return rows.map(row => this.rowToOffer(row));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ===== Discovery =====
|
|
245
|
+
|
|
246
|
+
async discoverOffers(
|
|
247
|
+
tags: string[],
|
|
248
|
+
excludeUsername: string | null,
|
|
249
|
+
limit: number,
|
|
250
|
+
offset: number
|
|
251
|
+
): Promise<Offer[]> {
|
|
252
|
+
if (tags.length === 0) return [];
|
|
253
|
+
|
|
254
|
+
// Use JSON_OVERLAPS for efficient tag matching (MySQL 8.0.17+)
|
|
255
|
+
// Falls back to JSON_CONTAINS for each tag with OR logic
|
|
256
|
+
const tagArray = JSON.stringify(tags);
|
|
257
|
+
|
|
258
|
+
let query = `
|
|
259
|
+
SELECT DISTINCT o.* FROM offers o
|
|
260
|
+
WHERE JSON_OVERLAPS(o.tags, ?)
|
|
261
|
+
AND o.expires_at > ?
|
|
262
|
+
AND o.answerer_username IS NULL
|
|
263
|
+
`;
|
|
264
|
+
const params: any[] = [tagArray, Date.now()];
|
|
265
|
+
|
|
266
|
+
if (excludeUsername) {
|
|
267
|
+
query += ' AND o.username != ?';
|
|
268
|
+
params.push(excludeUsername);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
query += ' ORDER BY o.created_at DESC LIMIT ? OFFSET ?';
|
|
272
|
+
params.push(limit, offset);
|
|
273
|
+
|
|
274
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(query, params);
|
|
275
|
+
return rows.map(row => this.rowToOffer(row));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async getRandomOffer(
|
|
279
|
+
tags: string[],
|
|
280
|
+
excludeUsername: string | null
|
|
281
|
+
): Promise<Offer | null> {
|
|
282
|
+
if (tags.length === 0) return null;
|
|
283
|
+
|
|
284
|
+
const tagArray = JSON.stringify(tags);
|
|
285
|
+
|
|
286
|
+
let query = `
|
|
287
|
+
SELECT DISTINCT o.* FROM offers o
|
|
288
|
+
WHERE JSON_OVERLAPS(o.tags, ?)
|
|
289
|
+
AND o.expires_at > ?
|
|
290
|
+
AND o.answerer_username IS NULL
|
|
291
|
+
`;
|
|
292
|
+
const params: any[] = [tagArray, Date.now()];
|
|
293
|
+
|
|
294
|
+
if (excludeUsername) {
|
|
295
|
+
query += ' AND o.username != ?';
|
|
296
|
+
params.push(excludeUsername);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
query += ' ORDER BY RAND() LIMIT 1';
|
|
300
|
+
|
|
301
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(query, params);
|
|
302
|
+
return rows.length > 0 ? this.rowToOffer(rows[0]) : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ===== ICE Candidate Management =====
|
|
306
|
+
|
|
307
|
+
async addIceCandidates(
|
|
308
|
+
offerId: string,
|
|
309
|
+
username: string,
|
|
310
|
+
role: 'offerer' | 'answerer',
|
|
311
|
+
candidates: any[]
|
|
312
|
+
): Promise<number> {
|
|
313
|
+
if (candidates.length === 0) return 0;
|
|
314
|
+
|
|
315
|
+
const baseTimestamp = Date.now();
|
|
316
|
+
const values = candidates.map((c, i) => [
|
|
317
|
+
offerId,
|
|
318
|
+
username,
|
|
319
|
+
role,
|
|
320
|
+
JSON.stringify(c),
|
|
321
|
+
baseTimestamp + i,
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
await this.pool.query(
|
|
325
|
+
`INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
|
|
326
|
+
VALUES ?`,
|
|
327
|
+
[values]
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
return candidates.length;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async getIceCandidates(
|
|
334
|
+
offerId: string,
|
|
335
|
+
targetRole: 'offerer' | 'answerer',
|
|
336
|
+
since?: number
|
|
337
|
+
): Promise<IceCandidate[]> {
|
|
338
|
+
let query = `SELECT * FROM ice_candidates WHERE offer_id = ? AND role = ?`;
|
|
339
|
+
const params: any[] = [offerId, targetRole];
|
|
340
|
+
|
|
341
|
+
if (since !== undefined) {
|
|
342
|
+
query += ' AND created_at > ?';
|
|
343
|
+
params.push(since);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
query += ' ORDER BY created_at ASC';
|
|
347
|
+
|
|
348
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(query, params);
|
|
349
|
+
return rows.map(row => this.rowToIceCandidate(row));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async getIceCandidatesForMultipleOffers(
|
|
353
|
+
offerIds: string[],
|
|
354
|
+
username: string,
|
|
355
|
+
since?: number
|
|
356
|
+
): Promise<Map<string, IceCandidate[]>> {
|
|
357
|
+
const result = new Map<string, IceCandidate[]>();
|
|
358
|
+
|
|
359
|
+
if (offerIds.length === 0) return result;
|
|
360
|
+
if (offerIds.length > 1000) {
|
|
361
|
+
throw new Error('Too many offer IDs (max 1000)');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const placeholders = offerIds.map(() => '?').join(',');
|
|
365
|
+
|
|
366
|
+
let query = `
|
|
367
|
+
SELECT ic.*, o.username as offer_username
|
|
368
|
+
FROM ice_candidates ic
|
|
369
|
+
INNER JOIN offers o ON o.id = ic.offer_id
|
|
370
|
+
WHERE ic.offer_id IN (${placeholders})
|
|
371
|
+
AND (
|
|
372
|
+
(o.username = ? AND ic.role = 'answerer')
|
|
373
|
+
OR (o.answerer_username = ? AND ic.role = 'offerer')
|
|
374
|
+
)
|
|
375
|
+
`;
|
|
376
|
+
const params: any[] = [...offerIds, username, username];
|
|
377
|
+
|
|
378
|
+
if (since !== undefined) {
|
|
379
|
+
query += ' AND ic.created_at > ?';
|
|
380
|
+
params.push(since);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
query += ' ORDER BY ic.created_at ASC';
|
|
384
|
+
|
|
385
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(query, params);
|
|
386
|
+
|
|
387
|
+
for (const row of rows) {
|
|
388
|
+
const candidate = this.rowToIceCandidate(row);
|
|
389
|
+
if (!result.has(row.offer_id)) {
|
|
390
|
+
result.set(row.offer_id, []);
|
|
391
|
+
}
|
|
392
|
+
result.get(row.offer_id)!.push(candidate);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ===== Credential Management =====
|
|
399
|
+
|
|
400
|
+
async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
|
|
401
|
+
const now = Date.now();
|
|
402
|
+
const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
|
|
403
|
+
|
|
404
|
+
const { generateCredentialName, generateSecret, encryptSecret } = await import('../crypto.ts');
|
|
405
|
+
|
|
406
|
+
let name: string;
|
|
407
|
+
|
|
408
|
+
if (request.name) {
|
|
409
|
+
const [existing] = await this.pool.query<RowDataPacket[]>(
|
|
410
|
+
`SELECT name FROM credentials WHERE name = ?`,
|
|
411
|
+
[request.name]
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
if (existing.length > 0) {
|
|
415
|
+
throw new Error('Username already taken');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
name = request.name;
|
|
419
|
+
} else {
|
|
420
|
+
let attempts = 0;
|
|
421
|
+
const maxAttempts = 100;
|
|
422
|
+
|
|
423
|
+
while (attempts < maxAttempts) {
|
|
424
|
+
name = generateCredentialName();
|
|
425
|
+
|
|
426
|
+
const [existing] = await this.pool.query<RowDataPacket[]>(
|
|
427
|
+
`SELECT name FROM credentials WHERE name = ?`,
|
|
428
|
+
[name]
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
if (existing.length === 0) break;
|
|
432
|
+
attempts++;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (attempts >= maxAttempts) {
|
|
436
|
+
throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const secret = generateSecret();
|
|
441
|
+
const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
|
|
442
|
+
|
|
443
|
+
await this.pool.query(
|
|
444
|
+
`INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
|
|
445
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
446
|
+
[name!, encryptedSecret, now, expiresAt, now]
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
name: name!,
|
|
451
|
+
secret,
|
|
452
|
+
createdAt: now,
|
|
453
|
+
expiresAt,
|
|
454
|
+
lastUsed: now,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async getCredential(name: string): Promise<Credential | null> {
|
|
459
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
460
|
+
`SELECT * FROM credentials WHERE name = ? AND expires_at > ?`,
|
|
461
|
+
[name, Date.now()]
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
if (rows.length === 0) return null;
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const { decryptSecret } = await import('../crypto.ts');
|
|
468
|
+
const decryptedSecret = await decryptSecret(rows[0].secret, this.masterEncryptionKey);
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
name: rows[0].name,
|
|
472
|
+
secret: decryptedSecret,
|
|
473
|
+
createdAt: Number(rows[0].created_at),
|
|
474
|
+
expiresAt: Number(rows[0].expires_at),
|
|
475
|
+
lastUsed: Number(rows[0].last_used),
|
|
476
|
+
};
|
|
477
|
+
} catch (error) {
|
|
478
|
+
console.error(`Failed to decrypt secret for credential '${name}':`, error);
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
|
|
484
|
+
await this.pool.query(
|
|
485
|
+
`UPDATE credentials SET last_used = ?, expires_at = ? WHERE name = ?`,
|
|
486
|
+
[lastUsed, expiresAt, name]
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async deleteExpiredCredentials(now: number): Promise<number> {
|
|
491
|
+
const [result] = await this.pool.query<ResultSetHeader>(
|
|
492
|
+
`DELETE FROM credentials WHERE expires_at < ?`,
|
|
493
|
+
[now]
|
|
494
|
+
);
|
|
495
|
+
return result.affectedRows;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ===== Rate Limiting =====
|
|
499
|
+
|
|
500
|
+
async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
|
|
501
|
+
const now = Date.now();
|
|
502
|
+
const resetTime = now + windowMs;
|
|
503
|
+
|
|
504
|
+
// Use INSERT ... ON DUPLICATE KEY UPDATE for atomic upsert
|
|
505
|
+
await this.pool.query(
|
|
506
|
+
`INSERT INTO rate_limits (identifier, count, reset_time)
|
|
507
|
+
VALUES (?, 1, ?)
|
|
508
|
+
ON DUPLICATE KEY UPDATE
|
|
509
|
+
count = IF(reset_time < ?, 1, count + 1),
|
|
510
|
+
reset_time = IF(reset_time < ?, ?, reset_time)`,
|
|
511
|
+
[identifier, resetTime, now, now, resetTime]
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Get current count
|
|
515
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
516
|
+
`SELECT count FROM rate_limits WHERE identifier = ?`,
|
|
517
|
+
[identifier]
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
return rows.length > 0 && rows[0].count <= limit;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async deleteExpiredRateLimits(now: number): Promise<number> {
|
|
524
|
+
const [result] = await this.pool.query<ResultSetHeader>(
|
|
525
|
+
`DELETE FROM rate_limits WHERE reset_time < ?`,
|
|
526
|
+
[now]
|
|
527
|
+
);
|
|
528
|
+
return result.affectedRows;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ===== Nonce Tracking (Replay Protection) =====
|
|
532
|
+
|
|
533
|
+
async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
|
|
534
|
+
try {
|
|
535
|
+
await this.pool.query(
|
|
536
|
+
`INSERT INTO nonces (nonce_key, expires_at) VALUES (?, ?)`,
|
|
537
|
+
[nonceKey, expiresAt]
|
|
538
|
+
);
|
|
539
|
+
return true;
|
|
540
|
+
} catch (error: any) {
|
|
541
|
+
// MySQL duplicate key error code
|
|
542
|
+
if (error.code === 'ER_DUP_ENTRY') {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async deleteExpiredNonces(now: number): Promise<number> {
|
|
550
|
+
const [result] = await this.pool.query<ResultSetHeader>(
|
|
551
|
+
`DELETE FROM nonces WHERE expires_at < ?`,
|
|
552
|
+
[now]
|
|
553
|
+
);
|
|
554
|
+
return result.affectedRows;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async close(): Promise<void> {
|
|
558
|
+
await this.pool.end();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ===== Helper Methods =====
|
|
562
|
+
|
|
563
|
+
private rowToOffer(row: RowDataPacket): Offer {
|
|
564
|
+
return {
|
|
565
|
+
id: row.id,
|
|
566
|
+
username: row.username,
|
|
567
|
+
tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags,
|
|
568
|
+
sdp: row.sdp,
|
|
569
|
+
createdAt: Number(row.created_at),
|
|
570
|
+
expiresAt: Number(row.expires_at),
|
|
571
|
+
lastSeen: Number(row.last_seen),
|
|
572
|
+
answererUsername: row.answerer_username || undefined,
|
|
573
|
+
answerSdp: row.answer_sdp || undefined,
|
|
574
|
+
answeredAt: row.answered_at ? Number(row.answered_at) : undefined,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private rowToIceCandidate(row: RowDataPacket): IceCandidate {
|
|
579
|
+
return {
|
|
580
|
+
id: Number(row.id),
|
|
581
|
+
offerId: row.offer_id,
|
|
582
|
+
username: row.username,
|
|
583
|
+
role: row.role as 'offerer' | 'answerer',
|
|
584
|
+
candidate: typeof row.candidate === 'string' ? JSON.parse(row.candidate) : row.candidate,
|
|
585
|
+
createdAt: Number(row.created_at),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
}
|