@xtr-dev/rondevu-server 0.5.0 → 0.5.6

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,2183 @@ 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
+ // ===== Helper Methods =====
733
+ removeOfferFromIndexes(offer) {
734
+ const usernameOffers = this.offersByUsername.get(offer.username);
735
+ if (usernameOffers) {
736
+ usernameOffers.delete(offer.id);
737
+ if (usernameOffers.size === 0) {
738
+ this.offersByUsername.delete(offer.username);
739
+ }
740
+ }
741
+ for (const tag of offer.tags) {
742
+ const tagOffers = this.offersByTag.get(tag);
743
+ if (tagOffers) {
744
+ tagOffers.delete(offer.id);
745
+ if (tagOffers.size === 0) {
746
+ this.offersByTag.delete(tag);
747
+ }
748
+ }
749
+ }
750
+ if (offer.answererUsername) {
751
+ const answererOffers = this.offersByAnswerer.get(offer.answererUsername);
752
+ if (answererOffers) {
753
+ answererOffers.delete(offer.id);
754
+ if (answererOffers.size === 0) {
755
+ this.offersByAnswerer.delete(offer.answererUsername);
756
+ }
757
+ }
758
+ }
759
+ }
760
+ };
761
+ }
762
+ });
763
+
764
+ // src/storage/sqlite.ts
765
+ var sqlite_exports = {};
766
+ __export(sqlite_exports, {
767
+ SQLiteStorage: () => SQLiteStorage
768
+ });
769
+ var import_better_sqlite3, YEAR_IN_MS2, SQLiteStorage;
770
+ var init_sqlite = __esm({
771
+ "src/storage/sqlite.ts"() {
772
+ "use strict";
773
+ import_better_sqlite3 = __toESM(require("better-sqlite3"));
774
+ init_hash_id();
775
+ YEAR_IN_MS2 = 365 * 24 * 60 * 60 * 1e3;
776
+ SQLiteStorage = class {
777
+ /**
778
+ * Creates a new SQLite storage instance
779
+ * @param path Path to SQLite database file, or ':memory:' for in-memory database
780
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets (32 bytes)
781
+ */
782
+ constructor(path = ":memory:", masterEncryptionKey) {
783
+ this.db = new import_better_sqlite3.default(path);
784
+ this.masterEncryptionKey = masterEncryptionKey;
785
+ this.initializeDatabase();
786
+ }
787
+ /**
788
+ * Initializes database schema with tags-based offers
789
+ */
790
+ initializeDatabase() {
791
+ this.db.exec(`
792
+ -- WebRTC signaling offers with tags
793
+ CREATE TABLE IF NOT EXISTS offers (
794
+ id TEXT PRIMARY KEY,
795
+ username TEXT NOT NULL,
796
+ tags TEXT NOT NULL,
797
+ sdp TEXT NOT NULL,
798
+ created_at INTEGER NOT NULL,
799
+ expires_at INTEGER NOT NULL,
800
+ last_seen INTEGER NOT NULL,
801
+ answerer_username TEXT,
802
+ answer_sdp TEXT,
803
+ answered_at INTEGER
804
+ );
805
+
806
+ CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username);
807
+ CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
808
+ CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
809
+ CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username);
810
+
811
+ -- ICE candidates table
812
+ CREATE TABLE IF NOT EXISTS ice_candidates (
813
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
814
+ offer_id TEXT NOT NULL,
815
+ username TEXT NOT NULL,
816
+ role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
817
+ candidate TEXT NOT NULL,
818
+ created_at INTEGER NOT NULL,
819
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
820
+ );
821
+
822
+ CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
823
+ CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username);
824
+ CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
825
+
826
+ -- Credentials table (replaces usernames with simpler name + secret auth)
827
+ CREATE TABLE IF NOT EXISTS credentials (
828
+ name TEXT PRIMARY KEY,
829
+ secret TEXT NOT NULL UNIQUE,
830
+ created_at INTEGER NOT NULL,
831
+ expires_at INTEGER NOT NULL,
832
+ last_used INTEGER NOT NULL,
833
+ CHECK(length(name) >= 3 AND length(name) <= 32)
834
+ );
835
+
836
+ CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at);
837
+ CREATE INDEX IF NOT EXISTS idx_credentials_secret ON credentials(secret);
838
+
839
+ -- Rate limits table (for distributed rate limiting)
840
+ CREATE TABLE IF NOT EXISTS rate_limits (
841
+ identifier TEXT PRIMARY KEY,
842
+ count INTEGER NOT NULL,
843
+ reset_time INTEGER NOT NULL
844
+ );
845
+
846
+ CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time);
847
+
848
+ -- Nonces table (for replay attack prevention)
849
+ CREATE TABLE IF NOT EXISTS nonces (
850
+ nonce_key TEXT PRIMARY KEY,
851
+ expires_at INTEGER NOT NULL
852
+ );
853
+
854
+ CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at);
855
+ `);
856
+ this.db.pragma("foreign_keys = ON");
857
+ }
858
+ // ===== Offer Management =====
859
+ async createOffers(offers) {
860
+ const created = [];
861
+ const offersWithIds = await Promise.all(
862
+ offers.map(async (offer) => ({
863
+ ...offer,
864
+ id: offer.id || await generateOfferHash(offer.sdp)
865
+ }))
866
+ );
867
+ const transaction = this.db.transaction((offersWithIds2) => {
868
+ const offerStmt = this.db.prepare(`
869
+ INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
870
+ VALUES (?, ?, ?, ?, ?, ?, ?)
871
+ `);
872
+ for (const offer of offersWithIds2) {
873
+ const now = Date.now();
874
+ offerStmt.run(
875
+ offer.id,
876
+ offer.username,
877
+ JSON.stringify(offer.tags),
878
+ offer.sdp,
879
+ now,
880
+ offer.expiresAt,
881
+ now
882
+ );
883
+ created.push({
884
+ id: offer.id,
885
+ username: offer.username,
886
+ tags: offer.tags,
887
+ sdp: offer.sdp,
888
+ createdAt: now,
889
+ expiresAt: offer.expiresAt,
890
+ lastSeen: now
891
+ });
892
+ }
893
+ });
894
+ transaction(offersWithIds);
895
+ return created;
896
+ }
897
+ async getOffersByUsername(username) {
898
+ const stmt = this.db.prepare(`
899
+ SELECT * FROM offers
900
+ WHERE username = ? AND expires_at > ?
901
+ ORDER BY last_seen DESC
902
+ `);
903
+ const rows = stmt.all(username, Date.now());
904
+ return rows.map((row) => this.rowToOffer(row));
905
+ }
906
+ async getOfferById(offerId) {
907
+ const stmt = this.db.prepare(`
908
+ SELECT * FROM offers
909
+ WHERE id = ? AND expires_at > ?
910
+ `);
911
+ const row = stmt.get(offerId, Date.now());
912
+ if (!row) {
913
+ return null;
914
+ }
915
+ return this.rowToOffer(row);
916
+ }
917
+ async deleteOffer(offerId, ownerUsername) {
918
+ const stmt = this.db.prepare(`
919
+ DELETE FROM offers
920
+ WHERE id = ? AND username = ?
921
+ `);
922
+ const result = stmt.run(offerId, ownerUsername);
923
+ return result.changes > 0;
924
+ }
925
+ async deleteExpiredOffers(now) {
926
+ const stmt = this.db.prepare("DELETE FROM offers WHERE expires_at < ?");
927
+ const result = stmt.run(now);
928
+ return result.changes;
929
+ }
930
+ async answerOffer(offerId, answererUsername, answerSdp) {
931
+ const offer = await this.getOfferById(offerId);
932
+ if (!offer) {
933
+ return {
934
+ success: false,
935
+ error: "Offer not found or expired"
936
+ };
937
+ }
938
+ if (offer.answererUsername) {
939
+ return {
940
+ success: false,
941
+ error: "Offer already answered"
942
+ };
943
+ }
944
+ const stmt = this.db.prepare(`
945
+ UPDATE offers
946
+ SET answerer_username = ?, answer_sdp = ?, answered_at = ?
947
+ WHERE id = ? AND answerer_username IS NULL
948
+ `);
949
+ const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId);
950
+ if (result.changes === 0) {
951
+ return {
952
+ success: false,
953
+ error: "Offer already answered (race condition)"
954
+ };
955
+ }
956
+ return { success: true };
957
+ }
958
+ async getAnsweredOffers(offererUsername) {
959
+ const stmt = this.db.prepare(`
960
+ SELECT * FROM offers
961
+ WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
962
+ ORDER BY answered_at DESC
963
+ `);
964
+ const rows = stmt.all(offererUsername, Date.now());
965
+ return rows.map((row) => this.rowToOffer(row));
966
+ }
967
+ async getOffersAnsweredBy(answererUsername) {
968
+ const stmt = this.db.prepare(`
969
+ SELECT * FROM offers
970
+ WHERE answerer_username = ? AND expires_at > ?
971
+ ORDER BY answered_at DESC
972
+ `);
973
+ const rows = stmt.all(answererUsername, Date.now());
974
+ return rows.map((row) => this.rowToOffer(row));
975
+ }
976
+ // ===== Discovery =====
977
+ async discoverOffers(tags, excludeUsername, limit, offset) {
978
+ if (tags.length === 0) {
979
+ return [];
980
+ }
981
+ const placeholders = tags.map(() => "?").join(",");
982
+ let query = `
983
+ SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
984
+ WHERE t.value IN (${placeholders})
985
+ AND o.expires_at > ?
986
+ AND o.answerer_username IS NULL
987
+ `;
988
+ const params = [...tags, Date.now()];
989
+ if (excludeUsername) {
990
+ query += " AND o.username != ?";
991
+ params.push(excludeUsername);
992
+ }
993
+ query += " ORDER BY o.created_at DESC LIMIT ? OFFSET ?";
994
+ params.push(limit, offset);
995
+ const stmt = this.db.prepare(query);
996
+ const rows = stmt.all(...params);
997
+ return rows.map((row) => this.rowToOffer(row));
998
+ }
999
+ async getRandomOffer(tags, excludeUsername) {
1000
+ if (tags.length === 0) {
1001
+ return null;
1002
+ }
1003
+ const placeholders = tags.map(() => "?").join(",");
1004
+ let query = `
1005
+ SELECT DISTINCT o.* FROM offers o, json_each(o.tags) as t
1006
+ WHERE t.value IN (${placeholders})
1007
+ AND o.expires_at > ?
1008
+ AND o.answerer_username IS NULL
1009
+ `;
1010
+ const params = [...tags, Date.now()];
1011
+ if (excludeUsername) {
1012
+ query += " AND o.username != ?";
1013
+ params.push(excludeUsername);
1014
+ }
1015
+ query += " ORDER BY RANDOM() LIMIT 1";
1016
+ const stmt = this.db.prepare(query);
1017
+ const row = stmt.get(...params);
1018
+ return row ? this.rowToOffer(row) : null;
1019
+ }
1020
+ // ===== ICE Candidate Management =====
1021
+ async addIceCandidates(offerId, username, role, candidates) {
1022
+ const stmt = this.db.prepare(`
1023
+ INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
1024
+ VALUES (?, ?, ?, ?, ?)
1025
+ `);
1026
+ const baseTimestamp = Date.now();
1027
+ const transaction = this.db.transaction((candidates2) => {
1028
+ for (let i = 0; i < candidates2.length; i++) {
1029
+ stmt.run(
1030
+ offerId,
1031
+ username,
1032
+ role,
1033
+ JSON.stringify(candidates2[i]),
1034
+ baseTimestamp + i
1035
+ );
1036
+ }
1037
+ });
1038
+ transaction(candidates);
1039
+ return candidates.length;
1040
+ }
1041
+ async getIceCandidates(offerId, targetRole, since) {
1042
+ let query = `
1043
+ SELECT * FROM ice_candidates
1044
+ WHERE offer_id = ? AND role = ?
1045
+ `;
1046
+ const params = [offerId, targetRole];
1047
+ if (since !== void 0) {
1048
+ query += " AND created_at > ?";
1049
+ params.push(since);
1050
+ }
1051
+ query += " ORDER BY created_at ASC";
1052
+ const stmt = this.db.prepare(query);
1053
+ const rows = stmt.all(...params);
1054
+ return rows.map((row) => ({
1055
+ id: row.id,
1056
+ offerId: row.offer_id,
1057
+ username: row.username,
1058
+ role: row.role,
1059
+ candidate: JSON.parse(row.candidate),
1060
+ createdAt: row.created_at
1061
+ }));
1062
+ }
1063
+ async getIceCandidatesForMultipleOffers(offerIds, username, since) {
1064
+ const result = /* @__PURE__ */ new Map();
1065
+ if (offerIds.length === 0) {
1066
+ return result;
1067
+ }
1068
+ if (!Array.isArray(offerIds) || !offerIds.every((id) => typeof id === "string")) {
1069
+ throw new Error("Invalid offer IDs: must be array of strings");
1070
+ }
1071
+ if (offerIds.length > 1e3) {
1072
+ throw new Error("Too many offer IDs (max 1000)");
1073
+ }
1074
+ const placeholders = offerIds.map(() => "?").join(",");
1075
+ let query = `
1076
+ SELECT ic.*, o.username as offer_username
1077
+ FROM ice_candidates ic
1078
+ INNER JOIN offers o ON o.id = ic.offer_id
1079
+ WHERE ic.offer_id IN (${placeholders})
1080
+ AND (
1081
+ (o.username = ? AND ic.role = 'answerer')
1082
+ OR (o.answerer_username = ? AND ic.role = 'offerer')
1083
+ )
1084
+ `;
1085
+ const params = [...offerIds, username, username];
1086
+ if (since !== void 0) {
1087
+ query += " AND ic.created_at > ?";
1088
+ params.push(since);
1089
+ }
1090
+ query += " ORDER BY ic.created_at ASC";
1091
+ const stmt = this.db.prepare(query);
1092
+ const rows = stmt.all(...params);
1093
+ for (const row of rows) {
1094
+ const candidate = {
1095
+ id: row.id,
1096
+ offerId: row.offer_id,
1097
+ username: row.username,
1098
+ role: row.role,
1099
+ candidate: JSON.parse(row.candidate),
1100
+ createdAt: row.created_at
1101
+ };
1102
+ if (!result.has(row.offer_id)) {
1103
+ result.set(row.offer_id, []);
1104
+ }
1105
+ result.get(row.offer_id).push(candidate);
1106
+ }
1107
+ return result;
1108
+ }
1109
+ // ===== Credential Management =====
1110
+ async generateCredentials(request) {
1111
+ const now = Date.now();
1112
+ const expiresAt = request.expiresAt || now + YEAR_IN_MS2;
1113
+ const { generateCredentialName: generateCredentialName2, generateSecret: generateSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1114
+ let name;
1115
+ if (request.name) {
1116
+ const existing = this.db.prepare(`
1117
+ SELECT name FROM credentials WHERE name = ?
1118
+ `).get(request.name);
1119
+ if (existing) {
1120
+ throw new Error("Username already taken");
1121
+ }
1122
+ name = request.name;
1123
+ } else {
1124
+ let attempts = 0;
1125
+ const maxAttempts = 100;
1126
+ while (attempts < maxAttempts) {
1127
+ name = generateCredentialName2();
1128
+ const existing = this.db.prepare(`
1129
+ SELECT name FROM credentials WHERE name = ?
1130
+ `).get(name);
1131
+ if (!existing) {
1132
+ break;
1133
+ }
1134
+ attempts++;
1135
+ }
1136
+ if (attempts >= maxAttempts) {
1137
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
1138
+ }
1139
+ }
1140
+ const secret = generateSecret2();
1141
+ const { encryptSecret: encryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1142
+ const encryptedSecret = await encryptSecret2(secret, this.masterEncryptionKey);
1143
+ const stmt = this.db.prepare(`
1144
+ INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
1145
+ VALUES (?, ?, ?, ?, ?)
1146
+ `);
1147
+ stmt.run(name, encryptedSecret, now, expiresAt, now);
1148
+ return {
1149
+ name,
1150
+ secret,
1151
+ // Return plaintext secret, not encrypted
1152
+ createdAt: now,
1153
+ expiresAt,
1154
+ lastUsed: now
1155
+ };
1156
+ }
1157
+ async getCredential(name) {
1158
+ const stmt = this.db.prepare(`
1159
+ SELECT * FROM credentials
1160
+ WHERE name = ? AND expires_at > ?
1161
+ `);
1162
+ const row = stmt.get(name, Date.now());
1163
+ if (!row) {
1164
+ return null;
1165
+ }
1166
+ try {
1167
+ const { decryptSecret: decryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1168
+ const decryptedSecret = await decryptSecret2(row.secret, this.masterEncryptionKey);
1169
+ return {
1170
+ name: row.name,
1171
+ secret: decryptedSecret,
1172
+ // Return decrypted secret
1173
+ createdAt: row.created_at,
1174
+ expiresAt: row.expires_at,
1175
+ lastUsed: row.last_used
1176
+ };
1177
+ } catch (error) {
1178
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
1179
+ return null;
1180
+ }
1181
+ }
1182
+ async updateCredentialUsage(name, lastUsed, expiresAt) {
1183
+ const stmt = this.db.prepare(`
1184
+ UPDATE credentials
1185
+ SET last_used = ?, expires_at = ?
1186
+ WHERE name = ?
1187
+ `);
1188
+ stmt.run(lastUsed, expiresAt, name);
1189
+ }
1190
+ async deleteExpiredCredentials(now) {
1191
+ const stmt = this.db.prepare("DELETE FROM credentials WHERE expires_at < ?");
1192
+ const result = stmt.run(now);
1193
+ return result.changes;
1194
+ }
1195
+ // ===== Rate Limiting =====
1196
+ async checkRateLimit(identifier, limit, windowMs) {
1197
+ const now = Date.now();
1198
+ const resetTime = now + windowMs;
1199
+ const result = this.db.prepare(`
1200
+ INSERT INTO rate_limits (identifier, count, reset_time)
1201
+ VALUES (?, 1, ?)
1202
+ ON CONFLICT(identifier) DO UPDATE SET
1203
+ count = CASE
1204
+ WHEN reset_time < ? THEN 1
1205
+ ELSE count + 1
1206
+ END,
1207
+ reset_time = CASE
1208
+ WHEN reset_time < ? THEN ?
1209
+ ELSE reset_time
1210
+ END
1211
+ RETURNING count
1212
+ `).get(identifier, resetTime, now, now, resetTime);
1213
+ return result.count <= limit;
1214
+ }
1215
+ async deleteExpiredRateLimits(now) {
1216
+ const stmt = this.db.prepare("DELETE FROM rate_limits WHERE reset_time < ?");
1217
+ const result = stmt.run(now);
1218
+ return result.changes;
1219
+ }
1220
+ // ===== Nonce Tracking (Replay Protection) =====
1221
+ async checkAndMarkNonce(nonceKey, expiresAt) {
1222
+ try {
1223
+ const stmt = this.db.prepare(`
1224
+ INSERT INTO nonces (nonce_key, expires_at)
1225
+ VALUES (?, ?)
1226
+ `);
1227
+ stmt.run(nonceKey, expiresAt);
1228
+ return true;
1229
+ } catch (error) {
1230
+ if (error?.code === "SQLITE_CONSTRAINT") {
1231
+ return false;
1232
+ }
1233
+ throw error;
1234
+ }
1235
+ }
1236
+ async deleteExpiredNonces(now) {
1237
+ const stmt = this.db.prepare("DELETE FROM nonces WHERE expires_at < ?");
1238
+ const result = stmt.run(now);
1239
+ return result.changes;
1240
+ }
1241
+ async close() {
1242
+ this.db.close();
1243
+ }
1244
+ // ===== Helper Methods =====
1245
+ /**
1246
+ * Helper method to convert database row to Offer object
1247
+ */
1248
+ rowToOffer(row) {
1249
+ return {
1250
+ id: row.id,
1251
+ username: row.username,
1252
+ tags: JSON.parse(row.tags),
1253
+ sdp: row.sdp,
1254
+ createdAt: row.created_at,
1255
+ expiresAt: row.expires_at,
1256
+ lastSeen: row.last_seen,
1257
+ answererUsername: row.answerer_username || void 0,
1258
+ answerSdp: row.answer_sdp || void 0,
1259
+ answeredAt: row.answered_at || void 0
1260
+ };
1261
+ }
1262
+ };
1263
+ }
1264
+ });
1265
+
1266
+ // src/storage/mysql.ts
1267
+ var mysql_exports = {};
1268
+ __export(mysql_exports, {
1269
+ MySQLStorage: () => MySQLStorage
1270
+ });
1271
+ var import_promise, YEAR_IN_MS3, MySQLStorage;
1272
+ var init_mysql = __esm({
1273
+ "src/storage/mysql.ts"() {
1274
+ "use strict";
1275
+ import_promise = __toESM(require("mysql2/promise"));
1276
+ init_hash_id();
1277
+ YEAR_IN_MS3 = 365 * 24 * 60 * 60 * 1e3;
1278
+ MySQLStorage = class _MySQLStorage {
1279
+ constructor(pool, masterEncryptionKey) {
1280
+ this.pool = pool;
1281
+ this.masterEncryptionKey = masterEncryptionKey;
1282
+ }
1283
+ /**
1284
+ * Creates a new MySQL storage instance with connection pooling
1285
+ * @param connectionString MySQL connection URL
1286
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets
1287
+ * @param poolSize Maximum number of connections in the pool
1288
+ */
1289
+ static async create(connectionString, masterEncryptionKey, poolSize = 10) {
1290
+ const pool = import_promise.default.createPool({
1291
+ uri: connectionString,
1292
+ waitForConnections: true,
1293
+ connectionLimit: poolSize,
1294
+ queueLimit: 0,
1295
+ enableKeepAlive: true,
1296
+ keepAliveInitialDelay: 1e4
1297
+ });
1298
+ const storage = new _MySQLStorage(pool, masterEncryptionKey);
1299
+ await storage.initializeDatabase();
1300
+ return storage;
1301
+ }
1302
+ async initializeDatabase() {
1303
+ const conn = await this.pool.getConnection();
1304
+ try {
1305
+ await conn.query(`
1306
+ CREATE TABLE IF NOT EXISTS offers (
1307
+ id VARCHAR(64) PRIMARY KEY,
1308
+ username VARCHAR(32) NOT NULL,
1309
+ tags JSON NOT NULL,
1310
+ sdp MEDIUMTEXT NOT NULL,
1311
+ created_at BIGINT NOT NULL,
1312
+ expires_at BIGINT NOT NULL,
1313
+ last_seen BIGINT NOT NULL,
1314
+ answerer_username VARCHAR(32),
1315
+ answer_sdp MEDIUMTEXT,
1316
+ answered_at BIGINT,
1317
+ INDEX idx_offers_username (username),
1318
+ INDEX idx_offers_expires (expires_at),
1319
+ INDEX idx_offers_last_seen (last_seen),
1320
+ INDEX idx_offers_answerer (answerer_username)
1321
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1322
+ `);
1323
+ await conn.query(`
1324
+ CREATE TABLE IF NOT EXISTS ice_candidates (
1325
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
1326
+ offer_id VARCHAR(64) NOT NULL,
1327
+ username VARCHAR(32) NOT NULL,
1328
+ role ENUM('offerer', 'answerer') NOT NULL,
1329
+ candidate JSON NOT NULL,
1330
+ created_at BIGINT NOT NULL,
1331
+ INDEX idx_ice_offer (offer_id),
1332
+ INDEX idx_ice_username (username),
1333
+ INDEX idx_ice_created (created_at),
1334
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
1335
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1336
+ `);
1337
+ await conn.query(`
1338
+ CREATE TABLE IF NOT EXISTS credentials (
1339
+ name VARCHAR(32) PRIMARY KEY,
1340
+ secret VARCHAR(512) NOT NULL UNIQUE,
1341
+ created_at BIGINT NOT NULL,
1342
+ expires_at BIGINT NOT NULL,
1343
+ last_used BIGINT NOT NULL,
1344
+ INDEX idx_credentials_expires (expires_at)
1345
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1346
+ `);
1347
+ await conn.query(`
1348
+ CREATE TABLE IF NOT EXISTS rate_limits (
1349
+ identifier VARCHAR(255) PRIMARY KEY,
1350
+ count INT NOT NULL,
1351
+ reset_time BIGINT NOT NULL,
1352
+ INDEX idx_rate_limits_reset (reset_time)
1353
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1354
+ `);
1355
+ await conn.query(`
1356
+ CREATE TABLE IF NOT EXISTS nonces (
1357
+ nonce_key VARCHAR(255) PRIMARY KEY,
1358
+ expires_at BIGINT NOT NULL,
1359
+ INDEX idx_nonces_expires (expires_at)
1360
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1361
+ `);
1362
+ } finally {
1363
+ conn.release();
1364
+ }
1365
+ }
1366
+ // ===== Offer Management =====
1367
+ async createOffers(offers) {
1368
+ if (offers.length === 0) return [];
1369
+ const created = [];
1370
+ const now = Date.now();
1371
+ const conn = await this.pool.getConnection();
1372
+ try {
1373
+ await conn.beginTransaction();
1374
+ for (const request of offers) {
1375
+ const id = request.id || await generateOfferHash(request.sdp);
1376
+ await conn.query(
1377
+ `INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
1378
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1379
+ [id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
1380
+ );
1381
+ created.push({
1382
+ id,
1383
+ username: request.username,
1384
+ tags: request.tags,
1385
+ sdp: request.sdp,
1386
+ createdAt: now,
1387
+ expiresAt: request.expiresAt,
1388
+ lastSeen: now
1389
+ });
1390
+ }
1391
+ await conn.commit();
1392
+ } catch (error) {
1393
+ await conn.rollback();
1394
+ throw error;
1395
+ } finally {
1396
+ conn.release();
1397
+ }
1398
+ return created;
1399
+ }
1400
+ async getOffersByUsername(username) {
1401
+ const [rows] = await this.pool.query(
1402
+ `SELECT * FROM offers WHERE username = ? AND expires_at > ? ORDER BY last_seen DESC`,
1403
+ [username, Date.now()]
1404
+ );
1405
+ return rows.map((row) => this.rowToOffer(row));
1406
+ }
1407
+ async getOfferById(offerId) {
1408
+ const [rows] = await this.pool.query(
1409
+ `SELECT * FROM offers WHERE id = ? AND expires_at > ?`,
1410
+ [offerId, Date.now()]
1411
+ );
1412
+ return rows.length > 0 ? this.rowToOffer(rows[0]) : null;
1413
+ }
1414
+ async deleteOffer(offerId, ownerUsername) {
1415
+ const [result] = await this.pool.query(
1416
+ `DELETE FROM offers WHERE id = ? AND username = ?`,
1417
+ [offerId, ownerUsername]
1418
+ );
1419
+ return result.affectedRows > 0;
1420
+ }
1421
+ async deleteExpiredOffers(now) {
1422
+ const [result] = await this.pool.query(
1423
+ `DELETE FROM offers WHERE expires_at < ?`,
1424
+ [now]
1425
+ );
1426
+ return result.affectedRows;
1427
+ }
1428
+ async answerOffer(offerId, answererUsername, answerSdp) {
1429
+ const offer = await this.getOfferById(offerId);
1430
+ if (!offer) {
1431
+ return { success: false, error: "Offer not found or expired" };
1432
+ }
1433
+ if (offer.answererUsername) {
1434
+ return { success: false, error: "Offer already answered" };
1435
+ }
1436
+ const [result] = await this.pool.query(
1437
+ `UPDATE offers SET answerer_username = ?, answer_sdp = ?, answered_at = ?
1438
+ WHERE id = ? AND answerer_username IS NULL`,
1439
+ [answererUsername, answerSdp, Date.now(), offerId]
1440
+ );
1441
+ if (result.affectedRows === 0) {
1442
+ return { success: false, error: "Offer already answered (race condition)" };
1443
+ }
1444
+ return { success: true };
1445
+ }
1446
+ async getAnsweredOffers(offererUsername) {
1447
+ const [rows] = await this.pool.query(
1448
+ `SELECT * FROM offers
1449
+ WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ?
1450
+ ORDER BY answered_at DESC`,
1451
+ [offererUsername, Date.now()]
1452
+ );
1453
+ return rows.map((row) => this.rowToOffer(row));
1454
+ }
1455
+ async getOffersAnsweredBy(answererUsername) {
1456
+ const [rows] = await this.pool.query(
1457
+ `SELECT * FROM offers
1458
+ WHERE answerer_username = ? AND expires_at > ?
1459
+ ORDER BY answered_at DESC`,
1460
+ [answererUsername, Date.now()]
1461
+ );
1462
+ return rows.map((row) => this.rowToOffer(row));
1463
+ }
1464
+ // ===== Discovery =====
1465
+ async discoverOffers(tags, excludeUsername, limit, offset) {
1466
+ if (tags.length === 0) return [];
1467
+ const tagArray = JSON.stringify(tags);
1468
+ let query = `
1469
+ SELECT DISTINCT o.* FROM offers o
1470
+ WHERE JSON_OVERLAPS(o.tags, ?)
1471
+ AND o.expires_at > ?
1472
+ AND o.answerer_username IS NULL
1473
+ `;
1474
+ const params = [tagArray, Date.now()];
1475
+ if (excludeUsername) {
1476
+ query += " AND o.username != ?";
1477
+ params.push(excludeUsername);
1478
+ }
1479
+ query += " ORDER BY o.created_at DESC LIMIT ? OFFSET ?";
1480
+ params.push(limit, offset);
1481
+ const [rows] = await this.pool.query(query, params);
1482
+ return rows.map((row) => this.rowToOffer(row));
1483
+ }
1484
+ async getRandomOffer(tags, excludeUsername) {
1485
+ if (tags.length === 0) return null;
1486
+ const tagArray = JSON.stringify(tags);
1487
+ let query = `
1488
+ SELECT DISTINCT o.* FROM offers o
1489
+ WHERE JSON_OVERLAPS(o.tags, ?)
1490
+ AND o.expires_at > ?
1491
+ AND o.answerer_username IS NULL
1492
+ `;
1493
+ const params = [tagArray, Date.now()];
1494
+ if (excludeUsername) {
1495
+ query += " AND o.username != ?";
1496
+ params.push(excludeUsername);
1497
+ }
1498
+ query += " ORDER BY RAND() LIMIT 1";
1499
+ const [rows] = await this.pool.query(query, params);
1500
+ return rows.length > 0 ? this.rowToOffer(rows[0]) : null;
1501
+ }
1502
+ // ===== ICE Candidate Management =====
1503
+ async addIceCandidates(offerId, username, role, candidates) {
1504
+ if (candidates.length === 0) return 0;
1505
+ const baseTimestamp = Date.now();
1506
+ const values = candidates.map((c, i) => [
1507
+ offerId,
1508
+ username,
1509
+ role,
1510
+ JSON.stringify(c),
1511
+ baseTimestamp + i
1512
+ ]);
1513
+ await this.pool.query(
1514
+ `INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
1515
+ VALUES ?`,
1516
+ [values]
1517
+ );
1518
+ return candidates.length;
1519
+ }
1520
+ async getIceCandidates(offerId, targetRole, since) {
1521
+ let query = `SELECT * FROM ice_candidates WHERE offer_id = ? AND role = ?`;
1522
+ const params = [offerId, targetRole];
1523
+ if (since !== void 0) {
1524
+ query += " AND created_at > ?";
1525
+ params.push(since);
1526
+ }
1527
+ query += " ORDER BY created_at ASC";
1528
+ const [rows] = await this.pool.query(query, params);
1529
+ return rows.map((row) => this.rowToIceCandidate(row));
1530
+ }
1531
+ async getIceCandidatesForMultipleOffers(offerIds, username, since) {
1532
+ const result = /* @__PURE__ */ new Map();
1533
+ if (offerIds.length === 0) return result;
1534
+ if (offerIds.length > 1e3) {
1535
+ throw new Error("Too many offer IDs (max 1000)");
1536
+ }
1537
+ const placeholders = offerIds.map(() => "?").join(",");
1538
+ let query = `
1539
+ SELECT ic.*, o.username as offer_username
1540
+ FROM ice_candidates ic
1541
+ INNER JOIN offers o ON o.id = ic.offer_id
1542
+ WHERE ic.offer_id IN (${placeholders})
1543
+ AND (
1544
+ (o.username = ? AND ic.role = 'answerer')
1545
+ OR (o.answerer_username = ? AND ic.role = 'offerer')
1546
+ )
1547
+ `;
1548
+ const params = [...offerIds, username, username];
1549
+ if (since !== void 0) {
1550
+ query += " AND ic.created_at > ?";
1551
+ params.push(since);
1552
+ }
1553
+ query += " ORDER BY ic.created_at ASC";
1554
+ const [rows] = await this.pool.query(query, params);
1555
+ for (const row of rows) {
1556
+ const candidate = this.rowToIceCandidate(row);
1557
+ if (!result.has(row.offer_id)) {
1558
+ result.set(row.offer_id, []);
1559
+ }
1560
+ result.get(row.offer_id).push(candidate);
1561
+ }
1562
+ return result;
1563
+ }
1564
+ // ===== Credential Management =====
1565
+ async generateCredentials(request) {
1566
+ const now = Date.now();
1567
+ const expiresAt = request.expiresAt || now + YEAR_IN_MS3;
1568
+ const { generateCredentialName: generateCredentialName2, generateSecret: generateSecret2, encryptSecret: encryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1569
+ let name;
1570
+ if (request.name) {
1571
+ const [existing] = await this.pool.query(
1572
+ `SELECT name FROM credentials WHERE name = ?`,
1573
+ [request.name]
1574
+ );
1575
+ if (existing.length > 0) {
1576
+ throw new Error("Username already taken");
1577
+ }
1578
+ name = request.name;
1579
+ } else {
1580
+ let attempts = 0;
1581
+ const maxAttempts = 100;
1582
+ while (attempts < maxAttempts) {
1583
+ name = generateCredentialName2();
1584
+ const [existing] = await this.pool.query(
1585
+ `SELECT name FROM credentials WHERE name = ?`,
1586
+ [name]
1587
+ );
1588
+ if (existing.length === 0) break;
1589
+ attempts++;
1590
+ }
1591
+ if (attempts >= maxAttempts) {
1592
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
1593
+ }
1594
+ }
1595
+ const secret = generateSecret2();
1596
+ const encryptedSecret = await encryptSecret2(secret, this.masterEncryptionKey);
1597
+ await this.pool.query(
1598
+ `INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
1599
+ VALUES (?, ?, ?, ?, ?)`,
1600
+ [name, encryptedSecret, now, expiresAt, now]
1601
+ );
1602
+ return {
1603
+ name,
1604
+ secret,
1605
+ createdAt: now,
1606
+ expiresAt,
1607
+ lastUsed: now
1608
+ };
1609
+ }
1610
+ async getCredential(name) {
1611
+ const [rows] = await this.pool.query(
1612
+ `SELECT * FROM credentials WHERE name = ? AND expires_at > ?`,
1613
+ [name, Date.now()]
1614
+ );
1615
+ if (rows.length === 0) return null;
1616
+ try {
1617
+ const { decryptSecret: decryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
1618
+ const decryptedSecret = await decryptSecret2(rows[0].secret, this.masterEncryptionKey);
1619
+ return {
1620
+ name: rows[0].name,
1621
+ secret: decryptedSecret,
1622
+ createdAt: Number(rows[0].created_at),
1623
+ expiresAt: Number(rows[0].expires_at),
1624
+ lastUsed: Number(rows[0].last_used)
1625
+ };
1626
+ } catch (error) {
1627
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
1628
+ return null;
1629
+ }
1630
+ }
1631
+ async updateCredentialUsage(name, lastUsed, expiresAt) {
1632
+ await this.pool.query(
1633
+ `UPDATE credentials SET last_used = ?, expires_at = ? WHERE name = ?`,
1634
+ [lastUsed, expiresAt, name]
1635
+ );
1636
+ }
1637
+ async deleteExpiredCredentials(now) {
1638
+ const [result] = await this.pool.query(
1639
+ `DELETE FROM credentials WHERE expires_at < ?`,
1640
+ [now]
1641
+ );
1642
+ return result.affectedRows;
1643
+ }
1644
+ // ===== Rate Limiting =====
1645
+ async checkRateLimit(identifier, limit, windowMs) {
1646
+ const now = Date.now();
1647
+ const resetTime = now + windowMs;
1648
+ await this.pool.query(
1649
+ `INSERT INTO rate_limits (identifier, count, reset_time)
1650
+ VALUES (?, 1, ?)
1651
+ ON DUPLICATE KEY UPDATE
1652
+ count = IF(reset_time < ?, 1, count + 1),
1653
+ reset_time = IF(reset_time < ?, ?, reset_time)`,
1654
+ [identifier, resetTime, now, now, resetTime]
1655
+ );
1656
+ const [rows] = await this.pool.query(
1657
+ `SELECT count FROM rate_limits WHERE identifier = ?`,
1658
+ [identifier]
1659
+ );
1660
+ return rows.length > 0 && rows[0].count <= limit;
1661
+ }
1662
+ async deleteExpiredRateLimits(now) {
1663
+ const [result] = await this.pool.query(
1664
+ `DELETE FROM rate_limits WHERE reset_time < ?`,
1665
+ [now]
1666
+ );
1667
+ return result.affectedRows;
1668
+ }
1669
+ // ===== Nonce Tracking (Replay Protection) =====
1670
+ async checkAndMarkNonce(nonceKey, expiresAt) {
1671
+ try {
1672
+ await this.pool.query(
1673
+ `INSERT INTO nonces (nonce_key, expires_at) VALUES (?, ?)`,
1674
+ [nonceKey, expiresAt]
1675
+ );
1676
+ return true;
1677
+ } catch (error) {
1678
+ if (error.code === "ER_DUP_ENTRY") {
1679
+ return false;
1680
+ }
1681
+ throw error;
1682
+ }
1683
+ }
1684
+ async deleteExpiredNonces(now) {
1685
+ const [result] = await this.pool.query(
1686
+ `DELETE FROM nonces WHERE expires_at < ?`,
1687
+ [now]
1688
+ );
1689
+ return result.affectedRows;
1690
+ }
1691
+ async close() {
1692
+ await this.pool.end();
1693
+ }
1694
+ // ===== Helper Methods =====
1695
+ rowToOffer(row) {
1696
+ return {
1697
+ id: row.id,
1698
+ username: row.username,
1699
+ tags: typeof row.tags === "string" ? JSON.parse(row.tags) : row.tags,
1700
+ sdp: row.sdp,
1701
+ createdAt: Number(row.created_at),
1702
+ expiresAt: Number(row.expires_at),
1703
+ lastSeen: Number(row.last_seen),
1704
+ answererUsername: row.answerer_username || void 0,
1705
+ answerSdp: row.answer_sdp || void 0,
1706
+ answeredAt: row.answered_at ? Number(row.answered_at) : void 0
1707
+ };
1708
+ }
1709
+ rowToIceCandidate(row) {
1710
+ return {
1711
+ id: Number(row.id),
1712
+ offerId: row.offer_id,
1713
+ username: row.username,
1714
+ role: row.role,
1715
+ candidate: typeof row.candidate === "string" ? JSON.parse(row.candidate) : row.candidate,
1716
+ createdAt: Number(row.created_at)
1717
+ };
1718
+ }
1719
+ };
1720
+ }
1721
+ });
1722
+
1723
+ // src/storage/postgres.ts
1724
+ var postgres_exports = {};
1725
+ __export(postgres_exports, {
1726
+ PostgreSQLStorage: () => PostgreSQLStorage
1727
+ });
1728
+ var import_pg, YEAR_IN_MS4, PostgreSQLStorage;
1729
+ var init_postgres = __esm({
1730
+ "src/storage/postgres.ts"() {
1731
+ "use strict";
1732
+ import_pg = require("pg");
1733
+ init_hash_id();
1734
+ YEAR_IN_MS4 = 365 * 24 * 60 * 60 * 1e3;
1735
+ PostgreSQLStorage = class _PostgreSQLStorage {
1736
+ constructor(pool, masterEncryptionKey) {
1737
+ this.pool = pool;
1738
+ this.masterEncryptionKey = masterEncryptionKey;
1739
+ }
1740
+ /**
1741
+ * Creates a new PostgreSQL storage instance with connection pooling
1742
+ * @param connectionString PostgreSQL connection URL
1743
+ * @param masterEncryptionKey 64-char hex string for encrypting secrets
1744
+ * @param poolSize Maximum number of connections in the pool
1745
+ */
1746
+ static async create(connectionString, masterEncryptionKey, poolSize = 10) {
1747
+ const pool = new import_pg.Pool({
1748
+ connectionString,
1749
+ max: poolSize,
1750
+ idleTimeoutMillis: 3e4,
1751
+ connectionTimeoutMillis: 5e3
1752
+ });
1753
+ const storage = new _PostgreSQLStorage(pool, masterEncryptionKey);
1754
+ await storage.initializeDatabase();
1755
+ return storage;
1756
+ }
1757
+ async initializeDatabase() {
1758
+ const client = await this.pool.connect();
1759
+ try {
1760
+ await client.query(`
1761
+ CREATE TABLE IF NOT EXISTS offers (
1762
+ id VARCHAR(64) PRIMARY KEY,
1763
+ username VARCHAR(32) NOT NULL,
1764
+ tags JSONB NOT NULL,
1765
+ sdp TEXT NOT NULL,
1766
+ created_at BIGINT NOT NULL,
1767
+ expires_at BIGINT NOT NULL,
1768
+ last_seen BIGINT NOT NULL,
1769
+ answerer_username VARCHAR(32),
1770
+ answer_sdp TEXT,
1771
+ answered_at BIGINT
1772
+ )
1773
+ `);
1774
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_username ON offers(username)`);
1775
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at)`);
1776
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen)`);
1777
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username)`);
1778
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_offers_tags ON offers USING GIN(tags)`);
1779
+ await client.query(`
1780
+ CREATE TABLE IF NOT EXISTS ice_candidates (
1781
+ id BIGSERIAL PRIMARY KEY,
1782
+ offer_id VARCHAR(64) NOT NULL REFERENCES offers(id) ON DELETE CASCADE,
1783
+ username VARCHAR(32) NOT NULL,
1784
+ role VARCHAR(8) NOT NULL CHECK (role IN ('offerer', 'answerer')),
1785
+ candidate JSONB NOT NULL,
1786
+ created_at BIGINT NOT NULL
1787
+ )
1788
+ `);
1789
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id)`);
1790
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_username ON ice_candidates(username)`);
1791
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at)`);
1792
+ await client.query(`
1793
+ CREATE TABLE IF NOT EXISTS credentials (
1794
+ name VARCHAR(32) PRIMARY KEY,
1795
+ secret VARCHAR(512) NOT NULL UNIQUE,
1796
+ created_at BIGINT NOT NULL,
1797
+ expires_at BIGINT NOT NULL,
1798
+ last_used BIGINT NOT NULL
1799
+ )
1800
+ `);
1801
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_credentials_expires ON credentials(expires_at)`);
1802
+ await client.query(`
1803
+ CREATE TABLE IF NOT EXISTS rate_limits (
1804
+ identifier VARCHAR(255) PRIMARY KEY,
1805
+ count INT NOT NULL,
1806
+ reset_time BIGINT NOT NULL
1807
+ )
1808
+ `);
1809
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_rate_limits_reset ON rate_limits(reset_time)`);
1810
+ await client.query(`
1811
+ CREATE TABLE IF NOT EXISTS nonces (
1812
+ nonce_key VARCHAR(255) PRIMARY KEY,
1813
+ expires_at BIGINT NOT NULL
1814
+ )
1815
+ `);
1816
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at)`);
1817
+ } finally {
1818
+ client.release();
1819
+ }
1820
+ }
1821
+ // ===== Offer Management =====
1822
+ async createOffers(offers) {
1823
+ if (offers.length === 0) return [];
1824
+ const created = [];
1825
+ const now = Date.now();
1826
+ const client = await this.pool.connect();
1827
+ try {
1828
+ await client.query("BEGIN");
1829
+ for (const request of offers) {
1830
+ const id = request.id || await generateOfferHash(request.sdp);
1831
+ await client.query(
1832
+ `INSERT INTO offers (id, username, tags, sdp, created_at, expires_at, last_seen)
1833
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
1834
+ [id, request.username, JSON.stringify(request.tags), request.sdp, now, request.expiresAt, now]
1835
+ );
1836
+ created.push({
1837
+ id,
1838
+ username: request.username,
1839
+ tags: request.tags,
1840
+ sdp: request.sdp,
1841
+ createdAt: now,
1842
+ expiresAt: request.expiresAt,
1843
+ lastSeen: now
1844
+ });
1845
+ }
1846
+ await client.query("COMMIT");
1847
+ } catch (error) {
1848
+ await client.query("ROLLBACK");
1849
+ throw error;
1850
+ } finally {
1851
+ client.release();
1852
+ }
1853
+ return created;
1854
+ }
1855
+ async getOffersByUsername(username) {
1856
+ const result = await this.pool.query(
1857
+ `SELECT * FROM offers WHERE username = $1 AND expires_at > $2 ORDER BY last_seen DESC`,
1858
+ [username, Date.now()]
1859
+ );
1860
+ return result.rows.map((row) => this.rowToOffer(row));
1861
+ }
1862
+ async getOfferById(offerId) {
1863
+ const result = await this.pool.query(
1864
+ `SELECT * FROM offers WHERE id = $1 AND expires_at > $2`,
1865
+ [offerId, Date.now()]
1866
+ );
1867
+ return result.rows.length > 0 ? this.rowToOffer(result.rows[0]) : null;
1868
+ }
1869
+ async deleteOffer(offerId, ownerUsername) {
1870
+ const result = await this.pool.query(
1871
+ `DELETE FROM offers WHERE id = $1 AND username = $2`,
1872
+ [offerId, ownerUsername]
1873
+ );
1874
+ return (result.rowCount ?? 0) > 0;
1875
+ }
1876
+ async deleteExpiredOffers(now) {
1877
+ const result = await this.pool.query(
1878
+ `DELETE FROM offers WHERE expires_at < $1`,
1879
+ [now]
1880
+ );
1881
+ return result.rowCount ?? 0;
1882
+ }
1883
+ async answerOffer(offerId, answererUsername, answerSdp) {
1884
+ const offer = await this.getOfferById(offerId);
1885
+ if (!offer) {
1886
+ return { success: false, error: "Offer not found or expired" };
1887
+ }
1888
+ if (offer.answererUsername) {
1889
+ return { success: false, error: "Offer already answered" };
1890
+ }
1891
+ const result = await this.pool.query(
1892
+ `UPDATE offers SET answerer_username = $1, answer_sdp = $2, answered_at = $3
1893
+ WHERE id = $4 AND answerer_username IS NULL`,
1894
+ [answererUsername, answerSdp, Date.now(), offerId]
1895
+ );
1896
+ if ((result.rowCount ?? 0) === 0) {
1897
+ return { success: false, error: "Offer already answered (race condition)" };
1898
+ }
1899
+ return { success: true };
1900
+ }
1901
+ async getAnsweredOffers(offererUsername) {
1902
+ const result = await this.pool.query(
1903
+ `SELECT * FROM offers
1904
+ WHERE username = $1 AND answerer_username IS NOT NULL AND expires_at > $2
1905
+ ORDER BY answered_at DESC`,
1906
+ [offererUsername, Date.now()]
1907
+ );
1908
+ return result.rows.map((row) => this.rowToOffer(row));
1909
+ }
1910
+ async getOffersAnsweredBy(answererUsername) {
1911
+ const result = await this.pool.query(
1912
+ `SELECT * FROM offers
1913
+ WHERE answerer_username = $1 AND expires_at > $2
1914
+ ORDER BY answered_at DESC`,
1915
+ [answererUsername, Date.now()]
1916
+ );
1917
+ return result.rows.map((row) => this.rowToOffer(row));
1918
+ }
1919
+ // ===== Discovery =====
1920
+ async discoverOffers(tags, excludeUsername, limit, offset) {
1921
+ if (tags.length === 0) return [];
1922
+ let query = `
1923
+ SELECT DISTINCT o.* FROM offers o
1924
+ WHERE o.tags ?| $1
1925
+ AND o.expires_at > $2
1926
+ AND o.answerer_username IS NULL
1927
+ `;
1928
+ const params = [tags, Date.now()];
1929
+ let paramIndex = 3;
1930
+ if (excludeUsername) {
1931
+ query += ` AND o.username != $${paramIndex}`;
1932
+ params.push(excludeUsername);
1933
+ paramIndex++;
1934
+ }
1935
+ query += ` ORDER BY o.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
1936
+ params.push(limit, offset);
1937
+ const result = await this.pool.query(query, params);
1938
+ return result.rows.map((row) => this.rowToOffer(row));
1939
+ }
1940
+ async getRandomOffer(tags, excludeUsername) {
1941
+ if (tags.length === 0) return null;
1942
+ let query = `
1943
+ SELECT DISTINCT o.* FROM offers o
1944
+ WHERE o.tags ?| $1
1945
+ AND o.expires_at > $2
1946
+ AND o.answerer_username IS NULL
1947
+ `;
1948
+ const params = [tags, Date.now()];
1949
+ let paramIndex = 3;
1950
+ if (excludeUsername) {
1951
+ query += ` AND o.username != $${paramIndex}`;
1952
+ params.push(excludeUsername);
1953
+ }
1954
+ query += " ORDER BY RANDOM() LIMIT 1";
1955
+ const result = await this.pool.query(query, params);
1956
+ return result.rows.length > 0 ? this.rowToOffer(result.rows[0]) : null;
1957
+ }
1958
+ // ===== ICE Candidate Management =====
1959
+ async addIceCandidates(offerId, username, role, candidates) {
1960
+ if (candidates.length === 0) return 0;
1961
+ const baseTimestamp = Date.now();
1962
+ const client = await this.pool.connect();
1963
+ try {
1964
+ await client.query("BEGIN");
1965
+ for (let i = 0; i < candidates.length; i++) {
1966
+ await client.query(
1967
+ `INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at)
1968
+ VALUES ($1, $2, $3, $4, $5)`,
1969
+ [offerId, username, role, JSON.stringify(candidates[i]), baseTimestamp + i]
1970
+ );
1971
+ }
1972
+ await client.query("COMMIT");
1973
+ } catch (error) {
1974
+ await client.query("ROLLBACK");
1975
+ throw error;
1976
+ } finally {
1977
+ client.release();
1978
+ }
1979
+ return candidates.length;
1980
+ }
1981
+ async getIceCandidates(offerId, targetRole, since) {
1982
+ let query = `SELECT * FROM ice_candidates WHERE offer_id = $1 AND role = $2`;
1983
+ const params = [offerId, targetRole];
1984
+ if (since !== void 0) {
1985
+ query += " AND created_at > $3";
1986
+ params.push(since);
1987
+ }
1988
+ query += " ORDER BY created_at ASC";
1989
+ const result = await this.pool.query(query, params);
1990
+ return result.rows.map((row) => this.rowToIceCandidate(row));
1991
+ }
1992
+ async getIceCandidatesForMultipleOffers(offerIds, username, since) {
1993
+ const resultMap = /* @__PURE__ */ new Map();
1994
+ if (offerIds.length === 0) return resultMap;
1995
+ if (offerIds.length > 1e3) {
1996
+ throw new Error("Too many offer IDs (max 1000)");
1997
+ }
1998
+ let query = `
1999
+ SELECT ic.*, o.username as offer_username
2000
+ FROM ice_candidates ic
2001
+ INNER JOIN offers o ON o.id = ic.offer_id
2002
+ WHERE ic.offer_id = ANY($1)
2003
+ AND (
2004
+ (o.username = $2 AND ic.role = 'answerer')
2005
+ OR (o.answerer_username = $2 AND ic.role = 'offerer')
2006
+ )
2007
+ `;
2008
+ const params = [offerIds, username];
2009
+ if (since !== void 0) {
2010
+ query += " AND ic.created_at > $3";
2011
+ params.push(since);
2012
+ }
2013
+ query += " ORDER BY ic.created_at ASC";
2014
+ const result = await this.pool.query(query, params);
2015
+ for (const row of result.rows) {
2016
+ const candidate = this.rowToIceCandidate(row);
2017
+ if (!resultMap.has(row.offer_id)) {
2018
+ resultMap.set(row.offer_id, []);
2019
+ }
2020
+ resultMap.get(row.offer_id).push(candidate);
2021
+ }
2022
+ return resultMap;
2023
+ }
2024
+ // ===== Credential Management =====
2025
+ async generateCredentials(request) {
2026
+ const now = Date.now();
2027
+ const expiresAt = request.expiresAt || now + YEAR_IN_MS4;
2028
+ const { generateCredentialName: generateCredentialName2, generateSecret: generateSecret2, encryptSecret: encryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
2029
+ let name;
2030
+ if (request.name) {
2031
+ const existing = await this.pool.query(
2032
+ `SELECT name FROM credentials WHERE name = $1`,
2033
+ [request.name]
2034
+ );
2035
+ if (existing.rows.length > 0) {
2036
+ throw new Error("Username already taken");
2037
+ }
2038
+ name = request.name;
2039
+ } else {
2040
+ let attempts = 0;
2041
+ const maxAttempts = 100;
2042
+ while (attempts < maxAttempts) {
2043
+ name = generateCredentialName2();
2044
+ const existing = await this.pool.query(
2045
+ `SELECT name FROM credentials WHERE name = $1`,
2046
+ [name]
2047
+ );
2048
+ if (existing.rows.length === 0) break;
2049
+ attempts++;
2050
+ }
2051
+ if (attempts >= maxAttempts) {
2052
+ throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
2053
+ }
2054
+ }
2055
+ const secret = generateSecret2();
2056
+ const encryptedSecret = await encryptSecret2(secret, this.masterEncryptionKey);
2057
+ await this.pool.query(
2058
+ `INSERT INTO credentials (name, secret, created_at, expires_at, last_used)
2059
+ VALUES ($1, $2, $3, $4, $5)`,
2060
+ [name, encryptedSecret, now, expiresAt, now]
2061
+ );
2062
+ return {
2063
+ name,
2064
+ secret,
2065
+ createdAt: now,
2066
+ expiresAt,
2067
+ lastUsed: now
2068
+ };
2069
+ }
2070
+ async getCredential(name) {
2071
+ const result = await this.pool.query(
2072
+ `SELECT * FROM credentials WHERE name = $1 AND expires_at > $2`,
2073
+ [name, Date.now()]
2074
+ );
2075
+ if (result.rows.length === 0) return null;
2076
+ try {
2077
+ const { decryptSecret: decryptSecret2 } = await Promise.resolve().then(() => (init_crypto(), crypto_exports));
2078
+ const decryptedSecret = await decryptSecret2(result.rows[0].secret, this.masterEncryptionKey);
2079
+ return {
2080
+ name: result.rows[0].name,
2081
+ secret: decryptedSecret,
2082
+ createdAt: Number(result.rows[0].created_at),
2083
+ expiresAt: Number(result.rows[0].expires_at),
2084
+ lastUsed: Number(result.rows[0].last_used)
2085
+ };
2086
+ } catch (error) {
2087
+ console.error(`Failed to decrypt secret for credential '${name}':`, error);
2088
+ return null;
2089
+ }
2090
+ }
2091
+ async updateCredentialUsage(name, lastUsed, expiresAt) {
2092
+ await this.pool.query(
2093
+ `UPDATE credentials SET last_used = $1, expires_at = $2 WHERE name = $3`,
2094
+ [lastUsed, expiresAt, name]
2095
+ );
2096
+ }
2097
+ async deleteExpiredCredentials(now) {
2098
+ const result = await this.pool.query(
2099
+ `DELETE FROM credentials WHERE expires_at < $1`,
2100
+ [now]
2101
+ );
2102
+ return result.rowCount ?? 0;
2103
+ }
2104
+ // ===== Rate Limiting =====
2105
+ async checkRateLimit(identifier, limit, windowMs) {
2106
+ const now = Date.now();
2107
+ const resetTime = now + windowMs;
2108
+ const result = await this.pool.query(
2109
+ `INSERT INTO rate_limits (identifier, count, reset_time)
2110
+ VALUES ($1, 1, $2)
2111
+ ON CONFLICT (identifier) DO UPDATE SET
2112
+ count = CASE
2113
+ WHEN rate_limits.reset_time < $3 THEN 1
2114
+ ELSE rate_limits.count + 1
2115
+ END,
2116
+ reset_time = CASE
2117
+ WHEN rate_limits.reset_time < $3 THEN $2
2118
+ ELSE rate_limits.reset_time
2119
+ END
2120
+ RETURNING count`,
2121
+ [identifier, resetTime, now]
2122
+ );
2123
+ return result.rows[0].count <= limit;
2124
+ }
2125
+ async deleteExpiredRateLimits(now) {
2126
+ const result = await this.pool.query(
2127
+ `DELETE FROM rate_limits WHERE reset_time < $1`,
2128
+ [now]
2129
+ );
2130
+ return result.rowCount ?? 0;
2131
+ }
2132
+ // ===== Nonce Tracking (Replay Protection) =====
2133
+ async checkAndMarkNonce(nonceKey, expiresAt) {
2134
+ try {
2135
+ await this.pool.query(
2136
+ `INSERT INTO nonces (nonce_key, expires_at) VALUES ($1, $2)`,
2137
+ [nonceKey, expiresAt]
2138
+ );
2139
+ return true;
2140
+ } catch (error) {
2141
+ if (error.code === "23505") {
2142
+ return false;
2143
+ }
2144
+ throw error;
2145
+ }
2146
+ }
2147
+ async deleteExpiredNonces(now) {
2148
+ const result = await this.pool.query(
2149
+ `DELETE FROM nonces WHERE expires_at < $1`,
2150
+ [now]
2151
+ );
2152
+ return result.rowCount ?? 0;
2153
+ }
2154
+ async close() {
2155
+ await this.pool.end();
2156
+ }
2157
+ // ===== Helper Methods =====
2158
+ rowToOffer(row) {
2159
+ return {
2160
+ id: row.id,
2161
+ username: row.username,
2162
+ tags: typeof row.tags === "string" ? JSON.parse(row.tags) : row.tags,
2163
+ sdp: row.sdp,
2164
+ createdAt: Number(row.created_at),
2165
+ expiresAt: Number(row.expires_at),
2166
+ lastSeen: Number(row.last_seen),
2167
+ answererUsername: row.answerer_username || void 0,
2168
+ answerSdp: row.answer_sdp || void 0,
2169
+ answeredAt: row.answered_at ? Number(row.answered_at) : void 0
2170
+ };
2171
+ }
2172
+ rowToIceCandidate(row) {
2173
+ return {
2174
+ id: Number(row.id),
2175
+ offerId: row.offer_id,
2176
+ username: row.username,
2177
+ role: row.role,
2178
+ candidate: typeof row.candidate === "string" ? JSON.parse(row.candidate) : row.candidate,
2179
+ createdAt: Number(row.created_at)
2180
+ };
2181
+ }
2182
+ };
2183
+ }
2184
+ });
24
2185
 
