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