@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/dist/index.js CHANGED
@@ -5,22 +5,2261 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
8
15
  var __copyProps = (to, from, except, desc) => {
9
16
  if (from && typeof from === "object" || typeof from === "function") {
10
17
  for (let key of __getOwnPropNames(from))
11
18
  if (!__hasOwnProp.call(to, key) && key !== except)
12
19
  __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
20
  }
14
- return to;
15
- };
16
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
- // If the importer is in node compatibility mode or this is not an ESM
18
- // file that has been converted to a CommonJS file using a Babel-
19
- // compatible transform (i.e. "__esModule" has not been set), then set
20
- // "default" to the CommonJS "module.exports" for node compatibility.
21
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
- mod
23
- ));
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+
32
+ // src/crypto.ts
33
+ var crypto_exports = {};
34
+ __export(crypto_exports, {
35
+ buildSignatureMessage: () => buildSignatureMessage,
36
+ decryptSecret: () => decryptSecret,
37
+ encryptSecret: () => encryptSecret,
38
+ generateCredentialName: () => generateCredentialName,
39
+ generateSecret: () => generateSecret,
40
+ generateSignature: () => generateSignature,
41
+ validateTag: () => validateTag,
42
+ validateTags: () => validateTags,
43
+ validateUsername: () => validateUsername,
44
+ verifySignature: () => verifySignature
45
+ });
46
+ function generateCredentialName() {
47
+ const adjectives = [
48
+ "brave",
49
+ "calm",
50
+ "eager",
51
+ "fancy",
52
+ "gentle",
53
+ "happy",
54
+ "jolly",
55
+ "kind",
56
+ "lively",
57
+ "merry",
58
+ "nice",
59
+ "proud",
60
+ "quiet",
61
+ "swift",
62
+ "witty",
63
+ "young",
64
+ "bright",
65
+ "clever",
66
+ "daring",
67
+ "fair",
68
+ "grand",
69
+ "humble",
70
+ "noble",
71
+ "quick"
72
+ ];
73
+ const nouns = [
74
+ "tiger",
75
+ "eagle",
76
+ "river",
77
+ "mountain",
78
+ "ocean",
79
+ "forest",
80
+ "desert",
81
+ "valley",
82
+ "thunder",
83
+ "wind",
84
+ "fire",
85
+ "stone",
86
+ "cloud",
87
+ "star",
88
+ "moon",
89
+ "sun",
90
+ "wolf",
91
+ "bear",
92
+ "hawk",
93
+ "lion",
94
+ "fox",
95
+ "deer",
96
+ "owl",
97
+ "swan"
98
+ ];
99
+ const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
100
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
101
+ const random = crypto.getRandomValues(new Uint8Array(8));
102
+ const hex = Array.from(random).map((b) => b.toString(16).padStart(2, "0")).join("");
103
+ return `${adjective}-${noun}-${hex}`;
104
+ }
105
+ function generateSecret() {
106
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
107
+ const secret = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
108
+ if (secret.length !== 64) {
109
+ throw new Error("Secret generation failed: invalid length");
110
+ }
111
+ for (let i = 0; i < secret.length; i++) {
112
+ const c = secret[i];
113
+ if ((c < "0" || c > "9") && (c < "a" || c > "f")) {
114
+ throw new Error(`Secret generation failed: invalid hex character at position ${i}: '${c}'`);
115
+ }
116
+ }
117
+ return secret;
118
+ }
119
+ function hexToBytes(hex) {
120
+ if (hex.length % 2 !== 0) {
121
+ throw new Error("Hex string must have even length");
122
+ }
123
+ for (let i = 0; i < hex.length; i++) {
124
+ const c = hex[i].toLowerCase();
125
+ if ((c < "0" || c > "9") && (c < "a" || c > "f")) {
126
+ throw new Error(`Invalid hex character at position ${i}: '${hex[i]}'`);
127
+ }
128
+ }
129
+ const match = hex.match(/.{1,2}/g);
130
+ if (!match) {
131
+ throw new Error("Invalid hex string format");
132
+ }
133
+ return new Uint8Array(match.map((byte) => {
134
+ const parsed = parseInt(byte, 16);
135
+ if (isNaN(parsed)) {
136
+ throw new Error(`Invalid hex byte: ${byte}`);
137
+ }
138
+ return parsed;
139
+ }));
140
+ }
141
+ async function encryptSecret(secret, masterKeyHex) {
142
+ if (!masterKeyHex || masterKeyHex.length !== 64) {
143
+ throw new Error("Master key must be 64-character hex string (32 bytes)");
144
+ }
145
+ const keyBytes = hexToBytes(masterKeyHex);
146
+ const key = await crypto.subtle.importKey(
147
+ "raw",
148
+ keyBytes,
149
+ { name: "AES-GCM", length: 256 },
150
+ false,
151
+ ["encrypt"]
152
+ );
153
+ const iv = crypto.getRandomValues(new Uint8Array(12));
154
+ const encoder = new TextEncoder();
155
+ const secretBytes = encoder.encode(secret);
156
+ const ciphertext = await crypto.subtle.encrypt(
157
+ { name: "AES-GCM", iv, tagLength: 128 },
158
+ key,
159
+ secretBytes
160
+ );
161
+ const ivHex = Array.from(iv).map((b) => b.toString(16).padStart(2, "0")).join("");
162
+ const ciphertextHex = Array.from(new Uint8Array(ciphertext)).map((b) => b.toString(16).padStart(2, "0")).join("");
163
+ return `${ivHex}:${ciphertextHex}`;
164
+ }
165
+ async function decryptSecret(encryptedSecret, masterKeyHex) {
166
+ if (!masterKeyHex || masterKeyHex.length !== 64) {
167
+ throw new Error("Master key must be 64-character hex string (32 bytes)");
168
+ }
169
+ const parts = encryptedSecret.split(":");
170
+ if (parts.length !== 2) {
171
+ throw new Error("Invalid encrypted secret format (expected iv:ciphertext)");
172
+ }
173
+ const [ivHex, ciphertextHex] = parts;
174
+ if (ivHex.length !== 24) {
175
+ throw new Error("Invalid IV length (expected 12 bytes = 24 hex characters)");
176
+ }
177
+ if (ciphertextHex.length < 32) {
178
+ throw new Error("Invalid ciphertext length (must include 16-byte auth tag)");
179
+ }
180
+ const iv = hexToBytes(ivHex);
181
+ const ciphertext = hexToBytes(ciphertextHex);
182
+ const keyBytes = hexToBytes(masterKeyHex);
183
+ const key = await crypto.subtle.importKey(
184
+ "raw",
185
+ keyBytes,
186
+ { name: "AES-GCM", length: 256 },
187
+ false,
188
+ ["decrypt"]
189
+ );
190
+ const decryptedBytes = await crypto.subtle.decrypt(
191
+ { name: "AES-GCM", iv, tagLength: 128 },
192
+ key,
193
+ ciphertext
194
+ );
195
+ const decoder = new TextDecoder();
196
+ return decoder.decode(decryptedBytes);
197
+ }
198
+ async function generateSignature(secret, message) {
199
+ const secretBytes = hexToBytes(secret);
200
+ const key = await crypto.subtle.importKey(
201
+ "raw",
202
+ secretBytes,
203
+ { name: "HMAC", hash: "SHA-256" },
204
+ false,
205
+ ["sign"]
206
+ );
207
+ const encoder = new TextEncoder();
208
+ const messageBytes = encoder.encode(message);
209
+ const signatureBytes = await crypto.subtle.sign("HMAC", key, messageBytes);
210
+ return import_node_buffer.Buffer.from(signatureBytes).toString("base64");
211
+ }
212
+ async function verifySignature(secret, message, signature) {
213
+ try {
214
+ const secretBytes = hexToBytes(secret);
215
+ const key = await crypto.subtle.importKey(
216
+ "raw",
217
+ secretBytes,
218
+ { name: "HMAC", hash: "SHA-256" },
219
+ false,
220
+ ["verify"]
221
+ );
222
+ const encoder = new TextEncoder();
223
+ const messageBytes = encoder.encode(message);
224
+ const signatureBytes = import_node_buffer.Buffer.from(signature, "base64");
225
+ return await crypto.subtle.verify("HMAC", key, signatureBytes, messageBytes);
226
+ } catch (error) {
227
+ console.error("Signature verification error:", error);
228
+ return false;
229
+ }
230
+ }
231
+ function canonicalJSON(obj, depth = 0) {
232
+ const MAX_DEPTH = 100;
233
+ if (depth > MAX_DEPTH) {
234
+ throw new Error("Object nesting too deep for canonicalization");
235
+ }
236
+ if (obj === null) return "null";
237
+ if (obj === void 0) return JSON.stringify(void 0);
238
+ const type = typeof obj;
239
+ if (type === "function") throw new Error("Functions are not supported in RPC parameters");
240
+ if (type === "symbol" || type === "bigint") throw new Error(`${type} is not supported in RPC parameters`);
241
+ if (type === "number" && !Number.isFinite(obj)) throw new Error("NaN and Infinity are not supported in RPC parameters");
242
+ if (type !== "object") return JSON.stringify(obj);
243
+ if (Array.isArray(obj)) {
244
+ return "[" + obj.map((item) => canonicalJSON(item, depth + 1)).join(",") + "]";
245
+ }
246
+ const sortedKeys = Object.keys(obj).sort();
247
+ const pairs = sortedKeys.map((key) => JSON.stringify(key) + ":" + canonicalJSON(obj[key], depth + 1));
248
+ return "{" + pairs.join(",") + "}";
249
+ }
250
+ function buildSignatureMessage(timestamp, nonce, method, params) {
251
+ if (nonce.length !== 36) {
252
+ throw new Error("Nonce must be a valid UUID v4 (use crypto.randomUUID())");
253
+ }
254
+ if (nonce[8] !== "-" || nonce[13] !== "-" || nonce[18] !== "-" || nonce[23] !== "-") {
255
+ throw new Error("Nonce must be a valid UUID v4 (use crypto.randomUUID())");
256
+ }
257
+ if (nonce[14] !== "4") {
258
+ throw new Error("Nonce must be a valid UUID v4 (use crypto.randomUUID())");
259
+ }
260
+ const variant = nonce[19].toLowerCase();
261
+ if (variant !== "8" && variant !== "9" && variant !== "a" && variant !== "b") {
262
+ throw new Error("Nonce must be a valid UUID v4 (use crypto.randomUUID())");
263
+ }
264
+ const hexChars = nonce.replace(/-/g, "");
265
+ for (let i = 0; i < hexChars.length; i++) {
266
+ const c = hexChars[i].toLowerCase();
267
+ if ((c < "0" || c > "9") && (c < "a" || c > "f")) {
268
+ throw new Error("Nonce must be a valid UUID v4 (use crypto.randomUUID())");
269
+ }
270
+ }
271
+ const paramsStr = canonicalJSON(params || {});
272
+ return `${timestamp}:${nonce}:${method}:${paramsStr}`;
273
+ }
274
+ function validateUsername(username) {
275
+ if (typeof username !== "string") {
276
+ return { valid: false, error: "Username must be a string" };
277
+ }
278
+ if (username.length < USERNAME_MIN_LENGTH) {
279
+ return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
280
+ }
281
+ if (username.length > USERNAME_MAX_LENGTH) {
282
+ return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
283
+ }
284
+ if (!USERNAME_REGEX.test(username)) {
285
+ return { valid: false, error: "Username must be lowercase alphanumeric with optional dashes/periods, and start/end with alphanumeric" };
286
+ }
287
+ return { valid: true };
288
+ }
289
+ function validateTag(tag) {
290
+ if (typeof tag !== "string") {
291
+ return { valid: false, error: "Tag must be a string" };
292
+ }
293
+ if (tag.length < TAG_MIN_LENGTH) {
294
+ return { valid: false, error: `Tag must be at least ${TAG_MIN_LENGTH} character` };
295
+ }
296
+ if (tag.length > TAG_MAX_LENGTH) {
297
+ return { valid: false, error: `Tag must be at most ${TAG_MAX_LENGTH} characters` };
298
+ }
299
+ if (tag.length === 1) {
300
+ if (!/^[a-z0-9]$/.test(tag)) {
301
+ return { valid: false, error: "Tag must be lowercase alphanumeric" };
302
+ }
303
+ return { valid: true };
304
+ }
305
+ if (!TAG_REGEX.test(tag)) {
306
+ return { valid: false, error: "Tag must be lowercase alphanumeric with optional dots/dashes, and start/end with alphanumeric" };
307
+ }
308
+ return { valid: true };
309
+ }
310
+ function validateTags(tags, maxTags = 20) {
311
+ if (!Array.isArray(tags)) {
312
+ return { valid: false, error: "Tags must be an array" };
313
+ }
314
+ if (tags.length === 0) {
315
+ return { valid: false, error: "At least one tag is required" };
316
+ }
317
+ if (tags.length > maxTags) {
318
+ return { valid: false, error: `Maximum ${maxTags} tags allowed` };
319
+ }
320
+ for (let i = 0; i < tags.length; i++) {
321
+ const result = validateTag(tags[i]);
322
+ if (!result.valid) {
323
+ return { valid: false, error: `Tag ${i + 1}: ${result.error}` };
324
+ }
325
+ }
326
+ const uniqueTags = new Set(tags);
327
+ if (uniqueTags.size !== tags.length) {
328
+ return { valid: false, error: "Duplicate tags are not allowed" };
329
+ }
330
+ return { valid: true };
331
+ }
332
+ var import_node_buffer, USERNAME_REGEX, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, TAG_MIN_LENGTH, TAG_MAX_LENGTH, TAG_REGEX;
333
+ var init_crypto = __esm({
334
+ "src/crypto.ts"() {
335
+ "use strict";
336
+ import_node_buffer = require("node:buffer");
337
+ USERNAME_REGEX = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/;
338
+ USERNAME_MIN_LENGTH = 4;
339
+ USERNAME_MAX_LENGTH = 32;
340
+ TAG_MIN_LENGTH = 1;
341
+ TAG_MAX_LENGTH = 64;
342
+ TAG_REGEX = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
343
+ }
344
+ });
345
+
346
+ // src/storage/hash-id.ts
347
+ async function generateOfferHash(sdp) {
348
+ const randomBytes = crypto.getRandomValues(new Uint8Array(8));
349
+ const randomHex = Array.from(randomBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
350
+ const hashInput = {
351
+ sdp,
352
+ timestamp: Date.now(),
353
+ nonce: randomHex
354
+ };
355
+ const jsonString = JSON.stringify(hashInput);
356
+ const encoder = new TextEncoder();
357
+ const data = encoder.encode(jsonString);
358
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
359
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
360
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
361
+ return hashHex;
362
+ }
363
+ var init_hash_id = __esm({
364
+ "src/storage/hash-id.ts"() {
365
+ "use strict";
366
+ }
367
+ });
368
+
369
+ // src/storage/memory.ts
370
+ var memory_exports = {};
371
+ __export(memory_exports, {
372
+ MemoryStorage: () => MemoryStorage
373
+ });
374
+ var YEAR_IN_MS, MemoryStorage;
375
+ var init_memory = __esm({
376
+ "src/storage/memory.ts"() {
377
+ "use strict";
378
+ init_hash_id();
379
+ YEAR_IN_MS = 365 * 24 * 60 * 60 * 1e3;
380
+ MemoryStorage = class {
381
+ constructor(masterEncryptionKey) {
382
+ // Primary storage
383
+ this.credentials = /* @__PURE__ */ new Map();
384
+ this.offers = /* @__PURE__ */ new Map();
385
+ this.iceCandidates = /* @__PURE__ */ new Map();
386
+ // offerId → candidates
387
+ this.rateLimits = /* @__PURE__ */ new Map();
388
+ this.nonces = /* @__PURE__ */ new Map();
389
+ // Secondary indexes for efficient lookups
390
+ this.offersByUsername = /* @__PURE__ */ new Map();
391
+ // username → offer IDs
392
+ this.offersByTag = /* @__PURE__ */ new Map();
393
+ // tag → offer IDs
394
+ this.offersByAnswerer = /* @__PURE__ */ new Map();
395
+ // answerer username → offer IDs
396
+ // Auto-increment counter for ICE candidates
397
+ this.iceCandidateIdCounter = 0;
398
+ this.masterEncryptionKey = masterEncryptionKey;
399
+ }
400
+ // ===== Offer Management =====
401
+ async createOffers(offers) {
402
+ const created = [];
403
+ const now = Date.now();
404
+ for (const request of offers) {
405
+ const id = request.id || await generateOfferHash(request.sdp);
406
+ const offer = {
407
+ id,
408
+ username: request.username,
409
+ tags: request.tags,
410
+ sdp: request.sdp,
411
+ createdAt: now,
412
+ expiresAt: request.expiresAt,
413
+ lastSeen: now
414
+ };
415
+ this.offers.set(id, offer);
416
+ if (!this.offersByUsername.has(request.username)) {
417
+ this.offersByUsername.set(request.username, /* @__PURE__ */ new Set());
418
+ }
419
+ this.offersByUsername.get(request.username).add(id);
420
+ for (const tag of request.tags) {
421
+ if (!this.offersByTag.has(tag)) {
422
+ this.offersByTag.set(tag, /* @__PURE__ */ new Set());
423
+ }
424
+ this.offersByTag.get(tag).add(id);
425
+ }
426
+ created.push(offer);
427
+ }
428
+ return created;
429
+ }
430
+ async getOffersByUsername(username) {
431
+ const now = Date.now();
432
+ const offerIds = this.offersByUsername.get(username);
433
+ if (!offerIds) return [];
434
+ const offers = [];
435
+ for (const id of offerIds) {
436
+ const offer = this.offers.get(id);
437
+ if (offer && offer.expiresAt > now) {
438
+ offers.push(offer);
439
+ }
440
+ }
441
+ return offers.sort((a, b) => b.lastSeen - a.lastSeen);
442
+ }
443
+ async getOfferById(offerId) {
444
+ const offer = this.offers.get(offerId);
445
+ if (!offer || offer.expiresAt <= Date.now()) {
446
+ return null;
447
+ }
448
+ return offer;
449
+ }
450
+ async deleteOffer(offerId, ownerUsername) {
451
+ const offer = this.offers.get(offerId);
452
+ if (!offer || offer.username !== ownerUsername) {
453
+ return false;
454
+ }
455
+ this.removeOfferFromIndexes(offer);
456
+ this.offers.delete(offerId);
457
+ this.iceCandidates.delete(offerId);
458
+ return true;
459
+ }
460
+ async deleteExpiredOffers(now) {
461
+ let count = 0;
462
+ for (const [id, offer] of this.offers) {
463
+ if (offer.expiresAt < now) {
464
+ this.removeOfferFromIndexes(offer);
465
+ this.offers.delete(id);
466
+ this.iceCandidates.delete(id);
467
+ count++;
468
+ }
469
+ }
470
+ return count;
471
+ }
472
+ async answerOffer(offerId, answererUsername, answerSdp) {
473
+ const offer = await this.getOfferById(offerId);
474
+ if (!offer) {
475
+ return { success: false, error: "Offer not found or expired" };
476
+ }
477
+ if (offer.answererUsername) {
478
+ return { success: false, error: "Offer already answered" };
479
+ }
480
+ const now = Date.now();
481
+ offer.answererUsername = answererUsername;
482
+ offer.answerSdp = answerSdp;
483
+ offer.answeredAt = now;
484
+ if (!this.offersByAnswerer.has(answererUsername)) {
485
+ this.offersByAnswerer.set(answererUsername, /* @__PURE__ */ new Set());
486
+ }
487
+ this.offersByAnswerer.get(answererUsername).add(offerId);
488
+ return { success: true };
489
+ }
490
+ async getAnsweredOffers(offererUsername) {
491
+ const now = Date.now();
492
+ const offerIds = this.offersByUsername.get(offererUsername);
493
+ if (!offerIds) return [];
494
+ const offers = [];
495
+ for (const id of offerIds) {
496
+ const offer = this.offers.get(id);
497
+ if (offer && offer.answererUsername && offer.expiresAt > now) {
498
+ offers.push(offer);
499
+ }
500
+ }
501
+ return offers.sort((a, b) => (b.answeredAt || 0) - (a.answeredAt || 0));
502
+ }
503
+ async getOffersAnsweredBy(answererUsername) {
504
+ const now = Date.now();
505
+ const offerIds = this.offersByAnswerer.get(answererUsername);
506
+ if (!offerIds) return [];
507
+ const offers = [];
508
+ for (const id of offerIds) {
509
+ const offer = this.offers.get(id);
510
+ if (offer && offer.expiresAt > now) {
511
+ offers.push(offer);
512
+ }
513
+ }
514
+ return offers.sort((a, b) => (b.answeredAt || 0) - (a.answeredAt || 0));
515
+ }
516
+ // ===== Discovery =====
517
+ async discoverOffers(tags, excludeUsername, limit, offset) {
518
+ if (tags.length === 0) return [];
519
+ const now = Date.now();
520
+ const matchingOfferIds = /* @__PURE__ */ new Set();
521
+ for (const tag of tags) {
522
+ const offerIds = this.offersByTag.get(tag);
523
+ if (offerIds) {
524
+ for (const id of offerIds) {
525
+ matchingOfferIds.add(id);
526
+ }
527
+ }
528
+ }
529
+ const offers = [];
530
+ for (const id of matchingOfferIds) {
531
+ const offer = this.offers.get(id);
532
+ if (offer && offer.expiresAt > now && !offer.answererUsername && (!excludeUsername || offer.username !== excludeUsername)) {
533
+ offers.push(offer);
534
+ }
535
+ }
536
+ offers.sort((a, b) => b.createdAt - a.createdAt);
537
+ return offers.slice(offset, offset + limit);
538
+ }
539
+ async getRandomOffer(tags, excludeUsername) {
540
+ if (tags.length === 0) return null;
541
+ const now = Date.now();
542
+ const matchingOffers = [];
543
+ const matchingOfferIds = /* @__PURE__ */ new Set();
544
+ for (const tag of tags) {
545
+ const offerIds = this.offersByTag.get(tag);
546
+ if (offerIds) {
547
+ for (const id of offerIds) {
548
+ matchingOfferIds.add(id);
549
+ }
550
+ }
551
+ }
552
+ for (const id of matchingOfferIds) {
553
+ const offer = this.offers.get(id);
554
+ if (offer && offer.expiresAt > now && !offer.answererUsername && (!excludeUsername || offer.username !== excludeUsername)) {
555
+ matchingOffers.push(offer);
556
+ }
557
+ }
558
+ if (matchingOffers.length === 0) return null;
559
+ const randomIndex = Math.floor(Math.random() * matchingOffers.length);
560
+ return matchingOffers[randomIndex];
561
+ }
562
+ // ===== ICE Candidate Management =====
563
+ async addIceCandidates(offerId, username, role, candidates) {
564
+ const baseTimestamp = Date.now();
565
+ if (!this.iceCandidates.has(offerId)) {
566
+ this.iceCandidates.set(offerId, []);
567
+ }
568
+ const candidateList = this.iceCandidates.get(offerId);
569
+ for (let i = 0; i < candidates.length; i++) {
570
+ const candidate = {
571
+ id: ++this.iceCandidateIdCounter,
572
+ offerId,
573
+ username,
574
+ role,
575
+ candidate: candidates[i],
576
+ createdAt: baseTimestamp + i
577
+ };
578
+ candidateList.push(candidate);
579
+ }
580
+ return candidates.length;
581
+ }
582
+ async getIceCandidates(offerId, targetRole, since) {
583
+ const candidates = this.iceCandidates.get(offerId) || [];
584
+ return candidates.filter((c) => c.role === targetRole && (since === void 0 || c.createdAt > since)).sort((a, b) => a.createdAt - b.createdAt);
585
+ }
586
+ async getIceCandidatesForMultipleOffers(offerIds, username, since) {
587
+ const result = /* @__PURE__ */ new Map();
588
+ if (offerIds.length === 0) return result;
589
+ if (offerIds.length > 1e3) {
590
+ throw new Error("Too many offer IDs (max 1000)");
591
+ }
592
+ for (const offerId of offerIds) {
593
+ const offer = this.offers.get(offerId);
594
+ if (!offer) continue;
595
+ const candidates = this.iceCandidates.get(offerId) || [];
596
+ const isOfferer = offer.username === username;
597
+ const isAnswerer = offer.answererUsername === username;
598
+ if (!isOfferer && !isAnswerer) continue;
599
+ const targetRole = isOfferer ? "answerer" : "offerer";
600
+ const filteredCandidates = candidates.filter((c) => c.role === targetRole && (since === void 0 || c.createdAt > since)).sort((a, b) => a.createdAt - b.createdAt);
601
+ if (filteredCandidates.length > 0) {
602
+ result.set(offerId, filteredCandidates);
603
+ }
604
+ }
605
+ return result;
606
+ }
607
+ // ===== Credential Management =====
608
+ async generateCredentials(request) {
609
+ const now = Date.now();
610
+ const expiresAt = request.expiresAt || now + YEAR_IN_MS;
611
+ const { generateCredentialName: generateCredentialName2, generateSecret: generateSecret2, encryptSecret: encryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
612
+ let name;
613
+ if (request.name) {
614
+ if (this.credentials.has(request.name)) {
615
+ throw new Error("Username already taken");
616
+ }
617
+ name = request.name;
618
+ } else {
619
+ let attempts = 0;
620
+ const maxAttempts = 100;
621
+ while (attempts < maxAttempts) {
622
+ name = generateCredentialName2();
623
+ if (!this.credentials.has(name)) break;
624
+ attempts++;
625
+ }
626
+ if (attempts >= maxAttempts) {
627
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
628
+ }
629
+ }
630
+ const secret = generateSecret2();
631
+ const encryptedSecret = await encryptSecret2(secret, this.masterEncryptionKey);
632
+ const credential = {
633
+ name,
634
+ secret: encryptedSecret,
635
+ createdAt: now,
636
+ expiresAt,
637
+ lastUsed: now
638
+ };
639
+ this.credentials.set(name, credential);
640
+ return {
641
+ ...credential,
642
+ secret
643
+ // Return plaintext, not encrypted
644
+ };
645
+ }
646
+ async getCredential(name) {
647
+ const credential = this.credentials.get(name);
648
+ if (!credential || credential.expiresAt <= Date.now()) {
649
+ return null;
650
+ }
651
+ try {
652
+ const { decryptSecret: decryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
653
+ const decryptedSecret = await decryptSecret2(credential.secret, this.masterEncryptionKey);
654
+ return {
655
+ ...credential,
656
+ secret: decryptedSecret
657
+ };
658
+ } catch (error) {
659
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
660
+ return null;
661
+ }
662
+ }
663
+ async updateCredentialUsage(name, lastUsed, expiresAt) {
664
+ const credential = this.credentials.get(name);
665
+ if (credential) {
666
+ credential.lastUsed = lastUsed;
667
+ credential.expiresAt = expiresAt;
668
+ }
669
+ }
670
+ async deleteExpiredCredentials(now) {
671
+ let count = 0;
672
+ for (const [name, credential] of this.credentials) {
673
+ if (credential.expiresAt < now) {
674
+ this.credentials.delete(name);
675
+ count++;
676
+ }
677
+ }
678
+ return count;
679
+ }
680
+ // ===== Rate Limiting =====
681
+ async checkRateLimit(identifier, limit, windowMs) {
682
+ const now = Date.now();
683
+ const existing = this.rateLimits.get(identifier);
684
+ if (!existing || existing.resetTime < now) {
685
+ this.rateLimits.set(identifier, {
686
+ count: 1,
687
+ resetTime: now + windowMs
688
+ });
689
+ return true;
690
+ }
691
+ existing.count++;
692
+ return existing.count <= limit;
693
+ }
694
+ async deleteExpiredRateLimits(now) {
695
+ let count = 0;
696
+ for (const [identifier, rateLimit] of this.rateLimits) {
697
+ if (rateLimit.resetTime < now) {
698
+ this.rateLimits.delete(identifier);
699
+ count++;
700
+ }
701
+ }
702
+ return count;
703
+ }
704
+ // ===== Nonce Tracking (Replay Protection) =====
705
+ async checkAndMarkNonce(nonceKey, expiresAt) {
706
+ if (this.nonces.has(nonceKey)) {
707
+ return false;
708
+ }
709
+ this.nonces.set(nonceKey, { expiresAt });
710
+ return true;
711
+ }
712
+ async deleteExpiredNonces(now) {
713
+ let count = 0;
714
+ for (const [key, entry] of this.nonces) {
715
+ if (entry.expiresAt < now) {
716
+ this.nonces.delete(key);
717
+ count++;
718
+ }
719
+ }
720
+ return count;
721
+ }
722
+ async close() {
723
+ this.credentials.clear();
724
+ this.offers.clear();
725
+ this.iceCandidates.clear();
726
+ this.rateLimits.clear();
727
+ this.nonces.clear();
728
+ this.offersByUsername.clear();
729
+ this.offersByTag.clear();
730
+ this.offersByAnswerer.clear();
731
+ }
732
+ // ===== Count Methods (for resource limits) =====
733
+ async getOfferCount() {
734
+ return this.offers.size;
735
+ }
736
+ async getOfferCountByUsername(username) {
737
+ const offerIds = this.offersByUsername.get(username);
738
+ return offerIds ? offerIds.size : 0;
739
+ }
740
+ async getCredentialCount() {
741
+ return this.credentials.size;
742
+ }
743
+ async getIceCandidateCount(offerId) {
744
+ const candidates = this.iceCandidates.get(offerId);
745
+ return candidates ? candidates.length : 0;
746
+ }
747
+ // ===== Helper Methods =====
748
+ removeOfferFromIndexes(offer) {
749
+ const usernameOffers = this.offersByUsername.get(offer.username);
750
+ if (usernameOffers) {
751
+ usernameOffers.delete(offer.id);
752
+ if (usernameOffers.size === 0) {
753
+ this.offersByUsername.delete(offer.username);
754
+ }
755
+ }
756
+ for (const tag of offer.tags) {
757
+ const tagOffers = this.offersByTag.get(tag);
758
+ if (tagOffers) {
759
+ tagOffers.delete(offer.id);
760
+ if (tagOffers.size === 0) {
761
+ this.offersByTag.delete(tag);
762
+ }
763
+ }
764
+ }
765
+ if (offer.answererUsername) {
766
+ const answererOffers = this.offersByAnswerer.get(offer.answererUsername);
767
+ if (answererOffers) {
768
+ answererOffers.delete(offer.id);
769
+ if (answererOffers.size === 0) {
770
+ this.offersByAnswerer.delete(offer.answererUsername);
771
+ }
772
+ }
773
+ }
774
+ }
775
+ };
776
+ }
777
+ });
778
+
779
+ // src/storage/sqlite.ts
780
+ var sqlite_exports = {};
781
+ __export(sqlite_exports, {
782
+ SQLiteStorage: () => SQLiteStorage
783
+ });
784
+ var import_better_sqlite3, YEAR_IN_MS2, SQLiteStorage;
785
+ var init_sqlite = __esm({
786
+ "src/storage/sqlite.ts"() {
787
+ "use strict";
788
+ import_better_sqlite3 = __toESM(require("better-sqlite3"));
789
+ init_hash_id();
790
+ YEAR_IN_MS2 = 365 * 24 * 60 * 60 * 1e3;
791
+ SQLiteStorage = class {
792
+ /**
793
+ * Creates a new SQLite storage instance
794
+ * @param path Path to SQLite database file, or ':memory:' for in-memory database
795
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets (32 bytes)
796
+ */
797
+ constructor(path = ":memory:", masterEncryptionKey) {
798
+ this.db = new import_better_sqlite3.default(path);
799
+ this.masterEncryptionKey = masterEncryptionKey;
800
+ this.initializeDatabase();
801
+ }
802
+ /**
803
+ * Initializes database schema with tags-based offers
804
+ */
805
+ initializeDatabase() {
806
+ this.db.exec(`
807
+ -- WebRTC signaling offers with tags
808
+ CREATE TABLE IF NOT EXISTS offers (
809
+ id TEXT PRIMARY KEY,
810
+ username TEXT NOT NULL,
811
+ tags TEXT NOT NULL,
812
+ sdp TEXT NOT NULL,
813
+ created_at INTEGER NOT NULL,
814
+ expires_at INTEGER NOT NULL,
815
+ last_seen INTEGER NOT NULL,
816
+ answerer_username TEXT,
817
+ answer_sdp TEXT,
818
+ answered_at INTEGER
819
+ );
820
+
821
+ CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
822
+ CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
823
+ CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
824
+ CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
825
+
826
+ -- ICE candidates table
827
+ CREATE TABLE IF NOT EXISTS ice_candidates (
828
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
829
+ offer_id TEXT NOT NULL,
830
+ username TEXT NOT NULL,
831
+ role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
832
+ candidate TEXT NOT NULL,
833
+ created_at INTEGER NOT NULL,
834
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
835
+ );
836
+
837
+ CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
838
+ CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
839
+ CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
840
+
841
+ -- Credentials table (replaces usernames with simpler name + secret auth)
842
+ CREATE TABLE IF NOT EXISTS credentials (
843
+ name TEXT PRIMARY KEY,
844
+ secret TEXT NOT NULL UNIQUE,
845
+ created_at INTEGER NOT NULL,
846
+ expires_at INTEGER NOT NULL,
847
+ last_used INTEGER NOT NULL,
848
+ CHECK(length(name) >= 3 AND length(name) <= 32)
849
+ );
850
+
851
+ CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at);
852
+ CREATE INDEX IF NOT EXISTS idx_credentials_secret ON credentials(secret);
853
+
854
+ -- Rate limits table (for distributed rate limiting)
855
+ CREATE TABLE IF NOT EXISTS rate_limits (
856
+ identifier TEXT PRIMARY KEY,
857
+ count INTEGER NOT NULL,
858
+ reset_time INTEGER NOT NULL
859
+ );
860
+
861
+ CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time);
862
+
863
+ -- Nonces table (for replay attack prevention)
864
+ CREATE TABLE IF NOT EXISTS nonces (
865
+ nonce_key TEXT PRIMARY KEY,
866
+ expires_at INTEGER NOT NULL
867
+ );
868
+
869
+ CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at);
870
+ `);
871
+ this.db.pragma("foreign_keys = ON");
872
+ }
873
+ // ===== Offer Management =====
874
+ async createOffers(offers) {
875
+ const created = [];
876
+ const offersWithIds = await Promise.all(
877
+ offers.map(async (offer) => ({
878
+ ...offer,
879
+ id: offer.id || await generateOfferHash(offer.sdp)
880
+ }))
881
+ );
882
+ const transaction = this.db.transaction((offersWithIds2) => {
883
+ const offerStmt = this.db.prepare(`
884
+ INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
885
+ VALUES (?, ?, ?, ?, ?, ?, ?)
886
+ `);
887
+ for (const offer of offersWithIds2) {
888
+ const now = Date.now();
889
+ offerStmt.run(
890
+ offer.id,
891
+ offer.username,
892
+ JSON.stringify(offer.tags),
893
+ offer.sdp,
894
+ now,
895
+ offer.expiresAt,
896
+ now
897
+ );
898
+ created.push({
899
+ id: offer.id,
900
+ username: offer.username,
901
+ tags: offer.tags,
902
+ sdp: offer.sdp,
903
+ createdAt: now,
904
+ expiresAt: offer.expiresAt,
905
+ lastSeen: now
906
+ });
907
+ }
908
+ });
909
+ transaction(offersWithIds);
910
+ return created;
911
+ }
912
+ async getOffersByUsername(username) {
913
+ const stmt = this.db.prepare(`
914
+ SELECT * FROM offers
915
+ WHERE username = ? AND expires_at > ?
916
+ ORDER BY last_seen DESC
917
+ `);
918
+ const rows = stmt.all(username, Date.now());
919
+ return rows.map((row) => this.rowToOffer(row));
920
+ }
921
+ async getOfferById(offerId) {
922
+ const stmt = this.db.prepare(`
923
+ SELECT * FROM offers
924
+ WHERE id = ? AND expires_at > ?
925
+ `);
926
+ const row = stmt.get(offerId, Date.now());
927
+ if (!row) {
928
+ return null;
929
+ }
930
+ return this.rowToOffer(row);
931
+ }
932
+ async deleteOffer(offerId, ownerUsername) {
933
+ const stmt = this.db.prepare(`
934
+ DELETE FROM offers
935
+ WHERE id = ? AND username = ?
936
+ `);
937
+ const result = stmt.run(offerId, ownerUsername);
938
+ return result.changes > 0;
939
+ }
940
+ async deleteExpiredOffers(now) {
941
+ const stmt = this.db.prepare("DELETE FROM offers WHERE expires_at < ?");
942
+ const result = stmt.run(now);
943
+ return result.changes;
944
+ }
945
+ async answerOffer(offerId, answererUsername, answerSdp) {
946
+ const offer = await this.getOfferById(offerId);
947
+ if (!offer) {
948
+ return {
949
+ success: false,
950
+ error: "Offer not found or expired"
951
+ };
952
+ }
953
+ if (offer.answererUsername) {
954
+ return {
955
+ success: false,
956
+ error: "Offer already answered"
957
+ };
958
+ }
959
+ const stmt = this.db.prepare(`
960
+ UPDATE offers
961
+ SET answerer_username = ?, answer_sdp = ?, answered_at = ?
962
+ WHERE id = ? AND answerer_username IS NULL
963
+ `);
964
+ const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
965
+ if (result.changes === 0) {
966
+ return {
967
+ success: false,
968
+ error: "Offer already answered (race condition)"
969
+ };
970
+ }
971
+ return { success: true };
972
+ }
973
+ async getAnsweredOffers(offererUsername) {
974
+ const stmt = this.db.prepare(`
975
+ SELECT * FROM offers
976
+ WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
977
+ ORDER BY answered_at DESC
978
+ `);
979
+ const rows = stmt.all(offererUsername, Date.now());
980
+ return rows.map((row) => this.rowToOffer(row));
981
+ }
982
+ async getOffersAnsweredBy(answererUsername) {
983
+ const stmt = this.db.prepare(`
984
+ SELECT * FROM offers
985
+ WHERE answerer_username = ? AND expires_at > ?
986
+ ORDER BY answered_at DESC
987
+ `);
988
+ const rows = stmt.all(answererUsername, Date.now());
989
+ return rows.map((row) => this.rowToOffer(row));
990
+ }
991
+ // ===== Discovery =====
992
+ async discoverOffers(tags, excludeUsername, limit, offset) {
993
+ if (tags.length === 0) {
994
+ return [];
995
+ }
996
+ const placeholders = tags.map(() => "?").join(",");
997
+ let query = `
998
+ SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
999
+ WHERE t.value IN (${placeholders})
1000
+ AND o.expires_at > ?
1001
+ AND o.answerer_username IS NULL
1002
+ `;
1003
+ const params = [...tags, Date.now()];
1004
+ if (excludeUsername) {
1005
+ query += " AND o.username != ?";
1006
+ params.push(excludeUsername);
1007
+ }
1008
+ query += " ORDER BY o.created_at DESC LIMIT ? OFFSET ?";
1009
+ params.push(limit, offset);
1010
+ const stmt = this.db.prepare(query);
1011
+ const rows = stmt.all(...params);
1012
+ return rows.map((row) => this.rowToOffer(row));
1013
+ }
1014
+ async getRandomOffer(tags, excludeUsername) {
1015
+ if (tags.length === 0) {
1016
+ return null;
1017
+ }
1018
+ const placeholders = tags.map(() => "?").join(",");
1019
+ let query = `
1020
+ SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
1021
+ WHERE t.value IN (${placeholders})
1022
+ AND o.expires_at > ?
1023
+ AND o.answerer_username IS NULL
1024
+ `;
1025
+ const params = [...tags, Date.now()];
1026
+ if (excludeUsername) {
1027
+ query += " AND o.username != ?";
1028
+ params.push(excludeUsername);
1029
+ }
1030
+ query += " ORDER BY RANDOM() LIMIT 1";
1031
+ const stmt = this.db.prepare(query);
1032
+ const row = stmt.get(...params);
1033
+ return row ? this.rowToOffer(row) : null;
1034
+ }
1035
+ // ===== ICE Candidate Management =====
1036
+ async addIceCandidates(offerId, username, role, candidates) {
1037
+ const stmt = this.db.prepare(`
1038
+ INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
1039
+ VALUES (?, ?, ?, ?, ?)
1040
+ `);
1041
+ const baseTimestamp = Date.now();
1042
+ const transaction = this.db.transaction((candidates2) => {
1043
+ for (let i = 0; i < candidates2.length; i++) {
1044
+ stmt.run(
1045
+ offerId,
1046
+ username,
1047
+ role,
1048
+ JSON.stringify(candidates2[i]),
1049
+ baseTimestamp + i
1050
+ );
1051
+ }
1052
+ });
1053
+ transaction(candidates);
1054
+ return candidates.length;
1055
+ }
1056
+ async getIceCandidates(offerId, targetRole, since) {
1057
+ let query = `
1058
+ SELECT * FROM ice_candidates
1059
+ WHERE offer_id = ? AND role = ?
1060
+ `;
1061
+ const params = [offerId, targetRole];
1062
+ if (since !== void 0) {
1063
+ query += " AND created_at > ?";
1064
+ params.push(since);
1065
+ }
1066
+ query += " ORDER BY created_at ASC";
1067
+ const stmt = this.db.prepare(query);
1068
+ const rows = stmt.all(...params);
1069
+ return rows.map((row) => ({
1070
+ id: row.id,
1071
+ offerId: row.offer_id,
1072
+ username: row.username,
1073
+ role: row.role,
1074
+ candidate: JSON.parse(row.candidate),
1075
+ createdAt: row.created_at
1076
+ }));
1077
+ }
1078
+ async getIceCandidatesForMultipleOffers(offerIds, username, since) {
1079
+ const result = /* @__PURE__ */ new Map();
1080
+ if (offerIds.length === 0) {
1081
+ return result;
1082
+ }
1083
+ if (!Array.isArray(offerIds) || !offerIds.every((id) => typeof id === "string")) {
1084
+ throw new Error("Invalid offer IDs: must be array of strings");
1085
+ }
1086
+ if (offerIds.length > 1e3) {
1087
+ throw new Error("Too many offer IDs (max 1000)");
1088
+ }
1089
+ const placeholders = offerIds.map(() => "?").join(",");
1090
+ let query = `
1091
+ SELECT ic.*, o.username as offer_username
1092
+ FROM ice_candidates ic
1093
+ INNER JOIN offers o ON o.id = ic.offer_id
1094
+ WHERE ic.offer_id IN (${placeholders})
1095
+ AND (
1096
+ (o.username = ? AND ic.role = 'answerer')
1097
+ OR (o.answerer_username = ? AND ic.role = 'offerer')
1098
+ )
1099
+ `;
1100
+ const params = [...offerIds, username, username];
1101
+ if (since !== void 0) {
1102
+ query += " AND ic.created_at > ?";
1103
+ params.push(since);
1104
+ }
1105
+ query += " ORDER BY ic.created_at ASC";
1106
+ const stmt = this.db.prepare(query);
1107
+ const rows = stmt.all(...params);
1108
+ for (const row of rows) {
1109
+ const candidate = {
1110
+ id: row.id,
1111
+ offerId: row.offer_id,
1112
+ username: row.username,
1113
+ role: row.role,
1114
+ candidate: JSON.parse(row.candidate),
1115
+ createdAt: row.created_at
1116
+ };
1117
+ if (!result.has(row.offer_id)) {
1118
+ result.set(row.offer_id, []);
1119
+ }
1120
+ result.get(row.offer_id).push(candidate);
1121
+ }
1122
+ return result;
1123
+ }
1124
+ // ===== Credential Management =====
1125
+ async generateCredentials(request) {
1126
+ const now = Date.now();
1127
+ const expiresAt = request.expiresAt || now + YEAR_IN_MS2;
1128
+ const { generateCredentialName: generateCredentialName2, generateSecret: generateSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1129
+ let name;
1130
+ if (request.name) {
1131
+ const existing = this.db.prepare(`
1132
+ SELECT name FROM credentials WHERE name = ?
1133
+ `).get(request.name);
1134
+ if (existing) {
1135
+ throw new Error("Username already taken");
1136
+ }
1137
+ name = request.name;
1138
+ } else {
1139
+ let attempts = 0;
1140
+ const maxAttempts = 100;
1141
+ while (attempts < maxAttempts) {
1142
+ name = generateCredentialName2();
1143
+ const existing = this.db.prepare(`
1144
+ SELECT name FROM credentials WHERE name = ?
1145
+ `).get(name);
1146
+ if (!existing) {
1147
+ break;
1148
+ }
1149
+ attempts++;
1150
+ }
1151
+ if (attempts >= maxAttempts) {
1152
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
1153
+ }
1154
+ }
1155
+ const secret = generateSecret2();
1156
+ const { encryptSecret: encryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1157
+ const encryptedSecret = await encryptSecret2(secret, this.masterEncryptionKey);
1158
+ const stmt = this.db.prepare(`
1159
+ INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
1160
+ VALUES (?, ?, ?, ?, ?)
1161
+ `);
1162
+ stmt.run(name, encryptedSecret, now, expiresAt, now);
1163
+ return {
1164
+ name,
1165
+ secret,
1166
+ // Return plaintext secret, not encrypted
1167
+ createdAt: now,
1168
+ expiresAt,
1169
+ lastUsed: now
1170
+ };
1171
+ }
1172
+ async getCredential(name) {
1173
+ const stmt = this.db.prepare(`
1174
+ SELECT * FROM credentials
1175
+ WHERE name = ? AND expires_at > ?
1176
+ `);
1177
+ const row = stmt.get(name, Date.now());
1178
+ if (!row) {
1179
+ return null;
1180
+ }
1181
+ try {
1182
+ const { decryptSecret: decryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1183
+ const decryptedSecret = await decryptSecret2(row.secret, this.masterEncryptionKey);
1184
+ return {
1185
+ name: row.name,
1186
+ secret: decryptedSecret,
1187
+ // Return decrypted secret
1188
+ createdAt: row.created_at,
1189
+ expiresAt: row.expires_at,
1190
+ lastUsed: row.last_used
1191
+ };
1192
+ } catch (error) {
1193
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
1194
+ return null;
1195
+ }
1196
+ }
1197
+ async updateCredentialUsage(name, lastUsed, expiresAt) {
1198
+ const stmt = this.db.prepare(`
1199
+ UPDATE credentials
1200
+ SET last_used = ?, expires_at = ?
1201
+ WHERE name = ?
1202
+ `);
1203
+ stmt.run(lastUsed, expiresAt, name);
1204
+ }
1205
+ async deleteExpiredCredentials(now) {
1206
+ const stmt = this.db.prepare("DELETE FROM credentials WHERE expires_at < ?");
1207
+ const result = stmt.run(now);
1208
+ return result.changes;
1209
+ }
1210
+ // ===== Rate Limiting =====
1211
+ async checkRateLimit(identifier, limit, windowMs) {
1212
+ const now = Date.now();
1213
+ const resetTime = now + windowMs;
1214
+ const result = this.db.prepare(`
1215
+ INSERT INTO rate_limits (identifier, count, reset_time)
1216
+ VALUES (?, 1, ?)
1217
+ ON CONFLICT(identifier) DO UPDATE SET
1218
+ count = CASE
1219
+ WHEN reset_time < ? THEN 1
1220
+ ELSE count + 1
1221
+ END,
1222
+ reset_time = CASE
1223
+ WHEN reset_time < ? THEN ?
1224
+ ELSE reset_time
1225
+ END
1226
+ RETURNING count
1227
+ `).get(identifier, resetTime, now, now, resetTime);
1228
+ return result.count <= limit;
1229
+ }
1230
+ async deleteExpiredRateLimits(now) {
1231
+ const stmt = this.db.prepare("DELETE FROM rate_limits WHERE reset_time < ?");
1232
+ const result = stmt.run(now);
1233
+ return result.changes;
1234
+ }
1235
+ // ===== Nonce Tracking (Replay Protection) =====
1236
+ async checkAndMarkNonce(nonceKey, expiresAt) {
1237
+ try {
1238
+ const stmt = this.db.prepare(`
1239
+ INSERT INTO nonces (nonce_key, expires_at)
1240
+ VALUES (?, ?)
1241
+ `);
1242
+ stmt.run(nonceKey, expiresAt);
1243
+ return true;
1244
+ } catch (error) {
1245
+ if (error?.code === "SQLITE_CONSTRAINT") {
1246
+ return false;
1247
+ }
1248
+ throw error;
1249
+ }
1250
+ }
1251
+ async deleteExpiredNonces(now) {
1252
+ const stmt = this.db.prepare("DELETE FROM nonces WHERE expires_at < ?");
1253
+ const result = stmt.run(now);
1254
+ return result.changes;
1255
+ }
1256
+ async close() {
1257
+ this.db.close();
1258
+ }
1259
+ // ===== Count Methods (for resource limits) =====
1260
+ async getOfferCount() {
1261
+ const result = this.db.prepare("SELECT COUNT(*) as count FROM offers").get();
1262
+ return result.count;
1263
+ }
1264
+ async getOfferCountByUsername(username) {
1265
+ const result = this.db.prepare("SELECT COUNT(*) as count FROM offers WHERE username = ?").get(username);
1266
+ return result.count;
1267
+ }
1268
+ async getCredentialCount() {
1269
+ const result = this.db.prepare("SELECT COUNT(*) as count FROM credentials").get();
1270
+ return result.count;
1271
+ }
1272
+ async getIceCandidateCount(offerId) {
1273
+ const result = this.db.prepare("SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?").get(offerId);
1274
+ return result.count;
1275
+ }
1276
+ // ===== Helper Methods =====
1277
+ /**
1278
+ * Helper method to convert database row to Offer object
1279
+ */
1280
+ rowToOffer(row) {
1281
+ return {
1282
+ id: row.id,
1283
+ username: row.username,
1284
+ tags: JSON.parse(row.tags),
1285
+ sdp: row.sdp,
1286
+ createdAt: row.created_at,
1287
+ expiresAt: row.expires_at,
1288
+ lastSeen: row.last_seen,
1289
+ answererUsername: row.answerer_username || void 0,
1290
+ answerSdp: row.answer_sdp || void 0,
1291
+ answeredAt: row.answered_at || void 0
1292
+ };
1293
+ }
1294
+ };
1295
+ }
1296
+ });
1297
+
1298
+ // src/storage/mysql.ts
1299
+ var mysql_exports = {};
1300
+ __export(mysql_exports, {
1301
+ MySQLStorage: () => MySQLStorage
1302
+ });
1303
+ var import_promise, YEAR_IN_MS3, MySQLStorage;
1304
+ var init_mysql = __esm({
1305
+ "src/storage/mysql.ts"() {
1306
+ "use strict";
1307
+ import_promise = __toESM(require("mysql2/promise"));
1308
+ init_hash_id();
1309
+ YEAR_IN_MS3 = 365 * 24 * 60 * 60 * 1e3;
1310
+ MySQLStorage = class _MySQLStorage {
1311
+ constructor(pool, masterEncryptionKey) {
1312
+ this.pool = pool;
1313
+ this.masterEncryptionKey = masterEncryptionKey;
1314
+ }
1315
+ /**
1316
+ * Creates a new MySQL storage instance with connection pooling
1317
+ * @param connectionString MySQL connection URL
1318
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets
1319
+ * @param poolSize Maximum number of connections in the pool
1320
+ */
1321
+ static async create(connectionString, masterEncryptionKey, poolSize = 10) {
1322
+ const pool = import_promise.default.createPool({
1323
+ uri: connectionString,
1324
+ waitForConnections: true,
1325
+ connectionLimit: poolSize,
1326
+ queueLimit: 0,
1327
+ enableKeepAlive: true,
1328
+ keepAliveInitialDelay: 1e4
1329
+ });
1330
+ const storage = new _MySQLStorage(pool, masterEncryptionKey);
1331
+ await storage.initializeDatabase();
1332
+ return storage;
1333
+ }
1334
+ async initializeDatabase() {
1335
+ const conn = await this.pool.getConnection();
1336
+ try {
1337
+ await conn.query(`
1338
+ CREATE TABLE IF NOT EXISTS offers (
1339
+ id VARCHAR(64) PRIMARY KEY,
1340
+ username VARCHAR(32) NOT NULL,
1341
+ tags JSON NOT NULL,
1342
+ sdp MEDIUMTEXT NOT NULL,
1343
+ created_at BIGINT NOT NULL,
1344
+ expires_at BIGINT NOT NULL,
1345
+ last_seen BIGINT NOT NULL,
1346
+ answerer_username VARCHAR(32),
1347
+ answer_sdp MEDIUMTEXT,
1348
+ answered_at BIGINT,
1349
+ INDEX idx_offers_username (username),
1350
+ INDEX idx_offers_expires (expires_at),
1351
+ INDEX idx_offers_last_seen (last_seen),
1352
+ INDEX idx_offers_answerer (answerer_username)
1353
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1354
+ `);
1355
+ await conn.query(`
1356
+ CREATE TABLE IF NOT EXISTS ice_candidates (
1357
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
1358
+ offer_id VARCHAR(64) NOT NULL,
1359
+ username VARCHAR(32) NOT NULL,
1360
+ role ENUM('offerer', 'answerer') NOT NULL,
1361
+ candidate JSON NOT NULL,
1362
+ created_at BIGINT NOT NULL,
1363
+ INDEX idx_ice_offer (offer_id),
1364
+ INDEX idx_ice_username (username),
1365
+ INDEX idx_ice_created (created_at),
1366
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
1367
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1368
+ `);
1369
+ await conn.query(`
1370
+ CREATE TABLE IF NOT EXISTS credentials (
1371
+ name VARCHAR(32) PRIMARY KEY,
1372
+ secret VARCHAR(512) NOT NULL UNIQUE,
1373
+ created_at BIGINT NOT NULL,
1374
+ expires_at BIGINT NOT NULL,
1375
+ last_used BIGINT NOT NULL,
1376
+ INDEX idx_credentials_expires (expires_at)
1377
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1378
+ `);
1379
+ await conn.query(`
1380
+ CREATE TABLE IF NOT EXISTS rate_limits (
1381
+ identifier VARCHAR(255) PRIMARY KEY,
1382
+ count INT NOT NULL,
1383
+ reset_time BIGINT NOT NULL,
1384
+ INDEX idx_rate_limits_reset (reset_time)
1385
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1386
+ `);
1387
+ await conn.query(`
1388
+ CREATE TABLE IF NOT EXISTS nonces (
1389
+ nonce_key VARCHAR(255) PRIMARY KEY,
1390
+ expires_at BIGINT NOT NULL,
1391
+ INDEX idx_nonces_expires (expires_at)
1392
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1393
+ `);
1394
+ } finally {
1395
+ conn.release();
1396
+ }
1397
+ }
1398
+ // ===== Offer Management =====
1399
+ async createOffers(offers) {
1400
+ if (offers.length === 0) return [];
1401
+ const created = [];
1402
+ const now = Date.now();
1403
+ const conn = await this.pool.getConnection();
1404
+ try {
1405
+ await conn.beginTransaction();
1406
+ for (const request of offers) {
1407
+ const id = request.id || await generateOfferHash(request.sdp);
1408
+ await conn.query(
1409
+ `INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
1410
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1411
+ [id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
1412
+ );
1413
+ created.push({
1414
+ id,
1415
+ username: request.username,
1416
+ tags: request.tags,
1417
+ sdp: request.sdp,
1418
+ createdAt: now,
1419
+ expiresAt: request.expiresAt,
1420
+ lastSeen: now
1421
+ });
1422
+ }
1423
+ await conn.commit();
1424
+ } catch (error) {
1425
+ await conn.rollback();
1426
+ throw error;
1427
+ } finally {
1428
+ conn.release();
1429
+ }
1430
+ return created;
1431
+ }
1432
+ async getOffersByUsername(username) {
1433
+ const [rows] = await this.pool.query(
1434
+ `SELECT * FROM offers WHERE username = ? AND expires_at > ? ORDER BY last_seen DESC`,
1435
+ [username, Date.now()]
1436
+ );
1437
+ return rows.map((row) => this.rowToOffer(row));
1438
+ }
1439
+ async getOfferById(offerId) {
1440
+ const [rows] = await this.pool.query(
1441
+ `SELECT * FROM offers WHERE id = ? AND expires_at > ?`,
1442
+ [offerId, Date.now()]
1443
+ );
1444
+ return rows.length > 0 ? this.rowToOffer(rows[0]) : null;
1445
+ }
1446
+ async deleteOffer(offerId, ownerUsername) {
1447
+ const [result] = await this.pool.query(
1448
+ `DELETE FROM offers WHERE id = ? AND username = ?`,
1449
+ [offerId, ownerUsername]
1450
+ );
1451
+ return result.affectedRows > 0;
1452
+ }
1453
+ async deleteExpiredOffers(now) {
1454
+ const [result] = await this.pool.query(
1455
+ `DELETE FROM offers WHERE expires_at < ?`,
1456
+ [now]
1457
+ );
1458
+ return result.affectedRows;
1459
+ }
1460
+ async answerOffer(offerId, answererUsername, answerSdp) {
1461
+ const offer = await this.getOfferById(offerId);
1462
+ if (!offer) {
1463
+ return { success: false, error: "Offer not found or expired" };
1464
+ }
1465
+ if (offer.answererUsername) {
1466
+ return { success: false, error: "Offer already answered" };
1467
+ }
1468
+ const [result] = await this.pool.query(
1469
+ `UPDATE offers SET answerer_username = ?, answer_sdp = ?, answered_at = ?
1470
+ WHERE id = ? AND answerer_username IS NULL`,
1471
+ [answererUsername, answerSdp, Date.now(), offerId]
1472
+ );
1473
+ if (result.affectedRows === 0) {
1474
+ return { success: false, error: "Offer already answered (race condition)" };
1475
+ }
1476
+ return { success: true };
1477
+ }
1478
+ async getAnsweredOffers(offererUsername) {
1479
+ const [rows] = await this.pool.query(
1480
+ `SELECT * FROM offers
1481
+ WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
1482
+ ORDER BY answered_at DESC`,
1483
+ [offererUsername, Date.now()]
1484
+ );
1485
+ return rows.map((row) => this.rowToOffer(row));
1486
+ }
1487
+ async getOffersAnsweredBy(answererUsername) {
1488
+ const [rows] = await this.pool.query(
1489
+ `SELECT * FROM offers
1490
+ WHERE answerer_username = ? AND expires_at > ?
1491
+ ORDER BY answered_at DESC`,
1492
+ [answererUsername, Date.now()]
1493
+ );
1494
+ return rows.map((row) => this.rowToOffer(row));
1495
+ }
1496
+ // ===== Discovery =====
1497
+ async discoverOffers(tags, excludeUsername, limit, offset) {
1498
+ if (tags.length === 0) return [];
1499
+ const tagArray = JSON.stringify(tags);
1500
+ let query = `
1501
+ SELECT DISTINCT o.* FROM offers o
1502
+ WHERE JSON_OVERLAPS(o.tags, ?)
1503
+ AND o.expires_at > ?
1504
+ AND o.answerer_username IS NULL
1505
+ `;
1506
+ const params = [tagArray, Date.now()];
1507
+ if (excludeUsername) {
1508
+ query += " AND o.username != ?";
1509
+ params.push(excludeUsername);
1510
+ }
1511
+ query += " ORDER BY o.created_at DESC LIMIT ? OFFSET ?";
1512
+ params.push(limit, offset);
1513
+ const [rows] = await this.pool.query(query, params);
1514
+ return rows.map((row) => this.rowToOffer(row));
1515
+ }
1516
+ async getRandomOffer(tags, excludeUsername) {
1517
+ if (tags.length === 0) return null;
1518
+ const tagArray = JSON.stringify(tags);
1519
+ let query = `
1520
+ SELECT DISTINCT o.* FROM offers o
1521
+ WHERE JSON_OVERLAPS(o.tags, ?)
1522
+ AND o.expires_at > ?
1523
+ AND o.answerer_username IS NULL
1524
+ `;
1525
+ const params = [tagArray, Date.now()];
1526
+ if (excludeUsername) {
1527
+ query += " AND o.username != ?";
1528
+ params.push(excludeUsername);
1529
+ }
1530
+ query += " ORDER BY RAND() LIMIT 1";
1531
+ const [rows] = await this.pool.query(query, params);
1532
+ return rows.length > 0 ? this.rowToOffer(rows[0]) : null;
1533
+ }
1534
+ // ===== ICE Candidate Management =====
1535
+ async addIceCandidates(offerId, username, role, candidates) {
1536
+ if (candidates.length === 0) return 0;
1537
+ const baseTimestamp = Date.now();
1538
+ const values = candidates.map((c, i) => [
1539
+ offerId,
1540
+ username,
1541
+ role,
1542
+ JSON.stringify(c),
1543
+ baseTimestamp + i
1544
+ ]);
1545
+ await this.pool.query(
1546
+ `INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
1547
+ VALUES ?`,
1548
+ [values]
1549
+ );
1550
+ return candidates.length;
1551
+ }
1552
+ async getIceCandidates(offerId, targetRole, since) {
1553
+ let query = `SELECT * FROM ice_candidates WHERE offer_id = ? AND role = ?`;
1554
+ const params = [offerId, targetRole];
1555
+ if (since !== void 0) {
1556
+ query += " AND created_at > ?";
1557
+ params.push(since);
1558
+ }
1559
+ query += " ORDER BY created_at ASC";
1560
+ const [rows] = await this.pool.query(query, params);
1561
+ return rows.map((row) => this.rowToIceCandidate(row));
1562
+ }
1563
+ async getIceCandidatesForMultipleOffers(offerIds, username, since) {
1564
+ const result = /* @__PURE__ */ new Map();
1565
+ if (offerIds.length === 0) return result;
1566
+ if (offerIds.length > 1e3) {
1567
+ throw new Error("Too many offer IDs (max 1000)");
1568
+ }
1569
+ const placeholders = offerIds.map(() => "?").join(",");
1570
+ let query = `
1571
+ SELECT ic.*, o.username as offer_username
1572
+ FROM ice_candidates ic
1573
+ INNER JOIN offers o ON o.id = ic.offer_id
1574
+ WHERE ic.offer_id IN (${placeholders})
1575
+ AND (
1576
+ (o.username = ? AND ic.role = 'answerer')
1577
+ OR (o.answerer_username = ? AND ic.role = 'offerer')
1578
+ )
1579
+ `;
1580
+ const params = [...offerIds, username, username];
1581
+ if (since !== void 0) {
1582
+ query += " AND ic.created_at > ?";
1583
+ params.push(since);
1584
+ }
1585
+ query += " ORDER BY ic.created_at ASC";
1586
+ const [rows] = await this.pool.query(query, params);
1587
+ for (const row of rows) {
1588
+ const candidate = this.rowToIceCandidate(row);
1589
+ if (!result.has(row.offer_id)) {
1590
+ result.set(row.offer_id, []);
1591
+ }
1592
+ result.get(row.offer_id).push(candidate);
1593
+ }
1594
+ return result;
1595
+ }
1596
+ // ===== Credential Management =====
1597
+ async generateCredentials(request) {
1598
+ const now = Date.now();
1599
+ const expiresAt = request.expiresAt || now + YEAR_IN_MS3;
1600
+ const { generateCredentialName: generateCredentialName2, generateSecret: generateSecret2, encryptSecret: encryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1601
+ let name;
1602
+ if (request.name) {
1603
+ const [existing] = await this.pool.query(
1604
+ `SELECT name FROM credentials WHERE name = ?`,
1605
+ [request.name]
1606
+ );
1607
+ if (existing.length > 0) {
1608
+ throw new Error("Username already taken");
1609
+ }
1610
+ name = request.name;
1611
+ } else {
1612
+ let attempts = 0;
1613
+ const maxAttempts = 100;
1614
+ while (attempts < maxAttempts) {
1615
+ name = generateCredentialName2();
1616
+ const [existing] = await this.pool.query(
1617
+ `SELECT name FROM credentials WHERE name = ?`,
1618
+ [name]
1619
+ );
1620
+ if (existing.length === 0) break;
1621
+ attempts++;
1622
+ }
1623
+ if (attempts >= maxAttempts) {
1624
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
1625
+ }
1626
+ }
1627
+ const secret = generateSecret2();
1628
+ const encryptedSecret = await encryptSecret2(secret, this.masterEncryptionKey);
1629
+ await this.pool.query(
1630
+ `INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
1631
+ VALUES (?, ?, ?, ?, ?)`,
1632
+ [name, encryptedSecret, now, expiresAt, now]
1633
+ );
1634
+ return {
1635
+ name,
1636
+ secret,
1637
+ createdAt: now,
1638
+ expiresAt,
1639
+ lastUsed: now
1640
+ };
1641
+ }
1642
+ async getCredential(name) {
1643
+ const [rows] = await this.pool.query(
1644
+ `SELECT * FROM credentials WHERE name = ? AND expires_at > ?`,
1645
+ [name, Date.now()]
1646
+ );
1647
+ if (rows.length === 0) return null;
1648
+ try {
1649
+ const { decryptSecret: decryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1650
+ const decryptedSecret = await decryptSecret2(rows[0].secret, this.masterEncryptionKey);
1651
+ return {
1652
+ name: rows[0].name,
1653
+ secret: decryptedSecret,
1654
+ createdAt: Number(rows[0].created_at),
1655
+ expiresAt: Number(rows[0].expires_at),
1656
+ lastUsed: Number(rows[0].last_used)
1657
+ };
1658
+ } catch (error) {
1659
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
1660
+ return null;
1661
+ }
1662
+ }
1663
+ async updateCredentialUsage(name, lastUsed, expiresAt) {
1664
+ await this.pool.query(
1665
+ `UPDATE credentials SET last_used = ?, expires_at = ? WHERE name = ?`,
1666
+ [lastUsed, expiresAt, name]
1667
+ );
1668
+ }
1669
+ async deleteExpiredCredentials(now) {
1670
+ const [result] = await this.pool.query(
1671
+ `DELETE FROM credentials WHERE expires_at < ?`,
1672
+ [now]
1673
+ );
1674
+ return result.affectedRows;
1675
+ }
1676
+ // ===== Rate Limiting =====
1677
+ async checkRateLimit(identifier, limit, windowMs) {
1678
+ const now = Date.now();
1679
+ const resetTime = now + windowMs;
1680
+ await this.pool.query(
1681
+ `INSERT INTO rate_limits (identifier, count, reset_time)
1682
+ VALUES (?, 1, ?)
1683
+ ON DUPLICATE KEY UPDATE
1684
+ count = IF(reset_time < ?, 1, count + 1),
1685
+ reset_time = IF(reset_time < ?, ?, reset_time)`,
1686
+ [identifier, resetTime, now, now, resetTime]
1687
+ );
1688
+ const [rows] = await this.pool.query(
1689
+ `SELECT count FROM rate_limits WHERE identifier = ?`,
1690
+ [identifier]
1691
+ );
1692
+ return rows.length > 0 && rows[0].count <= limit;
1693
+ }
1694
+ async deleteExpiredRateLimits(now) {
1695
+ const [result] = await this.pool.query(
1696
+ `DELETE FROM rate_limits WHERE reset_time < ?`,
1697
+ [now]
1698
+ );
1699
+ return result.affectedRows;
1700
+ }
1701
+ // ===== Nonce Tracking (Replay Protection) =====
1702
+ async checkAndMarkNonce(nonceKey, expiresAt) {
1703
+ try {
1704
+ await this.pool.query(
1705
+ `INSERT INTO nonces (nonce_key, expires_at) VALUES (?, ?)`,
1706
+ [nonceKey, expiresAt]
1707
+ );
1708
+ return true;
1709
+ } catch (error) {
1710
+ if (error.code === "ER_DUP_ENTRY") {
1711
+ return false;
1712
+ }
1713
+ throw error;
1714
+ }
1715
+ }
1716
+ async deleteExpiredNonces(now) {
1717
+ const [result] = await this.pool.query(
1718
+ `DELETE FROM nonces WHERE expires_at < ?`,
1719
+ [now]
1720
+ );
1721
+ return result.affectedRows;
1722
+ }
1723
+ async close() {
1724
+ await this.pool.end();
1725
+ }
1726
+ // ===== Count Methods (for resource limits) =====
1727
+ async getOfferCount() {
1728
+ const [rows] = await this.pool.query("SELECT COUNT(*) as count FROM offers");
1729
+ return Number(rows[0].count);
1730
+ }
1731
+ async getOfferCountByUsername(username) {
1732
+ const [rows] = await this.pool.query(
1733
+ "SELECT COUNT(*) as count FROM offers WHERE username = ?",
1734
+ [username]
1735
+ );
1736
+ return Number(rows[0].count);
1737
+ }
1738
+ async getCredentialCount() {
1739
+ const [rows] = await this.pool.query("SELECT COUNT(*) as count FROM credentials");
1740
+ return Number(rows[0].count);
1741
+ }
1742
+ async getIceCandidateCount(offerId) {
1743
+ const [rows] = await this.pool.query(
1744
+ "SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = ?",
1745
+ [offerId]
1746
+ );
1747
+ return Number(rows[0].count);
1748
+ }
1749
+ // ===== Helper Methods =====
1750
+ rowToOffer(row) {
1751
+ return {
1752
+ id: row.id,
1753
+ username: row.username,
1754
+ tags: typeof row.tags === "string" ? JSON.parse(row.tags) : row.tags,
1755
+ sdp: row.sdp,
1756
+ createdAt: Number(row.created_at),
1757
+ expiresAt: Number(row.expires_at),
1758
+ lastSeen: Number(row.last_seen),
1759
+ answererUsername: row.answerer_username || void 0,
1760
+ answerSdp: row.answer_sdp || void 0,
1761
+ answeredAt: row.answered_at ? Number(row.answered_at) : void 0
1762
+ };
1763
+ }
1764
+ rowToIceCandidate(row) {
1765
+ return {
1766
+ id: Number(row.id),
1767
+ offerId: row.offer_id,
1768
+ username: row.username,
1769
+ role: row.role,
1770
+ candidate: typeof row.candidate === "string" ? JSON.parse(row.candidate) : row.candidate,
1771
+ createdAt: Number(row.created_at)
1772
+ };
1773
+ }
1774
+ };
1775
+ }
1776
+ });
1777
+
1778
+ // src/storage/postgres.ts
1779
+ var postgres_exports = {};
1780
+ __export(postgres_exports, {
1781
+ PostgreSQLStorage: () => PostgreSQLStorage
1782
+ });
1783
+ var import_pg, YEAR_IN_MS4, PostgreSQLStorage;
1784
+ var init_postgres = __esm({
1785
+ "src/storage/postgres.ts"() {
1786
+ "use strict";
1787
+ import_pg = require("pg");
1788
+ init_hash_id();
1789
+ YEAR_IN_MS4 = 365 * 24 * 60 * 60 * 1e3;
1790
+ PostgreSQLStorage = class _PostgreSQLStorage {
1791
+ constructor(pool, masterEncryptionKey) {
1792
+ this.pool = pool;
1793
+ this.masterEncryptionKey = masterEncryptionKey;
1794
+ }
1795
+ /**
1796
+ * Creates a new PostgreSQL storage instance with connection pooling
1797
+ * @param connectionString PostgreSQL connection URL
1798
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets
1799
+ * @param poolSize Maximum number of connections in the pool
1800
+ */
1801
+ static async create(connectionString, masterEncryptionKey, poolSize = 10) {
1802
+ const pool = new import_pg.Pool({
1803
+ connectionString,
1804
+ max: poolSize,
1805
+ idleTimeoutMillis: 3e4,
1806
+ connectionTimeoutMillis: 5e3
1807
+ });
1808
+ const storage = new _PostgreSQLStorage(pool, masterEncryptionKey);
1809
+ await storage.initializeDatabase();
1810
+ return storage;
1811
+ }
1812
+ async initializeDatabase() {
1813
+ const client = await this.pool.connect();
1814
+ try {
1815
+ await client.query(`
1816
+ CREATE TABLE IF NOT EXISTS offers (
1817
+ id VARCHAR(64) PRIMARY KEY,
1818
+ username VARCHAR(32) NOT NULL,
1819
+ tags JSONB NOT NULL,
1820
+ sdp TEXT NOT NULL,
1821
+ created_at BIGINT NOT NULL,
1822
+ expires_at BIGINT NOT NULL,
1823
+ last_seen BIGINT NOT NULL,
1824
+ answerer_username VARCHAR(32),
1825
+ answer_sdp TEXT,
1826
+ answered_at BIGINT
1827
+ )
1828
+ `);
1829
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username)`);
1830
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at)`);
1831
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen)`);
1832
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username)`);
1833
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_tags ON offers USING GIN(tags)`);
1834
+ await client.query(`
1835
+ CREATE TABLE IF NOT EXISTS ice_candidates (
1836
+ id BIGSERIAL PRIMARY KEY,
1837
+ offer_id VARCHAR(64) NOT NULL REFERENCES offers(id) ON DELETE CASCADE,
1838
+ username VARCHAR(32) NOT NULL,
1839
+ role VARCHAR(8) NOT NULL CHECK (role IN ('offerer', 'answerer')),
1840
+ candidate JSONB NOT NULL,
1841
+ created_at BIGINT NOT NULL
1842
+ )
1843
+ `);
1844
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id)`);
1845
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username)`);
1846
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at)`);
1847
+ await client.query(`
1848
+ CREATE TABLE IF NOT EXISTS credentials (
1849
+ name VARCHAR(32) PRIMARY KEY,
1850
+ secret VARCHAR(512) NOT NULL UNIQUE,
1851
+ created_at BIGINT NOT NULL,
1852
+ expires_at BIGINT NOT NULL,
1853
+ last_used BIGINT NOT NULL
1854
+ )
1855
+ `);
1856
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at)`);
1857
+ await client.query(`
1858
+ CREATE TABLE IF NOT EXISTS rate_limits (
1859
+ identifier VARCHAR(255) PRIMARY KEY,
1860
+ count INT NOT NULL,
1861
+ reset_time BIGINT NOT NULL
1862
+ )
1863
+ `);
1864
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time)`);
1865
+ await client.query(`
1866
+ CREATE TABLE IF NOT EXISTS nonces (
1867
+ nonce_key VARCHAR(255) PRIMARY KEY,
1868
+ expires_at BIGINT NOT NULL
1869
+ )
1870
+ `);
1871
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at)`);
1872
+ } finally {
1873
+ client.release();
1874
+ }
1875
+ }
1876
+ // ===== Offer Management =====
1877
+ async createOffers(offers) {
1878
+ if (offers.length === 0) return [];
1879
+ const created = [];
1880
+ const now = Date.now();
1881
+ const client = await this.pool.connect();
1882
+ try {
1883
+ await client.query("BEGIN");
1884
+ for (const request of offers) {
1885
+ const id = request.id || await generateOfferHash(request.sdp);
1886
+ await client.query(
1887
+ `INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
1888
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
1889
+ [id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
1890
+ );
1891
+ created.push({
1892
+ id,
1893
+ username: request.username,
1894
+ tags: request.tags,
1895
+ sdp: request.sdp,
1896
+ createdAt: now,
1897
+ expiresAt: request.expiresAt,
1898
+ lastSeen: now
1899
+ });
1900
+ }
1901
+ await client.query("COMMIT");
1902
+ } catch (error) {
1903
+ await client.query("ROLLBACK");
1904
+ throw error;
1905
+ } finally {
1906
+ client.release();
1907
+ }
1908
+ return created;
1909
+ }
1910
+ async getOffersByUsername(username) {
1911
+ const result = await this.pool.query(
1912
+ `SELECT * FROM offers WHERE username = $1 AND expires_at > $2 ORDER BY last_seen DESC`,
1913
+ [username, Date.now()]
1914
+ );
1915
+ return result.rows.map((row) => this.rowToOffer(row));
1916
+ }
1917
+ async getOfferById(offerId) {
1918
+ const result = await this.pool.query(
1919
+ `SELECT * FROM offers WHERE id = $1 AND expires_at > $2`,
1920
+ [offerId, Date.now()]
1921
+ );
1922
+ return result.rows.length > 0 ? this.rowToOffer(result.rows[0]) : null;
1923
+ }
1924
+ async deleteOffer(offerId, ownerUsername) {
1925
+ const result = await this.pool.query(
1926
+ `DELETE FROM offers WHERE id = $1 AND username = $2`,
1927
+ [offerId, ownerUsername]
1928
+ );
1929
+ return (result.rowCount ?? 0) > 0;
1930
+ }
1931
+ async deleteExpiredOffers(now) {
1932
+ const result = await this.pool.query(
1933
+ `DELETE FROM offers WHERE expires_at < $1`,
1934
+ [now]
1935
+ );
1936
+ return result.rowCount ?? 0;
1937
+ }
1938
+ async answerOffer(offerId, answererUsername, answerSdp) {
1939
+ const offer = await this.getOfferById(offerId);
1940
+ if (!offer) {
1941
+ return { success: false, error: "Offer not found or expired" };
1942
+ }
1943
+ if (offer.answererUsername) {
1944
+ return { success: false, error: "Offer already answered" };
1945
+ }
1946
+ const result = await this.pool.query(
1947
+ `UPDATE offers SET answerer_username = $1, answer_sdp = $2, answered_at = $3
1948
+ WHERE id = $4 AND answerer_username IS NULL`,
1949
+ [answererUsername, answerSdp, Date.now(), offerId]
1950
+ );
1951
+ if ((result.rowCount ?? 0) === 0) {
1952
+ return { success: false, error: "Offer already answered (race condition)" };
1953
+ }
1954
+ return { success: true };
1955
+ }
1956
+ async getAnsweredOffers(offererUsername) {
1957
+ const result = await this.pool.query(
1958
+ `SELECT * FROM offers
1959
+ WHERE username = $1 AND answerer_username IS NOT NULL AND expires_at > $2
1960
+ ORDER BY answered_at DESC`,
1961
+ [offererUsername, Date.now()]
1962
+ );
1963
+ return result.rows.map((row) => this.rowToOffer(row));
1964
+ }
1965
+ async getOffersAnsweredBy(answererUsername) {
1966
+ const result = await this.pool.query(
1967
+ `SELECT * FROM offers
1968
+ WHERE answerer_username = $1 AND expires_at > $2
1969
+ ORDER BY answered_at DESC`,
1970
+ [answererUsername, Date.now()]
1971
+ );
1972
+ return result.rows.map((row) => this.rowToOffer(row));
1973
+ }
1974
+ // ===== Discovery =====
1975
+ async discoverOffers(tags, excludeUsername, limit, offset) {
1976
+ if (tags.length === 0) return [];
1977
+ let query = `
1978
+ SELECT DISTINCT o.* FROM offers o
1979
+ WHERE o.tags ?| $1
1980
+ AND o.expires_at > $2
1981
+ AND o.answerer_username IS NULL
1982
+ `;
1983
+ const params = [tags, Date.now()];
1984
+ let paramIndex = 3;
1985
+ if (excludeUsername) {
1986
+ query += ` AND o.username != $${paramIndex}`;
1987
+ params.push(excludeUsername);
1988
+ paramIndex++;
1989
+ }
1990
+ query += ` ORDER BY o.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
1991
+ params.push(limit, offset);
1992
+ const result = await this.pool.query(query, params);
1993
+ return result.rows.map((row) => this.rowToOffer(row));
1994
+ }
1995
+ async getRandomOffer(tags, excludeUsername) {
1996
+ if (tags.length === 0) return null;
1997
+ let query = `
1998
+ SELECT DISTINCT o.* FROM offers o
1999
+ WHERE o.tags ?| $1
2000
+ AND o.expires_at > $2
2001
+ AND o.answerer_username IS NULL
2002
+ `;
2003
+ const params = [tags, Date.now()];
2004
+ let paramIndex = 3;
2005
+ if (excludeUsername) {
2006
+ query += ` AND o.username != $${paramIndex}`;
2007
+ params.push(excludeUsername);
2008
+ }
2009
+ query += " ORDER BY RANDOM() LIMIT 1";
2010
+ const result = await this.pool.query(query, params);
2011
+ return result.rows.length > 0 ? this.rowToOffer(result.rows[0]) : null;
2012
+ }
2013
+ // ===== ICE Candidate Management =====
2014
+ async addIceCandidates(offerId, username, role, candidates) {
2015
+ if (candidates.length === 0) return 0;
2016
+ const baseTimestamp = Date.now();
2017
+ const client = await this.pool.connect();
2018
+ try {
2019
+ await client.query("BEGIN");
2020
+ for (let i = 0; i < candidates.length; i++) {
2021
+ await client.query(
2022
+ `INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
2023
+ VALUES ($1, $2, $3, $4, $5)`,
2024
+ [offerId, username, role, JSON.stringify(candidates[i]), baseTimestamp + i]
2025
+ );
2026
+ }
2027
+ await client.query("COMMIT");
2028
+ } catch (error) {
2029
+ await client.query("ROLLBACK");
2030
+ throw error;
2031
+ } finally {
2032
+ client.release();
2033
+ }
2034
+ return candidates.length;
2035
+ }
2036
+ async getIceCandidates(offerId, targetRole, since) {
2037
+ let query = `SELECT * FROM ice_candidates WHERE offer_id = $1 AND role = $2`;
2038
+ const params = [offerId, targetRole];
2039
+ if (since !== void 0) {
2040
+ query += " AND created_at > $3";
2041
+ params.push(since);
2042
+ }
2043
+ query += " ORDER BY created_at ASC";
2044
+ const result = await this.pool.query(query, params);
2045
+ return result.rows.map((row) => this.rowToIceCandidate(row));
2046
+ }
2047
+ async getIceCandidatesForMultipleOffers(offerIds, username, since) {
2048
+ const resultMap = /* @__PURE__ */ new Map();
2049
+ if (offerIds.length === 0) return resultMap;
2050
+ if (offerIds.length > 1e3) {
2051
+ throw new Error("Too many offer IDs (max 1000)");
2052
+ }
2053
+ let query = `
2054
+ SELECT ic.*, o.username as offer_username
2055
+ FROM ice_candidates ic
2056
+ INNER JOIN offers o ON o.id = ic.offer_id
2057
+ WHERE ic.offer_id = ANY($1)
2058
+ AND (
2059
+ (o.username = $2 AND ic.role = 'answerer')
2060
+ OR (o.answerer_username = $2 AND ic.role = 'offerer')
2061
+ )
2062
+ `;
2063
+ const params = [offerIds, username];
2064
+ if (since !== void 0) {
2065
+ query += " AND ic.created_at > $3";
2066
+ params.push(since);
2067
+ }
2068
+ query += " ORDER BY ic.created_at ASC";
2069
+ const result = await this.pool.query(query, params);
2070
+ for (const row of result.rows) {
2071
+ const candidate = this.rowToIceCandidate(row);
2072
+ if (!resultMap.has(row.offer_id)) {
2073
+ resultMap.set(row.offer_id, []);
2074
+ }
2075
+ resultMap.get(row.offer_id).push(candidate);
2076
+ }
2077
+ return resultMap;
2078
+ }
2079
+ // ===== Credential Management =====
2080
+ async generateCredentials(request) {
2081
+ const now = Date.now();
2082
+ const expiresAt = request.expiresAt || now + YEAR_IN_MS4;
2083
+ const { generateCredentialName: generateCredentialName2, generateSecret: generateSecret2, encryptSecret: encryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
2084
+ let name;
2085
+ if (request.name) {
2086
+ const existing = await this.pool.query(
2087
+ `SELECT name FROM credentials WHERE name = $1`,
2088
+ [request.name]
2089
+ );
2090
+ if (existing.rows.length > 0) {
2091
+ throw new Error("Username already taken");
2092
+ }
2093
+ name = request.name;
2094
+ } else {
2095
+ let attempts = 0;
2096
+ const maxAttempts = 100;
2097
+ while (attempts < maxAttempts) {
2098
+ name = generateCredentialName2();
2099
+ const existing = await this.pool.query(
2100
+ `SELECT name FROM credentials WHERE name = $1`,
2101
+ [name]
2102
+ );
2103
+ if (existing.rows.length === 0) break;
2104
+ attempts++;
2105
+ }
2106
+ if (attempts >= maxAttempts) {
2107
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
2108
+ }
2109
+ }
2110
+ const secret = generateSecret2();
2111
+ const encryptedSecret = await encryptSecret2(secret, this.masterEncryptionKey);
2112
+ await this.pool.query(
2113
+ `INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
2114
+ VALUES ($1, $2, $3, $4, $5)`,
2115
+ [name, encryptedSecret, now, expiresAt, now]
2116
+ );
2117
+ return {
2118
+ name,
2119
+ secret,
2120
+ createdAt: now,
2121
+ expiresAt,
2122
+ lastUsed: now
2123
+ };
2124
+ }
2125
+ async getCredential(name) {
2126
+ const result = await this.pool.query(
2127
+ `SELECT * FROM credentials WHERE name = $1 AND expires_at > $2`,
2128
+ [name, Date.now()]
2129
+ );
2130
+ if (result.rows.length === 0) return null;
2131
+ try {
2132
+ const { decryptSecret: decryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
2133
+ const decryptedSecret = await decryptSecret2(result.rows[0].secret, this.masterEncryptionKey);
2134
+ return {
2135
+ name: result.rows[0].name,
2136
+ secret: decryptedSecret,
2137
+ createdAt: Number(result.rows[0].created_at),
2138
+ expiresAt: Number(result.rows[0].expires_at),
2139
+ lastUsed: Number(result.rows[0].last_used)
2140
+ };
2141
+ } catch (error) {
2142
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
2143
+ return null;
2144
+ }
2145
+ }
2146
+ async updateCredentialUsage(name, lastUsed, expiresAt) {
2147
+ await this.pool.query(
2148
+ `UPDATE credentials SET last_used = $1, expires_at = $2 WHERE name = $3`,
2149
+ [lastUsed, expiresAt, name]
2150
+ );
2151
+ }
2152
+ async deleteExpiredCredentials(now) {
2153
+ const result = await this.pool.query(
2154
+ `DELETE FROM credentials WHERE expires_at < $1`,
2155
+ [now]
2156
+ );
2157
+ return result.rowCount ?? 0;
2158
+ }
2159
+ // ===== Rate Limiting =====
2160
+ async checkRateLimit(identifier, limit, windowMs) {
2161
+ const now = Date.now();
2162
+ const resetTime = now + windowMs;
2163
+ const result = await this.pool.query(
2164
+ `INSERT INTO rate_limits (identifier, count, reset_time)
2165
+ VALUES ($1, 1, $2)
2166
+ ON CONFLICT (identifier) DO UPDATE SET
2167
+ count = CASE
2168
+ WHEN rate_limits.reset_time < $3 THEN 1
2169
+ ELSE rate_limits.count + 1
2170
+ END,
2171
+ reset_time = CASE
2172
+ WHEN rate_limits.reset_time < $3 THEN $2
2173
+ ELSE rate_limits.reset_time
2174
+ END
2175
+ RETURNING count`,
2176
+ [identifier, resetTime, now]
2177
+ );
2178
+ return result.rows[0].count <= limit;
2179
+ }
2180
+ async deleteExpiredRateLimits(now) {
2181
+ const result = await this.pool.query(
2182
+ `DELETE FROM rate_limits WHERE reset_time < $1`,
2183
+ [now]
2184
+ );
2185
+ return result.rowCount ?? 0;
2186
+ }
2187
+ // ===== Nonce Tracking (Replay Protection) =====
2188
+ async checkAndMarkNonce(nonceKey, expiresAt) {
2189
+ try {
2190
+ await this.pool.query(
2191
+ `INSERT INTO nonces (nonce_key, expires_at) VALUES ($1, $2)`,
2192
+ [nonceKey, expiresAt]
2193
+ );
2194
+ return true;
2195
+ } catch (error) {
2196
+ if (error.code === "23505") {
2197
+ return false;
2198
+ }
2199
+ throw error;
2200
+ }
2201
+ }
2202
+ async deleteExpiredNonces(now) {
2203
+ const result = await this.pool.query(
2204
+ `DELETE FROM nonces WHERE expires_at < $1`,
2205
+ [now]
2206
+ );
2207
+ return result.rowCount ?? 0;
2208
+ }
2209
+ async close() {
2210
+ await this.pool.end();
2211
+ }
2212
+ // ===== Count Methods (for resource limits) =====
2213
+ async getOfferCount() {
2214
+ const result = await this.pool.query("SELECT COUNT(*) as count FROM offers");
2215
+ return Number(result.rows[0].count);
2216
+ }
2217
+ async getOfferCountByUsername(username) {
2218
+ const result = await this.pool.query(
2219
+ "SELECT COUNT(*) as count FROM offers WHERE username = $1",
2220
+ [username]
2221
+ );
2222
+ return Number(result.rows[0].count);
2223
+ }
2224
+ async getCredentialCount() {
2225
+ const result = await this.pool.query("SELECT COUNT(*) as count FROM credentials");
2226
+ return Number(result.rows[0].count);
2227
+ }
2228
+ async getIceCandidateCount(offerId) {
2229
+ const result = await this.pool.query(
2230
+ "SELECT COUNT(*) as count FROM ice_candidates WHERE offer_id = $1",
2231
+ [offerId]
2232
+ );
2233
+ return Number(result.rows[0].count);
2234
+ }
2235
+ // ===== Helper Methods =====
2236
+ rowToOffer(row) {
2237
+ return {
2238
+ id: row.id,
2239
+ username: row.username,
2240
+ tags: typeof row.tags === "string" ? JSON.parse(row.tags) : row.tags,
2241
+ sdp: row.sdp,
2242
+ createdAt: Number(row.created_at),
2243
+ expiresAt: Number(row.expires_at),
2244
+ lastSeen: Number(row.last_seen),
2245
+ answererUsername: row.answerer_username || void 0,
2246
+ answerSdp: row.answer_sdp || void 0,
2247
+ answeredAt: row.answered_at ? Number(row.answered_at) : void 0
2248
+ };
2249
+ }
2250
+ rowToIceCandidate(row) {
2251
+ return {
2252
+ id: Number(row.id),
2253
+ offerId: row.offer_id,
2254
+ username: row.username,
2255
+ role: row.role,
2256
+ candidate: typeof row.candidate === "string" ? JSON.parse(row.candidate) : row.candidate,
2257
+ createdAt: Number(row.created_at)
2258
+ };
2259
+ }
2260
+ };
2261
+ }
2262
+ });
24
2263
 
25
2264
  // src/index.ts
26
2265
  var import_node_server = require("@hono/node-server");
@@ -29,795 +2268,286 @@ var import_node_server = require("@hono/node-server");
29
2268
  var import_hono = require("hono");
30
2269
  var import_cors = require("hono/cors");
31
2270
 
32
- // node_modules/@noble/ed25519/index.js
33
- var ed25519_CURVE = {
34
- p: 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffedn,
35
- n: 0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3edn,
36
- h: 8n,
37
- a: 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffecn,
38
- d: 0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3n,
39
- Gx: 0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51an,
40
- Gy: 0x6666666666666666666666666666666666666666666666666666666666666658n
41
- };
42
- var { p: P, n: N, Gx, Gy, a: _a, d: _d, h } = ed25519_CURVE;
43
- var L = 32;
44
- var L2 = 64;
45
- var captureTrace = (...args) => {
46
- if ("captureStackTrace" in Error && typeof Error.captureStackTrace === "function") {
47
- Error.captureStackTrace(...args);
48
- }
49
- };
50
- var err = (message = "") => {
51
- const e = new Error(message);
52
- captureTrace(e, err);
53
- throw e;
54
- };
55
- var isBig = (n) => typeof n === "bigint";
56
- var isStr = (s) => typeof s === "string";
57
- var isBytes = (a) => a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array";
58
- var abytes = (value, length, title = "") => {
59
- const bytes = isBytes(value);
60
- const len = value?.length;
61
- const needsLen = length !== void 0;
62
- if (!bytes || needsLen && len !== length) {
63
- const prefix = title && `"${title}" `;
64
- const ofLen = needsLen ? ` of length ${length}` : "";
65
- const got = bytes ? `length=${len}` : `type=${typeof value}`;
66
- err(prefix + "expected Uint8Array" + ofLen + ", got " + got);
67
- }
68
- return value;
69
- };
70
- var u8n = (len) => new Uint8Array(len);
71
- var u8fr = (buf) => Uint8Array.from(buf);
72
- var padh = (n, pad) => n.toString(16).padStart(pad, "0");
73
- var bytesToHex = (b) => Array.from(abytes(b)).map((e) => padh(e, 2)).join("");
74
- var C = { _0: 48, _9: 57, A: 65, F: 70, a: 97, f: 102 };
75
- var _ch = (ch) => {
76
- if (ch >= C._0 && ch <= C._9)
77
- return ch - C._0;
78
- if (ch >= C.A && ch <= C.F)
79
- return ch - (C.A - 10);
80
- if (ch >= C.a && ch <= C.f)
81
- return ch - (C.a - 10);
82
- return;
83
- };
84
- var hexToBytes = (hex) => {
85
- const e = "hex invalid";
86
- if (!isStr(hex))
87
- return err(e);
88
- const hl = hex.length;
89
- const al = hl / 2;
90
- if (hl % 2)
91
- return err(e);
92
- const array = u8n(al);
93
- for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) {
94
- const n1 = _ch(hex.charCodeAt(hi));
95
- const n2 = _ch(hex.charCodeAt(hi + 1));
96
- if (n1 === void 0 || n2 === void 0)
97
- return err(e);
98
- array[ai] = n1 * 16 + n2;
99
- }
100
- return array;
101
- };
102
- var cr = () => globalThis?.crypto;
103
- var subtle = () => cr()?.subtle ?? err("crypto.subtle must be defined, consider polyfill");
104
- var concatBytes = (...arrs) => {
105
- const r = u8n(arrs.reduce((sum, a) => sum + abytes(a).length, 0));
106
- let pad = 0;
107
- arrs.forEach((a) => {
108
- r.set(a, pad);
109
- pad += a.length;
110
- });
111
- return r;
112
- };
113
- var big = BigInt;
114
- var assertRange = (n, min, max, msg = "bad number: out of range") => isBig(n) && min <= n && n < max ? n : err(msg);
115
- var M = (a, b = P) => {
116
- const r = a % b;
117
- return r >= 0n ? r : b + r;
118
- };
119
- var modN = (a) => M(a, N);
120
- var invert = (num, md) => {
121
- if (num === 0n || md <= 0n)
122
- err("no inverse n=" + num + " mod=" + md);
123
- let a = M(num, md), b = md, x = 0n, y = 1n, u = 1n, v = 0n;
124
- while (a !== 0n) {
125
- const q = b / a, r = b % a;
126
- const m = x - u * q, n = y - v * q;
127
- b = a, a = r, x = u, y = v, u = m, v = n;
128
- }
129
- return b === 1n ? M(x, md) : err("no inverse");
130
- };
131
- var apoint = (p) => p instanceof Point ? p : err("Point expected");
132
- var B256 = 2n ** 256n;
133
- var Point = class _Point {
134
- static BASE;
135
- static ZERO;
136
- X;
137
- Y;
138
- Z;
139
- T;
140
- constructor(X, Y, Z, T) {
141
- const max = B256;
142
- this.X = assertRange(X, 0n, max);
143
- this.Y = assertRange(Y, 0n, max);
144
- this.Z = assertRange(Z, 1n, max);
145
- this.T = assertRange(T, 0n, max);
146
- Object.freeze(this);
147
- }
148
- static CURVE() {
149
- return ed25519_CURVE;
150
- }
151
- static fromAffine(p) {
152
- return new _Point(p.x, p.y, 1n, M(p.x * p.y));
153
- }
154
- /** RFC8032 5.1.3: Uint8Array to Point. */
155
- static fromBytes(hex, zip215 = false) {
156
- const d = _d;
157
- const normed = u8fr(abytes(hex, L));
158
- const lastByte = hex[31];
159
- normed[31] = lastByte & ~128;
160
- const y = bytesToNumLE(normed);
161
- const max = zip215 ? B256 : P;
162
- assertRange(y, 0n, max);
163
- const y2 = M(y * y);
164
- const u = M(y2 - 1n);
165
- const v = M(d * y2 + 1n);
166
- let { isValid, value: x } = uvRatio(u, v);
167
- if (!isValid)
168
- err("bad point: y not sqrt");
169
- const isXOdd = (x & 1n) === 1n;
170
- const isLastByteOdd = (lastByte & 128) !== 0;
171
- if (!zip215 && x === 0n && isLastByteOdd)
172
- err("bad point: x==0, isLastByteOdd");
173
- if (isLastByteOdd !== isXOdd)
174
- x = M(-x);
175
- return new _Point(x, y, 1n, M(x * y));
176
- }
177
- static fromHex(hex, zip215) {
178
- return _Point.fromBytes(hexToBytes(hex), zip215);
179
- }
180
- get x() {
181
- return this.toAffine().x;
182
- }
183
- get y() {
184
- return this.toAffine().y;
185
- }
186
- /** Checks if the point is valid and on-curve. */
187
- assertValidity() {
188
- const a = _a;
189
- const d = _d;
190
- const p = this;
191
- if (p.is0())
192
- return err("bad point: ZERO");
193
- const { X, Y, Z, T } = p;
194
- const X2 = M(X * X);
195
- const Y2 = M(Y * Y);
196
- const Z2 = M(Z * Z);
197
- const Z4 = M(Z2 * Z2);
198
- const aX2 = M(X2 * a);
199
- const left = M(Z2 * M(aX2 + Y2));
200
- const right = M(Z4 + M(d * M(X2 * Y2)));
201
- if (left !== right)
202
- return err("bad point: equation left != right (1)");
203
- const XY = M(X * Y);
204
- const ZT = M(Z * T);
205
- if (XY !== ZT)
206
- return err("bad point: equation left != right (2)");
207
- return this;
208
- }
209
- /** Equality check: compare points P&Q. */
210
- equals(other) {
211
- const { X: X1, Y: Y1, Z: Z1 } = this;
212
- const { X: X2, Y: Y2, Z: Z2 } = apoint(other);
213
- const X1Z2 = M(X1 * Z2);
214
- const X2Z1 = M(X2 * Z1);
215
- const Y1Z2 = M(Y1 * Z2);
216
- const Y2Z1 = M(Y2 * Z1);
217
- return X1Z2 === X2Z1 && Y1Z2 === Y2Z1;
218
- }
219
- is0() {
220
- return this.equals(I);
221
- }
222
- /** Flip point over y coordinate. */
223
- negate() {
224
- return new _Point(M(-this.X), this.Y, this.Z, M(-this.T));
225
- }
226
- /** Point doubling. Complete formula. Cost: `4M + 4S + 1*a + 6add + 1*2`. */
227
- double() {
228
- const { X: X1, Y: Y1, Z: Z1 } = this;
229
- const a = _a;
230
- const A = M(X1 * X1);
231
- const B = M(Y1 * Y1);
232
- const C2 = M(2n * M(Z1 * Z1));
233
- const D = M(a * A);
234
- const x1y1 = X1 + Y1;
235
- const E = M(M(x1y1 * x1y1) - A - B);
236
- const G2 = D + B;
237
- const F = G2 - C2;
238
- const H = D - B;
239
- const X3 = M(E * F);
240
- const Y3 = M(G2 * H);
241
- const T3 = M(E * H);
242
- const Z3 = M(F * G2);
243
- return new _Point(X3, Y3, Z3, T3);
244
- }
245
- /** Point addition. Complete formula. Cost: `8M + 1*k + 8add + 1*2`. */
246
- add(other) {
247
- const { X: X1, Y: Y1, Z: Z1, T: T1 } = this;
248
- const { X: X2, Y: Y2, Z: Z2, T: T2 } = apoint(other);
249
- const a = _a;
250
- const d = _d;
251
- const A = M(X1 * X2);
252
- const B = M(Y1 * Y2);
253
- const C2 = M(T1 * d * T2);
254
- const D = M(Z1 * Z2);
255
- const E = M((X1 + Y1) * (X2 + Y2) - A - B);
256
- const F = M(D - C2);
257
- const G2 = M(D + C2);
258
- const H = M(B - a * A);
259
- const X3 = M(E * F);
260
- const Y3 = M(G2 * H);
261
- const T3 = M(E * H);
262
- const Z3 = M(F * G2);
263
- return new _Point(X3, Y3, Z3, T3);
264
- }
265
- subtract(other) {
266
- return this.add(apoint(other).negate());
267
- }
268
- /**
269
- * Point-by-scalar multiplication. Scalar must be in range 1 <= n < CURVE.n.
270
- * Uses {@link wNAF} for base point.
271
- * Uses fake point to mitigate side-channel leakage.
272
- * @param n scalar by which point is multiplied
273
- * @param safe safe mode guards against timing attacks; unsafe mode is faster
274
- */
275
- multiply(n, safe = true) {
276
- if (!safe && (n === 0n || this.is0()))
277
- return I;
278
- assertRange(n, 1n, N);
279
- if (n === 1n)
280
- return this;
281
- if (this.equals(G))
282
- return wNAF(n).p;
283
- let p = I;
284
- let f = G;
285
- for (let d = this; n > 0n; d = d.double(), n >>= 1n) {
286
- if (n & 1n)
287
- p = p.add(d);
288
- else if (safe)
289
- f = f.add(d);
290
- }
291
- return p;
292
- }
293
- multiplyUnsafe(scalar) {
294
- return this.multiply(scalar, false);
295
- }
296
- /** Convert point to 2d xy affine point. (X, Y, Z) ∋ (x=X/Z, y=Y/Z) */
297
- toAffine() {
298
- const { X, Y, Z } = this;
299
- if (this.equals(I))
300
- return { x: 0n, y: 1n };
301
- const iz = invert(Z, P);
302
- if (M(Z * iz) !== 1n)
303
- err("invalid inverse");
304
- const x = M(X * iz);
305
- const y = M(Y * iz);
306
- return { x, y };
307
- }
308
- toBytes() {
309
- const { x, y } = this.assertValidity().toAffine();
310
- const b = numTo32bLE(y);
311
- b[31] |= x & 1n ? 128 : 0;
312
- return b;
313
- }
314
- toHex() {
315
- return bytesToHex(this.toBytes());
316
- }
317
- clearCofactor() {
318
- return this.multiply(big(h), false);
319
- }
320
- isSmallOrder() {
321
- return this.clearCofactor().is0();
322
- }
323
- isTorsionFree() {
324
- let p = this.multiply(N / 2n, false).double();
325
- if (N % 2n)
326
- p = p.add(this);
327
- return p.is0();
328
- }
329
- };
330
- var G = new Point(Gx, Gy, 1n, M(Gx * Gy));
331
- var I = new Point(0n, 1n, 1n, 0n);
332
- Point.BASE = G;
333
- Point.ZERO = I;
334
- var numTo32bLE = (num) => hexToBytes(padh(assertRange(num, 0n, B256), L2)).reverse();
335
- var bytesToNumLE = (b) => big("0x" + bytesToHex(u8fr(abytes(b)).reverse()));
336
- var pow2 = (x, power) => {
337
- let r = x;
338
- while (power-- > 0n) {
339
- r *= r;
340
- r %= P;
341
- }
342
- return r;
343
- };
344
- var pow_2_252_3 = (x) => {
345
- const x2 = x * x % P;
346
- const b2 = x2 * x % P;
347
- const b4 = pow2(b2, 2n) * b2 % P;
348
- const b5 = pow2(b4, 1n) * x % P;
349
- const b10 = pow2(b5, 5n) * b5 % P;
350
- const b20 = pow2(b10, 10n) * b10 % P;
351
- const b40 = pow2(b20, 20n) * b20 % P;
352
- const b80 = pow2(b40, 40n) * b40 % P;
353
- const b160 = pow2(b80, 80n) * b80 % P;
354
- const b240 = pow2(b160, 80n) * b80 % P;
355
- const b250 = pow2(b240, 10n) * b10 % P;
356
- const pow_p_5_8 = pow2(b250, 2n) * x % P;
357
- return { pow_p_5_8, b2 };
358
- };
359
- var RM1 = 0x2b8324804fc1df0b2b4d00993dfbd7a72f431806ad2fe478c4ee1b274a0ea0b0n;
360
- var uvRatio = (u, v) => {
361
- const v3 = M(v * v * v);
362
- const v7 = M(v3 * v3 * v);
363
- const pow = pow_2_252_3(u * v7).pow_p_5_8;
364
- let x = M(u * v3 * pow);
365
- const vx2 = M(v * x * x);
366
- const root1 = x;
367
- const root2 = M(x * RM1);
368
- const useRoot1 = vx2 === u;
369
- const useRoot2 = vx2 === M(-u);
370
- const noRoot = vx2 === M(-u * RM1);
371
- if (useRoot1)
372
- x = root1;
373
- if (useRoot2 || noRoot)
374
- x = root2;
375
- if ((M(x) & 1n) === 1n)
376
- x = M(-x);
377
- return { isValid: useRoot1 || useRoot2, value: x };
378
- };
379
- var modL_LE = (hash) => modN(bytesToNumLE(hash));
380
- var sha512a = (...m) => hashes.sha512Async(concatBytes(...m));
381
- var hashFinishA = (res) => sha512a(res.hashable).then(res.finish);
382
- var defaultVerifyOpts = { zip215: true };
383
- var _verify = (sig, msg, pub, opts = defaultVerifyOpts) => {
384
- sig = abytes(sig, L2);
385
- msg = abytes(msg);
386
- pub = abytes(pub, L);
387
- const { zip215 } = opts;
388
- let A;
389
- let R;
390
- let s;
391
- let SB;
392
- let hashable = Uint8Array.of();
393
- try {
394
- A = Point.fromBytes(pub, zip215);
395
- R = Point.fromBytes(sig.slice(0, L), zip215);
396
- s = bytesToNumLE(sig.slice(L, L2));
397
- SB = G.multiply(s, false);
398
- hashable = concatBytes(R.toBytes(), A.toBytes(), msg);
399
- } catch (error) {
400
- }
401
- const finish = (hashed) => {
402
- if (SB == null)
403
- return false;
404
- if (!zip215 && A.isSmallOrder())
405
- return false;
406
- const k = modL_LE(hashed);
407
- const RkA = R.add(A.multiply(k, false));
408
- return RkA.add(SB.negate()).clearCofactor().is0();
409
- };
410
- return { hashable, finish };
411
- };
412
- var verifyAsync = async (signature, message, publicKey, opts = defaultVerifyOpts) => hashFinishA(_verify(signature, message, publicKey, opts));
413
- var hashes = {
414
- sha512Async: async (message) => {
415
- const s = subtle();
416
- const m = concatBytes(message);
417
- return u8n(await s.digest("SHA-512", m.buffer));
418
- },
419
- sha512: void 0
420
- };
421
- var W = 8;
422
- var scalarBits = 256;
423
- var pwindows = Math.ceil(scalarBits / W) + 1;
424
- var pwindowSize = 2 ** (W - 1);
425
- var precompute = () => {
426
- const points = [];
427
- let p = G;
428
- let b = p;
429
- for (let w = 0; w < pwindows; w++) {
430
- b = p;
431
- points.push(b);
432
- for (let i = 1; i < pwindowSize; i++) {
433
- b = b.add(p);
434
- points.push(b);
435
- }
436
- p = b.double();
437
- }
438
- return points;
439
- };
440
- var Gpows = void 0;
441
- var ctneg = (cnd, p) => {
442
- const n = p.negate();
443
- return cnd ? n : p;
444
- };
445
- var wNAF = (n) => {
446
- const comp = Gpows || (Gpows = precompute());
447
- let p = I;
448
- let f = G;
449
- const pow_2_w = 2 ** W;
450
- const maxNum = pow_2_w;
451
- const mask = big(pow_2_w - 1);
452
- const shiftBy = big(W);
453
- for (let w = 0; w < pwindows; w++) {
454
- let wbits = Number(n & mask);
455
- n >>= shiftBy;
456
- if (wbits > pwindowSize) {
457
- wbits -= maxNum;
458
- n += 1n;
459
- }
460
- const off = w * pwindowSize;
461
- const offF = off;
462
- const offP = off + Math.abs(wbits) - 1;
463
- const isEven = w % 2 !== 0;
464
- const isNeg = wbits < 0;
465
- if (wbits === 0) {
466
- f = f.add(ctneg(isEven, comp[offF]));
467
- } else {
468
- p = p.add(ctneg(isNeg, comp[offP]));
469
- }
470
- }
471
- if (n !== 0n)
472
- err("invalid wnaf");
473
- return { p, f };
474
- };
475
-
476
- // src/crypto.ts
477
- var import_node_buffer = require("node:buffer");
478
- hashes.sha512Async = async (message) => {
479
- return new Uint8Array(await crypto.subtle.digest("SHA-512", message));
480
- };
481
- var USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
482
- var USERNAME_MIN_LENGTH = 3;
483
- var USERNAME_MAX_LENGTH = 32;
484
- var TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1e3;
485
- function base64ToBytes(base64) {
486
- return new Uint8Array(import_node_buffer.Buffer.from(base64, "base64"));
487
- }
488
- function validateAuthMessage(expectedUsername, message) {
489
- const parts = message.split(":");
490
- if (parts.length < 3) {
491
- return { valid: false, error: "Invalid message format: must have at least action:username:timestamp" };
492
- }
493
- const messageUsername = parts[1];
494
- const timestamp = parseInt(parts[parts.length - 1], 10);
495
- if (messageUsername !== expectedUsername) {
496
- return { valid: false, error: "Username in message does not match authenticated username" };
497
- }
498
- if (isNaN(timestamp)) {
499
- return { valid: false, error: "Invalid timestamp in message" };
500
- }
501
- const timestampCheck = validateTimestamp(timestamp);
502
- if (!timestampCheck.valid) {
503
- return timestampCheck;
504
- }
505
- return { valid: true };
506
- }
507
- function validateUsername(username) {
508
- if (typeof username !== "string") {
509
- return { valid: false, error: "Username must be a string" };
510
- }
511
- if (username.length < USERNAME_MIN_LENGTH) {
512
- return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
513
- }
514
- if (username.length > USERNAME_MAX_LENGTH) {
515
- return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
516
- }
517
- if (!USERNAME_REGEX.test(username)) {
518
- return { valid: false, error: "Username must be lowercase alphanumeric with optional dashes, and start/end with alphanumeric" };
519
- }
520
- return { valid: true };
521
- }
522
- function validateServiceFqn(fqn) {
523
- if (typeof fqn !== "string") {
524
- return { valid: false, error: "Service FQN must be a string" };
525
- }
526
- const parsed = parseServiceFqn(fqn);
527
- if (!parsed) {
528
- return { valid: false, error: "Service FQN must be in format: service:version[@username]" };
529
- }
530
- const { serviceName, version, username } = parsed;
531
- const serviceNameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
532
- if (!serviceNameRegex.test(serviceName)) {
533
- return { valid: false, error: "Service name must be lowercase alphanumeric with optional dots/dashes" };
534
- }
535
- if (serviceName.length < 1 || serviceName.length > 128) {
536
- return { valid: false, error: "Service name must be 1-128 characters" };
537
- }
538
- const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
539
- if (!versionRegex.test(version)) {
540
- return { valid: false, error: "Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)" };
541
- }
542
- if (username) {
543
- const usernameCheck = validateUsername(username);
544
- if (!usernameCheck.valid) {
545
- return usernameCheck;
2271
+ // src/rpc.ts
2272
+ init_crypto();
2273
+ var MAX_PAGE_SIZE = 100;
2274
+ var CREDENTIAL_RATE_WINDOW = 60 * 60 * 1e3;
2275
+ var REQUEST_RATE_WINDOW = 1e3;
2276
+ function getJsonDepth(obj, maxDepth, currentDepth = 0) {
2277
+ if (obj === null || typeof obj !== "object") {
2278
+ return currentDepth;
2279
+ }
2280
+ if (currentDepth >= maxDepth) {
2281
+ return currentDepth + 1;
2282
+ }
2283
+ let maxChildDepth = currentDepth;
2284
+ for (const key in obj) {
2285
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
2286
+ const childDepth = getJsonDepth(obj[key], maxDepth, currentDepth + 1);
2287
+ maxChildDepth = Math.max(maxChildDepth, childDepth);
2288
+ if (maxChildDepth > maxDepth) {
2289
+ return maxChildDepth;
2290
+ }
546
2291
  }
547
2292
  }
548
- return { valid: true };
549
- }
550
- function parseVersion(version) {
551
- const match = version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-z0-9.-]+)?$/);
552
- if (!match) return null;
553
- return {
554
- major: parseInt(match[1], 10),
555
- minor: parseInt(match[2], 10),
556
- patch: parseInt(match[3], 10),
557
- prerelease: match[4]?.substring(1)
558
- // Remove leading dash
559
- };
560
- }
561
- function isVersionCompatible(requested, available) {
562
- const req = parseVersion(requested);
563
- const avail = parseVersion(available);
564
- if (!req || !avail) return false;
565
- if (req.major !== avail.major) return false;
566
- if (req.major === 0 && req.minor !== avail.minor) return false;
567
- if (avail.minor < req.minor) return false;
568
- if (avail.minor === req.minor && avail.patch < req.patch) return false;
569
- if (req.prerelease && req.prerelease !== avail.prerelease) return false;
570
- return true;
2293
+ return maxChildDepth;
571
2294
  }
