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