@xtr-dev/rondevu-server 0.5.1 → 0.5.7
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 +2891 -1446
- 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 +183 -9
- package/src/crypto.ts +361 -263
- package/src/index.ts +20 -25
- package/src/rpc.ts +714 -403
- package/src/storage/d1.ts +338 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +579 -0
- package/src/storage/mysql.ts +616 -0
- package/src/storage/postgres.ts +623 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +325 -269
- package/src/storage/types.ts +137 -109
- 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
package/src/storage/d1.ts
CHANGED
|
@@ -4,41 +4,41 @@ import {
|
|
|
4
4
|
Offer,
|
|
5
5
|
IceCandidate,
|
|
6
6
|
CreateOfferRequest,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Service,
|
|
10
|
-
CreateServiceRequest,
|
|
7
|
+
Credential,
|
|
8
|
+
GenerateCredentialsRequest,
|
|
11
9
|
} from './types.ts';
|
|
12
10
|
import { generateOfferHash } from './hash-id.ts';
|
|
13
|
-
import { parseServiceFqn } from '../crypto.ts';
|
|
14
11
|
|
|
15
12
|
const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
|
|
16
13
|
|
|
17
14
|
/**
|
|
18
|
-
* D1 storage adapter for rondevu
|
|
15
|
+
* D1 storage adapter for rondevu signaling system using Cloudflare D1
|
|
19
16
|
*/
|
|
20
17
|
export class D1Storage implements Storage {
|
|
21
18
|
private db: D1Database;
|
|
19
|
+
private masterEncryptionKey: string;
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* Creates a new D1 storage instance
|
|
25
23
|
* @param db D1Database instance from Cloudflare Workers environment
|
|
24
|
+
* @param masterEncryptionKey 64-char hex string for encrypting secrets (32 bytes)
|
|
26
25
|
*/
|
|
27
|
-
constructor(db: D1Database) {
|
|
26
|
+
constructor(db: D1Database, masterEncryptionKey: string) {
|
|
28
27
|
this.db = db;
|
|
28
|
+
this.masterEncryptionKey = masterEncryptionKey;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Initializes database schema with
|
|
32
|
+
* Initializes database schema with tags-based offers
|
|
33
33
|
* This should be run once during setup, not on every request
|
|
34
34
|
*/
|
|
35
35
|
async initializeDatabase(): Promise<void> {
|
|
36
36
|
await this.db.exec(`
|
|
37
|
-
-- WebRTC signaling offers
|
|
37
|
+
-- WebRTC signaling offers with tags
|
|
38
38
|
CREATE TABLE IF NOT EXISTS offers (
|
|
39
39
|
id TEXT PRIMARY KEY,
|
|
40
40
|
username TEXT NOT NULL,
|
|
41
|
-
|
|
41
|
+
tags TEXT NOT NULL,
|
|
42
42
|
sdp TEXT NOT NULL,
|
|
43
43
|
created_at INTEGER NOT NULL,
|
|
44
44
|
expires_at INTEGER NOT NULL,
|
|
@@ -49,7 +49,6 @@ export class D1Storage implements Storage {
|
|
|
49
49
|
);
|
|
50
50
|
|
|
51
51
|
CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
|
|
52
|
-
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
|
|
53
52
|
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
54
53
|
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
55
54
|
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
|
|
@@ -69,37 +68,35 @@ export class D1Storage implements Storage {
|
|
|
69
68
|
CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
|
|
70
69
|
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
71
70
|
|
|
72
|
-
--
|
|
73
|
-
CREATE TABLE IF NOT EXISTS
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
-- Credentials table (replaces usernames with simpler name + secret auth)
|
|
72
|
+
CREATE TABLE IF NOT EXISTS credentials (
|
|
73
|
+
name TEXT PRIMARY KEY,
|
|
74
|
+
secret TEXT NOT NULL UNIQUE,
|
|
75
|
+
created_at INTEGER NOT NULL,
|
|
77
76
|
expires_at INTEGER NOT NULL,
|
|
78
77
|
last_used INTEGER NOT NULL,
|
|
79
|
-
|
|
80
|
-
CHECK(length(username) >= 3 AND length(username) <= 32)
|
|
78
|
+
CHECK(length(name) >= 3 AND length(name) <= 32)
|
|
81
79
|
);
|
|
82
80
|
|
|
83
|
-
CREATE INDEX IF NOT EXISTS
|
|
84
|
-
CREATE INDEX IF NOT EXISTS
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_credentials_secret ON credentials(secret);
|
|
85
83
|
|
|
86
|
-
--
|
|
87
|
-
CREATE TABLE IF NOT EXISTS
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
84
|
+
-- Rate limits table (for distributed rate limiting)
|
|
85
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
86
|
+
identifier TEXT PRIMARY KEY,
|
|
87
|
+
count INTEGER NOT NULL,
|
|
88
|
+
reset_time INTEGER NOT NULL
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time);
|
|
92
|
+
|
|
93
|
+
-- Nonces table (for replay attack prevention)
|
|
94
|
+
CREATE TABLE IF NOT EXISTS nonces (
|
|
95
|
+
nonce_key TEXT PRIMARY KEY,
|
|
96
|
+
expires_at INTEGER NOT NULL
|
|
97
97
|
);
|
|
98
98
|
|
|
99
|
-
CREATE INDEX IF NOT EXISTS
|
|
100
|
-
CREATE INDEX IF NOT EXISTS idx_services_discovery ON services(service_name, version);
|
|
101
|
-
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
|
|
102
|
-
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at);
|
|
103
100
|
`);
|
|
104
101
|
}
|
|
105
102
|
|
|
@@ -114,15 +111,14 @@ export class D1Storage implements Storage {
|
|
|
114
111
|
const now = Date.now();
|
|
115
112
|
|
|
116
113
|
await this.db.prepare(`
|
|
117
|
-
INSERT INTO offers (id, username,
|
|
114
|
+
INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
|
|
118
115
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
119
|
-
`).bind(id, offer.username, offer.
|
|
116
|
+
`).bind(id, offer.username, JSON.stringify(offer.tags), offer.sdp, now, offer.expiresAt, now).run();
|
|
120
117
|
|
|
121
118
|
created.push({
|
|
122
119
|
id,
|
|
123
120
|
username: offer.username,
|
|
124
|
-
|
|
125
|
-
serviceFqn: offer.serviceFqn,
|
|
121
|
+
tags: offer.tags,
|
|
126
122
|
sdp: offer.sdp,
|
|
127
123
|
createdAt: now,
|
|
128
124
|
expiresAt: offer.expiresAt,
|
|
@@ -231,6 +227,94 @@ export class D1Storage implements Storage {
|
|
|
231
227
|
return result.results.map(row => this.rowToOffer(row as any));
|
|
232
228
|
}
|
|
233
229
|
|
|
230
|
+
async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
|
|
231
|
+
const result = await this.db.prepare(`
|
|
232
|
+
SELECT * FROM offers
|
|
233
|
+
WHERE answerer_username = ? AND expires_at > ?
|
|
234
|
+
ORDER BY answered_at DESC
|
|
235
|
+
`).bind(answererUsername, Date.now()).all();
|
|
236
|
+
|
|
237
|
+
if (!result.results) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return result.results.map(row => this.rowToOffer(row as any));
|
|
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) {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Build query with JSON tag matching (OR logic)
|
|
257
|
+
// D1/SQLite: Use json_each() to expand tags array and check if any tag matches
|
|
258
|
+
const placeholders = tags.map(() => '?').join(',');
|
|
259
|
+
|
|
260
|
+
let query = `
|
|
261
|
+
SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
|
|
262
|
+
WHERE t.value IN (${placeholders})
|
|
263
|
+
AND o.expires_at > ?
|
|
264
|
+
AND o.answerer_username IS NULL
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
const params: any[] = [...tags, Date.now()];
|
|
268
|
+
|
|
269
|
+
if (excludeUsername) {
|
|
270
|
+
query += ' AND o.username != ?';
|
|
271
|
+
params.push(excludeUsername);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
query += ' ORDER BY o.created_at DESC LIMIT ? OFFSET ?';
|
|
275
|
+
params.push(limit, offset);
|
|
276
|
+
|
|
277
|
+
const result = await this.db.prepare(query).bind(...params).all();
|
|
278
|
+
|
|
279
|
+
if (!result.results) {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return result.results.map(row => this.rowToOffer(row as any));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async getRandomOffer(
|
|
287
|
+
tags: string[],
|
|
288
|
+
excludeUsername: string | null
|
|
289
|
+
): Promise<Offer | null> {
|
|
290
|
+
if (tags.length === 0) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Build query with JSON tag matching (OR logic)
|
|
295
|
+
const placeholders = tags.map(() => '?').join(',');
|
|
296
|
+
|
|
297
|
+
let query = `
|
|
298
|
+
SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
|
|
299
|
+
WHERE t.value IN (${placeholders})
|
|
300
|
+
AND o.expires_at > ?
|
|
301
|
+
AND o.answerer_username IS NULL
|
|
302
|
+
`;
|
|
303
|
+
|
|
304
|
+
const params: any[] = [...tags, Date.now()];
|
|
305
|
+
|
|
306
|
+
if (excludeUsername) {
|
|
307
|
+
query += ' AND o.username != ?';
|
|
308
|
+
params.push(excludeUsername);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
query += ' ORDER BY RANDOM() LIMIT 1';
|
|
312
|
+
|
|
313
|
+
const result = await this.db.prepare(query).bind(...params).first();
|
|
314
|
+
|
|
315
|
+
return result ? this.rowToOffer(result as any) : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
234
318
|
// ===== ICE Candidate Management =====
|
|
235
319
|
|
|
236
320
|
async addIceCandidates(
|
|
@@ -292,270 +376,251 @@ export class D1Storage implements Storage {
|
|
|
292
376
|
}));
|
|
293
377
|
}
|
|
294
378
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
379
|
+
async getIceCandidatesForMultipleOffers(
|
|
380
|
+
offerIds: string[],
|
|
381
|
+
username: string,
|
|
382
|
+
since?: number
|
|
383
|
+
): Promise<Map<string, IceCandidate[]>> {
|
|
384
|
+
const result = new Map<string, IceCandidate[]>();
|
|
300
385
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
VALUES (?, ?, ?, ?, ?, NULL)
|
|
306
|
-
ON CONFLICT(username) DO UPDATE SET
|
|
307
|
-
expires_at = ?,
|
|
308
|
-
last_used = ?
|
|
309
|
-
WHERE public_key = ?
|
|
310
|
-
`).bind(
|
|
311
|
-
request.username,
|
|
312
|
-
request.publicKey,
|
|
313
|
-
now,
|
|
314
|
-
expiresAt,
|
|
315
|
-
now,
|
|
316
|
-
expiresAt,
|
|
317
|
-
now,
|
|
318
|
-
request.publicKey
|
|
319
|
-
).run();
|
|
386
|
+
// Return empty map if no offer IDs provided
|
|
387
|
+
if (offerIds.length === 0) {
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
320
390
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
391
|
+
// Validate array contains only strings
|
|
392
|
+
if (!Array.isArray(offerIds) || !offerIds.every(id => typeof id === 'string')) {
|
|
393
|
+
throw new Error('Invalid offer IDs: must be array of strings');
|
|
394
|
+
}
|
|
324
395
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
claimedAt: now,
|
|
329
|
-
expiresAt,
|
|
330
|
-
lastUsed: now,
|
|
331
|
-
};
|
|
332
|
-
} catch (err: any) {
|
|
333
|
-
// Handle UNIQUE constraint on public_key
|
|
334
|
-
if (err.message?.includes('UNIQUE constraint failed: usernames.public_key')) {
|
|
335
|
-
throw new Error('This public key has already claimed a different username');
|
|
336
|
-
}
|
|
337
|
-
throw err;
|
|
396
|
+
// Prevent DoS attacks from extremely large IN clauses
|
|
397
|
+
if (offerIds.length > 1000) {
|
|
398
|
+
throw new Error('Too many offer IDs (max 1000)');
|
|
338
399
|
}
|
|
339
|
-
}
|
|
340
400
|
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
SELECT * FROM usernames
|
|
344
|
-
WHERE username = ? AND expires_at > ?
|
|
345
|
-
`).bind(username, Date.now()).first();
|
|
401
|
+
// Build query that fetches candidates from the OTHER peer only
|
|
402
|
+
const placeholders = offerIds.map(() => '?').join(',');
|
|
346
403
|
|
|
347
|
-
|
|
348
|
-
|
|
404
|
+
let query = `
|
|
405
|
+
SELECT ic.*, o.username as offer_username
|
|
406
|
+
FROM ice_candidates ic
|
|
407
|
+
INNER JOIN offers o ON o.id = ic.offer_id
|
|
408
|
+
WHERE ic.offer_id IN (${placeholders})
|
|
409
|
+
AND (
|
|
410
|
+
(o.username = ? AND ic.role = 'answerer')
|
|
411
|
+
OR (o.answerer_username = ? AND ic.role = 'offerer')
|
|
412
|
+
)
|
|
413
|
+
`;
|
|
414
|
+
|
|
415
|
+
const params: any[] = [...offerIds, username, username];
|
|
416
|
+
|
|
417
|
+
if (since !== undefined) {
|
|
418
|
+
query += ' AND ic.created_at > ?';
|
|
419
|
+
params.push(since);
|
|
349
420
|
}
|
|
350
421
|
|
|
351
|
-
|
|
422
|
+
query += ' ORDER BY ic.created_at ASC';
|
|
352
423
|
|
|
353
|
-
|
|
354
|
-
username: row.username,
|
|
355
|
-
publicKey: row.public_key,
|
|
356
|
-
claimedAt: row.claimed_at,
|
|
357
|
-
expiresAt: row.expires_at,
|
|
358
|
-
lastUsed: row.last_used,
|
|
359
|
-
metadata: row.metadata || undefined,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
424
|
+
const queryResult = await this.db.prepare(query).bind(...params).all();
|
|
362
425
|
|
|
426
|
+
if (!queryResult.results) {
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
363
429
|
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
430
|
+
// Group candidates by offer_id
|
|
431
|
+
for (const row of queryResult.results as any[]) {
|
|
432
|
+
const candidate: IceCandidate = {
|
|
433
|
+
id: row.id,
|
|
434
|
+
offerId: row.offer_id,
|
|
435
|
+
username: row.username,
|
|
436
|
+
role: row.role,
|
|
437
|
+
candidate: JSON.parse(row.candidate),
|
|
438
|
+
createdAt: row.created_at,
|
|
439
|
+
};
|
|
368
440
|
|
|
369
|
-
|
|
441
|
+
if (!result.has(row.offer_id)) {
|
|
442
|
+
result.set(row.offer_id, []);
|
|
443
|
+
}
|
|
444
|
+
result.get(row.offer_id)!.push(candidate);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return result;
|
|
370
448
|
}
|
|
371
449
|
|
|
372
|
-
// =====
|
|
450
|
+
// ===== Credential Management =====
|
|
373
451
|
|
|
374
|
-
async
|
|
375
|
-
service: Service;
|
|
376
|
-
offers: Offer[];
|
|
377
|
-
}> {
|
|
378
|
-
const serviceId = crypto.randomUUID();
|
|
452
|
+
async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
|
|
379
453
|
const now = Date.now();
|
|
454
|
+
const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
|
|
380
455
|
|
|
381
|
-
|
|
382
|
-
const parsed = parseServiceFqn(request.serviceFqn);
|
|
383
|
-
if (!parsed) {
|
|
384
|
-
throw new Error(`Invalid service FQN: ${request.serviceFqn}`);
|
|
385
|
-
}
|
|
386
|
-
if (!parsed.username) {
|
|
387
|
-
throw new Error(`Service FQN must include username: ${request.serviceFqn}`);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const { serviceName, version, username } = parsed;
|
|
456
|
+
const { generateCredentialName, generateSecret } = await import('../crypto.ts');
|
|
391
457
|
|
|
392
|
-
|
|
393
|
-
// First get the existing service
|
|
394
|
-
const existingService = await this.db.prepare(`
|
|
395
|
-
SELECT id FROM services
|
|
396
|
-
WHERE service_name = ? AND version = ? AND username = ?
|
|
397
|
-
`).bind(serviceName, version, username).first();
|
|
458
|
+
let name: string;
|
|
398
459
|
|
|
399
|
-
if (
|
|
400
|
-
//
|
|
401
|
-
await this.db.prepare(`
|
|
402
|
-
|
|
403
|
-
`).bind(
|
|
460
|
+
if (request.name) {
|
|
461
|
+
// User requested specific username - check if available
|
|
462
|
+
const existing = await this.db.prepare(`
|
|
463
|
+
SELECT name FROM credentials WHERE name = ?
|
|
464
|
+
`).bind(request.name).first();
|
|
404
465
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
`).bind(existingService.id).run();
|
|
409
|
-
}
|
|
466
|
+
if (existing) {
|
|
467
|
+
throw new Error('Username already taken');
|
|
468
|
+
}
|
|
410
469
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
serviceId,
|
|
417
|
-
request.serviceFqn,
|
|
418
|
-
serviceName,
|
|
419
|
-
version,
|
|
420
|
-
username,
|
|
421
|
-
now,
|
|
422
|
-
request.expiresAt
|
|
423
|
-
).run();
|
|
424
|
-
|
|
425
|
-
// Create offers with serviceId
|
|
426
|
-
const offerRequests = request.offers.map(offer => ({
|
|
427
|
-
...offer,
|
|
428
|
-
serviceId,
|
|
429
|
-
}));
|
|
430
|
-
const offers = await this.createOffers(offerRequests);
|
|
470
|
+
name = request.name;
|
|
471
|
+
} else {
|
|
472
|
+
// Generate random name - retry until unique
|
|
473
|
+
let attempts = 0;
|
|
474
|
+
const maxAttempts = 100;
|
|
431
475
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
await this.db.prepare(`
|
|
435
|
-
UPDATE usernames
|
|
436
|
-
SET last_used = ?, expires_at = ?
|
|
437
|
-
WHERE username = ? AND expires_at > ?
|
|
438
|
-
`).bind(now, expiresAt, username, now).run();
|
|
476
|
+
while (attempts < maxAttempts) {
|
|
477
|
+
name = generateCredentialName();
|
|
439
478
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
serviceFqn: request.serviceFqn,
|
|
444
|
-
serviceName,
|
|
445
|
-
version,
|
|
446
|
-
username,
|
|
447
|
-
createdAt: now,
|
|
448
|
-
expiresAt: request.expiresAt,
|
|
449
|
-
},
|
|
450
|
-
offers,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
479
|
+
const existing = await this.db.prepare(`
|
|
480
|
+
SELECT name FROM credentials WHERE name = ?
|
|
481
|
+
`).bind(name).first();
|
|
453
482
|
|
|
483
|
+
if (!existing) {
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
454
486
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
SELECT * FROM offers
|
|
458
|
-
WHERE service_id = ? AND expires_at > ?
|
|
459
|
-
ORDER BY created_at ASC
|
|
460
|
-
`).bind(serviceId, Date.now()).all();
|
|
487
|
+
attempts++;
|
|
488
|
+
}
|
|
461
489
|
|
|
462
|
-
|
|
463
|
-
|
|
490
|
+
if (attempts >= maxAttempts) {
|
|
491
|
+
throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
|
|
492
|
+
}
|
|
464
493
|
}
|
|
465
494
|
|
|
466
|
-
|
|
495
|
+
const secret = generateSecret();
|
|
496
|
+
|
|
497
|
+
// Encrypt secret before storing (AES-256-GCM)
|
|
498
|
+
const { encryptSecret } = await import('../crypto.ts');
|
|
499
|
+
const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
|
|
500
|
+
|
|
501
|
+
// Insert credential with encrypted secret
|
|
502
|
+
await this.db.prepare(`
|
|
503
|
+
INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
|
|
504
|
+
VALUES (?, ?, ?, ?, ?)
|
|
505
|
+
`).bind(name!, encryptedSecret, now, expiresAt, now).run();
|
|
506
|
+
|
|
507
|
+
// Return plaintext secret to user (only time they'll see it)
|
|
508
|
+
return {
|
|
509
|
+
name: name!,
|
|
510
|
+
secret, // Return plaintext secret, not encrypted
|
|
511
|
+
createdAt: now,
|
|
512
|
+
expiresAt,
|
|
513
|
+
lastUsed: now,
|
|
514
|
+
};
|
|
467
515
|
}
|
|
468
516
|
|
|
469
|
-
async
|
|
517
|
+
async getCredential(name: string): Promise<Credential | null> {
|
|
470
518
|
const result = await this.db.prepare(`
|
|
471
|
-
SELECT * FROM
|
|
472
|
-
WHERE
|
|
473
|
-
`).bind(
|
|
519
|
+
SELECT * FROM credentials
|
|
520
|
+
WHERE name = ? AND expires_at > ?
|
|
521
|
+
`).bind(name, Date.now()).first();
|
|
474
522
|
|
|
475
523
|
if (!result) {
|
|
476
524
|
return null;
|
|
477
525
|
}
|
|
478
526
|
|
|
479
|
-
|
|
480
|
-
}
|
|
527
|
+
const row = result as any;
|
|
481
528
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
529
|
+
// Decrypt secret before returning
|
|
530
|
+
// If decryption fails (e.g., master key rotated), treat as credential not found
|
|
531
|
+
try {
|
|
532
|
+
const { decryptSecret } = await import('../crypto.ts');
|
|
533
|
+
const decryptedSecret = await decryptSecret(row.secret, this.masterEncryptionKey);
|
|
487
534
|
|
|
488
|
-
|
|
489
|
-
|
|
535
|
+
return {
|
|
536
|
+
name: row.name,
|
|
537
|
+
secret: decryptedSecret, // Return decrypted secret
|
|
538
|
+
createdAt: row.created_at,
|
|
539
|
+
expiresAt: row.expires_at,
|
|
540
|
+
lastUsed: row.last_used,
|
|
541
|
+
};
|
|
542
|
+
} catch (error) {
|
|
543
|
+
console.error(`Failed to decrypt secret for credential '${name}':`, error);
|
|
544
|
+
return null; // Treat as credential not found (fail-safe behavior)
|
|
490
545
|
}
|
|
546
|
+
}
|
|
491
547
|
|
|
492
|
-
|
|
548
|
+
async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
|
|
549
|
+
await this.db.prepare(`
|
|
550
|
+
UPDATE credentials
|
|
551
|
+
SET last_used = ?, expires_at = ?
|
|
552
|
+
WHERE name = ?
|
|
553
|
+
`).bind(lastUsed, expiresAt, name).run();
|
|
493
554
|
}
|
|
494
555
|
|
|
556
|
+
async deleteExpiredCredentials(now: number): Promise<number> {
|
|
557
|
+
const result = await this.db.prepare(`
|
|
558
|
+
DELETE FROM credentials WHERE expires_at < ?
|
|
559
|
+
`).bind(now).run();
|
|
495
560
|
|
|
561
|
+
return result.meta.changes || 0;
|
|
562
|
+
}
|
|
496
563
|
|
|
564
|
+
// ===== Rate Limiting =====
|
|
497
565
|
|
|
566
|
+
async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
|
|
567
|
+
const now = Date.now();
|
|
568
|
+
const resetTime = now + windowMs;
|
|
498
569
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
version: string,
|
|
502
|
-
limit: number,
|
|
503
|
-
offset: number
|
|
504
|
-
): Promise<Service[]> {
|
|
505
|
-
// Query for unique services with available offers
|
|
506
|
-
// We join with offers and filter for available ones (answerer_username IS NULL)
|
|
570
|
+
// Atomic UPSERT: Insert or increment count, reset if expired
|
|
571
|
+
// This prevents TOCTOU race conditions by doing check+increment in single operation
|
|
507
572
|
const result = await this.db.prepare(`
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
return result
|
|
573
|
+
INSERT INTO rate_limits (identifier, count, reset_time)
|
|
574
|
+
VALUES (?, 1, ?)
|
|
575
|
+
ON CONFLICT(identifier) DO UPDATE SET
|
|
576
|
+
count = CASE
|
|
577
|
+
WHEN reset_time < ? THEN 1
|
|
578
|
+
ELSE count + 1
|
|
579
|
+
END,
|
|
580
|
+
reset_time = CASE
|
|
581
|
+
WHEN reset_time < ? THEN ?
|
|
582
|
+
ELSE reset_time
|
|
583
|
+
END
|
|
584
|
+
RETURNING count
|
|
585
|
+
`).bind(identifier, resetTime, now, now, resetTime).first() as { count: number } | null;
|
|
586
|
+
|
|
587
|
+
// Check if limit exceeded
|
|
588
|
+
return result ? result.count <= limit : false;
|
|
524
589
|
}
|
|
525
590
|
|
|
526
|
-
async
|
|
527
|
-
// Get a random service with an available offer
|
|
591
|
+
async deleteExpiredRateLimits(now: number): Promise<number> {
|
|
528
592
|
const result = await this.db.prepare(`
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
WHERE s.service_name = ?
|
|
532
|
-
AND s.version = ?
|
|
533
|
-
AND s.expires_at > ?
|
|
534
|
-
AND o.answerer_username IS NULL
|
|
535
|
-
AND o.expires_at > ?
|
|
536
|
-
ORDER BY RANDOM()
|
|
537
|
-
LIMIT 1
|
|
538
|
-
`).bind(serviceName, version, Date.now(), Date.now()).first();
|
|
539
|
-
|
|
540
|
-
if (!result) {
|
|
541
|
-
return null;
|
|
542
|
-
}
|
|
593
|
+
DELETE FROM rate_limits WHERE reset_time < ?
|
|
594
|
+
`).bind(now).run();
|
|
543
595
|
|
|
544
|
-
return
|
|
596
|
+
return result.meta.changes || 0;
|
|
545
597
|
}
|
|
546
598
|
|
|
547
|
-
|
|
548
|
-
const result = await this.db.prepare(`
|
|
549
|
-
DELETE FROM services
|
|
550
|
-
WHERE id = ? AND username = ?
|
|
551
|
-
`).bind(serviceId, username).run();
|
|
599
|
+
// ===== Nonce Tracking (Replay Protection) =====
|
|
552
600
|
|
|
553
|
-
|
|
601
|
+
async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
|
|
602
|
+
try {
|
|
603
|
+
// Atomic INSERT - if nonce already exists, this will fail with UNIQUE constraint
|
|
604
|
+
// This prevents replay attacks by ensuring each nonce is only used once
|
|
605
|
+
const result = await this.db.prepare(`
|
|
606
|
+
INSERT INTO nonces (nonce_key, expires_at)
|
|
607
|
+
VALUES (?, ?)
|
|
608
|
+
`).bind(nonceKey, expiresAt).run();
|
|
609
|
+
|
|
610
|
+
// D1 returns success=true if insert succeeded
|
|
611
|
+
return result.success;
|
|
612
|
+
} catch (error: any) {
|
|
613
|
+
// UNIQUE constraint violation means nonce already used (replay attack)
|
|
614
|
+
if (error?.message?.includes('UNIQUE constraint failed')) {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
throw error; // Other errors should propagate
|
|
618
|
+
}
|
|
554
619
|
}
|
|
555
620
|
|
|
556
|
-
async
|
|
621
|
+
async deleteExpiredNonces(now: number): Promise<number> {
|
|
557
622
|
const result = await this.db.prepare(`
|
|
558
|
-
DELETE FROM
|
|
623
|
+
DELETE FROM nonces WHERE expires_at < ?
|
|
559
624
|
`).bind(now).run();
|
|
560
625
|
|
|
561
626
|
return result.meta.changes || 0;
|
|
@@ -566,6 +631,32 @@ export class D1Storage implements Storage {
|
|
|
566
631
|
// Connections are managed by the Cloudflare Workers runtime
|
|
567
632
|
}
|
|
568
633
|
|
|
634
|
+
// ===== Count Methods (for resource limits) =====
|
|
635
|
+
|
|
636
|
+
async getOfferCount(): Promise<number> {
|
|
637
|
+
const result = await this.db.prepare('SELECT COUNT(*) as count FROM offers').first() as { count: number } | null;
|
|
638
|
+
return result?.count ?? 0;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async getOfferCountByUsername(username: string): Promise<number> {
|
|
642
|
+
const result = await this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE username = ?')
|
|
643
|
+
.bind(username)
|
|
644
|
+
.first() as { count: number } | null;
|
|
645
|
+
return result?.count ?? 0;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async getCredentialCount(): Promise<number> {
|
|
649
|
+
const result = await this.db.prepare('SELECT COUNT(*) as count FROM credentials').first() as { count: number } | null;
|
|
650
|
+
return result?.count ?? 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async getIceCandidateCount(offerId: string): Promise<number> {
|
|
654
|
+
const result = await this.db.prepare('SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?')
|
|
655
|
+
.bind(offerId)
|
|
656
|
+
.first() as { count: number } | null;
|
|
657
|
+
return result?.count ?? 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
569
660
|
// ===== Helper Methods =====
|
|
570
661
|
|
|
571
662
|
/**
|
|
@@ -575,8 +666,7 @@ export class D1Storage implements Storage {
|
|
|
575
666
|
return {
|
|
576
667
|
id: row.id,
|
|
577
668
|
username: row.username,
|
|
578
|
-
|
|
579
|
-
serviceFqn: row.service_fqn || undefined,
|
|
669
|
+
tags: JSON.parse(row.tags),
|
|
580
670
|
sdp: row.sdp,
|
|
581
671
|
createdAt: row.created_at,
|
|
582
672
|
expiresAt: row.expires_at,
|
|
@@ -586,19 +676,4 @@ export class D1Storage implements Storage {
|
|
|
586
676
|
answeredAt: row.answered_at || undefined,
|
|
587
677
|
};
|
|
588
678
|
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Helper method to convert database row to Service object
|
|
592
|
-
*/
|
|
593
|
-
private rowToService(row: any): Service {
|
|
594
|
-
return {
|
|
595
|
-
id: row.id,
|
|
596
|
-
serviceFqn: row.service_fqn,
|
|
597
|
-
serviceName: row.service_name,
|
|
598
|
-
version: row.version,
|
|
599
|
-
username: row.username,
|
|
600
|
-
createdAt: row.created_at,
|
|
601
|
-
expiresAt: row.expires_at,
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
679
|
}
|