@roamcode.ai/server 1.0.23 → 1.1.0

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.
@@ -0,0 +1,1642 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/relay-start.ts
4
+ import { readFileSync as readFileSync2, realpathSync } from "fs";
5
+ import { join as join2, resolve } from "path";
6
+ import { fileURLToPath, pathToFileURL } from "url";
7
+
8
+ // src/relay-broker.ts
9
+ import { randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
10
+ import Fastify from "fastify";
11
+ import websocket from "@fastify/websocket";
12
+
13
+ // src/relay-account-store.ts
14
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
15
+ import { createRequire } from "module";
16
+ var require2 = createRequire(import.meta.url);
17
+ var RelayAccountRevisionConflictError = class extends Error {
18
+ constructor(current) {
19
+ super("relay account revision conflict");
20
+ this.current = current;
21
+ this.name = "RelayAccountRevisionConflictError";
22
+ }
23
+ current;
24
+ };
25
+ var PLAN_DEFAULTS = {
26
+ free: { maxRoutes: 3, maxDevicesPerRoute: 16 },
27
+ team: { maxRoutes: 25, maxDevicesPerRoute: 64 },
28
+ enterprise: { maxRoutes: 500, maxDevicesPerRoute: 500 }
29
+ };
30
+ function safeId(value) {
31
+ if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
32
+ throw new Error("invalid relay account id");
33
+ }
34
+ return value;
35
+ }
36
+ function safeLabel(value) {
37
+ if (typeof value !== "string") throw new Error("relay account label is required");
38
+ const label = value.trim().replace(/\s+/g, " ");
39
+ if (!label || label.length > 120 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(label)) {
40
+ throw new Error("invalid relay account label");
41
+ }
42
+ return label;
43
+ }
44
+ function safeCredential(value) {
45
+ if (typeof value !== "string" || !/^rrk_[A-Za-z0-9_-]{43}$/.test(value)) {
46
+ throw new Error("invalid relay account credential");
47
+ }
48
+ return value;
49
+ }
50
+ function digest(label, credential) {
51
+ return createHash("sha256").update(label).update("\0").update(safeCredential(credential)).digest("base64url");
52
+ }
53
+ function relayAccountCredentialHash(credential) {
54
+ return `sha256:${digest("roamcode-relay-account-credential-v1", credential)}`;
55
+ }
56
+ function relayAccountCredentialLookup(credential) {
57
+ return `lookup:${digest("roamcode-relay-account-lookup-v1", credential)}`;
58
+ }
59
+ function generateRelayAccountCredential() {
60
+ return `rrk_${randomBytes(32).toString("base64url")}`;
61
+ }
62
+ function safeHash(value) {
63
+ if (typeof value !== "string" || !/^sha256:[A-Za-z0-9_-]{43}$/.test(value)) {
64
+ throw new Error("invalid relay account credential hash");
65
+ }
66
+ return value;
67
+ }
68
+ function hashMatches(expected, credential) {
69
+ try {
70
+ const left = Buffer.from(safeHash(expected));
71
+ const right = Buffer.from(relayAccountCredentialHash(credential));
72
+ return left.length === right.length && timingSafeEqual(left, right);
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ function safePlan(value) {
78
+ if (value !== "free" && value !== "team" && value !== "enterprise") throw new Error("invalid relay account plan");
79
+ return value;
80
+ }
81
+ function boundedLimit(value, fallback, maximum, field) {
82
+ const candidate = value === void 0 ? fallback : value;
83
+ if (!Number.isSafeInteger(candidate) || candidate < 1 || candidate > maximum) {
84
+ throw new Error(`invalid relay account ${field}`);
85
+ }
86
+ return candidate;
87
+ }
88
+ function safeStatus(value) {
89
+ if (value !== "active" && value !== "suspended" && value !== "deleted") {
90
+ throw new Error("invalid relay account status");
91
+ }
92
+ return value;
93
+ }
94
+ function clone(record) {
95
+ return { ...record };
96
+ }
97
+ function createMemoryStore(options) {
98
+ const accounts = /* @__PURE__ */ new Map();
99
+ const lookup = /* @__PURE__ */ new Map();
100
+ const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
101
+ return {
102
+ mode: "memory",
103
+ createAccount(input, now = Date.now()) {
104
+ const id = safeId(generateAccountId());
105
+ if (accounts.has(id)) throw new Error("relay account already exists");
106
+ const plan = safePlan(input.plan ?? "free");
107
+ const credentialHash = relayAccountCredentialHash(input.credential);
108
+ const credentialLookup = relayAccountCredentialLookup(input.credential);
109
+ if (lookup.has(credentialLookup)) throw new Error("relay account credential already exists");
110
+ const defaults = PLAN_DEFAULTS[plan];
111
+ const record = {
112
+ id,
113
+ label: safeLabel(input.label),
114
+ status: "active",
115
+ plan,
116
+ maxRoutes: boundedLimit(input.maxRoutes, defaults.maxRoutes, 1e4, "route limit"),
117
+ maxDevicesPerRoute: boundedLimit(
118
+ input.maxDevicesPerRoute,
119
+ defaults.maxDevicesPerRoute,
120
+ 1e5,
121
+ "device limit"
122
+ ),
123
+ revision: 1,
124
+ createdAt: now,
125
+ updatedAt: now,
126
+ credentialHash,
127
+ credentialLookup
128
+ };
129
+ accounts.set(id, record);
130
+ lookup.set(credentialLookup, id);
131
+ return clone(record);
132
+ },
133
+ getAccount(id) {
134
+ const record = accounts.get(safeId(id));
135
+ return record ? clone(record) : void 0;
136
+ },
137
+ listAccounts({ includeDeleted = false } = {}) {
138
+ return [...accounts.values()].filter((record) => includeDeleted || record.status !== "deleted").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(clone);
139
+ },
140
+ authenticate(credential) {
141
+ let credentialLookup;
142
+ try {
143
+ credentialLookup = relayAccountCredentialLookup(credential);
144
+ } catch {
145
+ return void 0;
146
+ }
147
+ const id = lookup.get(credentialLookup);
148
+ const record = id ? accounts.get(id) : void 0;
149
+ return record?.status === "active" && hashMatches(record.credentialHash, credential) ? clone(record) : void 0;
150
+ },
151
+ updateAccount(id, input, expectedRevision, now = Date.now()) {
152
+ const current = accounts.get(safeId(id));
153
+ if (!current) return void 0;
154
+ if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone(current));
155
+ if (current.status === "deleted") throw new Error("deleted relay account is immutable");
156
+ const plan = input.plan === void 0 ? current.plan : safePlan(input.plan);
157
+ const status = input.status === void 0 ? current.status : safeStatus(input.status);
158
+ const defaults = PLAN_DEFAULTS[plan];
159
+ const next = {
160
+ ...current,
161
+ ...input.label === void 0 ? {} : { label: safeLabel(input.label) },
162
+ status,
163
+ plan,
164
+ maxRoutes: boundedLimit(
165
+ input.maxRoutes,
166
+ input.plan === void 0 ? current.maxRoutes : defaults.maxRoutes,
167
+ 1e4,
168
+ "route limit"
169
+ ),
170
+ maxDevicesPerRoute: boundedLimit(
171
+ input.maxDevicesPerRoute,
172
+ input.plan === void 0 ? current.maxDevicesPerRoute : defaults.maxDevicesPerRoute,
173
+ 1e5,
174
+ "device limit"
175
+ ),
176
+ revision: current.revision + 1,
177
+ updatedAt: now
178
+ };
179
+ accounts.set(current.id, next);
180
+ return clone(next);
181
+ },
182
+ rotateCredential(id, credential, expectedRevision, now = Date.now()) {
183
+ const current = accounts.get(safeId(id));
184
+ if (!current) return void 0;
185
+ if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone(current));
186
+ if (current.status === "deleted") throw new Error("deleted relay account is immutable");
187
+ const credentialHash = relayAccountCredentialHash(credential);
188
+ const credentialLookup = relayAccountCredentialLookup(credential);
189
+ const owner = lookup.get(credentialLookup);
190
+ if (owner && owner !== current.id) throw new Error("relay account credential already exists");
191
+ lookup.delete(current.credentialLookup);
192
+ lookup.set(credentialLookup, current.id);
193
+ const next = {
194
+ ...current,
195
+ credentialHash,
196
+ credentialLookup,
197
+ revision: current.revision + 1,
198
+ updatedAt: now
199
+ };
200
+ accounts.set(current.id, next);
201
+ return clone(next);
202
+ },
203
+ close() {
204
+ accounts.clear();
205
+ lookup.clear();
206
+ }
207
+ };
208
+ }
209
+ function fromRow(row) {
210
+ return {
211
+ id: row.id,
212
+ label: row.label,
213
+ status: row.status,
214
+ plan: row.plan,
215
+ maxRoutes: row.max_routes,
216
+ maxDevicesPerRoute: row.max_devices_per_route,
217
+ revision: row.revision,
218
+ createdAt: row.created_at,
219
+ updatedAt: row.updated_at
220
+ };
221
+ }
222
+ function openRelayAccountStore(options) {
223
+ let Database;
224
+ try {
225
+ if (options.loadDatabase) Database = options.loadDatabase();
226
+ else {
227
+ const mod = require2("better-sqlite3");
228
+ Database = mod.default ?? mod;
229
+ }
230
+ } catch {
231
+ return createMemoryStore(options);
232
+ }
233
+ const db = new Database(options.dbPath);
234
+ db.pragma("journal_mode = WAL");
235
+ db.pragma("busy_timeout = 5000");
236
+ db.exec(`
237
+ CREATE TABLE IF NOT EXISTS relay_accounts (
238
+ id TEXT PRIMARY KEY,
239
+ label TEXT NOT NULL,
240
+ status TEXT NOT NULL CHECK(status IN ('active', 'suspended', 'deleted')),
241
+ plan TEXT NOT NULL CHECK(plan IN ('free', 'team', 'enterprise')),
242
+ max_routes INTEGER NOT NULL,
243
+ max_devices_per_route INTEGER NOT NULL,
244
+ revision INTEGER NOT NULL,
245
+ credential_hash TEXT NOT NULL,
246
+ credential_lookup TEXT NOT NULL UNIQUE,
247
+ created_at INTEGER NOT NULL,
248
+ updated_at INTEGER NOT NULL
249
+ );
250
+ CREATE INDEX IF NOT EXISTS relay_accounts_status_idx ON relay_accounts(status);
251
+ `);
252
+ const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
253
+ const rowById = (id) => db.prepare("SELECT * FROM relay_accounts WHERE id = ?").get(id);
254
+ const createAccount = db.transaction((input, now) => {
255
+ const id = safeId(generateAccountId());
256
+ if (rowById(id)) throw new Error("relay account already exists");
257
+ const plan = safePlan(input.plan ?? "free");
258
+ const defaults = PLAN_DEFAULTS[plan];
259
+ const record = {
260
+ id,
261
+ label: safeLabel(input.label),
262
+ status: "active",
263
+ plan,
264
+ maxRoutes: boundedLimit(input.maxRoutes, defaults.maxRoutes, 1e4, "route limit"),
265
+ maxDevicesPerRoute: boundedLimit(input.maxDevicesPerRoute, defaults.maxDevicesPerRoute, 1e5, "device limit"),
266
+ revision: 1,
267
+ createdAt: now,
268
+ updatedAt: now
269
+ };
270
+ db.prepare(
271
+ `INSERT INTO relay_accounts
272
+ (id, label, status, plan, max_routes, max_devices_per_route, revision, credential_hash,
273
+ credential_lookup, created_at, updated_at)
274
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
275
+ ).run(
276
+ record.id,
277
+ record.label,
278
+ record.status,
279
+ record.plan,
280
+ record.maxRoutes,
281
+ record.maxDevicesPerRoute,
282
+ record.revision,
283
+ relayAccountCredentialHash(input.credential),
284
+ relayAccountCredentialLookup(input.credential),
285
+ record.createdAt,
286
+ record.updatedAt
287
+ );
288
+ return record;
289
+ });
290
+ return {
291
+ mode: "sqlite",
292
+ createAccount: (input, now = Date.now()) => clone(createAccount(input, now)),
293
+ getAccount(id) {
294
+ const row = rowById(safeId(id));
295
+ return row ? fromRow(row) : void 0;
296
+ },
297
+ listAccounts({ includeDeleted = false } = {}) {
298
+ const rows = db.prepare(
299
+ `SELECT * FROM relay_accounts ${includeDeleted ? "" : "WHERE status != 'deleted'"}
300
+ ORDER BY created_at, id`
301
+ ).all();
302
+ return rows.map(fromRow);
303
+ },
304
+ authenticate(credential) {
305
+ let credentialLookup;
306
+ try {
307
+ credentialLookup = relayAccountCredentialLookup(credential);
308
+ } catch {
309
+ return void 0;
310
+ }
311
+ const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status = 'active'").get(credentialLookup);
312
+ return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
313
+ },
314
+ updateAccount(id, input, expectedRevision, now = Date.now()) {
315
+ const safeAccountId = safeId(id);
316
+ const currentRow = rowById(safeAccountId);
317
+ if (!currentRow) return void 0;
318
+ const current = fromRow(currentRow);
319
+ if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
320
+ if (current.status === "deleted") throw new Error("deleted relay account is immutable");
321
+ const plan = input.plan === void 0 ? current.plan : safePlan(input.plan);
322
+ const defaults = PLAN_DEFAULTS[plan];
323
+ const next = {
324
+ ...current,
325
+ ...input.label === void 0 ? {} : { label: safeLabel(input.label) },
326
+ status: input.status === void 0 ? current.status : safeStatus(input.status),
327
+ plan,
328
+ maxRoutes: boundedLimit(
329
+ input.maxRoutes,
330
+ input.plan === void 0 ? current.maxRoutes : defaults.maxRoutes,
331
+ 1e4,
332
+ "route limit"
333
+ ),
334
+ maxDevicesPerRoute: boundedLimit(
335
+ input.maxDevicesPerRoute,
336
+ input.plan === void 0 ? current.maxDevicesPerRoute : defaults.maxDevicesPerRoute,
337
+ 1e5,
338
+ "device limit"
339
+ ),
340
+ revision: current.revision + 1,
341
+ updatedAt: now
342
+ };
343
+ const result = db.prepare(
344
+ `UPDATE relay_accounts SET label = ?, status = ?, plan = ?, max_routes = ?,
345
+ max_devices_per_route = ?, revision = ?, updated_at = ? WHERE id = ? AND revision = ?`
346
+ ).run(
347
+ next.label,
348
+ next.status,
349
+ next.plan,
350
+ next.maxRoutes,
351
+ next.maxDevicesPerRoute,
352
+ next.revision,
353
+ next.updatedAt,
354
+ next.id,
355
+ expectedRevision
356
+ );
357
+ if (result.changes !== 1) {
358
+ const latest = rowById(safeAccountId);
359
+ if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
360
+ return void 0;
361
+ }
362
+ return next;
363
+ },
364
+ rotateCredential(id, credential, expectedRevision, now = Date.now()) {
365
+ const safeAccountId = safeId(id);
366
+ const currentRow = rowById(safeAccountId);
367
+ if (!currentRow) return void 0;
368
+ const current = fromRow(currentRow);
369
+ if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
370
+ if (current.status === "deleted") throw new Error("deleted relay account is immutable");
371
+ const result = db.prepare(
372
+ `UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
373
+ updated_at = ? WHERE id = ? AND revision = ?`
374
+ ).run(
375
+ relayAccountCredentialHash(credential),
376
+ relayAccountCredentialLookup(credential),
377
+ now,
378
+ safeAccountId,
379
+ expectedRevision
380
+ );
381
+ if (result.changes !== 1) {
382
+ const latest = rowById(safeAccountId);
383
+ if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
384
+ return void 0;
385
+ }
386
+ return fromRow(rowById(safeAccountId));
387
+ },
388
+ close: () => db.close()
389
+ };
390
+ }
391
+
392
+ // src/relay-store.ts
393
+ import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
394
+ import { createRequire as createRequire2 } from "module";
395
+ var require3 = createRequire2(import.meta.url);
396
+ function safeId2(value, field) {
397
+ if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
398
+ return value;
399
+ }
400
+ function safeOwnerAccountId(value) {
401
+ if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
402
+ throw new Error("invalid relay route owner");
403
+ }
404
+ return value;
405
+ }
406
+ function safeLabel2(value) {
407
+ if (typeof value !== "string") throw new Error("relay route label is required");
408
+ const normalized = value.trim().replace(/\s+/g, " ");
409
+ if (!normalized || normalized.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(normalized)) {
410
+ throw new Error("invalid relay route label");
411
+ }
412
+ return normalized;
413
+ }
414
+ function safeCredential2(value) {
415
+ if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
416
+ throw new Error("invalid relay credential");
417
+ }
418
+ return value;
419
+ }
420
+ function relayCredentialHash(credential) {
421
+ return `sha256:${createHash2("sha256").update("roamcode-relay-credential-v1\0").update(safeCredential2(credential)).digest("base64url")}`;
422
+ }
423
+ function generateRelayCredential(prefix = "rrd") {
424
+ return `${prefix}_${randomBytes2(32).toString("base64url")}`;
425
+ }
426
+ function safeCredentialHash(value) {
427
+ if (typeof value !== "string" || !/^sha256:[A-Za-z0-9_-]{43}$/.test(value)) {
428
+ throw new Error("invalid relay credential hash");
429
+ }
430
+ return value;
431
+ }
432
+ function safeExpiry(value) {
433
+ if (value === void 0) return void 0;
434
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid relay device expiry");
435
+ return value;
436
+ }
437
+ function hashMatches2(expected, credential) {
438
+ try {
439
+ const actual = relayCredentialHash(credential);
440
+ const left = Buffer.from(safeCredentialHash(expected));
441
+ const right = Buffer.from(actual);
442
+ return left.length === right.length && timingSafeEqual2(left, right);
443
+ } catch {
444
+ return false;
445
+ }
446
+ }
447
+ function cloneRoute(route) {
448
+ return { ...route };
449
+ }
450
+ function cloneDevice(device) {
451
+ return { ...device };
452
+ }
453
+ function createMemoryStore2(options) {
454
+ const routes = /* @__PURE__ */ new Map();
455
+ const devices = /* @__PURE__ */ new Map();
456
+ const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
457
+ const deviceKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
458
+ const listRoutes = (now, ownerAccountId) => [...routes.values()].filter((route) => ownerAccountId === void 0 || route.ownerAccountId === ownerAccountId).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map((route) => ({
459
+ id: route.id,
460
+ label: route.label,
461
+ deviceCount: [...devices.values()].filter(
462
+ (device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
463
+ ).length,
464
+ createdAt: route.createdAt,
465
+ updatedAt: route.updatedAt
466
+ }));
467
+ return {
468
+ mode: "memory",
469
+ createRoute(input, now = Date.now()) {
470
+ const id = safeId2(input.id ?? generateRouteId(), "route id");
471
+ if (routes.has(id)) throw new Error("relay route already exists");
472
+ const route = {
473
+ id,
474
+ label: safeLabel2(input.label),
475
+ hostCredentialHash: safeCredentialHash(input.hostCredentialHash),
476
+ ...input.ownerAccountId === void 0 ? {} : { ownerAccountId: safeOwnerAccountId(input.ownerAccountId) },
477
+ createdAt: now,
478
+ updatedAt: now
479
+ };
480
+ routes.set(id, route);
481
+ return cloneRoute(route);
482
+ },
483
+ getRoute(id) {
484
+ const route = routes.get(safeId2(id, "route id"));
485
+ return route ? cloneRoute(route) : void 0;
486
+ },
487
+ listRoutes: (now = Date.now()) => listRoutes(now),
488
+ listRoutesByOwner(ownerAccountId, now = Date.now()) {
489
+ return listRoutes(now, safeOwnerAccountId(ownerAccountId));
490
+ },
491
+ countDevices(routeId, now = Date.now()) {
492
+ const safeRouteId = safeId2(routeId, "route id");
493
+ return [...devices.values()].filter(
494
+ (device) => device.routeId === safeRouteId && (device.expiresAt === void 0 || device.expiresAt >= now)
495
+ ).length;
496
+ },
497
+ rotateHostCredential(routeId, credentialHash, now = Date.now()) {
498
+ const route = routes.get(safeId2(routeId, "route id"));
499
+ if (!route) return false;
500
+ route.hostCredentialHash = safeCredentialHash(credentialHash);
501
+ route.updatedAt = now;
502
+ return true;
503
+ },
504
+ deleteRoute(id) {
505
+ const safeRouteId = safeId2(id, "route id");
506
+ if (!routes.delete(safeRouteId)) return false;
507
+ for (const [key, device] of devices) if (device.routeId === safeRouteId) devices.delete(key);
508
+ return true;
509
+ },
510
+ authenticateHost(routeId, credential) {
511
+ const route = routes.get(safeId2(routeId, "route id"));
512
+ return !!route && hashMatches2(route.hostCredentialHash, credential);
513
+ },
514
+ putDevice(input, now = Date.now()) {
515
+ const routeId = safeId2(input.routeId, "route id");
516
+ if (!routes.has(routeId)) throw new Error("relay route not found");
517
+ const deviceId = safeId2(input.deviceId, "device id");
518
+ const key = deviceKey(routeId, deviceId);
519
+ const current = devices.get(key);
520
+ const device = {
521
+ routeId,
522
+ deviceId,
523
+ credentialHash: safeCredentialHash(input.credentialHash),
524
+ createdAt: current?.createdAt ?? now,
525
+ updatedAt: now,
526
+ ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
527
+ };
528
+ devices.set(key, device);
529
+ const route = routes.get(routeId);
530
+ route.updatedAt = now;
531
+ return cloneDevice(device);
532
+ },
533
+ getDevice(routeId, deviceId, now = Date.now()) {
534
+ const device = devices.get(deviceKey(safeId2(routeId, "route id"), safeId2(deviceId, "device id")));
535
+ return device && (device.expiresAt === void 0 || device.expiresAt >= now) ? cloneDevice(device) : void 0;
536
+ },
537
+ authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
538
+ const device = devices.get(deviceKey(safeId2(routeId, "route id"), safeId2(deviceId, "device id")));
539
+ return !!device && (device.expiresAt === void 0 || device.expiresAt >= now) && hashMatches2(device.credentialHash, credential);
540
+ },
541
+ revokeDevice(routeId, deviceId) {
542
+ const safeRouteId = safeId2(routeId, "route id");
543
+ const removed = devices.delete(deviceKey(safeRouteId, safeId2(deviceId, "device id")));
544
+ if (removed) routes.get(safeRouteId).updatedAt = Date.now();
545
+ return removed;
546
+ },
547
+ close() {
548
+ routes.clear();
549
+ devices.clear();
550
+ }
551
+ };
552
+ }
553
+ function routeFromRow(row) {
554
+ return {
555
+ id: row.id,
556
+ label: row.label,
557
+ hostCredentialHash: row.host_credential_hash,
558
+ ...row.owner_account_id === null ? {} : { ownerAccountId: row.owner_account_id },
559
+ createdAt: row.created_at,
560
+ updatedAt: row.updated_at
561
+ };
562
+ }
563
+ function deviceFromRow(row) {
564
+ return {
565
+ routeId: row.route_id,
566
+ deviceId: row.device_id,
567
+ credentialHash: row.credential_hash,
568
+ createdAt: row.created_at,
569
+ updatedAt: row.updated_at,
570
+ ...row.expires_at === null || row.expires_at === void 0 ? {} : { expiresAt: row.expires_at }
571
+ };
572
+ }
573
+ function openRelayRouteStore(options) {
574
+ let Database;
575
+ try {
576
+ if (options.loadDatabase) Database = options.loadDatabase();
577
+ else {
578
+ const mod = require3("better-sqlite3");
579
+ Database = mod.default ?? mod;
580
+ }
581
+ } catch {
582
+ return createMemoryStore2(options);
583
+ }
584
+ const db = new Database(options.dbPath);
585
+ db.pragma("journal_mode = WAL");
586
+ db.pragma("busy_timeout = 5000");
587
+ db.pragma("foreign_keys = ON");
588
+ db.exec(`
589
+ CREATE TABLE IF NOT EXISTS relay_routes (
590
+ id TEXT PRIMARY KEY,
591
+ label TEXT NOT NULL,
592
+ host_credential_hash TEXT NOT NULL,
593
+ owner_account_id TEXT,
594
+ created_at INTEGER NOT NULL,
595
+ updated_at INTEGER NOT NULL
596
+ );
597
+ CREATE TABLE IF NOT EXISTS relay_route_devices (
598
+ route_id TEXT NOT NULL REFERENCES relay_routes(id) ON DELETE CASCADE,
599
+ device_id TEXT NOT NULL,
600
+ credential_hash TEXT NOT NULL,
601
+ created_at INTEGER NOT NULL,
602
+ updated_at INTEGER NOT NULL,
603
+ expires_at INTEGER,
604
+ PRIMARY KEY(route_id, device_id)
605
+ );
606
+ `);
607
+ const relayDeviceColumns = db.prepare("PRAGMA table_info(relay_route_devices)").all();
608
+ if (!relayDeviceColumns.some((column) => column.name === "expires_at")) {
609
+ db.exec("ALTER TABLE relay_route_devices ADD COLUMN expires_at INTEGER");
610
+ }
611
+ const relayRouteColumns = db.prepare("PRAGMA table_info(relay_routes)").all();
612
+ if (!relayRouteColumns.some((column) => column.name === "owner_account_id")) {
613
+ db.exec("ALTER TABLE relay_routes ADD COLUMN owner_account_id TEXT");
614
+ }
615
+ db.exec("CREATE INDEX IF NOT EXISTS relay_routes_owner_idx ON relay_routes(owner_account_id)");
616
+ const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
617
+ const getRoute = (id) => db.prepare("SELECT * FROM relay_routes WHERE id = ?").get(id);
618
+ const getDevice = (routeId, deviceId) => db.prepare("SELECT * FROM relay_route_devices WHERE route_id = ? AND device_id = ?").get(routeId, deviceId);
619
+ const createRoute = db.transaction(
620
+ (input, now) => {
621
+ const id = safeId2(input.id ?? generateRouteId(), "route id");
622
+ if (getRoute(id)) throw new Error("relay route already exists");
623
+ const route = {
624
+ id,
625
+ label: safeLabel2(input.label),
626
+ hostCredentialHash: safeCredentialHash(input.hostCredentialHash),
627
+ ...input.ownerAccountId === void 0 ? {} : { ownerAccountId: safeOwnerAccountId(input.ownerAccountId) },
628
+ createdAt: now,
629
+ updatedAt: now
630
+ };
631
+ db.prepare(
632
+ `INSERT INTO relay_routes
633
+ (id, label, host_credential_hash, owner_account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`
634
+ ).run(
635
+ route.id,
636
+ route.label,
637
+ route.hostCredentialHash,
638
+ route.ownerAccountId ?? null,
639
+ route.createdAt,
640
+ route.updatedAt
641
+ );
642
+ return route;
643
+ }
644
+ );
645
+ const putDevice = db.transaction(
646
+ (input, now) => {
647
+ const routeId = safeId2(input.routeId, "route id");
648
+ if (!getRoute(routeId)) throw new Error("relay route not found");
649
+ const deviceId = safeId2(input.deviceId, "device id");
650
+ const current = getDevice(routeId, deviceId);
651
+ const device = {
652
+ routeId,
653
+ deviceId,
654
+ credentialHash: safeCredentialHash(input.credentialHash),
655
+ createdAt: current?.created_at ?? now,
656
+ updatedAt: now,
657
+ ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
658
+ };
659
+ db.prepare(
660
+ `INSERT INTO relay_route_devices (route_id, device_id, credential_hash, created_at, updated_at, expires_at)
661
+ VALUES (?, ?, ?, ?, ?, ?)
662
+ ON CONFLICT(route_id, device_id) DO UPDATE SET credential_hash = excluded.credential_hash,
663
+ updated_at = excluded.updated_at, expires_at = excluded.expires_at`
664
+ ).run(
665
+ device.routeId,
666
+ device.deviceId,
667
+ device.credentialHash,
668
+ device.createdAt,
669
+ device.updatedAt,
670
+ device.expiresAt ?? null
671
+ );
672
+ db.prepare("UPDATE relay_routes SET updated_at = ? WHERE id = ?").run(now, routeId);
673
+ return device;
674
+ }
675
+ );
676
+ const listRoutes = (now, ownerAccountId) => db.prepare(
677
+ `SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
678
+ FROM relay_routes r LEFT JOIN relay_route_devices d ON d.route_id = r.id
679
+ AND (d.expires_at IS NULL OR d.expires_at >= ?)
680
+ ${ownerAccountId === void 0 ? "" : "WHERE r.owner_account_id = ?"}
681
+ GROUP BY r.id ORDER BY r.created_at, r.id`
682
+ ).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
683
+ id: row.id,
684
+ label: row.label,
685
+ deviceCount: row.device_count,
686
+ createdAt: row.created_at,
687
+ updatedAt: row.updated_at
688
+ }));
689
+ return {
690
+ mode: "sqlite",
691
+ createRoute: (input, now = Date.now()) => cloneRoute(createRoute(input, now)),
692
+ getRoute(id) {
693
+ const row = getRoute(safeId2(id, "route id"));
694
+ return row ? routeFromRow(row) : void 0;
695
+ },
696
+ listRoutes: (now = Date.now()) => listRoutes(now),
697
+ listRoutesByOwner: (ownerAccountId, now = Date.now()) => listRoutes(now, ownerAccountId),
698
+ countDevices(routeId, now = Date.now()) {
699
+ const row = db.prepare(
700
+ `SELECT COUNT(*) AS count FROM relay_route_devices
701
+ WHERE route_id = ? AND (expires_at IS NULL OR expires_at >= ?)`
702
+ ).get(safeId2(routeId, "route id"), now);
703
+ return row.count;
704
+ },
705
+ rotateHostCredential(routeId, credentialHash, now = Date.now()) {
706
+ return db.prepare("UPDATE relay_routes SET host_credential_hash = ?, updated_at = ? WHERE id = ?").run(safeCredentialHash(credentialHash), now, safeId2(routeId, "route id")).changes > 0;
707
+ },
708
+ deleteRoute(id) {
709
+ return db.prepare("DELETE FROM relay_routes WHERE id = ?").run(safeId2(id, "route id")).changes > 0;
710
+ },
711
+ authenticateHost(routeId, credential) {
712
+ const row = getRoute(safeId2(routeId, "route id"));
713
+ return !!row && hashMatches2(row.host_credential_hash, credential);
714
+ },
715
+ putDevice: (input, now = Date.now()) => cloneDevice(putDevice(input, now)),
716
+ getDevice(routeId, deviceId, now = Date.now()) {
717
+ const row = getDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"));
718
+ return row && (row.expires_at === null || row.expires_at === void 0 || row.expires_at >= now) ? deviceFromRow(row) : void 0;
719
+ },
720
+ authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
721
+ const row = getDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"));
722
+ return !!row && (row.expires_at === null || row.expires_at === void 0 || row.expires_at >= now) && hashMatches2(row.credential_hash, credential);
723
+ },
724
+ revokeDevice(routeId, deviceId) {
725
+ const safeRouteId = safeId2(routeId, "route id");
726
+ const result = db.prepare("DELETE FROM relay_route_devices WHERE route_id = ? AND device_id = ?").run(safeRouteId, safeId2(deviceId, "device id"));
727
+ if (result.changes > 0)
728
+ db.prepare("UPDATE relay_routes SET updated_at = ? WHERE id = ?").run(Date.now(), safeRouteId);
729
+ return result.changes > 0;
730
+ },
731
+ close: () => db.close()
732
+ };
733
+ }
734
+
735
+ // src/relay-broker.ts
736
+ var BLIND_RELAY_PROTOCOL_VERSION = 1;
737
+ var BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES = 15e5;
738
+ var BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES = 4e6;
739
+ var BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 64;
740
+ var BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE = 64 * 1024 * 1024;
741
+ var BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE = 12e3;
742
+ function safeToken(value, field) {
743
+ if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
744
+ throw new Error(`invalid relay ${field}`);
745
+ }
746
+ return value;
747
+ }
748
+ function safeCredentialHash2(value) {
749
+ if (typeof value !== "string" || !/^sha256:[A-Za-z0-9_-]{43}$/.test(value)) {
750
+ throw new Error("invalid relay credential hash");
751
+ }
752
+ return value;
753
+ }
754
+ function safeId3(value, field) {
755
+ if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
756
+ return value;
757
+ }
758
+ function safeLabel3(value) {
759
+ if (typeof value !== "string") throw new Error("relay route label is required");
760
+ const label = value.trim().replace(/\s+/g, " ");
761
+ if (!label || label.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(label)) throw new Error("invalid relay route label");
762
+ return label;
763
+ }
764
+ function safeExpiry2(value, now) {
765
+ if (value === void 0 || value === null) return void 0;
766
+ if (!Number.isSafeInteger(value) || value <= now || value > now + 10 * 6e4) {
767
+ throw new Error("invalid relay device expiry");
768
+ }
769
+ return value;
770
+ }
771
+ function safeRevision(value) {
772
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error("invalid relay account revision");
773
+ return value;
774
+ }
775
+ function bearer(request) {
776
+ const value = request.headers.authorization;
777
+ if (!value || Array.isArray(value)) return;
778
+ const match = /^Bearer ([A-Za-z0-9_-]{32,256})$/.exec(value);
779
+ return match?.[1];
780
+ }
781
+ function tokenMatches(left, right) {
782
+ if (!right) return false;
783
+ const a = Buffer.from(left);
784
+ const b = Buffer.from(right);
785
+ return a.length === b.length && timingSafeEqual3(a, b);
786
+ }
787
+ function publicRoute(route) {
788
+ return {
789
+ id: route.id,
790
+ label: route.label,
791
+ deviceCount: route.deviceCount,
792
+ createdAt: route.createdAt,
793
+ updatedAt: route.updatedAt
794
+ };
795
+ }
796
+ function normalizeOrigin(value) {
797
+ try {
798
+ const url = new URL(value);
799
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password) return;
800
+ return url.origin;
801
+ } catch {
802
+ return;
803
+ }
804
+ }
805
+ function parseJson(raw, limit) {
806
+ const buffer = Buffer.isBuffer(raw) ? raw : raw instanceof ArrayBuffer ? Buffer.from(raw) : Buffer.concat(raw);
807
+ if (buffer.byteLength > limit) throw new Error("relay frame too large");
808
+ return JSON.parse(buffer.toString("utf8"));
809
+ }
810
+ function parseAuthHello(value) {
811
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid relay hello");
812
+ const hello = value;
813
+ if (hello.v !== BLIND_RELAY_PROTOCOL_VERSION || hello.role !== "host" && hello.role !== "device") {
814
+ throw new Error("invalid relay hello");
815
+ }
816
+ const routeId = safeId3(hello.routeId, "route id");
817
+ const credential = safeToken(hello.credential, "credential");
818
+ return hello.role === "host" ? { v: 1, role: "host", routeId, credential } : { v: 1, role: "device", routeId, deviceId: safeId3(hello.deviceId, "device id"), credential };
819
+ }
820
+ function parsePayload(value, requireChannel, maxBytes) {
821
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid relay frame");
822
+ const frame = value;
823
+ if (frame.t !== "frame" || typeof frame.payload !== "string" || !/^[A-Za-z0-9_-]+$/.test(frame.payload)) {
824
+ throw new Error("invalid relay frame");
825
+ }
826
+ const bytes = Math.floor(frame.payload.length * 3 / 4);
827
+ if (bytes < 1 || bytes > maxBytes) throw new Error("relay frame too large");
828
+ const canonical = Buffer.from(frame.payload, "base64url").toString("base64url");
829
+ if (canonical !== frame.payload) throw new Error("invalid relay frame");
830
+ return {
831
+ ...requireChannel ? { channelId: safeId3(frame.channelId, "channel id") } : {},
832
+ payload: frame.payload,
833
+ bytes
834
+ };
835
+ }
836
+ function safeSend(socket, value, maxQueueBytes) {
837
+ if (socket.readyState !== socket.OPEN) return false;
838
+ const encoded = JSON.stringify(value);
839
+ if (socket.bufferedAmount + Buffer.byteLength(encoded) > maxQueueBytes) return false;
840
+ try {
841
+ socket.send(encoded);
842
+ return true;
843
+ } catch {
844
+ return false;
845
+ }
846
+ }
847
+ function createBlindRelayServer(options) {
848
+ const rootTokens = [safeToken(options.rootToken, "root token")];
849
+ for (const token of options.previousRootTokens ?? []) rootTokens.push(safeToken(token, "previous root token"));
850
+ if (rootTokens.length > 4 || new Set(rootTokens).size !== rootTokens.length) {
851
+ throw new Error("invalid relay root token rotation set");
852
+ }
853
+ const store = options.store ?? openRelayRouteStore({ dbPath: ":memory:" });
854
+ const ownsStore = options.store === void 0;
855
+ const accountStore = options.accountStore;
856
+ const now = options.now ?? Date.now;
857
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 5e3;
858
+ const idleTimeoutMs = options.idleTimeoutMs ?? 2 * 6e4;
859
+ const maxFrameBytes = options.maxFrameBytes ?? BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES;
860
+ const maxQueueBytes = options.maxQueueBytes ?? BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES;
861
+ const maxConnectionsPerRoute = options.maxConnectionsPerRoute ?? BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
862
+ const maxBytesPerMinute = options.maxBytesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE;
863
+ const maxMessagesPerMinute = options.maxMessagesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE;
864
+ for (const [value, minimum, maximum, label] of [
865
+ [handshakeTimeoutMs, 1e3, 3e4, "handshake timeout"],
866
+ [idleTimeoutMs, 1e4, 60 * 6e4, "idle timeout"],
867
+ [maxFrameBytes, 1024, 16 * 1024 * 1024, "frame limit"],
868
+ [maxQueueBytes, 1024, 64 * 1024 * 1024, "queue limit"],
869
+ [maxConnectionsPerRoute, 1, 1e4, "connection limit"],
870
+ [maxBytesPerMinute, 1024, 1024 * 1024 * 1024, "byte rate"],
871
+ [maxMessagesPerMinute, 10, 1e6, "message rate"]
872
+ ]) {
873
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`invalid relay ${label}`);
874
+ }
875
+ const allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin).filter(Boolean));
876
+ const generateChannelId = options.generateChannelId ?? (() => `rrc_${randomBytes3(16).toString("base64url")}`);
877
+ const hosts = /* @__PURE__ */ new Map();
878
+ const devicesByChannel = /* @__PURE__ */ new Map();
879
+ const metrics = {
880
+ activeHosts: 0,
881
+ activeDevices: 0,
882
+ acceptedConnections: 0,
883
+ rejectedConnections: 0,
884
+ forwardedFrames: 0,
885
+ forwardedBytes: 0,
886
+ droppedFrames: 0
887
+ };
888
+ const app = Fastify({ logger: false, trustProxy: false, bodyLimit: 32 * 1024 });
889
+ const authenticatedAccounts = /* @__PURE__ */ new WeakMap();
890
+ const routeAccountIsActive = (routeId) => {
891
+ const ownerAccountId = store.getRoute(routeId)?.ownerAccountId;
892
+ if (!ownerAccountId) return true;
893
+ return accountStore?.getAccount(ownerAccountId)?.status === "active";
894
+ };
895
+ const requireRoot = async (request, reply) => {
896
+ const presented = bearer(request);
897
+ if (rootTokens.some((token) => tokenMatches(token, presented))) return;
898
+ reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
899
+ };
900
+ const requireHost = async (request, reply) => {
901
+ try {
902
+ if (routeAccountIsActive(request.params.routeId) && store.authenticateHost(request.params.routeId, bearer(request) ?? ""))
903
+ return;
904
+ } catch {
905
+ }
906
+ reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
907
+ };
908
+ const requireAccount = async (request, reply) => {
909
+ const account = accountStore?.authenticate(bearer(request) ?? "");
910
+ if (account) {
911
+ authenticatedAccounts.set(request, account);
912
+ return;
913
+ }
914
+ reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
915
+ };
916
+ app.addHook("onSend", async (_request, reply, payload) => {
917
+ reply.header("cache-control", "no-store");
918
+ reply.header("content-security-policy", "default-src 'none'");
919
+ reply.header("x-content-type-options", "nosniff");
920
+ return payload;
921
+ });
922
+ app.get("/health", async () => ({ status: "ok", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }));
923
+ app.get("/ready", async (_request, reply) => {
924
+ try {
925
+ store.listRoutes(now());
926
+ accountStore?.listAccounts();
927
+ return { status: "ready", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION };
928
+ } catch {
929
+ reply.code(503);
930
+ return { status: "unavailable", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION };
931
+ }
932
+ });
933
+ app.get("/v1/metrics", { preHandler: requireRoot }, async () => ({
934
+ protocolVersion: BLIND_RELAY_PROTOCOL_VERSION,
935
+ metrics: { ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size }
936
+ }));
937
+ app.get("/v1/routes", { preHandler: requireRoot }, async () => ({ routes: store.listRoutes().map(publicRoute) }));
938
+ app.post(
939
+ "/v1/routes",
940
+ { preHandler: requireRoot },
941
+ async (request, reply) => {
942
+ try {
943
+ const hostCredential = generateRelayCredential("rrh");
944
+ const route = store.createRoute({
945
+ ...request.body?.id === void 0 ? {} : { id: safeId3(request.body.id, "route id") },
946
+ label: safeLabel3(request.body?.label),
947
+ hostCredentialHash: relayCredentialHash(hostCredential)
948
+ });
949
+ reply.code(201).send({
950
+ route: {
951
+ id: route.id,
952
+ label: route.label,
953
+ deviceCount: 0,
954
+ createdAt: route.createdAt,
955
+ updatedAt: route.updatedAt
956
+ },
957
+ hostCredential
958
+ });
959
+ } catch (error) {
960
+ const conflict = error.message === "relay route already exists";
961
+ reply.code(conflict ? 409 : 400).send({
962
+ code: conflict ? "RELAY_ROUTE_EXISTS" : "INVALID_RELAY_ROUTE",
963
+ error: conflict ? "relay route already exists" : "invalid relay route"
964
+ });
965
+ }
966
+ }
967
+ );
968
+ app.delete(
969
+ "/v1/routes/:routeId",
970
+ { preHandler: requireRoot },
971
+ async (request, reply) => {
972
+ let removed = false;
973
+ try {
974
+ removed = store.deleteRoute(request.params.routeId);
975
+ } catch {
976
+ }
977
+ if (!removed) {
978
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
979
+ return;
980
+ }
981
+ const host = hosts.get(request.params.routeId);
982
+ host?.socket.close(4403, "route deleted");
983
+ for (const device of host?.devices.values() ?? []) device.socket.close(4403, "route deleted");
984
+ reply.code(204).send();
985
+ }
986
+ );
987
+ app.get(
988
+ "/v1/routes/:routeId/status",
989
+ { preHandler: requireHost },
990
+ async (request) => ({
991
+ routeId: request.params.routeId,
992
+ hostOnline: hosts.has(request.params.routeId),
993
+ activeDevices: hosts.get(request.params.routeId)?.devices.size ?? 0
994
+ })
995
+ );
996
+ app.put("/v1/routes/:routeId/devices/:deviceId", { preHandler: requireHost }, async (request, reply) => {
997
+ try {
998
+ const route = store.getRoute(request.params.routeId);
999
+ if (route?.ownerAccountId && !store.getDevice(route.id, request.params.deviceId, now())) {
1000
+ const account = accountStore?.getAccount(route.ownerAccountId);
1001
+ if (!account || store.countDevices(route.id, now()) >= account.maxDevicesPerRoute) {
1002
+ reply.code(429).send({ code: "RELAY_DEVICE_LIMIT", error: "relay device limit reached" });
1003
+ return;
1004
+ }
1005
+ }
1006
+ const device = store.putDevice({
1007
+ routeId: request.params.routeId,
1008
+ deviceId: request.params.deviceId,
1009
+ credentialHash: typeof request.body?.credentialHash === "string" ? request.body.credentialHash : "invalid",
1010
+ ...request.body?.expiresAt === void 0 ? {} : { expiresAt: safeExpiry2(request.body.expiresAt, now()) }
1011
+ });
1012
+ reply.code(200).send({
1013
+ device: {
1014
+ routeId: device.routeId,
1015
+ deviceId: device.deviceId,
1016
+ createdAt: device.createdAt,
1017
+ updatedAt: device.updatedAt
1018
+ }
1019
+ });
1020
+ } catch {
1021
+ reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device route" });
1022
+ }
1023
+ });
1024
+ app.delete(
1025
+ "/v1/routes/:routeId/devices/:deviceId",
1026
+ { preHandler: requireHost },
1027
+ async (request, reply) => {
1028
+ let removed = false;
1029
+ try {
1030
+ removed = store.revokeDevice(request.params.routeId, request.params.deviceId);
1031
+ } catch {
1032
+ }
1033
+ if (!removed) {
1034
+ reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
1035
+ return;
1036
+ }
1037
+ const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
1038
+ live?.socket.close(4403, "device revoked");
1039
+ reply.code(204).send();
1040
+ }
1041
+ );
1042
+ const consumeRate = (window, bytes) => {
1043
+ const current = now();
1044
+ if (current - window.startedAt >= 6e4) {
1045
+ window.startedAt = current;
1046
+ window.bytes = 0;
1047
+ window.messages = 0;
1048
+ }
1049
+ window.bytes += bytes;
1050
+ window.messages += 1;
1051
+ return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
1052
+ };
1053
+ const closeDevice = (device, code = 1e3, reason = "device disconnected") => {
1054
+ if (device.closed) return;
1055
+ device.closed = true;
1056
+ if (device.idle) clearTimeout(device.idle);
1057
+ devicesByChannel.delete(device.channelId);
1058
+ const host = hosts.get(device.routeId);
1059
+ if (host?.devices.get(device.deviceId) === device) host.devices.delete(device.deviceId);
1060
+ metrics.activeDevices = Math.max(0, metrics.activeDevices - 1);
1061
+ if (host) safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes);
1062
+ if (device.socket.readyState === device.socket.OPEN) device.socket.close(code, reason);
1063
+ };
1064
+ const touchDevice = (device) => {
1065
+ if (device.idle) clearTimeout(device.idle);
1066
+ device.idle = setTimeout(() => closeDevice(device, 4408, "idle timeout"), idleTimeoutMs);
1067
+ device.idle.unref?.();
1068
+ };
1069
+ const closeHost = (host, code = 1e3, reason = "host disconnected") => {
1070
+ if (host.closed) return;
1071
+ host.closed = true;
1072
+ if (host.idle) clearTimeout(host.idle);
1073
+ if (hosts.get(host.routeId) === host) hosts.delete(host.routeId);
1074
+ for (const device of [...host.devices.values()]) closeDevice(device, 4412, "host unavailable");
1075
+ metrics.activeHosts = Math.max(0, metrics.activeHosts - 1);
1076
+ if (host.socket.readyState === host.socket.OPEN) host.socket.close(code, reason);
1077
+ };
1078
+ const touchHost = (host) => {
1079
+ if (host.idle) clearTimeout(host.idle);
1080
+ host.idle = setTimeout(() => closeHost(host, 4408, "idle timeout"), idleTimeoutMs);
1081
+ host.idle.unref?.();
1082
+ };
1083
+ if (accountStore) {
1084
+ const accountEnvelope = (account) => ({
1085
+ account,
1086
+ usage: { routes: store.listRoutesByOwner(account.id, now()).length, maxRoutes: account.maxRoutes }
1087
+ });
1088
+ const closeAccountRoutes = (accountId, reason) => {
1089
+ for (const route of store.listRoutesByOwner(accountId, now())) {
1090
+ const host = hosts.get(route.id);
1091
+ if (host) closeHost(host, 4403, reason);
1092
+ }
1093
+ };
1094
+ const ownedRoute = (accountId, routeId) => {
1095
+ const route = store.getRoute(routeId);
1096
+ return route?.ownerAccountId === accountId ? route : void 0;
1097
+ };
1098
+ app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
1099
+ accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
1100
+ }));
1101
+ app.post("/v1/accounts", { preHandler: requireRoot }, async (request, reply) => {
1102
+ const accountCredential = generateRelayAccountCredential();
1103
+ try {
1104
+ const account = accountStore.createAccount({
1105
+ ...request.body,
1106
+ credential: accountCredential
1107
+ });
1108
+ reply.code(201).send({ ...accountEnvelope(account), accountCredential });
1109
+ } catch {
1110
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1111
+ }
1112
+ });
1113
+ app.patch(
1114
+ "/v1/accounts/:accountId",
1115
+ { preHandler: requireRoot },
1116
+ async (request, reply) => {
1117
+ try {
1118
+ const account = accountStore.updateAccount(
1119
+ request.params.accountId,
1120
+ request.body,
1121
+ safeRevision(request.body?.expectedRevision)
1122
+ );
1123
+ if (!account) {
1124
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1125
+ return;
1126
+ }
1127
+ if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
1128
+ reply.code(200).send(accountEnvelope(account));
1129
+ } catch (error) {
1130
+ if (error instanceof RelayAccountRevisionConflictError) {
1131
+ reply.code(409).send({
1132
+ code: "RELAY_ACCOUNT_REVISION_CONFLICT",
1133
+ error: "relay account revision conflict",
1134
+ current: accountEnvelope(error.current)
1135
+ });
1136
+ return;
1137
+ }
1138
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1139
+ }
1140
+ }
1141
+ );
1142
+ app.post(
1143
+ "/v1/accounts/:accountId/credential",
1144
+ { preHandler: requireRoot },
1145
+ async (request, reply) => {
1146
+ const accountCredential = generateRelayAccountCredential();
1147
+ try {
1148
+ const account = accountStore.rotateCredential(
1149
+ request.params.accountId,
1150
+ accountCredential,
1151
+ safeRevision(request.body?.expectedRevision)
1152
+ );
1153
+ if (!account) {
1154
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1155
+ return;
1156
+ }
1157
+ reply.code(200).send({ ...accountEnvelope(account), accountCredential });
1158
+ } catch (error) {
1159
+ if (error instanceof RelayAccountRevisionConflictError) {
1160
+ reply.code(409).send({
1161
+ code: "RELAY_ACCOUNT_REVISION_CONFLICT",
1162
+ error: "relay account revision conflict",
1163
+ current: accountEnvelope(error.current)
1164
+ });
1165
+ return;
1166
+ }
1167
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1168
+ }
1169
+ }
1170
+ );
1171
+ app.get(
1172
+ "/v1/account",
1173
+ { preHandler: requireAccount },
1174
+ async (request) => accountEnvelope(authenticatedAccounts.get(request))
1175
+ );
1176
+ app.get("/v1/account/routes", { preHandler: requireAccount }, async (request) => ({
1177
+ routes: store.listRoutesByOwner(authenticatedAccounts.get(request).id, now()).map((route) => ({
1178
+ ...publicRoute(route),
1179
+ hostOnline: hosts.has(route.id)
1180
+ }))
1181
+ }));
1182
+ app.post(
1183
+ "/v1/account/routes",
1184
+ { preHandler: requireAccount },
1185
+ async (request, reply) => {
1186
+ const account = authenticatedAccounts.get(request);
1187
+ let id;
1188
+ let label;
1189
+ let credentialHash;
1190
+ try {
1191
+ id = safeId3(request.body?.id, "route id");
1192
+ label = safeLabel3(request.body?.label);
1193
+ credentialHash = safeCredentialHash2(request.body?.credentialHash);
1194
+ } catch {
1195
+ reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
1196
+ return;
1197
+ }
1198
+ const existing = store.getRoute(id);
1199
+ if (existing) {
1200
+ if (existing.ownerAccountId !== account.id || existing.label !== label || !tokenMatches(existing.hostCredentialHash, credentialHash)) {
1201
+ reply.code(409).send({ code: "RELAY_ROUTE_EXISTS", error: "relay route already exists" });
1202
+ return;
1203
+ }
1204
+ reply.code(200).send({
1205
+ route: {
1206
+ ...publicRoute({ ...existing, deviceCount: store.countDevices(existing.id, now()) }),
1207
+ hostOnline: hosts.has(id)
1208
+ },
1209
+ connection: { path: "/v1/connect", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }
1210
+ });
1211
+ return;
1212
+ }
1213
+ if (store.listRoutesByOwner(account.id, now()).length >= account.maxRoutes) {
1214
+ reply.code(429).send({ code: "RELAY_ROUTE_LIMIT", error: "relay route limit reached" });
1215
+ return;
1216
+ }
1217
+ try {
1218
+ const route = store.createRoute({
1219
+ id,
1220
+ label,
1221
+ hostCredentialHash: credentialHash,
1222
+ ownerAccountId: account.id
1223
+ });
1224
+ reply.code(201).send({
1225
+ route: { ...publicRoute({ ...route, deviceCount: 0 }), hostOnline: false },
1226
+ connection: { path: "/v1/connect", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }
1227
+ });
1228
+ } catch {
1229
+ reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
1230
+ }
1231
+ }
1232
+ );
1233
+ app.delete(
1234
+ "/v1/account/routes/:routeId",
1235
+ { preHandler: requireAccount },
1236
+ async (request, reply) => {
1237
+ const account = authenticatedAccounts.get(request);
1238
+ let removed = false;
1239
+ try {
1240
+ removed = !!ownedRoute(account.id, request.params.routeId) && store.deleteRoute(request.params.routeId);
1241
+ } catch {
1242
+ }
1243
+ if (!removed) {
1244
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1245
+ return;
1246
+ }
1247
+ const host = hosts.get(request.params.routeId);
1248
+ if (host) closeHost(host, 4403, "route deleted");
1249
+ reply.code(204).send();
1250
+ }
1251
+ );
1252
+ app.post(
1253
+ "/v1/account/routes/:routeId/credential",
1254
+ { preHandler: requireAccount },
1255
+ async (request, reply) => {
1256
+ const account = authenticatedAccounts.get(request);
1257
+ try {
1258
+ if (!ownedRoute(account.id, request.params.routeId)) {
1259
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1260
+ return;
1261
+ }
1262
+ } catch {
1263
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1264
+ return;
1265
+ }
1266
+ let credentialHash;
1267
+ try {
1268
+ credentialHash = safeCredentialHash2(request.body?.credentialHash);
1269
+ } catch {
1270
+ reply.code(400).send({ code: "INVALID_RELAY_CREDENTIAL", error: "invalid relay credential hash" });
1271
+ return;
1272
+ }
1273
+ if (!store.rotateHostCredential(request.params.routeId, credentialHash, now())) {
1274
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1275
+ return;
1276
+ }
1277
+ const host = hosts.get(request.params.routeId);
1278
+ if (host) closeHost(host, 4409, "host credential rotated");
1279
+ reply.code(204).send();
1280
+ }
1281
+ );
1282
+ }
1283
+ app.register(websocket);
1284
+ app.register(async (scope) => {
1285
+ scope.get("/v1/connect", { websocket: true }, (socket, request) => {
1286
+ let authenticated = false;
1287
+ let liveHost;
1288
+ let liveDevice;
1289
+ const handshakeTimer = setTimeout(() => socket.close(4401, "authentication timeout"), handshakeTimeoutMs);
1290
+ handshakeTimer.unref?.();
1291
+ const reject = (code, reason) => {
1292
+ metrics.rejectedConnections += 1;
1293
+ socket.close(code, reason);
1294
+ };
1295
+ socket.on("message", (raw, isBinary) => {
1296
+ if (isBinary) {
1297
+ metrics.droppedFrames += 1;
1298
+ reject(4400, "text envelope required");
1299
+ return;
1300
+ }
1301
+ if (!authenticated) {
1302
+ try {
1303
+ const hello = parseAuthHello(parseJson(raw, 8 * 1024));
1304
+ const origin = typeof request.headers.origin === "string" ? normalizeOrigin(request.headers.origin) : void 0;
1305
+ if (hello.role === "device" && origin && allowedOrigins.size > 0 && !allowedOrigins.has(origin)) {
1306
+ reject(4403, "origin denied");
1307
+ return;
1308
+ }
1309
+ if (hello.role === "host") {
1310
+ if (!routeAccountIsActive(hello.routeId) || !store.authenticateHost(hello.routeId, hello.credential)) {
1311
+ reject(4401, "authentication failed");
1312
+ return;
1313
+ }
1314
+ const previous = hosts.get(hello.routeId);
1315
+ if (previous) closeHost(previous, 4409, "superseded host");
1316
+ liveHost = {
1317
+ socket,
1318
+ routeId: hello.routeId,
1319
+ devices: /* @__PURE__ */ new Map(),
1320
+ rate: { startedAt: now(), bytes: 0, messages: 0 },
1321
+ closed: false
1322
+ };
1323
+ hosts.set(hello.routeId, liveHost);
1324
+ metrics.activeHosts += 1;
1325
+ touchHost(liveHost);
1326
+ safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes);
1327
+ } else {
1328
+ if (!routeAccountIsActive(hello.routeId) || !store.authenticateDevice(hello.routeId, hello.deviceId, hello.credential, now())) {
1329
+ reject(4401, "authentication failed");
1330
+ return;
1331
+ }
1332
+ const host = hosts.get(hello.routeId);
1333
+ if (!host || host.socket.readyState !== host.socket.OPEN) {
1334
+ reject(4412, "host unavailable");
1335
+ return;
1336
+ }
1337
+ if (host.devices.size >= maxConnectionsPerRoute && !host.devices.has(hello.deviceId)) {
1338
+ reject(4429, "route connection limit");
1339
+ return;
1340
+ }
1341
+ const previous = host.devices.get(hello.deviceId);
1342
+ if (previous) closeDevice(previous, 4409, "superseded device");
1343
+ const channelId = safeId3(generateChannelId(), "channel id");
1344
+ liveDevice = {
1345
+ socket,
1346
+ routeId: hello.routeId,
1347
+ deviceId: hello.deviceId,
1348
+ channelId,
1349
+ rate: { startedAt: now(), bytes: 0, messages: 0 },
1350
+ closed: false
1351
+ };
1352
+ host.devices.set(hello.deviceId, liveDevice);
1353
+ devicesByChannel.set(channelId, liveDevice);
1354
+ metrics.activeDevices += 1;
1355
+ touchDevice(liveDevice);
1356
+ safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes);
1357
+ if (!safeSend(host.socket, { t: "peer-open", channelId, deviceId: hello.deviceId }, maxQueueBytes)) {
1358
+ closeDevice(liveDevice, 4408, "host backpressure");
1359
+ return;
1360
+ }
1361
+ }
1362
+ authenticated = true;
1363
+ metrics.acceptedConnections += 1;
1364
+ clearTimeout(handshakeTimer);
1365
+ } catch {
1366
+ reject(4400, "invalid authentication frame");
1367
+ }
1368
+ return;
1369
+ }
1370
+ try {
1371
+ const value = parseJson(raw, maxFrameBytes * 2);
1372
+ if (liveDevice) {
1373
+ touchDevice(liveDevice);
1374
+ if (value?.t === "ping") {
1375
+ safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
1376
+ return;
1377
+ }
1378
+ const frame = parsePayload(value, false, maxFrameBytes);
1379
+ if (!consumeRate(liveDevice.rate, frame.bytes)) {
1380
+ closeDevice(liveDevice, 4429, "rate limit");
1381
+ return;
1382
+ }
1383
+ const host = hosts.get(liveDevice.routeId);
1384
+ if (!host || !safeSend(
1385
+ host.socket,
1386
+ { t: "frame", channelId: liveDevice.channelId, payload: frame.payload },
1387
+ maxQueueBytes
1388
+ )) {
1389
+ metrics.droppedFrames += 1;
1390
+ closeDevice(liveDevice, 4408, "host unavailable or slow");
1391
+ return;
1392
+ }
1393
+ metrics.forwardedFrames += 1;
1394
+ metrics.forwardedBytes += frame.bytes;
1395
+ return;
1396
+ }
1397
+ if (liveHost) {
1398
+ touchHost(liveHost);
1399
+ if (value?.t === "ping") {
1400
+ safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
1401
+ return;
1402
+ }
1403
+ if (value?.t === "close-peer") {
1404
+ const channelId = safeId3(value.channelId, "channel id");
1405
+ if (!consumeRate(liveHost.rate, 1)) {
1406
+ closeHost(liveHost, 4429, "rate limit");
1407
+ return;
1408
+ }
1409
+ const device2 = devicesByChannel.get(channelId);
1410
+ if (device2?.routeId === liveHost.routeId) closeDevice(device2, 4400, "host closed channel");
1411
+ return;
1412
+ }
1413
+ const frame = parsePayload(value, true, maxFrameBytes);
1414
+ if (!consumeRate(liveHost.rate, frame.bytes)) {
1415
+ closeHost(liveHost, 4429, "rate limit");
1416
+ return;
1417
+ }
1418
+ const device = devicesByChannel.get(frame.channelId);
1419
+ if (!device || device.routeId !== liveHost.routeId) {
1420
+ metrics.droppedFrames += 1;
1421
+ return;
1422
+ }
1423
+ if (!safeSend(device.socket, { t: "frame", payload: frame.payload }, maxQueueBytes)) {
1424
+ metrics.droppedFrames += 1;
1425
+ closeDevice(device, 4408, "device backpressure");
1426
+ return;
1427
+ }
1428
+ metrics.forwardedFrames += 1;
1429
+ metrics.forwardedBytes += frame.bytes;
1430
+ }
1431
+ } catch {
1432
+ metrics.droppedFrames += 1;
1433
+ if (liveDevice) closeDevice(liveDevice, 4400, "invalid relay frame");
1434
+ else if (liveHost) closeHost(liveHost, 4400, "invalid relay frame");
1435
+ }
1436
+ });
1437
+ socket.once("close", () => {
1438
+ clearTimeout(handshakeTimer);
1439
+ if (liveDevice) closeDevice(liveDevice);
1440
+ if (liveHost) closeHost(liveHost);
1441
+ });
1442
+ socket.once("error", () => {
1443
+ });
1444
+ });
1445
+ });
1446
+ app.addHook("onClose", async () => {
1447
+ for (const host of [...hosts.values()]) closeHost(host, 1001, "relay shutting down");
1448
+ if (ownsStore) store.close();
1449
+ });
1450
+ return {
1451
+ app,
1452
+ store,
1453
+ ...accountStore ? { accountStore } : {},
1454
+ metrics: () => ({ ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size })
1455
+ };
1456
+ }
1457
+
1458
+ // src/data-dir.ts
1459
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1460
+ import { randomBytes as randomBytes4 } from "crypto";
1461
+ import { join } from "path";
1462
+ function resolveDataDir(env, exists = existsSync) {
1463
+ if (env.ROAMCODE_DATA_DIR) return env.ROAMCODE_DATA_DIR;
1464
+ if (env.REMOTE_CODER_DATA_DIR) return env.REMOTE_CODER_DATA_DIR;
1465
+ const pick = (next, legacy) => !exists(next) && exists(legacy) ? legacy : next;
1466
+ if (env.XDG_CONFIG_HOME)
1467
+ return pick(join(env.XDG_CONFIG_HOME, "roamcode"), join(env.XDG_CONFIG_HOME, "remote-coder"));
1468
+ if (env.HOME) return pick(join(env.HOME, ".config", "roamcode"), join(env.HOME, ".config", "remote-coder"));
1469
+ return pick(join(process.cwd(), ".roamcode"), join(process.cwd(), ".remote-coder"));
1470
+ }
1471
+ function ensureDataDir(dir) {
1472
+ mkdirSync(dir, { recursive: true, mode: 448 });
1473
+ }
1474
+
1475
+ // src/relay-start.ts
1476
+ function boundedInteger(value, fallback, minimum, maximum, field) {
1477
+ const parsed = value === void 0 ? fallback : Number(value);
1478
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`invalid ${field}`);
1479
+ return parsed;
1480
+ }
1481
+ function relayDataDir(env) {
1482
+ const configured = env.ROAMCODE_RELAY_DATA_DIR?.trim();
1483
+ return configured ? resolve(configured) : join2(resolveDataDir(env), "relay");
1484
+ }
1485
+ function relayOrigins(env) {
1486
+ return (env.ROAMCODE_RELAY_ALLOWED_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
1487
+ }
1488
+ function previousRootTokens(env) {
1489
+ return (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
1490
+ }
1491
+ function explicitBoolean(value, field) {
1492
+ if (value === void 0 || value === "" || value === "0" || value === "false") return false;
1493
+ if (value === "1" || value === "true") return true;
1494
+ throw new Error(`invalid ${field}`);
1495
+ }
1496
+ function secretValue(env, directKey, fileKey) {
1497
+ const direct = env[directKey]?.trim();
1498
+ const file = env[fileKey]?.trim();
1499
+ if (direct && file) throw new Error(`${directKey} and ${fileKey} are mutually exclusive`);
1500
+ if (direct) return direct;
1501
+ if (!file) return void 0;
1502
+ let contents;
1503
+ try {
1504
+ contents = readFileSync2(file, "utf8");
1505
+ } catch {
1506
+ throw new Error(`${fileKey} could not be read`);
1507
+ }
1508
+ if (Buffer.byteLength(contents) > 4096) throw new Error(`${fileKey} is too large`);
1509
+ return contents.trim();
1510
+ }
1511
+ async function startBlindRelay(env = process.env) {
1512
+ const rootToken = secretValue(env, "ROAMCODE_RELAY_ROOT_TOKEN", "ROAMCODE_RELAY_ROOT_TOKEN_FILE");
1513
+ if (!rootToken) throw new Error("ROAMCODE_RELAY_ROOT_TOKEN or ROAMCODE_RELAY_ROOT_TOKEN_FILE is required");
1514
+ const allowedOrigins = relayOrigins(env);
1515
+ const allowAnyOrigin = explicitBoolean(env.ROAMCODE_RELAY_ALLOW_ANY_ORIGIN, "relay allow-any-origin flag");
1516
+ if (env.NODE_ENV === "production" && allowedOrigins.length === 0 && !allowAnyOrigin) {
1517
+ throw new Error(
1518
+ "ROAMCODE_RELAY_ALLOWED_ORIGINS is required in production; explicitly set ROAMCODE_RELAY_ALLOW_ANY_ORIGIN=1 only for a reviewed deployment"
1519
+ );
1520
+ }
1521
+ const dataDir = relayDataDir(env);
1522
+ ensureDataDir(dataDir);
1523
+ const store = openRelayRouteStore({ dbPath: join2(dataDir, "routes.db") });
1524
+ if (store.mode !== "sqlite") {
1525
+ store.close();
1526
+ throw new Error("relay requires durable SQLite; rebuild better-sqlite3 before starting");
1527
+ }
1528
+ const accountsEnabled = explicitBoolean(env.ROAMCODE_RELAY_ACCOUNTS_ENABLED, "relay accounts-enabled flag");
1529
+ let accountStore;
1530
+ if (accountsEnabled) {
1531
+ accountStore = openRelayAccountStore({ dbPath: join2(dataDir, "accounts.db") });
1532
+ if (accountStore.mode !== "sqlite") {
1533
+ accountStore.close();
1534
+ store.close();
1535
+ throw new Error("relay accounts require durable SQLite; rebuild better-sqlite3 before starting");
1536
+ }
1537
+ }
1538
+ let relay;
1539
+ try {
1540
+ relay = createBlindRelayServer({
1541
+ rootToken,
1542
+ previousRootTokens: previousRootTokens(env),
1543
+ store,
1544
+ ...accountStore ? { accountStore } : {},
1545
+ allowedOrigins,
1546
+ handshakeTimeoutMs: boundedInteger(
1547
+ env.ROAMCODE_RELAY_HANDSHAKE_TIMEOUT_MS,
1548
+ 5e3,
1549
+ 1e3,
1550
+ 3e4,
1551
+ "relay handshake timeout"
1552
+ ),
1553
+ idleTimeoutMs: boundedInteger(
1554
+ env.ROAMCODE_RELAY_IDLE_TIMEOUT_MS,
1555
+ 12e4,
1556
+ 1e4,
1557
+ 36e5,
1558
+ "relay idle timeout"
1559
+ ),
1560
+ maxFrameBytes: boundedInteger(
1561
+ env.ROAMCODE_RELAY_MAX_FRAME_BYTES,
1562
+ 15e5,
1563
+ 1024,
1564
+ 16 * 1024 * 1024,
1565
+ "relay frame limit"
1566
+ ),
1567
+ maxQueueBytes: boundedInteger(
1568
+ env.ROAMCODE_RELAY_MAX_QUEUE_BYTES,
1569
+ 4e6,
1570
+ 1024,
1571
+ 64 * 1024 * 1024,
1572
+ "relay queue limit"
1573
+ ),
1574
+ maxConnectionsPerRoute: boundedInteger(
1575
+ env.ROAMCODE_RELAY_MAX_CONNECTIONS_PER_ROUTE,
1576
+ 64,
1577
+ 1,
1578
+ 1e4,
1579
+ "relay route connection limit"
1580
+ ),
1581
+ maxBytesPerMinute: boundedInteger(
1582
+ env.ROAMCODE_RELAY_MAX_BYTES_PER_MINUTE,
1583
+ 64 * 1024 * 1024,
1584
+ 1024,
1585
+ 1024 * 1024 * 1024,
1586
+ "relay byte rate"
1587
+ ),
1588
+ maxMessagesPerMinute: boundedInteger(
1589
+ env.ROAMCODE_RELAY_MAX_MESSAGES_PER_MINUTE,
1590
+ 12e3,
1591
+ 10,
1592
+ 1e6,
1593
+ "relay message rate"
1594
+ )
1595
+ });
1596
+ } catch (error) {
1597
+ accountStore?.close();
1598
+ store.close();
1599
+ throw error;
1600
+ }
1601
+ relay.app.addHook("onClose", async () => {
1602
+ accountStore?.close();
1603
+ store.close();
1604
+ });
1605
+ const host = env.ROAMCODE_RELAY_BIND?.trim() || "127.0.0.1";
1606
+ const port = boundedInteger(env.ROAMCODE_RELAY_PORT, 4281, 0, 65535, "relay port");
1607
+ try {
1608
+ const url = await relay.app.listen({ host, port });
1609
+ return { ...relay, url };
1610
+ } catch (error) {
1611
+ accountStore?.close();
1612
+ store.close();
1613
+ throw error;
1614
+ }
1615
+ }
1616
+ function isRelayDirectExecution(moduleUrl, argv1, embeddedContainerBuild2 = false) {
1617
+ if (embeddedContainerBuild2) return false;
1618
+ if (!argv1) return false;
1619
+ try {
1620
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argv1);
1621
+ } catch {
1622
+ return moduleUrl === pathToFileURL(argv1).href;
1623
+ }
1624
+ }
1625
+ var embeddedContainerBuild = typeof __RELAY_CONTAINER_BUILD__ === "boolean" && __RELAY_CONTAINER_BUILD__ === true;
1626
+ if (isRelayDirectExecution(import.meta.url, process.argv[1], embeddedContainerBuild)) {
1627
+ void startBlindRelay().then((relay) => {
1628
+ process.stdout.write(`RoamCode blind relay is listening at ${relay.url}
1629
+ `);
1630
+ const shutdown = () => relay.app.close().finally(() => process.exit(0));
1631
+ process.once("SIGINT", shutdown);
1632
+ process.once("SIGTERM", shutdown);
1633
+ }).catch((error) => {
1634
+ process.stderr.write(`roamcode relay failed to start: ${error.message}
1635
+ `);
1636
+ process.exitCode = 1;
1637
+ });
1638
+ }
1639
+ export {
1640
+ isRelayDirectExecution,
1641
+ startBlindRelay
1642
+ };