25
2186
  // src/index.ts
26
2187
  var import_node_server = require("@hono/node-server");
@@ -29,795 +2190,262 @@ var import_node_server = require("@hono/node-server");
29
2190
  var import_hono = require("hono");
30
2191
  var import_cors = require("hono/cors");
31
2192
 
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
- hashes.sha512Async = async (message) => {
478
- return new Uint8Array(await crypto.subtle.digest("SHA-512", message));
479
- };
480
- var USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
481
- var USERNAME_MIN_LENGTH = 3;
482
- var USERNAME_MAX_LENGTH = 32;
483
- var TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1e3;
484
- function base64ToBytes(base64) {
485
- const binString = atob(base64);
486
- return Uint8Array.from(binString, (char) => char.codePointAt(0));
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;
2193
+ // src/rpc.ts
2194
+ init_crypto();
2195
+ var MAX_PAGE_SIZE = 100;
2196
+ var CREDENTIAL_RATE_LIMIT = 1;
2197
+ var CREDENTIAL_RATE_WINDOW = 1e3;
2198
+ function getJsonDepth(obj, maxDepth, currentDepth = 0) {
2199
+ if (obj === null || typeof obj !== "object") {
2200
+ return currentDepth;
2201
+ }
2202
+ if (currentDepth >= maxDepth) {
2203
+ return currentDepth + 1;
2204
+ }
2205
+ let maxChildDepth = currentDepth;
2206
+ for (const key in obj) {
2207
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
2208
+ const childDepth = getJsonDepth(obj[key], maxDepth, currentDepth + 1);
2209
+ maxChildDepth = Math.max(maxChildDepth, childDepth);
2210
+ if (maxChildDepth > maxDepth) {
2211
+ return maxChildDepth;
2212
+ }
546
2213
  }
547
2214
  }
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;
2215
+ return maxChildDepth;
571
2216
  }
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;
2217
+ function validateStringParam(value, paramName) {
2218
+ if (typeof value !== "string" || value.length === 0) {
2219
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `${paramName} must be a non-empty string`);
582
2220
  }
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
2221
  }