572
- function parseServiceFqn(fqn) {
573
- if (!fqn || typeof fqn !== "string") return null;
574
- const atIndex = fqn.lastIndexOf("@");
575
- let serviceVersion;
576
- let username = null;
577
- if (atIndex > 0) {
578
- serviceVersion = fqn.substring(0, atIndex);
579
- username = fqn.substring(atIndex + 1);
580
- } else {
581
- serviceVersion = fqn;
2295
+ function validateStringParam(value, paramName) {
2296
+ if (typeof value !== "string" || value.length === 0) {
2297
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `${paramName} must be a non-empty string`);
582
2298
  }
583
- const colonIndex = serviceVersion.indexOf(":");
584
- if (colonIndex <= 0) return null;
585
- const serviceName = serviceVersion.substring(0, colonIndex);
586
- const version = serviceVersion.substring(colonIndex + 1);
587
- if (!serviceName || !version) return null;
588
- return {
589
- serviceName,
590
- version,
591
- username
592
- };
593
2299
  }
594
- function validateTimestamp(timestamp) {
595
- if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
596
- return { valid: false, error: "Timestamp must be a finite number" };
2300
+ var ErrorCodes = {
2301
+ // Authentication errors
2302
+ AUTH_REQUIRED: "AUTH_REQUIRED",
2303
+ INVALID_CREDENTIALS: "INVALID_CREDENTIALS",
2304
+ // Validation errors
2305
+ INVALID_NAME: "INVALID_NAME",
2306
+ INVALID_TAG: "INVALID_TAG",
2307
+ INVALID_SDP: "INVALID_SDP",
2308
+ INVALID_PARAMS: "INVALID_PARAMS",
2309
+ MISSING_PARAMS: "MISSING_PARAMS",
2310
+ // Resource errors
2311
+ OFFER_NOT_FOUND: "OFFER_NOT_FOUND",
2312
+ OFFER_ALREADY_ANSWERED: "OFFER_ALREADY_ANSWERED",
2313
+ OFFER_NOT_ANSWERED: "OFFER_NOT_ANSWERED",
2314
+ NO_AVAILABLE_OFFERS: "NO_AVAILABLE_OFFERS",
2315
+ // Authorization errors
2316
+ NOT_AUTHORIZED: "NOT_AUTHORIZED",
2317
+ OWNERSHIP_MISMATCH: "OWNERSHIP_MISMATCH",
2318
+ // Limit errors
2319
+ TOO_MANY_OFFERS: "TOO_MANY_OFFERS",
2320
+ SDP_TOO_LARGE: "SDP_TOO_LARGE",
2321
+ BATCH_TOO_LARGE: "BATCH_TOO_LARGE",
2322
+ RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
2323
+ TOO_MANY_OFFERS_PER_USER: "TOO_MANY_OFFERS_PER_USER",
2324
+ STORAGE_FULL: "STORAGE_FULL",
2325
+ TOO_MANY_ICE_CANDIDATES: "TOO_MANY_ICE_CANDIDATES",
2326
+ // Generic errors
2327
+ INTERNAL_ERROR: "INTERNAL_ERROR",
2328
+ UNKNOWN_METHOD: "UNKNOWN_METHOD"
2329
+ };
2330
+ var RpcError = class extends Error {
2331
+ constructor(errorCode, message) {
2332
+ super(message);
2333
+ this.errorCode = errorCode;
2334
+ this.name = "RpcError";
597
2335
  }
2336
+ };
2337
+ function validateTimestamp(timestamp, config) {
598
2338
  const now = Date.now();
599
- const diff = Math.abs(now - timestamp);
600
- if (diff > TIMESTAMP_TOLERANCE_MS) {
601
- return { valid: false, error: `Timestamp too old or too far in future (tolerance: ${TIMESTAMP_TOLERANCE_MS / 1e3}s)` };
2339
+ if (now - timestamp > config.timestampMaxAge) {
2340
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Timestamp too old");
602
2341
  }
603
- return { valid: true };
604
- }
605
- async function verifyEd25519Signature(publicKey, signature, message) {
606
- try {
607
- const publicKeyBytes = base64ToBytes(publicKey);
608
- const signatureBytes = base64ToBytes(signature);
609
- const encoder = new TextEncoder();
610
- const messageBytes = encoder.encode(message);
611
- const isValid = await verifyAsync(signatureBytes, messageBytes, publicKeyBytes);
612
- return isValid;
613
- } catch (err2) {
614
- console.error("Ed25519 signature verification failed:", err2);
615
- return false;
2342
+ if (timestamp - now > config.timestampMaxFuture) {
2343
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Timestamp too far in future");
616
2344
  }
617
2345
  }
618
-
619
- // src/rpc.ts
620
- var MAX_PAGE_SIZE = 100;
621
- async function verifyAuth(username, message, signature, publicKey, storage) {
622
- let usernameRecord = await storage.getUsername(username);
623
- if (!usernameRecord) {
624
- if (!publicKey) {
625
- return {
626
- valid: false,
627
- error: `Username "${username}" is not claimed and no public key provided for auto-claim.`
628
- };
629
- }
630
- const usernameValidation = validateUsername(username);
631
- if (!usernameValidation.valid) {
632
- return usernameValidation;
633
- }
634
- const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
635
- if (!signatureValid) {
636
- return { valid: false, error: "Invalid signature for auto-claim" };
637
- }
638
- const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1e3;
639
- await storage.claimUsername({
640
- username,
641
- publicKey,
642
- expiresAt
643
- });
644
- usernameRecord = await storage.getUsername(username);
645
- if (!usernameRecord) {
646
- return { valid: false, error: "Failed to claim username" };
647
- }
648
- }
649
- const isValid = await verifyEd25519Signature(
650
- usernameRecord.publicKey,
651
- signature,
652
- message
653
- );
2346
+ async function verifyRequestSignature(name, timestamp, nonce, signature, method, params, storage, config) {
2347
+ validateTimestamp(timestamp, config);
2348
+ const credential = await storage.getCredential(name);
2349
+ if (!credential) {
2350
+ throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, "Invalid credentials");
2351
+ }
2352
+ const message = buildSignatureMessage(timestamp, nonce, method, params);
2353
+ const isValid = await verifySignature(credential.secret, message, signature);
654
2354
  if (!isValid) {
655
- return { valid: false, error: "Invalid signature" };
2355
+ throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, "Invalid signature");
656
2356
  }
657
- const validation = validateAuthMessage(username, message);
658
- if (!validation.valid) {
659
- return { valid: false, error: validation.error };
2357
+ const nonceKey = `nonce:${name}:${nonce}`;
2358
+ const nonceExpiresAt = timestamp + config.timestampMaxAge;
2359
+ const nonceIsNew = await storage.checkAndMarkNonce(nonceKey, nonceExpiresAt);
2360
+ if (!nonceIsNew) {
2361
+ throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, "Nonce already used (replay attack detected)");
660
2362
  }
661
- return { valid: true };
662
- }
663
- function extractUsername(message) {
664
- const parts = message.split(":");
665
- if (parts.length < 2) return null;
666
- return parts[1];
2363
+ const now = Date.now();
2364
+ const credentialExpiresAt = now + 365 * 24 * 60 * 60 * 1e3;
2365
+ await storage.updateCredentialUsage(name, now, credentialExpiresAt);
667
2366
  }
668
2367
  var handlers = {
669
2368
  /**
670
- * Check if username is available
2369
+ * Generate new credentials (name + secret pair)
2370
+ * No authentication required - this is how users get started
2371
+ * SECURITY: Rate limited per IP to prevent abuse (database-backed for multi-instance support)
671
2372
  */
672
- async getUser(params, message, signature, publicKey, storage, config) {
673
- const { username } = params;
674
- const claimed = await storage.getUsername(username);
675
- if (!claimed) {
2373
+ async generateCredentials(params, name, timestamp, signature, storage, config, request) {
2374
+ const credentialCount = await storage.getCredentialCount();
2375
+ if (credentialCount >= config.maxTotalCredentials) {
2376
+ throw new RpcError(
2377
+ ErrorCodes.STORAGE_FULL,
2378
+ `Server credential limit reached (${config.maxTotalCredentials}). Try again later.`
2379
+ );
2380
+ }
2381
+ let rateLimitKey;
2382
+ let rateLimit;
2383
+ if (!request.clientIp) {
2384
+ console.warn("\u26A0\uFE0F WARNING: Unable to determine client IP for credential generation. Using global rate limit.");
2385
+ rateLimitKey = "cred_gen:global_unknown";
2386
+ rateLimit = 2;
2387
+ } else {
2388
+ rateLimitKey = `cred_gen:${request.clientIp}`;
2389
+ rateLimit = config.credentialsPerIpPerHour;
2390
+ }
2391
+ const allowed = await storage.checkRateLimit(
2392
+ rateLimitKey,
2393
+ rateLimit,
2394
+ CREDENTIAL_RATE_WINDOW
2395
+ );
2396
+ if (!allowed) {
2397
+ throw new RpcError(
2398
+ ErrorCodes.RATE_LIMIT_EXCEEDED,
2399
+ `Rate limit exceeded. Maximum ${rateLimit} credentials per hour${request.clientIp ? " per IP" : " (global limit for unidentified IPs)"}.`
2400
+ );
2401
+ }
2402
+ if (params.name !== void 0) {
2403
+ if (typeof params.name !== "string") {
2404
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "name must be a string");
2405
+ }
2406
+ const usernameValidation = validateUsername(params.name);
2407
+ if (!usernameValidation.valid) {
2408
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, usernameValidation.error || "Invalid username");
2409
+ }
2410
+ }
2411
+ if (params.expiresAt !== void 0) {
2412
+ if (typeof params.expiresAt !== "number" || isNaN(params.expiresAt) || !Number.isFinite(params.expiresAt)) {
2413
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "expiresAt must be a valid timestamp");
2414
+ }
2415
+ const now = Date.now();
2416
+ if (params.expiresAt < now - 6e4) {
2417
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "expiresAt cannot be in the past");
2418
+ }
2419
+ const maxFuture = now + 10 * 365 * 24 * 60 * 60 * 1e3;
2420
+ if (params.expiresAt > maxFuture) {
2421
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "expiresAt cannot be more than 10 years in the future");
2422
+ }
2423
+ }
2424
+ try {
2425
+ const credential = await storage.generateCredentials({
2426
+ name: params.name,
2427
+ expiresAt: params.expiresAt
2428
+ });
676
2429
  return {
677
- username,
678
- available: true
2430
+ name: credential.name,
2431
+ secret: credential.secret,
2432
+ createdAt: credential.createdAt,
2433
+ expiresAt: credential.expiresAt
679
2434
  };
2435
+ } catch (error) {
2436
+ if (error.message === "Username already taken") {
2437
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Username already taken");
2438
+ }
2439
+ throw error;
680
2440
  }
681
- return {
682
- username: claimed.username,
683
- available: false,
684
- claimedAt: claimed.claimedAt,
685
- expiresAt: claimed.expiresAt,
686
- publicKey: claimed.publicKey
687
- };
688
2441
  },
689
2442
  /**
690
- * Get service by FQN - Supports 3 modes:
691
- * 1. Direct lookup: FQN includes @username
692
- * 2. Paginated discovery: FQN without @username, with limit/offset
693
- * 3. Random discovery: FQN without @username, no limit
2443
+ * Discover offers by tags - Supports 2 modes:
2444
+ * 1. Paginated discovery: tags array with limit/offset
2445
+ * 2. Random discovery: tags array without limit (returns single random offer)
694
2446
  */
695
- async getService(params, message, signature, publicKey, storage, config) {
696
- const { serviceFqn, limit, offset } = params;
697
- const username = extractUsername(message);
698
- if (username) {
699
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
700
- if (!auth.valid) {
701
- throw new Error(auth.error);
702
- }
2447
+ async discover(params, name, timestamp, signature, storage, config, request) {
2448
+ const { tags, limit, offset } = params;
2449
+ const tagsValidation = validateTags(tags);
2450
+ if (!tagsValidation.valid) {
2451
+ throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || "Invalid tags");
703
2452
  }
704
- const fqnValidation = validateServiceFqn(serviceFqn);
705
- if (!fqnValidation.valid) {
706
- throw new Error(fqnValidation.error || "Invalid service FQN");
707
- }
708
- const parsed = parseServiceFqn(serviceFqn);
709
- if (!parsed) {
710
- throw new Error("Failed to parse service FQN");
711
- }
712
- const filterCompatibleServices = (services) => {
713
- return services.filter((s) => {
714
- const serviceVersion = parseServiceFqn(s.serviceFqn);
715
- return serviceVersion && isVersionCompatible(parsed.version, serviceVersion.version);
716
- });
717
- };
718
- const findAvailableOffer = async (service) => {
719
- const offers = await storage.getOffersForService(service.id);
720
- return offers.find((o) => !o.answererUsername);
721
- };
722
- const buildServiceResponse = (service, offer) => ({
723
- serviceId: service.id,
724
- username: service.username,
725
- serviceFqn: service.serviceFqn,
726
- offerId: offer.id,
727
- sdp: offer.sdp,
728
- createdAt: service.createdAt,
729
- expiresAt: service.expiresAt
730
- });
731
2453
  if (limit !== void 0) {
2454
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
2455
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "limit must be a non-negative integer");
2456
+ }
2457
+ if (offset !== void 0 && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) {
2458
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "offset must be a non-negative integer");
2459
+ }
732
2460
  const pageLimit = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);
