@xtr-dev/rondevu-server 0.5.11 → 0.5.13
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 +39 -19
- package/dist/index.js.map +2 -2
- 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 +95 -271
- package/src/storage/d1.ts +77 -236
- package/src/storage/factory.ts +4 -17
- package/src/storage/memory.ts +49 -152
- package/src/storage/mysql.ts +71 -188
- package/src/storage/postgres.ts +72 -188
- package/src/storage/sqlite.ts +70 -195
- package/src/storage/types.ts +32 -88
- package/src/worker.ts +4 -9
package/src/storage/d1.ts
CHANGED
|
@@ -1,63 +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
|
-
answered_at INTEGER
|
|
48
|
+
answered_at INTEGER,
|
|
49
|
+
matched_tags TEXT,
|
|
50
|
+
FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
|
|
49
51
|
);
|
|
50
52
|
|
|
51
|
-
CREATE INDEX IF NOT EXISTS
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_offers_public_key ON offers(public_key);
|
|
52
54
|
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
53
55
|
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
54
|
-
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_public_key);
|
|
55
57
|
|
|
56
58
|
-- ICE candidates table
|
|
57
59
|
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
58
60
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
61
|
offer_id TEXT NOT NULL,
|
|
60
|
-
|
|
62
|
+
public_key TEXT NOT NULL,
|
|
61
63
|
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
62
64
|
candidate TEXT NOT NULL,
|
|
63
65
|
created_at INTEGER NOT NULL,
|
|
@@ -65,22 +67,10 @@ export class D1Storage implements Storage {
|
|
|
65
67
|
);
|
|
66
68
|
|
|
67
69
|
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
68
|
-
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);
|
|
69
72
|
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
70
73
|
|
|
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,
|
|
76
|
-
expires_at INTEGER NOT NULL,
|
|
77
|
-
last_used INTEGER NOT NULL,
|
|
78
|
-
CHECK(length(name) >= 3 AND length(name) <= 32)
|
|
79
|
-
);
|
|
80
|
-
|
|
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);
|
|
83
|
-
|
|
84
74
|
-- Rate limits table (for distributed rate limiting)
|
|
85
75
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
86
76
|
identifier TEXT PRIMARY KEY,
|
|
@@ -105,19 +95,18 @@ export class D1Storage implements Storage {
|
|
|
105
95
|
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
|
106
96
|
const created: Offer[] = [];
|
|
107
97
|
|
|
108
|
-
// D1 doesn't support true transactions yet, so we do this sequentially
|
|
109
98
|
for (const offer of offers) {
|
|
110
99
|
const id = offer.id || await generateOfferHash(offer.sdp);
|
|
111
100
|
const now = Date.now();
|
|
112
101
|
|
|
113
102
|
await this.db.prepare(`
|
|
114
|
-
INSERT INTO offers (id,
|
|
103
|
+
INSERT INTO offers (id, public_key, tags, sdp, created_at, expires_at, last_seen)
|
|
115
104
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
116
|
-
`).bind(id, offer.
|
|
105
|
+
`).bind(id, offer.publicKey, JSON.stringify(offer.tags), offer.sdp, now, offer.expiresAt, now).run();
|
|
117
106
|
|
|
118
107
|
created.push({
|
|
119
108
|
id,
|
|
120
|
-
|
|
109
|
+
publicKey: offer.publicKey,
|
|
121
110
|
tags: offer.tags,
|
|
122
111
|
sdp: offer.sdp,
|
|
123
112
|
createdAt: now,
|
|
@@ -129,12 +118,12 @@ export class D1Storage implements Storage {
|
|
|
129
118
|
return created;
|
|
130
119
|
}
|
|
131
120
|
|
|
132
|
-
async
|
|
121
|
+
async getOffersByPublicKey(publicKey: string): Promise<Offer[]> {
|
|
133
122
|
const result = await this.db.prepare(`
|
|
134
123
|
SELECT * FROM offers
|
|
135
|
-
WHERE
|
|
124
|
+
WHERE public_key = ? AND expires_at > ?
|
|
136
125
|
ORDER BY last_seen DESC
|
|
137
|
-
`).bind(
|
|
126
|
+
`).bind(publicKey, Date.now()).all();
|
|
138
127
|
|
|
139
128
|
if (!result.results) {
|
|
140
129
|
return [];
|
|
@@ -156,11 +145,11 @@ export class D1Storage implements Storage {
|
|
|
156
145
|
return this.rowToOffer(result as any);
|
|
157
146
|
}
|
|
158
147
|
|
|
159
|
-
async deleteOffer(offerId: string,
|
|
148
|
+
async deleteOffer(offerId: string, ownerPublicKey: string): Promise<boolean> {
|
|
160
149
|
const result = await this.db.prepare(`
|
|
161
150
|
DELETE FROM offers
|
|
162
|
-
WHERE id = ? AND
|
|
163
|
-
`).bind(offerId,
|
|
151
|
+
WHERE id = ? AND public_key = ?
|
|
152
|
+
`).bind(offerId, ownerPublicKey).run();
|
|
164
153
|
|
|
165
154
|
return (result.meta.changes || 0) > 0;
|
|
166
155
|
}
|
|
@@ -175,50 +164,40 @@ export class D1Storage implements Storage {
|
|
|
175
164
|
|
|
176
165
|
async answerOffer(
|
|
177
166
|
offerId: string,
|
|
178
|
-
|
|
179
|
-
answerSdp: string
|
|
167
|
+
answererPublicKey: string,
|
|
168
|
+
answerSdp: string,
|
|
169
|
+
matchedTags?: string[]
|
|
180
170
|
): Promise<{ success: boolean; error?: string }> {
|
|
181
|
-
// Check if offer exists and is not expired
|
|
182
171
|
const offer = await this.getOfferById(offerId);
|
|
183
172
|
|
|
184
173
|
if (!offer) {
|
|
185
|
-
return {
|
|
186
|
-
success: false,
|
|
187
|
-
error: 'Offer not found or expired'
|
|
188
|
-
};
|
|
174
|
+
return { success: false, error: 'Offer not found or expired' };
|
|
189
175
|
}
|
|
190
176
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
success: false,
|
|
195
|
-
error: 'Offer already answered'
|
|
196
|
-
};
|
|
177
|
+
if (offer.answererPublicKey) {
|
|
178
|
+
return { success: false, error: 'Offer already answered' };
|
|
197
179
|
}
|
|
198
180
|
|
|
199
|
-
|
|
181
|
+
const matchedTagsJson = matchedTags ? JSON.stringify(matchedTags) : null;
|
|
200
182
|
const result = await this.db.prepare(`
|
|
201
183
|
UPDATE offers
|
|
202
|
-
SET
|
|
203
|
-
WHERE id = ? AND
|
|
204
|
-
`).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();
|
|
205
187
|
|
|
206
188
|
if ((result.meta.changes || 0) === 0) {
|
|
207
|
-
return {
|
|
208
|
-
success: false,
|
|
209
|
-
error: 'Offer already answered (race condition)'
|
|
210
|
-
};
|
|
189
|
+
return { success: false, error: 'Offer already answered (race condition)' };
|
|
211
190
|
}
|
|
212
191
|
|
|
213
192
|
return { success: true };
|
|
214
193
|
}
|
|
215
194
|
|
|
216
|
-
async getAnsweredOffers(
|
|
195
|
+
async getAnsweredOffers(offererPublicKey: string): Promise<Offer[]> {
|
|
217
196
|
const result = await this.db.prepare(`
|
|
218
197
|
SELECT * FROM offers
|
|
219
|
-
WHERE
|
|
198
|
+
WHERE public_key = ? AND answerer_public_key IS NOT NULL AND expires_at > ?
|
|
220
199
|
ORDER BY answered_at DESC
|
|
221
|
-
`).bind(
|
|
200
|
+
`).bind(offererPublicKey, Date.now()).all();
|
|
222
201
|
|
|
223
202
|
if (!result.results) {
|
|
224
203
|
return [];
|
|
@@ -227,12 +206,12 @@ export class D1Storage implements Storage {
|
|
|
227
206
|
return result.results.map(row => this.rowToOffer(row as any));
|
|
228
207
|
}
|
|
229
208
|
|
|
230
|
-
async getOffersAnsweredBy(
|
|
209
|
+
async getOffersAnsweredBy(answererPublicKey: string): Promise<Offer[]> {
|
|
231
210
|
const result = await this.db.prepare(`
|
|
232
211
|
SELECT * FROM offers
|
|
233
|
-
WHERE
|
|
212
|
+
WHERE answerer_public_key = ? AND expires_at > ?
|
|
234
213
|
ORDER BY answered_at DESC
|
|
235
|
-
`).bind(
|
|
214
|
+
`).bind(answererPublicKey, Date.now()).all();
|
|
236
215
|
|
|
237
216
|
if (!result.results) {
|
|
238
217
|
return [];
|
|
@@ -245,7 +224,7 @@ export class D1Storage implements Storage {
|
|
|
245
224
|
|
|
246
225
|
async discoverOffers(
|
|
247
226
|
tags: string[],
|
|
248
|
-
|
|
227
|
+
excludePublicKey: string | null,
|
|
249
228
|
limit: number,
|
|
250
229
|
offset: number
|
|
251
230
|
): Promise<Offer[]> {
|
|
@@ -253,22 +232,20 @@ export class D1Storage implements Storage {
|
|
|
253
232
|
return [];
|
|
254
233
|
}
|
|
255
234
|
|
|
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
235
|
const placeholders = tags.map(() => '?').join(',');
|
|
259
236
|
|
|
260
237
|
let query = `
|
|
261
238
|
SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
|
|
262
239
|
WHERE t.value IN (${placeholders})
|
|
263
240
|
AND o.expires_at > ?
|
|
264
|
-
AND o.
|
|
241
|
+
AND o.answerer_public_key IS NULL
|
|
265
242
|
`;
|
|
266
243
|
|
|
267
244
|
const params: any[] = [...tags, Date.now()];
|
|
268
245
|
|
|
269
|
-
if (
|
|
270
|
-
query += ' AND o.
|
|
271
|
-
params.push(
|
|
246
|
+
if (excludePublicKey) {
|
|
247
|
+
query += ' AND o.public_key != ?';
|
|
248
|
+
params.push(excludePublicKey);
|
|
272
249
|
}
|
|
273
250
|
|
|
274
251
|
query += ' ORDER BY o.created_at DESC LIMIT ? OFFSET ?';
|
|
@@ -285,27 +262,26 @@ export class D1Storage implements Storage {
|
|
|
285
262
|
|
|
286
263
|
async getRandomOffer(
|
|
287
264
|
tags: string[],
|
|
288
|
-
|
|
265
|
+
excludePublicKey: string | null
|
|
289
266
|
): Promise<Offer | null> {
|
|
290
267
|
if (tags.length === 0) {
|
|
291
268
|
return null;
|
|
292
269
|
}
|
|
293
270
|
|
|
294
|
-
// Build query with JSON tag matching (OR logic)
|
|
295
271
|
const placeholders = tags.map(() => '?').join(',');
|
|
296
272
|
|
|
297
273
|
let query = `
|
|
298
274
|
SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
|
|
299
275
|
WHERE t.value IN (${placeholders})
|
|
300
276
|
AND o.expires_at > ?
|
|
301
|
-
AND o.
|
|
277
|
+
AND o.answerer_public_key IS NULL
|
|
302
278
|
`;
|
|
303
279
|
|
|
304
280
|
const params: any[] = [...tags, Date.now()];
|
|
305
281
|
|
|
306
|
-
if (
|
|
307
|
-
query += ' AND o.
|
|
308
|
-
params.push(
|
|
282
|
+
if (excludePublicKey) {
|
|
283
|
+
query += ' AND o.public_key != ?';
|
|
284
|
+
params.push(excludePublicKey);
|
|
309
285
|
}
|
|
310
286
|
|
|
311
287
|
query += ' ORDER BY RANDOM() LIMIT 1';
|
|
@@ -319,19 +295,18 @@ export class D1Storage implements Storage {
|
|
|
319
295
|
|
|
320
296
|
async addIceCandidates(
|
|
321
297
|
offerId: string,
|
|
322
|
-
|
|
298
|
+
publicKey: string,
|
|
323
299
|
role: 'offerer' | 'answerer',
|
|
324
300
|
candidates: any[]
|
|
325
301
|
): Promise<number> {
|
|
326
|
-
// D1 doesn't have transactions, so insert one by one
|
|
327
302
|
for (let i = 0; i < candidates.length; i++) {
|
|
328
303
|
const timestamp = Date.now() + i;
|
|
329
304
|
await this.db.prepare(`
|
|
330
|
-
INSERT INTO ice_candidates (offer_id,
|
|
305
|
+
INSERT INTO ice_candidates (offer_id, public_key, role, candidate, created_at)
|
|
331
306
|
VALUES (?, ?, ?, ?, ?)
|
|
332
307
|
`).bind(
|
|
333
308
|
offerId,
|
|
334
|
-
|
|
309
|
+
publicKey,
|
|
335
310
|
role,
|
|
336
311
|
JSON.stringify(candidates[i]),
|
|
337
312
|
timestamp
|
|
@@ -369,7 +344,7 @@ export class D1Storage implements Storage {
|
|
|
369
344
|
return result.results.map((row: any) => ({
|
|
370
345
|
id: row.id,
|
|
371
346
|
offerId: row.offer_id,
|
|
372
|
-
|
|
347
|
+
publicKey: row.public_key,
|
|
373
348
|
role: row.role,
|
|
374
349
|
candidate: JSON.parse(row.candidate),
|
|
375
350
|
createdAt: row.created_at,
|
|
@@ -378,41 +353,37 @@ export class D1Storage implements Storage {
|
|
|
378
353
|
|
|
379
354
|
async getIceCandidatesForMultipleOffers(
|
|
380
355
|
offerIds: string[],
|
|
381
|
-
|
|
356
|
+
publicKey: string,
|
|
382
357
|
since?: number
|
|
383
358
|
): Promise<Map<string, IceCandidate[]>> {
|
|
384
359
|
const result = new Map<string, IceCandidate[]>();
|
|
385
360
|
|
|
386
|
-
// Return empty map if no offer IDs provided
|
|
387
361
|
if (offerIds.length === 0) {
|
|
388
362
|
return result;
|
|
389
363
|
}
|
|
390
364
|
|
|
391
|
-
// Validate array contains only strings
|
|
392
365
|
if (!Array.isArray(offerIds) || !offerIds.every(id => typeof id === 'string')) {
|
|
393
366
|
throw new Error('Invalid offer IDs: must be array of strings');
|
|
394
367
|
}
|
|
395
368
|
|
|
396
|
-
// Prevent DoS attacks from extremely large IN clauses
|
|
397
369
|
if (offerIds.length > 1000) {
|
|
398
370
|
throw new Error('Too many offer IDs (max 1000)');
|
|
399
371
|
}
|
|
400
372
|
|
|
401
|
-
// Build query that fetches candidates from the OTHER peer only
|
|
402
373
|
const placeholders = offerIds.map(() => '?').join(',');
|
|
403
374
|
|
|
404
375
|
let query = `
|
|
405
|
-
SELECT ic.*, o.
|
|
376
|
+
SELECT ic.*, o.public_key as offer_public_key
|
|
406
377
|
FROM ice_candidates ic
|
|
407
378
|
INNER JOIN offers o ON o.id = ic.offer_id
|
|
408
379
|
WHERE ic.offer_id IN (${placeholders})
|
|
409
380
|
AND (
|
|
410
|
-
(o.
|
|
411
|
-
OR (o.
|
|
381
|
+
(o.public_key = ? AND ic.role = 'answerer')
|
|
382
|
+
OR (o.answerer_public_key = ? AND ic.role = 'offerer')
|
|
412
383
|
)
|
|
413
384
|
`;
|
|
414
385
|
|
|
415
|
-
const params: any[] = [...offerIds,
|
|
386
|
+
const params: any[] = [...offerIds, publicKey, publicKey];
|
|
416
387
|
|
|
417
388
|
if (since !== undefined) {
|
|
418
389
|
query += ' AND ic.created_at > ?';
|
|
@@ -427,12 +398,11 @@ export class D1Storage implements Storage {
|
|
|
427
398
|
return result;
|
|
428
399
|
}
|
|
429
400
|
|
|
430
|
-
// Group candidates by offer_id
|
|
431
401
|
for (const row of queryResult.results as any[]) {
|
|
432
402
|
const candidate: IceCandidate = {
|
|
433
403
|
id: row.id,
|
|
434
404
|
offerId: row.offer_id,
|
|
435
|
-
|
|
405
|
+
publicKey: row.public_key,
|
|
436
406
|
role: row.role,
|
|
437
407
|
candidate: JSON.parse(row.candidate),
|
|
438
408
|
createdAt: row.created_at,
|
|
@@ -447,128 +417,12 @@ export class D1Storage implements Storage {
|
|
|
447
417
|
return result;
|
|
448
418
|
}
|
|
449
419
|
|
|
450
|
-
// ===== Credential Management =====
|
|
451
|
-
|
|
452
|
-
async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
|
|
453
|
-
const now = Date.now();
|
|
454
|
-
const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
|
|
455
|
-
|
|
456
|
-
const { generateCredentialName, generateSecret } = await import('../crypto.ts');
|
|
457
|
-
|
|
458
|
-
let name: string;
|
|
459
|
-
|
|
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();
|
|
465
|
-
|
|
466
|
-
if (existing) {
|
|
467
|
-
throw new Error('Username already taken');
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
name = request.name;
|
|
471
|
-
} else {
|
|
472
|
-
// Generate random name - retry until unique
|
|
473
|
-
let attempts = 0;
|
|
474
|
-
const maxAttempts = 100;
|
|
475
|
-
|
|
476
|
-
while (attempts < maxAttempts) {
|
|
477
|
-
name = generateCredentialName();
|
|
478
|
-
|
|
479
|
-
const existing = await this.db.prepare(`
|
|
480
|
-
SELECT name FROM credentials WHERE name = ?
|
|
481
|
-
`).bind(name).first();
|
|
482
|
-
|
|
483
|
-
if (!existing) {
|
|
484
|
-
break;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
attempts++;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
if (attempts >= maxAttempts) {
|
|
491
|
-
throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
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
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
async getCredential(name: string): Promise<Credential | null> {
|
|
518
|
-
const result = await this.db.prepare(`
|
|
519
|
-
SELECT * FROM credentials
|
|
520
|
-
WHERE name = ? AND expires_at > ?
|
|
521
|
-
`).bind(name, Date.now()).first();
|
|
522
|
-
|
|
523
|
-
if (!result) {
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const row = result as any;
|
|
528
|
-
|
|
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);
|
|
534
|
-
|
|
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)
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
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();
|
|
554
|
-
}
|
|
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();
|
|
560
|
-
|
|
561
|
-
return result.meta.changes || 0;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
420
|
// ===== Rate Limiting =====
|
|
565
421
|
|
|
566
422
|
async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
|
|
567
423
|
const now = Date.now();
|
|
568
424
|
const resetTime = now + windowMs;
|
|
569
425
|
|
|
570
|
-
// Atomic UPSERT: Insert or increment count, reset if expired
|
|
571
|
-
// This prevents TOCTOU race conditions by doing check+increment in single operation
|
|
572
426
|
const result = await this.db.prepare(`
|
|
573
427
|
INSERT INTO rate_limits (identifier, count, reset_time)
|
|
574
428
|
VALUES (?, 1, ?)
|
|
@@ -584,7 +438,6 @@ export class D1Storage implements Storage {
|
|
|
584
438
|
RETURNING count
|
|
585
439
|
`).bind(identifier, resetTime, now, now, resetTime).first() as { count: number } | null;
|
|
586
440
|
|
|
587
|
-
// Check if limit exceeded
|
|
588
441
|
return result ? result.count <= limit : false;
|
|
589
442
|
}
|
|
590
443
|
|
|
@@ -600,21 +453,17 @@ export class D1Storage implements Storage {
|
|
|
600
453
|
|
|
601
454
|
async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
|
|
602
455
|
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
456
|
const result = await this.db.prepare(`
|
|
606
457
|
INSERT INTO nonces (nonce_key, expires_at)
|
|
607
458
|
VALUES (?, ?)
|
|
608
459
|
`).bind(nonceKey, expiresAt).run();
|
|
609
460
|
|
|
610
|
-
// D1 returns success=true if insert succeeded
|
|
611
461
|
return result.success;
|
|
612
462
|
} catch (error: any) {
|
|
613
|
-
// UNIQUE constraint violation means nonce already used (replay attack)
|
|
614
463
|
if (error?.message?.includes('UNIQUE constraint failed')) {
|
|
615
464
|
return false;
|
|
616
465
|
}
|
|
617
|
-
throw error;
|
|
466
|
+
throw error;
|
|
618
467
|
}
|
|
619
468
|
}
|
|
620
469
|
|
|
@@ -628,7 +477,6 @@ export class D1Storage implements Storage {
|
|
|
628
477
|
|
|
629
478
|
async close(): Promise<void> {
|
|
630
479
|
// D1 doesn't require explicit connection closing
|
|
631
|
-
// Connections are managed by the Cloudflare Workers runtime
|
|
632
480
|
}
|
|
633
481
|
|
|
634
482
|
// ===== Count Methods (for resource limits) =====
|
|
@@ -638,18 +486,13 @@ export class D1Storage implements Storage {
|
|
|
638
486
|
return result?.count ?? 0;
|
|
639
487
|
}
|
|
640
488
|
|
|
641
|
-
async
|
|
642
|
-
const result = await this.db.prepare('SELECT COUNT(*) as count FROM offers WHERE
|
|
643
|
-
.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)
|
|
644
492
|
.first() as { count: number } | null;
|
|
645
493
|
return result?.count ?? 0;
|
|
646
494
|
}
|
|
647
495
|
|
|
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
496
|
async getIceCandidateCount(offerId: string): Promise<number> {
|
|
654
497
|
const result = await this.db.prepare('SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?')
|
|
655
498
|
.bind(offerId)
|
|
@@ -659,21 +502,19 @@ export class D1Storage implements Storage {
|
|
|
659
502
|
|
|
660
503
|
// ===== Helper Methods =====
|
|
661
504
|
|
|
662
|
-
/**
|
|
663
|
-
* Helper method to convert database row to Offer object
|
|
664
|
-
*/
|
|
665
505
|
private rowToOffer(row: any): Offer {
|
|
666
506
|
return {
|
|
667
507
|
id: row.id,
|
|
668
|
-
|
|
508
|
+
publicKey: row.public_key,
|
|
669
509
|
tags: JSON.parse(row.tags),
|
|
670
510
|
sdp: row.sdp,
|
|
671
511
|
createdAt: row.created_at,
|
|
672
512
|
expiresAt: row.expires_at,
|
|
673
513
|
lastSeen: row.last_seen,
|
|
674
|
-
|
|
514
|
+
answererPublicKey: row.answerer_public_key || undefined,
|
|
675
515
|
answerSdp: row.answer_sdp || undefined,
|
|
676
516
|
answeredAt: row.answered_at || undefined,
|
|
517
|
+
matchedTags: row.matched_tags ? JSON.parse(row.matched_tags) : undefined,
|
|
677
518
|
};
|
|
678
519
|
}
|
|
679
520
|
}
|
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:
|