594
- function validateTimestamp(timestamp) {
595
- if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
596
- return { valid: false, error: "Timestamp must be a finite number" };
2222
+ var ErrorCodes = {
2223
+ // Authentication errors
2224
+ AUTH_REQUIRED: "AUTH_REQUIRED",
2225
+ INVALID_CREDENTIALS: "INVALID_CREDENTIALS",
2226
+ // Validation errors
2227
+ INVALID_NAME: "INVALID_NAME",
2228
+ INVALID_TAG: "INVALID_TAG",
2229
+ INVALID_SDP: "INVALID_SDP",
2230
+ INVALID_PARAMS: "INVALID_PARAMS",
2231
+ MISSING_PARAMS: "MISSING_PARAMS",
2232
+ // Resource errors
2233
+ OFFER_NOT_FOUND: "OFFER_NOT_FOUND",
2234
+ OFFER_ALREADY_ANSWERED: "OFFER_ALREADY_ANSWERED",
2235
+ OFFER_NOT_ANSWERED: "OFFER_NOT_ANSWERED",
2236
+ NO_AVAILABLE_OFFERS: "NO_AVAILABLE_OFFERS",
2237
+ // Authorization errors
2238
+ NOT_AUTHORIZED: "NOT_AUTHORIZED",
2239
+ OWNERSHIP_MISMATCH: "OWNERSHIP_MISMATCH",
2240
+ // Limit errors
2241
+ TOO_MANY_OFFERS: "TOO_MANY_OFFERS",
2242
+ SDP_TOO_LARGE: "SDP_TOO_LARGE",
2243
+ BATCH_TOO_LARGE: "BATCH_TOO_LARGE",
2244
+ RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
2245
+ // Generic errors
2246
+ INTERNAL_ERROR: "INTERNAL_ERROR",
2247
+ UNKNOWN_METHOD: "UNKNOWN_METHOD"
2248
+ };
2249
+ var RpcError = class extends Error {
2250
+ constructor(errorCode, message) {
2251
+ super(message);
2252
+ this.errorCode = errorCode;
2253
+ this.name = "RpcError";
597
2254
  }
2255
+ };
2256
+ function validateTimestamp(timestamp, config) {
598
2257
  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)` };
2258
+ if (now - timestamp > config.timestampMaxAge) {
2259
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Timestamp too old");
602
2260
  }
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;
2261
+ if (timestamp - now > config.timestampMaxFuture) {
2262
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Timestamp too far in future");
616
2263
  }
617
2264
  }
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
- );
2265
+ async function verifyRequestSignature(name, timestamp, nonce, signature, method, params, storage, config) {
2266
+ validateTimestamp(timestamp, config);
2267
+ const credential = await storage.getCredential(name);
2268
+ if (!credential) {
2269
+ throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, "Invalid credentials");
2270
+ }
2271
+ const message = buildSignatureMessage(timestamp, nonce, method, params);
2272
+ const isValid = await verifySignature(credential.secret, message, signature);
654
2273
  if (!isValid) {
655
- return { valid: false, error: "Invalid signature" };
2274
+ throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, "Invalid signature");
656
2275
  }
657
- const validation = validateAuthMessage(username, message);
658
- if (!validation.valid) {
659
- return { valid: false, error: validation.error };
2276
+ const nonceKey = `nonce:${name}:${nonce}`;
2277
+ const nonceExpiresAt = timestamp + config.timestampMaxAge;
2278
+ const nonceIsNew = await storage.checkAndMarkNonce(nonceKey, nonceExpiresAt);
2279
+ if (!nonceIsNew) {
2280
+ throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, "Nonce already used (replay attack detected)");
660
2281
  }
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];
2282
+ const now = Date.now();
2283
+ const credentialExpiresAt = now + 365 * 24 * 60 * 60 * 1e3;
2284
+ await storage.updateCredentialUsage(name, now, credentialExpiresAt);
667
2285
  }
668
2286
  var handlers = {
669
2287
  /**
670
- * Check if username is available
2288
+ * Generate new credentials (name + secret pair)
2289
+ * No authentication required - this is how users get started
2290
+ * SECURITY: Rate limited per IP to prevent abuse (database-backed for multi-instance support)
671
2291
  */
672
- async getUser(params, message, signature, publicKey, storage, config) {
673
- const { username } = params;
674
- const claimed = await storage.getUsername(username);
675
- if (!claimed) {
2292
+ async generateCredentials(params, name, timestamp, signature, storage, config, request) {
2293
+ let rateLimitKey;
2294
+ let rateLimit;
2295
+ if (!request.clientIp) {
2296
+ console.warn("\u26A0\uFE0F WARNING: Unable to determine client IP for credential generation. Using global rate limit.");
2297
+ rateLimitKey = "cred_gen:global_unknown";
2298
+ rateLimit = 2;
2299
+ } else {
2300
+ rateLimitKey = `cred_gen:${request.clientIp}`;
2301
+ rateLimit = CREDENTIAL_RATE_LIMIT;
2302
+ }
2303
+ const allowed = await storage.checkRateLimit(
2304
+ rateLimitKey,
2305
+ rateLimit,
2306
+ CREDENTIAL_RATE_WINDOW
2307
+ );
2308
+ if (!allowed) {
2309
+ throw new RpcError(
2310
+ ErrorCodes.RATE_LIMIT_EXCEEDED,
2311
+ `Rate limit exceeded. Maximum ${rateLimit} credential per second${request.clientIp ? " per IP" : " (global limit for unidentified IPs)"}.`
2312
+ );
2313
+ }
2314
+ if (params.name !== void 0) {
2315
+ if (typeof params.name !== "string") {
2316
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "name must be a string");
2317
+ }
2318
+ const usernameValidation = validateUsername(params.name);
2319
+ if (!usernameValidation.valid) {
2320
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, usernameValidation.error || "Invalid username");
2321
+ }
2322
+ }
2323
+ if (params.expiresAt !== void 0) {
2324
+ if (typeof params.expiresAt !== "number" || isNaN(params.expiresAt) || !Number.isFinite(params.expiresAt)) {
2325
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "expiresAt must be a valid timestamp");
2326
+ }
2327
+ const now = Date.now();
2328
+ if (params.expiresAt < now - 6e4) {
2329
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "expiresAt cannot be in the past");
2330
+ }
2331
+ const maxFuture = now + 10 * 365 * 24 * 60 * 60 * 1e3;
2332
+ if (params.expiresAt > maxFuture) {
2333
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "expiresAt cannot be more than 10 years in the future");
2334
+ }
2335
+ }
2336
+ try {
2337
+ const credential = await storage.generateCredentials({
2338
+ name: params.name,
2339
+ expiresAt: params.expiresAt
2340
+ });
676
2341
  return {
677
- username,
678
- available: true
2342
+ name: credential.name,
2343
+ secret: credential.secret,
2344
+ createdAt: credential.createdAt,
2345
+ expiresAt: credential.expiresAt
679
2346
  };
2347
+ } catch (error) {
2348
+ if (error.message === "Username already taken") {
2349
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Username already taken");
2350
+ }
2351
+ throw error;
680
2352
  }
681
- return {
682
- username: claimed.username,
683
- available: false,
684
- claimedAt: claimed.claimedAt,
685
- expiresAt: claimed.expiresAt,
686
- publicKey: claimed.publicKey
687
- };
688
2353
  },
689
2354
  /**
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
2355
+ * Discover offers by tags - Supports 2 modes:
2356
+ * 1. Paginated discovery: tags array with limit/offset
2357
+ * 2. Random discovery: tags array without limit (returns single random offer)
694
2358
  */
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
- }
703
- }
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");
2359
+ async discover(params, name, timestamp, signature, storage, config, request) {
2360
+ const { tags, limit, offset } = params;
2361
+ const tagsValidation = validateTags(tags);
2362
+ if (!tagsValidation.valid) {
2363
+ throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || "Invalid tags");
711
2364
  }
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
2365
  if (limit !== void 0) {
2366
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
2367
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "limit must be a non-negative integer");
2368
+ }
2369
+ if (offset !== void 0 && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) {
2370
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "offset must be a non-negative integer");
2371
+ }
732
2372
  const pageLimit = Math.min(Math.max(1, limit), MAX_PAGE_SIZE);
733
2373
  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);
2374
+ const excludeUsername2 = name || null;
2375
+ const offers = await storage.discoverOffers(
2376
+ tags,
2377
+ excludeUsername2,
2378
+ pageLimit,
2379
+ pageOffset
2380
+ );
748
2381
  return {
749
- services: paginatedServices,
750
- count: paginatedServices.length,
2382
+ offers: offers.map((offer2) => ({
2383
+ offerId: offer2.id,
2384
+ username: offer2.username,
2385
+ tags: offer2.tags,
2386
+ sdp: offer2.sdp,
2387
+ createdAt: offer2.createdAt,
2388
+ expiresAt: offer2.expiresAt
2389
+ })),
2390
+ count: offers.length,
751
2391
  limit: pageLimit,
752
2392
  offset: pageOffset
753
2393
  };
754
2394
  }
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");
2395
+ const excludeUsername = name || null;
2396
+ const offer = await storage.getRandomOffer(tags, excludeUsername);
2397
+ if (!offer) {
2398
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "No offers found matching tags");
775
2399
  }
776
- return buildServiceResponse(randomService, availableOffer);
2400
+ return {
2401
+ offerId: offer.id,
2402
+ username: offer.username,
2403
+ tags: offer.tags,
2404
+ sdp: offer.sdp,
2405
+ createdAt: offer.createdAt,
2406
+ expiresAt: offer.expiresAt
2407
+ };
777
2408
  },
778
2409
  /**
779
- * Publish a service
2410
+ * Publish offers with tags
780
2411
  */
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");
786
- }
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");
2412
+ async publishOffer(params, name, timestamp, signature, storage, config, request) {
2413
+ const { tags, offers, ttl } = params;
2414
+ if (!name) {
2415
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required for offer publishing");
794
2416
  }
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");
2417
+ const tagsValidation = validateTags(tags);
2418
+ if (!tagsValidation.valid) {
2419
+ throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || "Invalid tags");
801
2420
  }
802
2421
  if (!offers || !Array.isArray(offers) || offers.length === 0) {
803
- throw new Error("Must provide at least one offer");
2422
+ throw new RpcError(ErrorCodes.MISSING_PARAMS, "Must provide at least one offer");
804
2423
  }
805
2424
  if (offers.length > config.maxOffersPerRequest) {
806
- throw new Error(
2425
+ throw new RpcError(
2426
+ ErrorCodes.TOO_MANY_OFFERS,
807
2427
  `Too many offers (max ${config.maxOffersPerRequest})`
808
2428
  );
809
2429
  }
810
2430
  offers.forEach((offer, index) => {
811
2431
  if (!offer || typeof offer !== "object") {
812
- throw new Error(`Invalid offer at index ${index}: must be an object`);
2432
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid offer at index ${index}: must be an object`);
813
2433
  }
