@xtr-dev/rondevu-server 0.5.12 → 0.5.14
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/README.md +9 -21
- package/dist/index.js +939 -1110
- package/dist/index.js.map +4 -4
- package/migrations/0009_public_key_auth.sql +74 -0
- package/migrations/fresh_schema.sql +20 -21
- package/package.json +2 -1
- package/src/config.ts +1 -47
- package/src/crypto.ts +70 -304
- package/src/index.ts +2 -3
- package/src/rpc.ts +90 -272
- package/src/storage/d1.ts +72 -235
- package/src/storage/factory.ts +4 -17
- package/src/storage/memory.ts +46 -151
- package/src/storage/mysql.ts +66 -187
- package/src/storage/postgres.ts +66 -186
- package/src/storage/sqlite.ts +65 -194
- package/src/storage/types.ts +30 -88
- package/src/worker.ts +4 -9
- package/wrangler.toml +1 -1
package/src/storage/d1.ts
CHANGED
|
@@ -1,64 +1,65 @@
|
|
|
1
|
-
// Use Web Crypto API (available globally in Cloudflare Workers)
|
|
2
1
|
import {
|
|
3
2
|
Storage,
|
|
4
3
|
Offer,
|
|
5
4
|
IceCandidate,
|
|
6
5
|
CreateOfferRequest,
|
|
7
|
-
Credential,
|
|
8
|
-
GenerateCredentialsRequest,
|
|
9
6
|
} from './types.ts';
|
|
10
7
|
import { generateOfferHash } from './hash-id.ts';
|
|
11
8
|
|
|
12
|
-
const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
|
|
13
|
-
|
|
14
9
|
/**
|
|
15
10
|
* D1 storage adapter for rondevu signaling system using Cloudflare D1
|
|
11
|
+
* Uses Ed25519 public key as identity (no usernames, no secrets)
|
|
16
12
|
*/
|
|
17
13
|
export class D1Storage implements Storage {
|
|
18
14
|
private db: D1Database;
|
|
19
|
-
private masterEncryptionKey: string;
|
|
20
15
|
|
|
21
|
-
|
|
22
|
-
* Creates a new D1 storage instance
|
|
23
|
-
* @param db D1Database instance from Cloudflare Workers environment
|
|
24
|
-
* @param masterEncryptionKey 64-char hex string for encrypting secrets (32 bytes)
|
|
25
|
-
*/
|
|
26
|
-
constructor(db: D1Database, masterEncryptionKey: string) {
|
|
16
|
+
constructor(db: D1Database) {
|
|
27
17
|
this.db = db;
|
|
28
|
-
this.masterEncryptionKey = masterEncryptionKey;
|
|
29
18
|
}
|
|
30
19
|
|
|
31
20
|
/**
|
|
32
|
-
* Initializes database schema
|
|
21
|
+
* Initializes database schema for Ed25519 public key identity system
|
|
33
22
|
* This should be run once during setup, not on every request
|
|
34
23
|
*/
|
|
35
24
|
async initializeDatabase(): Promise<void> {
|
|
36
25
|
await this.db.exec(`
|
|
26
|
+
-- Identities table (Ed25519 public key as identity)
|
|
27
|
+
CREATE TABLE IF NOT EXISTS identities (
|
|
28
|
+
public_key TEXT PRIMARY KEY,
|
|
29
|
+
created_at INTEGER NOT NULL,
|
|
30
|
+
expires_at INTEGER NOT NULL,
|
|
31
|
+
last_used INTEGER NOT NULL,
|
|
32
|
+
CHECK(length(public_key) = 64)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_identities_expires ON identities(expires_at);
|
|
36
|
+
|
|
37
37
|
-- WebRTC signaling offers with tags
|
|
38
38
|
CREATE TABLE IF NOT EXISTS offers (
|
|
39
39
|
id TEXT PRIMARY KEY,
|
|
40
|
-
|
|
40
|
+
public_key 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,
|
|
45
45
|
last_seen INTEGER NOT NULL,
|
|
46
|
-
|
|
46
|
+
answerer_public_key TEXT,
|
|
47
47
|
answer_sdp TEXT,
|
|
48
48
|
answered_at INTEGER,
|
|
49
|
-
matched_tags TEXT
|
|
49
|
+
matched_tags TEXT,
|
|
50
|
+
FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
|
|
50
51
|
);
|
|
51
52
|
|
|
52
|
-
CREATE INDEX IF NOT EXISTS
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_offers_public_key ON offers(public_key);
|
|
53
54
|
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
54
55
|
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
55
|
-
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_public_key);
|
|
56
57
|
|
|
57
58
|
-- ICE candidates table
|
|
58
59
|
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
59
60
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
61
|
offer_id TEXT NOT NULL,
|
|
61
|
-
|
|
62
|
+
public_key TEXT NOT NULL,
|
|
62
63
|
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
63
64
|
candidate TEXT NOT NULL,
|
|
64
65
|
created_at INTEGER NOT NULL,
|
|
@@ -66,22 +67,10 @@ export class D1Storage implements Storage {
|
|
|
66
67
|
);
|
|
67
68
|
|
|
68
69
|
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
69
|
-
CREATE INDEX IF NOT EXISTS
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_ice_public_key ON ice_candidates(public_key);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_ice_role ON ice_candidates(role);
|
|
70
72
|
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
71
73
|
|
|
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,
|
|
77
|
-
expires_at INTEGER NOT NULL,
|
|
78
|
-
last_used INTEGER NOT NULL,
|
|
79
|
-
CHECK(length(name) >= 3 AND length(name) <= 32)
|
|
80
|
-
);
|
|
81
|
-
|
|
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);
|
|
84
|
-
|
|
85
74
|
-- Rate limits table (for distributed rate limiting)
|
|
86
75
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
87
76
|
identifier TEXT PRIMARY KEY,
|
|
@@ -106,19 +95,18 @@ export class D1Storage implements Storage {
|
|
|
106
95
|
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
|
107
96
|
const created: Offer[] = [];
|
|
108
97
|
|
|
109
|
-
// D1 doesn't support true transactions yet, so we do this sequentially
|
|
110
98
|
for (const offer of offers) {
|
|
111
99
|
const id = offer.id || await generateOfferHash(offer.sdp);
|
|
112
100
|
const now = Date.now();
|
|
113
101
|
|
|
114
102
|
await this.db.prepare(`
|
|
115
|
-
INSERT INTO offers (id,
|
|
103
|
+
INSERT INTO offers (id, public_key, tags, sdp, created_at, expires_at, last_seen)
|
|
116
104
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
117
|
-
`).bind(id, offer.
|
|
105
|
+
`).bind(id, offer.publicKey, JSON.stringify(offer.tags), offer.sdp, now, offer.expiresAt, now).run();
|
|
118
106
|
|
|
119
107
|
created.push({
|
|
120
108
|
id,
|
|
121
|
-
|
|
109
|
+
publicKey: offer.publicKey,
|
|
122
110
|
tags: offer.tags,
|
|
123
111
|
sdp: offer.sdp,
|
|
124
112
|
createdAt: now,
|
|
@@ -130,12 +118,12 @@ export class D1Storage implements Storage {
|
|
|
130
118
|
return created;
|
|
131
119
|
}
|
|
132
120
|
|
|
133
|
-
async
|
|
121
|
+
async getOffersByPublicKey(publicKey: string): Promise<Offer[]> {
|
|
134
122
|
const result = await this.db.prepare(`
|
|
135
123
|
SELECT * FROM offers
|
|
136
|
-
WHERE
|
|
124
|
+
WHERE public_key = ? AND expires_at > ?
|
|
137
125
|
ORDER BY last_seen DESC
|
|
138
|
-
`).bind(
|
|
126
|
+
`).bind(publicKey, Date.now()).all();
|
|
139
127
|
|
|
140
128
|
if (!result.results) {
|
|
141
129
|
return [];
|
|
@@ -157,11 +145,11 @@ export class D1Storage implements Storage {
|
|
|
157
145
|
return this.rowToOffer(result as any);
|
|
158
146
|
}
|
|
159
147
|
|
|
160
|
-
async deleteOffer(offerId: string,
|
|
148
|
+
async deleteOffer(offerId: string, ownerPublicKey: string): Promise<boolean> {
|
|
161
149
|
const result = await this.db.prepare(`
|
|
162
150
|
DELETE FROM offers
|
|
163
|
-
WHERE id = ? AND
|
|
164
|
-
`).bind(offerId,
|
|
151
|
+
WHERE id = ? AND public_key = ?
|
|
152
|
+
`).bind(offerId, ownerPublicKey).run();
|
|
165
153
|
|
|
166
154
|
return (result.meta.changes || 0) > 0;
|
|
167
155
|
}
|
|
@@ -176,52 +164,40 @@ export class D1Storage implements Storage {
|
|
|
176
164
|
|
|
177
165
|
async answerOffer(
|
|
178
166
|
offerId: string,
|
|
179
|
-
|
|
167
|
+
answererPublicKey: string,
|
|
180
168
|
answerSdp: string,
|
|
181
169
|
matchedTags?: string[]
|
|
182
170
|
): Promise<{ success: boolean; error?: string }> {
|
|
183
|
-
// Check if offer exists and is not expired
|
|
184
171
|
const offer = await this.getOfferById(offerId);
|
|
185
172
|
|
|
186
173
|
if (!offer) {
|
|
187
|
-
return {
|
|
188
|
-
success: false,
|
|
189
|
-
error: 'Offer not found or expired'
|
|
190
|
-
};
|
|
174
|
+
return { success: false, error: 'Offer not found or expired' };
|
|
191
175
|
}
|
|
192
176
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return {
|
|
196
|
-
success: false,
|
|
197
|
-
error: 'Offer already answered'
|
|
198
|
-
};
|
|
177
|
+
if (offer.answererPublicKey) {
|
|
178
|
+
return { success: false, error: 'Offer already answered' };
|
|
199
179
|
}
|
|
200
180
|
|
|
201
|
-
// Update offer with answer
|
|
202
181
|
const matchedTagsJson = matchedTags ? JSON.stringify(matchedTags) : null;
|
|
203
182
|
const result = await this.db.prepare(`
|
|
204
183
|
UPDATE offers
|
|
205
|
-
SET
|
|
206
|
-
WHERE id = ? AND
|
|
207
|
-
`).bind(
|
|
184
|
+
SET answerer_public_key = ?, answer_sdp = ?, answered_at = ?, matched_tags = ?
|
|
185
|
+
WHERE id = ? AND answerer_public_key IS NULL
|
|
186
|
+
`).bind(answererPublicKey, answerSdp, Date.now(), matchedTagsJson, offerId).run();
|
|
208
187
|
|
|
209
188
|
if ((result.meta.changes || 0) === 0) {
|
|
210
|
-
return {
|
|
211
|
-
success: false,
|
|
212
|
-
error: 'Offer already answered (race condition)'
|
|
213
|
-
};
|
|
189
|
+
return { success: false, error: 'Offer already answered (race condition)' };
|
|
214
190
|
}
|
|
215
191
|
|
|
216
192
|
return { success: true };
|
|
217
193
|
}
|
|
218
194
|
|
|
219
|
-
async getAnsweredOffers(
|
|
195
|
+
async getAnsweredOffers(offererPublicKey: string): Promise<Offer[]> {
|
|
220
196
|
const result = await this.db.prepare(`
|
|
221
197
|
SELECT * FROM offers
|
|
222
|
-
WHERE
|
|
198
|
+
WHERE public_key = ? AND answerer_public_key IS NOT NULL AND expires_at > ?
|
|
223
199
|
ORDER BY answered_at DESC
|
|
224
|
-
`).bind(
|
|
200
|
+
`).bind(offererPublicKey, Date.now()).all();
|
|
225
201
|
|
|
226
202
|
if (!result.results) {
|
|
227
203
|
return [];
|
|
@@ -230,12 +206,12 @@ export class D1Storage implements Storage {
|
|
|
230
206
|
return result.results.map(row => this.rowToOffer(row as any));
|
|
231
207
|
}
|
|
232
208
|
|
|
233
|
-
async getOffersAnsweredBy(
|
|
209
|
+
async getOffersAnsweredBy(answererPublicKey: string): Promise<Offer[]> {
|
|
234
210
|
const result = await this.db.prepare(`
|
|
235
211
|
SELECT * FROM offers
|
|
236
|
-
WHERE
|
|
212
|
+
WHERE answerer_public_key = ? AND expires_at > ?
|
|
237
213
|
ORDER BY answered_at DESC
|
|
238
|
-
`).bind(
|
|
214
|
+
`).bind(answererPublicKey, Date.now()).all();
|
|
239
215
|
|
|
240
216
|
if (!result.results) {
|
|
241
217
|
return [];
|
|
@@ -248,7 +224,7 @@ export class D1Storage implements Storage {
|
|
|
248
224
|
|
|
249
225
|
async discoverOffers(
|
|
250
226
|
tags: string[],
|
|
251
|
-
|
|
227
|
+
excludePublicKey: string | null,
|
|
252
228
|
limit: number,
|
|
253
229
|
offset: number
|
|
254
230
|
): Promise<Offer[]> {
|
|
@@ -256,22 +232,20 @@ export class D1Storage implements Storage {
|
|
|
256
232
|
return [];
|
|
257
233
|
}
|
|
258
234
|
|
|
259
|
-
// Build query with JSON tag matching (OR logic)
|
|
260
|
-
// D1/SQLite: Use json_each() to expand tags array and check if any tag matches
|
|
261
235
|
const placeholders = tags.map(() => '?').join(',');
|
|
262
236
|
|
|
263
237
|
let query = `
|
|
264
238
|
SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
|
|
265
239
|
WHERE t.value IN (${placeholders})
|
|
266
240
|
AND o.expires_at > ?
|
|
267
|
-
AND o.
|
|
241
|
+
AND o.answerer_public_key IS NULL
|
|
268
242
|
`;
|
|
269
243
|
|
|
270
244
|
const params: any[] = [...tags, Date.now()];
|
|
271
245
|
|
|
272
|
-
if (
|
|
273
|
-
query += ' AND o.
|
|
274
|
-
params.push(
|
|
246
|
+
if (excludePublicKey) {
|
|
247
|
+
query += ' AND o.public_key != ?';
|
|
248
|
+
params.push(excludePublicKey);
|
|
275
249
|
}
|
|
276
250
|
|
|
277
251
|
query += ' ORDER BY o.created_at DESC LIMIT ? OFFSET ?';
|
|
@@ -288,27 +262,26 @@ export class D1Storage implements Storage {
|
|
|
288
262
|
|
|
289
263
|
async getRandomOffer(
|
|
290
264
|
tags: string[],
|
|
291
|
-
|
|
265
|
+
excludePublicKey: string | null
|
|
292
266
|
): Promise<Offer | null> {
|
|
293
267
|
if (tags.length === 0) {
|
|
294
268
|
return null;
|
|
295
269
|
}
|
|
296
270
|
|
|
297
|
-
// Build query with JSON tag matching (OR logic)
|
|
298
271
|
const placeholders = tags.map(() => '?').join(',');
|
|
299
272
|
|
|
300
273
|
let query = `
|
|
301
274
|
SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
|
|
302
275
|
WHERE t.value IN (${placeholders})
|
|
303
276
|
AND o.expires_at > ?
|
|
304
|
-
AND o.
|
|
277
|
+
AND o.answerer_public_key IS NULL
|
|
305
278
|
`;
|
|
306
279
|
|
|
307
280
|
const params: any[] = [...tags, Date.now()];
|
|
308
281
|
|
|
309
|
-
if (
|
|
310
|
-
query += ' AND o.
|
|
311
|
-
params.push(
|
|
282
|
+
if (excludePublicKey) {
|
|
283
|
+
query += ' AND o.public_key != ?';
|
|
284
|
+
params.push(excludePublicKey);
|
|
312
285
|
}
|
|
313
286
|
|
|
314
287
|
query += ' ORDER BY RANDOM() LIMIT 1';
|
|
@@ -322,19 +295,18 @@ export class D1Storage implements Storage {
|
|
|
322
295
|
|
|
323
296
|
async addIceCandidates(
|
|
324
297
|
offerId: string,
|
|
325
|
-
|
|
298
|
+
publicKey: string,
|
|
326
299
|
role: 'offerer' | 'answerer',
|
|
327
300
|
candidates: any[]
|
|
328
301
|
): Promise<number> {
|
|
329
|
-
// D1 doesn't have transactions, so insert one by one
|
|
330
302
|
for (let i = 0; i < candidates.length; i++) {
|
|
331
303
|
const timestamp = Date.now() + i;
|
|
332
304
|
await this.db.prepare(`
|
|
333
|
-
INSERT INTO ice_candidates (offer_id,
|
|
305
|
+
INSERT INTO ice_candidates (offer_id, public_key, role, candidate, created_at)
|
|
334
306
|
VALUES (?, ?, ?, ?, ?)
|
|
335
307
|
`).bind(
|
|
336
308
|
offerId,
|
|
337
|
-
|
|
309
|
+
publicKey,
|
|
338
310
|
role,
|
|
339
311
|
JSON.stringify(candidates[i]),
|
|
340
312
|
timestamp
|
|
@@ -372,7 +344,7 @@ export class D1Storage implements Storage {
|
|
|
372
344
|
return result.results.map((row: any) => ({
|
|
373
345
|
id: row.id,
|
|
374
346
|
offerId: row.offer_id,
|
|
375
|
-
|
|
347
|
+
publicKey: row.public_key,
|
|
376
348
|
role: row.role,
|
|
377
349
|
candidate: JSON.parse(row.candidate),
|
|
378
350
|
createdAt: row.created_at,
|
|
@@ -381,41 +353,37 @@ export class D1Storage implements Storage {
|
|
|
381
353
|
|
|
382
354
|
async getIceCandidatesForMultipleOffers(
|
|
383
355
|
offerIds: string[],
|
|
384
|
-
|
|
356
|
+
publicKey: string,
|
|
385
357
|
since?: number
|
|
386
358
|
): Promise<Map<string, IceCandidate[]>> {
|
|
387
359
|
const result = new Map<string, IceCandidate[]>();
|
|
388
360
|
|
|
389
|
-
// Return empty map if no offer IDs provided
|
|
390
361
|
if (offerIds.length === 0) {
|
|
391
362
|
return result;
|
|
392
363
|
}
|
|
393
364
|
|
|
394
|
-
// Validate array contains only strings
|
|
395
365
|
if (!Array.isArray(offerIds) || !offerIds.every(id => typeof id === 'string')) {
|
|
396
366
|
throw new Error('Invalid offer IDs: must be array of strings');
|
|
397
367
|
}
|
|
398
368
|
|
|
399
|
-
// Prevent DoS attacks from extremely large IN clauses
|
|
400
369
|
if (offerIds.length > 1000) {
|
|
401
370
|
throw new Error('Too many offer IDs (max 1000)');
|
|
402
371
|
}
|
|
403
372
|
|
|
404
|
-
// Build query that fetches candidates from the OTHER peer only
|
|
405
373
|
const placeholders = offerIds.map(() => '?').join(',');
|
|
406
374
|
|
|
407
375
|
let query = `
|
|
408
|
-
SELECT ic.*, o.
|
|
376
|
+
SELECT ic.*, o.public_key as offer_public_key
|
|
409
377
|
FROM ice_candidates ic
|
|
410
378
|
INNER JOIN offers o ON o.id = ic.offer_id
|
|
411
379
|
WHERE ic.offer_id IN (${placeholders})
|
|
412
380
|
AND (
|
|
413
|
-
(o.
|
|
414
|
-
OR (o.
|
|
381
|
+
(o.public_key = ? AND ic.role = 'answerer')
|
|
382
|
+
OR (o.answerer_public_key = ? AND ic.role = 'offerer')
|
|
415
383
|
)
|
|
416
384
|
`;
|
|
417
385
|
|
|
418
|
-
const params: any[] = [...offerIds,
|
|
386
|
+
const params: any[] = [...offerIds, publicKey, publicKey];
|
|
419
387
|
|
|
420
388
|
if (since !== undefined) {
|
|
421
389
|
query += ' AND ic.created_at > ?';
|
|
@@ -430,12 +398,11 @@ export class D1Storage implements Storage {
|
|
|
430
398
|
return result;
|
|
431
399
|
}
|
|
432
400
|
|
|
433
|
-
// Group candidates by offer_id
|
|
434
401
|
for (const row of queryResult.results as any[]) {
|
|
435
402
|
const candidate: IceCandidate = {
|
|
436
403
|
id: row.id,
|
|
437
404
|
offerId: row.offer_id,
|
|
438
|
-
|
|
405
|
+
publicKey: row.public_key,
|
|
439
406
|
role: row.role,
|
|
440
407
|
candidate: JSON.parse(row.candidate),
|
|
441
408
|
createdAt: row.created_at,
|
|
@@ -450,128 +417,12 @@ export class D1Storage implements Storage {
|
|
|
450
417
|
return result;
|
|
451
418
|
}
|
|
452
419
|
|
|
453
|
-
// ===== Credential Management =====
|
|
454
|
-
|
|
455
|
-
async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
|
|
456
|
-
const now = Date.now();
|
|
457
|
-
const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
|
|
458
|
-
|
|
459
|
-
const { generateCredentialName, generateSecret } = await import('../crypto.ts');
|
|
460
|
-
|
|
461
|
-
let name: string;
|
|
462
|
-
|
|
463
|
-
if (request.name) {
|
|
464
|
-
// User requested specific username - check if available
|
|
465
|
-
const existing = await this.db.prepare(`
|
|
466
|
-
SELECT name FROM credentials WHERE name = ?
|
|
467
|
-
`).bind(request.name).first();
|
|
468
|
-
|
|
469
|
-
if (existing) {
|
|
470
|
-
throw new Error('Username already taken');
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
name = request.name;
|
|
474
|
-
} else {
|
|
475
|
-
// Generate random name - retry until unique
|
|
476
|
-
let attempts = 0;
|
|
477
|
-
const maxAttempts = 100;
|
|
478
|
-
|
|
479
|
-
while (attempts < maxAttempts) {
|
|
480
|
-
name = generateCredentialName();
|
|
481
|
-
|
|
482
|
-
const existing = await this.db.prepare(`
|
|
483
|
-
SELECT name FROM credentials WHERE name = ?
|
|
484
|
-
`).bind(name).first();
|
|
485
|
-
|
|
486
|
-
if (!existing) {
|
|
487
|
-
break;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
attempts++;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (attempts >= maxAttempts) {
|
|
494
|
-
throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
const secret = generateSecret();
|
|
499
|
-
|
|
500
|
-
// Encrypt secret before storing (AES-256-GCM)
|
|
501
|
-
const { encryptSecret } = await import('../crypto.ts');
|
|
502
|
-
const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
|
|
503
|
-
|
|
504
|
-
// Insert credential with encrypted secret
|
|
505
|
-
await this.db.prepare(`
|
|
506
|
-
INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
|
|
507
|
-
VALUES (?, ?, ?, ?, ?)
|
|
508
|
-
`).bind(name!, encryptedSecret, now, expiresAt, now).run();
|
|
509
|
-
|
|
510
|
-
// Return plaintext secret to user (only time they'll see it)
|
|
511
|
-
return {
|
|
512
|
-
name: name!,
|
|
513
|
-
secret, // Return plaintext secret, not encrypted
|
|
514
|
-
createdAt: now,
|
|
515
|
-
expiresAt,
|
|
516
|
-
lastUsed: now,
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async getCredential(name: string): Promise<Credential | null> {
|
|
521
|
-
const result = await this.db.prepare(`
|
|
522
|
-
SELECT * FROM credentials
|
|
523
|
-
WHERE name = ? AND expires_at > ?
|
|
524
|
-
`).bind(name, Date.now()).first();
|
|
525
|
-
|
|
526
|
-
if (!result) {
|
|
527
|
-
return null;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const row = result as any;
|
|
531
|
-
|
|
532
|
-
// Decrypt secret before returning
|
|
533
|
-
// If decryption fails (e.g., master key rotated), treat as credential not found
|
|
534
|
-
try {
|
|
535
|
-
const { decryptSecret } = await import('../crypto.ts');
|
|
536
|
-
const decryptedSecret = await decryptSecret(row.secret, this.masterEncryptionKey);
|
|
537
|
-
|
|
538
|
-
return {
|
|
539
|
-
name: row.name,
|
|
540
|
-
secret: decryptedSecret, // Return decrypted secret
|
|
541
|
-
createdAt: row.created_at,
|
|
542
|
-
expiresAt: row.expires_at,
|
|
543
|
-
lastUsed: row.last_used,
|
|
544
|
-
};
|
|
545
|
-
} catch (error) {
|
|
546
|
-
console.error(`Failed to decrypt secret for credential '${name}':`, error);
|
|
547
|
-
return null; // Treat as credential not found (fail-safe behavior)
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
|
|
552
|
-
await this.db.prepare(`
|
|
553
|
-
UPDATE credentials
|
|
554
|
-
SET last_used = ?, expires_at = ?
|
|
555
|
-
WHERE name = ?
|
|
556
|
-
`).bind(lastUsed, expiresAt, name).run();
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
async deleteExpiredCredentials(now: number): Promise<number> {
|
|
560
|
-
const result = await this.db.prepare(`
|
|
561
|
-
DELETE FROM credentials WHERE expires_at < ?
|
|
562
|
-
`).bind(now).run();
|
|
563
|
-
|
|
564
|
-
return result.meta.changes || 0;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
420
|
// ===== Rate Limiting =====
|
|
568
421
|
|
|
569
422
|
async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
|
|
570
423
|
const now = Date.now();
|
|
571
424
|
const resetTime = now + windowMs;
|
|
572
425
|
|
|
573
|
-
// Atomic UPSERT: Insert or increment count, reset if expired
|
|
574
|
-
// This prevents TOCTOU race conditions by doing check+increment in single operation
|
|
575
426
|
const result = await this.db.prepare(`
|
|
576
427
|
INSERT INTO rate_limits (identifier, count, reset_time)
|
|
577
428
|
VALUES (?, 1, ?)
|
|
@@ -587,7 +438,6 @@ export class D1Storage implements Storage {
|
|
|
587
438
|
RETURNING count
|
|
588
439
|
`).bind(identifier, resetTime, now, now, resetTime).first() as { count: number } | null;
|
|
589
440
|
|
|
590
|
-
// Check if limit exceeded
|
|
591
441
|
return result ? result.count <= limit : false;
|
|
592
442
|
}
|
|
593
443
|
|
|
@@ -603,21 +453,17 @@ export class D1Storage implements Storage {
|
|
|
603
453
|
|
|
604
454
|
async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
|
|
605
455
|
try {
|
|
606
|
-
// Atomic INSERT - if nonce already exists, this will fail with UNIQUE constraint
|
|
607
|
-
// This prevents replay attacks by ensuring each nonce is only used once
|
|
608
456
|
const result = await this.db.prepare(`
|
|
609
457
|
INSERT INTO nonces (nonce_key, expires_at)
|
|
610
458
|
VALUES (?, ?)
|
|
611
459
|
`).bind(nonceKey, expiresAt).run();
|
|
612
460
|
|
|
613
|
-
// D1 returns success=true if insert succeeded
|
|
614
461
|
return result.success;
|
|
615
462
|
} catch (error: any) {
|
|
616
|
-
// UNIQUE constraint violation means nonce already used (replay attack)
|
|
617
463
|
if (error?.message?.includes('UNIQUE constraint failed')) {
|
|
618
464
|
return false;
|
|
619
465
|
}
|
|
620
|
-
throw error;
|
|
466
|
+
throw error;
|
|
621
467
|
}
|
|
622
468
|
}
|
|
623
469
|
|
|
@@ -631,7 +477,6 @@ export class D1Storage implements Storage {
|
|
|
631
477
|
|
|
632
478
|
async close(): Promise<void> {
|
|
633
479
|
// D1 doesn't require explicit connection closing
|
|
634
|
-
// Connections are managed by the Cloudflare Workers runtime
|
|
635
480
|
}
|
|
636
481
|
|
|
637
482
|
// ===== Count Methods (for resource limits) =====
|
|
@@ -641,18 +486,13 @@ export class D1Storage implements Storage {
|
|
|
641
486
|
return result?.count ?? 0;
|
|
642
487
|
}
|
|
643
488
|
|
|
644
|
-
async
|
|
645
|
-
const result = await this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE
|
|
646
|
-
.bind(
|
|
489
|
+
async getOfferCountByPublicKey(publicKey: string): Promise<number> {
|
|
490
|
+
const result = await this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE public_key = ?')
|
|
491
|
+
.bind(publicKey)
|
|
647
492
|
.first() as { count: number } | null;
|
|
648
493
|
return result?.count ?? 0;
|
|
649
494
|
}
|
|
650
495
|
|
|
651
|
-
async getCredentialCount(): Promise<number> {
|
|
652
|
-
const result = await this.db.prepare('SELECT COUNT(*) as count FROM credentials').first() as { count: number } | null;
|
|
653
|
-
return result?.count ?? 0;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
496
|
async getIceCandidateCount(offerId: string): Promise<number> {
|
|
657
497
|
const result = await this.db.prepare('SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?')
|
|
658
498
|
.bind(offerId)
|
|
@@ -662,19 +502,16 @@ export class D1Storage implements Storage {
|
|
|
662
502
|
|
|
663
503
|
// ===== Helper Methods =====
|
|
664
504
|
|
|
665
|
-
/**
|
|
666
|
-
* Helper method to convert database row to Offer object
|
|
667
|
-
*/
|
|
668
505
|
private rowToOffer(row: any): Offer {
|
|
669
506
|
return {
|
|
670
507
|
id: row.id,
|
|
671
|
-
|
|
508
|
+
publicKey: row.public_key,
|
|
672
509
|
tags: JSON.parse(row.tags),
|
|
673
510
|
sdp: row.sdp,
|
|
674
511
|
createdAt: row.created_at,
|
|
675
512
|
expiresAt: row.expires_at,
|
|
676
513
|
lastSeen: row.last_seen,
|
|
677
|
-
|
|
514
|
+
answererPublicKey: row.answerer_public_key || undefined,
|
|
678
515
|
answerSdp: row.answer_sdp || undefined,
|
|
679
516
|
answeredAt: row.answered_at || undefined,
|
|
680
517
|
matchedTags: row.matched_tags ? JSON.parse(row.matched_tags) : undefined,
|
package/src/storage/factory.ts
CHANGED
|
@@ -10,8 +10,6 @@ export type StorageType = 'memory' | 'sqlite' | 'mysql' | 'postgres';
|
|
|
10
10
|
*/
|
|
11
11
|
export interface StorageConfig {
|
|
12
12
|
type: StorageType;
|
|
13
|
-
/** Master encryption key for secrets (64-char hex string) */
|
|
14
|
-
masterEncryptionKey: string;
|
|
15
13
|
/** SQLite database path (default: ':memory:') */
|
|
16
14
|
sqlitePath?: string;
|
|
17
15
|
/** Connection string for MySQL/PostgreSQL */
|
|
@@ -28,15 +26,12 @@ export async function createStorage(config: StorageConfig): Promise<Storage> {
|
|
|
28
26
|
switch (config.type) {
|
|
29
27
|
case 'memory': {
|
|
30
28
|
const { MemoryStorage } = await import('./memory.ts');
|
|
31
|
-
return new MemoryStorage(
|
|
29
|
+
return new MemoryStorage();
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
case 'sqlite': {
|
|
35
33
|
const { SQLiteStorage } = await import('./sqlite.ts');
|
|
36
|
-
return new SQLiteStorage(
|
|
37
|
-
config.sqlitePath || ':memory:',
|
|
38
|
-
config.masterEncryptionKey
|
|
39
|
-
);
|
|
34
|
+
return new SQLiteStorage(config.sqlitePath || ':memory:');
|
|
40
35
|
}
|
|
41
36
|
|
|
42
37
|
case 'mysql': {
|
|
@@ -44,11 +39,7 @@ export async function createStorage(config: StorageConfig): Promise<Storage> {
|
|
|
44
39
|
throw new Error('MySQL storage requires DATABASE_URL connection string');
|
|
45
40
|
}
|
|
46
41
|
const { MySQLStorage } = await import('./mysql.ts');
|
|
47
|
-
return MySQLStorage.create(
|
|
48
|
-
config.connectionString,
|
|
49
|
-
config.masterEncryptionKey,
|
|
50
|
-
config.poolSize || 10
|
|
51
|
-
);
|
|
42
|
+
return MySQLStorage.create(config.connectionString, config.poolSize || 10);
|
|
52
43
|
}
|
|
53
44
|
|
|
54
45
|
case 'postgres': {
|
|
@@ -56,11 +47,7 @@ export async function createStorage(config: StorageConfig): Promise<Storage> {
|
|
|
56
47
|
throw new Error('PostgreSQL storage requires DATABASE_URL connection string');
|
|
57
48
|
}
|
|
58
49
|
const { PostgreSQLStorage } = await import('./postgres.ts');
|
|
59
|
-
return PostgreSQLStorage.create(
|
|
60
|
-
config.connectionString,
|
|
61
|
-
config.masterEncryptionKey,
|
|
62
|
-
config.poolSize || 10
|
|
63
|
-
);
|
|
50
|
+
return PostgreSQLStorage.create(config.connectionString, config.poolSize || 10);
|
|
64
51
|
}
|
|
65
52
|
|
|
66
53
|
default:
|