@roamcode.ai/server 1.1.0 → 1.3.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.
@@ -1,5 +1,14 @@
1
1
  // src/relay-start.ts
2
- import { readFileSync as readFileSync2, realpathSync } from "fs";
2
+ import {
3
+ closeSync as closeSync2,
4
+ constants as constants2,
5
+ fstatSync as fstatSync2,
6
+ lstatSync as lstatSync2,
7
+ openSync as openSync2,
8
+ readFileSync as readFileSync2,
9
+ readdirSync,
10
+ realpathSync
11
+ } from "fs";
3
12
  import { join as join2, resolve } from "path";
4
13
  import { fileURLToPath, pathToFileURL } from "url";
5
14
 
@@ -25,6 +34,7 @@ var PLAN_DEFAULTS = {
25
34
  team: { maxRoutes: 25, maxDevicesPerRoute: 64 },
26
35
  enterprise: { maxRoutes: 500, maxDevicesPerRoute: 500 }
27
36
  };
37
+ var UNSAFE_TERMINAL_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
28
38
  function safeId(value) {
29
39
  if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
30
40
  throw new Error("invalid relay account id");
@@ -34,7 +44,7 @@ function safeId(value) {
34
44
  function safeLabel(value) {
35
45
  if (typeof value !== "string") throw new Error("relay account label is required");
36
46
  const label = value.trim().replace(/\s+/g, " ");
37
- if (!label || label.length > 120 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(label)) {
47
+ if (!label || label.length > 120 || UNSAFE_TERMINAL_TEXT.test(label)) {
38
48
  throw new Error("invalid relay account label");
39
49
  }
40
50
  return label;
@@ -63,6 +73,33 @@ function safeHash(value) {
63
73
  }
64
74
  return value;
65
75
  }
76
+ function safeLookup(value) {
77
+ if (typeof value !== "string" || !/^lookup:[A-Za-z0-9_-]{43}$/.test(value)) {
78
+ throw new Error("invalid relay account credential lookup");
79
+ }
80
+ return value;
81
+ }
82
+ function credentialMaterial(input) {
83
+ if (typeof input === "string") {
84
+ return {
85
+ credentialHash: relayAccountCredentialHash(input),
86
+ credentialLookup: relayAccountCredentialLookup(input)
87
+ };
88
+ }
89
+ if ("credential" in input && input.credential !== void 0) {
90
+ if (input.credentialHash !== void 0 || input.credentialLookup !== void 0) {
91
+ throw new Error("relay account credential must be raw or pre-hashed, not both");
92
+ }
93
+ return {
94
+ credentialHash: relayAccountCredentialHash(input.credential),
95
+ credentialLookup: relayAccountCredentialLookup(input.credential)
96
+ };
97
+ }
98
+ return {
99
+ credentialHash: safeHash(input.credentialHash),
100
+ credentialLookup: safeLookup(input.credentialLookup)
101
+ };
102
+ }
66
103
  function hashMatches(expected, credential) {
67
104
  try {
68
105
  const left = Buffer.from(safeHash(expected));
@@ -72,6 +109,18 @@ function hashMatches(expected, credential) {
72
109
  return false;
73
110
  }
74
111
  }
112
+ function storedCredentialMatches(expectedHash, expectedLookup, credential) {
113
+ try {
114
+ const actual = credentialMaterial(credential);
115
+ const expectedHashBytes = Buffer.from(safeHash(expectedHash));
116
+ const actualHashBytes = Buffer.from(actual.credentialHash);
117
+ const expectedLookupBytes = Buffer.from(safeLookup(expectedLookup));
118
+ const actualLookupBytes = Buffer.from(actual.credentialLookup);
119
+ return expectedHashBytes.length === actualHashBytes.length && expectedLookupBytes.length === actualLookupBytes.length && timingSafeEqual(expectedHashBytes, actualHashBytes) && timingSafeEqual(expectedLookupBytes, actualLookupBytes);
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
75
124
  function safePlan(value) {
76
125
  if (value !== "free" && value !== "team" && value !== "enterprise") throw new Error("invalid relay account plan");
77
126
  return value;
@@ -90,20 +139,40 @@ function safeStatus(value) {
90
139
  return value;
91
140
  }
92
141
  function clone(record) {
93
- return { ...record };
142
+ return {
143
+ id: record.id,
144
+ label: record.label,
145
+ status: record.status,
146
+ plan: record.plan,
147
+ maxRoutes: record.maxRoutes,
148
+ maxDevicesPerRoute: record.maxDevicesPerRoute,
149
+ revision: record.revision,
150
+ createdAt: record.createdAt,
151
+ updatedAt: record.updatedAt
152
+ };
94
153
  }
95
154
  function createMemoryStore(options) {
96
155
  const accounts = /* @__PURE__ */ new Map();
97
156
  const lookup = /* @__PURE__ */ new Map();
98
157
  const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
158
+ const verifyCredential = (credential) => {
159
+ let credentialLookup;
160
+ try {
161
+ credentialLookup = relayAccountCredentialLookup(credential);
162
+ } catch {
163
+ return;
164
+ }
165
+ const id = lookup.get(credentialLookup);
166
+ const record = id ? accounts.get(id) : void 0;
167
+ return record && record.status !== "deleted" && hashMatches(record.credentialHash, credential) ? clone(record) : void 0;
168
+ };
99
169
  return {
100
170
  mode: "memory",
101
171
  createAccount(input, now = Date.now()) {
102
- const id = safeId(generateAccountId());
172
+ const id = safeId(input.id ?? generateAccountId());
103
173
  if (accounts.has(id)) throw new Error("relay account already exists");
104
174
  const plan = safePlan(input.plan ?? "free");
105
- const credentialHash = relayAccountCredentialHash(input.credential);
106
- const credentialLookup = relayAccountCredentialLookup(input.credential);
175
+ const { credentialHash, credentialLookup } = credentialMaterial(input);
107
176
  if (lookup.has(credentialLookup)) throw new Error("relay account credential already exists");
108
177
  const defaults = PLAN_DEFAULTS[plan];
109
178
  const record = {
@@ -135,16 +204,18 @@ function createMemoryStore(options) {
135
204
  listAccounts({ includeDeleted = false } = {}) {
136
205
  return [...accounts.values()].filter((record) => includeDeleted || record.status !== "deleted").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(clone);
137
206
  },
207
+ verifyCredential,
138
208
  authenticate(credential) {
139
- let credentialLookup;
209
+ const record = verifyCredential(credential);
210
+ return record?.status === "active" ? record : void 0;
211
+ },
212
+ credentialMatches(id, credential) {
140
213
  try {
141
- credentialLookup = relayAccountCredentialLookup(credential);
214
+ const record = accounts.get(safeId(id));
215
+ return !!record && storedCredentialMatches(record.credentialHash, record.credentialLookup, credential);
142
216
  } catch {
143
- return void 0;
217
+ return false;
144
218
  }
145
- const id = lookup.get(credentialLookup);
146
- const record = id ? accounts.get(id) : void 0;
147
- return record?.status === "active" && hashMatches(record.credentialHash, credential) ? clone(record) : void 0;
148
219
  },
149
220
  updateAccount(id, input, expectedRevision, now = Date.now()) {
150
221
  const current = accounts.get(safeId(id));
@@ -182,8 +253,7 @@ function createMemoryStore(options) {
182
253
  if (!current) return void 0;
183
254
  if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone(current));
184
255
  if (current.status === "deleted") throw new Error("deleted relay account is immutable");
185
- const credentialHash = relayAccountCredentialHash(credential);
186
- const credentialLookup = relayAccountCredentialLookup(credential);
256
+ const { credentialHash, credentialLookup } = credentialMaterial(credential);
187
257
  const owner = lookup.get(credentialLookup);
188
258
  if (owner && owner !== current.id) throw new Error("relay account credential already exists");
189
259
  lookup.delete(current.credentialLookup);
@@ -204,6 +274,17 @@ function createMemoryStore(options) {
204
274
  }
205
275
  };
206
276
  }
277
+ function normalizeSqliteAccountConstraint(error) {
278
+ const code = error?.code;
279
+ const message = error instanceof Error ? error.message : "";
280
+ if (code === "SQLITE_CONSTRAINT_UNIQUE" && message.includes("relay_accounts.credential_lookup")) {
281
+ throw new Error("relay account credential already exists");
282
+ }
283
+ if (code === "SQLITE_CONSTRAINT_UNIQUE" && message.includes("relay_accounts.id")) {
284
+ throw new Error("relay account already exists");
285
+ }
286
+ throw error;
287
+ }
207
288
  function fromRow(row) {
208
289
  return {
209
290
  id: row.id,
@@ -249,10 +330,21 @@ function openRelayAccountStore(options) {
249
330
  `);
250
331
  const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
251
332
  const rowById = (id) => db.prepare("SELECT * FROM relay_accounts WHERE id = ?").get(id);
333
+ const verifyCredential = (credential) => {
334
+ let credentialLookup;
335
+ try {
336
+ credentialLookup = relayAccountCredentialLookup(credential);
337
+ } catch {
338
+ return;
339
+ }
340
+ const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status != 'deleted'").get(credentialLookup);
341
+ return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
342
+ };
252
343
  const createAccount = db.transaction((input, now) => {
253
- const id = safeId(generateAccountId());
344
+ const id = safeId(input.id ?? generateAccountId());
254
345
  if (rowById(id)) throw new Error("relay account already exists");
255
346
  const plan = safePlan(input.plan ?? "free");
347
+ const { credentialHash, credentialLookup } = credentialMaterial(input);
256
348
  const defaults = PLAN_DEFAULTS[plan];
257
349
  const record = {
258
350
  id,
@@ -265,24 +357,28 @@ function openRelayAccountStore(options) {
265
357
  createdAt: now,
266
358
  updatedAt: now
267
359
  };
268
- db.prepare(
269
- `INSERT INTO relay_accounts
270
- (id, label, status, plan, max_routes, max_devices_per_route, revision, credential_hash,
271
- credential_lookup, created_at, updated_at)
272
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
273
- ).run(
274
- record.id,
275
- record.label,
276
- record.status,
277
- record.plan,
278
- record.maxRoutes,
279
- record.maxDevicesPerRoute,
280
- record.revision,
281
- relayAccountCredentialHash(input.credential),
282
- relayAccountCredentialLookup(input.credential),
283
- record.createdAt,
284
- record.updatedAt
285
- );
360
+ try {
361
+ db.prepare(
362
+ `INSERT INTO relay_accounts
363
+ (id, label, status, plan, max_routes, max_devices_per_route, revision, credential_hash,
364
+ credential_lookup, created_at, updated_at)
365
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
366
+ ).run(
367
+ record.id,
368
+ record.label,
369
+ record.status,
370
+ record.plan,
371
+ record.maxRoutes,
372
+ record.maxDevicesPerRoute,
373
+ record.revision,
374
+ credentialHash,
375
+ credentialLookup,
376
+ record.createdAt,
377
+ record.updatedAt
378
+ );
379
+ } catch (error) {
380
+ normalizeSqliteAccountConstraint(error);
381
+ }
286
382
  return record;
287
383
  });
288
384
  return {
@@ -299,19 +395,22 @@ function openRelayAccountStore(options) {
299
395
  ).all();
300
396
  return rows.map(fromRow);
301
397
  },
398
+ verifyCredential,
302
399
  authenticate(credential) {
303
- let credentialLookup;
400
+ const record = verifyCredential(credential);
401
+ return record?.status === "active" ? record : void 0;
402
+ },
403
+ credentialMatches(id, credential) {
304
404
  try {
305
- credentialLookup = relayAccountCredentialLookup(credential);
405
+ const row = rowById(safeId(id));
406
+ return !!row && storedCredentialMatches(row.credential_hash, row.credential_lookup, credential);
306
407
  } catch {
307
- return void 0;
408
+ return false;
308
409
  }
309
- const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status = 'active'").get(credentialLookup);
310
- return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
311
410
  },
312
411
  updateAccount(id, input, expectedRevision, now = Date.now()) {
313
- const safeAccountId = safeId(id);
314
- const currentRow = rowById(safeAccountId);
412
+ const safeAccountId2 = safeId(id);
413
+ const currentRow = rowById(safeAccountId2);
315
414
  if (!currentRow) return void 0;
316
415
  const current = fromRow(currentRow);
317
416
  if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
@@ -353,35 +452,35 @@ function openRelayAccountStore(options) {
353
452
  expectedRevision
354
453
  );
355
454
  if (result.changes !== 1) {
356
- const latest = rowById(safeAccountId);
455
+ const latest = rowById(safeAccountId2);
357
456
  if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
358
457
  return void 0;
359
458
  }
360
459
  return next;
361
460
  },
362
461
  rotateCredential(id, credential, expectedRevision, now = Date.now()) {
363
- const safeAccountId = safeId(id);
364
- const currentRow = rowById(safeAccountId);
462
+ const safeAccountId2 = safeId(id);
463
+ const currentRow = rowById(safeAccountId2);
365
464
  if (!currentRow) return void 0;
366
465
  const current = fromRow(currentRow);
367
466
  if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
368
467
  if (current.status === "deleted") throw new Error("deleted relay account is immutable");
369
- const result = db.prepare(
370
- `UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
371
- updated_at = ? WHERE id = ? AND revision = ?`
372
- ).run(
373
- relayAccountCredentialHash(credential),
374
- relayAccountCredentialLookup(credential),
375
- now,
376
- safeAccountId,
377
- expectedRevision
378
- );
468
+ const { credentialHash, credentialLookup } = credentialMaterial(credential);
469
+ let result;
470
+ try {
471
+ result = db.prepare(
472
+ `UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
473
+ updated_at = ? WHERE id = ? AND revision = ?`
474
+ ).run(credentialHash, credentialLookup, now, safeAccountId2, expectedRevision);
475
+ } catch (error) {
476
+ normalizeSqliteAccountConstraint(error);
477
+ }
379
478
  if (result.changes !== 1) {
380
- const latest = rowById(safeAccountId);
479
+ const latest = rowById(safeAccountId2);
381
480
  if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
382
481
  return void 0;
383
482
  }
384
- return fromRow(rowById(safeAccountId));
483
+ return fromRow(rowById(safeAccountId2));
385
484
  },
386
485
  close: () => db.close()
387
486
  };
@@ -391,6 +490,7 @@ function openRelayAccountStore(options) {
391
490
  import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
392
491
  import { createRequire as createRequire2 } from "module";
393
492
  var require3 = createRequire2(import.meta.url);
493
+ var UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
394
494
  function safeId2(value, field) {
395
495
  if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
396
496
  return value;
@@ -404,7 +504,7 @@ function safeOwnerAccountId(value) {
404
504
  function safeLabel2(value) {
405
505
  if (typeof value !== "string") throw new Error("relay route label is required");
406
506
  const normalized = value.trim().replace(/\s+/g, " ");
407
- if (!normalized || normalized.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(normalized)) {
507
+ if (!normalized || normalized.length > 80 || UNSAFE_DISPLAY_TEXT.test(normalized)) {
408
508
  throw new Error("invalid relay route label");
409
509
  }
410
510
  return normalized;
@@ -446,22 +546,72 @@ function cloneRoute(route) {
446
546
  return { ...route };
447
547
  }
448
548
  function cloneDevice(device) {
449
- return { ...device };
549
+ return {
550
+ routeId: device.routeId,
551
+ deviceId: device.deviceId,
552
+ credentialHash: device.credentialHash,
553
+ createdAt: device.createdAt,
554
+ updatedAt: device.updatedAt,
555
+ ...device.expiresAt === void 0 ? {} : { expiresAt: device.expiresAt }
556
+ };
557
+ }
558
+ function credentialOverlap(current, credentialHash, expiresAt, now) {
559
+ if (!current || expiresAt !== void 0) return {};
560
+ if (current.expiresAt !== void 0) {
561
+ if (current.credentialHash === credentialHash) {
562
+ throw new Error("relay bootstrap promotion must rotate the device credential");
563
+ }
564
+ return {
565
+ previousCredentialHash: current.credentialHash,
566
+ previousCredentialExpiresAt: current.expiresAt
567
+ };
568
+ }
569
+ if (current.previousCredentialHash !== void 0 && current.previousCredentialExpiresAt !== void 0 && current.previousCredentialExpiresAt >= now) {
570
+ if (current.previousCredentialHash === credentialHash) {
571
+ throw new Error("relay bootstrap credential cannot become durable");
572
+ }
573
+ return {
574
+ previousCredentialHash: current.previousCredentialHash,
575
+ previousCredentialExpiresAt: current.previousCredentialExpiresAt
576
+ };
577
+ }
578
+ return {};
450
579
  }
451
580
  function createMemoryStore2(options) {
452
581
  const routes = /* @__PURE__ */ new Map();
453
582
  const devices = /* @__PURE__ */ new Map();
454
583
  const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
455
584
  const deviceKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
456
- 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) => ({
457
- id: route.id,
458
- label: route.label,
459
- deviceCount: [...devices.values()].filter(
460
- (device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
461
- ).length,
462
- createdAt: route.createdAt,
463
- updatedAt: route.updatedAt
464
- }));
585
+ const pruneExpiredDevices = (now) => {
586
+ for (const [key, device] of devices) {
587
+ if (device.expiresAt !== void 0 && device.expiresAt < now) devices.delete(key);
588
+ else if (device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt < now) {
589
+ delete device.previousCredentialHash;
590
+ delete device.previousCredentialExpiresAt;
591
+ }
592
+ }
593
+ };
594
+ const liveDevice = (routeId, deviceId, now) => {
595
+ const key = deviceKey(routeId, deviceId);
596
+ const device = devices.get(key);
597
+ if (device?.expiresAt !== void 0 && device.expiresAt < now) {
598
+ devices.delete(key);
599
+ return;
600
+ }
601
+ return device;
602
+ };
603
+ const listRoutes = (now, ownerAccountId) => {
604
+ pruneExpiredDevices(now);
605
+ return [...routes.values()].filter((route) => ownerAccountId === void 0 || route.ownerAccountId === ownerAccountId).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map((route) => ({
606
+ id: route.id,
607
+ label: route.label,
608
+ deviceCount: [...devices.values()].filter(
609
+ (device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
610
+ ).length,
611
+ createdAt: route.createdAt,
612
+ updatedAt: route.updatedAt
613
+ }));
614
+ };
465
615
  return {
466
616
  mode: "memory",
467
617
  createRoute(input, now = Date.now()) {
@@ -487,6 +637,7 @@ function createMemoryStore2(options) {
487
637
  return listRoutes(now, safeOwnerAccountId(ownerAccountId));
488
638
  },
489
639
  countDevices(routeId, now = Date.now()) {
640
+ pruneExpiredDevices(now);
490
641
  const safeRouteId = safeId2(routeId, "route id");
491
642
  return [...devices.values()].filter(
492
643
  (device) => device.routeId === safeRouteId && (device.expiresAt === void 0 || device.expiresAt >= now)
@@ -510,18 +661,22 @@ function createMemoryStore2(options) {
510
661
  return !!route && hashMatches2(route.hostCredentialHash, credential);
511
662
  },
512
663
  putDevice(input, now = Date.now()) {
664
+ pruneExpiredDevices(now);
513
665
  const routeId = safeId2(input.routeId, "route id");
514
666
  if (!routes.has(routeId)) throw new Error("relay route not found");
515
667
  const deviceId = safeId2(input.deviceId, "device id");
516
668
  const key = deviceKey(routeId, deviceId);
517
669
  const current = devices.get(key);
670
+ const credentialHash = safeCredentialHash(input.credentialHash);
671
+ const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
518
672
  const device = {
519
673
  routeId,
520
674
  deviceId,
521
- credentialHash: safeCredentialHash(input.credentialHash),
675
+ credentialHash,
522
676
  createdAt: current?.createdAt ?? now,
523
677
  updatedAt: now,
524
- ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
678
+ ...expiresAt === void 0 ? {} : { expiresAt },
679
+ ...credentialOverlap(current, credentialHash, expiresAt, now)
525
680
  };
526
681
  devices.set(key, device);
527
682
  const route = routes.get(routeId);
@@ -529,12 +684,12 @@ function createMemoryStore2(options) {
529
684
  return cloneDevice(device);
530
685
  },
531
686
  getDevice(routeId, deviceId, now = Date.now()) {
532
- const device = devices.get(deviceKey(safeId2(routeId, "route id"), safeId2(deviceId, "device id")));
533
- return device && (device.expiresAt === void 0 || device.expiresAt >= now) ? cloneDevice(device) : void 0;
687
+ const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
688
+ return device ? cloneDevice(device) : void 0;
534
689
  },
535
690
  authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
536
- const device = devices.get(deviceKey(safeId2(routeId, "route id"), safeId2(deviceId, "device id")));
537
- return !!device && (device.expiresAt === void 0 || device.expiresAt >= now) && hashMatches2(device.credentialHash, credential);
691
+ const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
692
+ return !!device && (hashMatches2(device.credentialHash, credential) || device.previousCredentialHash !== void 0 && device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt >= now && hashMatches2(device.previousCredentialHash, credential));
538
693
  },
539
694
  revokeDevice(routeId, deviceId) {
540
695
  const safeRouteId = safeId2(routeId, "route id");
@@ -596,6 +751,8 @@ function openRelayRouteStore(options) {
596
751
  route_id TEXT NOT NULL REFERENCES relay_routes(id) ON DELETE CASCADE,
597
752
  device_id TEXT NOT NULL,
598
753
  credential_hash TEXT NOT NULL,
754
+ previous_credential_hash TEXT,
755
+ previous_credential_expires_at INTEGER,
599
756
  created_at INTEGER NOT NULL,
600
757
  updated_at INTEGER NOT NULL,
601
758
  expires_at INTEGER,
@@ -606,6 +763,12 @@ function openRelayRouteStore(options) {
606
763
  if (!relayDeviceColumns.some((column) => column.name === "expires_at")) {
607
764
  db.exec("ALTER TABLE relay_route_devices ADD COLUMN expires_at INTEGER");
608
765
  }
766
+ if (!relayDeviceColumns.some((column) => column.name === "previous_credential_hash")) {
767
+ db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_hash TEXT");
768
+ }
769
+ if (!relayDeviceColumns.some((column) => column.name === "previous_credential_expires_at")) {
770
+ db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_expires_at INTEGER");
771
+ }
609
772
  const relayRouteColumns = db.prepare("PRAGMA table_info(relay_routes)").all();
610
773
  if (!relayRouteColumns.some((column) => column.name === "owner_account_id")) {
611
774
  db.exec("ALTER TABLE relay_routes ADD COLUMN owner_account_id TEXT");
@@ -614,6 +777,21 @@ function openRelayRouteStore(options) {
614
777
  const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
615
778
  const getRoute = (id) => db.prepare("SELECT * FROM relay_routes WHERE id = ?").get(id);
616
779
  const getDevice = (routeId, deviceId) => db.prepare("SELECT * FROM relay_route_devices WHERE route_id = ? AND device_id = ?").get(routeId, deviceId);
780
+ const pruneExpiredDevices = (now) => {
781
+ db.prepare("DELETE FROM relay_route_devices WHERE expires_at IS NOT NULL AND expires_at < ?").run(now);
782
+ db.prepare(
783
+ `UPDATE relay_route_devices SET previous_credential_hash = NULL, previous_credential_expires_at = NULL
784
+ WHERE previous_credential_expires_at IS NOT NULL AND previous_credential_expires_at < ?`
785
+ ).run(now);
786
+ };
787
+ const liveDevice = (routeId, deviceId, now) => {
788
+ const row = getDevice(routeId, deviceId);
789
+ if (row?.expires_at !== null && row?.expires_at !== void 0 && row.expires_at < now) {
790
+ db.prepare("DELETE FROM relay_route_devices WHERE route_id = ? AND device_id = ?").run(routeId, deviceId);
791
+ return;
792
+ }
793
+ return row;
794
+ };
617
795
  const createRoute = db.transaction(
618
796
  (input, now) => {
619
797
  const id = safeId2(input.id ?? generateRouteId(), "route id");
@@ -644,25 +822,44 @@ function openRelayRouteStore(options) {
644
822
  (input, now) => {
645
823
  const routeId = safeId2(input.routeId, "route id");
646
824
  if (!getRoute(routeId)) throw new Error("relay route not found");
825
+ pruneExpiredDevices(now);
647
826
  const deviceId = safeId2(input.deviceId, "device id");
648
827
  const current = getDevice(routeId, deviceId);
828
+ const credentialHash = safeCredentialHash(input.credentialHash);
829
+ const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
830
+ const overlap = credentialOverlap(
831
+ current ? {
832
+ ...deviceFromRow(current),
833
+ ...current.previous_credential_hash === null || current.previous_credential_hash === void 0 ? {} : { previousCredentialHash: current.previous_credential_hash },
834
+ ...current.previous_credential_expires_at === null || current.previous_credential_expires_at === void 0 ? {} : { previousCredentialExpiresAt: current.previous_credential_expires_at }
835
+ } : void 0,
836
+ credentialHash,
837
+ expiresAt,
838
+ now
839
+ );
649
840
  const device = {
650
841
  routeId,
651
842
  deviceId,
652
- credentialHash: safeCredentialHash(input.credentialHash),
843
+ credentialHash,
653
844
  createdAt: current?.created_at ?? now,
654
845
  updatedAt: now,
655
- ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
846
+ ...expiresAt === void 0 ? {} : { expiresAt }
656
847
  };
657
848
  db.prepare(
658
- `INSERT INTO relay_route_devices (route_id, device_id, credential_hash, created_at, updated_at, expires_at)
659
- VALUES (?, ?, ?, ?, ?, ?)
849
+ `INSERT INTO relay_route_devices
850
+ (route_id, device_id, credential_hash, previous_credential_hash, previous_credential_expires_at,
851
+ created_at, updated_at, expires_at)
852
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
660
853
  ON CONFLICT(route_id, device_id) DO UPDATE SET credential_hash = excluded.credential_hash,
854
+ previous_credential_hash = excluded.previous_credential_hash,
855
+ previous_credential_expires_at = excluded.previous_credential_expires_at,
661
856
  updated_at = excluded.updated_at, expires_at = excluded.expires_at`
662
857
  ).run(
663
858
  device.routeId,
664
859
  device.deviceId,
665
860
  device.credentialHash,
861
+ overlap.previousCredentialHash ?? null,
862
+ overlap.previousCredentialExpiresAt ?? null,
666
863
  device.createdAt,
667
864
  device.updatedAt,
668
865
  device.expiresAt ?? null
@@ -671,19 +868,22 @@ function openRelayRouteStore(options) {
671
868
  return device;
672
869
  }
673
870
  );
674
- const listRoutes = (now, ownerAccountId) => db.prepare(
675
- `SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
871
+ const listRoutes = (now, ownerAccountId) => {
872
+ pruneExpiredDevices(now);
873
+ return db.prepare(
874
+ `SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
676
875
  FROM relay_routes r LEFT JOIN relay_route_devices d ON d.route_id = r.id
677
876
  AND (d.expires_at IS NULL OR d.expires_at >= ?)
678
877
  ${ownerAccountId === void 0 ? "" : "WHERE r.owner_account_id = ?"}
679
878
  GROUP BY r.id ORDER BY r.created_at, r.id`
680
- ).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
681
- id: row.id,
682
- label: row.label,
683
- deviceCount: row.device_count,
684
- createdAt: row.created_at,
685
- updatedAt: row.updated_at
686
- }));
879
+ ).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
880
+ id: row.id,
881
+ label: row.label,
882
+ deviceCount: row.device_count,
883
+ createdAt: row.created_at,
884
+ updatedAt: row.updated_at
885
+ }));
886
+ };
687
887
  return {
688
888
  mode: "sqlite",
689
889
  createRoute: (input, now = Date.now()) => cloneRoute(createRoute(input, now)),
@@ -694,6 +894,7 @@ function openRelayRouteStore(options) {
694
894
  listRoutes: (now = Date.now()) => listRoutes(now),
695
895
  listRoutesByOwner: (ownerAccountId, now = Date.now()) => listRoutes(now, ownerAccountId),
696
896
  countDevices(routeId, now = Date.now()) {
897
+ pruneExpiredDevices(now);
697
898
  const row = db.prepare(
698
899
  `SELECT COUNT(*) AS count FROM relay_route_devices
699
900
  WHERE route_id = ? AND (expires_at IS NULL OR expires_at >= ?)`
@@ -712,12 +913,12 @@ function openRelayRouteStore(options) {
712
913
  },
713
914
  putDevice: (input, now = Date.now()) => cloneDevice(putDevice(input, now)),
714
915
  getDevice(routeId, deviceId, now = Date.now()) {
715
- const row = getDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"));
716
- return row && (row.expires_at === null || row.expires_at === void 0 || row.expires_at >= now) ? deviceFromRow(row) : void 0;
916
+ const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
917
+ return row ? deviceFromRow(row) : void 0;
717
918
  },
718
919
  authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
719
- const row = getDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"));
720
- return !!row && (row.expires_at === null || row.expires_at === void 0 || row.expires_at >= now) && hashMatches2(row.credential_hash, credential);
920
+ const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
921
+ return !!row && (hashMatches2(row.credential_hash, credential) || row.previous_credential_hash !== null && row.previous_credential_hash !== void 0 && row.previous_credential_expires_at !== null && row.previous_credential_expires_at !== void 0 && row.previous_credential_expires_at >= now && hashMatches2(row.previous_credential_hash, credential));
721
922
  },
722
923
  revokeDevice(routeId, deviceId) {
723
924
  const safeRouteId = safeId2(routeId, "route id");
@@ -734,9 +935,11 @@ function openRelayRouteStore(options) {
734
935
  var BLIND_RELAY_PROTOCOL_VERSION = 1;
735
936
  var BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES = 15e5;
736
937
  var BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES = 4e6;
938
+ var BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS = 1024;
737
939
  var BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 64;
738
940
  var BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE = 64 * 1024 * 1024;
739
941
  var BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE = 12e3;
942
+ var UNSAFE_DISPLAY_TEXT2 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
740
943
  function safeToken(value, field) {
741
944
  if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
742
945
  throw new Error(`invalid relay ${field}`);
@@ -756,7 +959,7 @@ function safeId3(value, field) {
756
959
  function safeLabel3(value) {
757
960
  if (typeof value !== "string") throw new Error("relay route label is required");
758
961
  const label = value.trim().replace(/\s+/g, " ");
759
- if (!label || label.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(label)) throw new Error("invalid relay route label");
962
+ if (!label || label.length > 80 || UNSAFE_DISPLAY_TEXT2.test(label)) throw new Error("invalid relay route label");
760
963
  return label;
761
964
  }
762
965
  function safeExpiry2(value, now) {
@@ -770,6 +973,59 @@ function safeRevision(value) {
770
973
  if (!Number.isSafeInteger(value) || value < 1) throw new Error("invalid relay account revision");
771
974
  return value;
772
975
  }
976
+ function safeAccountId(value) {
977
+ if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
978
+ throw new Error("invalid relay account id");
979
+ }
980
+ return value;
981
+ }
982
+ function safeAccountLabel(value) {
983
+ if (typeof value !== "string") throw new Error("relay account label is required");
984
+ const label = value.trim().replace(/\s+/g, " ");
985
+ if (!label || label.length > 120 || UNSAFE_DISPLAY_TEXT2.test(label)) throw new Error("invalid relay account label");
986
+ return label;
987
+ }
988
+ function safeAccountPlan(value) {
989
+ if (value !== "free" && value !== "team" && value !== "enterprise") {
990
+ throw new Error("invalid relay account plan");
991
+ }
992
+ return value;
993
+ }
994
+ function safeAccountLimit(value, maximum) {
995
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
996
+ throw new Error("invalid relay account limit");
997
+ }
998
+ return value;
999
+ }
1000
+ function strictInternalBody(value, allowedFields) {
1001
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid internal request body");
1002
+ const body = value;
1003
+ if (Object.keys(body).some((field) => !allowedFields.includes(field))) {
1004
+ throw new Error("unknown internal request field");
1005
+ }
1006
+ return body;
1007
+ }
1008
+ function safeCredentialLookup(value) {
1009
+ if (typeof value !== "string" || !/^lookup:[A-Za-z0-9_-]{43}$/.test(value)) {
1010
+ throw new Error("invalid relay credential lookup");
1011
+ }
1012
+ return value;
1013
+ }
1014
+ function safeAccountCredentialMaterial(body) {
1015
+ if (!body || body.credential !== void 0 || body.accountCredential !== void 0) {
1016
+ throw new Error("client-hashed account credential material is required");
1017
+ }
1018
+ return {
1019
+ credentialHash: safeCredentialHash2(body.credentialHash),
1020
+ credentialLookup: safeCredentialLookup(body.credentialLookup)
1021
+ };
1022
+ }
1023
+ function safeRouteCredentialHash(body, field = "credentialHash") {
1024
+ if (!body || body.credential !== void 0 || body.hostCredential !== void 0) {
1025
+ throw new Error("client-hashed route credential material is required");
1026
+ }
1027
+ return safeCredentialHash2(body[field]);
1028
+ }
773
1029
  function bearer(request) {
774
1030
  const value = request.headers.authorization;
775
1031
  if (!value || Array.isArray(value)) return;
@@ -794,7 +1050,9 @@ function publicRoute(route) {
794
1050
  function normalizeOrigin(value) {
795
1051
  try {
796
1052
  const url = new URL(value);
797
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password) return;
1053
+ const loopback = url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
1054
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
1055
+ return;
798
1056
  return url.origin;
799
1057
  } catch {
800
1058
  return;
@@ -856,6 +1114,7 @@ function createBlindRelayServer(options) {
856
1114
  const idleTimeoutMs = options.idleTimeoutMs ?? 2 * 6e4;
857
1115
  const maxFrameBytes = options.maxFrameBytes ?? BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES;
858
1116
  const maxQueueBytes = options.maxQueueBytes ?? BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES;
1117
+ const maxTotalConnections = options.maxTotalConnections ?? BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS;
859
1118
  const maxConnectionsPerRoute = options.maxConnectionsPerRoute ?? BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
860
1119
  const maxBytesPerMinute = options.maxBytesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE;
861
1120
  const maxMessagesPerMinute = options.maxMessagesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE;
@@ -864,17 +1123,63 @@ function createBlindRelayServer(options) {
864
1123
  [idleTimeoutMs, 1e4, 60 * 6e4, "idle timeout"],
865
1124
  [maxFrameBytes, 1024, 16 * 1024 * 1024, "frame limit"],
866
1125
  [maxQueueBytes, 1024, 64 * 1024 * 1024, "queue limit"],
1126
+ [maxTotalConnections, 1, 1e5, "total connection limit"],
867
1127
  [maxConnectionsPerRoute, 1, 1e4, "connection limit"],
868
1128
  [maxBytesPerMinute, 1024, 1024 * 1024 * 1024, "byte rate"],
869
1129
  [maxMessagesPerMinute, 10, 1e6, "message rate"]
870
1130
  ]) {
871
1131
  if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`invalid relay ${label}`);
872
1132
  }
873
- const allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin).filter(Boolean));
1133
+ const maxEnvelopeBytes = Math.max(8 * 1024, Math.ceil(maxFrameBytes * 4 / 3) + 8 * 1024);
1134
+ const configuredOrigins = options.allowedOrigins ?? [];
1135
+ const normalizedOrigins = configuredOrigins.map(normalizeOrigin);
1136
+ if (normalizedOrigins.some((origin) => origin === void 0)) {
1137
+ throw new Error("invalid relay allowed origin");
1138
+ }
1139
+ const allowedOrigins = new Set(normalizedOrigins);
874
1140
  const generateChannelId = options.generateChannelId ?? (() => `rrc_${randomBytes3(16).toString("base64url")}`);
875
1141
  const hosts = /* @__PURE__ */ new Map();
876
1142
  const devicesByChannel = /* @__PURE__ */ new Map();
1143
+ const sockets = /* @__PURE__ */ new Set();
1144
+ const hostRates = /* @__PURE__ */ new Map();
1145
+ const deviceRates = /* @__PURE__ */ new Map();
1146
+ const maxRateIdentities = Math.min(1e5, Math.max(256, maxTotalConnections * 4));
1147
+ const deviceRateKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
1148
+ const clearRouteRates = (routeId) => {
1149
+ hostRates.delete(routeId);
1150
+ for (const key of deviceRates.keys()) if (key.startsWith(`${routeId}\0`)) deviceRates.delete(key);
1151
+ };
1152
+ const rateWindowFor = (windows, key) => {
1153
+ const current = now();
1154
+ if (windows.size >= maxRateIdentities) {
1155
+ for (const [candidate, window] of windows) {
1156
+ if (current - window.lastSeenAt >= 12e4) windows.delete(candidate);
1157
+ }
1158
+ }
1159
+ const existing = windows.get(key);
1160
+ if (existing) {
1161
+ existing.lastSeenAt = current;
1162
+ return existing;
1163
+ }
1164
+ if (windows.size >= maxRateIdentities) return;
1165
+ const created = { startedAt: current, lastSeenAt: current, bytes: 0, messages: 0 };
1166
+ windows.set(key, created);
1167
+ return created;
1168
+ };
1169
+ const consumeRate = (window, bytes) => {
1170
+ const current = now();
1171
+ if (current - window.startedAt >= 6e4) {
1172
+ window.startedAt = current;
1173
+ window.bytes = 0;
1174
+ window.messages = 0;
1175
+ }
1176
+ window.lastSeenAt = current;
1177
+ window.bytes += bytes;
1178
+ window.messages += 1;
1179
+ return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
1180
+ };
877
1181
  const metrics = {
1182
+ activeConnections: 0,
878
1183
  activeHosts: 0,
879
1184
  activeDevices: 0,
880
1185
  acceptedConnections: 0,
@@ -911,6 +1216,14 @@ function createBlindRelayServer(options) {
911
1216
  }
912
1217
  reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
913
1218
  };
1219
+ const requireRecoverableAccount = async (request, reply) => {
1220
+ const account = accountStore?.verifyCredential(bearer(request) ?? "");
1221
+ if (account) {
1222
+ authenticatedAccounts.set(request, account);
1223
+ return;
1224
+ }
1225
+ reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
1226
+ };
914
1227
  app.addHook("onSend", async (_request, reply, payload) => {
915
1228
  reply.header("cache-control", "no-store");
916
1229
  reply.header("content-security-policy", "default-src 'none'");
@@ -930,7 +1243,12 @@ function createBlindRelayServer(options) {
930
1243
  });
931
1244
  app.get("/v1/metrics", { preHandler: requireRoot }, async () => ({
932
1245
  protocolVersion: BLIND_RELAY_PROTOCOL_VERSION,
933
- metrics: { ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size }
1246
+ metrics: {
1247
+ ...metrics,
1248
+ activeConnections: sockets.size,
1249
+ activeHosts: hosts.size,
1250
+ activeDevices: devicesByChannel.size
1251
+ }
934
1252
  }));
935
1253
  app.get("/v1/routes", { preHandler: requireRoot }, async () => ({ routes: store.listRoutes().map(publicRoute) }));
936
1254
  app.post(
@@ -976,6 +1294,7 @@ function createBlindRelayServer(options) {
976
1294
  reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
977
1295
  return;
978
1296
  }
1297
+ clearRouteRates(request.params.routeId);
979
1298
  const host = hosts.get(request.params.routeId);
980
1299
  host?.socket.close(4403, "route deleted");
981
1300
  for (const device of host?.devices.values() ?? []) device.socket.close(4403, "route deleted");
@@ -1019,35 +1338,93 @@ function createBlindRelayServer(options) {
1019
1338
  reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device route" });
1020
1339
  }
1021
1340
  });
1341
+ app.post("/v1/routes/:routeId/devices/:deviceId/promote", { preHandler: requireHost }, async (request, reply) => {
1342
+ try {
1343
+ const body = strictInternalBody(request.body, ["expectedCredentialHash", "credentialHash"]);
1344
+ const routeId = safeId3(request.params.routeId, "route id");
1345
+ const deviceId = safeId3(request.params.deviceId, "device id");
1346
+ const expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
1347
+ const credentialHash = safeCredentialHash2(body.credentialHash);
1348
+ if (tokenMatches(expectedCredentialHash, credentialHash)) throw new Error("relay credentials must differ");
1349
+ const current = store.getDevice(routeId, deviceId, now());
1350
+ if (!current) {
1351
+ reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
1352
+ return;
1353
+ }
1354
+ if (current.expiresAt === void 0 && tokenMatches(current.credentialHash, credentialHash)) {
1355
+ reply.code(200).send({
1356
+ device: {
1357
+ routeId: current.routeId,
1358
+ deviceId: current.deviceId,
1359
+ createdAt: current.createdAt,
1360
+ updatedAt: current.updatedAt,
1361
+ expiresAt: null
1362
+ }
1363
+ });
1364
+ return;
1365
+ }
1366
+ if (current.expiresAt === void 0 || !tokenMatches(current.credentialHash, expectedCredentialHash)) {
1367
+ reply.code(409).send({
1368
+ code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
1369
+ error: "relay device credential conflict"
1370
+ });
1371
+ return;
1372
+ }
1373
+ const device = store.putDevice({ routeId, deviceId, credentialHash }, now());
1374
+ reply.code(200).send({
1375
+ device: {
1376
+ routeId: device.routeId,
1377
+ deviceId: device.deviceId,
1378
+ createdAt: device.createdAt,
1379
+ updatedAt: device.updatedAt,
1380
+ expiresAt: null
1381
+ }
1382
+ });
1383
+ } catch {
1384
+ reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device promotion" });
1385
+ }
1386
+ });
1022
1387
  app.delete(
1023
1388
  "/v1/routes/:routeId/devices/:deviceId",
1024
1389
  { preHandler: requireHost },
1025
1390
  async (request, reply) => {
1026
- let removed = false;
1391
+ let routeId;
1392
+ let deviceId;
1393
+ let expectedCredentialHash;
1027
1394
  try {
1028
- removed = store.revokeDevice(request.params.routeId, request.params.deviceId);
1395
+ routeId = safeId3(request.params.routeId, "route id");
1396
+ deviceId = safeId3(request.params.deviceId, "device id");
1397
+ if (request.body !== void 0) {
1398
+ const body = strictInternalBody(request.body, ["expectedCredentialHash"]);
1399
+ expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
1400
+ }
1029
1401
  } catch {
1402
+ reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device" });
1403
+ return;
1030
1404
  }
1031
- if (!removed) {
1405
+ const current = store.getDevice(routeId, deviceId, now());
1406
+ if (!current) {
1407
+ if (expectedCredentialHash) {
1408
+ reply.code(204).send();
1409
+ return;
1410
+ }
1032
1411
  reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
1033
1412
  return;
1034
1413
  }
1035
- const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
1414
+ if (expectedCredentialHash && !tokenMatches(current.credentialHash, expectedCredentialHash)) {
1415
+ reply.code(409).send({
1416
+ code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
1417
+ error: "relay device credential conflict"
1418
+ });
1419
+ return;
1420
+ }
1421
+ store.revokeDevice(routeId, deviceId);
1422
+ deviceRates.delete(deviceRateKey(routeId, deviceId));
1423
+ const live = hosts.get(routeId)?.devices.get(deviceId);
1036
1424
  live?.socket.close(4403, "device revoked");
1037
1425
  reply.code(204).send();
1038
1426
  }
1039
1427
  );
1040
- const consumeRate = (window, bytes) => {
1041
- const current = now();
1042
- if (current - window.startedAt >= 6e4) {
1043
- window.startedAt = current;
1044
- window.bytes = 0;
1045
- window.messages = 0;
1046
- }
1047
- window.bytes += bytes;
1048
- window.messages += 1;
1049
- return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
1050
- };
1051
1428
  const closeDevice = (device, code = 1e3, reason = "device disconnected") => {
1052
1429
  if (device.closed) return;
1053
1430
  device.closed = true;
@@ -1056,7 +1433,10 @@ function createBlindRelayServer(options) {
1056
1433
  const host = hosts.get(device.routeId);
1057
1434
  if (host?.devices.get(device.deviceId) === device) host.devices.delete(device.deviceId);
1058
1435
  metrics.activeDevices = Math.max(0, metrics.activeDevices - 1);
1059
- if (host) safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes);
1436
+ if (host && !safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes)) {
1437
+ metrics.droppedFrames += 1;
1438
+ closeHost(host, 4408, "relay backpressure");
1439
+ }
1060
1440
  if (device.socket.readyState === device.socket.OPEN) device.socket.close(code, reason);
1061
1441
  };
1062
1442
  const touchDevice = (device) => {
@@ -1084,30 +1464,607 @@ function createBlindRelayServer(options) {
1084
1464
  usage: { routes: store.listRoutesByOwner(account.id, now()).length, maxRoutes: account.maxRoutes }
1085
1465
  });
1086
1466
  const closeAccountRoutes = (accountId, reason) => {
1467
+ for (const host of [...hosts.values()]) if (host.ownerAccountId === accountId) closeHost(host, 4403, reason);
1468
+ };
1469
+ const purgeAccountRoutes = (accountId) => {
1470
+ closeAccountRoutes(accountId, "account deleted");
1087
1471
  for (const route of store.listRoutesByOwner(accountId, now())) {
1088
- const host = hosts.get(route.id);
1089
- if (host) closeHost(host, 4403, reason);
1472
+ store.deleteRoute(route.id);
1473
+ clearRouteRates(route.id);
1090
1474
  }
1091
1475
  };
1476
+ for (const listedRoute of store.listRoutes(now())) {
1477
+ const route = store.getRoute(listedRoute.id);
1478
+ if (!route?.ownerAccountId) continue;
1479
+ const owner = accountStore.getAccount(route.ownerAccountId);
1480
+ if (!owner || owner.status === "deleted") {
1481
+ store.deleteRoute(route.id);
1482
+ clearRouteRates(route.id);
1483
+ }
1484
+ }
1092
1485
  const ownedRoute = (accountId, routeId) => {
1093
1486
  const route = store.getRoute(routeId);
1094
1487
  return route?.ownerAccountId === accountId ? route : void 0;
1095
1488
  };
1489
+ const internalRouteEnvelope = (accountId, routeId) => {
1490
+ const route = ownedRoute(accountId, routeId);
1491
+ if (!route) return;
1492
+ return {
1493
+ accountId,
1494
+ route: publicRoute({ ...route, deviceCount: store.countDevices(route.id, now()) }),
1495
+ status: {
1496
+ hostOnline: hosts.has(route.id),
1497
+ activeDevices: hosts.get(route.id)?.devices.size ?? 0
1498
+ },
1499
+ connection: { path: "/v1/connect", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }
1500
+ };
1501
+ };
1502
+ const internalDeviceEnvelope = (accountId, device) => ({
1503
+ accountId,
1504
+ device: {
1505
+ routeId: device.routeId,
1506
+ deviceId: device.deviceId,
1507
+ createdAt: device.createdAt,
1508
+ updatedAt: device.updatedAt,
1509
+ expiresAt: device.expiresAt ?? null
1510
+ }
1511
+ });
1512
+ const closeAndDeleteRoute = (routeId, reason) => {
1513
+ const route = store.getRoute(routeId);
1514
+ if (!route) return false;
1515
+ const host = hosts.get(routeId);
1516
+ if (host) closeHost(host, 4403, reason);
1517
+ const deleted = store.deleteRoute(routeId);
1518
+ if (deleted) clearRouteRates(routeId);
1519
+ return deleted;
1520
+ };
1521
+ const accountRevisionConflict = (reply, account) => reply.code(409).send({
1522
+ code: "RELAY_ACCOUNT_REVISION_CONFLICT",
1523
+ error: "relay account revision conflict",
1524
+ current: accountEnvelope(account)
1525
+ });
1526
+ app.put(
1527
+ "/internal/v1/accounts/:accountId",
1528
+ { preHandler: requireRoot },
1529
+ async (request, reply) => {
1530
+ let accountId;
1531
+ let input;
1532
+ try {
1533
+ const body = strictInternalBody(request.body, [
1534
+ "label",
1535
+ "plan",
1536
+ "maxRoutes",
1537
+ "maxDevicesPerRoute",
1538
+ "credentialHash",
1539
+ "credentialLookup"
1540
+ ]);
1541
+ accountId = safeAccountId(request.params.accountId);
1542
+ input = {
1543
+ id: accountId,
1544
+ label: safeAccountLabel(body.label),
1545
+ plan: safeAccountPlan(body.plan),
1546
+ maxRoutes: safeAccountLimit(body.maxRoutes, 1e4),
1547
+ maxDevicesPerRoute: safeAccountLimit(body.maxDevicesPerRoute, 1e5),
1548
+ ...safeAccountCredentialMaterial(body)
1549
+ };
1550
+ } catch {
1551
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1552
+ return;
1553
+ }
1554
+ const existing = accountStore.getAccount(accountId);
1555
+ if (existing) {
1556
+ const matches = existing.status !== "deleted" && existing.label === input.label && existing.plan === input.plan && existing.maxRoutes === input.maxRoutes && existing.maxDevicesPerRoute === input.maxDevicesPerRoute && accountStore.credentialMatches(accountId, input);
1557
+ if (!matches) {
1558
+ reply.code(409).send({ code: "RELAY_ACCOUNT_EXISTS", error: "relay account already exists" });
1559
+ return;
1560
+ }
1561
+ reply.code(200).send(accountEnvelope(existing));
1562
+ return;
1563
+ }
1564
+ try {
1565
+ const account = accountStore.createAccount(input);
1566
+ reply.code(201).send(accountEnvelope(account));
1567
+ } catch (error) {
1568
+ if (error.message === "relay account already exists" || error.message === "relay account credential already exists") {
1569
+ reply.code(409).send({ code: "RELAY_ACCOUNT_EXISTS", error: "relay account already exists" });
1570
+ return;
1571
+ }
1572
+ throw error;
1573
+ }
1574
+ }
1575
+ );
1576
+ app.get(
1577
+ "/internal/v1/accounts/:accountId/status",
1578
+ { preHandler: requireRoot },
1579
+ async (request, reply) => {
1580
+ let account;
1581
+ try {
1582
+ account = accountStore.getAccount(safeAccountId(request.params.accountId));
1583
+ } catch {
1584
+ }
1585
+ if (!account || account.status === "deleted") {
1586
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1587
+ return;
1588
+ }
1589
+ reply.code(200).send(accountEnvelope(account));
1590
+ }
1591
+ );
1592
+ app.put("/internal/v1/accounts/:accountId/metadata", { preHandler: requireRoot }, async (request, reply) => {
1593
+ let accountId;
1594
+ let expectedRevision;
1595
+ let update;
1596
+ try {
1597
+ const body = strictInternalBody(request.body, [
1598
+ "expectedRevision",
1599
+ "label",
1600
+ "plan",
1601
+ "maxRoutes",
1602
+ "maxDevicesPerRoute"
1603
+ ]);
1604
+ accountId = safeAccountId(request.params.accountId);
1605
+ expectedRevision = safeRevision(body.expectedRevision);
1606
+ update = {
1607
+ label: safeAccountLabel(body.label),
1608
+ plan: safeAccountPlan(body.plan),
1609
+ maxRoutes: safeAccountLimit(body.maxRoutes, 1e4),
1610
+ maxDevicesPerRoute: safeAccountLimit(body.maxDevicesPerRoute, 1e5)
1611
+ };
1612
+ } catch {
1613
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1614
+ return;
1615
+ }
1616
+ const current = accountStore.getAccount(accountId);
1617
+ if (!current || current.status === "deleted") {
1618
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1619
+ return;
1620
+ }
1621
+ const matches = current.label === update.label && current.plan === update.plan && current.maxRoutes === update.maxRoutes && current.maxDevicesPerRoute === update.maxDevicesPerRoute;
1622
+ if (matches && (current.revision === expectedRevision || current.revision === expectedRevision + 1)) {
1623
+ reply.code(200).send(accountEnvelope(current));
1624
+ return;
1625
+ }
1626
+ if (current.revision !== expectedRevision) {
1627
+ accountRevisionConflict(reply, current);
1628
+ return;
1629
+ }
1630
+ try {
1631
+ const account = accountStore.updateAccount(accountId, update, expectedRevision);
1632
+ if (!account) {
1633
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1634
+ return;
1635
+ }
1636
+ reply.code(200).send(accountEnvelope(account));
1637
+ } catch (error) {
1638
+ if (error instanceof RelayAccountRevisionConflictError) {
1639
+ accountRevisionConflict(reply, error.current);
1640
+ return;
1641
+ }
1642
+ throw error;
1643
+ }
1644
+ });
1645
+ app.put("/internal/v1/accounts/:accountId/credential", { preHandler: requireRoot }, async (request, reply) => {
1646
+ let accountId;
1647
+ let expectedRevision;
1648
+ let credential;
1649
+ try {
1650
+ const body = strictInternalBody(request.body, ["expectedRevision", "credentialHash", "credentialLookup"]);
1651
+ accountId = safeAccountId(request.params.accountId);
1652
+ expectedRevision = safeRevision(body.expectedRevision);
1653
+ credential = safeAccountCredentialMaterial(body);
1654
+ } catch {
1655
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1656
+ return;
1657
+ }
1658
+ const current = accountStore.getAccount(accountId);
1659
+ if (!current || current.status === "deleted") {
1660
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1661
+ return;
1662
+ }
1663
+ if (accountStore.credentialMatches(accountId, credential)) {
1664
+ if (current.revision === expectedRevision || current.revision === expectedRevision + 1) {
1665
+ reply.code(200).send(accountEnvelope(current));
1666
+ return;
1667
+ }
1668
+ accountRevisionConflict(reply, current);
1669
+ return;
1670
+ }
1671
+ if (current.revision !== expectedRevision) {
1672
+ accountRevisionConflict(reply, current);
1673
+ return;
1674
+ }
1675
+ try {
1676
+ const account = accountStore.rotateCredential(accountId, credential, expectedRevision);
1677
+ if (!account) {
1678
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1679
+ return;
1680
+ }
1681
+ reply.code(200).send(accountEnvelope(account));
1682
+ } catch (error) {
1683
+ if (error instanceof RelayAccountRevisionConflictError) {
1684
+ accountRevisionConflict(reply, error.current);
1685
+ return;
1686
+ }
1687
+ if (error.message === "relay account credential already exists") {
1688
+ reply.code(409).send({ code: "RELAY_ACCOUNT_CREDENTIAL_CONFLICT", error: "relay credential conflict" });
1689
+ return;
1690
+ }
1691
+ throw error;
1692
+ }
1693
+ });
1694
+ app.delete("/internal/v1/accounts/:accountId", { preHandler: requireRoot }, async (request, reply) => {
1695
+ let accountId;
1696
+ let expectedRevision;
1697
+ try {
1698
+ const body = strictInternalBody(request.body, ["expectedRevision"]);
1699
+ accountId = safeAccountId(request.params.accountId);
1700
+ expectedRevision = safeRevision(body.expectedRevision);
1701
+ } catch {
1702
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1703
+ return;
1704
+ }
1705
+ const current = accountStore.getAccount(accountId);
1706
+ if (!current || current.status === "deleted") {
1707
+ reply.code(204).send();
1708
+ return;
1709
+ }
1710
+ if (current.revision !== expectedRevision) {
1711
+ accountRevisionConflict(reply, current);
1712
+ return;
1713
+ }
1714
+ try {
1715
+ const account = accountStore.updateAccount(accountId, { status: "deleted" }, expectedRevision);
1716
+ if (account) purgeAccountRoutes(account.id);
1717
+ reply.code(204).send();
1718
+ } catch (error) {
1719
+ if (error instanceof RelayAccountRevisionConflictError) {
1720
+ accountRevisionConflict(reply, error.current);
1721
+ return;
1722
+ }
1723
+ throw error;
1724
+ }
1725
+ });
1726
+ app.put("/internal/v1/accounts/:accountId/routes/:routeId", { preHandler: requireRoot }, async (request, reply) => {
1727
+ let accountId;
1728
+ let routeId;
1729
+ let label;
1730
+ let credentialHash;
1731
+ try {
1732
+ const body = strictInternalBody(request.body, ["label", "credentialHash"]);
1733
+ accountId = safeAccountId(request.params.accountId);
1734
+ routeId = safeId3(request.params.routeId, "route id");
1735
+ label = safeLabel3(body.label);
1736
+ credentialHash = safeRouteCredentialHash(body);
1737
+ } catch {
1738
+ reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
1739
+ return;
1740
+ }
1741
+ const account = accountStore.getAccount(accountId);
1742
+ if (!account || account.status === "deleted") {
1743
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1744
+ return;
1745
+ }
1746
+ if (account.status !== "active") {
1747
+ reply.code(409).send({ code: "RELAY_ACCOUNT_UNAVAILABLE", error: "relay account unavailable" });
1748
+ return;
1749
+ }
1750
+ const existing = store.getRoute(routeId);
1751
+ if (existing) {
1752
+ if (existing.ownerAccountId !== accountId || existing.label !== label || !tokenMatches(existing.hostCredentialHash, credentialHash)) {
1753
+ reply.code(409).send({ code: "RELAY_ROUTE_EXISTS", error: "relay route already exists" });
1754
+ return;
1755
+ }
1756
+ reply.code(200).send(internalRouteEnvelope(accountId, routeId));
1757
+ return;
1758
+ }
1759
+ if (store.listRoutesByOwner(accountId, now()).length >= account.maxRoutes) {
1760
+ reply.code(429).send({ code: "RELAY_ROUTE_LIMIT", error: "relay route limit reached" });
1761
+ return;
1762
+ }
1763
+ try {
1764
+ store.createRoute({ id: routeId, label, hostCredentialHash: credentialHash, ownerAccountId: accountId });
1765
+ reply.code(201).send(internalRouteEnvelope(accountId, routeId));
1766
+ } catch (error) {
1767
+ if (error.message === "relay route already exists") {
1768
+ reply.code(409).send({ code: "RELAY_ROUTE_EXISTS", error: "relay route already exists" });
1769
+ return;
1770
+ }
1771
+ throw error;
1772
+ }
1773
+ });
1774
+ app.get(
1775
+ "/internal/v1/accounts/:accountId/routes/:routeId/status",
1776
+ { preHandler: requireRoot },
1777
+ async (request, reply) => {
1778
+ let route;
1779
+ try {
1780
+ route = internalRouteEnvelope(
1781
+ safeAccountId(request.params.accountId),
1782
+ safeId3(request.params.routeId, "route id")
1783
+ );
1784
+ } catch {
1785
+ }
1786
+ if (!route) {
1787
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1788
+ return;
1789
+ }
1790
+ reply.code(200).send(route);
1791
+ }
1792
+ );
1793
+ app.put(
1794
+ "/internal/v1/accounts/:accountId/routes/:routeId/devices/:deviceId",
1795
+ { preHandler: requireRoot },
1796
+ async (request, reply) => {
1797
+ let accountId;
1798
+ let routeId;
1799
+ let deviceId;
1800
+ let credentialHash;
1801
+ let expiresAt;
1802
+ try {
1803
+ const body = strictInternalBody(request.body, ["credentialHash", "expiresAt"]);
1804
+ accountId = safeAccountId(request.params.accountId);
1805
+ routeId = safeId3(request.params.routeId, "route id");
1806
+ deviceId = safeId3(request.params.deviceId, "device id");
1807
+ credentialHash = safeCredentialHash2(body.credentialHash);
1808
+ const parsedExpiry = safeExpiry2(body.expiresAt, now());
1809
+ if (parsedExpiry === void 0) throw new Error("temporary relay device expiry is required");
1810
+ expiresAt = parsedExpiry;
1811
+ } catch {
1812
+ reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device" });
1813
+ return;
1814
+ }
1815
+ const account = accountStore.getAccount(accountId);
1816
+ if (!account || account.status === "deleted") {
1817
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1818
+ return;
1819
+ }
1820
+ if (account.status !== "active") {
1821
+ reply.code(409).send({ code: "RELAY_ACCOUNT_UNAVAILABLE", error: "relay account unavailable" });
1822
+ return;
1823
+ }
1824
+ if (!ownedRoute(accountId, routeId)) {
1825
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1826
+ return;
1827
+ }
1828
+ const current = store.getDevice(routeId, deviceId, now());
1829
+ if (current) {
1830
+ if (tokenMatches(current.credentialHash, credentialHash) && current.expiresAt === expiresAt) {
1831
+ reply.code(200).send(internalDeviceEnvelope(accountId, current));
1832
+ return;
1833
+ }
1834
+ reply.code(409).send({
1835
+ code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
1836
+ error: "relay device credential conflict",
1837
+ current: internalDeviceEnvelope(accountId, current)
1838
+ });
1839
+ return;
1840
+ }
1841
+ if (store.countDevices(routeId, now()) >= account.maxDevicesPerRoute) {
1842
+ reply.code(429).send({ code: "RELAY_DEVICE_LIMIT", error: "relay device limit reached" });
1843
+ return;
1844
+ }
1845
+ const device = store.putDevice({ routeId, deviceId, credentialHash, expiresAt }, now());
1846
+ reply.code(201).send(internalDeviceEnvelope(accountId, device));
1847
+ }
1848
+ );
1849
+ app.post(
1850
+ "/internal/v1/accounts/:accountId/routes/:routeId/devices/:deviceId/promote",
1851
+ { preHandler: requireRoot },
1852
+ async (request, reply) => {
1853
+ let accountId;
1854
+ let routeId;
1855
+ let deviceId;
1856
+ let expectedCredentialHash;
1857
+ let credentialHash;
1858
+ try {
1859
+ const body = strictInternalBody(request.body, ["expectedCredentialHash", "credentialHash"]);
1860
+ accountId = safeAccountId(request.params.accountId);
1861
+ routeId = safeId3(request.params.routeId, "route id");
1862
+ deviceId = safeId3(request.params.deviceId, "device id");
1863
+ expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
1864
+ credentialHash = safeCredentialHash2(body.credentialHash);
1865
+ if (tokenMatches(expectedCredentialHash, credentialHash)) throw new Error("relay credentials must differ");
1866
+ } catch {
1867
+ reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device promotion" });
1868
+ return;
1869
+ }
1870
+ const account = accountStore.getAccount(accountId);
1871
+ if (!account || account.status === "deleted") {
1872
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1873
+ return;
1874
+ }
1875
+ if (account.status !== "active") {
1876
+ reply.code(409).send({ code: "RELAY_ACCOUNT_UNAVAILABLE", error: "relay account unavailable" });
1877
+ return;
1878
+ }
1879
+ if (!ownedRoute(accountId, routeId)) {
1880
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1881
+ return;
1882
+ }
1883
+ const current = store.getDevice(routeId, deviceId, now());
1884
+ if (!current) {
1885
+ reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
1886
+ return;
1887
+ }
1888
+ if (current.expiresAt === void 0 && tokenMatches(current.credentialHash, credentialHash)) {
1889
+ reply.code(200).send(internalDeviceEnvelope(accountId, current));
1890
+ return;
1891
+ }
1892
+ if (current.expiresAt === void 0 || !tokenMatches(current.credentialHash, expectedCredentialHash)) {
1893
+ reply.code(409).send({
1894
+ code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
1895
+ error: "relay device credential conflict",
1896
+ current: internalDeviceEnvelope(accountId, current)
1897
+ });
1898
+ return;
1899
+ }
1900
+ const device = store.putDevice({ routeId, deviceId, credentialHash }, now());
1901
+ reply.code(200).send(internalDeviceEnvelope(accountId, device));
1902
+ }
1903
+ );
1904
+ app.delete(
1905
+ "/internal/v1/accounts/:accountId/routes/:routeId/devices/:deviceId",
1906
+ { preHandler: requireRoot },
1907
+ async (request, reply) => {
1908
+ let accountId;
1909
+ let routeId;
1910
+ let deviceId;
1911
+ let expectedCredentialHash;
1912
+ try {
1913
+ const body = strictInternalBody(request.body, ["expectedCredentialHash"]);
1914
+ accountId = safeAccountId(request.params.accountId);
1915
+ routeId = safeId3(request.params.routeId, "route id");
1916
+ deviceId = safeId3(request.params.deviceId, "device id");
1917
+ expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
1918
+ } catch {
1919
+ reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device" });
1920
+ return;
1921
+ }
1922
+ const account = accountStore.getAccount(accountId);
1923
+ if (!account || account.status === "deleted") {
1924
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1925
+ return;
1926
+ }
1927
+ if (!ownedRoute(accountId, routeId)) {
1928
+ reply.code(204).send();
1929
+ return;
1930
+ }
1931
+ const current = store.getDevice(routeId, deviceId, now());
1932
+ if (!current) {
1933
+ reply.code(204).send();
1934
+ return;
1935
+ }
1936
+ if (!tokenMatches(current.credentialHash, expectedCredentialHash)) {
1937
+ reply.code(409).send({
1938
+ code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
1939
+ error: "relay device credential conflict",
1940
+ current: internalDeviceEnvelope(accountId, current)
1941
+ });
1942
+ return;
1943
+ }
1944
+ store.revokeDevice(routeId, deviceId);
1945
+ deviceRates.delete(deviceRateKey(routeId, deviceId));
1946
+ const live = hosts.get(routeId)?.devices.get(deviceId);
1947
+ live?.socket.close(4403, "device revoked");
1948
+ reply.code(204).send();
1949
+ }
1950
+ );
1951
+ app.put(
1952
+ "/internal/v1/accounts/:accountId/routes/:routeId/credential",
1953
+ { preHandler: requireRoot },
1954
+ async (request, reply) => {
1955
+ let accountId;
1956
+ let routeId;
1957
+ let expectedCredentialHash;
1958
+ let credentialHash;
1959
+ try {
1960
+ const body = strictInternalBody(request.body, ["expectedCredentialHash", "credentialHash"]);
1961
+ accountId = safeAccountId(request.params.accountId);
1962
+ routeId = safeId3(request.params.routeId, "route id");
1963
+ expectedCredentialHash = safeRouteCredentialHash(body, "expectedCredentialHash");
1964
+ credentialHash = safeRouteCredentialHash(body);
1965
+ } catch {
1966
+ reply.code(400).send({ code: "INVALID_RELAY_CREDENTIAL", error: "invalid relay credential hash" });
1967
+ return;
1968
+ }
1969
+ const route = ownedRoute(accountId, routeId);
1970
+ if (!route) {
1971
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1972
+ return;
1973
+ }
1974
+ if (tokenMatches(route.hostCredentialHash, credentialHash)) {
1975
+ reply.code(200).send(internalRouteEnvelope(accountId, routeId));
1976
+ return;
1977
+ }
1978
+ if (!tokenMatches(route.hostCredentialHash, expectedCredentialHash)) {
1979
+ reply.code(409).send({
1980
+ code: "RELAY_ROUTE_CREDENTIAL_CONFLICT",
1981
+ error: "relay route credential conflict",
1982
+ current: internalRouteEnvelope(accountId, routeId)
1983
+ });
1984
+ return;
1985
+ }
1986
+ if (!store.rotateHostCredential(routeId, credentialHash, now())) {
1987
+ reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1988
+ return;
1989
+ }
1990
+ const host = hosts.get(routeId);
1991
+ if (host) closeHost(host, 4409, "host credential rotated");
1992
+ reply.code(200).send(internalRouteEnvelope(accountId, routeId));
1993
+ }
1994
+ );
1995
+ app.delete("/internal/v1/accounts/:accountId/routes/:routeId", { preHandler: requireRoot }, async (request, reply) => {
1996
+ let accountId;
1997
+ let routeId;
1998
+ let expectedCredentialHash;
1999
+ try {
2000
+ const body = strictInternalBody(request.body, ["expectedCredentialHash"]);
2001
+ accountId = safeAccountId(request.params.accountId);
2002
+ routeId = safeId3(request.params.routeId, "route id");
2003
+ expectedCredentialHash = safeRouteCredentialHash(body, "expectedCredentialHash");
2004
+ } catch {
2005
+ reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
2006
+ return;
2007
+ }
2008
+ const route = ownedRoute(accountId, routeId);
2009
+ if (!route) {
2010
+ reply.code(204).send();
2011
+ return;
2012
+ }
2013
+ if (!tokenMatches(route.hostCredentialHash, expectedCredentialHash)) {
2014
+ reply.code(409).send({
2015
+ code: "RELAY_ROUTE_CREDENTIAL_CONFLICT",
2016
+ error: "relay route credential conflict",
2017
+ current: internalRouteEnvelope(accountId, routeId)
2018
+ });
2019
+ return;
2020
+ }
2021
+ closeAndDeleteRoute(routeId, "route deleted");
2022
+ reply.code(204).send();
2023
+ });
1096
2024
  app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
1097
2025
  accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
1098
2026
  }));
1099
- app.post("/v1/accounts", { preHandler: requireRoot }, async (request, reply) => {
1100
- const accountCredential = generateRelayAccountCredential();
2027
+ const createAccountHandler = (clientHashedOnly) => async (request, reply) => {
1101
2028
  try {
2029
+ const hasCredentialHash = request.body?.credentialHash !== void 0;
2030
+ const hasCredentialLookup = request.body?.credentialLookup !== void 0;
2031
+ if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
2032
+ throw new Error("client-hashed account credential material is required");
2033
+ }
2034
+ const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
2035
+ const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
2036
+ const credential = suppliedCredentialMaterial ? {
2037
+ credentialHash: request.body?.credentialHash,
2038
+ credentialLookup: request.body?.credentialLookup
2039
+ } : accountCredential;
1102
2040
  const account = accountStore.createAccount({
1103
- ...request.body,
1104
- credential: accountCredential
2041
+ label: request.body?.label,
2042
+ ...request.body?.plan === void 0 ? {} : { plan: request.body.plan },
2043
+ ...request.body?.maxRoutes === void 0 ? {} : { maxRoutes: request.body.maxRoutes },
2044
+ ...request.body?.maxDevicesPerRoute === void 0 ? {} : { maxDevicesPerRoute: request.body.maxDevicesPerRoute },
2045
+ ...typeof credential === "string" ? { credential } : {
2046
+ credentialHash: credential.credentialHash,
2047
+ credentialLookup: credential.credentialLookup
2048
+ }
2049
+ });
2050
+ reply.code(201).send({
2051
+ ...accountEnvelope(account),
2052
+ ...accountCredential === void 0 ? {} : { accountCredential }
1105
2053
  });
1106
- reply.code(201).send({ ...accountEnvelope(account), accountCredential });
1107
2054
  } catch {
1108
2055
  reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1109
2056
  }
1110
- });
2057
+ };
2058
+ app.post(
2059
+ "/v1/accounts",
2060
+ { preHandler: requireRoot },
2061
+ createAccountHandler(false)
2062
+ );
2063
+ app.post(
2064
+ "/v1/accounts/client-hashed",
2065
+ { preHandler: requireRoot },
2066
+ createAccountHandler(true)
2067
+ );
1111
2068
  app.patch(
1112
2069
  "/v1/accounts/:accountId",
1113
2070
  { preHandler: requireRoot },
@@ -1122,7 +2079,8 @@ function createBlindRelayServer(options) {
1122
2079
  reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1123
2080
  return;
1124
2081
  }
1125
- if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
2082
+ if (account.status === "deleted") purgeAccountRoutes(account.id);
2083
+ else if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
1126
2084
  reply.code(200).send(accountEnvelope(account));
1127
2085
  } catch (error) {
1128
2086
  if (error instanceof RelayAccountRevisionConflictError) {
@@ -1137,34 +2095,58 @@ function createBlindRelayServer(options) {
1137
2095
  }
1138
2096
  }
1139
2097
  );
2098
+ const rotateAccountHandler = (clientHashedOnly) => async (request, reply) => {
2099
+ try {
2100
+ const hasCredentialHash = request.body?.credentialHash !== void 0;
2101
+ const hasCredentialLookup = request.body?.credentialLookup !== void 0;
2102
+ if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
2103
+ throw new Error("client-hashed account credential material is required");
2104
+ }
2105
+ const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
2106
+ const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
2107
+ const credential = suppliedCredentialMaterial ? {
2108
+ credentialHash: request.body?.credentialHash,
2109
+ credentialLookup: request.body?.credentialLookup
2110
+ } : accountCredential;
2111
+ const account = accountStore.rotateCredential(
2112
+ request.params.accountId,
2113
+ credential,
2114
+ safeRevision(request.body?.expectedRevision)
2115
+ );
2116
+ if (!account) {
2117
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
2118
+ return;
2119
+ }
2120
+ reply.code(200).send({
2121
+ ...accountEnvelope(account),
2122
+ ...accountCredential === void 0 ? {} : { accountCredential }
2123
+ });
2124
+ } catch (error) {
2125
+ if (error instanceof RelayAccountRevisionConflictError) {
2126
+ reply.code(409).send({
2127
+ code: "RELAY_ACCOUNT_REVISION_CONFLICT",
2128
+ error: "relay account revision conflict",
2129
+ current: accountEnvelope(error.current)
2130
+ });
2131
+ return;
2132
+ }
2133
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
2134
+ }
2135
+ };
1140
2136
  app.post(
1141
2137
  "/v1/accounts/:accountId/credential",
1142
2138
  { preHandler: requireRoot },
1143
- async (request, reply) => {
1144
- const accountCredential = generateRelayAccountCredential();
1145
- try {
1146
- const account = accountStore.rotateCredential(
1147
- request.params.accountId,
1148
- accountCredential,
1149
- safeRevision(request.body?.expectedRevision)
1150
- );
1151
- if (!account) {
1152
- reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1153
- return;
1154
- }
1155
- reply.code(200).send({ ...accountEnvelope(account), accountCredential });
1156
- } catch (error) {
1157
- if (error instanceof RelayAccountRevisionConflictError) {
1158
- reply.code(409).send({
1159
- code: "RELAY_ACCOUNT_REVISION_CONFLICT",
1160
- error: "relay account revision conflict",
1161
- current: accountEnvelope(error.current)
1162
- });
1163
- return;
1164
- }
1165
- reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1166
- }
1167
- }
2139
+ rotateAccountHandler(false)
2140
+ );
2141
+ app.post(
2142
+ "/v1/accounts/:accountId/credential/client-hashed",
2143
+ { preHandler: requireRoot },
2144
+ rotateAccountHandler(true)
2145
+ );
2146
+ app.get(
2147
+ "/v1/account/recovery",
2148
+ { preHandler: requireRecoverableAccount },
2149
+ async (request) => accountEnvelope(authenticatedAccounts.get(request))
1168
2150
  );
1169
2151
  app.get(
1170
2152
  "/v1/account",
@@ -1242,6 +2224,7 @@ function createBlindRelayServer(options) {
1242
2224
  reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1243
2225
  return;
1244
2226
  }
2227
+ clearRouteRates(request.params.routeId);
1245
2228
  const host = hosts.get(request.params.routeId);
1246
2229
  if (host) closeHost(host, 4403, "route deleted");
1247
2230
  reply.code(204).send();
@@ -1278,9 +2261,15 @@ function createBlindRelayServer(options) {
1278
2261
  }
1279
2262
  );
1280
2263
  }
1281
- app.register(websocket);
2264
+ app.register(websocket, { options: { maxPayload: maxEnvelopeBytes, perMessageDeflate: false } });
1282
2265
  app.register(async (scope) => {
1283
2266
  scope.get("/v1/connect", { websocket: true }, (socket, request) => {
2267
+ if (sockets.size >= maxTotalConnections) {
2268
+ metrics.rejectedConnections += 1;
2269
+ socket.close(4429, "relay connection limit");
2270
+ return;
2271
+ }
2272
+ sockets.add(socket);
1284
2273
  let authenticated = false;
1285
2274
  let liveHost;
1286
2275
  let liveDevice;
@@ -1299,29 +2288,41 @@ function createBlindRelayServer(options) {
1299
2288
  if (!authenticated) {
1300
2289
  try {
1301
2290
  const hello = parseAuthHello(parseJson(raw, 8 * 1024));
1302
- const origin = typeof request.headers.origin === "string" ? normalizeOrigin(request.headers.origin) : void 0;
1303
- if (hello.role === "device" && origin && allowedOrigins.size > 0 && !allowedOrigins.has(origin)) {
2291
+ const presentedOrigin = request.headers.origin;
2292
+ const origin = typeof presentedOrigin === "string" ? normalizeOrigin(presentedOrigin) : void 0;
2293
+ if (hello.role === "device" && allowedOrigins.size > 0 && presentedOrigin !== void 0 && (!origin || !allowedOrigins.has(origin))) {
1304
2294
  reject(4403, "origin denied");
1305
2295
  return;
1306
2296
  }
1307
2297
  if (hello.role === "host") {
1308
- if (!routeAccountIsActive(hello.routeId) || !store.authenticateHost(hello.routeId, hello.credential)) {
2298
+ const route = store.getRoute(hello.routeId);
2299
+ if (!route || route.ownerAccountId !== void 0 && accountStore?.getAccount(route.ownerAccountId)?.status !== "active" || !store.authenticateHost(hello.routeId, hello.credential)) {
1309
2300
  reject(4401, "authentication failed");
1310
2301
  return;
1311
2302
  }
2303
+ const rate = rateWindowFor(hostRates, hello.routeId);
2304
+ if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
2305
+ reject(4429, "relay identity rate limit");
2306
+ return;
2307
+ }
1312
2308
  const previous = hosts.get(hello.routeId);
1313
2309
  if (previous) closeHost(previous, 4409, "superseded host");
1314
2310
  liveHost = {
1315
2311
  socket,
1316
2312
  routeId: hello.routeId,
2313
+ ...route.ownerAccountId === void 0 ? {} : { ownerAccountId: route.ownerAccountId },
1317
2314
  devices: /* @__PURE__ */ new Map(),
1318
- rate: { startedAt: now(), bytes: 0, messages: 0 },
2315
+ rate,
1319
2316
  closed: false
1320
2317
  };
1321
2318
  hosts.set(hello.routeId, liveHost);
1322
2319
  metrics.activeHosts += 1;
1323
2320
  touchHost(liveHost);
1324
- safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes);
2321
+ if (!safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes)) {
2322
+ metrics.rejectedConnections += 1;
2323
+ closeHost(liveHost, 4408, "relay backpressure");
2324
+ return;
2325
+ }
1325
2326
  } else {
1326
2327
  if (!routeAccountIsActive(hello.routeId) || !store.authenticateDevice(hello.routeId, hello.deviceId, hello.credential, now())) {
1327
2328
  reject(4401, "authentication failed");
@@ -1336,6 +2337,11 @@ function createBlindRelayServer(options) {
1336
2337
  reject(4429, "route connection limit");
1337
2338
  return;
1338
2339
  }
2340
+ const rate = rateWindowFor(deviceRates, deviceRateKey(hello.routeId, hello.deviceId));
2341
+ if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
2342
+ reject(4429, "relay identity rate limit");
2343
+ return;
2344
+ }
1339
2345
  const previous = host.devices.get(hello.deviceId);
1340
2346
  if (previous) closeDevice(previous, 4409, "superseded device");
1341
2347
  const channelId = safeId3(generateChannelId(), "channel id");
@@ -1344,15 +2350,20 @@ function createBlindRelayServer(options) {
1344
2350
  routeId: hello.routeId,
1345
2351
  deviceId: hello.deviceId,
1346
2352
  channelId,
1347
- rate: { startedAt: now(), bytes: 0, messages: 0 },
2353
+ rate,
1348
2354
  closed: false
1349
2355
  };
1350
2356
  host.devices.set(hello.deviceId, liveDevice);
1351
2357
  devicesByChannel.set(channelId, liveDevice);
1352
2358
  metrics.activeDevices += 1;
1353
2359
  touchDevice(liveDevice);
1354
- safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes);
2360
+ if (!safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes)) {
2361
+ metrics.rejectedConnections += 1;
2362
+ closeDevice(liveDevice, 4408, "relay backpressure");
2363
+ return;
2364
+ }
1355
2365
  if (!safeSend(host.socket, { t: "peer-open", channelId, deviceId: hello.deviceId }, maxQueueBytes)) {
2366
+ metrics.rejectedConnections += 1;
1356
2367
  closeDevice(liveDevice, 4408, "host backpressure");
1357
2368
  return;
1358
2369
  }
@@ -1366,11 +2377,18 @@ function createBlindRelayServer(options) {
1366
2377
  return;
1367
2378
  }
1368
2379
  try {
1369
- const value = parseJson(raw, maxFrameBytes * 2);
2380
+ const value = parseJson(raw, maxEnvelopeBytes);
1370
2381
  if (liveDevice) {
1371
2382
  touchDevice(liveDevice);
1372
2383
  if (value?.t === "ping") {
1373
- safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
2384
+ if (!consumeRate(liveDevice.rate, 1)) {
2385
+ closeDevice(liveDevice, 4429, "rate limit");
2386
+ return;
2387
+ }
2388
+ if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
2389
+ metrics.droppedFrames += 1;
2390
+ closeDevice(liveDevice, 4408, "relay backpressure");
2391
+ }
1374
2392
  return;
1375
2393
  }
1376
2394
  const frame = parsePayload(value, false, maxFrameBytes);
@@ -1395,7 +2413,14 @@ function createBlindRelayServer(options) {
1395
2413
  if (liveHost) {
1396
2414
  touchHost(liveHost);
1397
2415
  if (value?.t === "ping") {
1398
- safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
2416
+ if (!consumeRate(liveHost.rate, 1)) {
2417
+ closeHost(liveHost, 4429, "rate limit");
2418
+ return;
2419
+ }
2420
+ if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
2421
+ metrics.droppedFrames += 1;
2422
+ closeHost(liveHost, 4408, "relay backpressure");
2423
+ }
1399
2424
  return;
1400
2425
  }
1401
2426
  if (value?.t === "close-peer") {
@@ -1433,6 +2458,7 @@ function createBlindRelayServer(options) {
1433
2458
  }
1434
2459
  });
1435
2460
  socket.once("close", () => {
2461
+ sockets.delete(socket);
1436
2462
  clearTimeout(handshakeTimer);
1437
2463
  if (liveDevice) closeDevice(liveDevice);
1438
2464
  if (liveHost) closeHost(liveHost);
@@ -1443,20 +2469,45 @@ function createBlindRelayServer(options) {
1443
2469
  });
1444
2470
  app.addHook("onClose", async () => {
1445
2471
  for (const host of [...hosts.values()]) closeHost(host, 1001, "relay shutting down");
2472
+ for (const socket of [...sockets]) socket.close(1001, "relay shutting down");
2473
+ hostRates.clear();
2474
+ deviceRates.clear();
1446
2475
  if (ownsStore) store.close();
1447
2476
  });
1448
2477
  return {
1449
2478
  app,
1450
2479
  store,
1451
2480
  ...accountStore ? { accountStore } : {},
1452
- metrics: () => ({ ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size })
2481
+ metrics: () => ({
2482
+ ...metrics,
2483
+ activeConnections: sockets.size,
2484
+ activeHosts: hosts.size,
2485
+ activeDevices: devicesByChannel.size
2486
+ })
1453
2487
  };
1454
2488
  }
1455
2489
 
1456
2490
  // src/data-dir.ts
1457
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2491
+ import {
2492
+ closeSync,
2493
+ constants,
2494
+ existsSync,
2495
+ fchmodSync,
2496
+ fstatSync,
2497
+ fsyncSync,
2498
+ linkSync,
2499
+ lstatSync,
2500
+ mkdirSync,
2501
+ openSync,
2502
+ readFileSync,
2503
+ renameSync,
2504
+ unlinkSync,
2505
+ writeFileSync
2506
+ } from "fs";
1458
2507
  import { randomBytes as randomBytes4 } from "crypto";
1459
- import { join } from "path";
2508
+ import { dirname, join } from "path";
2509
+ var MAX_ACCESS_TOKEN_BYTES = 4 * 1024;
2510
+ var MAX_ACCESS_TOKEN_FILE_BYTES = MAX_ACCESS_TOKEN_BYTES + 2;
1460
2511
  function resolveDataDir(env, exists = existsSync) {
1461
2512
  if (env.ROAMCODE_DATA_DIR) return env.ROAMCODE_DATA_DIR;
1462
2513
  if (env.REMOTE_CODER_DATA_DIR) return env.REMOTE_CODER_DATA_DIR;
@@ -1483,8 +2534,51 @@ function relayDataDir(env) {
1483
2534
  function relayOrigins(env) {
1484
2535
  return (env.ROAMCODE_RELAY_ALLOWED_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
1485
2536
  }
2537
+ function privateSecretFile(path, field) {
2538
+ let descriptor;
2539
+ try {
2540
+ const before = lstatSync2(path);
2541
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
2542
+ throw new Error("unsafe secret file");
2543
+ }
2544
+ descriptor = openSync2(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
2545
+ const opened = fstatSync2(descriptor);
2546
+ if (!opened.isFile() || opened.size > 4096 || (opened.mode & 63) !== 0 || typeof process.getuid === "function" && opened.uid !== process.getuid() || opened.dev !== before.dev || opened.ino !== before.ino) {
2547
+ throw new Error("unsafe secret file");
2548
+ }
2549
+ return readFileSync2(descriptor, "utf8").trim();
2550
+ } catch {
2551
+ throw new Error(`${field} could not be read securely`);
2552
+ } finally {
2553
+ if (descriptor !== void 0) closeSync2(descriptor);
2554
+ }
2555
+ }
1486
2556
  function previousRootTokens(env) {
1487
- return (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
2557
+ const inline = (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
2558
+ const directory = env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR?.trim();
2559
+ if (inline.length > 0 && directory) {
2560
+ throw new Error(
2561
+ "ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS and ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR are mutually exclusive"
2562
+ );
2563
+ }
2564
+ if (!directory) return inline;
2565
+ let before;
2566
+ let entries;
2567
+ try {
2568
+ before = lstatSync2(directory);
2569
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
2570
+ throw new Error("unsafe secret directory");
2571
+ }
2572
+ entries = readdirSync(directory, { withFileTypes: true });
2573
+ const after = lstatSync2(directory);
2574
+ if (after.dev !== before.dev || after.ino !== before.ino || (after.mode & 63) !== 0 || typeof process.getuid === "function" && after.uid !== process.getuid() || entries.some((entry) => !entry.isFile())) {
2575
+ throw new Error("unsafe secret directory");
2576
+ }
2577
+ } catch {
2578
+ throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR could not be read securely");
2579
+ }
2580
+ if (entries.length > 3) throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR may contain at most three files");
2581
+ return entries.map((entry) => entry.name).sort().map((name) => privateSecretFile(join2(directory, name), "previous relay root capability"));
1488
2582
  }
1489
2583
  function explicitBoolean(value, field) {
1490
2584
  if (value === void 0 || value === "" || value === "0" || value === "false") return false;
@@ -1497,18 +2591,12 @@ function secretValue(env, directKey, fileKey) {
1497
2591
  if (direct && file) throw new Error(`${directKey} and ${fileKey} are mutually exclusive`);
1498
2592
  if (direct) return direct;
1499
2593
  if (!file) return void 0;
1500
- let contents;
1501
- try {
1502
- contents = readFileSync2(file, "utf8");
1503
- } catch {
1504
- throw new Error(`${fileKey} could not be read`);
1505
- }
1506
- if (Buffer.byteLength(contents) > 4096) throw new Error(`${fileKey} is too large`);
1507
- return contents.trim();
2594
+ return privateSecretFile(file, fileKey);
1508
2595
  }
1509
2596
  async function startBlindRelay(env = process.env) {
1510
2597
  const rootToken = secretValue(env, "ROAMCODE_RELAY_ROOT_TOKEN", "ROAMCODE_RELAY_ROOT_TOKEN_FILE");
1511
2598
  if (!rootToken) throw new Error("ROAMCODE_RELAY_ROOT_TOKEN or ROAMCODE_RELAY_ROOT_TOKEN_FILE is required");
2599
+ const previousRoots = previousRootTokens(env);
1512
2600
  const allowedOrigins = relayOrigins(env);
1513
2601
  const allowAnyOrigin = explicitBoolean(env.ROAMCODE_RELAY_ALLOW_ANY_ORIGIN, "relay allow-any-origin flag");
1514
2602
  if (env.NODE_ENV === "production" && allowedOrigins.length === 0 && !allowAnyOrigin) {
@@ -1537,7 +2625,7 @@ async function startBlindRelay(env = process.env) {
1537
2625
  try {
1538
2626
  relay = createBlindRelayServer({
1539
2627
  rootToken,
1540
- previousRootTokens: previousRootTokens(env),
2628
+ previousRootTokens: previousRoots,
1541
2629
  store,
1542
2630
  ...accountStore ? { accountStore } : {},
1543
2631
  allowedOrigins,
@@ -1569,6 +2657,13 @@ async function startBlindRelay(env = process.env) {
1569
2657
  64 * 1024 * 1024,
1570
2658
  "relay queue limit"
1571
2659
  ),
2660
+ maxTotalConnections: boundedInteger(
2661
+ env.ROAMCODE_RELAY_MAX_TOTAL_CONNECTIONS,
2662
+ 1024,
2663
+ 1,
2664
+ 1e5,
2665
+ "relay total connection limit"
2666
+ ),
1572
2667
  maxConnectionsPerRoute: boundedInteger(
1573
2668
  env.ROAMCODE_RELAY_MAX_CONNECTIONS_PER_ROUTE,
1574
2669
  64,