733
2461
  const pageOffset = Math.max(0, offset || 0);
734
- const allServices2 = await storage.getServicesByName(parsed.service, parsed.version);
735
- const compatibleServices2 = filterCompatibleServices(allServices2);
736
- const usernameSet = /* @__PURE__ */ new Set();
737
- const uniqueServices = [];
738
- for (const service of compatibleServices2) {
739
- if (!usernameSet.has(service.username)) {
740
- usernameSet.add(service.username);
741
- const availableOffer2 = await findAvailableOffer(service);
742
- if (availableOffer2) {
743
- uniqueServices.push(buildServiceResponse(service, availableOffer2));
744
- }
745
- }
746
- }
747
- const paginatedServices = uniqueServices.slice(pageOffset, pageOffset + pageLimit);
2462
+ const excludeUsername2 = name || null;
2463
+ const offers = await storage.discoverOffers(
2464
+ tags,
2465
+ excludeUsername2,
2466
+ pageLimit,
2467
+ pageOffset
2468
+ );
748
2469
  return {
749
- services: paginatedServices,
750
- count: paginatedServices.length,
2470
+ offers: offers.map((offer2) => ({
2471
+ offerId: offer2.id,
2472
+ username: offer2.username,
2473
+ tags: offer2.tags,
2474
+ sdp: offer2.sdp,
2475
+ createdAt: offer2.createdAt,
2476
+ expiresAt: offer2.expiresAt
2477
+ })),
2478
+ count: offers.length,
751
2479
  limit: pageLimit,
752
2480
  offset: pageOffset
753
2481
  };
