@xtr-dev/rondevu-server 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -28,173 +28,585 @@ var import_node_server = require("@hono/node-server");
28
28
  // src/app.ts
29
29
  var import_hono = require("hono");
30
30
  var import_cors = require("hono/cors");
31
+
32
+ // src/crypto.ts
33
+ var ALGORITHM = "AES-GCM";
34
+ var IV_LENGTH = 12;
35
+ var KEY_LENGTH = 32;
36
+ function generatePeerId() {
37
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
38
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
39
+ }
40
+ function generateSecretKey() {
41
+ const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
42
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
43
+ }
44
+ function hexToBytes(hex) {
45
+ const bytes = new Uint8Array(hex.length / 2);
46
+ for (let i = 0; i < hex.length; i += 2) {
47
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
48
+ }
49
+ return bytes;
50
+ }
51
+ function bytesToBase64(bytes) {
52
+ const binString = Array.from(
53
+ bytes,
54
+ (byte) => String.fromCodePoint(byte)
55
+ ).join("");
56
+ return btoa(binString);
57
+ }
58
+ function base64ToBytes(base64) {
59
+ const binString = atob(base64);
60
+ return Uint8Array.from(binString, (char) => char.codePointAt(0));
61
+ }
62
+ async function encryptPeerId(peerId, secretKeyHex) {
63
+ const keyBytes = hexToBytes(secretKeyHex);
64
+ if (keyBytes.length !== KEY_LENGTH) {
65
+ throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
66
+ }
67
+ const key = await crypto.subtle.importKey(
68
+ "raw",
69
+ keyBytes,
70
+ { name: ALGORITHM, length: 256 },
71
+ false,
72
+ ["encrypt"]
73
+ );
74
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
75
+ const encoder = new TextEncoder();
76
+ const data = encoder.encode(peerId);
77
+ const encrypted = await crypto.subtle.encrypt(
78
+ { name: ALGORITHM, iv },
79
+ key,
80
+ data
81
+ );
82
+ const combined = new Uint8Array(iv.length + encrypted.byteLength);
83
+ combined.set(iv, 0);
84
+ combined.set(new Uint8Array(encrypted), iv.length);
85
+ return bytesToBase64(combined);
86
+ }
87
+ async function decryptPeerId(encryptedSecret, secretKeyHex) {
88
+ try {
89
+ const keyBytes = hexToBytes(secretKeyHex);
90
+ if (keyBytes.length !== KEY_LENGTH) {
91
+ throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
92
+ }
93
+ const combined = base64ToBytes(encryptedSecret);
94
+ const iv = combined.slice(0, IV_LENGTH);
95
+ const ciphertext = combined.slice(IV_LENGTH);
96
+ const key = await crypto.subtle.importKey(
97
+ "raw",
98
+ keyBytes,
99
+ { name: ALGORITHM, length: 256 },
100
+ false,
101
+ ["decrypt"]
102
+ );
103
+ const decrypted = await crypto.subtle.decrypt(
104
+ { name: ALGORITHM, iv },
105
+ key,
106
+ ciphertext
107
+ );
108
+ const decoder = new TextDecoder();
109
+ return decoder.decode(decrypted);
110
+ } catch (err) {
111
+ throw new Error("Failed to decrypt peer ID: invalid secret or secret key");
112
+ }
113
+ }
114
+ async function validateCredentials(peerId, encryptedSecret, secretKey) {
115
+ try {
116
+ const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey);
117
+ return decryptedPeerId === peerId;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ // src/middleware/auth.ts
124
+ function createAuthMiddleware(authSecret) {
125
+ return async (c, next) => {
126
+ const authHeader = c.req.header("Authorization");
127
+ if (!authHeader) {
128
+ return c.json({ error: "Missing Authorization header" }, 401);
129
+ }
130
+ const parts = authHeader.split(" ");
131
+ if (parts.length !== 2 || parts[0] !== "Bearer") {
132
+ return c.json({ error: "Invalid Authorization header format. Expected: Bearer {peerId}:{secret}" }, 401);
133
+ }
134
+ const credentials = parts[1].split(":");
135
+ if (credentials.length !== 2) {
136
+ return c.json({ error: "Invalid credentials format. Expected: {peerId}:{secret}" }, 401);
137
+ }
138
+ const [peerId, encryptedSecret] = credentials;
139
+ const isValid = await validateCredentials(peerId, encryptedSecret, authSecret);
140
+ if (!isValid) {
141
+ return c.json({ error: "Invalid credentials" }, 401);
142
+ }
143
+ c.set("peerId", peerId);
144
+ await next();
145
+ };
146
+ }
147
+ function getAuthenticatedPeerId(c) {
148
+ const peerId = c.get("peerId");
149
+ if (!peerId) {
150
+ throw new Error("No authenticated peer ID in context");
151
+ }
152
+ return peerId;
153
+ }
154
+
155
+ // src/bloom.ts
156
+ var BloomFilter = class {
157
+ /**
158
+ * Creates a bloom filter from a base64 encoded bit array
159
+ */
160
+ constructor(base64Data, numHashes = 3) {
161
+ const binaryString = atob(base64Data);
162
+ const bytes = new Uint8Array(binaryString.length);
163
+ for (let i = 0; i < binaryString.length; i++) {
164
+ bytes[i] = binaryString.charCodeAt(i);
165
+ }
166
+ this.bits = bytes;
167
+ this.size = this.bits.length * 8;
168
+ this.numHashes = numHashes;
169
+ }
170
+ /**
171
+ * Test if a peer ID might be in the filter
172
+ * Returns true if possibly in set, false if definitely not in set
173
+ */
174
+ test(peerId) {
175
+ for (let i = 0; i < this.numHashes; i++) {
176
+ const hash = this.hash(peerId, i);
177
+ const index = hash % this.size;
178
+ const byteIndex = Math.floor(index / 8);
179
+ const bitIndex = index % 8;
180
+ if (!(this.bits[byteIndex] & 1 << bitIndex)) {
181
+ return false;
182
+ }
183
+ }
184
+ return true;
185
+ }
186
+ /**
187
+ * Simple hash function (FNV-1a variant)
188
+ */
189
+ hash(str, seed) {
190
+ let hash = 2166136261 ^ seed;
191
+ for (let i = 0; i < str.length; i++) {
192
+ hash ^= str.charCodeAt(i);
193
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
194
+ }
195
+ return hash >>> 0;
196
+ }
197
+ };
198
+ function parseBloomFilter(base64) {
199
+ try {
200
+ return new BloomFilter(base64);
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ // src/app.ts
31
207
  function createApp(storage, config) {
32
208
  const app = new import_hono.Hono();
209
+ const authMiddleware = createAuthMiddleware(config.authSecret);
33
210
  app.use("/*", (0, import_cors.cors)({
34
- origin: config.corsOrigins,
35
- allowMethods: ["GET", "POST", "OPTIONS"],
36
- allowHeaders: ["Content-Type"],
211
+ origin: (origin) => {
212
+ if (config.corsOrigins.length === 1 && config.corsOrigins[0] === "*") {
213
+ return origin;
214
+ }
215
+ if (config.corsOrigins.includes(origin)) {
216
+ return origin;
217
+ }
218
+ return config.corsOrigins[0];
219
+ },
220
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
221
+ allowHeaders: ["Content-Type", "Origin", "Authorization"],
37
222
  exposeHeaders: ["Content-Type"],
38
223
  maxAge: 600,
39
224
  credentials: true
40
225
  }));
41
- app.get("/", async (c) => {
226
+ app.get("/", (c) => {
227
+ return c.json({
228
+ version: config.version,
229
+ name: "Rondevu",
230
+ description: "Topic-based peer discovery and signaling server"
231
+ });
232
+ });
233
+ app.get("/health", (c) => {
234
+ return c.json({
235
+ status: "ok",
236
+ timestamp: Date.now(),
237
+ version: config.version
238
+ });
239
+ });
240
+ app.post("/register", async (c) => {
42
241
  try {
43
- const origin = c.req.header("Origin") || c.req.header("origin") || "unknown";
44
- const page = parseInt(c.req.query("page") || "1", 10);
45
- const limit = parseInt(c.req.query("limit") || "100", 10);
46
- const result = await storage.listTopics(origin, page, limit);
47
- return c.json(result);
242
+ const peerId = generatePeerId();
243
+ const secret = await encryptPeerId(peerId, config.authSecret);
244
+ return c.json({
245
+ peerId,
246
+ secret
247
+ }, 200);
48
248
  } catch (err) {
49
- console.error("Error listing topics:", err);
249
+ console.error("Error registering peer:", err);
50
250
  return c.json({ error: "Internal server error" }, 500);
51
251
  }
52
252
  });
53
- app.get("/:topic/sessions", async (c) => {
253
+ app.post("/offers", authMiddleware, async (c) => {
54
254
  try {
55
- const origin = c.req.header("Origin") || c.req.header("origin") || "unknown";
56
- const topic = c.req.param("topic");
57
- if (!topic) {
58
- return c.json({ error: "Missing required parameter: topic" }, 400);
255
+ const body = await c.req.json();
256
+ const { offers } = body;
257
+ if (!Array.isArray(offers) || offers.length === 0) {
258
+ return c.json({ error: "Missing or invalid required parameter: offers (must be non-empty array)" }, 400);
59
259
  }
60
- if (topic.length > 256) {
61
- return c.json({ error: "Topic string must be 256 characters or less" }, 400);
260
+ if (offers.length > config.maxOffersPerRequest) {
261
+ return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
62
262
  }
63
- const sessions = await storage.listSessionsByTopic(origin, topic);
263
+ const peerId = getAuthenticatedPeerId(c);
264
+ const offerRequests = [];
265
+ for (const offer of offers) {
266
+ if (!offer.sdp || typeof offer.sdp !== "string") {
267
+ return c.json({ error: "Each offer must have an sdp field" }, 400);
268
+ }
269
+ if (offer.sdp.length > 65536) {
270
+ return c.json({ error: "SDP must be 64KB or less" }, 400);
271
+ }
272
+ if (offer.secret !== void 0) {
273
+ if (typeof offer.secret !== "string") {
274
+ return c.json({ error: "Secret must be a string" }, 400);
275
+ }
276
+ if (offer.secret.length > 128) {
277
+ return c.json({ error: "Secret must be 128 characters or less" }, 400);
278
+ }
279
+ }
280
+ if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
281
+ return c.json({ error: "Each offer must have a non-empty topics array" }, 400);
282
+ }
283
+ if (offer.topics.length > config.maxTopicsPerOffer) {
284
+ return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
285
+ }
286
+ for (const topic of offer.topics) {
287
+ if (typeof topic !== "string" || topic.length === 0 || topic.length > 256) {
288
+ return c.json({ error: "Each topic must be a string between 1 and 256 characters" }, 400);
289
+ }
290
+ }
291
+ let ttl = offer.ttl || config.offerDefaultTtl;
292
+ if (ttl < config.offerMinTtl) {
293
+ ttl = config.offerMinTtl;
294
+ }
295
+ if (ttl > config.offerMaxTtl) {
296
+ ttl = config.offerMaxTtl;
297
+ }
298
+ offerRequests.push({
299
+ id: offer.id,
300
+ peerId,
301
+ sdp: offer.sdp,
302
+ topics: offer.topics,
303
+ expiresAt: Date.now() + ttl,
304
+ secret: offer.secret
305
+ });
306
+ }
307
+ const createdOffers = await storage.createOffers(offerRequests);
64
308
  return c.json({
65
- sessions: sessions.map((s) => ({
66
- code: s.code,
67
- info: s.info,
68
- offer: s.offer,
69
- offerCandidates: s.offerCandidates,
70
- createdAt: s.createdAt,
71
- expiresAt: s.expiresAt
309
+ offers: createdOffers.map((o) => ({
310
+ id: o.id,
311
+ peerId: o.peerId,
312
+ topics: o.topics,
313
+ expiresAt: o.expiresAt
72
314
  }))
73
- });
315
+ }, 200);
74
316
  } catch (err) {
75
- console.error("Error listing sessions:", err);
317
+ console.error("Error creating offers:", err);
76
318
  return c.json({ error: "Internal server error" }, 500);
77
319
  }
78
320
  });
79
- app.post("/:topic/offer", async (c) => {
321
+ app.get("/offers/by-topic/:topic", async (c) => {
80
322
  try {
81
- const origin = c.req.header("Origin") || c.req.header("origin") || "unknown";
82
323
  const topic = c.req.param("topic");
83
- const body = await c.req.json();
84
- const { info, offer } = body;
85
- if (!topic || typeof topic !== "string") {
86
- return c.json({ error: "Missing or invalid required parameter: topic" }, 400);
87
- }
88
- if (topic.length > 256) {
89
- return c.json({ error: "Topic string must be 256 characters or less" }, 400);
90
- }
91
- if (!info || typeof info !== "string") {
92
- return c.json({ error: "Missing or invalid required parameter: info" }, 400);
93
- }
94
- if (info.length > 1024) {
95
- return c.json({ error: "Info string must be 1024 characters or less" }, 400);
324
+ const bloomParam = c.req.query("bloom");
325
+ const limitParam = c.req.query("limit");
326
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
327
+ let excludePeerIds = [];
328
+ if (bloomParam) {
329
+ const bloom = parseBloomFilter(bloomParam);
330
+ if (!bloom) {
331
+ return c.json({ error: "Invalid bloom filter format" }, 400);
332
+ }
333
+ const allOffers = await storage.getOffersByTopic(topic);
334
+ const excludeSet = /* @__PURE__ */ new Set();
335
+ for (const offer of allOffers) {
336
+ if (bloom.test(offer.peerId)) {
337
+ excludeSet.add(offer.peerId);
338
+ }
339
+ }
340
+ excludePeerIds = Array.from(excludeSet);
96
341
  }
97
- if (!offer || typeof offer !== "string") {
98
- return c.json({ error: "Missing or invalid required parameter: offer" }, 400);
342
+ let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : void 0);
343
+ const total = offers.length;
344
+ offers = offers.slice(0, limit);
345
+ return c.json({
346
+ topic,
347
+ offers: offers.map((o) => ({
348
+ id: o.id,
349
+ peerId: o.peerId,
350
+ sdp: o.sdp,
351
+ topics: o.topics,
352
+ expiresAt: o.expiresAt,
353
+ lastSeen: o.lastSeen,
354
+ hasSecret: !!o.secret
355
+ // Indicate if secret is required without exposing it
356
+ })),
357
+ total: bloomParam ? total + excludePeerIds.length : total,
358
+ returned: offers.length
359
+ }, 200);
360
+ } catch (err) {
361
+ console.error("Error fetching offers by topic:", err);
362
+ return c.json({ error: "Internal server error" }, 500);
363
+ }
364
+ });
365
+ app.get("/topics", async (c) => {
366
+ try {
367
+ const limitParam = c.req.query("limit");
368
+ const offsetParam = c.req.query("offset");
369
+ const startsWithParam = c.req.query("startsWith");
370
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
371
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
372
+ const startsWith = startsWithParam || void 0;
373
+ const result = await storage.getTopics(limit, offset, startsWith);
374
+ return c.json({
375
+ topics: result.topics,
376
+ total: result.total,
377
+ limit,
378
+ offset,
379
+ ...startsWith && { startsWith }
380
+ }, 200);
381
+ } catch (err) {
382
+ console.error("Error fetching topics:", err);
383
+ return c.json({ error: "Internal server error" }, 500);
384
+ }
385
+ });
386
+ app.get("/peers/:peerId/offers", async (c) => {
387
+ try {
388
+ const peerId = c.req.param("peerId");
389
+ const offers = await storage.getOffersByPeerId(peerId);
390
+ const topicsSet = /* @__PURE__ */ new Set();
391
+ offers.forEach((o) => o.topics.forEach((t) => topicsSet.add(t)));
392
+ return c.json({
393
+ peerId,
394
+ offers: offers.map((o) => ({
395
+ id: o.id,
396
+ sdp: o.sdp,
397
+ topics: o.topics,
398
+ expiresAt: o.expiresAt,
399
+ lastSeen: o.lastSeen,
400
+ hasSecret: !!o.secret
401
+ // Indicate if secret is required without exposing it
402
+ })),
403
+ topics: Array.from(topicsSet)
404
+ }, 200);
405
+ } catch (err) {
406
+ console.error("Error fetching peer offers:", err);
407
+ return c.json({ error: "Internal server error" }, 500);
408
+ }
409
+ });
410
+ app.get("/offers/mine", authMiddleware, async (c) => {
411
+ try {
412
+ const peerId = getAuthenticatedPeerId(c);
413
+ const offers = await storage.getOffersByPeerId(peerId);
414
+ return c.json({
415
+ peerId,
416
+ offers: offers.map((o) => ({
417
+ id: o.id,
418
+ sdp: o.sdp,
419
+ topics: o.topics,
420
+ createdAt: o.createdAt,
421
+ expiresAt: o.expiresAt,
422
+ lastSeen: o.lastSeen,
423
+ secret: o.secret,
424
+ // Owner can see the secret
425
+ answererPeerId: o.answererPeerId,
426
+ answeredAt: o.answeredAt
427
+ }))
428
+ }, 200);
429
+ } catch (err) {
430
+ console.error("Error fetching own offers:", err);
431
+ return c.json({ error: "Internal server error" }, 500);
432
+ }
433
+ });
434
+ app.delete("/offers/:offerId", authMiddleware, async (c) => {
435
+ try {
436
+ const offerId = c.req.param("offerId");
437
+ const peerId = getAuthenticatedPeerId(c);
438
+ const deleted = await storage.deleteOffer(offerId, peerId);
439
+ if (!deleted) {
440
+ return c.json({ error: "Offer not found or not authorized" }, 404);
99
441
  }
100
- const expiresAt = Date.now() + config.sessionTimeout;
101
- const code = await storage.createSession(origin, topic, info, offer, expiresAt);
102
- return c.json({ code }, 200);
442
+ return c.json({ deleted: true }, 200);
103
443
  } catch (err) {
104
- console.error("Error creating offer:", err);
444
+ console.error("Error deleting offer:", err);
105
445
  return c.json({ error: "Internal server error" }, 500);
106
446
  }
107
447
  });
108
- app.post("/answer", async (c) => {
448
+ app.post("/offers/:offerId/answer", authMiddleware, async (c) => {
109
449
  try {
110
- const origin = c.req.header("Origin") || c.req.header("origin") || "unknown";
450
+ const offerId = c.req.param("offerId");
451
+ const peerId = getAuthenticatedPeerId(c);
111
452
  const body = await c.req.json();
112
- const { code, answer, candidate, side } = body;
113
- if (!code || typeof code !== "string") {
114
- return c.json({ error: "Missing or invalid required parameter: code" }, 400);
115
- }
116
- if (!side || side !== "offerer" && side !== "answerer") {
117
- return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
453
+ const { sdp, secret } = body;
454
+ if (!sdp || typeof sdp !== "string") {
455
+ return c.json({ error: "Missing or invalid required parameter: sdp" }, 400);
118
456
  }
119
- if (!answer && !candidate) {
120
- return c.json({ error: "Missing required parameter: answer or candidate" }, 400);
457
+ if (sdp.length > 65536) {
458
+ return c.json({ error: "SDP must be 64KB or less" }, 400);
121
459
  }
122
- if (answer && candidate) {
123
- return c.json({ error: "Cannot provide both answer and candidate" }, 400);
460
+ if (secret !== void 0 && typeof secret !== "string") {
461
+ return c.json({ error: "Secret must be a string" }, 400);
124
462
  }
125
- const session = await storage.getSession(code, origin);
126
- if (!session) {
127
- return c.json({ error: "Session not found, expired, or origin mismatch" }, 404);
463
+ const result = await storage.answerOffer(offerId, peerId, sdp, secret);
464
+ if (!result.success) {
465
+ return c.json({ error: result.error }, 400);
128
466
  }
129
- if (answer) {
130
- await storage.updateSession(code, origin, { answer });
131
- }
132
- if (candidate) {
133
- if (side === "offerer") {
134
- const updatedCandidates = [...session.offerCandidates, candidate];
135
- await storage.updateSession(code, origin, { offerCandidates: updatedCandidates });
136
- } else {
137
- const updatedCandidates = [...session.answerCandidates, candidate];
138
- await storage.updateSession(code, origin, { answerCandidates: updatedCandidates });
139
- }
140
- }
141
- return c.json({ success: true }, 200);
467
+ return c.json({
468
+ offerId,
469
+ answererId: peerId,
470
+ answeredAt: Date.now()
471
+ }, 200);
472
+ } catch (err) {
473
+ console.error("Error answering offer:", err);
474
+ return c.json({ error: "Internal server error" }, 500);
475
+ }
476
+ });
477
+ app.get("/offers/answers", authMiddleware, async (c) => {
478
+ try {
479
+ const peerId = getAuthenticatedPeerId(c);
480
+ const offers = await storage.getAnsweredOffers(peerId);
481
+ return c.json({
482
+ answers: offers.map((o) => ({
483
+ offerId: o.id,
484
+ answererId: o.answererPeerId,
485
+ sdp: o.answerSdp,
486
+ answeredAt: o.answeredAt,
487
+ topics: o.topics
488
+ }))
489
+ }, 200);
142
490
  } catch (err) {
143
- console.error("Error handling answer:", err);
491
+ console.error("Error fetching answers:", err);
144
492
  return c.json({ error: "Internal server error" }, 500);
145
493
  }
146
494
  });
147
- app.post("/poll", async (c) => {
495
+ app.post("/offers/:offerId/ice-candidates", authMiddleware, async (c) => {
148
496
  try {
149
- const origin = c.req.header("Origin") || c.req.header("origin") || "unknown";
497
+ const offerId = c.req.param("offerId");
498
+ const peerId = getAuthenticatedPeerId(c);
150
499
  const body = await c.req.json();
151
- const { code, side } = body;
152
- if (!code || typeof code !== "string") {
153
- return c.json({ error: "Missing or invalid required parameter: code" }, 400);
154
- }
155
- if (!side || side !== "offerer" && side !== "answerer") {
156
- return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
500
+ const { candidates } = body;
501
+ if (!Array.isArray(candidates) || candidates.length === 0) {
502
+ return c.json({ error: "Missing or invalid required parameter: candidates (must be non-empty array)" }, 400);
157
503
  }
158
- const session = await storage.getSession(code, origin);
159
- if (!session) {
160
- return c.json({ error: "Session not found, expired, or origin mismatch" }, 404);
504
+ const offer = await storage.getOfferById(offerId);
505
+ if (!offer) {
506
+ return c.json({ error: "Offer not found or expired" }, 404);
161
507
  }
162
- if (side === "offerer") {
163
- return c.json({
164
- answer: session.answer || null,
165
- answerCandidates: session.answerCandidates
166
- });
508
+ let role;
509
+ if (offer.peerId === peerId) {
510
+ role = "offerer";
511
+ } else if (offer.answererPeerId === peerId) {
512
+ role = "answerer";
167
513
  } else {
168
- return c.json({
169
- offer: session.offer,
170
- offerCandidates: session.offerCandidates
171
- });
514
+ return c.json({ error: "Not authorized to post ICE candidates for this offer" }, 403);
172
515
  }
516
+ const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
517
+ return c.json({
518
+ offerId,
519
+ candidatesAdded: added
520
+ }, 200);
173
521
  } catch (err) {
174
- console.error("Error polling session:", err);
522
+ console.error("Error adding ICE candidates:", err);
175
523
  return c.json({ error: "Internal server error" }, 500);
176
524
  }
177
525
  });
178
- app.get("/health", (c) => {
179
- return c.json({ status: "ok", timestamp: Date.now() });
526
+ app.get("/offers/:offerId/ice-candidates", authMiddleware, async (c) => {
527
+ try {
528
+ const offerId = c.req.param("offerId");
529
+ const peerId = getAuthenticatedPeerId(c);
530
+ const sinceParam = c.req.query("since");
531
+ const since = sinceParam ? parseInt(sinceParam, 10) : void 0;
532
+ const offer = await storage.getOfferById(offerId);
533
+ if (!offer) {
534
+ return c.json({ error: "Offer not found or expired" }, 404);
535
+ }
536
+ let targetRole;
537
+ if (offer.peerId === peerId) {
538
+ targetRole = "answerer";
539
+ console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
540
+ } else if (offer.answererPeerId === peerId) {
541
+ targetRole = "offerer";
542
+ console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
543
+ } else {
544
+ return c.json({ error: "Not authorized to view ICE candidates for this offer" }, 403);
545
+ }
546
+ const candidates = await storage.getIceCandidates(offerId, targetRole, since);
547
+ console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
548
+ return c.json({
549
+ offerId,
550
+ candidates: candidates.map((c2) => ({
551
+ candidate: c2.candidate,
552
+ peerId: c2.peerId,
553
+ role: c2.role,
554
+ createdAt: c2.createdAt
555
+ }))
556
+ }, 200);
557
+ } catch (err) {
558
+ console.error("Error fetching ICE candidates:", err);
559
+ return c.json({ error: "Internal server error" }, 500);
560
+ }
180
561
  });
181
562
  return app;
182
563
  }
183
564
 
184
565
  // src/config.ts
185
566
  function loadConfig() {
567
+ let authSecret = process.env.AUTH_SECRET;
568
+ if (!authSecret) {
569
+ authSecret = generateSecretKey();
570
+ console.warn("WARNING: No AUTH_SECRET provided. Generated temporary secret:", authSecret);
571
+ console.warn("All peer credentials will be invalidated on server restart.");
572
+ console.warn("Set AUTH_SECRET environment variable to persist credentials across restarts.");
573
+ }
186
574
  return {
187
575
  port: parseInt(process.env.PORT || "3000", 10),
188
576
  storageType: process.env.STORAGE_TYPE || "sqlite",
189
577
  storagePath: process.env.STORAGE_PATH || ":memory:",
190
- sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || "300000", 10),
191
- corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) : ["*"]
578
+ corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) : ["*"],
579
+ version: process.env.VERSION || "unknown",
580
+ authSecret,
581
+ offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || "60000", 10),
582
+ offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || "86400000", 10),
583
+ offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || "60000", 10),
584
+ cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || "60000", 10),
585
+ maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || "100", 10),
586
+ maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || "50", 10)
192
587
  };
193
588
  }
194
589
 
195
590
  // src/storage/sqlite.ts
196
591
  var import_better_sqlite3 = __toESM(require("better-sqlite3"));
197
- var import_crypto = require("crypto");
592
+
593
+ // src/storage/hash-id.ts
594
+ async function generateOfferHash(sdp, topics) {
595
+ const sanitizedOffer = {
596
+ sdp,
597
+ topics: [...topics].sort()
598
+ // Sort topics for consistency
599
+ };
600
+ const jsonString = JSON.stringify(sanitizedOffer);
601
+ const encoder = new TextEncoder();
602
+ const data = encoder.encode(jsonString);
603
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
604
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
605
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
606
+ return hashHex;
607
+ }
608
+
609
+ // src/storage/sqlite.ts
198
610
  var SQLiteStorage = class {
199
611
  /**
200
612
  * Creates a new SQLite storage instance
@@ -203,193 +615,301 @@ var SQLiteStorage = class {
203
615
  constructor(path = ":memory:") {
204
616
  this.db = new import_better_sqlite3.default(path);
205
617
  this.initializeDatabase();
206
- this.startCleanupInterval();
207
618
  }
208
619
  /**
209
- * Initializes database schema
620
+ * Initializes database schema with new topic-based structure
210
621
  */
211
622
  initializeDatabase() {
212
623
  this.db.exec(`
213
- CREATE TABLE IF NOT EXISTS sessions (
214
- code TEXT PRIMARY KEY,
215
- origin TEXT NOT NULL,
624
+ CREATE TABLE IF NOT EXISTS offers (
625
+ id TEXT PRIMARY KEY,
626
+ peer_id TEXT NOT NULL,
627
+ sdp TEXT NOT NULL,
628
+ created_at INTEGER NOT NULL,
629
+ expires_at INTEGER NOT NULL,
630
+ last_seen INTEGER NOT NULL,
631
+ secret TEXT,
632
+ answerer_peer_id TEXT,
633
+ answer_sdp TEXT,
634
+ answered_at INTEGER
635
+ );
636
+
637
+ CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
638
+ CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
639
+ CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
640
+ CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
641
+
642
+ CREATE TABLE IF NOT EXISTS offer_topics (
643
+ offer_id TEXT NOT NULL,
216
644
  topic TEXT NOT NULL,
217
- info TEXT NOT NULL CHECK(length(info) <= 1024),
218
- offer TEXT NOT NULL,
219
- answer TEXT,
220
- offer_candidates TEXT NOT NULL DEFAULT '[]',
221
- answer_candidates TEXT NOT NULL DEFAULT '[]',
645
+ PRIMARY KEY (offer_id, topic),
646
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
647
+ );
648
+
649
+ CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
650
+ CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
651
+
652
+ CREATE TABLE IF NOT EXISTS ice_candidates (
653
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
654
+ offer_id TEXT NOT NULL,
655
+ peer_id TEXT NOT NULL,
656
+ role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
657
+ candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
222
658
  created_at INTEGER NOT NULL,
223
- expires_at INTEGER NOT NULL
659
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
224
660
  );
225
661
 
226
- CREATE INDEX IF NOT EXISTS idx_expires_at ON sessions(expires_at);
227
- CREATE INDEX IF NOT EXISTS idx_origin_topic ON sessions(origin, topic);
228
- CREATE INDEX IF NOT EXISTS idx_origin_topic_expires ON sessions(origin, topic, expires_at);
662
+ CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
663
+ CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
664
+ CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
229
665
  `);
666
+ this.db.pragma("foreign_keys = ON");
230
667
  }
231
- /**
232
- * Starts periodic cleanup of expired sessions
233
- */
234
- startCleanupInterval() {
235
- setInterval(() => {
236
- this.cleanup().catch((err) => {
237
- console.error("Cleanup error:", err);
238
- });
239
- }, 6e4);
240
- }
241
- /**
242
- * Generates a unique code using UUID
243
- */
244
- generateCode() {
245
- return (0, import_crypto.randomUUID)();
246
- }
247
- async createSession(origin, topic, info, offer, expiresAt) {
248
- if (info.length > 1024) {
249
- throw new Error("Info string must be 1024 characters or less");
250
- }
251
- let code;
252
- let attempts = 0;
253
- const maxAttempts = 10;
254
- do {
255
- code = this.generateCode();
256
- attempts++;
257
- if (attempts > maxAttempts) {
258
- throw new Error("Failed to generate unique session code");
259
- }
260
- try {
261
- const stmt = this.db.prepare(`
262
- INSERT INTO sessions (code, origin, topic, info, offer, created_at, expires_at)
263
- VALUES (?, ?, ?, ?, ?, ?, ?)
264
- `);
265
- stmt.run(code, origin, topic, info, offer, Date.now(), expiresAt);
266
- break;
267
- } catch (err) {
268
- if (err.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
269
- continue;
668
+ async createOffers(offers) {
669
+ const created = [];
670
+ const offersWithIds = await Promise.all(
671
+ offers.map(async (offer) => ({
672
+ ...offer,
673
+ id: offer.id || await generateOfferHash(offer.sdp, offer.topics)
674
+ }))
675
+ );
676
+ const transaction = this.db.transaction((offersWithIds2) => {
677
+ const offerStmt = this.db.prepare(`
678
+ INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
679
+ VALUES (?, ?, ?, ?, ?, ?, ?)
680
+ `);
681
+ const topicStmt = this.db.prepare(`
682
+ INSERT INTO offer_topics (offer_id, topic)
683
+ VALUES (?, ?)
684
+ `);
685
+ for (const offer of offersWithIds2) {
686
+ const now = Date.now();
687
+ offerStmt.run(
688
+ offer.id,
689
+ offer.peerId,
690
+ offer.sdp,
691
+ now,
692
+ offer.expiresAt,
693
+ now,
694
+ offer.secret || null
695
+ );
696
+ for (const topic of offer.topics) {
697
+ topicStmt.run(offer.id, topic);
270
698
  }
271
- throw err;
699
+ created.push({
700
+ id: offer.id,
701
+ peerId: offer.peerId,
702
+ sdp: offer.sdp,
703
+ topics: offer.topics,
704
+ createdAt: now,
705
+ expiresAt: offer.expiresAt,
706
+ lastSeen: now,
707
+ secret: offer.secret
708
+ });
272
709
  }
273
- } while (true);
274
- return code;
710
+ });
711
+ transaction(offersWithIds);
712
+ return created;
275
713
  }
276
- async listSessionsByTopic(origin, topic) {
277
- const stmt = this.db.prepare(`
278
- SELECT * FROM sessions
279
- WHERE origin = ? AND topic = ? AND expires_at > ? AND answer IS NULL
280
- ORDER BY created_at DESC
281
- `);
282
- const rows = stmt.all(origin, topic, Date.now());
283
- return rows.map((row) => ({
284
- code: row.code,
285
- origin: row.origin,
286
- topic: row.topic,
287
- info: row.info,
288
- offer: row.offer,
289
- answer: row.answer || void 0,
290
- offerCandidates: JSON.parse(row.offer_candidates),
291
- answerCandidates: JSON.parse(row.answer_candidates),
292
- createdAt: row.created_at,
293
- expiresAt: row.expires_at
294
- }));
714
+ async getOffersByTopic(topic, excludePeerIds) {
715
+ let query = `
716
+ SELECT DISTINCT o.*
717
+ FROM offers o
718
+ INNER JOIN offer_topics ot ON o.id = ot.offer_id
719
+ WHERE ot.topic = ? AND o.expires_at > ?
720
+ `;
721
+ const params = [topic, Date.now()];
722
+ if (excludePeerIds && excludePeerIds.length > 0) {
723
+ const placeholders = excludePeerIds.map(() => "?").join(",");
724
+ query += ` AND o.peer_id NOT IN (${placeholders})`;
725
+ params.push(...excludePeerIds);
726
+ }
727
+ query += " ORDER BY o.last_seen DESC";
728
+ const stmt = this.db.prepare(query);
729
+ const rows = stmt.all(...params);
730
+ return Promise.all(rows.map((row) => this.rowToOffer(row)));
295
731
  }
296
- async listTopics(origin, page, limit) {
297
- const safeLimit = Math.min(Math.max(1, limit), 1e3);
298
- const safePage = Math.max(1, page);
299
- const offset = (safePage - 1) * safeLimit;
300
- const countStmt = this.db.prepare(`
301
- SELECT COUNT(DISTINCT topic) as total
302
- FROM sessions
303
- WHERE origin = ? AND expires_at > ? AND answer IS NULL
304
- `);
305
- const { total } = countStmt.get(origin, Date.now());
732
+ async getOffersByPeerId(peerId) {
306
733
  const stmt = this.db.prepare(`
307
- SELECT topic, COUNT(*) as count
308
- FROM sessions
309
- WHERE origin = ? AND expires_at > ? AND answer IS NULL
310
- GROUP BY topic
311
- ORDER BY topic ASC
312
- LIMIT ? OFFSET ?
734
+ SELECT * FROM offers
735
+ WHERE peer_id = ? AND expires_at > ?
736
+ ORDER BY last_seen DESC
313
737
  `);
314
- const rows = stmt.all(origin, Date.now(), safeLimit, offset);
315
- const topics = rows.map((row) => ({
316
- topic: row.topic,
317
- count: row.count
318
- }));
319
- return {
320
- topics,
321
- pagination: {
322
- page: safePage,
323
- limit: safeLimit,
324
- total,
325
- hasMore: offset + topics.length < total
326
- }
327
- };
738
+ const rows = stmt.all(peerId, Date.now());
739
+ return Promise.all(rows.map((row) => this.rowToOffer(row)));
328
740
  }
329
- async getSession(code, origin) {
741
+ async getOfferById(offerId) {
330
742
  const stmt = this.db.prepare(`
331
- SELECT * FROM sessions WHERE code = ? AND origin = ? AND expires_at > ?
743
+ SELECT * FROM offers
744
+ WHERE id = ? AND expires_at > ?
332
745
  `);
333
- const row = stmt.get(code, origin, Date.now());
746
+ const row = stmt.get(offerId, Date.now());
334
747
  if (!row) {
335
748
  return null;
336
749
  }
337
- return {
338
- code: row.code,
339
- origin: row.origin,
340
- topic: row.topic,
341
- info: row.info,
342
- offer: row.offer,
343
- answer: row.answer || void 0,
344
- offerCandidates: JSON.parse(row.offer_candidates),
345
- answerCandidates: JSON.parse(row.answer_candidates),
346
- createdAt: row.created_at,
347
- expiresAt: row.expires_at
348
- };
750
+ return this.rowToOffer(row);
349
751
  }
350
- async updateSession(code, origin, update) {
351
- const current = await this.getSession(code, origin);
352
- if (!current) {
353
- throw new Error("Session not found or origin mismatch");
354
- }
355
- const updates = [];
356
- const values = [];
357
- if (update.answer !== void 0) {
358
- updates.push("answer = ?");
359
- values.push(update.answer);
752
+ async deleteOffer(offerId, ownerPeerId) {
753
+ const stmt = this.db.prepare(`
754
+ DELETE FROM offers
755
+ WHERE id = ? AND peer_id = ?
756
+ `);
757
+ const result = stmt.run(offerId, ownerPeerId);
758
+ return result.changes > 0;
759
+ }
760
+ async deleteExpiredOffers(now) {
761
+ const stmt = this.db.prepare("DELETE FROM offers WHERE expires_at < ?");
762
+ const result = stmt.run(now);
763
+ return result.changes;
764
+ }
765
+ async answerOffer(offerId, answererPeerId, answerSdp, secret) {
766
+ const offer = await this.getOfferById(offerId);
767
+ if (!offer) {
768
+ return {
769
+ success: false,
770
+ error: "Offer not found or expired"
771
+ };
360
772
  }
361
- if (update.offerCandidates !== void 0) {
362
- updates.push("offer_candidates = ?");
363
- values.push(JSON.stringify(update.offerCandidates));
773
+ if (offer.secret && offer.secret !== secret) {
774
+ return {
775
+ success: false,
776
+ error: "Invalid or missing secret"
777
+ };
364
778
  }
365
- if (update.answerCandidates !== void 0) {
366
- updates.push("answer_candidates = ?");
367
- values.push(JSON.stringify(update.answerCandidates));
779
+ if (offer.answererPeerId) {
780
+ return {
781
+ success: false,
782
+ error: "Offer already answered"
783
+ };
368
784
  }
369
- if (updates.length === 0) {
370
- return;
785
+ const stmt = this.db.prepare(`
786
+ UPDATE offers
787
+ SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
788
+ WHERE id = ? AND answerer_peer_id IS NULL
789
+ `);
790
+ const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId);
791
+ if (result.changes === 0) {
792
+ return {
793
+ success: false,
794
+ error: "Offer already answered (race condition)"
795
+ };
371
796
  }
372
- values.push(code);
373
- values.push(origin);
797
+ return { success: true };
798
+ }
799
+ async getAnsweredOffers(offererPeerId) {
374
800
  const stmt = this.db.prepare(`
375
- UPDATE sessions SET ${updates.join(", ")} WHERE code = ? AND origin = ?
801
+ SELECT * FROM offers
802
+ WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
803
+ ORDER BY answered_at DESC
376
804
  `);
377
- stmt.run(...values);
805
+ const rows = stmt.all(offererPeerId, Date.now());
806
+ return Promise.all(rows.map((row) => this.rowToOffer(row)));
378
807
  }
379
- async deleteSession(code) {
380
- const stmt = this.db.prepare("DELETE FROM sessions WHERE code = ?");
381
- stmt.run(code);
808
+ async addIceCandidates(offerId, peerId, role, candidates) {
809
+ const stmt = this.db.prepare(`
810
+ INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
811
+ VALUES (?, ?, ?, ?, ?)
812
+ `);
813
+ const baseTimestamp = Date.now();
814
+ const transaction = this.db.transaction((candidates2) => {
815
+ for (let i = 0; i < candidates2.length; i++) {
816
+ stmt.run(
817
+ offerId,
818
+ peerId,
819
+ role,
820
+ JSON.stringify(candidates2[i]),
821
+ // Store full object as JSON
822
+ baseTimestamp + i
823
+ // Ensure unique timestamps to avoid "since" filtering issues
824
+ );
825
+ }
826
+ });
827
+ transaction(candidates);
828
+ return candidates.length;
382
829
  }
383
- async cleanup() {
384
- const stmt = this.db.prepare("DELETE FROM sessions WHERE expires_at <= ?");
385
- const result = stmt.run(Date.now());
386
- if (result.changes > 0) {
387
- console.log(`Cleaned up ${result.changes} expired session(s)`);
830
+ async getIceCandidates(offerId, targetRole, since) {
831
+ let query = `
832
+ SELECT * FROM ice_candidates
833
+ WHERE offer_id = ? AND role = ?
834
+ `;
835
+ const params = [offerId, targetRole];
836
+ if (since !== void 0) {
837
+ query += " AND created_at > ?";
838
+ params.push(since);
388
839
  }
840
+ query += " ORDER BY created_at ASC";
841
+ const stmt = this.db.prepare(query);
842
+ const rows = stmt.all(...params);
843
+ return rows.map((row) => ({
844
+ id: row.id,
845
+ offerId: row.offer_id,
846
+ peerId: row.peer_id,
847
+ role: row.role,
848
+ candidate: JSON.parse(row.candidate),
849
+ // Parse JSON back to object
850
+ createdAt: row.created_at
851
+ }));
852
+ }
853
+ async getTopics(limit, offset, startsWith) {
854
+ const now = Date.now();
855
+ const whereClause = startsWith ? "o.expires_at > ? AND ot.topic LIKE ?" : "o.expires_at > ?";
856
+ const startsWithPattern = startsWith ? `${startsWith}%` : null;
857
+ const countQuery = `
858
+ SELECT COUNT(DISTINCT ot.topic) as count
859
+ FROM offer_topics ot
860
+ INNER JOIN offers o ON ot.offer_id = o.id
861
+ WHERE ${whereClause}
862
+ `;
863
+ const countStmt = this.db.prepare(countQuery);
864
+ const countParams = startsWith ? [now, startsWithPattern] : [now];
865
+ const countRow = countStmt.get(...countParams);
866
+ const total = countRow.count;
867
+ const topicsQuery = `
868
+ SELECT
869
+ ot.topic,
870
+ COUNT(DISTINCT o.peer_id) as active_peers
871
+ FROM offer_topics ot
872
+ INNER JOIN offers o ON ot.offer_id = o.id
873
+ WHERE ${whereClause}
874
+ GROUP BY ot.topic
875
+ ORDER BY active_peers DESC, ot.topic ASC
876
+ LIMIT ? OFFSET ?
877
+ `;
878
+ const topicsStmt = this.db.prepare(topicsQuery);
879
+ const topicsParams = startsWith ? [now, startsWithPattern, limit, offset] : [now, limit, offset];
880
+ const rows = topicsStmt.all(...topicsParams);
881
+ const topics = rows.map((row) => ({
882
+ topic: row.topic,
883
+ activePeers: row.active_peers
884
+ }));
885
+ return { topics, total };
389
886
  }
390
887
  async close() {
391
888
  this.db.close();
392
889
  }
890
+ /**
891
+ * Helper method to convert database row to Offer object with topics
892
+ */
893
+ async rowToOffer(row) {
894
+ const topicStmt = this.db.prepare(`
895
+ SELECT topic FROM offer_topics WHERE offer_id = ?
896
+ `);
897
+ const topicRows = topicStmt.all(row.id);
898
+ const topics = topicRows.map((t) => t.topic);
899
+ return {
900
+ id: row.id,
901
+ peerId: row.peer_id,
902
+ sdp: row.sdp,
903
+ topics,
904
+ createdAt: row.created_at,
905
+ expiresAt: row.expires_at,
906
+ lastSeen: row.last_seen,
907
+ secret: row.secret || void 0,
908
+ answererPeerId: row.answerer_peer_id || void 0,
909
+ answerSdp: row.answer_sdp || void 0,
910
+ answeredAt: row.answered_at || void 0
911
+ };
912
+ }
393
913
  };
394
914
 
395
915
  // src/index.ts
@@ -400,8 +920,14 @@ async function main() {
400
920
  port: config.port,
401
921
  storageType: config.storageType,
402
922
  storagePath: config.storagePath,
403
- sessionTimeout: `${config.sessionTimeout}ms`,
404
- corsOrigins: config.corsOrigins
923
+ offerDefaultTtl: `${config.offerDefaultTtl}ms`,
924
+ offerMaxTtl: `${config.offerMaxTtl}ms`,
925
+ offerMinTtl: `${config.offerMinTtl}ms`,
926
+ cleanupInterval: `${config.cleanupInterval}ms`,
927
+ maxOffersPerRequest: config.maxOffersPerRequest,
928
+ maxTopicsPerOffer: config.maxTopicsPerOffer,
929
+ corsOrigins: config.corsOrigins,
930
+ version: config.version
405
931
  });
406
932
  let storage;
407
933
  if (config.storageType === "sqlite") {
@@ -410,25 +936,32 @@ async function main() {
410
936
  } else {
411
937
  throw new Error("Unsupported storage type");
412
938
  }
413
- const app = createApp(storage, {
414
- sessionTimeout: config.sessionTimeout,
415
- corsOrigins: config.corsOrigins
416
- });
939
+ const cleanupInterval = setInterval(async () => {
940
+ try {
941
+ const now = Date.now();
942
+ const deleted = await storage.deleteExpiredOffers(now);
943
+ if (deleted > 0) {
944
+ console.log(`Cleanup: Deleted ${deleted} expired offer(s)`);
945
+ }
946
+ } catch (err) {
947
+ console.error("Cleanup error:", err);
948
+ }
949
+ }, config.cleanupInterval);
950
+ const app = createApp(storage, config);
417
951
  const server = (0, import_node_server.serve)({
418
952
  fetch: app.fetch,
419
953
  port: config.port
420
954
  });
421
955
  console.log(`Server running on http://localhost:${config.port}`);
422
- process.on("SIGINT", async () => {
956
+ console.log("Ready to accept connections");
957
+ const shutdown = async () => {
423
958
  console.log("\nShutting down gracefully...");
959
+ clearInterval(cleanupInterval);
424
960
  await storage.close();
425
961
  process.exit(0);
426
- });
427
- process.on("SIGTERM", async () => {
428
- console.log("\nShutting down gracefully...");
429
- await storage.close();
430
- process.exit(0);
431
- });
962
+ };
963
+ process.on("SIGINT", shutdown);
964
+ process.on("SIGTERM", shutdown);
432
965
  }
433
966
  main().catch((err) => {
434
967
  console.error("Fatal error:", err);