@roamcode.ai/server 1.1.0 → 1.3.0

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