754
2482
  }
755
- if (parsed.username) {
756
- const service = await storage.getServiceByFqn(serviceFqn);
757
- if (!service) {
758
- throw new Error("Service not found");
759
- }
760
- const availableOffer2 = await findAvailableOffer(service);
761
- if (!availableOffer2) {
762
- throw new Error("Service has no available offers");
763
- }
764
- return buildServiceResponse(service, availableOffer2);
765
- }
766
- const allServices = await storage.getServicesByName(parsed.service, parsed.version);
767
- const compatibleServices = filterCompatibleServices(allServices);
768
- if (compatibleServices.length === 0) {
769
- throw new Error("No services found");
770
- }
771
- const randomService = compatibleServices[Math.floor(Math.random() * compatibleServices.length)];
772
- const availableOffer = await findAvailableOffer(randomService);
773
- if (!availableOffer) {
774
- throw new Error("Service has no available offers");
2483
+ const excludeUsername = name || null;
2484
+ const offer = await storage.getRandomOffer(tags, excludeUsername);
2485
+ if (!offer) {
2486
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "No offers found matching tags");
775
2487
  }
776
- return buildServiceResponse(randomService, availableOffer);
2488
+ return {
2489
+ offerId: offer.id,
2490
+ username: offer.username,
2491
+ tags: offer.tags,
2492
+ sdp: offer.sdp,
2493
+ createdAt: offer.createdAt,
2494
+ expiresAt: offer.expiresAt
2495
+ };
777
2496
  },