814
2434
  if (!offer.sdp || typeof offer.sdp !== "string") {
815
- throw new Error(`Invalid offer at index ${index}: missing or invalid SDP`);
2435
+ throw new RpcError(ErrorCodes.INVALID_SDP, `Invalid offer at index ${index}: missing or invalid SDP`);
816
2436
  }
817
2437
  if (!offer.sdp.trim()) {
818
- throw new Error(`Invalid offer at index ${index}: SDP cannot be empty`);
2438
+ throw new RpcError(ErrorCodes.INVALID_SDP, `Invalid offer at index ${index}: SDP cannot be empty`);
2439
+ }
2440
+ if (offer.sdp.length > config.maxSdpSize) {
2441
+ throw new RpcError(ErrorCodes.SDP_TOO_LARGE, `SDP too large at index ${index} (max ${config.maxSdpSize} bytes)`);
819
2442
  }
820
2443
  });
2444
+ if (ttl !== void 0) {
2445
+ if (typeof ttl !== "number" || isNaN(ttl) || ttl < 0) {
2446
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "TTL must be a non-negative number");
2447
+ }
2448
+ }
821
2449
  const now = Date.now();
822
2450
  const offerTtl = ttl !== void 0 ? Math.min(
823
2451
  Math.max(ttl, config.offerMinTtl),
@@ -825,108 +2453,83 @@ var handlers = {
825
2453
  ) : config.offerDefaultTtl;
826
2454
  const expiresAt = now + offerTtl;
827
2455
  const offerRequests = offers.map((offer) => ({
828
- username,
829
- serviceFqn,
2456
+ username: name,
2457
+ tags,
830
2458
  sdp: offer.sdp,
831
2459
  expiresAt
832
2460
  }));
833
- const result = await storage.createService({
834
- serviceFqn,
835
- expiresAt,
836
- offers: offerRequests
837
- });
2461
+ const createdOffers = await storage.createOffers(offerRequests);
838
2462
  return {
839
- serviceId: result.service.id,
840
- username: result.service.username,
841
- serviceFqn: result.service.serviceFqn,
842
- offers: result.offers.map((offer) => ({
2463
+ username: name,
2464
+ tags,
2465
+ offers: createdOffers.map((offer) => ({
843
2466
  offerId: offer.id,
844
2467
  sdp: offer.sdp,
845
2468
  createdAt: offer.createdAt,
846
2469
  expiresAt: offer.expiresAt
847
2470
  })),
848
- createdAt: result.service.createdAt,
849
- expiresAt: result.service.expiresAt
2471
+ createdAt: now,
2472
+ expiresAt
850
2473
  };
851
2474
  },
852
- /**
853
- * Delete a service
854
- */
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");
2475
+ /**
2476
+ * Delete an offer by ID
2477
+ */
2478
+ async deleteOffer(params, name, timestamp, signature, storage, config, request) {
2479
+ const { offerId } = params;
2480
+ if (!name) {
2481
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
872
2482
  }
873
- const deleted = await storage.deleteService(service.id, username);
2483
+ validateStringParam(offerId, "offerId");
2484
+ const deleted = await storage.deleteOffer(offerId, name);
874
2485
  if (!deleted) {
875
- throw new Error("Service not found or not owned by this username");
2486
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, "Offer not found or not owned by this name");
876
2487
  }
877
2488
  return { success: true };
878
2489
  },
879
2490
  /**
880
2491
  * Answer an offer
881
2492
  */
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);
2493
+ async answerOffer(params, name, timestamp, signature, storage, config, request) {
2494
+ const { offerId, sdp } = params;
2495
+ validateStringParam(offerId, "offerId");
2496
+ if (!name) {
2497
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
891
2498
  }
892
2499
  if (!sdp || typeof sdp !== "string" || sdp.length === 0) {
893
- throw new Error("Invalid SDP");
2500
+ throw new RpcError(ErrorCodes.INVALID_SDP, "Invalid SDP");
894
2501
  }
895
- if (sdp.length > 64 * 1024) {
896
- throw new Error("SDP too large (max 64KB)");
2502
+ if (sdp.length > config.maxSdpSize) {
2503
+ throw new RpcError(ErrorCodes.SDP_TOO_LARGE, `SDP too large (max ${config.maxSdpSize} bytes)`);
897
2504
  }
898
2505
  const offer = await storage.getOfferById(offerId);
899
2506
  if (!offer) {
900
- throw new Error("Offer not found");
2507
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
901
2508
  }
902
2509
  if (offer.answererUsername) {
903
- throw new Error("Offer already answered");
2510
+ throw new RpcError(ErrorCodes.OFFER_ALREADY_ANSWERED, "Offer already answered");
904
2511
  }
905
- await storage.answerOffer(offerId, username, sdp);
2512
+ await storage.answerOffer(offerId, name, sdp);
906
2513
  return { success: true, offerId };
907
2514
  },
908
2515
  /**
909
2516
  * Get answer for an offer
910
2517
  */
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);
2518
+ async getOfferAnswer(params, name, timestamp, signature, storage, config, request) {
2519
+ const { offerId } = params;
2520
+ validateStringParam(offerId, "offerId");
2521
+ if (!name) {
2522
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
920
2523
  }
921
2524
  const offer = await storage.getOfferById(offerId);
922
2525
  if (!offer) {
923
- throw new Error("Offer not found");
2526
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
924
2527
  }
925
- if (offer.username !== username) {
926
- throw new Error("Not authorized to access this offer");
2528
+ if (offer.username !== name) {
2529
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, "Not authorized to access this offer");
927
2530
  }
928
2531
  if (!offer.answererUsername || !offer.answerSdp) {
929
- throw new Error("Offer not yet answered");
2532
+ throw new RpcError(ErrorCodes.OFFER_NOT_ANSWERED, "Offer not yet answered");
930
2533
  }
931
2534
  return {
932
2535
  sdp: offer.answerSdp,
@@ -938,58 +2541,38 @@ var handlers = {
938
2541
  /**
939
2542
  * Combined polling for answers and ICE candidates
940
2543
  */
941
- async poll(params, message, signature, publicKey, storage, config) {
2544
+ async poll(params, name, timestamp, signature, storage, config, request) {
942
2545
  const { since } = params;
943
- const username = extractUsername(message);
944
- if (!username) {
945
- throw new Error("Username required");
2546
+ if (!name) {
2547
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
946
2548
  }
947
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
948
- if (!auth.valid) {
949
- throw new Error(auth.error);
2549
+ if (since !== void 0 && (typeof since !== "number" || since < 0 || !Number.isFinite(since))) {
2550
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Invalid since parameter: must be a non-negative number");
950
2551
  }
951
- const sinceTimestamp = since || 0;
952
- const answeredOffers = await storage.getAnsweredOffers(username);
2552
+ const sinceTimestamp = since !== void 0 ? since : 0;
2553
+ const answeredOffers = await storage.getAnsweredOffers(name);
953
2554
  const filteredAnswers = answeredOffers.filter(
954
2555
  (offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
955
2556
  );
956
- const allOffers = await storage.getOffersByUsername(username);
2557
+ const ownedOffers = await storage.getOffersByUsername(name);
2558
+ const answeredByUser = await storage.getOffersAnsweredBy(name);
2559
+ const allOfferIds = [
2560
+ ...ownedOffers.map((offer) => offer.id),
2561
+ ...answeredByUser.map((offer) => offer.id)
2562
+ ];
2563
+ const offerIds = [...new Set(allOfferIds)];
2564
+ const iceCandidatesMap = await storage.getIceCandidatesForMultipleOffers(
2565
+ offerIds,
2566
+ name,
2567
+ sinceTimestamp
2568
+ );
957
2569
  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
- }
2570
+ for (const [offerId, candidates] of iceCandidatesMap.entries()) {
2571
+ iceCandidatesByOffer[offerId] = candidates;
988
2572
  }
989
2573
  return {
990
2574
  answers: filteredAnswers.map((offer) => ({
991
2575
  offerId: offer.id,
992
- serviceId: offer.serviceId,
993
2576
  answererId: offer.answererUsername,
994
2577
  sdp: offer.answerSdp,
995
2578
  answeredAt: offer.answeredAt
@@ -1000,32 +2583,53 @@ var handlers = {
1000
2583
  /**
1001
2584
  * Add ICE candidates
1002
2585
  */
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);
2586
+ async addIceCandidates(params, name, timestamp, signature, storage, config, request) {
2587
+ const { offerId, candidates } = params;
2588
+ validateStringParam(offerId, "offerId");
2589
+ if (!name) {
2590
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
1012
2591
  }
1013
2592
  if (!Array.isArray(candidates) || candidates.length === 0) {
1014
- throw new Error("Missing or invalid required parameter: candidates");
2593
+ throw new RpcError(ErrorCodes.MISSING_PARAMS, "Missing or invalid required parameter: candidates");
2594
+ }
2595
+ if (candidates.length > config.maxCandidatesPerRequest) {
2596
+ throw new RpcError(
2597
+ ErrorCodes.INVALID_PARAMS,
2598
+ `Too many candidates (max ${config.maxCandidatesPerRequest})`
2599
+ );
1015
2600
  }
1016
2601
  candidates.forEach((candidate, index) => {
1017
2602
  if (!candidate || typeof candidate !== "object") {
1018
- throw new Error(`Invalid candidate at index ${index}: must be an object`);
2603
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `Invalid candidate at index ${index}: must be an object`);
2604
+ }
2605
+ const depth = getJsonDepth(candidate, config.maxCandidateDepth + 1);
2606
+ if (depth > config.maxCandidateDepth) {
2607
+ throw new RpcError(
2608
+ ErrorCodes.INVALID_PARAMS,
2609
+ `Candidate at index ${index} too deeply nested (max depth ${config.maxCandidateDepth})`
2610
+ );
2611
+ }
2612
+ let candidateJson;
2613
+ try {
2614
+ candidateJson = JSON.stringify(candidate);
2615
+ } catch (e) {
2616
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, `Candidate at index ${index} is not serializable`);
2617
+ }
2618
+ if (candidateJson.length > config.maxCandidateSize) {
2619
+ throw new RpcError(
2620
+ ErrorCodes.INVALID_PARAMS,
2621
+ `Candidate at index ${index} too large (max ${config.maxCandidateSize} bytes)`
2622
+ );
1019
2623
  }
1020
2624
  });
1021
2625
  const offer = await storage.getOfferById(offerId);
1022
2626
  if (!offer) {
1023
- throw new Error("Offer not found");
2627
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
1024
2628
  }
1025
- const role = offer.username === username ? "offerer" : "answerer";
2629
+ const role = offer.username === name ? "offerer" : "answerer";
1026
2630
  const count = await storage.addIceCandidates(
1027
2631
  offerId,
1028
- username,
2632
+ name,
1029
2633
  role,
1030
2634
  candidates
1031
2635
  );
@@ -1034,22 +2638,25 @@ var handlers = {
1034
2638
  /**
1035
2639
  * Get ICE candidates
1036
2640
  */
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");
2641
+ async getIceCandidates(params, name, timestamp, signature, storage, config, request) {
2642
+ const { offerId, since } = params;
2643
+ validateStringParam(offerId, "offerId");
2644
+ if (!name) {
2645
+ throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
1042
2646
  }
1043
- const auth = await verifyAuth(username, message, signature, publicKey, storage);
1044
- if (!auth.valid) {
1045
- throw new Error(auth.error);
2647
+ if (since !== void 0 && (typeof since !== "number" || since < 0 || !Number.isFinite(since))) {
2648
+ throw new RpcError(ErrorCodes.INVALID_PARAMS, "Invalid since parameter: must be a non-negative number");
1046
2649
  }
1047
- const sinceTimestamp = since || 0;
2650
+ const sinceTimestamp = since !== void 0 ? since : 0;
1048
2651
  const offer = await storage.getOfferById(offerId);
1049
2652
  if (!offer) {
1050
- throw new Error("Offer not found");
2653
+ throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
2654
+ }
2655
+ const isOfferer = offer.username === name;
2656
+ const isAnswerer = offer.answererUsername === name;
2657
+ if (!isOfferer && !isAnswerer) {
2658
+ throw new RpcError(ErrorCodes.NOT_AUTHORIZED, "Not authorized to access ICE candidates for this offer");
1051
2659
  }
1052
- const isOfferer = offer.username === username;
1053
2660
  const role = isOfferer ? "answerer" : "offerer";
1054
2661
  const candidates = await storage.getIceCandidates(
1055
2662
  offerId,
@@ -1065,64 +2672,150 @@ var handlers = {
1065
2672
  };
1066
2673
  }
1067
2674
  };
1068
- async function handleRpc(requests, storage, config) {
2675
+ var UNAUTHENTICATED_METHODS = /* @__PURE__ */ new Set(["generateCredentials", "discover"]);
2676
+ async function handleRpc(requests, ctx, storage, config) {
1069
2677
  const responses = [];
2678
+ const clientIp = ctx.req.header("cf-connecting-ip") || // Cloudflare
2679
+ ctx.req.header("x-real-ip") || // Nginx
2680
+ ctx.req.header("x-forwarded-for")?.split(",")[0].trim() || void 0;
2681
+ const name = ctx.req.header("X-Name");
2682
+ const timestampHeader = ctx.req.header("X-Timestamp");
2683
+ const nonce = ctx.req.header("X-Nonce");
2684
+ const signature = ctx.req.header("X-Signature");
2685
+ const timestamp = timestampHeader ? parseInt(timestampHeader, 10) : 0;
2686
+ let totalOperations = 0;
2687
+ for (const request of requests) {
2688
+ const { method, params } = request;
2689
+ if (method === "publishOffer" && params?.offers && Array.isArray(params.offers)) {
2690
+ totalOperations += params.offers.length;
2691
+ } else if (method === "addIceCandidates" && params?.candidates && Array.isArray(params.candidates)) {
2692
+ totalOperations += params.candidates.length;
2693
+ } else {
2694
+ totalOperations += 1;
2695
+ }
2696
+ }
2697
+ if (totalOperations > config.maxTotalOperations) {
2698
+ return requests.map(() => ({
2699
+ success: false,
2700
+ error: `Total operations across batch exceed limit: ${totalOperations} > ${config.maxTotalOperations}`,
2701
+ errorCode: ErrorCodes.BATCH_TOO_LARGE
2702
+ }));
2703
+ }
1070
2704
  for (const request of requests) {
1071
2705
  try {
1072
- const { method, message, signature, publicKey, params } = request;
2706
+ const { method, params } = request;
1073
2707
  if (!method || typeof method !== "string") {
1074
2708
  responses.push({
1075
2709
  success: false,
1076
- error: "Missing or invalid method"
2710
+ error: "Missing or invalid method",
2711
+ errorCode: ErrorCodes.INVALID_PARAMS
1077
2712
  });
1078
2713
  continue;
1079
2714
  }
1080
- if (!message || typeof message !== "string") {
2715
+ const handler = handlers[method];
2716
+ if (!handler) {
1081
2717
  responses.push({
1082
2718
  success: false,
1083
- error: "Missing or invalid message"
2719
+ error: `Unknown method: ${method}`,
2720
+ errorCode: ErrorCodes.UNKNOWN_METHOD
1084
2721
  });
1085
2722
  continue;
1086
2723
  }
1087
- if (!signature || typeof signature !== "string") {
2724
+ const requiresAuth = !UNAUTHENTICATED_METHODS.has(method);
2725
+ if (requiresAuth) {
2726
+ if (!name || typeof name !== "string") {
2727
+ responses.push({
2728
+ success: false,
2729
+ error: "Missing or invalid X-Name header",
2730
+ errorCode: ErrorCodes.AUTH_REQUIRED
2731
+ });
2732
+ continue;
2733
+ }
2734
+ if (!timestampHeader || typeof timestampHeader !== "string" || isNaN(timestamp)) {
2735
+ responses.push({
2736
+ success: false,
2737
+ error: "Missing or invalid X-Timestamp header",
2738
+ errorCode: ErrorCodes.AUTH_REQUIRED
2739
+ });
2740
+ continue;
2741
+ }
2742
+ if (!nonce || typeof nonce !== "string") {
2743
+ responses.push({
2744
+ success: false,
2745
+ error: "Missing or invalid X-Nonce header (use crypto.randomUUID())",
2746
+ errorCode: ErrorCodes.AUTH_REQUIRED
2747
+ });
2748
+ continue;
2749
+ }
2750
+ if (!signature || typeof signature !== "string") {
2751
+ responses.push({
2752
+ success: false,
2753
+ error: "Missing or invalid X-Signature header",
2754
+ errorCode: ErrorCodes.AUTH_REQUIRED
2755
+ });
2756
+ continue;
2757
+ }
2758
+ await verifyRequestSignature(
2759
+ name,
2760
+ timestamp,
2761
+ nonce,
2762
+ signature,
2763
+ method,
2764
+ params,
2765
+ storage,
2766
+ config
2767
+ );
2768
+ const result = await handler(
2769
+ params || {},
2770
+ name,
2771
+ timestamp,
2772
+ signature,
2773
+ storage,
2774
+ config,
2775
+ { ...request, clientIp }
2776
+ );
2777
+ responses.push({
2778
+ success: true,
2779
+ result
2780
+ });
2781
+ } else {
2782
+ const result = await handler(
2783
+ params || {},
2784
+ name || "",
2785
+ 0,
2786
+ // timestamp
2787
+ "",
2788
+ // signature
2789
+ storage,
2790
+ config,
2791
+ { ...request, clientIp }
2792
+ );
2793
+ responses.push({
2794
+ success: true,
2795
+ result
2796
+ });
2797
+ }
2798
+ } catch (err) {
2799
+ if (err instanceof RpcError) {
1088
2800
  responses.push({
1089
2801
  success: false,
1090
- error: "Missing or invalid signature"
2802
+ error: err.message,
2803
+ errorCode: err.errorCode
1091
2804
  });
1092
- continue;
1093
- }
1094
- const handler = handlers[method];
1095
- if (!handler) {
2805
+ } else {
2806
+ console.error("Unexpected RPC error:", err);
1096
2807
  responses.push({
1097
2808
  success: false,
1098
- error: `Unknown method: ${method}`
2809
+ error: "Internal server error",
2810
+ errorCode: ErrorCodes.INTERNAL_ERROR
1099
2811
  });
1100
- continue;
1101
2812
  }
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
2813
  }
1120
2814
  }
1121
2815
  return responses;
1122
2816
  }
1123
2817
 
1124
2818
  // src/app.ts
1125
- var MAX_BATCH_SIZE = 100;
1126
2819
  function createApp(storage, config) {
1127
2820
  const app = new import_hono.Hono();
1128
2821
  app.use("/*", (0, import_cors.cors)({
@@ -1136,7 +2829,7 @@ function createApp(storage, config) {
1136
2829
  return config.corsOrigins[0];
1137
2830
  },
1138
2831
  allowMethods: ["GET", "POST", "OPTIONS"],
1139
- allowHeaders: ["Content-Type", "Origin"],
2832
+ allowHeaders: ["Content-Type", "Origin", "X-Name", "X-Timestamp", "X-Nonce", "X-Signature"],
1140
2833
  exposeHeaders: ["Content-Type"],
1141
2834
  credentials: false,
1142
2835
  maxAge: 86400
@@ -1145,7 +2838,7 @@ function createApp(storage, config) {
1145
2838
  return c.json({
1146
2839
  version: config.version,
1147
2840
  name: "Rondevu",
1148
- description: "WebRTC signaling with RPC interface and Ed25519 authentication"
2841
+ description: "WebRTC signaling with RPC interface and HMAC signature-based authentication"
1149
2842
  }, 200);
1150
2843
  });
1151
2844
  app.get("/health", (c) => {
@@ -1158,21 +2851,38 @@ function createApp(storage, config) {
1158
2851
  app.post("/rpc", async (c) => {
1159
2852
  try {
1160
2853
  const body = await c.req.json();
1161
- const requests = Array.isArray(body) ? body : [body];
2854
+ if (!Array.isArray(body)) {
2855
+ return c.json([{
2856
+ success: false,
2857
+ error: "Request must be an array of RPC calls",
2858
+ errorCode: "INVALID_PARAMS"
2859
+ }], 400);
2860
+ }
2861
+ const requests = body;
1162
2862
  if (requests.length === 0) {
1163
- return c.json({ error: "Empty request array" }, 400);
2863
+ return c.json([{
2864
+ success: false,
2865
+ error: "Empty request array",
2866
+ errorCode: "INVALID_PARAMS"
2867
+ }], 400);
1164
2868
  }
1165
- if (requests.length > MAX_BATCH_SIZE) {
1166
- return c.json({ error: `Too many requests in batch (max ${MAX_BATCH_SIZE})` }, 400);
2869
+ if (requests.length > config.maxBatchSize) {
2870
+ return c.json([{
2871
+ success: false,
2872
+ error: `Too many requests in batch (max ${config.maxBatchSize})`,
2873
+ errorCode: "BATCH_TOO_LARGE"
2874
+ }], 413);
1167
2875
  }
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({
2876
+ const responses = await handleRpc(requests, c, storage, config);
2877
+ return c.json(responses, 200);
2878
+ } catch (err) {
2879
+ console.error("RPC error:", err);
2880
+ const errorMsg = err instanceof SyntaxError ? "Invalid JSON in request body" : "Request must be valid JSON array";
2881
+ return c.json([{
1173
2882
  success: false,
1174
- error: "Invalid request format"
1175
- }, 400);
2883
+ error: errorMsg,
2884
+ errorCode: "INVALID_PARAMS"
2885
+ }], 400);
1176
2886
  }
1177
2887
  });
1178
2888
  app.all("*", (c) => {
@@ -1185,521 +2895,124 @@ function createApp(storage, config) {
1185
2895
 
1186
2896
  // src/config.ts
1187
2897
  function loadConfig() {
1188
- return {
1189
- port: parseInt(process.env.PORT || "3000", 10),
1190
- storageType: process.env.STORAGE_TYPE || "sqlite",
2898
+ let masterEncryptionKey = process.env.MASTER_ENCRYPTION_KEY;
2899
+ if (!masterEncryptionKey) {
2900
+ const isDevelopment = process.env.NODE_ENV === "development";
2901
+ if (!isDevelopment) {
2902
+ throw new Error(
2903
+ "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."
2904
+ );
2905
+ }
2906
+ console.error("\u26A0\uFE0F WARNING: Using insecure deterministic development key");
2907
+ console.error("\u26A0\uFE0F ONLY use NODE_ENV=development for local development");
2908
+ console.error("\u26A0\uFE0F Generate production key with: openssl rand -hex 32");
2909
+ masterEncryptionKey = "a3f8b9c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2";
2910
+ }
2911
+ if (masterEncryptionKey.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(masterEncryptionKey)) {
2912
+ throw new Error("MASTER_ENCRYPTION_KEY must be 64-character hex string (32 bytes). Generate with: openssl rand -hex 32");
2913
+ }
2914
+ function parsePositiveInt(value, defaultValue, name, min = 1) {
2915
+ const parsed = parseInt(value || defaultValue, 10);
2916
+ if (isNaN(parsed)) {
2917
+ throw new Error(`${name} must be a valid integer (got: ${value})`);
2918
+ }
2919
+ if (parsed < min) {
2920
+ throw new Error(`${name} must be >= ${min} (got: ${parsed})`);
2921
+ }
2922
+ return parsed;
2923
+ }
2924
+ const config = {
2925
+ port: parsePositiveInt(process.env.PORT, "3000", "PORT", 1),
2926
+ storageType: process.env.STORAGE_TYPE || "memory",
1191
2927
  storagePath: process.env.STORAGE_PATH || ":memory:",
2928
+ databaseUrl: process.env.DATABASE_URL || "",
2929
+ dbPoolSize: parsePositiveInt(process.env.DB_POOL_SIZE, "10", "DB_POOL_SIZE", 1),
1192
2930
  corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) : ["*"],
1193
2931
  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)
2932
+ offerDefaultTtl: parsePositiveInt(process.env.OFFER_DEFAULT_TTL, "60000", "OFFER_DEFAULT_TTL", 1e3),
2933
+ offerMaxTtl: parsePositiveInt(process.env.OFFER_MAX_TTL, "86400000", "OFFER_MAX_TTL", 1e3),
2934
+ offerMinTtl: parsePositiveInt(process.env.OFFER_MIN_TTL, "60000", "OFFER_MIN_TTL", 1e3),
2935
+ cleanupInterval: parsePositiveInt(process.env.CLEANUP_INTERVAL, "60000", "CLEANUP_INTERVAL", 1e3),
2936
+ maxOffersPerRequest: parsePositiveInt(process.env.MAX_OFFERS_PER_REQUEST, "100", "MAX_OFFERS_PER_REQUEST", 1),
2937
+ maxBatchSize: parsePositiveInt(process.env.MAX_BATCH_SIZE, "100", "MAX_BATCH_SIZE", 1),
2938
+ maxSdpSize: parsePositiveInt(process.env.MAX_SDP_SIZE, String(64 * 1024), "MAX_SDP_SIZE", 1024),
2939
+ // Min 1KB
2940
+ maxCandidateSize: parsePositiveInt(process.env.MAX_CANDIDATE_SIZE, String(4 * 1024), "MAX_CANDIDATE_SIZE", 256),
2941
+ // Min 256 bytes
2942
+ maxCandidateDepth: parsePositiveInt(process.env.MAX_CANDIDATE_DEPTH, "10", "MAX_CANDIDATE_DEPTH", 1),
2943
+ maxCandidatesPerRequest: parsePositiveInt(process.env.MAX_CANDIDATES_PER_REQUEST, "100", "MAX_CANDIDATES_PER_REQUEST", 1),
2944
+ maxTotalOperations: parsePositiveInt(process.env.MAX_TOTAL_OPERATIONS, "1000", "MAX_TOTAL_OPERATIONS", 1),
2945
+ timestampMaxAge: parsePositiveInt(process.env.TIMESTAMP_MAX_AGE, "60000", "TIMESTAMP_MAX_AGE", 1e3),
2946
+ // Min 1 second
2947
+ timestampMaxFuture: parsePositiveInt(process.env.TIMESTAMP_MAX_FUTURE, "60000", "TIMESTAMP_MAX_FUTURE", 1e3),
2948
+ // Min 1 second
2949
+ masterEncryptionKey
1199
2950
  };
2951
+ return config;
1200
2952
  }
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;
2953
+ var CONFIG_DEFAULTS = {
2954
+ offerDefaultTtl: 6e4,
2955
+ offerMaxTtl: 864e5,
2956
+ offerMinTtl: 6e4,
2957
+ cleanupInterval: 6e4,
2958
+ maxOffersPerRequest: 100,
2959
+ maxBatchSize: 100,
2960
+ maxSdpSize: 64 * 1024,
2961
+ maxCandidateSize: 4 * 1024,
2962
+ maxCandidateDepth: 10,
2963
+ maxCandidatesPerRequest: 100,
2964
+ maxTotalOperations: 1e3,
2965
+ timestampMaxAge: 6e4,
2966
+ timestampMaxFuture: 6e4
2967
+ };
2968
+ async function runCleanup(storage, now) {
2969
+ const offers = await storage.deleteExpiredOffers(now);
2970
+ const credentials = await storage.deleteExpiredCredentials(now);
2971
+ const rateLimits = await storage.deleteExpiredRateLimits(now);
2972
+ const nonces = await storage.deleteExpiredNonces(now);
2973
+ return { offers, credentials, rateLimits, nonces };
1218
2974
  }
1219
2975
 
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)
2976
+ // src/storage/factory.ts
2977
+ async function createStorage(config) {
2978
+ switch (config.type) {
2979
+ case "memory": {
2980
+ const { MemoryStorage: MemoryStorage2 } = await Promise.resolve().then(() => (init_memory(), memory_exports));
2981
+ return new MemoryStorage2(config.masterEncryptionKey);
2982
+ }
2983
+ case "sqlite": {
2984
+ const { SQLiteStorage: SQLiteStorage2 } = await Promise.resolve().then(() => (init_sqlite(), sqlite_exports));
2985
+ return new SQLiteStorage2(
2986
+ config.sqlitePath || ":memory:",
2987
+ config.masterEncryptionKey
1297
2988
  );
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
2989
  }
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
- );
2990
+ case "mysql": {
2991
+ if (!config.connectionString) {
2992
+ throw new Error("MySQL storage requires DATABASE_URL connection string");
1432
2993
  }
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
2994
+ const { MySQLStorage: MySQLStorage2 } = await Promise.resolve().then(() => (init_mysql(), mysql_exports));
2995
+ return MySQLStorage2.create(
2996
+ config.connectionString,
2997
+ config.masterEncryptionKey,
2998
+ config.poolSize || 10
1562
2999
  );
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
3000
  }
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;
3001
+ case "postgres": {
3002
+ if (!config.connectionString) {
3003
+ throw new Error("PostgreSQL storage requires DATABASE_URL connection string");
3004
+ }
3005
+ const { PostgreSQLStorage: PostgreSQLStorage2 } = await Promise.resolve().then(() => (init_postgres(), postgres_exports));
3006
+ return PostgreSQLStorage2.create(
3007
+ config.connectionString,
3008
+ config.masterEncryptionKey,
3009
+ config.poolSize || 10
3010
+ );
1650
3011
  }
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();
3012
+ default:
3013
+ throw new Error(`Unsupported storage type: ${config.type}`);
1668
3014
  }
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
- };
3015
+ }
1703
3016
 
1704
3017
  // src/index.ts
1705
3018
  async function main() {
@@ -1708,31 +3021,30 @@ async function main() {
1708
3021
  console.log("Configuration:", {
1709
3022
  port: config.port,
1710
3023
  storageType: config.storageType,
1711
- storagePath: config.storagePath,
3024
+ storagePath: config.storageType === "sqlite" ? config.storagePath : void 0,
3025
+ databaseUrl: config.databaseUrl ? "[configured]" : void 0,
3026
+ dbPoolSize: ["mysql", "postgres"].includes(config.storageType) ? config.dbPoolSize : void 0,
1712
3027
  offerDefaultTtl: `${config.offerDefaultTtl}ms`,
1713
- offerMaxTtl: `${config.offerMaxTtl}ms`,
1714
- offerMinTtl: `${config.offerMinTtl}ms`,
1715
3028
  cleanupInterval: `${config.cleanupInterval}ms`,
1716
- maxOffersPerRequest: config.maxOffersPerRequest,
1717
- corsOrigins: config.corsOrigins,
1718
3029
  version: config.version
1719
3030
  });
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 () => {
3031
+ const storage = await createStorage({
3032
+ type: config.storageType,
3033
+ masterEncryptionKey: config.masterEncryptionKey,
3034
+ sqlitePath: config.storagePath,
3035
+ connectionString: config.databaseUrl,
3036
+ poolSize: config.dbPoolSize
3037
+ });
3038
+ console.log(`Using ${config.storageType} storage`);
3039
+ const cleanupTimer = setInterval(async () => {
1728
3040
  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)`);
3041
+ const result = await runCleanup(storage, Date.now());
3042
+ const total = result.offers + result.credentials + result.rateLimits + result.nonces;
3043
+ if (total > 0) {
3044
+ console.log(`Cleanup: ${result.offers} offers, ${result.credentials} credentials, ${result.rateLimits} rate limits, ${result.nonces} nonces`);
1733
3045
  }
1734
- } catch (err2) {
1735
- console.error("Cleanup error:", err2);
3046
+ } catch (err) {
3047
+ console.error("Cleanup error:", err);
1736
3048
  }
1737
3049
  }, config.cleanupInterval);
1738
3050
  const app = createApp(storage, config);
@@ -1744,20 +3056,15 @@ async function main() {
1744
3056
  console.log("Ready to accept connections");
1745
3057
  const shutdown = async () => {
1746
3058
  console.log("\nShutting down gracefully...");
1747
- clearInterval(cleanupInterval);
3059
+ clearInterval(cleanupTimer);
1748
3060
  await storage.close();
1749
3061
  process.exit(0);
1750
3062
  };
1751
3063
  process.on("SIGINT", shutdown);
1752
3064
  process.on("SIGTERM", shutdown);
1753
3065
  }
1754
- main().catch((err2) => {
1755
- console.error("Fatal error:", err2);
3066
+ main().catch((err) => {
3067
+ console.error("Fatal error:", err);
1756
3068
  process.exit(1);
1757
3069
  });
1758
- /*! Bundled license information:
1759
-
1760
- @noble/ed25519/index.js:
1761
- (*! noble-ed25519 - MIT License (c) 2019 Paul Miller (paulmillr.com) *)
1762
- */
1763
3070
  //# sourceMappingURL=index.js.map