@roamcode.ai/server 1.1.0 → 1.2.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));
@@ -96,14 +133,24 @@ function createMemoryStore(options) {
96
133
  const accounts = /* @__PURE__ */ new Map();
97
134
  const lookup = /* @__PURE__ */ new Map();
98
135
  const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
136
+ const verifyCredential = (credential) => {
137
+ let credentialLookup;
138
+ try {
139
+ credentialLookup = relayAccountCredentialLookup(credential);
140
+ } catch {
141
+ return;
142
+ }
143
+ const id = lookup.get(credentialLookup);
144
+ const record = id ? accounts.get(id) : void 0;
145
+ return record && record.status !== "deleted" && hashMatches(record.credentialHash, credential) ? clone(record) : void 0;
146
+ };
99
147
  return {
100
148
  mode: "memory",
101
149
  createAccount(input, now = Date.now()) {
102
150
  const id = safeId(generateAccountId());
103
151
  if (accounts.has(id)) throw new Error("relay account already exists");
104
152
  const plan = safePlan(input.plan ?? "free");
105
- const credentialHash = relayAccountCredentialHash(input.credential);
106
- const credentialLookup = relayAccountCredentialLookup(input.credential);
153
+ const { credentialHash, credentialLookup } = credentialMaterial(input);
107
154
  if (lookup.has(credentialLookup)) throw new Error("relay account credential already exists");
108
155
  const defaults = PLAN_DEFAULTS[plan];
109
156
  const record = {
@@ -135,16 +182,10 @@ function createMemoryStore(options) {
135
182
  listAccounts({ includeDeleted = false } = {}) {
136
183
  return [...accounts.values()].filter((record) => includeDeleted || record.status !== "deleted").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(clone);
137
184
  },
185
+ verifyCredential,
138
186
  authenticate(credential) {
139
- let credentialLookup;
140
- try {
141
- credentialLookup = relayAccountCredentialLookup(credential);
142
- } catch {
143
- return void 0;
144
- }
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;
187
+ const record = verifyCredential(credential);
188
+ return record?.status === "active" ? record : void 0;
148
189
  },
149
190
  updateAccount(id, input, expectedRevision, now = Date.now()) {
150
191
  const current = accounts.get(safeId(id));
@@ -182,8 +223,7 @@ function createMemoryStore(options) {
182
223
  if (!current) return void 0;
183
224
  if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone(current));
184
225
  if (current.status === "deleted") throw new Error("deleted relay account is immutable");
185
- const credentialHash = relayAccountCredentialHash(credential);
186
- const credentialLookup = relayAccountCredentialLookup(credential);
226
+ const { credentialHash, credentialLookup } = credentialMaterial(credential);
187
227
  const owner = lookup.get(credentialLookup);
188
228
  if (owner && owner !== current.id) throw new Error("relay account credential already exists");
189
229
  lookup.delete(current.credentialLookup);
@@ -249,10 +289,21 @@ function openRelayAccountStore(options) {
249
289
  `);
250
290
  const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
251
291
  const rowById = (id) => db.prepare("SELECT * FROM relay_accounts WHERE id = ?").get(id);
292
+ const verifyCredential = (credential) => {
293
+ let credentialLookup;
294
+ try {
295
+ credentialLookup = relayAccountCredentialLookup(credential);
296
+ } catch {
297
+ return;
298
+ }
299
+ const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status != 'deleted'").get(credentialLookup);
300
+ return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
301
+ };
252
302
  const createAccount = db.transaction((input, now) => {
253
303
  const id = safeId(generateAccountId());
254
304
  if (rowById(id)) throw new Error("relay account already exists");
255
305
  const plan = safePlan(input.plan ?? "free");
306
+ const { credentialHash, credentialLookup } = credentialMaterial(input);
256
307
  const defaults = PLAN_DEFAULTS[plan];
257
308
  const record = {
258
309
  id,
@@ -278,8 +329,8 @@ function openRelayAccountStore(options) {
278
329
  record.maxRoutes,
279
330
  record.maxDevicesPerRoute,
280
331
  record.revision,
281
- relayAccountCredentialHash(input.credential),
282
- relayAccountCredentialLookup(input.credential),
332
+ credentialHash,
333
+ credentialLookup,
283
334
  record.createdAt,
284
335
  record.updatedAt
285
336
  );
@@ -299,15 +350,10 @@ function openRelayAccountStore(options) {
299
350
  ).all();
300
351
  return rows.map(fromRow);
301
352
  },
353
+ verifyCredential,
302
354
  authenticate(credential) {
303
- let credentialLookup;
304
- try {
305
- credentialLookup = relayAccountCredentialLookup(credential);
306
- } catch {
307
- return void 0;
308
- }
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;
355
+ const record = verifyCredential(credential);
356
+ return record?.status === "active" ? record : void 0;
311
357
  },
312
358
  updateAccount(id, input, expectedRevision, now = Date.now()) {
313
359
  const safeAccountId = safeId(id);
@@ -366,16 +412,11 @@ function openRelayAccountStore(options) {
366
412
  const current = fromRow(currentRow);
367
413
  if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
368
414
  if (current.status === "deleted") throw new Error("deleted relay account is immutable");
415
+ const { credentialHash, credentialLookup } = credentialMaterial(credential);
369
416
  const result = db.prepare(
370
417
  `UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
371
418
  updated_at = ? WHERE id = ? AND revision = ?`
372
- ).run(
373
- relayAccountCredentialHash(credential),
374
- relayAccountCredentialLookup(credential),
375
- now,
376
- safeAccountId,
377
- expectedRevision
378
- );
419
+ ).run(credentialHash, credentialLookup, now, safeAccountId, expectedRevision);
379
420
  if (result.changes !== 1) {
380
421
  const latest = rowById(safeAccountId);
381
422
  if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
@@ -391,6 +432,7 @@ function openRelayAccountStore(options) {
391
432
  import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
392
433
  import { createRequire as createRequire2 } from "module";
393
434
  var require3 = createRequire2(import.meta.url);
435
+ var UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
394
436
  function safeId2(value, field) {
395
437
  if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
396
438
  return value;
@@ -404,7 +446,7 @@ function safeOwnerAccountId(value) {
404
446
  function safeLabel2(value) {
405
447
  if (typeof value !== "string") throw new Error("relay route label is required");
406
448
  const normalized = value.trim().replace(/\s+/g, " ");
407
- if (!normalized || normalized.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(normalized)) {
449
+ if (!normalized || normalized.length > 80 || UNSAFE_DISPLAY_TEXT.test(normalized)) {
408
450
  throw new Error("invalid relay route label");
409
451
  }
410
452
  return normalized;
@@ -446,22 +488,72 @@ function cloneRoute(route) {
446
488
  return { ...route };
447
489
  }
448
490
  function cloneDevice(device) {
449
- return { ...device };
491
+ return {
492
+ routeId: device.routeId,
493
+ deviceId: device.deviceId,
494
+ credentialHash: device.credentialHash,
495
+ createdAt: device.createdAt,
496
+ updatedAt: device.updatedAt,
497
+ ...device.expiresAt === void 0 ? {} : { expiresAt: device.expiresAt }
498
+ };
499
+ }
500
+ function credentialOverlap(current, credentialHash, expiresAt, now) {
501
+ if (!current || expiresAt !== void 0) return {};
502
+ if (current.expiresAt !== void 0) {
503
+ if (current.credentialHash === credentialHash) {
504
+ throw new Error("relay bootstrap promotion must rotate the device credential");
505
+ }
506
+ return {
507
+ previousCredentialHash: current.credentialHash,
508
+ previousCredentialExpiresAt: current.expiresAt
509
+ };
510
+ }
511
+ if (current.previousCredentialHash !== void 0 && current.previousCredentialExpiresAt !== void 0 && current.previousCredentialExpiresAt >= now) {
512
+ if (current.previousCredentialHash === credentialHash) {
513
+ throw new Error("relay bootstrap credential cannot become durable");
514
+ }
515
+ return {
516
+ previousCredentialHash: current.previousCredentialHash,
517
+ previousCredentialExpiresAt: current.previousCredentialExpiresAt
518
+ };
519
+ }
520
+ return {};
450
521
  }
451
522
  function createMemoryStore2(options) {
452
523
  const routes = /* @__PURE__ */ new Map();
453
524
  const devices = /* @__PURE__ */ new Map();
454
525
  const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
455
526
  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
- }));
527
+ const pruneExpiredDevices = (now) => {
528
+ for (const [key, device] of devices) {
529
+ if (device.expiresAt !== void 0 && device.expiresAt < now) devices.delete(key);
530
+ else if (device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt < now) {
531
+ delete device.previousCredentialHash;
532
+ delete device.previousCredentialExpiresAt;
533
+ }
534
+ }
535
+ };
536
+ const liveDevice = (routeId, deviceId, now) => {
537
+ const key = deviceKey(routeId, deviceId);
538
+ const device = devices.get(key);
539
+ if (device?.expiresAt !== void 0 && device.expiresAt < now) {
540
+ devices.delete(key);
541
+ return;
542
+ }
543
+ return device;
544
+ };
545
+ const listRoutes = (now, ownerAccountId) => {
546
+ pruneExpiredDevices(now);
547
+ 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) => ({
548
+ id: route.id,
549
+ label: route.label,
550
+ deviceCount: [...devices.values()].filter(
551
+ (device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
552
+ ).length,
553
+ createdAt: route.createdAt,
554
+ updatedAt: route.updatedAt
555
+ }));
556
+ };
465
557
  return {
466
558
  mode: "memory",
467
559
  createRoute(input, now = Date.now()) {
@@ -487,6 +579,7 @@ function createMemoryStore2(options) {
487
579
  return listRoutes(now, safeOwnerAccountId(ownerAccountId));
488
580
  },
489
581
  countDevices(routeId, now = Date.now()) {
582
+ pruneExpiredDevices(now);
490
583
  const safeRouteId = safeId2(routeId, "route id");
491
584
  return [...devices.values()].filter(
492
585
  (device) => device.routeId === safeRouteId && (device.expiresAt === void 0 || device.expiresAt >= now)
@@ -510,18 +603,22 @@ function createMemoryStore2(options) {
510
603
  return !!route && hashMatches2(route.hostCredentialHash, credential);
511
604
  },
512
605
  putDevice(input, now = Date.now()) {
606
+ pruneExpiredDevices(now);
513
607
  const routeId = safeId2(input.routeId, "route id");
514
608
  if (!routes.has(routeId)) throw new Error("relay route not found");
515
609
  const deviceId = safeId2(input.deviceId, "device id");
516
610
  const key = deviceKey(routeId, deviceId);
517
611
  const current = devices.get(key);
612
+ const credentialHash = safeCredentialHash(input.credentialHash);
613
+ const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
518
614
  const device = {
519
615
  routeId,
520
616
  deviceId,
521
- credentialHash: safeCredentialHash(input.credentialHash),
617
+ credentialHash,
522
618
  createdAt: current?.createdAt ?? now,
523
619
  updatedAt: now,
524
- ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
620
+ ...expiresAt === void 0 ? {} : { expiresAt },
621
+ ...credentialOverlap(current, credentialHash, expiresAt, now)
525
622
  };
526
623
  devices.set(key, device);
527
624
  const route = routes.get(routeId);
@@ -529,12 +626,12 @@ function createMemoryStore2(options) {
529
626
  return cloneDevice(device);
530
627
  },
531
628
  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;
629
+ const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
630
+ return device ? cloneDevice(device) : void 0;
534
631
  },
535
632
  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);
633
+ const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
634
+ return !!device && (hashMatches2(device.credentialHash, credential) || device.previousCredentialHash !== void 0 && device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt >= now && hashMatches2(device.previousCredentialHash, credential));
538
635
  },
539
636
  revokeDevice(routeId, deviceId) {
540
637
  const safeRouteId = safeId2(routeId, "route id");
@@ -596,6 +693,8 @@ function openRelayRouteStore(options) {
596
693
  route_id TEXT NOT NULL REFERENCES relay_routes(id) ON DELETE CASCADE,
597
694
  device_id TEXT NOT NULL,
598
695
  credential_hash TEXT NOT NULL,
696
+ previous_credential_hash TEXT,
697
+ previous_credential_expires_at INTEGER,
599
698
  created_at INTEGER NOT NULL,
600
699
  updated_at INTEGER NOT NULL,
601
700
  expires_at INTEGER,
@@ -606,6 +705,12 @@ function openRelayRouteStore(options) {
606
705
  if (!relayDeviceColumns.some((column) => column.name === "expires_at")) {
607
706
  db.exec("ALTER TABLE relay_route_devices ADD COLUMN expires_at INTEGER");
608
707
  }
708
+ if (!relayDeviceColumns.some((column) => column.name === "previous_credential_hash")) {
709
+ db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_hash TEXT");
710
+ }
711
+ if (!relayDeviceColumns.some((column) => column.name === "previous_credential_expires_at")) {
712
+ db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_expires_at INTEGER");
713
+ }
609
714
  const relayRouteColumns = db.prepare("PRAGMA table_info(relay_routes)").all();
610
715
  if (!relayRouteColumns.some((column) => column.name === "owner_account_id")) {
611
716
  db.exec("ALTER TABLE relay_routes ADD COLUMN owner_account_id TEXT");
@@ -614,6 +719,21 @@ function openRelayRouteStore(options) {
614
719
  const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
615
720
  const getRoute = (id) => db.prepare("SELECT * FROM relay_routes WHERE id = ?").get(id);
616
721
  const getDevice = (routeId, deviceId) => db.prepare("SELECT * FROM relay_route_devices WHERE route_id = ? AND device_id = ?").get(routeId, deviceId);
722
+ const pruneExpiredDevices = (now) => {
723
+ db.prepare("DELETE FROM relay_route_devices WHERE expires_at IS NOT NULL AND expires_at < ?").run(now);
724
+ db.prepare(
725
+ `UPDATE relay_route_devices SET previous_credential_hash = NULL, previous_credential_expires_at = NULL
726
+ WHERE previous_credential_expires_at IS NOT NULL AND previous_credential_expires_at < ?`
727
+ ).run(now);
728
+ };
729
+ const liveDevice = (routeId, deviceId, now) => {
730
+ const row = getDevice(routeId, deviceId);
731
+ if (row?.expires_at !== null && row?.expires_at !== void 0 && row.expires_at < now) {
732
+ db.prepare("DELETE FROM relay_route_devices WHERE route_id = ? AND device_id = ?").run(routeId, deviceId);
733
+ return;
734
+ }
735
+ return row;
736
+ };
617
737
  const createRoute = db.transaction(
618
738
  (input, now) => {
619
739
  const id = safeId2(input.id ?? generateRouteId(), "route id");
@@ -644,25 +764,44 @@ function openRelayRouteStore(options) {
644
764
  (input, now) => {
645
765
  const routeId = safeId2(input.routeId, "route id");
646
766
  if (!getRoute(routeId)) throw new Error("relay route not found");
767
+ pruneExpiredDevices(now);
647
768
  const deviceId = safeId2(input.deviceId, "device id");
648
769
  const current = getDevice(routeId, deviceId);
770
+ const credentialHash = safeCredentialHash(input.credentialHash);
771
+ const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
772
+ const overlap = credentialOverlap(
773
+ current ? {
774
+ ...deviceFromRow(current),
775
+ ...current.previous_credential_hash === null || current.previous_credential_hash === void 0 ? {} : { previousCredentialHash: current.previous_credential_hash },
776
+ ...current.previous_credential_expires_at === null || current.previous_credential_expires_at === void 0 ? {} : { previousCredentialExpiresAt: current.previous_credential_expires_at }
777
+ } : void 0,
778
+ credentialHash,
779
+ expiresAt,
780
+ now
781
+ );
649
782
  const device = {
650
783
  routeId,
651
784
  deviceId,
652
- credentialHash: safeCredentialHash(input.credentialHash),
785
+ credentialHash,
653
786
  createdAt: current?.created_at ?? now,
654
787
  updatedAt: now,
655
- ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
788
+ ...expiresAt === void 0 ? {} : { expiresAt }
656
789
  };
657
790
  db.prepare(
658
- `INSERT INTO relay_route_devices (route_id, device_id, credential_hash, created_at, updated_at, expires_at)
659
- VALUES (?, ?, ?, ?, ?, ?)
791
+ `INSERT INTO relay_route_devices
792
+ (route_id, device_id, credential_hash, previous_credential_hash, previous_credential_expires_at,
793
+ created_at, updated_at, expires_at)
794
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
660
795
  ON CONFLICT(route_id, device_id) DO UPDATE SET credential_hash = excluded.credential_hash,
796
+ previous_credential_hash = excluded.previous_credential_hash,
797
+ previous_credential_expires_at = excluded.previous_credential_expires_at,
661
798
  updated_at = excluded.updated_at, expires_at = excluded.expires_at`
662
799
  ).run(
663
800
  device.routeId,
664
801
  device.deviceId,
665
802
  device.credentialHash,
803
+ overlap.previousCredentialHash ?? null,
804
+ overlap.previousCredentialExpiresAt ?? null,
666
805
  device.createdAt,
667
806
  device.updatedAt,
668
807
  device.expiresAt ?? null
@@ -671,19 +810,22 @@ function openRelayRouteStore(options) {
671
810
  return device;
672
811
  }
673
812
  );
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
813
+ const listRoutes = (now, ownerAccountId) => {
814
+ pruneExpiredDevices(now);
815
+ return db.prepare(
816
+ `SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
676
817
  FROM relay_routes r LEFT JOIN relay_route_devices d ON d.route_id = r.id
677
818
  AND (d.expires_at IS NULL OR d.expires_at >= ?)
678
819
  ${ownerAccountId === void 0 ? "" : "WHERE r.owner_account_id = ?"}
679
820
  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
- }));
821
+ ).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
822
+ id: row.id,
823
+ label: row.label,
824
+ deviceCount: row.device_count,
825
+ createdAt: row.created_at,
826
+ updatedAt: row.updated_at
827
+ }));
828
+ };
687
829
  return {
688
830
  mode: "sqlite",
689
831
  createRoute: (input, now = Date.now()) => cloneRoute(createRoute(input, now)),
@@ -694,6 +836,7 @@ function openRelayRouteStore(options) {
694
836
  listRoutes: (now = Date.now()) => listRoutes(now),
695
837
  listRoutesByOwner: (ownerAccountId, now = Date.now()) => listRoutes(now, ownerAccountId),
696
838
  countDevices(routeId, now = Date.now()) {
839
+ pruneExpiredDevices(now);
697
840
  const row = db.prepare(
698
841
  `SELECT COUNT(*) AS count FROM relay_route_devices
699
842
  WHERE route_id = ? AND (expires_at IS NULL OR expires_at >= ?)`
@@ -712,12 +855,12 @@ function openRelayRouteStore(options) {
712
855
  },
713
856
  putDevice: (input, now = Date.now()) => cloneDevice(putDevice(input, now)),
714
857
  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;
858
+ const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
859
+ return row ? deviceFromRow(row) : void 0;
717
860
  },
718
861
  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);
862
+ const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
863
+ 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
864
  },
722
865
  revokeDevice(routeId, deviceId) {
723
866
  const safeRouteId = safeId2(routeId, "route id");
@@ -734,9 +877,11 @@ function openRelayRouteStore(options) {
734
877
  var BLIND_RELAY_PROTOCOL_VERSION = 1;
735
878
  var BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES = 15e5;
736
879
  var BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES = 4e6;
880
+ var BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS = 1024;
737
881
  var BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 64;
738
882
  var BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE = 64 * 1024 * 1024;
739
883
  var BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE = 12e3;
884
+ var UNSAFE_DISPLAY_TEXT2 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
740
885
  function safeToken(value, field) {
741
886
  if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
742
887
  throw new Error(`invalid relay ${field}`);
@@ -756,7 +901,7 @@ function safeId3(value, field) {
756
901
  function safeLabel3(value) {
757
902
  if (typeof value !== "string") throw new Error("relay route label is required");
758
903
  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");
904
+ if (!label || label.length > 80 || UNSAFE_DISPLAY_TEXT2.test(label)) throw new Error("invalid relay route label");
760
905
  return label;
761
906
  }
762
907
  function safeExpiry2(value, now) {
@@ -794,7 +939,9 @@ function publicRoute(route) {
794
939
  function normalizeOrigin(value) {
795
940
  try {
796
941
  const url = new URL(value);
797
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password) return;
942
+ const loopback = url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
943
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
944
+ return;
798
945
  return url.origin;
799
946
  } catch {
800
947
  return;
@@ -856,6 +1003,7 @@ function createBlindRelayServer(options) {
856
1003
  const idleTimeoutMs = options.idleTimeoutMs ?? 2 * 6e4;
857
1004
  const maxFrameBytes = options.maxFrameBytes ?? BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES;
858
1005
  const maxQueueBytes = options.maxQueueBytes ?? BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES;
1006
+ const maxTotalConnections = options.maxTotalConnections ?? BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS;
859
1007
  const maxConnectionsPerRoute = options.maxConnectionsPerRoute ?? BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
860
1008
  const maxBytesPerMinute = options.maxBytesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE;
861
1009
  const maxMessagesPerMinute = options.maxMessagesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE;
@@ -864,17 +1012,63 @@ function createBlindRelayServer(options) {
864
1012
  [idleTimeoutMs, 1e4, 60 * 6e4, "idle timeout"],
865
1013
  [maxFrameBytes, 1024, 16 * 1024 * 1024, "frame limit"],
866
1014
  [maxQueueBytes, 1024, 64 * 1024 * 1024, "queue limit"],
1015
+ [maxTotalConnections, 1, 1e5, "total connection limit"],
867
1016
  [maxConnectionsPerRoute, 1, 1e4, "connection limit"],
868
1017
  [maxBytesPerMinute, 1024, 1024 * 1024 * 1024, "byte rate"],
869
1018
  [maxMessagesPerMinute, 10, 1e6, "message rate"]
870
1019
  ]) {
871
1020
  if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`invalid relay ${label}`);
872
1021
  }
873
- const allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin).filter(Boolean));
1022
+ const maxEnvelopeBytes = Math.max(8 * 1024, Math.ceil(maxFrameBytes * 4 / 3) + 8 * 1024);
1023
+ const configuredOrigins = options.allowedOrigins ?? [];
1024
+ const normalizedOrigins = configuredOrigins.map(normalizeOrigin);
1025
+ if (normalizedOrigins.some((origin) => origin === void 0)) {
1026
+ throw new Error("invalid relay allowed origin");
1027
+ }
1028
+ const allowedOrigins = new Set(normalizedOrigins);
874
1029
  const generateChannelId = options.generateChannelId ?? (() => `rrc_${randomBytes3(16).toString("base64url")}`);
875
1030
  const hosts = /* @__PURE__ */ new Map();
876
1031
  const devicesByChannel = /* @__PURE__ */ new Map();
1032
+ const sockets = /* @__PURE__ */ new Set();
1033
+ const hostRates = /* @__PURE__ */ new Map();
1034
+ const deviceRates = /* @__PURE__ */ new Map();
1035
+ const maxRateIdentities = Math.min(1e5, Math.max(256, maxTotalConnections * 4));
1036
+ const deviceRateKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
1037
+ const clearRouteRates = (routeId) => {
1038
+ hostRates.delete(routeId);
1039
+ for (const key of deviceRates.keys()) if (key.startsWith(`${routeId}\0`)) deviceRates.delete(key);
1040
+ };
1041
+ const rateWindowFor = (windows, key) => {
1042
+ const current = now();
1043
+ if (windows.size >= maxRateIdentities) {
1044
+ for (const [candidate, window] of windows) {
1045
+ if (current - window.lastSeenAt >= 12e4) windows.delete(candidate);
1046
+ }
1047
+ }
1048
+ const existing = windows.get(key);
1049
+ if (existing) {
1050
+ existing.lastSeenAt = current;
1051
+ return existing;
1052
+ }
1053
+ if (windows.size >= maxRateIdentities) return;
1054
+ const created = { startedAt: current, lastSeenAt: current, bytes: 0, messages: 0 };
1055
+ windows.set(key, created);
1056
+ return created;
1057
+ };
1058
+ const consumeRate = (window, bytes) => {
1059
+ const current = now();
1060
+ if (current - window.startedAt >= 6e4) {
1061
+ window.startedAt = current;
1062
+ window.bytes = 0;
1063
+ window.messages = 0;
1064
+ }
1065
+ window.lastSeenAt = current;
1066
+ window.bytes += bytes;
1067
+ window.messages += 1;
1068
+ return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
1069
+ };
877
1070
  const metrics = {
1071
+ activeConnections: 0,
878
1072
  activeHosts: 0,
879
1073
  activeDevices: 0,
880
1074
  acceptedConnections: 0,
@@ -911,6 +1105,14 @@ function createBlindRelayServer(options) {
911
1105
  }
912
1106
  reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
913
1107
  };
1108
+ const requireRecoverableAccount = async (request, reply) => {
1109
+ const account = accountStore?.verifyCredential(bearer(request) ?? "");
1110
+ if (account) {
1111
+ authenticatedAccounts.set(request, account);
1112
+ return;
1113
+ }
1114
+ reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
1115
+ };
914
1116
  app.addHook("onSend", async (_request, reply, payload) => {
915
1117
  reply.header("cache-control", "no-store");
916
1118
  reply.header("content-security-policy", "default-src 'none'");
@@ -930,7 +1132,12 @@ function createBlindRelayServer(options) {
930
1132
  });
931
1133
  app.get("/v1/metrics", { preHandler: requireRoot }, async () => ({
932
1134
  protocolVersion: BLIND_RELAY_PROTOCOL_VERSION,
933
- metrics: { ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size }
1135
+ metrics: {
1136
+ ...metrics,
1137
+ activeConnections: sockets.size,
1138
+ activeHosts: hosts.size,
1139
+ activeDevices: devicesByChannel.size
1140
+ }
934
1141
  }));
935
1142
  app.get("/v1/routes", { preHandler: requireRoot }, async () => ({ routes: store.listRoutes().map(publicRoute) }));
936
1143
  app.post(
@@ -976,6 +1183,7 @@ function createBlindRelayServer(options) {
976
1183
  reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
977
1184
  return;
978
1185
  }
1186
+ clearRouteRates(request.params.routeId);
979
1187
  const host = hosts.get(request.params.routeId);
980
1188
  host?.socket.close(4403, "route deleted");
981
1189
  for (const device of host?.devices.values() ?? []) device.socket.close(4403, "route deleted");
@@ -1032,22 +1240,12 @@ function createBlindRelayServer(options) {
1032
1240
  reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
1033
1241
  return;
1034
1242
  }
1243
+ deviceRates.delete(deviceRateKey(request.params.routeId, request.params.deviceId));
1035
1244
  const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
1036
1245
  live?.socket.close(4403, "device revoked");
1037
1246
  reply.code(204).send();
1038
1247
  }
1039
1248
  );
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
1249
  const closeDevice = (device, code = 1e3, reason = "device disconnected") => {
1052
1250
  if (device.closed) return;
1053
1251
  device.closed = true;
@@ -1056,7 +1254,10 @@ function createBlindRelayServer(options) {
1056
1254
  const host = hosts.get(device.routeId);
1057
1255
  if (host?.devices.get(device.deviceId) === device) host.devices.delete(device.deviceId);
1058
1256
  metrics.activeDevices = Math.max(0, metrics.activeDevices - 1);
1059
- if (host) safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes);
1257
+ if (host && !safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes)) {
1258
+ metrics.droppedFrames += 1;
1259
+ closeHost(host, 4408, "relay backpressure");
1260
+ }
1060
1261
  if (device.socket.readyState === device.socket.OPEN) device.socket.close(code, reason);
1061
1262
  };
1062
1263
  const touchDevice = (device) => {
@@ -1084,11 +1285,24 @@ function createBlindRelayServer(options) {
1084
1285
  usage: { routes: store.listRoutesByOwner(account.id, now()).length, maxRoutes: account.maxRoutes }
1085
1286
  });
1086
1287
  const closeAccountRoutes = (accountId, reason) => {
1288
+ for (const host of [...hosts.values()]) if (host.ownerAccountId === accountId) closeHost(host, 4403, reason);
1289
+ };
1290
+ const purgeAccountRoutes = (accountId) => {
1291
+ closeAccountRoutes(accountId, "account deleted");
1087
1292
  for (const route of store.listRoutesByOwner(accountId, now())) {
1088
- const host = hosts.get(route.id);
1089
- if (host) closeHost(host, 4403, reason);
1293
+ store.deleteRoute(route.id);
1294
+ clearRouteRates(route.id);
1090
1295
  }
1091
1296
  };
1297
+ for (const listedRoute of store.listRoutes(now())) {
1298
+ const route = store.getRoute(listedRoute.id);
1299
+ if (!route?.ownerAccountId) continue;
1300
+ const owner = accountStore.getAccount(route.ownerAccountId);
1301
+ if (!owner || owner.status === "deleted") {
1302
+ store.deleteRoute(route.id);
1303
+ clearRouteRates(route.id);
1304
+ }
1305
+ }
1092
1306
  const ownedRoute = (accountId, routeId) => {
1093
1307
  const route = store.getRoute(routeId);
1094
1308
  return route?.ownerAccountId === accountId ? route : void 0;
@@ -1096,18 +1310,47 @@ function createBlindRelayServer(options) {
1096
1310
  app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
1097
1311
  accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
1098
1312
  }));
1099
- app.post("/v1/accounts", { preHandler: requireRoot }, async (request, reply) => {
1100
- const accountCredential = generateRelayAccountCredential();
1313
+ const createAccountHandler = (clientHashedOnly) => async (request, reply) => {
1101
1314
  try {
1315
+ const hasCredentialHash = request.body?.credentialHash !== void 0;
1316
+ const hasCredentialLookup = request.body?.credentialLookup !== void 0;
1317
+ if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
1318
+ throw new Error("client-hashed account credential material is required");
1319
+ }
1320
+ const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
1321
+ const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
1322
+ const credential = suppliedCredentialMaterial ? {
1323
+ credentialHash: request.body?.credentialHash,
1324
+ credentialLookup: request.body?.credentialLookup
1325
+ } : accountCredential;
1102
1326
  const account = accountStore.createAccount({
1103
- ...request.body,
1104
- credential: accountCredential
1327
+ label: request.body?.label,
1328
+ ...request.body?.plan === void 0 ? {} : { plan: request.body.plan },
1329
+ ...request.body?.maxRoutes === void 0 ? {} : { maxRoutes: request.body.maxRoutes },
1330
+ ...request.body?.maxDevicesPerRoute === void 0 ? {} : { maxDevicesPerRoute: request.body.maxDevicesPerRoute },
1331
+ ...typeof credential === "string" ? { credential } : {
1332
+ credentialHash: credential.credentialHash,
1333
+ credentialLookup: credential.credentialLookup
1334
+ }
1335
+ });
1336
+ reply.code(201).send({
1337
+ ...accountEnvelope(account),
1338
+ ...accountCredential === void 0 ? {} : { accountCredential }
1105
1339
  });
1106
- reply.code(201).send({ ...accountEnvelope(account), accountCredential });
1107
1340
  } catch {
1108
1341
  reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1109
1342
  }
1110
- });
1343
+ };
1344
+ app.post(
1345
+ "/v1/accounts",
1346
+ { preHandler: requireRoot },
1347
+ createAccountHandler(false)
1348
+ );
1349
+ app.post(
1350
+ "/v1/accounts/client-hashed",
1351
+ { preHandler: requireRoot },
1352
+ createAccountHandler(true)
1353
+ );
1111
1354
  app.patch(
1112
1355
  "/v1/accounts/:accountId",
1113
1356
  { preHandler: requireRoot },
@@ -1122,7 +1365,8 @@ function createBlindRelayServer(options) {
1122
1365
  reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1123
1366
  return;
1124
1367
  }
1125
- if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
1368
+ if (account.status === "deleted") purgeAccountRoutes(account.id);
1369
+ else if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
1126
1370
  reply.code(200).send(accountEnvelope(account));
1127
1371
  } catch (error) {
1128
1372
  if (error instanceof RelayAccountRevisionConflictError) {
@@ -1137,34 +1381,58 @@ function createBlindRelayServer(options) {
1137
1381
  }
1138
1382
  }
1139
1383
  );
1384
+ const rotateAccountHandler = (clientHashedOnly) => async (request, reply) => {
1385
+ try {
1386
+ const hasCredentialHash = request.body?.credentialHash !== void 0;
1387
+ const hasCredentialLookup = request.body?.credentialLookup !== void 0;
1388
+ if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
1389
+ throw new Error("client-hashed account credential material is required");
1390
+ }
1391
+ const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
1392
+ const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
1393
+ const credential = suppliedCredentialMaterial ? {
1394
+ credentialHash: request.body?.credentialHash,
1395
+ credentialLookup: request.body?.credentialLookup
1396
+ } : accountCredential;
1397
+ const account = accountStore.rotateCredential(
1398
+ request.params.accountId,
1399
+ credential,
1400
+ safeRevision(request.body?.expectedRevision)
1401
+ );
1402
+ if (!account) {
1403
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
1404
+ return;
1405
+ }
1406
+ reply.code(200).send({
1407
+ ...accountEnvelope(account),
1408
+ ...accountCredential === void 0 ? {} : { accountCredential }
1409
+ });
1410
+ } catch (error) {
1411
+ if (error instanceof RelayAccountRevisionConflictError) {
1412
+ reply.code(409).send({
1413
+ code: "RELAY_ACCOUNT_REVISION_CONFLICT",
1414
+ error: "relay account revision conflict",
1415
+ current: accountEnvelope(error.current)
1416
+ });
1417
+ return;
1418
+ }
1419
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
1420
+ }
1421
+ };
1140
1422
  app.post(
1141
1423
  "/v1/accounts/:accountId/credential",
1142
1424
  { 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
- }
1425
+ rotateAccountHandler(false)
1426
+ );
1427
+ app.post(
1428
+ "/v1/accounts/:accountId/credential/client-hashed",
1429
+ { preHandler: requireRoot },
1430
+ rotateAccountHandler(true)
1431
+ );
1432
+ app.get(
1433
+ "/v1/account/recovery",
1434
+ { preHandler: requireRecoverableAccount },
1435
+ async (request) => accountEnvelope(authenticatedAccounts.get(request))
1168
1436
  );
1169
1437
  app.get(
1170
1438
  "/v1/account",
@@ -1242,6 +1510,7 @@ function createBlindRelayServer(options) {
1242
1510
  reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
1243
1511
  return;
1244
1512
  }
1513
+ clearRouteRates(request.params.routeId);
1245
1514
  const host = hosts.get(request.params.routeId);
1246
1515
  if (host) closeHost(host, 4403, "route deleted");
1247
1516
  reply.code(204).send();
@@ -1278,9 +1547,15 @@ function createBlindRelayServer(options) {
1278
1547
  }
1279
1548
  );
1280
1549
  }
1281
- app.register(websocket);
1550
+ app.register(websocket, { options: { maxPayload: maxEnvelopeBytes, perMessageDeflate: false } });
1282
1551
  app.register(async (scope) => {
1283
1552
  scope.get("/v1/connect", { websocket: true }, (socket, request) => {
1553
+ if (sockets.size >= maxTotalConnections) {
1554
+ metrics.rejectedConnections += 1;
1555
+ socket.close(4429, "relay connection limit");
1556
+ return;
1557
+ }
1558
+ sockets.add(socket);
1284
1559
  let authenticated = false;
1285
1560
  let liveHost;
1286
1561
  let liveDevice;
@@ -1299,29 +1574,41 @@ function createBlindRelayServer(options) {
1299
1574
  if (!authenticated) {
1300
1575
  try {
1301
1576
  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)) {
1577
+ const presentedOrigin = request.headers.origin;
1578
+ const origin = typeof presentedOrigin === "string" ? normalizeOrigin(presentedOrigin) : void 0;
1579
+ if (hello.role === "device" && allowedOrigins.size > 0 && presentedOrigin !== void 0 && (!origin || !allowedOrigins.has(origin))) {
1304
1580
  reject(4403, "origin denied");
1305
1581
  return;
1306
1582
  }
1307
1583
  if (hello.role === "host") {
1308
- if (!routeAccountIsActive(hello.routeId) || !store.authenticateHost(hello.routeId, hello.credential)) {
1584
+ const route = store.getRoute(hello.routeId);
1585
+ if (!route || route.ownerAccountId !== void 0 && accountStore?.getAccount(route.ownerAccountId)?.status !== "active" || !store.authenticateHost(hello.routeId, hello.credential)) {
1309
1586
  reject(4401, "authentication failed");
1310
1587
  return;
1311
1588
  }
1589
+ const rate = rateWindowFor(hostRates, hello.routeId);
1590
+ if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
1591
+ reject(4429, "relay identity rate limit");
1592
+ return;
1593
+ }
1312
1594
  const previous = hosts.get(hello.routeId);
1313
1595
  if (previous) closeHost(previous, 4409, "superseded host");
1314
1596
  liveHost = {
1315
1597
  socket,
1316
1598
  routeId: hello.routeId,
1599
+ ...route.ownerAccountId === void 0 ? {} : { ownerAccountId: route.ownerAccountId },
1317
1600
  devices: /* @__PURE__ */ new Map(),
1318
- rate: { startedAt: now(), bytes: 0, messages: 0 },
1601
+ rate,
1319
1602
  closed: false
1320
1603
  };
1321
1604
  hosts.set(hello.routeId, liveHost);
1322
1605
  metrics.activeHosts += 1;
1323
1606
  touchHost(liveHost);
1324
- safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes);
1607
+ if (!safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes)) {
1608
+ metrics.rejectedConnections += 1;
1609
+ closeHost(liveHost, 4408, "relay backpressure");
1610
+ return;
1611
+ }
1325
1612
  } else {
1326
1613
  if (!routeAccountIsActive(hello.routeId) || !store.authenticateDevice(hello.routeId, hello.deviceId, hello.credential, now())) {
1327
1614
  reject(4401, "authentication failed");
@@ -1336,6 +1623,11 @@ function createBlindRelayServer(options) {
1336
1623
  reject(4429, "route connection limit");
1337
1624
  return;
1338
1625
  }
1626
+ const rate = rateWindowFor(deviceRates, deviceRateKey(hello.routeId, hello.deviceId));
1627
+ if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
1628
+ reject(4429, "relay identity rate limit");
1629
+ return;
1630
+ }
1339
1631
  const previous = host.devices.get(hello.deviceId);
1340
1632
  if (previous) closeDevice(previous, 4409, "superseded device");
1341
1633
  const channelId = safeId3(generateChannelId(), "channel id");
@@ -1344,15 +1636,20 @@ function createBlindRelayServer(options) {
1344
1636
  routeId: hello.routeId,
1345
1637
  deviceId: hello.deviceId,
1346
1638
  channelId,
1347
- rate: { startedAt: now(), bytes: 0, messages: 0 },
1639
+ rate,
1348
1640
  closed: false
1349
1641
  };
1350
1642
  host.devices.set(hello.deviceId, liveDevice);
1351
1643
  devicesByChannel.set(channelId, liveDevice);
1352
1644
  metrics.activeDevices += 1;
1353
1645
  touchDevice(liveDevice);
1354
- safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes);
1646
+ if (!safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes)) {
1647
+ metrics.rejectedConnections += 1;
1648
+ closeDevice(liveDevice, 4408, "relay backpressure");
1649
+ return;
1650
+ }
1355
1651
  if (!safeSend(host.socket, { t: "peer-open", channelId, deviceId: hello.deviceId }, maxQueueBytes)) {
1652
+ metrics.rejectedConnections += 1;
1356
1653
  closeDevice(liveDevice, 4408, "host backpressure");
1357
1654
  return;
1358
1655
  }
@@ -1366,11 +1663,18 @@ function createBlindRelayServer(options) {
1366
1663
  return;
1367
1664
  }
1368
1665
  try {
1369
- const value = parseJson(raw, maxFrameBytes * 2);
1666
+ const value = parseJson(raw, maxEnvelopeBytes);
1370
1667
  if (liveDevice) {
1371
1668
  touchDevice(liveDevice);
1372
1669
  if (value?.t === "ping") {
1373
- safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
1670
+ if (!consumeRate(liveDevice.rate, 1)) {
1671
+ closeDevice(liveDevice, 4429, "rate limit");
1672
+ return;
1673
+ }
1674
+ if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
1675
+ metrics.droppedFrames += 1;
1676
+ closeDevice(liveDevice, 4408, "relay backpressure");
1677
+ }
1374
1678
  return;
1375
1679
  }
1376
1680
  const frame = parsePayload(value, false, maxFrameBytes);
@@ -1395,7 +1699,14 @@ function createBlindRelayServer(options) {
1395
1699
  if (liveHost) {
1396
1700
  touchHost(liveHost);
1397
1701
  if (value?.t === "ping") {
1398
- safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
1702
+ if (!consumeRate(liveHost.rate, 1)) {
1703
+ closeHost(liveHost, 4429, "rate limit");
1704
+ return;
1705
+ }
1706
+ if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
1707
+ metrics.droppedFrames += 1;
1708
+ closeHost(liveHost, 4408, "relay backpressure");
1709
+ }
1399
1710
  return;
1400
1711
  }
1401
1712
  if (value?.t === "close-peer") {
@@ -1433,6 +1744,7 @@ function createBlindRelayServer(options) {
1433
1744
  }
1434
1745
  });
1435
1746
  socket.once("close", () => {
1747
+ sockets.delete(socket);
1436
1748
  clearTimeout(handshakeTimer);
1437
1749
  if (liveDevice) closeDevice(liveDevice);
1438
1750
  if (liveHost) closeHost(liveHost);
@@ -1443,20 +1755,45 @@ function createBlindRelayServer(options) {
1443
1755
  });
1444
1756
  app.addHook("onClose", async () => {
1445
1757
  for (const host of [...hosts.values()]) closeHost(host, 1001, "relay shutting down");
1758
+ for (const socket of [...sockets]) socket.close(1001, "relay shutting down");
1759
+ hostRates.clear();
1760
+ deviceRates.clear();
1446
1761
  if (ownsStore) store.close();
1447
1762
  });
1448
1763
  return {
1449
1764
  app,
1450
1765
  store,
1451
1766
  ...accountStore ? { accountStore } : {},
1452
- metrics: () => ({ ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size })
1767
+ metrics: () => ({
1768
+ ...metrics,
1769
+ activeConnections: sockets.size,
1770
+ activeHosts: hosts.size,
1771
+ activeDevices: devicesByChannel.size
1772
+ })
1453
1773
  };
1454
1774
  }
1455
1775
 
1456
1776
  // src/data-dir.ts
1457
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1777
+ import {
1778
+ closeSync,
1779
+ constants,
1780
+ existsSync,
1781
+ fchmodSync,
1782
+ fstatSync,
1783
+ fsyncSync,
1784
+ linkSync,
1785
+ lstatSync,
1786
+ mkdirSync,
1787
+ openSync,
1788
+ readFileSync,
1789
+ renameSync,
1790
+ unlinkSync,
1791
+ writeFileSync
1792
+ } from "fs";
1458
1793
  import { randomBytes as randomBytes4 } from "crypto";
1459
- import { join } from "path";
1794
+ import { dirname, join } from "path";
1795
+ var MAX_ACCESS_TOKEN_BYTES = 4 * 1024;
1796
+ var MAX_ACCESS_TOKEN_FILE_BYTES = MAX_ACCESS_TOKEN_BYTES + 2;
1460
1797
  function resolveDataDir(env, exists = existsSync) {
1461
1798
  if (env.ROAMCODE_DATA_DIR) return env.ROAMCODE_DATA_DIR;
1462
1799
  if (env.REMOTE_CODER_DATA_DIR) return env.REMOTE_CODER_DATA_DIR;
@@ -1483,8 +1820,51 @@ function relayDataDir(env) {
1483
1820
  function relayOrigins(env) {
1484
1821
  return (env.ROAMCODE_RELAY_ALLOWED_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
1485
1822
  }
1823
+ function privateSecretFile(path, field) {
1824
+ let descriptor;
1825
+ try {
1826
+ const before = lstatSync2(path);
1827
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
1828
+ throw new Error("unsafe secret file");
1829
+ }
1830
+ descriptor = openSync2(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
1831
+ const opened = fstatSync2(descriptor);
1832
+ 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) {
1833
+ throw new Error("unsafe secret file");
1834
+ }
1835
+ return readFileSync2(descriptor, "utf8").trim();
1836
+ } catch {
1837
+ throw new Error(`${field} could not be read securely`);
1838
+ } finally {
1839
+ if (descriptor !== void 0) closeSync2(descriptor);
1840
+ }
1841
+ }
1486
1842
  function previousRootTokens(env) {
1487
- return (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
1843
+ const inline = (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
1844
+ const directory = env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR?.trim();
1845
+ if (inline.length > 0 && directory) {
1846
+ throw new Error(
1847
+ "ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS and ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR are mutually exclusive"
1848
+ );
1849
+ }
1850
+ if (!directory) return inline;
1851
+ let before;
1852
+ let entries;
1853
+ try {
1854
+ before = lstatSync2(directory);
1855
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
1856
+ throw new Error("unsafe secret directory");
1857
+ }
1858
+ entries = readdirSync(directory, { withFileTypes: true });
1859
+ const after = lstatSync2(directory);
1860
+ 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())) {
1861
+ throw new Error("unsafe secret directory");
1862
+ }
1863
+ } catch {
1864
+ throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR could not be read securely");
1865
+ }
1866
+ if (entries.length > 3) throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR may contain at most three files");
1867
+ return entries.map((entry) => entry.name).sort().map((name) => privateSecretFile(join2(directory, name), "previous relay root capability"));
1488
1868
  }
1489
1869
  function explicitBoolean(value, field) {
1490
1870
  if (value === void 0 || value === "" || value === "0" || value === "false") return false;
@@ -1497,18 +1877,12 @@ function secretValue(env, directKey, fileKey) {
1497
1877
  if (direct && file) throw new Error(`${directKey} and ${fileKey} are mutually exclusive`);
1498
1878
  if (direct) return direct;
1499
1879
  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();
1880
+ return privateSecretFile(file, fileKey);
1508
1881
  }
1509
1882
  async function startBlindRelay(env = process.env) {
1510
1883
  const rootToken = secretValue(env, "ROAMCODE_RELAY_ROOT_TOKEN", "ROAMCODE_RELAY_ROOT_TOKEN_FILE");
1511
1884
  if (!rootToken) throw new Error("ROAMCODE_RELAY_ROOT_TOKEN or ROAMCODE_RELAY_ROOT_TOKEN_FILE is required");
1885
+ const previousRoots = previousRootTokens(env);
1512
1886
  const allowedOrigins = relayOrigins(env);
1513
1887
  const allowAnyOrigin = explicitBoolean(env.ROAMCODE_RELAY_ALLOW_ANY_ORIGIN, "relay allow-any-origin flag");
1514
1888
  if (env.NODE_ENV === "production" && allowedOrigins.length === 0 && !allowAnyOrigin) {
@@ -1537,7 +1911,7 @@ async function startBlindRelay(env = process.env) {
1537
1911
  try {
1538
1912
  relay = createBlindRelayServer({
1539
1913
  rootToken,
1540
- previousRootTokens: previousRootTokens(env),
1914
+ previousRootTokens: previousRoots,
1541
1915
  store,
1542
1916
  ...accountStore ? { accountStore } : {},
1543
1917
  allowedOrigins,
@@ -1569,6 +1943,13 @@ async function startBlindRelay(env = process.env) {
1569
1943
  64 * 1024 * 1024,
1570
1944
  "relay queue limit"
1571
1945
  ),
1946
+ maxTotalConnections: boundedInteger(
1947
+ env.ROAMCODE_RELAY_MAX_TOTAL_CONNECTIONS,
1948
+ 1024,
1949
+ 1,
1950
+ 1e5,
1951
+ "relay total connection limit"
1952
+ ),
1572
1953
  maxConnectionsPerRoute: boundedInteger(
1573
1954
  env.ROAMCODE_RELAY_MAX_CONNECTIONS_PER_ROUTE,
1574
1955
  64,