778
2497
  /**
779
- * Publish a service
2498
+ * Publish offers with tags
780
2499
  */
781
- async publishService(params, message, signature, publicKey, storage, config) {
782
- const { serviceFqn, offers, ttl } = params;
783
- const username = extractUsername(message);
784
- if (!username) {
785
- throw new Error("Username required for service publishing");
2500
+ async publishOffer(params, name, timestamp, signature, storage, config, request) {
2501
+ const { tags, offers, ttl } = params;
2502
+ if (!name) {
2503
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required for offer publishing");
786
2504
  }
787
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
788
- if (!auth.valid) {
789
- throw new Error(auth.error);
790
- }
791
- const fqnValidation = validateServiceFqn(serviceFqn);
792
- if (!fqnValidation.valid) {
793
- throw new Error(fqnValidation.error || "Invalid service FQN");
794
- }
795
- const parsed = parseServiceFqn(serviceFqn);
796
- if (!parsed || !parsed.username) {
797
- throw new Error("Service FQN must include username");
798
- }
799
- if (parsed.username !== username) {
800
- throw new Error("Service FQN username must match authenticated username");
2505
+ const tagsValidation = validateTags(tags);
2506
+ if (!tagsValidation.valid) {
2507
+ throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || "Invalid tags");
801
2508
  }
802
2509
  if (!offers || !Array.isArray(offers) || offers.length === 0) {
803
- throw new Error("Must provide at least one offer");
2510
+ throw new RpcError(ErrorCodes.MISSING_PARAMS, "Must provide at least one offer");
804
2511
  }
805
2512
  if (offers.length > config.maxOffersPerRequest) {
806
- throw new Error(
2513
+ throw new RpcError(
2514
+ ErrorCodes.TOO_MANY_OFFERS,
807
2515
  `Too many offers (max ${config.maxOffersPerRequest})`
808
2516
  );
809
2517
  }
2518
+ const userOfferCount = await storage.getOfferCountByUsername(name);
2519
+ if (userOfferCount + offers.length > config.maxOffersPerUser) {
2520
+ throw new RpcError(
2521
+ ErrorCodes.TOO_MANY_OFFERS_PER_USER,
2522
+ `User offer limit exceeded. You have ${userOfferCount} offers, limit is ${config.maxOffersPerUser}.`
2523
+ );
2524
+ }
2525
+ const totalOfferCount = await storage.getOfferCount();
2526
+ if (totalOfferCount + offers.length > config.maxTotalOffers) {
2527
+ throw new RpcError(
2528
+ ErrorCodes.STORAGE_FULL,
2529
+ `Server offer limit reached (${config.maxTotalOffers}). Try again later.`
2530
+ );
2531
+ }
810
2532
  offers.forEach((offer, index) => {
811
2533
  if (!offer || typeof offer !== "object") {
812
- throw new Error(`Invalid offer at index ${index}: must be an object`);
2534
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid offer at index ${index}: must be an object`);
813
2535
  }
814
2536
  if (!offer.sdp || typeof offer.sdp !== "string") {
815
- throw new Error(`Invalid offer at index ${index}: missing or invalid SDP`);
2537
+ throw new RpcError(ErrorCodes.INVALID_SDP, `Invalid offer at index ${index}: missing or invalid SDP`);
816
2538
  }
817
2539
  if (!offer.sdp.trim()) {
818
- throw new Error(`Invalid offer at index ${index}: SDP cannot be empty`);
2540
+ throw new RpcError(ErrorCodes.INVALID_SDP, `Invalid offer at index ${index}: SDP cannot be empty`);
2541
+ }
2542
+ if (offer.sdp.length > config.maxSdpSize) {
2543
+ throw new RpcError(ErrorCodes.SDP_TOO_LARGE, `SDP too large at index ${index} (max ${config.maxSdpSize} bytes)`);
819
2544
  }
820
2545
  });
2546
+ if (ttl !== void 0) {
2547
+ if (typeof ttl !== "number" || isNaN(ttl) || ttl < 0) {
2548
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "TTL must be a non-negative number");
2549
+ }
2550
+ }
821
2551
  const now = Date.now();
822
2552
  const offerTtl = ttl !== void 0 ? Math.min(
823
2553
  Math.max(ttl, config.offerMinTtl),
@@ -825,108 +2555,83 @@ var handlers = {
825
2555
  ) : config.offerDefaultTtl;
826
2556
  const expiresAt = now + offerTtl;
827
2557
  const offerRequests = offers.map((offer) => ({
828
- username,
829
- serviceFqn,
2558
+ username: name,
2559
+ tags,
830
2560
  sdp: offer.sdp,
831
2561
  expiresAt
832
2562
  }));
833
- const result = await storage.createService({
834
- serviceFqn,
835
- expiresAt,
836
- offers: offerRequests
837
- });
2563
+ const createdOffers = await storage.createOffers(offerRequests);
838
2564
  return {
839
- serviceId: result.service.id,
840
- username: result.service.username,
841
- serviceFqn: result.service.serviceFqn,
842
- offers: result.offers.map((offer) => ({
2565
+ username: name,
2566
+ tags,
2567
+ offers: createdOffers.map((offer) => ({
843
2568
  offerId: offer.id,
844
2569
  sdp: offer.sdp,
845
2570
  createdAt: offer.createdAt,
846
2571
  expiresAt: offer.expiresAt
847
2572
  })),
848
- createdAt: result.service.createdAt,
849
- expiresAt: result.service.expiresAt
2573
+ createdAt: now,
2574
+ expiresAt
850
2575
  };
851
2576
  },
852
2577
  /**
853
- * Delete a service
2578
+ * Delete an offer by ID
854
2579
  */
855
- async deleteService(params, message, signature, publicKey, storage, config) {
856
- const { serviceFqn } = params;
857
- const username = extractUsername(message);
858
- if (!username) {
859
- throw new Error("Username required");
860
- }
861
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
862
- if (!auth.valid) {
863
- throw new Error(auth.error);
864
- }
865
- const parsed = parseServiceFqn(serviceFqn);
866
- if (!parsed || !parsed.username) {
867
- throw new Error("Service FQN must include username");
868
- }
869
- const service = await storage.getServiceByFqn(serviceFqn);
870
- if (!service) {
871
- throw new Error("Service not found");
2580
+ async deleteOffer(params, name, timestamp, signature, storage, config, request) {
2581
+ const { offerId } = params;
2582
+ if (!name) {
2583
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
872
2584
  }
873
- const deleted = await storage.deleteService(service.id, username);
2585
+ validateStringParam(offerId, "offerId");
2586
+ const deleted = await storage.deleteOffer(offerId, name);
874
2587
  if (!deleted) {
875
- throw new Error("Service not found or not owned by this username");
2588
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, "Offer not found or not owned by this name");
876
2589
  }
877
2590
  return { success: true };
878
2591
  },
879
2592
  /**
880
2593
  * Answer an offer
881
2594
  */
882
- async answerOffer(params, message, signature, publicKey, storage, config) {
883
- const { serviceFqn, offerId, sdp } = params;
884
- const username = extractUsername(message);
885
- if (!username) {
886
- throw new Error("Username required");
887
- }
888
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
889
- if (!auth.valid) {
890
- throw new Error(auth.error);
2595
+ async answerOffer(params, name, timestamp, signature, storage, config, request) {
2596
+ const { offerId, sdp } = params;
2597
+ validateStringParam(offerId, "offerId");
2598
+ if (!name) {
2599
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
891
2600
  }
892
2601
  if (!sdp || typeof sdp !== "string" || sdp.length === 0) {
893
- throw new Error("Invalid SDP");
2602
+ throw new RpcError(ErrorCodes.INVALID_SDP, "Invalid SDP");
894
2603
  }
895
- if (sdp.length > 64 * 1024) {
896
- throw new Error("SDP too large (max 64KB)");
2604
+ if (sdp.length > config.maxSdpSize) {
2605
+ throw new RpcError(ErrorCodes.SDP_TOO_LARGE, `SDP too large (max ${config.maxSdpSize} bytes)`);
897
2606
  }
898
2607
  const offer = await storage.getOfferById(offerId);
899
2608
  if (!offer) {
900
- throw new Error("Offer not found");
2609
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
901
2610
  }
902
2611
  if (offer.answererUsername) {
903
- throw new Error("Offer already answered");
2612
+ throw new RpcError(ErrorCodes.OFFER_ALREADY_ANSWERED, "Offer already answered");
904
2613
  }
905
- await storage.answerOffer(offerId, username, sdp);
2614
+ await storage.answerOffer(offerId, name, sdp);
906
2615
  return { success: true, offerId };
907
2616
  },
908
2617
  /**
909
2618
  * Get answer for an offer
910
2619
  */
911
- async getOfferAnswer(params, message, signature, publicKey, storage, config) {
912
- const { serviceFqn, offerId } = params;
913
- const username = extractUsername(message);
914
- if (!username) {
915
- throw new Error("Username required");
916
- }
917
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
918
- if (!auth.valid) {
919
- throw new Error(auth.error);
2620
+ async getOfferAnswer(params, name, timestamp, signature, storage, config, request) {
2621
+ const { offerId } = params;
2622
+ validateStringParam(offerId, "offerId");
2623
+ if (!name) {
2624
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
920
2625
  }
921
2626
  const offer = await storage.getOfferById(offerId);
922
2627
  if (!offer) {
923
- throw new Error("Offer not found");
2628
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
924
2629
  }
925
- if (offer.username !== username) {
926
- throw new Error("Not authorized to access this offer");
2630
+ if (offer.username !== name) {
2631
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, "Not authorized to access this offer");
927
2632
  }
928
2633
  if (!offer.answererUsername || !offer.answerSdp) {
929
- throw new Error("Offer not yet answered");
2634
+ throw new RpcError(ErrorCodes.OFFER_NOT_ANSWERED, "Offer not yet answered");
930
2635
  }
931
2636
  return {
932
2637
  sdp: offer.answerSdp,
@@ -938,58 +2643,38 @@ var handlers = {
938
2643
  /**
939
2644
  * Combined polling for answers and ICE candidates
940
2645
  */
941
- async poll(params, message, signature, publicKey, storage, config) {
2646
+ async poll(params, name, timestamp, signature, storage, config, request) {
942
2647
  const { since } = params;
943
- const username = extractUsername(message);
944
- if (!username) {
945
- throw new Error("Username required");
2648
+ if (!name) {
2649
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
946
2650
  }
947
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
948
- if (!auth.valid) {
949
- throw new Error(auth.error);
2651
+ if (since !== void 0 && (typeof since !== "number" || since < 0 || !Number.isFinite(since))) {
2652
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Invalid since parameter: must be a non-negative number");
950
2653
  }
951
- const sinceTimestamp = since || 0;
952
- const answeredOffers = await storage.getAnsweredOffers(username);
2654
+ const sinceTimestamp = since !== void 0 ? since : 0;
2655
+ const answeredOffers = await storage.getAnsweredOffers(name);
953
2656
  const filteredAnswers = answeredOffers.filter(
954
2657
  (offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
955
2658
  );
956
- const allOffers = await storage.getOffersByUsername(username);
2659
+ const ownedOffers = await storage.getOffersByUsername(name);
2660
+ const answeredByUser = await storage.getOffersAnsweredBy(name);
2661
+ const allOfferIds = [
2662
+ ...ownedOffers.map((offer) => offer.id),
2663
+ ...answeredByUser.map((offer) => offer.id)
2664
+ ];
2665
+ const offerIds = [...new Set(allOfferIds)];
2666
+ const iceCandidatesMap = await storage.getIceCandidatesForMultipleOffers(
2667
+ offerIds,
2668
+ name,
2669
+ sinceTimestamp
2670
+ );
957
2671
  const iceCandidatesByOffer = {};
958
- for (const offer of allOffers) {
959
- const offererCandidates = await storage.getIceCandidates(
960
- offer.id,
961
- "offerer",
962
- sinceTimestamp
963
- );
964
- const answererCandidates = await storage.getIceCandidates(
965
- offer.id,
966
- "answerer",
967
- sinceTimestamp
968
- );
969
- const allCandidates = [
970
- ...offererCandidates.map((c) => ({
971
- ...c,
972
- role: "offerer"
973
- })),
974
- ...answererCandidates.map((c) => ({
975
- ...c,
976
- role: "answerer"
977
- }))
978
- ];
979
- if (allCandidates.length > 0) {
980
- const isOfferer = offer.username === username;
981
- const filtered = allCandidates.filter(
982
- (c) => isOfferer ? c.role === "answerer" : c.role === "offerer"
983
- );
984
- if (filtered.length > 0) {
985
- iceCandidatesByOffer[offer.id] = filtered;
986
- }
987
- }
2672
+ for (const [offerId, candidates] of iceCandidatesMap.entries()) {
2673
+ iceCandidatesByOffer[offerId] = candidates;
988
2674
  }
989
2675
  return {
990
2676
  answers: filteredAnswers.map((offer) => ({
991
2677
  offerId: offer.id,
992
- serviceId: offer.serviceId,
993
2678
  answererId: offer.answererUsername,
994
2679
  sdp: offer.answerSdp,
995
2680
  answeredAt: offer.answeredAt
@@ -1000,32 +2685,60 @@ var handlers = {
1000
2685
  /**
1001
2686
  * Add ICE candidates
1002
2687
  */
1003
- async addIceCandidates(params, message, signature, publicKey, storage, config) {
1004
- const { serviceFqn, offerId, candidates } = params;
1005
- const username = extractUsername(message);
1006
- if (!username) {
1007
- throw new Error("Username required");
1008
- }
1009
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
1010
- if (!auth.valid) {
1011
- throw new Error(auth.error);
2688
+ async addIceCandidates(params, name, timestamp, signature, storage, config, request) {
2689
+ const { offerId, candidates } = params;
2690
+ validateStringParam(offerId, "offerId");
2691
+ if (!name) {
2692
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
1012
2693
  }
1013
2694
  if (!Array.isArray(candidates) || candidates.length === 0) {
1014
- throw new Error("Missing or invalid required parameter: candidates");
2695
+ throw new RpcError(ErrorCodes.MISSING_PARAMS, "Missing or invalid required parameter: candidates");
2696
+ }
2697
+ if (candidates.length > config.maxCandidatesPerRequest) {
2698
+ throw new RpcError(
2699
+ ErrorCodes.INVALID_PARAMS,
2700
+ `Too many candidates (max ${config.maxCandidatesPerRequest})`
2701
+ );
1015
2702
  }
1016
2703
  candidates.forEach((candidate, index) => {
1017
2704
  if (!candidate || typeof candidate !== "object") {
1018
- throw new Error(`Invalid candidate at index ${index}: must be an object`);
2705
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid candidate at index ${index}: must be an object`);
2706
+ }
2707
+ const depth = getJsonDepth(candidate, config.maxCandidateDepth + 1);
2708
+ if (depth > config.maxCandidateDepth) {
2709
+ throw new RpcError(
2710
+ ErrorCodes.INVALID_PARAMS,
2711
+ `Candidate at index ${index} too deeply nested (max depth ${config.maxCandidateDepth})`
2712
+ );
2713
+ }
2714
+ let candidateJson;
2715
+ try {
2716
+ candidateJson = JSON.stringify(candidate);
2717
+ } catch (e) {
2718
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `Candidate at index ${index} is not serializable`);
2719
+ }
2720
+ if (candidateJson.length > config.maxCandidateSize) {
2721
+ throw new RpcError(
2722
+ ErrorCodes.INVALID_PARAMS,
2723
+ `Candidate at index ${index} too large (max ${config.maxCandidateSize} bytes)`
2724
+ );
1019
2725
  }
1020
2726
  });
1021
2727
  const offer = await storage.getOfferById(offerId);
1022
2728
  if (!offer) {
1023
- throw new Error("Offer not found");
2729
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
2730
+ }
2731
+ const currentCandidateCount = await storage.getIceCandidateCount(offerId);
2732
+ if (currentCandidateCount + candidates.length > config.maxIceCandidatesPerOffer) {
2733
+ throw new RpcError(
2734
+ ErrorCodes.TOO_MANY_ICE_CANDIDATES,
2735
+ `ICE candidate limit exceeded for offer. Current: ${currentCandidateCount}, limit: ${config.maxIceCandidatesPerOffer}.`
2736
+ );
1024
2737
  }
1025
- const role = offer.username === username ? "offerer" : "answerer";
2738
+ const role = offer.username === name ? "offerer" : "answerer";
1026
2739
  const count = await storage.addIceCandidates(
1027
2740
  offerId,
1028
- username,
2741
+ name,
1029
2742
  role,
1030
2743
  candidates
1031
2744
  );
@@ -1034,22 +2747,25 @@ var handlers = {
1034
2747
  /**
1035
2748
  * Get ICE candidates
1036
2749
  */
1037
- async getIceCandidates(params, message, signature, publicKey, storage, config) {
1038
- const { serviceFqn, offerId, since } = params;
1039
- const username = extractUsername(message);
1040
- if (!username) {
1041
- throw new Error("Username required");
2750
+ async getIceCandidates(params, name, timestamp, signature, storage, config, request) {
2751
+ const { offerId, since } = params;
2752
+ validateStringParam(offerId, "offerId");
2753
+ if (!name) {
2754
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
1042
2755
  }
1043
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
1044
- if (!auth.valid) {
1045
- throw new Error(auth.error);
2756
+ if (since !== void 0 && (typeof since !== "number" || since < 0 || !Number.isFinite(since))) {
2757
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Invalid since parameter: must be a non-negative number");
1046
2758
  }
1047
- const sinceTimestamp = since || 0;
2759
+ const sinceTimestamp = since !== void 0 ? since : 0;
1048
2760
  const offer = await storage.getOfferById(offerId);
1049
2761
  if (!offer) {
1050
- throw new Error("Offer not found");
2762
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
2763
+ }
2764
+ const isOfferer = offer.username === name;
2765
+ const isAnswerer = offer.answererUsername === name;
2766
+ if (!isOfferer && !isAnswerer) {
2767
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, "Not authorized to access ICE candidates for this offer");
1051
2768
  }
1052
- const isOfferer = offer.username === username;
1053
2769
  const role = isOfferer ? "answerer" : "offerer";
1054
2770
  const candidates = await storage.getIceCandidates(
1055
2771
  offerId,
@@ -1065,64 +2781,165 @@ var handlers = {
1065
2781
  };
1066
2782
  }
1067
2783
  };
1068
- async function handleRpc(requests, storage, config) {
2784
+ var UNAUTHENTICATED_METHODS = /* @__PURE__ */ new Set(["generateCredentials", "discover"]);
2785
+ async function handleRpc(requests, ctx, storage, config) {
1069
2786
  const responses = [];
2787
+ const clientIp = ctx.req.header("cf-connecting-ip") || // Cloudflare
2788
+ ctx.req.header("x-real-ip") || // Nginx
2789
+ ctx.req.header("x-forwarded-for")?.split(",")[0].trim() || void 0;
2790
+ if (clientIp) {
2791
+ const rateLimitKey = `req:${clientIp}`;
2792
+ const allowed = await storage.checkRateLimit(
2793
+ rateLimitKey,
2794
+ config.requestsPerIpPerSecond,
2795
+ REQUEST_RATE_WINDOW
2796
+ );
2797
+ if (!allowed) {
2798
+ return requests.map(() => ({
2799
+ success: false,
2800
+ error: `Rate limit exceeded. Maximum ${config.requestsPerIpPerSecond} requests per second per IP.`,
2801
+ errorCode: ErrorCodes.RATE_LIMIT_EXCEEDED
2802
+ }));
2803
+ }
2804
+ }
2805
+ const name = ctx.req.header("X-Name");
2806
+ const timestampHeader = ctx.req.header("X-Timestamp");
2807
+ const nonce = ctx.req.header("X-Nonce");
2808
+ const signature = ctx.req.header("X-Signature");
2809
+ const timestamp = timestampHeader ? parseInt(timestampHeader, 10) : 0;
2810
+ let totalOperations = 0;
2811
+ for (const request of requests) {
2812
+ const { method, params } = request;
2813
+ if (method === "publishOffer" && params?.offers && Array.isArray(params.offers)) {
2814
+ totalOperations += params.offers.length;
2815
+ } else if (method === "addIceCandidates" && params?.candidates && Array.isArray(params.candidates)) {
2816
+ totalOperations += params.candidates.length;
2817
+ } else {
2818
+ totalOperations += 1;
2819
+ }
2820
+ }
2821
+ if (totalOperations > config.maxTotalOperations) {
2822
+ return requests.map(() => ({
2823
+ success: false,
2824
+ error: `Total operations across batch exceed limit: ${totalOperations} > ${config.maxTotalOperations}`,
2825
+ errorCode: ErrorCodes.BATCH_TOO_LARGE
2826
+ }));
2827
+ }
1070
2828
  for (const request of requests) {
1071
2829
  try {
1072
- const { method, message, signature, publicKey, params } = request;
2830
+ const { method, params } = request;
1073
2831
  if (!method || typeof method !== "string") {
1074
2832
  responses.push({
1075
2833
  success: false,
1076
- error: "Missing or invalid method"
2834
+ error: "Missing or invalid method",
2835
+ errorCode: ErrorCodes.INVALID_PARAMS
1077
2836
  });
1078
2837
  continue;
1079
2838
  }
1080
- if (!message || typeof message !== "string") {
2839
+ const handler = handlers[method];
2840
+ if (!handler) {
1081
2841
  responses.push({
1082
2842
  success: false,
1083
- error: "Missing or invalid message"
2843
+ error: `Unknown method: ${method}`,
2844
+ errorCode: ErrorCodes.UNKNOWN_METHOD
1084
2845
  });
1085
2846
  continue;
1086
2847
  }
1087
- if (!signature || typeof signature !== "string") {
2848
+ const requiresAuth = !UNAUTHENTICATED_METHODS.has(method);
2849
+ if (requiresAuth) {
2850
+ if (!name || typeof name !== "string") {
2851
+ responses.push({
2852
+ success: false,
2853
+ error: "Missing or invalid X-Name header",
2854
+ errorCode: ErrorCodes.AUTH_REQUIRED
2855
+ });
2856
+ continue;
2857
+ }
2858
+ if (!timestampHeader || typeof timestampHeader !== "string" || isNaN(timestamp)) {
2859
+ responses.push({
2860
+ success: false,
2861
+ error: "Missing or invalid X-Timestamp header",
2862
+ errorCode: ErrorCodes.AUTH_REQUIRED
2863
+ });
2864
+ continue;
2865
+ }
2866
+ if (!nonce || typeof nonce !== "string") {
2867
+ responses.push({
2868
+ success: false,
2869
+ error: "Missing or invalid X-Nonce header (use crypto.randomUUID())",
2870
+ errorCode: ErrorCodes.AUTH_REQUIRED
2871
+ });
2872
+ continue;
2873
+ }
2874
+ if (!signature || typeof signature !== "string") {
2875
+ responses.push({
2876
+ success: false,
2877
+ error: "Missing or invalid X-Signature header",
2878
+ errorCode: ErrorCodes.AUTH_REQUIRED
2879
+ });
2880
+ continue;
2881
+ }
2882
+ await verifyRequestSignature(
2883
+ name,
2884
+ timestamp,
2885
+ nonce,
2886
+ signature,
2887
+ method,
2888
+ params,
2889
+ storage,
2890
+ config
2891
+ );
2892
+ const result = await handler(
2893
+ params || {},
2894
+ name,
2895
+ timestamp,
2896
+ signature,
2897
+ storage,
2898
+ config,
2899
+ { ...request, clientIp }
2900
+ );
2901
+ responses.push({
2902
+ success: true,
2903
+ result
2904
+ });
2905
+ } else {
2906
+ const result = await handler(
2907
+ params || {},
2908
+ name || "",
2909
+ 0,
2910
+ // timestamp
2911
+ "",
2912
+ // signature
2913
+ storage,
2914
+ config,
2915
+ { ...request, clientIp }
2916
+ );
2917
+ responses.push({
2918
+ success: true,
2919
+ result
2920
+ });
2921
+ }
2922
+ } catch (err) {
2923
+ if (err instanceof RpcError) {
1088
2924
  responses.push({
1089
2925
  success: false,
1090
- error: "Missing or invalid signature"
2926
+ error: err.message,
2927
+ errorCode: err.errorCode
1091
2928
  });
1092
- continue;
1093
- }
1094
- const handler = handlers[method];
1095
- if (!handler) {
2929
+ } else {
2930
+ console.error("Unexpected RPC error:", err);
1096
2931
  responses.push({
1097
2932
  success: false,
1098
- error: `Unknown method: ${method}`
2933
+ error: "Internal server error",
2934
+ errorCode: ErrorCodes.INTERNAL_ERROR
1099
2935
  });
1100
- continue;
1101
2936
  }
1102
- const result = await handler(
1103
- params || {},
1104
- message,
1105
- signature,
1106
- publicKey,
1107
- storage,
1108
- config
1109
- );
1110
- responses.push({
1111
- success: true,
1112
- result
1113
- });
1114
- } catch (err2) {
1115
- responses.push({
1116
- success: false,
1117
- error: err2.message || "Internal server error"
1118
- });
1119
2937
  }
1120
2938
  }
1121
2939
  return responses;
1122
2940
  }
1123
2941
 
1124
2942
  // src/app.ts
1125
- var MAX_BATCH_SIZE = 100;
1126
2943
  function createApp(storage, config) {
1127
2944
  const app = new import_hono.Hono();
1128
2945
  app.use("/*", (0, import_cors.cors)({
@@ -1136,7 +2953,7 @@ function createApp(storage, config) {
1136
2953
  return config.corsOrigins[0];
1137
2954
  },
1138
2955
  allowMethods: ["GET", "POST", "OPTIONS"],
1139
- allowHeaders: ["Content-Type", "Origin"],
2956
+ allowHeaders: ["Content-Type", "Origin", "X-Name", "X-Timestamp", "X-Nonce", "X-Signature"],
1140
2957
  exposeHeaders: ["Content-Type"],
1141
2958
  credentials: false,
1142
2959
  maxAge: 86400
@@ -1145,7 +2962,7 @@ function createApp(storage, config) {
1145
2962
  return c.json({
1146
2963
  version: config.version,
1147
2964
  name: "Rondevu",
1148
- description: "WebRTC signaling with RPC interface and Ed25519 authentication"
2965
+ description: "WebRTC signaling with RPC interface and HMAC signature-based authentication"
1149
2966
  }, 200);
1150
2967
  });
1151
2968
  app.get("/health", (c) => {
@@ -1158,21 +2975,38 @@ function createApp(storage, config) {
1158
2975
  app.post("/rpc", async (c) => {
1159
2976
  try {
1160
2977
  const body = await c.req.json();
1161
- const requests = Array.isArray(body) ? body : [body];
2978
+ if (!Array.isArray(body)) {
2979
+ return c.json([{
2980
+ success: false,
2981
+ error: "Request must be an array of RPC calls",
2982
+ errorCode: "INVALID_PARAMS"
2983
+ }], 400);
2984
+ }
2985
+ const requests = body;
1162
2986
  if (requests.length === 0) {
1163
- return c.json({ error: "Empty request array" }, 400);
2987
+ return c.json([{
2988
+ success: false,
2989
+ error: "Empty request array",
2990
+ errorCode: "INVALID_PARAMS"
2991
+ }], 400);
1164
2992
  }
1165
- if (requests.length > MAX_BATCH_SIZE) {
1166
- return c.json({ error: `Too many requests in batch (max ${MAX_BATCH_SIZE})` }, 400);
2993
+ if (requests.length > config.maxBatchSize) {
2994
+ return c.json([{
2995
+ success: false,
2996
+ error: `Too many requests in batch (max ${config.maxBatchSize})`,
2997
+ errorCode: "BATCH_TOO_LARGE"
2998
+ }], 413);
1167
2999
  }
1168
- const responses = await handleRpc(requests, storage, config);
1169
- return c.json(Array.isArray(body) ? responses : responses[0], 200);
1170
- } catch (err2) {
1171
- console.error("RPC error:", err2);
1172
- return c.json({
3000
+ const responses = await handleRpc(requests, c, storage, config);
3001
+ return c.json(responses, 200);
3002
+ } catch (err) {
3003
+ console.error("RPC error:", err);
3004
+ const errorMsg = err instanceof SyntaxError ? "Invalid JSON in request body" : "Request must be valid JSON array";
3005
+ return c.json([{
1173
3006
  success: false,
1174
- error: "Invalid request format"
1175
- }, 400);
3007
+ error: errorMsg,
3008
+ errorCode: "INVALID_PARAMS"
3009
+ }], 400);
1176
3010
  }
1177
3011
  });
1178
3012
  app.all("*", (c) => {
@@ -1185,521 +3019,138 @@ function createApp(storage, config) {
1185
3019
 
1186
3020
  // src/config.ts
1187
3021
  function loadConfig() {
1188
- return {
1189
- port: parseInt(process.env.PORT || "3000", 10),
1190
- storageType: process.env.STORAGE_TYPE || "sqlite",
3022
+ let masterEncryptionKey = process.env.MASTER_ENCRYPTION_KEY;
3023
+ if (!masterEncryptionKey) {
3024
+ const isDevelopment = process.env.NODE_ENV === "development";
3025
+ if (!isDevelopment) {
3026
+ throw new Error(
3027
+ "MASTER_ENCRYPTION_KEY environment variable must be set. Generate with: openssl rand -hex 32\nFor development only, set NODE_ENV=development to use insecure dev key."
3028
+ );
3029
+ }
3030
+ console.error("\u26A0\uFE0F WARNING: Using insecure deterministic development key");
3031
+ console.error("\u26A0\uFE0F ONLY use NODE_ENV=development for local development");
3032
+ console.error("\u26A0\uFE0F Generate production key with: openssl rand -hex 32");
3033
+ masterEncryptionKey = "a3f8b9c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0";
3034
+ }
3035
+ if (masterEncryptionKey.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(masterEncryptionKey)) {
3036
+ throw new Error("MASTER_ENCRYPTION_KEY must be 64-character hex string (32 bytes). Generate with: openssl rand -hex 32");
3037
+ }
3038
+ function parsePositiveInt(value, defaultValue, name, min = 1) {
3039
+ const parsed = parseInt(value || defaultValue, 10);
3040
+ if (isNaN(parsed)) {
3041
+ throw new Error(`${name} must be a valid integer (got: ${value})`);
3042
+ }
3043
+ if (parsed < min) {
3044
+ throw new Error(`${name} must be >= ${min} (got: ${parsed})`);
3045
+ }
3046
+ return parsed;
3047
+ }
3048
+ const config = {
3049
+ port: parsePositiveInt(process.env.PORT, "3000", "PORT", 1),
3050
+ storageType: process.env.STORAGE_TYPE || "memory",
1191
3051
  storagePath: process.env.STORAGE_PATH || ":memory:",
3052
+ databaseUrl: process.env.DATABASE_URL || "",
3053
+ dbPoolSize: parsePositiveInt(process.env.DB_POOL_SIZE, "10", "DB_POOL_SIZE", 1),
1192
3054
  corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) : ["*"],
1193
3055
  version: process.env.VERSION || "unknown",
1194
- offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || "60000", 10),
1195
- offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || "86400000", 10),
1196
- offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || "60000", 10),
1197
- cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || "60000", 10),
1198
- maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || "100", 10)
3056
+ offerDefaultTtl: parsePositiveInt(process.env.OFFER_DEFAULT_TTL, "60000", "OFFER_DEFAULT_TTL", 1e3),
3057
+ offerMaxTtl: parsePositiveInt(process.env.OFFER_MAX_TTL, "86400000", "OFFER_MAX_TTL", 1e3),
3058
+ offerMinTtl: parsePositiveInt(process.env.OFFER_MIN_TTL, "60000", "OFFER_MIN_TTL", 1e3),
3059
+ cleanupInterval: parsePositiveInt(process.env.CLEANUP_INTERVAL, "60000", "CLEANUP_INTERVAL", 1e3),
3060
+ maxOffersPerRequest: parsePositiveInt(process.env.MAX_OFFERS_PER_REQUEST, "100", "MAX_OFFERS_PER_REQUEST", 1),
3061
+ maxBatchSize: parsePositiveInt(process.env.MAX_BATCH_SIZE, "100", "MAX_BATCH_SIZE", 1),
3062
+ maxSdpSize: parsePositiveInt(process.env.MAX_SDP_SIZE, String(64 * 1024), "MAX_SDP_SIZE", 1024),
3063
+ // Min 1KB
3064
+ maxCandidateSize: parsePositiveInt(process.env.MAX_CANDIDATE_SIZE, String(4 * 1024), "MAX_CANDIDATE_SIZE", 256),
3065
+ // Min 256 bytes
3066
+ maxCandidateDepth: parsePositiveInt(process.env.MAX_CANDIDATE_DEPTH, "10", "MAX_CANDIDATE_DEPTH", 1),
3067
+ maxCandidatesPerRequest: parsePositiveInt(process.env.MAX_CANDIDATES_PER_REQUEST, "100", "MAX_CANDIDATES_PER_REQUEST", 1),
3068
+ maxTotalOperations: parsePositiveInt(process.env.MAX_TOTAL_OPERATIONS, "1000", "MAX_TOTAL_OPERATIONS", 1),
3069
+ timestampMaxAge: parsePositiveInt(process.env.TIMESTAMP_MAX_AGE, "60000", "TIMESTAMP_MAX_AGE", 1e3),
3070
+ // Min 1 second
3071
+ timestampMaxFuture: parsePositiveInt(process.env.TIMESTAMP_MAX_FUTURE, "60000", "TIMESTAMP_MAX_FUTURE", 1e3),
3072
+ // Min 1 second
3073
+ masterEncryptionKey,
3074
+ // Resource limits
3075
+ maxOffersPerUser: parsePositiveInt(process.env.MAX_OFFERS_PER_USER, "20", "MAX_OFFERS_PER_USER", 1),
3076
+ maxTotalOffers: parsePositiveInt(process.env.MAX_TOTAL_OFFERS, "10000", "MAX_TOTAL_OFFERS", 1),
3077
+ maxTotalCredentials: parsePositiveInt(process.env.MAX_TOTAL_CREDENTIALS, "50000", "MAX_TOTAL_CREDENTIALS", 1),
3078
+ maxIceCandidatesPerOffer: parsePositiveInt(process.env.MAX_ICE_CANDIDATES_PER_OFFER, "50", "MAX_ICE_CANDIDATES_PER_OFFER", 1),
3079
+ credentialsPerIpPerHour: parsePositiveInt(process.env.CREDENTIALS_PER_IP_PER_HOUR, "10", "CREDENTIALS_PER_IP_PER_HOUR", 1),
3080
+ requestsPerIpPerSecond: parsePositiveInt(process.env.REQUESTS_PER_IP_PER_SECOND, "50", "REQUESTS_PER_IP_PER_SECOND", 1)
1199
3081
  };
3082
+ return config;
1200
3083
  }
1201
-
1202
- // src/storage/sqlite.ts
1203
- var import_better_sqlite3 = __toESM(require("better-sqlite3"));
1204
- var import_node_crypto = require("node:crypto");
1205
-
1206
- // src/storage/hash-id.ts
1207
- async function generateOfferHash(sdp) {
1208
- const sanitizedOffer = {
1209
- sdp
1210
- };
1211
- const jsonString = JSON.stringify(sanitizedOffer);
1212
- const encoder = new TextEncoder();
1213
- const data = encoder.encode(jsonString);
1214
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1215
- const hashArray = Array.from(new Uint8Array(hashBuffer));
1216
- const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
1217
- return hashHex;
3084
+ var CONFIG_DEFAULTS = {
3085
+ offerDefaultTtl: 6e4,
3086
+ offerMaxTtl: 864e5,
3087
+ offerMinTtl: 6e4,
3088
+ cleanupInterval: 6e4,
3089
+ maxOffersPerRequest: 100,
3090
+ maxBatchSize: 100,
3091
+ maxSdpSize: 64 * 1024,
3092
+ maxCandidateSize: 4 * 1024,
3093
+ maxCandidateDepth: 10,
3094
+ maxCandidatesPerRequest: 100,
3095
+ maxTotalOperations: 1e3,
3096
+ timestampMaxAge: 6e4,
3097
+ timestampMaxFuture: 6e4,
3098
+ // Resource limits
3099
+ maxOffersPerUser: 20,
3100
+ maxTotalOffers: 1e4,
3101
+ maxTotalCredentials: 5e4,
3102
+ maxIceCandidatesPerOffer: 50,
3103
+ credentialsPerIpPerHour: 10,
3104
+ requestsPerIpPerSecond: 50
3105
+ };
3106
+ async function runCleanup(storage, now) {
3107
+ const offers = await storage.deleteExpiredOffers(now);
3108
+ const credentials = await storage.deleteExpiredCredentials(now);
3109
+ const rateLimits = await storage.deleteExpiredRateLimits(now);
3110
+ const nonces = await storage.deleteExpiredNonces(now);
3111
+ return { offers, credentials, rateLimits, nonces };
1218
3112
  }
1219
3113
 
1220
- // src/storage/sqlite.ts
1221
- var YEAR_IN_MS = 365 * 24 * 60 * 60 * 1e3;
1222
- var SQLiteStorage = class {
1223
- /**
1224
- * Creates a new SQLite storage instance
1225
- * @param path Path to SQLite database file, or ':memory:' for in-memory database
1226
- */
1227
- constructor(path = ":memory:") {
1228
- this.db = new import_better_sqlite3.default(path);
1229
- this.initializeDatabase();
1230
- }
1231
- /**
1232
- * Initializes database schema with username and service-based structure
1233
- */
1234
- initializeDatabase() {
1235
- this.db.exec(`
1236
- -- WebRTC signaling offers
1237
- CREATE TABLE IF NOT EXISTS offers (
1238
- id TEXT PRIMARY KEY,
1239
- username TEXT NOT NULL,
1240
- service_id TEXT,
1241
- sdp TEXT NOT NULL,
1242
- created_at INTEGER NOT NULL,
1243
- expires_at INTEGER NOT NULL,
1244
- last_seen INTEGER NOT NULL,
1245
- answerer_username TEXT,
1246
- answer_sdp TEXT,
1247
- answered_at INTEGER,
1248
- FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
1249
- );
1250
-
1251
- CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
1252
- CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
1253
- CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
1254
- CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
1255
- CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
1256
-
1257
- -- ICE candidates table
1258
- CREATE TABLE IF NOT EXISTS ice_candidates (
1259
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1260
- offer_id TEXT NOT NULL,
1261
- username TEXT NOT NULL,
1262
- role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
1263
- candidate TEXT NOT NULL,
1264
- created_at INTEGER NOT NULL,
1265
- FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
1266
- );
1267
-
1268
- CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
1269
- CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
1270
- CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
1271
-
1272
- -- Usernames table
1273
- CREATE TABLE IF NOT EXISTS usernames (
1274
- username TEXT PRIMARY KEY,
1275
- public_key TEXT NOT NULL UNIQUE,
1276
- claimed_at INTEGER NOT NULL,
1277
- expires_at INTEGER NOT NULL,
1278
- last_used INTEGER NOT NULL,
1279
- metadata TEXT,
1280
- CHECK(length(username) >= 3 AND length(username) <= 32)
1281
- );
1282
-
1283
- CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
1284
- CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
1285
-
1286
- -- Services table (new schema with extracted fields for discovery)
1287
- CREATE TABLE IF NOT EXISTS services (
1288
- id TEXT PRIMARY KEY,
1289
- service_fqn TEXT NOT NULL,
1290
- service_name TEXT NOT NULL,
1291
- version TEXT NOT NULL,
1292
- username TEXT NOT NULL,
1293
- created_at INTEGER NOT NULL,
1294
- expires_at INTEGER NOT NULL,
1295
- FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
1296
- UNIQUE(service_fqn)
3114
+ // src/storage/factory.ts
3115
+ async function createStorage(config) {
3116
+ switch (config.type) {
3117
+ case "memory": {
3118
+ const { MemoryStorage: MemoryStorage2 } = await Promise.resolve().then(() => (init_memory(), memory_exports));
3119
+ return new MemoryStorage2(config.masterEncryptionKey);
3120
+ }
3121
+ case "sqlite": {
3122
+ const { SQLiteStorage: SQLiteStorage2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
3123
+ return new SQLiteStorage2(
3124
+ config.sqlitePath || ":memory:",
3125
+ config.masterEncryptionKey
1297
3126
  );
1298
-
1299
- CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
1300
- CREATE INDEX IF NOT EXISTS idx_services_discovery ON services(service_name, version);
1301
- CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
1302
- CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
1303
- `);
1304
- this.db.pragma("foreign_keys = ON");
1305
- }
1306
- // ===== Offer Management =====
1307
- async createOffers(offers) {
1308
- const created = [];
1309
- const offersWithIds = await Promise.all(
1310
- offers.map(async (offer) => ({
1311
- ...offer,
1312
- id: offer.id || await generateOfferHash(offer.sdp)
1313
- }))
1314
- );
1315
- const transaction = this.db.transaction((offersWithIds2) => {
1316
- const offerStmt = this.db.prepare(`
1317
- INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen)
1318
- VALUES (?, ?, ?, ?, ?, ?, ?)
1319
- `);
1320
- for (const offer of offersWithIds2) {
1321
- const now = Date.now();
1322
- offerStmt.run(
1323
- offer.id,
1324
- offer.username,
1325
- offer.serviceId || null,
1326
- offer.sdp,
1327
- now,
1328
- offer.expiresAt,
1329
- now
1330
- );
1331
- created.push({
1332
- id: offer.id,
1333
- username: offer.username,
1334
- serviceId: offer.serviceId || void 0,
1335
- serviceFqn: offer.serviceFqn,
1336
- sdp: offer.sdp,
1337
- createdAt: now,
1338
- expiresAt: offer.expiresAt,
1339
- lastSeen: now
1340
- });
1341
- }
1342
- });
1343
- transaction(offersWithIds);
1344
- return created;
1345
- }
1346
- async getOffersByUsername(username) {
1347
- const stmt = this.db.prepare(`
1348
- SELECT * FROM offers
1349
- WHERE username = ? AND expires_at > ?
1350
- ORDER BY last_seen DESC
1351
- `);
1352
- const rows = stmt.all(username, Date.now());
1353
- return rows.map((row) => this.rowToOffer(row));
1354
- }
1355
- async getOfferById(offerId) {
1356
- const stmt = this.db.prepare(`
1357
- SELECT * FROM offers
1358
- WHERE id = ? AND expires_at > ?
1359
- `);
1360
- const row = stmt.get(offerId, Date.now());
1361
- if (!row) {
1362
- return null;
1363
- }
1364
- return this.rowToOffer(row);
1365
- }
1366
- async deleteOffer(offerId, ownerUsername) {
1367
- const stmt = this.db.prepare(`
1368
- DELETE FROM offers
1369
- WHERE id = ? AND username = ?
1370
- `);
1371
- const result = stmt.run(offerId, ownerUsername);
1372
- return result.changes > 0;
1373
- }
1374
- async deleteExpiredOffers(now) {
1375
- const stmt = this.db.prepare("DELETE FROM offers WHERE expires_at < ?");
1376
- const result = stmt.run(now);
1377
- return result.changes;
1378
- }
1379
- async answerOffer(offerId, answererUsername, answerSdp) {
1380
- const offer = await this.getOfferById(offerId);
1381
- if (!offer) {
1382
- return {
1383
- success: false,
1384
- error: "Offer not found or expired"
1385
- };
1386
- }
1387
- if (offer.answererUsername) {
1388
- return {
1389
- success: false,
1390
- error: "Offer already answered"
1391
- };
1392
- }
1393
- const stmt = this.db.prepare(`
1394
- UPDATE offers
1395
- SET answerer_username = ?, answer_sdp = ?, answered_at = ?
1396
- WHERE id = ? AND answerer_username IS NULL
1397
- `);
1398
- const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
1399
- if (result.changes === 0) {
1400
- return {
1401
- success: false,
1402
- error: "Offer already answered (race condition)"
1403
- };
1404
3127
  }
1405
- return { success: true };
1406
- }
1407
- async getAnsweredOffers(offererUsername) {
1408
- const stmt = this.db.prepare(`
1409
- SELECT * FROM offers
1410
- WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
1411
- ORDER BY answered_at DESC
1412
- `);
1413
- const rows = stmt.all(offererUsername, Date.now());
1414
- return rows.map((row) => this.rowToOffer(row));
1415
- }
1416
- // ===== ICE Candidate Management =====
1417
- async addIceCandidates(offerId, username, role, candidates) {
1418
- const stmt = this.db.prepare(`
1419
- INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
1420
- VALUES (?, ?, ?, ?, ?)
1421
- `);
1422
- const baseTimestamp = Date.now();
1423
- const transaction = this.db.transaction((candidates2) => {
1424
- for (let i = 0; i < candidates2.length; i++) {
1425
- stmt.run(
1426
- offerId,
1427
- username,
1428
- role,
1429
- JSON.stringify(candidates2[i]),
1430
- baseTimestamp + i
1431
- );
3128
+ case "mysql": {
3129
+ if (!config.connectionString) {
3130
+ throw new Error("MySQL storage requires DATABASE_URL connection string");
1432
3131
  }
1433
- });
1434
- transaction(candidates);
1435
- return candidates.length;
1436
- }
1437
- async getIceCandidates(offerId, targetRole, since) {
1438
- let query = `
1439
- SELECT * FROM ice_candidates
1440
- WHERE offer_id = ? AND role = ?
1441
- `;
1442
- const params = [offerId, targetRole];
1443
- if (since !== void 0) {
1444
- query += " AND created_at > ?";
1445
- params.push(since);
1446
- }
1447
- query += " ORDER BY created_at ASC";
1448
- const stmt = this.db.prepare(query);
1449
- const rows = stmt.all(...params);
1450
- return rows.map((row) => ({
1451
- id: row.id,
1452
- offerId: row.offer_id,
1453
- username: row.username,
1454
- role: row.role,
1455
- candidate: JSON.parse(row.candidate),
1456
- createdAt: row.created_at
1457
- }));
1458
- }
1459
- // ===== Username Management =====
1460
- async claimUsername(request) {
1461
- const now = Date.now();
1462
- const expiresAt = now + YEAR_IN_MS;
1463
- const stmt = this.db.prepare(`
1464
- INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata)
1465
- VALUES (?, ?, ?, ?, ?, NULL)
1466
- ON CONFLICT(username) DO UPDATE SET
1467
- expires_at = ?,
1468
- last_used = ?
1469
- WHERE public_key = ?
1470
- `);
1471
- const result = stmt.run(
1472
- request.username,
1473
- request.publicKey,
1474
- now,
1475
- expiresAt,
1476
- now,
1477
- expiresAt,
1478
- now,
1479
- request.publicKey
1480
- );
1481
- if (result.changes === 0) {
1482
- throw new Error("Username already claimed by different public key");
1483
- }
1484
- return {
1485
- username: request.username,
1486
- publicKey: request.publicKey,
1487
- claimedAt: now,
1488
- expiresAt,
1489
- lastUsed: now
1490
- };
1491
- }
1492
- async getUsername(username) {
1493
- const stmt = this.db.prepare(`
1494
- SELECT * FROM usernames
1495
- WHERE username = ? AND expires_at > ?
1496
- `);
1497
- const row = stmt.get(username, Date.now());
1498
- if (!row) {
1499
- return null;
1500
- }
1501
- return {
1502
- username: row.username,
1503
- publicKey: row.public_key,
1504
- claimedAt: row.claimed_at,
1505
- expiresAt: row.expires_at,
1506
- lastUsed: row.last_used,
1507
- metadata: row.metadata || void 0
1508
- };
1509
- }
1510
- async touchUsername(username) {
1511
- const now = Date.now();
1512
- const expiresAt = now + YEAR_IN_MS;
1513
- const stmt = this.db.prepare(`
1514
- UPDATE usernames
1515
- SET last_used = ?, expires_at = ?
1516
- WHERE username = ? AND expires_at > ?
1517
- `);
1518
- const result = stmt.run(now, expiresAt, username, now);
1519
- return result.changes > 0;
1520
- }
1521
- async deleteExpiredUsernames(now) {
1522
- const stmt = this.db.prepare("DELETE FROM usernames WHERE expires_at < ?");
1523
- const result = stmt.run(now);
1524
- return result.changes;
1525
- }
1526
- // ===== Service Management =====
1527
- async createService(request) {
1528
- const serviceId = (0, import_node_crypto.randomUUID)();
1529
- const now = Date.now();
1530
- const parsed = parseServiceFqn(request.serviceFqn);
1531
- if (!parsed) {
1532
- throw new Error(`Invalid service FQN: ${request.serviceFqn}`);
1533
- }
1534
- if (!parsed.username) {
1535
- throw new Error(`Service FQN must include username: ${request.serviceFqn}`);
1536
- }
1537
- const { serviceName, version, username } = parsed;
1538
- const transaction = this.db.transaction(() => {
1539
- const existingService = this.db.prepare(`
1540
- SELECT id FROM services
1541
- WHERE service_name = ? AND version = ? AND username = ?
1542
- `).get(serviceName, version, username);
1543
- if (existingService) {
1544
- this.db.prepare(`
1545
- DELETE FROM offers WHERE service_id = ?
1546
- `).run(existingService.id);
1547
- this.db.prepare(`
1548
- DELETE FROM services WHERE id = ?
1549
- `).run(existingService.id);
1550
- }
1551
- this.db.prepare(`
1552
- INSERT INTO services (id, service_fqn, service_name, version, username, created_at, expires_at)
1553
- VALUES (?, ?, ?, ?, ?, ?, ?)
1554
- `).run(
1555
- serviceId,
1556
- request.serviceFqn,
1557
- serviceName,
1558
- version,
1559
- username,
1560
- now,
1561
- request.expiresAt
3132
+ const { MySQLStorage: MySQLStorage2 } = await Promise.resolve().then(() => (init_mysql(), mysql_exports));
3133
+ return MySQLStorage2.create(
3134
+ config.connectionString,
3135
+ config.masterEncryptionKey,
3136
+ config.poolSize || 10
1562
3137
  );
1563
- const expiresAt = now + YEAR_IN_MS;
1564
- this.db.prepare(`
1565
- UPDATE usernames
1566
- SET last_used = ?, expires_at = ?
1567
- WHERE username = ? AND expires_at > ?
1568
- `).run(now, expiresAt, username, now);
1569
- });
1570
- transaction();
1571
- const offerRequests = request.offers.map((offer) => ({
1572
- ...offer,
1573
- serviceId
1574
- }));
1575
- const offers = await this.createOffers(offerRequests);
1576
- return {
1577
- service: {
1578
- id: serviceId,
1579
- serviceFqn: request.serviceFqn,
1580
- serviceName,
1581
- version,
1582
- username,
1583
- createdAt: now,
1584
- expiresAt: request.expiresAt
1585
- },
1586
- offers
1587
- };
1588
- }
1589
- async getOffersForService(serviceId) {
1590
- const stmt = this.db.prepare(`
1591
- SELECT * FROM offers
1592
- WHERE service_id = ? AND expires_at > ?
1593
- ORDER BY created_at ASC
1594
- `);
1595
- const rows = stmt.all(serviceId, Date.now());
1596
- return rows.map((row) => this.rowToOffer(row));
1597
- }
1598
- async getServiceById(serviceId) {
1599
- const stmt = this.db.prepare(`
1600
- SELECT * FROM services
1601
- WHERE id = ? AND expires_at > ?
1602
- `);
1603
- const row = stmt.get(serviceId, Date.now());
1604
- if (!row) {
1605
- return null;
1606
- }
1607
- return this.rowToService(row);
1608
- }
1609
- async getServiceByFqn(serviceFqn) {
1610
- const stmt = this.db.prepare(`
1611
- SELECT * FROM services
1612
- WHERE service_fqn = ? AND expires_at > ?
1613
- `);
1614
- const row = stmt.get(serviceFqn, Date.now());
1615
- if (!row) {
1616
- return null;
1617
3138
  }
1618
- return this.rowToService(row);
1619
- }
1620
- async discoverServices(serviceName, version, limit, offset) {
1621
- const stmt = this.db.prepare(`
1622
- SELECT DISTINCT s.* FROM services s
1623
- INNER JOIN offers o ON o.service_id = s.id
1624
- WHERE s.service_name = ?
1625
- AND s.version = ?
1626
- AND s.expires_at > ?
1627
- AND o.answerer_username IS NULL
1628
- AND o.expires_at > ?
1629
- ORDER BY s.created_at DESC
1630
- LIMIT ? OFFSET ?
1631
- `);
1632
- const rows = stmt.all(serviceName, version, Date.now(), Date.now(), limit, offset);
1633
- return rows.map((row) => this.rowToService(row));
1634
- }
1635
- async getRandomService(serviceName, version) {
1636
- const stmt = this.db.prepare(`
1637
- SELECT s.* FROM services s
1638
- INNER JOIN offers o ON o.service_id = s.id
1639
- WHERE s.service_name = ?
1640
- AND s.version = ?
1641
- AND s.expires_at > ?
1642
- AND o.answerer_username IS NULL
1643
- AND o.expires_at > ?
1644
- ORDER BY RANDOM()
1645
- LIMIT 1
1646
- `);
1647
- const row = stmt.get(serviceName, version, Date.now(), Date.now());
1648
- if (!row) {
1649
- return null;
3139
+ case "postgres": {
3140
+ if (!config.connectionString) {
3141
+ throw new Error("PostgreSQL storage requires DATABASE_URL connection string");
3142
+ }
3143
+ const { PostgreSQLStorage: PostgreSQLStorage2 } = await Promise.resolve().then(() => (init_postgres(), postgres_exports));
3144
+ return PostgreSQLStorage2.create(
3145
+ config.connectionString,
3146
+ config.masterEncryptionKey,
3147
+ config.poolSize || 10
3148
+ );
1650
3149
  }
1651
- return this.rowToService(row);
1652
- }
1653
- async deleteService(serviceId, username) {
1654
- const stmt = this.db.prepare(`
1655
- DELETE FROM services
1656
- WHERE id = ? AND username = ?
1657
- `);
1658
- const result = stmt.run(serviceId, username);
1659
- return result.changes > 0;
1660
- }
1661
- async deleteExpiredServices(now) {
1662
- const stmt = this.db.prepare("DELETE FROM services WHERE expires_at < ?");
1663
- const result = stmt.run(now);
1664
- return result.changes;
1665
- }
1666
- async close() {
1667
- this.db.close();
3150
+ default:
3151
+ throw new Error(`Unsupported storage type: ${config.type}`);
1668
3152
  }
1669
- // ===== Helper Methods =====
1670
- /**
1671
- * Helper method to convert database row to Offer object
1672
- */
1673
- rowToOffer(row) {
1674
- return {
1675
- id: row.id,
1676
- username: row.username,
1677
- serviceId: row.service_id || void 0,
1678
- serviceFqn: row.service_fqn || void 0,
1679
- sdp: row.sdp,
1680
- createdAt: row.created_at,
1681
- expiresAt: row.expires_at,
1682
- lastSeen: row.last_seen,
1683
- answererUsername: row.answerer_username || void 0,
1684
- answerSdp: row.answer_sdp || void 0,
1685
- answeredAt: row.answered_at || void 0
1686
- };
1687
- }
1688
- /**
1689
- * Helper method to convert database row to Service object
1690
- */
1691
- rowToService(row) {
1692
- return {
1693
- id: row.id,
1694
- serviceFqn: row.service_fqn,
1695
- serviceName: row.service_name,
1696
- version: row.version,
1697
- username: row.username,
1698
- createdAt: row.created_at,
1699
- expiresAt: row.expires_at
1700
- };
1701
- }
1702
- };
3153
+ }
1703
3154
 
1704
3155
  // src/index.ts
1705
3156
  async function main() {
@@ -1708,31 +3159,30 @@ async function main() {
1708
3159
  console.log("Configuration:", {
1709
3160
  port: config.port,
1710
3161
  storageType: config.storageType,
1711
- storagePath: config.storagePath,
3162
+ storagePath: config.storageType === "sqlite" ? config.storagePath : void 0,
3163
+ databaseUrl: config.databaseUrl ? "[configured]" : void 0,
3164
+ dbPoolSize: ["mysql", "postgres"].includes(config.storageType) ? config.dbPoolSize : void 0,
1712
3165
  offerDefaultTtl: `${config.offerDefaultTtl}ms`,
1713
- offerMaxTtl: `${config.offerMaxTtl}ms`,
1714
- offerMinTtl: `${config.offerMinTtl}ms`,
1715
3166
  cleanupInterval: `${config.cleanupInterval}ms`,
1716
- maxOffersPerRequest: config.maxOffersPerRequest,
1717
- corsOrigins: config.corsOrigins,
1718
3167
  version: config.version
1719
3168
  });
1720
- let storage;
1721
- if (config.storageType === "sqlite") {
1722
- storage = new SQLiteStorage(config.storagePath);
1723
- console.log("Using SQLite storage");
1724
- } else {
1725
- throw new Error("Unsupported storage type");
1726
- }
1727
- const cleanupInterval = setInterval(async () => {
3169
+ const storage = await createStorage({
3170
+ type: config.storageType,
3171
+ masterEncryptionKey: config.masterEncryptionKey,
3172
+ sqlitePath: config.storagePath,
3173
+ connectionString: config.databaseUrl,
3174
+ poolSize: config.dbPoolSize
3175
+ });
3176
+ console.log(`Using ${config.storageType} storage`);
3177
+ const cleanupTimer = setInterval(async () => {
1728
3178
  try {
1729
- const now = Date.now();
1730
- const deleted = await storage.deleteExpiredOffers(now);
1731
- if (deleted > 0) {
1732
- console.log(`Cleanup: Deleted ${deleted} expired offer(s)`);
3179
+ const result = await runCleanup(storage, Date.now());
3180
+ const total = result.offers + result.credentials + result.rateLimits + result.nonces;
3181
+ if (total > 0) {
3182
+ console.log(`Cleanup: ${result.offers} offers, ${result.credentials} credentials, ${result.rateLimits} rate limits, ${result.nonces} nonces`);
1733
3183
  }
1734
- } catch (err2) {
1735
- console.error("Cleanup error:", err2);
3184
+ } catch (err) {
3185
+ console.error("Cleanup error:", err);
1736
3186
  }
1737
3187
  }, config.cleanupInterval);
1738
3188
  const app = createApp(storage, config);
@@ -1744,20 +3194,15 @@ async function main() {
1744
3194
  console.log("Ready to accept connections");
1745
3195
  const shutdown = async () => {
1746
3196
  console.log("\nShutting down gracefully...");
1747
- clearInterval(cleanupInterval);
3197
+ clearInterval(cleanupTimer);
1748
3198
  await storage.close();
1749
3199
  process.exit(0);
1750
3200
  };
1751
3201
  process.on("SIGINT", shutdown);
1752
3202
  process.on("SIGTERM", shutdown);
1753
3203
  }
1754
- main().catch((err2) => {
1755
- console.error("Fatal error:", err2);
3204
+ main().catch((err) => {
3205
+ console.error("Fatal error:", err);
1756
3206
  process.exit(1);
1757
3207
  });
1758
- /*! Bundled license information:
1759
-
1760
- @noble/ed25519/index.js:
1761
- (*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) *)
1762
- */
1763
3208
  //# sourceMappingURL=index.js.map