@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/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.idea/modules.xml +8 -0
- package/.idea/rondevu-server.iml +8 -0
- package/.idea/workspace.xml +17 -0
- package/README.md +80 -199
- package/build.js +4 -1
- package/dist/index.js +2755 -1448
- package/dist/index.js.map +4 -4
- package/migrations/fresh_schema.sql +36 -41
- package/package.json +10 -4
- package/src/app.ts +38 -18
- package/src/config.ts +155 -9
- package/src/crypto.ts +362 -265
- package/src/index.ts +20 -25
- package/src/rpc.ts +658 -405
- package/src/storage/d1.ts +312 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +559 -0
- package/src/storage/mysql.ts +588 -0
- package/src/storage/postgres.ts +595 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +303 -269
- package/src/storage/types.ts +113 -113
- package/src/worker.ts +15 -34
- package/tests/integration/api.test.ts +395 -0
- package/tests/integration/setup.ts +170 -0
- package/wrangler.toml +25 -26
- package/ADVANCED.md +0 -502
package/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
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
573
|
-
if (
|
|
574
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
2274
|
+
throw new RpcError(ErrorCodes.INVALID_CREDENTIALS, "Invalid signature");
|
|
656
2275
|
}
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
*
|
|
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
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
if (!
|
|
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
|
-
|
|
678
|
-
|
|
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
|
-
*
|
|
691
|
-
* 1.
|
|
692
|
-
* 2.
|
|
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
|
|
696
|
-
const {
|
|
697
|
-
const
|
|
698
|
-
if (
|
|
699
|
-
|
|
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
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
750
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
|
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
|
|
2410
|
+
* Publish offers with tags
|
|
780
2411
|
*/
|
|
781
|
-
async
|
|
782
|
-
const {
|
|
783
|
-
|
|
784
|
-
|
|
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
|
|
796
|
-
if (!
|
|
797
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2456
|
+
username: name,
|
|
2457
|
+
tags,
|
|
830
2458
|
sdp: offer.sdp,
|
|
831
2459
|
expiresAt
|
|
832
2460
|
}));
|
|
833
|
-
const
|
|
834
|
-
serviceFqn,
|
|
835
|
-
expiresAt,
|
|
836
|
-
offers: offerRequests
|
|
837
|
-
});
|
|
2461
|
+
const createdOffers = await storage.createOffers(offerRequests);
|
|
838
2462
|
return {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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:
|
|
849
|
-
expiresAt
|
|
2471
|
+
createdAt: now,
|
|
2472
|
+
expiresAt
|
|
850
2473
|
};
|
|
851
2474
|
},
|
|
852
|
-
/**
|
|
853
|
-
* Delete
|
|
854
|
-
*/
|
|
855
|
-
async
|
|
856
|
-
const {
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
2483
|
+
validateStringParam(offerId, "offerId");
|
|
2484
|
+
const deleted = await storage.deleteOffer(offerId, name);
|
|
874
2485
|
if (!deleted) {
|
|
875
|
-
throw new
|
|
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,
|
|
883
|
-
const {
|
|
884
|
-
|
|
885
|
-
if (!
|
|
886
|
-
throw new
|
|
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
|
|
2500
|
+
throw new RpcError(ErrorCodes.INVALID_SDP, "Invalid SDP");
|
|
894
2501
|
}
|
|
895
|
-
if (sdp.length >
|
|
896
|
-
throw new
|
|
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
|
|
2507
|
+
throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
|
|
901
2508
|
}
|
|
902
2509
|
if (offer.answererUsername) {
|
|
903
|
-
throw new
|
|
2510
|
+
throw new RpcError(ErrorCodes.OFFER_ALREADY_ANSWERED, "Offer already answered");
|
|
904
2511
|
}
|
|
905
|
-
await storage.answerOffer(offerId,
|
|
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,
|
|
912
|
-
const {
|
|
913
|
-
|
|
914
|
-
if (!
|
|
915
|
-
throw new
|
|
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
|
|
2526
|
+
throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
|
|
924
2527
|
}
|
|
925
|
-
if (offer.username !==
|
|
926
|
-
throw new
|
|
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
|
|
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,
|
|
2544
|
+
async poll(params, name, timestamp, signature, storage, config, request) {
|
|
942
2545
|
const { since } = params;
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
throw new Error("Username required");
|
|
2546
|
+
if (!name) {
|
|
2547
|
+
throw new RpcError(ErrorCodes.AUTH_REQUIRED, "Name required");
|
|
946
2548
|
}
|
|
947
|
-
|
|
948
|
-
|
|
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
|
|
952
|
-
const answeredOffers = await storage.getAnsweredOffers(
|
|
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
|
|
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
|
|
959
|
-
|
|
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,
|
|
1004
|
-
const {
|
|
1005
|
-
|
|
1006
|
-
if (!
|
|
1007
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
2627
|
+
throw new RpcError(ErrorCodes.OFFER_NOT_FOUND, "Offer not found");
|
|
1024
2628
|
}
|
|
1025
|
-
const role = offer.username ===
|
|
2629
|
+
const role = offer.username === name ? "offerer" : "answerer";
|
|
1026
2630
|
const count = await storage.addIceCandidates(
|
|
1027
2631
|
offerId,
|
|
1028
|
-
|
|
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,
|
|
1038
|
-
const {
|
|
1039
|
-
|
|
1040
|
-
if (!
|
|
1041
|
-
throw new
|
|
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
|
-
|
|
1044
|
-
|
|
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
|
|
2650
|
+
const sinceTimestamp = since !== void 0 ? since : 0;
|
|
1048
2651
|
const offer = await storage.getOfferById(offerId);
|
|
1049
2652
|
if (!offer) {
|
|
1050
|
-
throw new
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
2715
|
+
const handler = handlers[method];
|
|
2716
|
+
if (!handler) {
|
|
1081
2717
|
responses.push({
|
|
1082
2718
|
success: false,
|
|
1083
|
-
error:
|
|
2719
|
+
error: `Unknown method: ${method}`,
|
|
2720
|
+
errorCode: ErrorCodes.UNKNOWN_METHOD
|
|
1084
2721
|
});
|
|
1085
2722
|
continue;
|
|
1086
2723
|
}
|
|
1087
|
-
|
|
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:
|
|
2802
|
+
error: err.message,
|
|
2803
|
+
errorCode: err.errorCode
|
|
1091
2804
|
});
|
|
1092
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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({
|
|
2863
|
+
return c.json([{
|
|
2864
|
+
success: false,
|
|
2865
|
+
error: "Empty request array",
|
|
2866
|
+
errorCode: "INVALID_PARAMS"
|
|
2867
|
+
}], 400);
|
|
1164
2868
|
}
|
|
1165
|
-
if (requests.length >
|
|
1166
|
-
return c.json({
|
|
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(
|
|
1170
|
-
} catch (
|
|
1171
|
-
console.error("RPC error:",
|
|
1172
|
-
|
|
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:
|
|
1175
|
-
|
|
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
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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:
|
|
1195
|
-
offerMaxTtl:
|
|
1196
|
-
offerMinTtl:
|
|
1197
|
-
cleanupInterval:
|
|
1198
|
-
maxOffersPerRequest:
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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/
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
}
|
|
1727
|
-
|
|
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
|
|
1730
|
-
const
|
|
1731
|
-
if (
|
|
1732
|
-
console.log(`Cleanup:
|
|
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 (
|
|
1735
|
-
console.error("Cleanup error:",
|
|
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(
|
|
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((
|
|
1755
|
-
console.error("Fatal error:",
|
|
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
|