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