@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.
package/dist/index.js CHANGED
@@ -49,9 +49,27 @@ function hookAuthFileContent(token) {
49
49
  }
50
50
 
51
51
  // src/data-dir.ts
52
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
52
+ import {
53
+ closeSync,
54
+ constants,
55
+ existsSync,
56
+ fchmodSync,
57
+ fstatSync,
58
+ fsyncSync,
59
+ linkSync,
60
+ lstatSync,
61
+ mkdirSync,
62
+ openSync,
63
+ readFileSync,
64
+ renameSync,
65
+ unlinkSync,
66
+ writeFileSync
67
+ } from "fs";
53
68
  import { randomBytes } from "crypto";
54
- import { join as join2 } from "path";
69
+ import { dirname, join as join2 } from "path";
70
+ var ACCESS_TOKEN_FILE = "token";
71
+ var MAX_ACCESS_TOKEN_BYTES = 4 * 1024;
72
+ var MAX_ACCESS_TOKEN_FILE_BYTES = MAX_ACCESS_TOKEN_BYTES + 2;
55
73
  function resolveDataDir(env, exists = existsSync) {
56
74
  if (env.ROAMCODE_DATA_DIR) return env.ROAMCODE_DATA_DIR;
57
75
  if (env.REMOTE_CODER_DATA_DIR) return env.REMOTE_CODER_DATA_DIR;
@@ -67,23 +85,164 @@ function ensureDataDir(dir) {
67
85
  function generateAccessToken() {
68
86
  return randomBytes(32).toString("base64url");
69
87
  }
88
+ function fsyncDirectory(path) {
89
+ let descriptor;
90
+ try {
91
+ descriptor = openSync(path, constants.O_RDONLY);
92
+ fsyncSync(descriptor);
93
+ } catch (error) {
94
+ const code = error.code;
95
+ if (code !== "EINVAL" && code !== "ENOTSUP" && !(process.platform === "win32" && code === "EPERM")) {
96
+ throw error;
97
+ }
98
+ } finally {
99
+ if (descriptor !== void 0) closeSync(descriptor);
100
+ }
101
+ }
102
+ function existingTokenStat(path) {
103
+ try {
104
+ return lstatSync(path);
105
+ } catch (error) {
106
+ if (error.code === "ENOENT") return void 0;
107
+ throw error;
108
+ }
109
+ }
110
+ function validateAccessToken(token) {
111
+ if (!token || Buffer.byteLength(token, "utf8") > MAX_ACCESS_TOKEN_BYTES || /[\u0000-\u0020\u007f]/u.test(token)) {
112
+ throw new Error("access token must be non-empty printable text without whitespace");
113
+ }
114
+ return token;
115
+ }
116
+ function decodePersistedAccessToken(content) {
117
+ let token = content;
118
+ if (token.endsWith("\n")) token = token.slice(0, -1);
119
+ if (token.endsWith("\r")) token = token.slice(0, -1);
120
+ return validateAccessToken(token);
121
+ }
122
+ function readPersistedAccessToken(path) {
123
+ const before = existingTokenStat(path);
124
+ if (!before) return void 0;
125
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error("access token path must be a regular file");
126
+ if (before.size > MAX_ACCESS_TOKEN_FILE_BYTES) throw new Error("access token file is too large");
127
+ if (typeof process.getuid === "function" && before.uid !== process.getuid()) {
128
+ throw new Error("access token file must be owned by the current user");
129
+ }
130
+ let descriptor;
131
+ try {
132
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
133
+ const opened = fstatSync(descriptor);
134
+ if (!opened.isFile() || opened.size > MAX_ACCESS_TOKEN_FILE_BYTES || opened.dev !== before.dev || opened.ino !== before.ino || typeof process.getuid === "function" && opened.uid !== process.getuid()) {
135
+ throw new Error("access token changed while it was being opened");
136
+ }
137
+ fchmodSync(descriptor, 384);
138
+ return decodePersistedAccessToken(readFileSync(descriptor, "utf8"));
139
+ } catch (error) {
140
+ if (error instanceof Error && error.message.startsWith("access token ")) throw error;
141
+ throw new Error("access token file could not be read safely");
142
+ } finally {
143
+ if (descriptor !== void 0) closeSync(descriptor);
144
+ }
145
+ }
146
+ function stageAccessToken(path, token) {
147
+ const temporary = `${path}.${randomBytes(12).toString("hex")}.tmp`;
148
+ let descriptor;
149
+ let failed = false;
150
+ let failure;
151
+ try {
152
+ descriptor = openSync(
153
+ temporary,
154
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0),
155
+ 384
156
+ );
157
+ fchmodSync(descriptor, 384);
158
+ writeFileSync(descriptor, `${validateAccessToken(token)}
159
+ `, "utf8");
160
+ fsyncSync(descriptor);
161
+ } catch (error) {
162
+ failed = true;
163
+ failure = error;
164
+ }
165
+ if (descriptor !== void 0) {
166
+ try {
167
+ closeSync(descriptor);
168
+ } catch (error) {
169
+ if (!failed) {
170
+ failed = true;
171
+ failure = error;
172
+ }
173
+ }
174
+ descriptor = void 0;
175
+ }
176
+ if (failed) {
177
+ try {
178
+ unlinkSync(temporary);
179
+ } catch {
180
+ }
181
+ throw failure;
182
+ }
183
+ return temporary;
184
+ }
70
185
  function persistAccessToken(dataDir, token) {
71
186
  ensureDataDir(dataDir);
72
- const tokenPath = join2(dataDir, "token");
73
- writeFileSync(tokenPath, token + "\n", { mode: 384 });
74
- chmodSync(tokenPath, 384);
187
+ const tokenPath = join2(dataDir, ACCESS_TOKEN_FILE);
188
+ const existing = existingTokenStat(tokenPath);
189
+ if (existing) {
190
+ if (!existing.isFile() || existing.isSymbolicLink()) throw new Error("access token path must be a regular file");
191
+ if (typeof process.getuid === "function" && existing.uid !== process.getuid()) {
192
+ throw new Error("access token file must be owned by the current user");
193
+ }
194
+ }
195
+ const temporary = stageAccessToken(tokenPath, token);
196
+ try {
197
+ renameSync(temporary, tokenPath);
198
+ try {
199
+ fsyncDirectory(dirname(tokenPath));
200
+ } catch (error) {
201
+ let visible;
202
+ try {
203
+ visible = readPersistedAccessToken(tokenPath);
204
+ } catch {
205
+ }
206
+ if (visible !== token) throw error;
207
+ }
208
+ } finally {
209
+ try {
210
+ unlinkSync(temporary);
211
+ } catch {
212
+ }
213
+ }
214
+ }
215
+ function installAccessTokenIfMissing(dataDir, token) {
216
+ ensureDataDir(dataDir);
217
+ const tokenPath = join2(dataDir, ACCESS_TOKEN_FILE);
218
+ const temporary = stageAccessToken(tokenPath, token);
219
+ let installed = false;
220
+ try {
221
+ try {
222
+ linkSync(temporary, tokenPath);
223
+ installed = true;
224
+ } catch (error) {
225
+ if (error.code !== "EEXIST") throw error;
226
+ }
227
+ } finally {
228
+ try {
229
+ unlinkSync(temporary);
230
+ } catch {
231
+ }
232
+ }
233
+ if (installed) fsyncDirectory(dirname(tokenPath));
234
+ return installed;
75
235
  }
76
236
  function resolveAccessToken(opts) {
77
237
  if (opts.configured) return { token: opts.configured, generated: false };
78
- const tokenPath = join2(opts.dataDir, "token");
79
- try {
80
- const existing = readFileSync(tokenPath, "utf8").trim();
81
- if (existing) return { token: existing, generated: false };
82
- } catch {
83
- }
84
- const token = (opts.generate ?? generateAccessToken)();
85
- persistAccessToken(opts.dataDir, token);
86
- return { token, generated: true };
238
+ const tokenPath = join2(opts.dataDir, ACCESS_TOKEN_FILE);
239
+ const existing = readPersistedAccessToken(tokenPath);
240
+ if (existing) return { token: existing, generated: false };
241
+ const token = validateAccessToken((opts.generate ?? generateAccessToken)());
242
+ if (installAccessTokenIfMissing(opts.dataDir, token)) return { token, generated: true };
243
+ const winner = readPersistedAccessToken(tokenPath);
244
+ if (!winner) throw new Error("access token initialization raced with removal; retry startup");
245
+ return { token: winner, generated: false };
87
246
  }
88
247
 
89
248
  // src/origin-check.ts
@@ -309,7 +468,7 @@ var AuthGate = class {
309
468
  // src/fs-service.ts
310
469
  import { createWriteStream } from "fs";
311
470
  import { readdir, readFile, stat, realpath, open, mkdir, unlink, rename, rmdir } from "fs/promises";
312
- import { constants } from "fs";
471
+ import { constants as constants2 } from "fs";
313
472
  import { resolve, join as join3, sep, basename } from "path";
314
473
  import { randomUUID } from "crypto";
315
474
  import { pipeline } from "stream/promises";
@@ -430,7 +589,7 @@ var FsService = class {
430
589
  const dir = this.resolveWithinRoot(targetDir);
431
590
  await this.realWithinRoot(dir);
432
591
  const dest = this.resolveWithinRoot(join3(dir, filename));
433
- const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW;
592
+ const flags = constants2.O_WRONLY | constants2.O_CREAT | constants2.O_TRUNC | constants2.O_NOFOLLOW;
434
593
  let fh;
435
594
  try {
436
595
  fh = await open(dest, flags, 384);
@@ -2669,6 +2828,7 @@ function establishRelayChannel(options) {
2669
2828
  var require3 = createRequire2(import.meta.url);
2670
2829
  var PAIRING_TTL_MS = 5 * 60 * 1e3;
2671
2830
  var LAST_SEEN_WRITE_INTERVAL_MS = 60 * 1e3;
2831
+ var UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
2672
2832
  var DevicePairingError = class extends Error {
2673
2833
  constructor(code, message) {
2674
2834
  super(message);
@@ -2724,7 +2884,7 @@ function randomCredential(prefix) {
2724
2884
  function normalizeDeviceName(value) {
2725
2885
  if (typeof value !== "string") return void 0;
2726
2886
  const normalized = value.trim().replace(/\s+/g, " ");
2727
- if (!normalized || normalized.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(normalized)) return void 0;
2887
+ if (!normalized || normalized.length > 80 || UNSAFE_DISPLAY_TEXT.test(normalized)) return void 0;
2728
2888
  return normalized;
2729
2889
  }
2730
2890
  function inMemoryStore2(opts) {
@@ -2755,6 +2915,7 @@ function inMemoryStore2(opts) {
2755
2915
  }
2756
2916
  throw new Error("could not allocate a unique pairing credential");
2757
2917
  },
2918
+ cancelPairing: (secret) => pairings.delete(digest(secret)),
2758
2919
  claimPairing(secret, rawName, now = Date.now(), relayIdentityPublicKey) {
2759
2920
  const name = normalizeDeviceName(rawName);
2760
2921
  if (!name) return void 0;
@@ -2804,13 +2965,46 @@ function inMemoryStore2(opts) {
2804
2965
  prune(now);
2805
2966
  return [...pairings.values()].some((pairing) => pairing.deviceId === deviceId && pairing.expiresAt >= now);
2806
2967
  },
2968
+ beginRelayPairingCancellation(deviceId, now = Date.now()) {
2969
+ prune(now);
2970
+ const pairing = [...pairings.values()].find((candidate) => candidate.deviceId === deviceId);
2971
+ if (!pairing || pairing.expiresAt < now) return { status: "missing" };
2972
+ if (pairing.cancellationId) return { status: "busy" };
2973
+ const reservation = { deviceId, reservationId: randomUUID2() };
2974
+ pairing.cancellationId = reservation.reservationId;
2975
+ return { status: "reserved", reservation };
2976
+ },
2977
+ releaseRelayPairingCancellation(reservation) {
2978
+ const pairing = [...pairings.values()].find((candidate) => candidate.deviceId === reservation.deviceId);
2979
+ if (pairing?.cancellationId !== reservation.reservationId) return false;
2980
+ delete pairing.cancellationId;
2981
+ return true;
2982
+ },
2983
+ finishRelayPairingCancellation(reservation) {
2984
+ for (const [secretHash, pairing] of pairings) {
2985
+ if (pairing.deviceId === reservation.deviceId && pairing.cancellationId === reservation.reservationId) {
2986
+ pairings.delete(secretHash);
2987
+ return true;
2988
+ }
2989
+ }
2990
+ return false;
2991
+ },
2992
+ cancelRelayPairing(deviceId) {
2993
+ for (const [secretHash, pairing] of pairings) {
2994
+ if (pairing.deviceId === deviceId) {
2995
+ pairings.delete(secretHash);
2996
+ return true;
2997
+ }
2998
+ }
2999
+ return false;
3000
+ },
2807
3001
  claimRelayPairing(secret, token, rawName, relayIdentityPublicKey, now = Date.now()) {
2808
3002
  const name = normalizeDeviceName(rawName);
2809
3003
  if (!name || !/^rcd_[A-Za-z0-9_-]{43}$/.test(token)) return void 0;
2810
3004
  prune(now);
2811
3005
  const secretHash = digest(secret);
2812
3006
  const pairing = pairings.get(secretHash);
2813
- if (!pairing?.deviceId || !pairing.tokenHash || pairing.expiresAt < now || pairing.tokenHash !== digest(token))
3007
+ if (!pairing?.deviceId || !pairing.tokenHash || pairing.cancellationId || pairing.expiresAt < now || pairing.tokenHash !== digest(token))
2814
3008
  return void 0;
2815
3009
  const relayIdentity = relayIdentityForClaim(pairing.scopes, relayIdentityPublicKey);
2816
3010
  pairings.delete(secretHash);
@@ -2894,7 +3088,8 @@ function openDeviceStore(opts) {
2894
3088
  expires_at INTEGER NOT NULL,
2895
3089
  scopes_json TEXT NOT NULL DEFAULT '["direct"]',
2896
3090
  device_id TEXT,
2897
- token_hash TEXT
3091
+ token_hash TEXT,
3092
+ cancellation_id TEXT
2898
3093
  );
2899
3094
  CREATE INDEX IF NOT EXISTS devices_last_seen_idx ON devices(last_seen_at DESC);
2900
3095
  CREATE INDEX IF NOT EXISTS pairing_expiry_idx ON pairing_sessions(expires_at);
@@ -2919,6 +3114,9 @@ function openDeviceStore(opts) {
2919
3114
  if (!pairingColumns.some((column) => column.name === "token_hash")) {
2920
3115
  db.exec("ALTER TABLE pairing_sessions ADD COLUMN token_hash TEXT");
2921
3116
  }
3117
+ if (!pairingColumns.some((column) => column.name === "cancellation_id")) {
3118
+ db.exec("ALTER TABLE pairing_sessions ADD COLUMN cancellation_id TEXT");
3119
+ }
2922
3120
  db.exec(`
2923
3121
  CREATE UNIQUE INDEX IF NOT EXISTS pairing_device_id_idx ON pairing_sessions(device_id) WHERE device_id IS NOT NULL;
2924
3122
  CREATE UNIQUE INDEX IF NOT EXISTS pairing_token_hash_idx ON pairing_sessions(token_hash) WHERE token_hash IS NOT NULL;
@@ -2933,12 +3131,26 @@ function openDeviceStore(opts) {
2933
3131
  "INSERT INTO pairing_sessions (secret_hash, created_at, expires_at, scopes_json, device_id, token_hash) VALUES (?, ?, ?, ?, ?, ?)"
2934
3132
  );
2935
3133
  const prunePairings = db.prepare("DELETE FROM pairing_sessions WHERE expires_at < ?");
3134
+ const cancelPairing = db.prepare("DELETE FROM pairing_sessions WHERE secret_hash = ?");
2936
3135
  const findPairing = db.prepare(
2937
- "SELECT secret_hash, expires_at, scopes_json, device_id, token_hash FROM pairing_sessions WHERE secret_hash = ?"
3136
+ "SELECT secret_hash, expires_at, scopes_json, device_id, token_hash, cancellation_id FROM pairing_sessions WHERE secret_hash = ?"
2938
3137
  );
2939
3138
  const findPendingRelayPairing = db.prepare(
2940
3139
  "SELECT 1 FROM pairing_sessions WHERE device_id = ? AND expires_at >= ? LIMIT 1"
2941
3140
  );
3141
+ const cancelRelayPairing = db.prepare("DELETE FROM pairing_sessions WHERE device_id = ?");
3142
+ const findRelayPairingByDevice = db.prepare(
3143
+ "SELECT secret_hash, expires_at, scopes_json, device_id, token_hash, cancellation_id FROM pairing_sessions WHERE device_id = ?"
3144
+ );
3145
+ const reserveRelayPairingCancellation = db.prepare(
3146
+ "UPDATE pairing_sessions SET cancellation_id = ? WHERE device_id = ? AND cancellation_id IS NULL"
3147
+ );
3148
+ const releaseRelayPairingCancellation = db.prepare(
3149
+ "UPDATE pairing_sessions SET cancellation_id = NULL WHERE device_id = ? AND cancellation_id = ?"
3150
+ );
3151
+ const finishRelayPairingCancellation = db.prepare(
3152
+ "DELETE FROM pairing_sessions WHERE device_id = ? AND cancellation_id = ?"
3153
+ );
2942
3154
  const deletePairing = db.prepare("DELETE FROM pairing_sessions WHERE secret_hash = ?");
2943
3155
  const insertDevice = db.prepare(
2944
3156
  "INSERT INTO devices (id, name, token_hash, created_at, last_seen_at, scopes_json, relay_public_key, relay_fingerprint) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
@@ -2985,7 +3197,7 @@ function openDeviceStore(opts) {
2985
3197
  (secretHash, token, name, now, relayIdentityPublicKey) => {
2986
3198
  prunePairings.run(now);
2987
3199
  const pairing = findPairing.get(secretHash);
2988
- if (!pairing?.device_id || !pairing.token_hash || pairing.expires_at < now || pairing.token_hash !== digest(token))
3200
+ if (!pairing?.device_id || !pairing.token_hash || pairing.cancellation_id || pairing.expires_at < now || pairing.token_hash !== digest(token))
2989
3201
  return void 0;
2990
3202
  const scopes = scopesFromJson(pairing.scopes_json);
2991
3203
  const relayIdentity = relayIdentityForClaim(scopes, relayIdentityPublicKey);
@@ -3032,6 +3244,7 @@ function openDeviceStore(opts) {
3032
3244
  }
3033
3245
  throw new Error("could not allocate a unique pairing credential");
3034
3246
  },
3247
+ cancelPairing: (secret) => cancelPairing.run(digest(secret)).changes > 0,
3035
3248
  claimPairing(secret, rawName, now = Date.now(), relayIdentityPublicKey) {
3036
3249
  const name = normalizeDeviceName(rawName);
3037
3250
  if (!name) return void 0;
@@ -3065,6 +3278,20 @@ function openDeviceStore(opts) {
3065
3278
  prunePairings.run(now);
3066
3279
  return findPendingRelayPairing.get(deviceId, now) !== void 0;
3067
3280
  },
3281
+ beginRelayPairingCancellation(deviceId, now = Date.now()) {
3282
+ prunePairings.run(now);
3283
+ const pairing = findRelayPairingByDevice.get(deviceId);
3284
+ if (!pairing || pairing.expires_at < now) return { status: "missing" };
3285
+ if (pairing.cancellation_id) return { status: "busy" };
3286
+ const reservation = { deviceId, reservationId: randomUUID2() };
3287
+ if (reserveRelayPairingCancellation.run(reservation.reservationId, deviceId).changes !== 1) {
3288
+ return findRelayPairingByDevice.get(deviceId) ? { status: "busy" } : { status: "missing" };
3289
+ }
3290
+ return { status: "reserved", reservation };
3291
+ },
3292
+ releaseRelayPairingCancellation: (reservation) => releaseRelayPairingCancellation.run(reservation.deviceId, reservation.reservationId).changes === 1,
3293
+ finishRelayPairingCancellation: (reservation) => finishRelayPairingCancellation.run(reservation.deviceId, reservation.reservationId).changes === 1,
3294
+ cancelRelayPairing: (deviceId) => cancelRelayPairing.run(deviceId).changes > 0,
3068
3295
  claimRelayPairing(secret, token, rawName, relayIdentityPublicKey, now = Date.now()) {
3069
3296
  const name = normalizeDeviceName(rawName);
3070
3297
  if (!name || !/^rcd_[A-Za-z0-9_-]{43}$/.test(token)) return void 0;
@@ -4060,34 +4287,49 @@ var PresenceCoordinator = class {
4060
4287
 
4061
4288
  // src/relay-identity-store.ts
4062
4289
  import {
4063
- chmodSync as chmodSync2,
4064
- closeSync,
4065
- constants as constants2,
4290
+ closeSync as closeSync2,
4291
+ constants as constants3,
4066
4292
  existsSync as existsSync2,
4067
- fsyncSync,
4068
- linkSync,
4069
- lstatSync,
4070
- openSync,
4293
+ fchmodSync as fchmodSync2,
4294
+ fstatSync as fstatSync2,
4295
+ fsyncSync as fsyncSync2,
4296
+ linkSync as linkSync2,
4297
+ lstatSync as lstatSync2,
4298
+ openSync as openSync2,
4071
4299
  readFileSync as readFileSync2,
4072
- unlinkSync,
4300
+ unlinkSync as unlinkSync2,
4073
4301
  writeFileSync as writeFileSync2
4074
4302
  } from "fs";
4075
4303
  import { randomBytes as randomBytes5 } from "crypto";
4076
- import { join as join4 } from "path";
4304
+ import { dirname as dirname2, join as join4 } from "path";
4077
4305
  var RELAY_IDENTITY_FILE = "relay-identity.json";
4306
+ var MAX_IDENTITY_BYTES = 32 * 1024;
4078
4307
  function readIdentity(path) {
4079
- let stat8;
4308
+ let before;
4080
4309
  try {
4081
- stat8 = lstatSync(path);
4310
+ before = lstatSync2(path);
4082
4311
  } catch {
4083
4312
  throw new Error("relay identity could not be read");
4084
4313
  }
4085
- if (!stat8.isFile() || stat8.isSymbolicLink()) throw new Error("relay identity path must be a regular file");
4314
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error("relay identity path must be a regular file");
4315
+ if (before.size > MAX_IDENTITY_BYTES) throw new Error("relay identity file is too large");
4316
+ if (typeof process.getuid === "function" && before.uid !== process.getuid()) {
4317
+ throw new Error("relay identity must be owned by the current user");
4318
+ }
4319
+ let descriptor;
4086
4320
  let document;
4087
4321
  try {
4088
- document = JSON.parse(readFileSync2(path, "utf8"));
4322
+ descriptor = openSync2(path, constants3.O_RDONLY | (constants3.O_NOFOLLOW ?? 0));
4323
+ const opened = fstatSync2(descriptor);
4324
+ if (!opened.isFile() || opened.size > MAX_IDENTITY_BYTES || opened.dev !== before.dev || opened.ino !== before.ino || typeof process.getuid === "function" && opened.uid !== process.getuid()) {
4325
+ throw new Error("relay identity changed while it was being opened");
4326
+ }
4327
+ fchmodSync2(descriptor, 384);
4328
+ document = JSON.parse(readFileSync2(descriptor, "utf8"));
4089
4329
  } catch {
4090
4330
  throw new Error("relay identity file is corrupt; restore it from backup instead of replacing it");
4331
+ } finally {
4332
+ if (descriptor !== void 0) closeSync2(descriptor);
4091
4333
  }
4092
4334
  if (document.version !== 1 || !Number.isSafeInteger(document.createdAt) || document.createdAt < 0) {
4093
4335
  throw new Error("relay identity file has an unsupported format");
@@ -4098,34 +4340,54 @@ function readIdentity(path) {
4098
4340
  } catch {
4099
4341
  throw new Error("relay identity file contains an invalid or mismatched keypair");
4100
4342
  }
4101
- chmodSync2(path, 384);
4102
4343
  return { identity: identity2, createdAt: document.createdAt, path, generated: false };
4103
4344
  }
4345
+ function fsyncDirectory2(path) {
4346
+ let descriptor;
4347
+ try {
4348
+ descriptor = openSync2(path, constants3.O_RDONLY);
4349
+ fsyncSync2(descriptor);
4350
+ } catch (error) {
4351
+ const code = error.code;
4352
+ if (code !== "EINVAL" && code !== "ENOTSUP" && !(process.platform === "win32" && code === "EPERM")) {
4353
+ throw error;
4354
+ }
4355
+ } finally {
4356
+ if (descriptor !== void 0) closeSync2(descriptor);
4357
+ }
4358
+ }
4104
4359
  function writeDurably(path, document) {
4105
4360
  const temporary = `${path}.${randomBytes5(12).toString("hex")}.tmp`;
4106
4361
  let descriptor;
4362
+ let installed = false;
4107
4363
  try {
4108
- descriptor = openSync(temporary, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
4364
+ descriptor = openSync2(
4365
+ temporary,
4366
+ constants3.O_CREAT | constants3.O_EXCL | constants3.O_WRONLY | (constants3.O_NOFOLLOW ?? 0),
4367
+ 384
4368
+ );
4369
+ fchmodSync2(descriptor, 384);
4109
4370
  writeFileSync2(descriptor, `${JSON.stringify(document)}
4110
4371
  `, "utf8");
4111
- fsyncSync(descriptor);
4112
- closeSync(descriptor);
4372
+ fsyncSync2(descriptor);
4373
+ closeSync2(descriptor);
4113
4374
  descriptor = void 0;
4114
- chmodSync2(temporary, 384);
4115
4375
  try {
4116
- linkSync(temporary, path);
4117
- return true;
4376
+ linkSync2(temporary, path);
4377
+ installed = true;
4118
4378
  } catch (error) {
4119
4379
  if (error.code === "EEXIST") return false;
4120
4380
  throw error;
4121
4381
  }
4122
4382
  } finally {
4123
- if (descriptor !== void 0) closeSync(descriptor);
4383
+ if (descriptor !== void 0) closeSync2(descriptor);
4124
4384
  try {
4125
- unlinkSync(temporary);
4385
+ unlinkSync2(temporary);
4126
4386
  } catch {
4127
4387
  }
4128
4388
  }
4389
+ if (installed) fsyncDirectory2(dirname2(path));
4390
+ return installed;
4129
4391
  }
4130
4392
  function loadOrCreateRelayIdentity(options) {
4131
4393
  ensureDataDir(options.dataDir);
@@ -4143,6 +4405,7 @@ function loadOrCreateRelayIdentity(options) {
4143
4405
  import { createHash as createHash3, randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual3 } from "crypto";
4144
4406
  import { createRequire as createRequire4 } from "module";
4145
4407
  var require5 = createRequire4(import.meta.url);
4408
+ var UNSAFE_DISPLAY_TEXT2 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
4146
4409
  function safeId4(value, field) {
4147
4410
  if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
4148
4411
  return value;
@@ -4156,7 +4419,7 @@ function safeOwnerAccountId(value) {
4156
4419
  function safeLabel2(value) {
4157
4420
  if (typeof value !== "string") throw new Error("relay route label is required");
4158
4421
  const normalized = value.trim().replace(/\s+/g, " ");
4159
- if (!normalized || normalized.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(normalized)) {
4422
+ if (!normalized || normalized.length > 80 || UNSAFE_DISPLAY_TEXT2.test(normalized)) {
4160
4423
  throw new Error("invalid relay route label");
4161
4424
  }
4162
4425
  return normalized;
@@ -4198,22 +4461,72 @@ function cloneRoute(route) {
4198
4461
  return { ...route };
4199
4462
  }
4200
4463
  function cloneDevice(device) {
4201
- return { ...device };
4464
+ return {
4465
+ routeId: device.routeId,
4466
+ deviceId: device.deviceId,
4467
+ credentialHash: device.credentialHash,
4468
+ createdAt: device.createdAt,
4469
+ updatedAt: device.updatedAt,
4470
+ ...device.expiresAt === void 0 ? {} : { expiresAt: device.expiresAt }
4471
+ };
4472
+ }
4473
+ function credentialOverlap(current, credentialHash, expiresAt, now) {
4474
+ if (!current || expiresAt !== void 0) return {};
4475
+ if (current.expiresAt !== void 0) {
4476
+ if (current.credentialHash === credentialHash) {
4477
+ throw new Error("relay bootstrap promotion must rotate the device credential");
4478
+ }
4479
+ return {
4480
+ previousCredentialHash: current.credentialHash,
4481
+ previousCredentialExpiresAt: current.expiresAt
4482
+ };
4483
+ }
4484
+ if (current.previousCredentialHash !== void 0 && current.previousCredentialExpiresAt !== void 0 && current.previousCredentialExpiresAt >= now) {
4485
+ if (current.previousCredentialHash === credentialHash) {
4486
+ throw new Error("relay bootstrap credential cannot become durable");
4487
+ }
4488
+ return {
4489
+ previousCredentialHash: current.previousCredentialHash,
4490
+ previousCredentialExpiresAt: current.previousCredentialExpiresAt
4491
+ };
4492
+ }
4493
+ return {};
4202
4494
  }
4203
4495
  function createMemoryStore2(options) {
4204
4496
  const routes = /* @__PURE__ */ new Map();
4205
4497
  const devices = /* @__PURE__ */ new Map();
4206
4498
  const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes6(16).toString("base64url")}`);
4207
4499
  const deviceKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
4208
- 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) => ({
4209
- id: route.id,
4210
- label: route.label,
4211
- deviceCount: [...devices.values()].filter(
4212
- (device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
4213
- ).length,
4214
- createdAt: route.createdAt,
4215
- updatedAt: route.updatedAt
4216
- }));
4500
+ const pruneExpiredDevices = (now) => {
4501
+ for (const [key, device] of devices) {
4502
+ if (device.expiresAt !== void 0 && device.expiresAt < now) devices.delete(key);
4503
+ else if (device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt < now) {
4504
+ delete device.previousCredentialHash;
4505
+ delete device.previousCredentialExpiresAt;
4506
+ }
4507
+ }
4508
+ };
4509
+ const liveDevice = (routeId, deviceId, now) => {
4510
+ const key = deviceKey(routeId, deviceId);
4511
+ const device = devices.get(key);
4512
+ if (device?.expiresAt !== void 0 && device.expiresAt < now) {
4513
+ devices.delete(key);
4514
+ return;
4515
+ }
4516
+ return device;
4517
+ };
4518
+ const listRoutes = (now, ownerAccountId) => {
4519
+ pruneExpiredDevices(now);
4520
+ 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) => ({
4521
+ id: route.id,
4522
+ label: route.label,
4523
+ deviceCount: [...devices.values()].filter(
4524
+ (device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
4525
+ ).length,
4526
+ createdAt: route.createdAt,
4527
+ updatedAt: route.updatedAt
4528
+ }));
4529
+ };
4217
4530
  return {
4218
4531
  mode: "memory",
4219
4532
  createRoute(input, now = Date.now()) {
@@ -4239,6 +4552,7 @@ function createMemoryStore2(options) {
4239
4552
  return listRoutes(now, safeOwnerAccountId(ownerAccountId));
4240
4553
  },
4241
4554
  countDevices(routeId, now = Date.now()) {
4555
+ pruneExpiredDevices(now);
4242
4556
  const safeRouteId = safeId4(routeId, "route id");
4243
4557
  return [...devices.values()].filter(
4244
4558
  (device) => device.routeId === safeRouteId && (device.expiresAt === void 0 || device.expiresAt >= now)
@@ -4262,18 +4576,22 @@ function createMemoryStore2(options) {
4262
4576
  return !!route && hashMatches(route.hostCredentialHash, credential);
4263
4577
  },
4264
4578
  putDevice(input, now = Date.now()) {
4579
+ pruneExpiredDevices(now);
4265
4580
  const routeId = safeId4(input.routeId, "route id");
4266
4581
  if (!routes.has(routeId)) throw new Error("relay route not found");
4267
4582
  const deviceId = safeId4(input.deviceId, "device id");
4268
4583
  const key = deviceKey(routeId, deviceId);
4269
4584
  const current = devices.get(key);
4585
+ const credentialHash = safeCredentialHash(input.credentialHash);
4586
+ const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
4270
4587
  const device = {
4271
4588
  routeId,
4272
4589
  deviceId,
4273
- credentialHash: safeCredentialHash(input.credentialHash),
4590
+ credentialHash,
4274
4591
  createdAt: current?.createdAt ?? now,
4275
4592
  updatedAt: now,
4276
- ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
4593
+ ...expiresAt === void 0 ? {} : { expiresAt },
4594
+ ...credentialOverlap(current, credentialHash, expiresAt, now)
4277
4595
  };
4278
4596
  devices.set(key, device);
4279
4597
  const route = routes.get(routeId);
@@ -4281,12 +4599,12 @@ function createMemoryStore2(options) {
4281
4599
  return cloneDevice(device);
4282
4600
  },
4283
4601
  getDevice(routeId, deviceId, now = Date.now()) {
4284
- const device = devices.get(deviceKey(safeId4(routeId, "route id"), safeId4(deviceId, "device id")));
4285
- return device && (device.expiresAt === void 0 || device.expiresAt >= now) ? cloneDevice(device) : void 0;
4602
+ const device = liveDevice(safeId4(routeId, "route id"), safeId4(deviceId, "device id"), now);
4603
+ return device ? cloneDevice(device) : void 0;
4286
4604
  },
4287
4605
  authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
4288
- const device = devices.get(deviceKey(safeId4(routeId, "route id"), safeId4(deviceId, "device id")));
4289
- return !!device && (device.expiresAt === void 0 || device.expiresAt >= now) && hashMatches(device.credentialHash, credential);
4606
+ const device = liveDevice(safeId4(routeId, "route id"), safeId4(deviceId, "device id"), now);
4607
+ return !!device && (hashMatches(device.credentialHash, credential) || device.previousCredentialHash !== void 0 && device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt >= now && hashMatches(device.previousCredentialHash, credential));
4290
4608
  },
4291
4609
  revokeDevice(routeId, deviceId) {
4292
4610
  const safeRouteId = safeId4(routeId, "route id");
@@ -4348,6 +4666,8 @@ function openRelayRouteStore(options) {
4348
4666
  route_id TEXT NOT NULL REFERENCES relay_routes(id) ON DELETE CASCADE,
4349
4667
  device_id TEXT NOT NULL,
4350
4668
  credential_hash TEXT NOT NULL,
4669
+ previous_credential_hash TEXT,
4670
+ previous_credential_expires_at INTEGER,
4351
4671
  created_at INTEGER NOT NULL,
4352
4672
  updated_at INTEGER NOT NULL,
4353
4673
  expires_at INTEGER,
@@ -4358,6 +4678,12 @@ function openRelayRouteStore(options) {
4358
4678
  if (!relayDeviceColumns.some((column) => column.name === "expires_at")) {
4359
4679
  db.exec("ALTER TABLE relay_route_devices ADD COLUMN expires_at INTEGER");
4360
4680
  }
4681
+ if (!relayDeviceColumns.some((column) => column.name === "previous_credential_hash")) {
4682
+ db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_hash TEXT");
4683
+ }
4684
+ if (!relayDeviceColumns.some((column) => column.name === "previous_credential_expires_at")) {
4685
+ db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_expires_at INTEGER");
4686
+ }
4361
4687
  const relayRouteColumns = db.prepare("PRAGMA table_info(relay_routes)").all();
4362
4688
  if (!relayRouteColumns.some((column) => column.name === "owner_account_id")) {
4363
4689
  db.exec("ALTER TABLE relay_routes ADD COLUMN owner_account_id TEXT");
@@ -4366,6 +4692,21 @@ function openRelayRouteStore(options) {
4366
4692
  const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes6(16).toString("base64url")}`);
4367
4693
  const getRoute = (id) => db.prepare("SELECT * FROM relay_routes WHERE id = ?").get(id);
4368
4694
  const getDevice = (routeId, deviceId) => db.prepare("SELECT * FROM relay_route_devices WHERE route_id = ? AND device_id = ?").get(routeId, deviceId);
4695
+ const pruneExpiredDevices = (now) => {
4696
+ db.prepare("DELETE FROM relay_route_devices WHERE expires_at IS NOT NULL AND expires_at < ?").run(now);
4697
+ db.prepare(
4698
+ `UPDATE relay_route_devices SET previous_credential_hash = NULL, previous_credential_expires_at = NULL
4699
+ WHERE previous_credential_expires_at IS NOT NULL AND previous_credential_expires_at < ?`
4700
+ ).run(now);
4701
+ };
4702
+ const liveDevice = (routeId, deviceId, now) => {
4703
+ const row = getDevice(routeId, deviceId);
4704
+ if (row?.expires_at !== null && row?.expires_at !== void 0 && row.expires_at < now) {
4705
+ db.prepare("DELETE FROM relay_route_devices WHERE route_id = ? AND device_id = ?").run(routeId, deviceId);
4706
+ return;
4707
+ }
4708
+ return row;
4709
+ };
4369
4710
  const createRoute = db.transaction(
4370
4711
  (input, now) => {
4371
4712
  const id = safeId4(input.id ?? generateRouteId(), "route id");
@@ -4396,25 +4737,44 @@ function openRelayRouteStore(options) {
4396
4737
  (input, now) => {
4397
4738
  const routeId = safeId4(input.routeId, "route id");
4398
4739
  if (!getRoute(routeId)) throw new Error("relay route not found");
4740
+ pruneExpiredDevices(now);
4399
4741
  const deviceId = safeId4(input.deviceId, "device id");
4400
4742
  const current = getDevice(routeId, deviceId);
4743
+ const credentialHash = safeCredentialHash(input.credentialHash);
4744
+ const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
4745
+ const overlap = credentialOverlap(
4746
+ current ? {
4747
+ ...deviceFromRow(current),
4748
+ ...current.previous_credential_hash === null || current.previous_credential_hash === void 0 ? {} : { previousCredentialHash: current.previous_credential_hash },
4749
+ ...current.previous_credential_expires_at === null || current.previous_credential_expires_at === void 0 ? {} : { previousCredentialExpiresAt: current.previous_credential_expires_at }
4750
+ } : void 0,
4751
+ credentialHash,
4752
+ expiresAt,
4753
+ now
4754
+ );
4401
4755
  const device = {
4402
4756
  routeId,
4403
4757
  deviceId,
4404
- credentialHash: safeCredentialHash(input.credentialHash),
4758
+ credentialHash,
4405
4759
  createdAt: current?.created_at ?? now,
4406
4760
  updatedAt: now,
4407
- ...input.expiresAt === void 0 ? {} : { expiresAt: safeExpiry(input.expiresAt) }
4761
+ ...expiresAt === void 0 ? {} : { expiresAt }
4408
4762
  };
4409
4763
  db.prepare(
4410
- `INSERT INTO relay_route_devices (route_id, device_id, credential_hash, created_at, updated_at, expires_at)
4411
- VALUES (?, ?, ?, ?, ?, ?)
4764
+ `INSERT INTO relay_route_devices
4765
+ (route_id, device_id, credential_hash, previous_credential_hash, previous_credential_expires_at,
4766
+ created_at, updated_at, expires_at)
4767
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4412
4768
  ON CONFLICT(route_id, device_id) DO UPDATE SET credential_hash = excluded.credential_hash,
4769
+ previous_credential_hash = excluded.previous_credential_hash,
4770
+ previous_credential_expires_at = excluded.previous_credential_expires_at,
4413
4771
  updated_at = excluded.updated_at, expires_at = excluded.expires_at`
4414
4772
  ).run(
4415
4773
  device.routeId,
4416
4774
  device.deviceId,
4417
4775
  device.credentialHash,
4776
+ overlap.previousCredentialHash ?? null,
4777
+ overlap.previousCredentialExpiresAt ?? null,
4418
4778
  device.createdAt,
4419
4779
  device.updatedAt,
4420
4780
  device.expiresAt ?? null
@@ -4423,19 +4783,22 @@ function openRelayRouteStore(options) {
4423
4783
  return device;
4424
4784
  }
4425
4785
  );
4426
- const listRoutes = (now, ownerAccountId) => db.prepare(
4427
- `SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
4786
+ const listRoutes = (now, ownerAccountId) => {
4787
+ pruneExpiredDevices(now);
4788
+ return db.prepare(
4789
+ `SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
4428
4790
  FROM relay_routes r LEFT JOIN relay_route_devices d ON d.route_id = r.id
4429
4791
  AND (d.expires_at IS NULL OR d.expires_at >= ?)
4430
4792
  ${ownerAccountId === void 0 ? "" : "WHERE r.owner_account_id = ?"}
4431
4793
  GROUP BY r.id ORDER BY r.created_at, r.id`
4432
- ).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
4433
- id: row.id,
4434
- label: row.label,
4435
- deviceCount: row.device_count,
4436
- createdAt: row.created_at,
4437
- updatedAt: row.updated_at
4438
- }));
4794
+ ).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
4795
+ id: row.id,
4796
+ label: row.label,
4797
+ deviceCount: row.device_count,
4798
+ createdAt: row.created_at,
4799
+ updatedAt: row.updated_at
4800
+ }));
4801
+ };
4439
4802
  return {
4440
4803
  mode: "sqlite",
4441
4804
  createRoute: (input, now = Date.now()) => cloneRoute(createRoute(input, now)),
@@ -4446,6 +4809,7 @@ function openRelayRouteStore(options) {
4446
4809
  listRoutes: (now = Date.now()) => listRoutes(now),
4447
4810
  listRoutesByOwner: (ownerAccountId, now = Date.now()) => listRoutes(now, ownerAccountId),
4448
4811
  countDevices(routeId, now = Date.now()) {
4812
+ pruneExpiredDevices(now);
4449
4813
  const row = db.prepare(
4450
4814
  `SELECT COUNT(*) AS count FROM relay_route_devices
4451
4815
  WHERE route_id = ? AND (expires_at IS NULL OR expires_at >= ?)`
@@ -4464,12 +4828,12 @@ function openRelayRouteStore(options) {
4464
4828
  },
4465
4829
  putDevice: (input, now = Date.now()) => cloneDevice(putDevice(input, now)),
4466
4830
  getDevice(routeId, deviceId, now = Date.now()) {
4467
- const row = getDevice(safeId4(routeId, "route id"), safeId4(deviceId, "device id"));
4468
- return row && (row.expires_at === null || row.expires_at === void 0 || row.expires_at >= now) ? deviceFromRow(row) : void 0;
4831
+ const row = liveDevice(safeId4(routeId, "route id"), safeId4(deviceId, "device id"), now);
4832
+ return row ? deviceFromRow(row) : void 0;
4469
4833
  },
4470
4834
  authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
4471
- const row = getDevice(safeId4(routeId, "route id"), safeId4(deviceId, "device id"));
4472
- return !!row && (row.expires_at === null || row.expires_at === void 0 || row.expires_at >= now) && hashMatches(row.credential_hash, credential);
4835
+ const row = liveDevice(safeId4(routeId, "route id"), safeId4(deviceId, "device id"), now);
4836
+ return !!row && (hashMatches(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 && hashMatches(row.previous_credential_hash, credential));
4473
4837
  },
4474
4838
  revokeDevice(routeId, deviceId) {
4475
4839
  const safeRouteId = safeId4(routeId, "route id");
@@ -4499,6 +4863,7 @@ var PLAN_DEFAULTS = {
4499
4863
  team: { maxRoutes: 25, maxDevicesPerRoute: 64 },
4500
4864
  enterprise: { maxRoutes: 500, maxDevicesPerRoute: 500 }
4501
4865
  };
4866
+ var UNSAFE_TERMINAL_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
4502
4867
  function safeId5(value) {
4503
4868
  if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
4504
4869
  throw new Error("invalid relay account id");
@@ -4508,7 +4873,7 @@ function safeId5(value) {
4508
4873
  function safeLabel3(value) {
4509
4874
  if (typeof value !== "string") throw new Error("relay account label is required");
4510
4875
  const label = value.trim().replace(/\s+/g, " ");
4511
- if (!label || label.length > 120 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(label)) {
4876
+ if (!label || label.length > 120 || UNSAFE_TERMINAL_TEXT.test(label)) {
4512
4877
  throw new Error("invalid relay account label");
4513
4878
  }
4514
4879
  return label;
@@ -4537,6 +4902,33 @@ function safeHash(value) {
4537
4902
  }
4538
4903
  return value;
4539
4904
  }
4905
+ function safeLookup(value) {
4906
+ if (typeof value !== "string" || !/^lookup:[A-Za-z0-9_-]{43}$/.test(value)) {
4907
+ throw new Error("invalid relay account credential lookup");
4908
+ }
4909
+ return value;
4910
+ }
4911
+ function credentialMaterial(input) {
4912
+ if (typeof input === "string") {
4913
+ return {
4914
+ credentialHash: relayAccountCredentialHash(input),
4915
+ credentialLookup: relayAccountCredentialLookup(input)
4916
+ };
4917
+ }
4918
+ if ("credential" in input && input.credential !== void 0) {
4919
+ if (input.credentialHash !== void 0 || input.credentialLookup !== void 0) {
4920
+ throw new Error("relay account credential must be raw or pre-hashed, not both");
4921
+ }
4922
+ return {
4923
+ credentialHash: relayAccountCredentialHash(input.credential),
4924
+ credentialLookup: relayAccountCredentialLookup(input.credential)
4925
+ };
4926
+ }
4927
+ return {
4928
+ credentialHash: safeHash(input.credentialHash),
4929
+ credentialLookup: safeLookup(input.credentialLookup)
4930
+ };
4931
+ }
4540
4932
  function hashMatches2(expected, credential) {
4541
4933
  try {
4542
4934
  const left = Buffer.from(safeHash(expected));
@@ -4570,14 +4962,24 @@ function createMemoryStore3(options) {
4570
4962
  const accounts = /* @__PURE__ */ new Map();
4571
4963
  const lookup = /* @__PURE__ */ new Map();
4572
4964
  const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes7(18).toString("base64url")}`);
4965
+ const verifyCredential = (credential) => {
4966
+ let credentialLookup;
4967
+ try {
4968
+ credentialLookup = relayAccountCredentialLookup(credential);
4969
+ } catch {
4970
+ return;
4971
+ }
4972
+ const id = lookup.get(credentialLookup);
4973
+ const record = id ? accounts.get(id) : void 0;
4974
+ return record && record.status !== "deleted" && hashMatches2(record.credentialHash, credential) ? clone3(record) : void 0;
4975
+ };
4573
4976
  return {
4574
4977
  mode: "memory",
4575
4978
  createAccount(input, now = Date.now()) {
4576
4979
  const id = safeId5(generateAccountId());
4577
4980
  if (accounts.has(id)) throw new Error("relay account already exists");
4578
4981
  const plan = safePlan(input.plan ?? "free");
4579
- const credentialHash = relayAccountCredentialHash(input.credential);
4580
- const credentialLookup = relayAccountCredentialLookup(input.credential);
4982
+ const { credentialHash, credentialLookup } = credentialMaterial(input);
4581
4983
  if (lookup.has(credentialLookup)) throw new Error("relay account credential already exists");
4582
4984
  const defaults = PLAN_DEFAULTS[plan];
4583
4985
  const record = {
@@ -4609,16 +5011,10 @@ function createMemoryStore3(options) {
4609
5011
  listAccounts({ includeDeleted = false } = {}) {
4610
5012
  return [...accounts.values()].filter((record) => includeDeleted || record.status !== "deleted").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(clone3);
4611
5013
  },
5014
+ verifyCredential,
4612
5015
  authenticate(credential) {
4613
- let credentialLookup;
4614
- try {
4615
- credentialLookup = relayAccountCredentialLookup(credential);
4616
- } catch {
4617
- return void 0;
4618
- }
4619
- const id = lookup.get(credentialLookup);
4620
- const record = id ? accounts.get(id) : void 0;
4621
- return record?.status === "active" && hashMatches2(record.credentialHash, credential) ? clone3(record) : void 0;
5016
+ const record = verifyCredential(credential);
5017
+ return record?.status === "active" ? record : void 0;
4622
5018
  },
4623
5019
  updateAccount(id, input, expectedRevision, now = Date.now()) {
4624
5020
  const current = accounts.get(safeId5(id));
@@ -4656,8 +5052,7 @@ function createMemoryStore3(options) {
4656
5052
  if (!current) return void 0;
4657
5053
  if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone3(current));
4658
5054
  if (current.status === "deleted") throw new Error("deleted relay account is immutable");
4659
- const credentialHash = relayAccountCredentialHash(credential);
4660
- const credentialLookup = relayAccountCredentialLookup(credential);
5055
+ const { credentialHash, credentialLookup } = credentialMaterial(credential);
4661
5056
  const owner = lookup.get(credentialLookup);
4662
5057
  if (owner && owner !== current.id) throw new Error("relay account credential already exists");
4663
5058
  lookup.delete(current.credentialLookup);
@@ -4723,10 +5118,21 @@ function openRelayAccountStore(options) {
4723
5118
  `);
4724
5119
  const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes7(18).toString("base64url")}`);
4725
5120
  const rowById = (id) => db.prepare("SELECT * FROM relay_accounts WHERE id = ?").get(id);
5121
+ const verifyCredential = (credential) => {
5122
+ let credentialLookup;
5123
+ try {
5124
+ credentialLookup = relayAccountCredentialLookup(credential);
5125
+ } catch {
5126
+ return;
5127
+ }
5128
+ const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status != 'deleted'").get(credentialLookup);
5129
+ return row && hashMatches2(row.credential_hash, credential) ? fromRow(row) : void 0;
5130
+ };
4726
5131
  const createAccount = db.transaction((input, now) => {
4727
5132
  const id = safeId5(generateAccountId());
4728
5133
  if (rowById(id)) throw new Error("relay account already exists");
4729
5134
  const plan = safePlan(input.plan ?? "free");
5135
+ const { credentialHash, credentialLookup } = credentialMaterial(input);
4730
5136
  const defaults = PLAN_DEFAULTS[plan];
4731
5137
  const record = {
4732
5138
  id,
@@ -4752,8 +5158,8 @@ function openRelayAccountStore(options) {
4752
5158
  record.maxRoutes,
4753
5159
  record.maxDevicesPerRoute,
4754
5160
  record.revision,
4755
- relayAccountCredentialHash(input.credential),
4756
- relayAccountCredentialLookup(input.credential),
5161
+ credentialHash,
5162
+ credentialLookup,
4757
5163
  record.createdAt,
4758
5164
  record.updatedAt
4759
5165
  );
@@ -4773,15 +5179,10 @@ function openRelayAccountStore(options) {
4773
5179
  ).all();
4774
5180
  return rows.map(fromRow);
4775
5181
  },
5182
+ verifyCredential,
4776
5183
  authenticate(credential) {
4777
- let credentialLookup;
4778
- try {
4779
- credentialLookup = relayAccountCredentialLookup(credential);
4780
- } catch {
4781
- return void 0;
4782
- }
4783
- const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status = 'active'").get(credentialLookup);
4784
- return row && hashMatches2(row.credential_hash, credential) ? fromRow(row) : void 0;
5184
+ const record = verifyCredential(credential);
5185
+ return record?.status === "active" ? record : void 0;
4785
5186
  },
4786
5187
  updateAccount(id, input, expectedRevision, now = Date.now()) {
4787
5188
  const safeAccountId = safeId5(id);
@@ -4840,16 +5241,11 @@ function openRelayAccountStore(options) {
4840
5241
  const current = fromRow(currentRow);
4841
5242
  if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
4842
5243
  if (current.status === "deleted") throw new Error("deleted relay account is immutable");
5244
+ const { credentialHash, credentialLookup } = credentialMaterial(credential);
4843
5245
  const result = db.prepare(
4844
5246
  `UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
4845
5247
  updated_at = ? WHERE id = ? AND revision = ?`
4846
- ).run(
4847
- relayAccountCredentialHash(credential),
4848
- relayAccountCredentialLookup(credential),
4849
- now,
4850
- safeAccountId,
4851
- expectedRevision
4852
- );
5248
+ ).run(credentialHash, credentialLookup, now, safeAccountId, expectedRevision);
4853
5249
  if (result.changes !== 1) {
4854
5250
  const latest = rowById(safeAccountId);
4855
5251
  if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
@@ -5018,7 +5414,7 @@ function safeId7(value, field) {
5018
5414
  return value;
5019
5415
  }
5020
5416
  function isLoopback(hostname) {
5021
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
5417
+ return hostname === "localhost" || hostname === "[::1]" || hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
5022
5418
  }
5023
5419
  function relayConnectUrl(raw) {
5024
5420
  let url;
@@ -5780,6 +6176,7 @@ function createRelayHostConnector(options) {
5780
6176
 
5781
6177
  // src/relay-provision.ts
5782
6178
  var RESPONSE_LIMIT = 16 * 1024;
6179
+ var UNSAFE_TERMINAL_TEXT2 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
5783
6180
  function safeId8(value, field) {
5784
6181
  if (!/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
5785
6182
  return value;
@@ -5794,13 +6191,44 @@ function relayHttpOrigin(raw) {
5794
6191
  }
5795
6192
  async function boundedError(response2) {
5796
6193
  const declared = Number(response2.headers.get("content-length"));
5797
- if (Number.isFinite(declared) && declared > RESPONSE_LIMIT) return `relay returned ${response2.status}`;
6194
+ if (Number.isFinite(declared) && declared > RESPONSE_LIMIT) {
6195
+ await discardBody(response2);
6196
+ return `relay returned ${response2.status}`;
6197
+ }
6198
+ if (!response2.body) return `relay returned ${response2.status}`;
6199
+ const chunks = [];
6200
+ let total = 0;
6201
+ const reader = response2.body.getReader();
5798
6202
  try {
5799
- const body = (await response2.text()).slice(0, RESPONSE_LIMIT);
6203
+ while (true) {
6204
+ const next = await reader.read();
6205
+ if (next.done) break;
6206
+ if (!next.value) continue;
6207
+ total += next.value.byteLength;
6208
+ if (total > RESPONSE_LIMIT) {
6209
+ try {
6210
+ await reader.cancel();
6211
+ } catch {
6212
+ }
6213
+ return `relay returned ${response2.status}`;
6214
+ }
6215
+ chunks.push(Buffer.from(next.value));
6216
+ }
6217
+ const body = Buffer.concat(chunks, total).toString("utf8");
5800
6218
  const parsed = JSON.parse(body);
5801
- return typeof parsed.error === "string" && parsed.error.length <= 200 ? parsed.error : `relay returned ${response2.status}`;
6219
+ if (typeof parsed.error !== "string") return `relay returned ${response2.status}`;
6220
+ const message = parsed.error.trim().replace(/\s+/g, " ");
6221
+ return message && message.length <= 200 && !UNSAFE_TERMINAL_TEXT2.test(message) && !/\brr[a-z]_[A-Za-z0-9_-]{20,}\b|\bsha256:[A-Za-z0-9_-]{43}\b|\bBearer\s+/i.test(message) ? message : `relay returned ${response2.status}`;
5802
6222
  } catch {
5803
6223
  return `relay returned ${response2.status}`;
6224
+ } finally {
6225
+ reader.releaseLock();
6226
+ }
6227
+ }
6228
+ async function discardBody(response2) {
6229
+ try {
6230
+ await response2.body?.cancel();
6231
+ } catch {
5804
6232
  }
5805
6233
  }
5806
6234
  function createRelayDeviceProvisioner(options) {
@@ -5826,26 +6254,31 @@ function createRelayDeviceProvisioner(options) {
5826
6254
  "content-type": "application/json"
5827
6255
  },
5828
6256
  body: JSON.stringify({ credentialHash, ...expiresAt === void 0 ? {} : { expiresAt } }),
6257
+ redirect: "error",
5829
6258
  ...requestSignal ? { signal: requestSignal } : {}
5830
6259
  });
5831
6260
  if (!response2.ok) throw new Error(`could not provision relay device: ${await boundedError(response2)}`);
6261
+ await discardBody(response2);
5832
6262
  },
5833
6263
  async revokeDevice(deviceId) {
5834
6264
  const requestSignal = signal();
5835
6265
  const response2 = await request(endpoint(deviceId), {
5836
6266
  method: "DELETE",
5837
6267
  headers: { authorization: `Bearer ${options.hostCredential}` },
6268
+ redirect: "error",
5838
6269
  ...requestSignal ? { signal: requestSignal } : {}
5839
6270
  });
5840
- if (!response2.ok && response2.status !== 404)
6271
+ if (!response2.ok && response2.status !== 404) {
5841
6272
  throw new Error(`could not revoke relay device: ${await boundedError(response2)}`);
6273
+ }
6274
+ await discardBody(response2);
5842
6275
  }
5843
6276
  };
5844
6277
  }
5845
6278
 
5846
6279
  // src/relay-pairing.ts
5847
6280
  function isLoopback2(hostname) {
5848
- return hostname === "localhost" || hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
6281
+ return hostname === "localhost" || hostname === "::1" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
5849
6282
  }
5850
6283
  function normalizeRelayAppUrl(value) {
5851
6284
  const url = new URL(value.trim());
@@ -5870,9 +6303,11 @@ import websocket from "@fastify/websocket";
5870
6303
  var BLIND_RELAY_PROTOCOL_VERSION = 1;
5871
6304
  var BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES = 15e5;
5872
6305
  var BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES = 4e6;
6306
+ var BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS = 1024;
5873
6307
  var BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 64;
5874
6308
  var BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE = 64 * 1024 * 1024;
5875
6309
  var BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE = 12e3;
6310
+ var UNSAFE_DISPLAY_TEXT3 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
5876
6311
  function safeToken(value, field) {
5877
6312
  if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
5878
6313
  throw new Error(`invalid relay ${field}`);
@@ -5892,7 +6327,7 @@ function safeId9(value, field) {
5892
6327
  function safeLabel4(value) {
5893
6328
  if (typeof value !== "string") throw new Error("relay route label is required");
5894
6329
  const label = value.trim().replace(/\s+/g, " ");
5895
- if (!label || label.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(label)) throw new Error("invalid relay route label");
6330
+ if (!label || label.length > 80 || UNSAFE_DISPLAY_TEXT3.test(label)) throw new Error("invalid relay route label");
5896
6331
  return label;
5897
6332
  }
5898
6333
  function safeExpiry2(value, now) {
@@ -5930,7 +6365,9 @@ function publicRoute(route) {
5930
6365
  function normalizeOrigin2(value) {
5931
6366
  try {
5932
6367
  const url = new URL(value);
5933
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password) return;
6368
+ const loopback = url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
6369
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
6370
+ return;
5934
6371
  return url.origin;
5935
6372
  } catch {
5936
6373
  return;
@@ -5992,6 +6429,7 @@ function createBlindRelayServer(options) {
5992
6429
  const idleTimeoutMs = options.idleTimeoutMs ?? 2 * 6e4;
5993
6430
  const maxFrameBytes = options.maxFrameBytes ?? BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES;
5994
6431
  const maxQueueBytes = options.maxQueueBytes ?? BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES;
6432
+ const maxTotalConnections = options.maxTotalConnections ?? BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS;
5995
6433
  const maxConnectionsPerRoute = options.maxConnectionsPerRoute ?? BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
5996
6434
  const maxBytesPerMinute = options.maxBytesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE;
5997
6435
  const maxMessagesPerMinute = options.maxMessagesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE;
@@ -6000,17 +6438,63 @@ function createBlindRelayServer(options) {
6000
6438
  [idleTimeoutMs, 1e4, 60 * 6e4, "idle timeout"],
6001
6439
  [maxFrameBytes, 1024, 16 * 1024 * 1024, "frame limit"],
6002
6440
  [maxQueueBytes, 1024, 64 * 1024 * 1024, "queue limit"],
6441
+ [maxTotalConnections, 1, 1e5, "total connection limit"],
6003
6442
  [maxConnectionsPerRoute, 1, 1e4, "connection limit"],
6004
6443
  [maxBytesPerMinute, 1024, 1024 * 1024 * 1024, "byte rate"],
6005
6444
  [maxMessagesPerMinute, 10, 1e6, "message rate"]
6006
6445
  ]) {
6007
6446
  if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`invalid relay ${label}`);
6008
6447
  }
6009
- const allowedOrigins = new Set((options.allowedOrigins ?? []).map(normalizeOrigin2).filter(Boolean));
6448
+ const maxEnvelopeBytes = Math.max(8 * 1024, Math.ceil(maxFrameBytes * 4 / 3) + 8 * 1024);
6449
+ const configuredOrigins = options.allowedOrigins ?? [];
6450
+ const normalizedOrigins = configuredOrigins.map(normalizeOrigin2);
6451
+ if (normalizedOrigins.some((origin) => origin === void 0)) {
6452
+ throw new Error("invalid relay allowed origin");
6453
+ }
6454
+ const allowedOrigins = new Set(normalizedOrigins);
6010
6455
  const generateChannelId = options.generateChannelId ?? (() => `rrc_${randomBytes8(16).toString("base64url")}`);
6011
6456
  const hosts = /* @__PURE__ */ new Map();
6012
6457
  const devicesByChannel = /* @__PURE__ */ new Map();
6458
+ const sockets = /* @__PURE__ */ new Set();
6459
+ const hostRates = /* @__PURE__ */ new Map();
6460
+ const deviceRates = /* @__PURE__ */ new Map();
6461
+ const maxRateIdentities = Math.min(1e5, Math.max(256, maxTotalConnections * 4));
6462
+ const deviceRateKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
6463
+ const clearRouteRates = (routeId) => {
6464
+ hostRates.delete(routeId);
6465
+ for (const key of deviceRates.keys()) if (key.startsWith(`${routeId}\0`)) deviceRates.delete(key);
6466
+ };
6467
+ const rateWindowFor = (windows, key) => {
6468
+ const current = now();
6469
+ if (windows.size >= maxRateIdentities) {
6470
+ for (const [candidate, window] of windows) {
6471
+ if (current - window.lastSeenAt >= 12e4) windows.delete(candidate);
6472
+ }
6473
+ }
6474
+ const existing = windows.get(key);
6475
+ if (existing) {
6476
+ existing.lastSeenAt = current;
6477
+ return existing;
6478
+ }
6479
+ if (windows.size >= maxRateIdentities) return;
6480
+ const created = { startedAt: current, lastSeenAt: current, bytes: 0, messages: 0 };
6481
+ windows.set(key, created);
6482
+ return created;
6483
+ };
6484
+ const consumeRate = (window, bytes) => {
6485
+ const current = now();
6486
+ if (current - window.startedAt >= 6e4) {
6487
+ window.startedAt = current;
6488
+ window.bytes = 0;
6489
+ window.messages = 0;
6490
+ }
6491
+ window.lastSeenAt = current;
6492
+ window.bytes += bytes;
6493
+ window.messages += 1;
6494
+ return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
6495
+ };
6013
6496
  const metrics = {
6497
+ activeConnections: 0,
6014
6498
  activeHosts: 0,
6015
6499
  activeDevices: 0,
6016
6500
  acceptedConnections: 0,
@@ -6047,6 +6531,14 @@ function createBlindRelayServer(options) {
6047
6531
  }
6048
6532
  reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
6049
6533
  };
6534
+ const requireRecoverableAccount = async (request, reply) => {
6535
+ const account = accountStore?.verifyCredential(bearer(request) ?? "");
6536
+ if (account) {
6537
+ authenticatedAccounts.set(request, account);
6538
+ return;
6539
+ }
6540
+ reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
6541
+ };
6050
6542
  app.addHook("onSend", async (_request, reply, payload) => {
6051
6543
  reply.header("cache-control", "no-store");
6052
6544
  reply.header("content-security-policy", "default-src 'none'");
@@ -6066,7 +6558,12 @@ function createBlindRelayServer(options) {
6066
6558
  });
6067
6559
  app.get("/v1/metrics", { preHandler: requireRoot }, async () => ({
6068
6560
  protocolVersion: BLIND_RELAY_PROTOCOL_VERSION,
6069
- metrics: { ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size }
6561
+ metrics: {
6562
+ ...metrics,
6563
+ activeConnections: sockets.size,
6564
+ activeHosts: hosts.size,
6565
+ activeDevices: devicesByChannel.size
6566
+ }
6070
6567
  }));
6071
6568
  app.get("/v1/routes", { preHandler: requireRoot }, async () => ({ routes: store.listRoutes().map(publicRoute) }));
6072
6569
  app.post(
@@ -6112,6 +6609,7 @@ function createBlindRelayServer(options) {
6112
6609
  reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
6113
6610
  return;
6114
6611
  }
6612
+ clearRouteRates(request.params.routeId);
6115
6613
  const host = hosts.get(request.params.routeId);
6116
6614
  host?.socket.close(4403, "route deleted");
6117
6615
  for (const device of host?.devices.values() ?? []) device.socket.close(4403, "route deleted");
@@ -6168,22 +6666,12 @@ function createBlindRelayServer(options) {
6168
6666
  reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
6169
6667
  return;
6170
6668
  }
6669
+ deviceRates.delete(deviceRateKey(request.params.routeId, request.params.deviceId));
6171
6670
  const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
6172
6671
  live?.socket.close(4403, "device revoked");
6173
6672
  reply.code(204).send();
6174
6673
  }
6175
6674
  );
6176
- const consumeRate = (window, bytes) => {
6177
- const current = now();
6178
- if (current - window.startedAt >= 6e4) {
6179
- window.startedAt = current;
6180
- window.bytes = 0;
6181
- window.messages = 0;
6182
- }
6183
- window.bytes += bytes;
6184
- window.messages += 1;
6185
- return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
6186
- };
6187
6675
  const closeDevice = (device, code = 1e3, reason = "device disconnected") => {
6188
6676
  if (device.closed) return;
6189
6677
  device.closed = true;
@@ -6192,7 +6680,10 @@ function createBlindRelayServer(options) {
6192
6680
  const host = hosts.get(device.routeId);
6193
6681
  if (host?.devices.get(device.deviceId) === device) host.devices.delete(device.deviceId);
6194
6682
  metrics.activeDevices = Math.max(0, metrics.activeDevices - 1);
6195
- if (host) safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes);
6683
+ if (host && !safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes)) {
6684
+ metrics.droppedFrames += 1;
6685
+ closeHost(host, 4408, "relay backpressure");
6686
+ }
6196
6687
  if (device.socket.readyState === device.socket.OPEN) device.socket.close(code, reason);
6197
6688
  };
6198
6689
  const touchDevice = (device) => {
@@ -6220,11 +6711,24 @@ function createBlindRelayServer(options) {
6220
6711
  usage: { routes: store.listRoutesByOwner(account.id, now()).length, maxRoutes: account.maxRoutes }
6221
6712
  });
6222
6713
  const closeAccountRoutes = (accountId, reason) => {
6714
+ for (const host of [...hosts.values()]) if (host.ownerAccountId === accountId) closeHost(host, 4403, reason);
6715
+ };
6716
+ const purgeAccountRoutes = (accountId) => {
6717
+ closeAccountRoutes(accountId, "account deleted");
6223
6718
  for (const route of store.listRoutesByOwner(accountId, now())) {
6224
- const host = hosts.get(route.id);
6225
- if (host) closeHost(host, 4403, reason);
6719
+ store.deleteRoute(route.id);
6720
+ clearRouteRates(route.id);
6226
6721
  }
6227
6722
  };
6723
+ for (const listedRoute of store.listRoutes(now())) {
6724
+ const route = store.getRoute(listedRoute.id);
6725
+ if (!route?.ownerAccountId) continue;
6726
+ const owner = accountStore.getAccount(route.ownerAccountId);
6727
+ if (!owner || owner.status === "deleted") {
6728
+ store.deleteRoute(route.id);
6729
+ clearRouteRates(route.id);
6730
+ }
6731
+ }
6228
6732
  const ownedRoute = (accountId, routeId) => {
6229
6733
  const route = store.getRoute(routeId);
6230
6734
  return route?.ownerAccountId === accountId ? route : void 0;
@@ -6232,18 +6736,47 @@ function createBlindRelayServer(options) {
6232
6736
  app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
6233
6737
  accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
6234
6738
  }));
6235
- app.post("/v1/accounts", { preHandler: requireRoot }, async (request, reply) => {
6236
- const accountCredential = generateRelayAccountCredential();
6739
+ const createAccountHandler = (clientHashedOnly) => async (request, reply) => {
6237
6740
  try {
6741
+ const hasCredentialHash = request.body?.credentialHash !== void 0;
6742
+ const hasCredentialLookup = request.body?.credentialLookup !== void 0;
6743
+ if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
6744
+ throw new Error("client-hashed account credential material is required");
6745
+ }
6746
+ const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
6747
+ const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
6748
+ const credential = suppliedCredentialMaterial ? {
6749
+ credentialHash: request.body?.credentialHash,
6750
+ credentialLookup: request.body?.credentialLookup
6751
+ } : accountCredential;
6238
6752
  const account = accountStore.createAccount({
6239
- ...request.body,
6240
- credential: accountCredential
6753
+ label: request.body?.label,
6754
+ ...request.body?.plan === void 0 ? {} : { plan: request.body.plan },
6755
+ ...request.body?.maxRoutes === void 0 ? {} : { maxRoutes: request.body.maxRoutes },
6756
+ ...request.body?.maxDevicesPerRoute === void 0 ? {} : { maxDevicesPerRoute: request.body.maxDevicesPerRoute },
6757
+ ...typeof credential === "string" ? { credential } : {
6758
+ credentialHash: credential.credentialHash,
6759
+ credentialLookup: credential.credentialLookup
6760
+ }
6761
+ });
6762
+ reply.code(201).send({
6763
+ ...accountEnvelope(account),
6764
+ ...accountCredential === void 0 ? {} : { accountCredential }
6241
6765
  });
6242
- reply.code(201).send({ ...accountEnvelope(account), accountCredential });
6243
6766
  } catch {
6244
6767
  reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
6245
6768
  }
6246
- });
6769
+ };
6770
+ app.post(
6771
+ "/v1/accounts",
6772
+ { preHandler: requireRoot },
6773
+ createAccountHandler(false)
6774
+ );
6775
+ app.post(
6776
+ "/v1/accounts/client-hashed",
6777
+ { preHandler: requireRoot },
6778
+ createAccountHandler(true)
6779
+ );
6247
6780
  app.patch(
6248
6781
  "/v1/accounts/:accountId",
6249
6782
  { preHandler: requireRoot },
@@ -6258,7 +6791,8 @@ function createBlindRelayServer(options) {
6258
6791
  reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
6259
6792
  return;
6260
6793
  }
6261
- if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
6794
+ if (account.status === "deleted") purgeAccountRoutes(account.id);
6795
+ else if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
6262
6796
  reply.code(200).send(accountEnvelope(account));
6263
6797
  } catch (error) {
6264
6798
  if (error instanceof RelayAccountRevisionConflictError) {
@@ -6273,34 +6807,58 @@ function createBlindRelayServer(options) {
6273
6807
  }
6274
6808
  }
6275
6809
  );
6810
+ const rotateAccountHandler = (clientHashedOnly) => async (request, reply) => {
6811
+ try {
6812
+ const hasCredentialHash = request.body?.credentialHash !== void 0;
6813
+ const hasCredentialLookup = request.body?.credentialLookup !== void 0;
6814
+ if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
6815
+ throw new Error("client-hashed account credential material is required");
6816
+ }
6817
+ const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
6818
+ const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
6819
+ const credential = suppliedCredentialMaterial ? {
6820
+ credentialHash: request.body?.credentialHash,
6821
+ credentialLookup: request.body?.credentialLookup
6822
+ } : accountCredential;
6823
+ const account = accountStore.rotateCredential(
6824
+ request.params.accountId,
6825
+ credential,
6826
+ safeRevision(request.body?.expectedRevision)
6827
+ );
6828
+ if (!account) {
6829
+ reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
6830
+ return;
6831
+ }
6832
+ reply.code(200).send({
6833
+ ...accountEnvelope(account),
6834
+ ...accountCredential === void 0 ? {} : { accountCredential }
6835
+ });
6836
+ } catch (error) {
6837
+ if (error instanceof RelayAccountRevisionConflictError) {
6838
+ reply.code(409).send({
6839
+ code: "RELAY_ACCOUNT_REVISION_CONFLICT",
6840
+ error: "relay account revision conflict",
6841
+ current: accountEnvelope(error.current)
6842
+ });
6843
+ return;
6844
+ }
6845
+ reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
6846
+ }
6847
+ };
6276
6848
  app.post(
6277
6849
  "/v1/accounts/:accountId/credential",
6278
6850
  { preHandler: requireRoot },
6279
- async (request, reply) => {
6280
- const accountCredential = generateRelayAccountCredential();
6281
- try {
6282
- const account = accountStore.rotateCredential(
6283
- request.params.accountId,
6284
- accountCredential,
6285
- safeRevision(request.body?.expectedRevision)
6286
- );
6287
- if (!account) {
6288
- reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
6289
- return;
6290
- }
6291
- reply.code(200).send({ ...accountEnvelope(account), accountCredential });
6292
- } catch (error) {
6293
- if (error instanceof RelayAccountRevisionConflictError) {
6294
- reply.code(409).send({
6295
- code: "RELAY_ACCOUNT_REVISION_CONFLICT",
6296
- error: "relay account revision conflict",
6297
- current: accountEnvelope(error.current)
6298
- });
6299
- return;
6300
- }
6301
- reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
6302
- }
6303
- }
6851
+ rotateAccountHandler(false)
6852
+ );
6853
+ app.post(
6854
+ "/v1/accounts/:accountId/credential/client-hashed",
6855
+ { preHandler: requireRoot },
6856
+ rotateAccountHandler(true)
6857
+ );
6858
+ app.get(
6859
+ "/v1/account/recovery",
6860
+ { preHandler: requireRecoverableAccount },
6861
+ async (request) => accountEnvelope(authenticatedAccounts.get(request))
6304
6862
  );
6305
6863
  app.get(
6306
6864
  "/v1/account",
@@ -6378,6 +6936,7 @@ function createBlindRelayServer(options) {
6378
6936
  reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
6379
6937
  return;
6380
6938
  }
6939
+ clearRouteRates(request.params.routeId);
6381
6940
  const host = hosts.get(request.params.routeId);
6382
6941
  if (host) closeHost(host, 4403, "route deleted");
6383
6942
  reply.code(204).send();
@@ -6414,9 +6973,15 @@ function createBlindRelayServer(options) {
6414
6973
  }
6415
6974
  );
6416
6975
  }
6417
- app.register(websocket);
6976
+ app.register(websocket, { options: { maxPayload: maxEnvelopeBytes, perMessageDeflate: false } });
6418
6977
  app.register(async (scope) => {
6419
6978
  scope.get("/v1/connect", { websocket: true }, (socket, request) => {
6979
+ if (sockets.size >= maxTotalConnections) {
6980
+ metrics.rejectedConnections += 1;
6981
+ socket.close(4429, "relay connection limit");
6982
+ return;
6983
+ }
6984
+ sockets.add(socket);
6420
6985
  let authenticated = false;
6421
6986
  let liveHost;
6422
6987
  let liveDevice;
@@ -6435,29 +7000,41 @@ function createBlindRelayServer(options) {
6435
7000
  if (!authenticated) {
6436
7001
  try {
6437
7002
  const hello = parseAuthHello(parseJson(raw, 8 * 1024));
6438
- const origin = typeof request.headers.origin === "string" ? normalizeOrigin2(request.headers.origin) : void 0;
6439
- if (hello.role === "device" && origin && allowedOrigins.size > 0 && !allowedOrigins.has(origin)) {
7003
+ const presentedOrigin = request.headers.origin;
7004
+ const origin = typeof presentedOrigin === "string" ? normalizeOrigin2(presentedOrigin) : void 0;
7005
+ if (hello.role === "device" && allowedOrigins.size > 0 && presentedOrigin !== void 0 && (!origin || !allowedOrigins.has(origin))) {
6440
7006
  reject(4403, "origin denied");
6441
7007
  return;
6442
7008
  }
6443
7009
  if (hello.role === "host") {
6444
- if (!routeAccountIsActive(hello.routeId) || !store.authenticateHost(hello.routeId, hello.credential)) {
7010
+ const route = store.getRoute(hello.routeId);
7011
+ if (!route || route.ownerAccountId !== void 0 && accountStore?.getAccount(route.ownerAccountId)?.status !== "active" || !store.authenticateHost(hello.routeId, hello.credential)) {
6445
7012
  reject(4401, "authentication failed");
6446
7013
  return;
6447
7014
  }
7015
+ const rate = rateWindowFor(hostRates, hello.routeId);
7016
+ if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
7017
+ reject(4429, "relay identity rate limit");
7018
+ return;
7019
+ }
6448
7020
  const previous = hosts.get(hello.routeId);
6449
7021
  if (previous) closeHost(previous, 4409, "superseded host");
6450
7022
  liveHost = {
6451
7023
  socket,
6452
7024
  routeId: hello.routeId,
7025
+ ...route.ownerAccountId === void 0 ? {} : { ownerAccountId: route.ownerAccountId },
6453
7026
  devices: /* @__PURE__ */ new Map(),
6454
- rate: { startedAt: now(), bytes: 0, messages: 0 },
7027
+ rate,
6455
7028
  closed: false
6456
7029
  };
6457
7030
  hosts.set(hello.routeId, liveHost);
6458
7031
  metrics.activeHosts += 1;
6459
7032
  touchHost(liveHost);
6460
- safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes);
7033
+ if (!safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes)) {
7034
+ metrics.rejectedConnections += 1;
7035
+ closeHost(liveHost, 4408, "relay backpressure");
7036
+ return;
7037
+ }
6461
7038
  } else {
6462
7039
  if (!routeAccountIsActive(hello.routeId) || !store.authenticateDevice(hello.routeId, hello.deviceId, hello.credential, now())) {
6463
7040
  reject(4401, "authentication failed");
@@ -6472,6 +7049,11 @@ function createBlindRelayServer(options) {
6472
7049
  reject(4429, "route connection limit");
6473
7050
  return;
6474
7051
  }
7052
+ const rate = rateWindowFor(deviceRates, deviceRateKey(hello.routeId, hello.deviceId));
7053
+ if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
7054
+ reject(4429, "relay identity rate limit");
7055
+ return;
7056
+ }
6475
7057
  const previous = host.devices.get(hello.deviceId);
6476
7058
  if (previous) closeDevice(previous, 4409, "superseded device");
6477
7059
  const channelId = safeId9(generateChannelId(), "channel id");
@@ -6480,15 +7062,20 @@ function createBlindRelayServer(options) {
6480
7062
  routeId: hello.routeId,
6481
7063
  deviceId: hello.deviceId,
6482
7064
  channelId,
6483
- rate: { startedAt: now(), bytes: 0, messages: 0 },
7065
+ rate,
6484
7066
  closed: false
6485
7067
  };
6486
7068
  host.devices.set(hello.deviceId, liveDevice);
6487
7069
  devicesByChannel.set(channelId, liveDevice);
6488
7070
  metrics.activeDevices += 1;
6489
7071
  touchDevice(liveDevice);
6490
- safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes);
7072
+ if (!safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes)) {
7073
+ metrics.rejectedConnections += 1;
7074
+ closeDevice(liveDevice, 4408, "relay backpressure");
7075
+ return;
7076
+ }
6491
7077
  if (!safeSend(host.socket, { t: "peer-open", channelId, deviceId: hello.deviceId }, maxQueueBytes)) {
7078
+ metrics.rejectedConnections += 1;
6492
7079
  closeDevice(liveDevice, 4408, "host backpressure");
6493
7080
  return;
6494
7081
  }
@@ -6502,11 +7089,18 @@ function createBlindRelayServer(options) {
6502
7089
  return;
6503
7090
  }
6504
7091
  try {
6505
- const value = parseJson(raw, maxFrameBytes * 2);
7092
+ const value = parseJson(raw, maxEnvelopeBytes);
6506
7093
  if (liveDevice) {
6507
7094
  touchDevice(liveDevice);
6508
7095
  if (value?.t === "ping") {
6509
- safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
7096
+ if (!consumeRate(liveDevice.rate, 1)) {
7097
+ closeDevice(liveDevice, 4429, "rate limit");
7098
+ return;
7099
+ }
7100
+ if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
7101
+ metrics.droppedFrames += 1;
7102
+ closeDevice(liveDevice, 4408, "relay backpressure");
7103
+ }
6510
7104
  return;
6511
7105
  }
6512
7106
  const frame = parsePayload(value, false, maxFrameBytes);
@@ -6531,7 +7125,14 @@ function createBlindRelayServer(options) {
6531
7125
  if (liveHost) {
6532
7126
  touchHost(liveHost);
6533
7127
  if (value?.t === "ping") {
6534
- safeSend(socket, { t: "pong", at: now() }, maxQueueBytes);
7128
+ if (!consumeRate(liveHost.rate, 1)) {
7129
+ closeHost(liveHost, 4429, "rate limit");
7130
+ return;
7131
+ }
7132
+ if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
7133
+ metrics.droppedFrames += 1;
7134
+ closeHost(liveHost, 4408, "relay backpressure");
7135
+ }
6535
7136
  return;
6536
7137
  }
6537
7138
  if (value?.t === "close-peer") {
@@ -6569,6 +7170,7 @@ function createBlindRelayServer(options) {
6569
7170
  }
6570
7171
  });
6571
7172
  socket.once("close", () => {
7173
+ sockets.delete(socket);
6572
7174
  clearTimeout(handshakeTimer);
6573
7175
  if (liveDevice) closeDevice(liveDevice);
6574
7176
  if (liveHost) closeHost(liveHost);
@@ -6579,18 +7181,35 @@ function createBlindRelayServer(options) {
6579
7181
  });
6580
7182
  app.addHook("onClose", async () => {
6581
7183
  for (const host of [...hosts.values()]) closeHost(host, 1001, "relay shutting down");
7184
+ for (const socket of [...sockets]) socket.close(1001, "relay shutting down");
7185
+ hostRates.clear();
7186
+ deviceRates.clear();
6582
7187
  if (ownsStore) store.close();
6583
7188
  });
6584
7189
  return {
6585
7190
  app,
6586
7191
  store,
6587
7192
  ...accountStore ? { accountStore } : {},
6588
- metrics: () => ({ ...metrics, activeHosts: hosts.size, activeDevices: devicesByChannel.size })
7193
+ metrics: () => ({
7194
+ ...metrics,
7195
+ activeConnections: sockets.size,
7196
+ activeHosts: hosts.size,
7197
+ activeDevices: devicesByChannel.size
7198
+ })
6589
7199
  };
6590
7200
  }
6591
7201
 
6592
7202
  // src/relay-start.ts
6593
- import { readFileSync as readFileSync3, realpathSync } from "fs";
7203
+ import {
7204
+ closeSync as closeSync3,
7205
+ constants as constants4,
7206
+ fstatSync as fstatSync3,
7207
+ lstatSync as lstatSync3,
7208
+ openSync as openSync3,
7209
+ readFileSync as readFileSync3,
7210
+ readdirSync,
7211
+ realpathSync
7212
+ } from "fs";
6594
7213
  import { join as join5, resolve as resolve2 } from "path";
6595
7214
  import { fileURLToPath, pathToFileURL } from "url";
6596
7215
  function boundedInteger(value, fallback, minimum, maximum, field) {
@@ -6605,8 +7224,51 @@ function relayDataDir(env) {
6605
7224
  function relayOrigins(env) {
6606
7225
  return (env.ROAMCODE_RELAY_ALLOWED_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
6607
7226
  }
7227
+ function privateSecretFile(path, field) {
7228
+ let descriptor;
7229
+ try {
7230
+ const before = lstatSync3(path);
7231
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
7232
+ throw new Error("unsafe secret file");
7233
+ }
7234
+ descriptor = openSync3(path, constants4.O_RDONLY | (constants4.O_NOFOLLOW ?? 0));
7235
+ const opened = fstatSync3(descriptor);
7236
+ 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) {
7237
+ throw new Error("unsafe secret file");
7238
+ }
7239
+ return readFileSync3(descriptor, "utf8").trim();
7240
+ } catch {
7241
+ throw new Error(`${field} could not be read securely`);
7242
+ } finally {
7243
+ if (descriptor !== void 0) closeSync3(descriptor);
7244
+ }
7245
+ }
6608
7246
  function previousRootTokens(env) {
6609
- return (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
7247
+ const inline = (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
7248
+ const directory = env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR?.trim();
7249
+ if (inline.length > 0 && directory) {
7250
+ throw new Error(
7251
+ "ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS and ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR are mutually exclusive"
7252
+ );
7253
+ }
7254
+ if (!directory) return inline;
7255
+ let before;
7256
+ let entries;
7257
+ try {
7258
+ before = lstatSync3(directory);
7259
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
7260
+ throw new Error("unsafe secret directory");
7261
+ }
7262
+ entries = readdirSync(directory, { withFileTypes: true });
7263
+ const after = lstatSync3(directory);
7264
+ 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())) {
7265
+ throw new Error("unsafe secret directory");
7266
+ }
7267
+ } catch {
7268
+ throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR could not be read securely");
7269
+ }
7270
+ if (entries.length > 3) throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR may contain at most three files");
7271
+ return entries.map((entry) => entry.name).sort().map((name) => privateSecretFile(join5(directory, name), "previous relay root capability"));
6610
7272
  }
6611
7273
  function explicitBoolean(value, field) {
6612
7274
  if (value === void 0 || value === "" || value === "0" || value === "false") return false;
@@ -6619,18 +7281,12 @@ function secretValue(env, directKey, fileKey) {
6619
7281
  if (direct && file) throw new Error(`${directKey} and ${fileKey} are mutually exclusive`);
6620
7282
  if (direct) return direct;
6621
7283
  if (!file) return void 0;
6622
- let contents;
6623
- try {
6624
- contents = readFileSync3(file, "utf8");
6625
- } catch {
6626
- throw new Error(`${fileKey} could not be read`);
6627
- }
6628
- if (Buffer.byteLength(contents) > 4096) throw new Error(`${fileKey} is too large`);
6629
- return contents.trim();
7284
+ return privateSecretFile(file, fileKey);
6630
7285
  }
6631
7286
  async function startBlindRelay(env = process.env) {
6632
7287
  const rootToken = secretValue(env, "ROAMCODE_RELAY_ROOT_TOKEN", "ROAMCODE_RELAY_ROOT_TOKEN_FILE");
6633
7288
  if (!rootToken) throw new Error("ROAMCODE_RELAY_ROOT_TOKEN or ROAMCODE_RELAY_ROOT_TOKEN_FILE is required");
7289
+ const previousRoots = previousRootTokens(env);
6634
7290
  const allowedOrigins = relayOrigins(env);
6635
7291
  const allowAnyOrigin = explicitBoolean(env.ROAMCODE_RELAY_ALLOW_ANY_ORIGIN, "relay allow-any-origin flag");
6636
7292
  if (env.NODE_ENV === "production" && allowedOrigins.length === 0 && !allowAnyOrigin) {
@@ -6659,7 +7315,7 @@ async function startBlindRelay(env = process.env) {
6659
7315
  try {
6660
7316
  relay = createBlindRelayServer({
6661
7317
  rootToken,
6662
- previousRootTokens: previousRootTokens(env),
7318
+ previousRootTokens: previousRoots,
6663
7319
  store,
6664
7320
  ...accountStore ? { accountStore } : {},
6665
7321
  allowedOrigins,
@@ -6691,6 +7347,13 @@ async function startBlindRelay(env = process.env) {
6691
7347
  64 * 1024 * 1024,
6692
7348
  "relay queue limit"
6693
7349
  ),
7350
+ maxTotalConnections: boundedInteger(
7351
+ env.ROAMCODE_RELAY_MAX_TOTAL_CONNECTIONS,
7352
+ 1024,
7353
+ 1,
7354
+ 1e5,
7355
+ "relay total connection limit"
7356
+ ),
6694
7357
  maxConnectionsPerRoute: boundedInteger(
6695
7358
  env.ROAMCODE_RELAY_MAX_CONNECTIONS_PER_ROUTE,
6696
7359
  64,
@@ -6759,25 +7422,27 @@ if (isRelayDirectExecution(import.meta.url, process.argv[1], embeddedContainerBu
6759
7422
 
6760
7423
  // src/relay-host-config.ts
6761
7424
  import {
6762
- chmodSync as chmodSync3,
6763
- closeSync as closeSync2,
6764
- constants as constants3,
6765
- fsyncSync as fsyncSync2,
6766
- lstatSync as lstatSync2,
6767
- openSync as openSync2,
7425
+ closeSync as closeSync4,
7426
+ constants as constants5,
7427
+ fchmodSync as fchmodSync3,
7428
+ fstatSync as fstatSync4,
7429
+ fsyncSync as fsyncSync3,
7430
+ lstatSync as lstatSync4,
7431
+ openSync as openSync4,
6768
7432
  readFileSync as readFileSync4,
6769
- renameSync,
6770
- unlinkSync as unlinkSync2,
7433
+ renameSync as renameSync2,
7434
+ unlinkSync as unlinkSync3,
6771
7435
  writeFileSync as writeFileSync3
6772
7436
  } from "fs";
6773
7437
  import { randomBytes as randomBytes9 } from "crypto";
6774
- import { join as join6 } from "path";
7438
+ import { dirname as dirname3, join as join6 } from "path";
6775
7439
  var RELAY_HOST_CONFIG_FILE = "relay-host.json";
6776
7440
  var MAX_CONFIG_BYTES = 16 * 1024;
7441
+ var UNSAFE_DISPLAY_TEXT4 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
6777
7442
  function safeHostLabel(value) {
6778
7443
  if (typeof value !== "string") throw new Error("relay host label is required");
6779
7444
  const normalized = value.trim().replace(/\s+/g, " ");
6780
- if (!normalized || normalized.length > 80 || /[\p{Cc}\p{Zl}\p{Zp}]/u.test(normalized)) {
7445
+ if (!normalized || normalized.length > 80 || UNSAFE_DISPLAY_TEXT4.test(normalized)) {
6781
7446
  throw new Error("relay host label must be 1-80 printable characters");
6782
7447
  }
6783
7448
  return normalized;
@@ -6804,23 +7469,49 @@ function relayHostConfigPath(dataDir) {
6804
7469
  }
6805
7470
  function existingConfigStat(path) {
6806
7471
  try {
6807
- return lstatSync2(path);
7472
+ return lstatSync4(path);
6808
7473
  } catch (error) {
6809
7474
  if (error.code === "ENOENT") return void 0;
6810
7475
  throw error;
6811
7476
  }
6812
7477
  }
7478
+ function fsyncDirectory3(path) {
7479
+ let descriptor;
7480
+ try {
7481
+ descriptor = openSync4(path, constants5.O_RDONLY);
7482
+ fsyncSync3(descriptor);
7483
+ } catch (error) {
7484
+ const code = error.code;
7485
+ if (code !== "EINVAL" && code !== "ENOTSUP" && !(process.platform === "win32" && code === "EPERM")) {
7486
+ throw error;
7487
+ }
7488
+ } finally {
7489
+ if (descriptor !== void 0) closeSync4(descriptor);
7490
+ }
7491
+ }
6813
7492
  function readRelayHostConfig(dataDir) {
6814
7493
  const path = relayHostConfigPath(dataDir);
6815
- const stat8 = existingConfigStat(path);
6816
- if (!stat8) return void 0;
6817
- if (!stat8.isFile() || stat8.isSymbolicLink()) throw new Error("relay host config path must be a regular file");
6818
- if (stat8.size > MAX_CONFIG_BYTES) throw new Error("relay host config is too large");
7494
+ const before = existingConfigStat(path);
7495
+ if (!before) return void 0;
7496
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error("relay host config path must be a regular file");
7497
+ if (before.size > MAX_CONFIG_BYTES) throw new Error("relay host config is too large");
7498
+ if (typeof process.getuid === "function" && before.uid !== process.getuid()) {
7499
+ throw new Error("relay host config must be owned by the current user");
7500
+ }
7501
+ let descriptor;
6819
7502
  let value;
6820
7503
  try {
6821
- value = JSON.parse(readFileSync4(path, "utf8"));
7504
+ descriptor = openSync4(path, constants5.O_RDONLY | (constants5.O_NOFOLLOW ?? 0));
7505
+ const opened = fstatSync4(descriptor);
7506
+ if (!opened.isFile() || opened.size > MAX_CONFIG_BYTES || opened.dev !== before.dev || opened.ino !== before.ino || typeof process.getuid === "function" && opened.uid !== process.getuid()) {
7507
+ throw new Error("relay host config changed while it was being opened");
7508
+ }
7509
+ fchmodSync3(descriptor, 384);
7510
+ value = JSON.parse(readFileSync4(descriptor, "utf8"));
6822
7511
  } catch {
6823
7512
  throw new Error("relay host config is corrupt");
7513
+ } finally {
7514
+ if (descriptor !== void 0) closeSync4(descriptor);
6824
7515
  }
6825
7516
  if (value.version !== 1) throw new Error("relay host config has an unsupported version");
6826
7517
  const validated = validateCore({
@@ -6830,7 +7521,6 @@ function readRelayHostConfig(dataDir) {
6830
7521
  appUrl: value.appUrl,
6831
7522
  hostLabel: value.hostLabel
6832
7523
  });
6833
- chmodSync3(path, 384);
6834
7524
  return { version: 1, ...validated };
6835
7525
  }
6836
7526
  function writeRelayHostConfig(dataDir, input) {
@@ -6841,24 +7531,31 @@ function writeRelayHostConfig(dataDir, input) {
6841
7531
  if (existing) {
6842
7532
  const stat8 = existing;
6843
7533
  if (!stat8.isFile() || stat8.isSymbolicLink()) throw new Error("relay host config path must be a regular file");
7534
+ if (typeof process.getuid === "function" && stat8.uid !== process.getuid()) {
7535
+ throw new Error("relay host config must be owned by the current user");
7536
+ }
6844
7537
  }
6845
7538
  const temporary = `${path}.${randomBytes9(12).toString("hex")}.tmp`;
6846
7539
  let descriptor;
6847
7540
  try {
6848
- descriptor = openSync2(temporary, constants3.O_CREAT | constants3.O_EXCL | constants3.O_WRONLY, 384);
7541
+ descriptor = openSync4(
7542
+ temporary,
7543
+ constants5.O_CREAT | constants5.O_EXCL | constants5.O_WRONLY | (constants5.O_NOFOLLOW ?? 0),
7544
+ 384
7545
+ );
7546
+ fchmodSync3(descriptor, 384);
6849
7547
  writeFileSync3(descriptor, `${JSON.stringify(document)}
6850
7548
  `, "utf8");
6851
- fsyncSync2(descriptor);
6852
- closeSync2(descriptor);
7549
+ fsyncSync3(descriptor);
7550
+ closeSync4(descriptor);
6853
7551
  descriptor = void 0;
6854
- chmodSync3(temporary, 384);
6855
- renameSync(temporary, path);
6856
- chmodSync3(path, 384);
7552
+ renameSync2(temporary, path);
7553
+ fsyncDirectory3(dirname3(path));
6857
7554
  return { ...document };
6858
7555
  } finally {
6859
- if (descriptor !== void 0) closeSync2(descriptor);
7556
+ if (descriptor !== void 0) closeSync4(descriptor);
6860
7557
  try {
6861
- unlinkSync2(temporary);
7558
+ unlinkSync3(temporary);
6862
7559
  } catch {
6863
7560
  }
6864
7561
  }
@@ -6868,7 +7565,11 @@ function removeRelayHostConfig(dataDir) {
6868
7565
  const stat8 = existingConfigStat(path);
6869
7566
  if (!stat8) return false;
6870
7567
  if (!stat8.isFile() || stat8.isSymbolicLink()) throw new Error("relay host config path must be a regular file");
6871
- unlinkSync2(path);
7568
+ if (typeof process.getuid === "function" && stat8.uid !== process.getuid()) {
7569
+ throw new Error("relay host config must be owned by the current user");
7570
+ }
7571
+ unlinkSync3(path);
7572
+ fsyncDirectory3(dirname3(path));
6872
7573
  return true;
6873
7574
  }
6874
7575
  function resolveRelayHostConfig(env, dataDir) {
@@ -7317,7 +8018,7 @@ function openPolicyStore(options) {
7317
8018
 
7318
8019
  // src/peer-store.ts
7319
8020
  import { randomBytes as randomBytes10 } from "crypto";
7320
- import { chmodSync as chmodSync4 } from "fs";
8021
+ import { chmodSync } from "fs";
7321
8022
  import { createRequire as createRequire7 } from "module";
7322
8023
  var require8 = createRequire7(import.meta.url);
7323
8024
  var PeerRevisionConflictError = class extends Error {
@@ -7344,7 +8045,7 @@ function safeLabel5(value) {
7344
8045
  return label;
7345
8046
  }
7346
8047
  function isLoopback3(hostname) {
7347
- return hostname === "localhost" || hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
8048
+ return hostname === "localhost" || hostname === "::1" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
7348
8049
  }
7349
8050
  function normalizePeerBaseUrl(value) {
7350
8051
  if (typeof value !== "string") throw new Error("peer URL is required");
@@ -7542,7 +8243,7 @@ function openPeerStore(options) {
7542
8243
  return createMemoryStore5(options);
7543
8244
  }
7544
8245
  const db = new Database(options.dbPath);
7545
- if (options.dbPath !== ":memory:") chmodSync4(options.dbPath, 384);
8246
+ if (options.dbPath !== ":memory:") chmodSync(options.dbPath, 384);
7546
8247
  db.pragma("journal_mode = WAL");
7547
8248
  db.pragma("busy_timeout = 5000");
7548
8249
  db.exec(`
@@ -8608,7 +9309,7 @@ var normalizeCommandCenterLabel = normalizeLabel;
8608
9309
  // src/worktree-service.ts
8609
9310
  import { spawn } from "child_process";
8610
9311
  import { realpath as realpath2, stat as stat2 } from "fs/promises";
8611
- import { basename as basename3, dirname, isAbsolute, relative, resolve as resolve4 } from "path";
9312
+ import { basename as basename3, dirname as dirname4, isAbsolute, relative, resolve as resolve4 } from "path";
8612
9313
  var MAX_GIT_OUTPUT_BYTES = 256 * 1024;
8613
9314
  var GIT_TIMEOUT_MS = 6e4;
8614
9315
  var WorktreeError = class extends Error {
@@ -8649,7 +9350,7 @@ function createWorktreeService(options) {
8649
9350
  };
8650
9351
  const confinedTarget = async (value) => {
8651
9352
  const lexical = resolve4(value);
8652
- const parent = await realpath2(dirname(lexical)).catch(() => void 0);
9353
+ const parent = await realpath2(dirname4(lexical)).catch(() => void 0);
8653
9354
  if (!parent || !inside(await root(), parent)) {
8654
9355
  throw new WorktreeError("WORKTREE_OUTSIDE_ROOT", "worktree parent is outside FS_ROOT", 403);
8655
9356
  }
@@ -8724,7 +9425,7 @@ function createWorktreeService(options) {
8724
9425
  const gitRaw = (await runGit(candidate, ["rev-parse", "--git-dir"])).stdout.trim();
8725
9426
  const commonDir = await realpath2(resolve4(candidate, commonRaw));
8726
9427
  const gitDir = await realpath2(resolve4(candidate, gitRaw));
8727
- const repositoryPath = dirname(commonDir);
9428
+ const repositoryPath = dirname4(commonDir);
8728
9429
  if (!inside(await root(), repositoryPath)) {
8729
9430
  throw new WorktreeError("WORKTREE_OUTSIDE_ROOT", "repository metadata is outside FS_ROOT", 403);
8730
9431
  }
@@ -8808,7 +9509,7 @@ function createWorktreeService(options) {
8808
9509
  import { createHash as createHash5, createPublicKey as createPublicKey2, verify as verifySignature } from "crypto";
8809
9510
  import { createRequire as createRequire9 } from "module";
8810
9511
  import { access, mkdir as mkdir2, open as open2, opendir, readFile as readFile2, realpath as realpath3, rename as rename2, rm, stat as stat3 } from "fs/promises";
8811
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join7, relative as relative2, resolve as resolve5 } from "path";
9512
+ import { dirname as dirname5, isAbsolute as isAbsolute2, join as join7, relative as relative2, resolve as resolve5 } from "path";
8812
9513
  import { z as z4 } from "zod";
8813
9514
  var require10 = createRequire9(import.meta.url);
8814
9515
  var MAX_PACKAGE_FILES = 512;
@@ -9080,7 +9781,7 @@ async function materialize(snapshot, target) {
9080
9781
  try {
9081
9782
  for (const file of snapshot.files) {
9082
9783
  const output = join7(temporary, ...file.path.split("/"));
9083
- await mkdir2(dirname2(output), { recursive: true, mode: 448 });
9784
+ await mkdir2(dirname5(output), { recursive: true, mode: 448 });
9084
9785
  const handle = await open2(output, "wx", file.mode);
9085
9786
  try {
9086
9787
  await handle.writeFile(file.bytes);
@@ -9088,7 +9789,7 @@ async function materialize(snapshot, target) {
9088
9789
  await handle.close();
9089
9790
  }
9090
9791
  }
9091
- await mkdir2(dirname2(target), { recursive: true, mode: 448 });
9792
+ await mkdir2(dirname5(target), { recursive: true, mode: 448 });
9092
9793
  await rename2(temporary, target).catch(async (error) => {
9093
9794
  if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY")
9094
9795
  throw error;
@@ -10056,7 +10757,7 @@ var RateLimiter = class {
10056
10757
  };
10057
10758
 
10058
10759
  // src/vapid.ts
10059
- import { chmodSync as chmodSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
10760
+ import { chmodSync as chmodSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
10060
10761
  import { join as join8 } from "path";
10061
10762
  import webpush from "web-push";
10062
10763
  function defaultGenerate() {
@@ -10075,7 +10776,7 @@ function resolveVapidKeys(opts) {
10075
10776
  const keys = (opts.generate ?? defaultGenerate)();
10076
10777
  ensureDataDir(opts.dataDir);
10077
10778
  writeFileSync4(path, JSON.stringify(keys), { mode: 384 });
10078
- chmodSync5(path, 384);
10779
+ chmodSync2(path, 384);
10079
10780
  return keys;
10080
10781
  }
10081
10782
 
@@ -10091,6 +10792,22 @@ import multipart from "@fastify/multipart";
10091
10792
  import fastifyStatic from "@fastify/static";
10092
10793
  import { existsSync as existsSync3 } from "fs";
10093
10794
  import { resolve as resolve7, sep as sep2 } from "path";
10795
+ var PWA_BOOT_WATCHDOG_SHA256 = "sha256-tcgQYptaPeNGqJtts8Ft/5H4tf+s+jfSWQSguIhTp8k=";
10796
+ var PWA_CONTENT_SECURITY_POLICY = [
10797
+ "default-src 'self'",
10798
+ `script-src 'self' '${PWA_BOOT_WATCHDOG_SHA256}'`,
10799
+ "style-src 'self' 'unsafe-inline'",
10800
+ "img-src 'self' data: blob:",
10801
+ "font-src 'self' data:",
10802
+ "connect-src 'self' https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*",
10803
+ "worker-src 'self' blob:",
10804
+ "manifest-src 'self'",
10805
+ "frame-src 'self' https: blob:",
10806
+ "object-src 'none'",
10807
+ "base-uri 'none'",
10808
+ "form-action 'none'",
10809
+ "frame-ancestors 'none'"
10810
+ ].join("; ");
10094
10811
  var API_PATH_DENYLIST = [
10095
10812
  /^\/sessions/,
10096
10813
  /^\/resumable/,
@@ -10145,6 +10862,9 @@ function registerStatic(app, opts) {
10145
10862
  app.register(fastifyStatic, { root: opts.webDir, wildcard: false });
10146
10863
  app.addHook("onSend", (request, reply, payload, done) => {
10147
10864
  const contentType = String(reply.getHeader("content-type") ?? "");
10865
+ if (isPublicForRequest(request.url)) {
10866
+ reply.header("content-security-policy", PWA_CONTENT_SECURITY_POLICY).header("permissions-policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()").header("referrer-policy", "no-referrer").header("x-content-type-options", "nosniff").header("x-frame-options", "DENY").header("x-permitted-cross-domain-policies", "none");
10867
+ }
10148
10868
  if (pathForGate(request.url) === "/sw.js" || contentType.includes("text/html")) {
10149
10869
  reply.header("cache-control", "no-store, no-cache, must-revalidate");
10150
10870
  }
@@ -10253,35 +10973,35 @@ import {
10253
10973
  renameSync as nodeRenameSync,
10254
10974
  writeFileSync as nodeWriteFileSync
10255
10975
  } from "fs";
10256
- import { dirname as dirname5, join as join12 } from "path";
10976
+ import { dirname as dirname8, join as join12 } from "path";
10257
10977
  import { fileURLToPath as fileURLToPath2 } from "url";
10258
10978
 
10259
10979
  // src/managed-runtime.ts
10260
10980
  import { spawn as spawn3 } from "child_process";
10261
10981
  import { randomUUID as randomUUID5 } from "crypto";
10262
10982
  import {
10263
- chmodSync as chmodSync7,
10983
+ chmodSync as chmodSync4,
10264
10984
  existsSync as existsSync5,
10265
- lstatSync as lstatSync3,
10985
+ lstatSync as lstatSync5,
10266
10986
  mkdirSync as mkdirSync3,
10267
- openSync as openSync3,
10268
- closeSync as closeSync3,
10269
- readdirSync,
10987
+ openSync as openSync5,
10988
+ closeSync as closeSync5,
10989
+ readdirSync as readdirSync2,
10270
10990
  readFileSync as readFileSync7,
10271
10991
  realpathSync as realpathSync2,
10272
- renameSync as renameSync2,
10992
+ renameSync as renameSync3,
10273
10993
  rmSync,
10274
10994
  symlinkSync,
10275
- unlinkSync as unlinkSync3,
10995
+ unlinkSync as unlinkSync4,
10276
10996
  writeFileSync as writeFileSync6
10277
10997
  } from "fs";
10278
10998
  import { homedir as homedir2, tmpdir } from "os";
10279
- import { basename as basename4, dirname as dirname4, join as join11, relative as relative4, resolve as resolve9 } from "path";
10999
+ import { basename as basename4, dirname as dirname7, join as join11, relative as relative4, resolve as resolve9 } from "path";
10280
11000
 
10281
11001
  // src/service-install.ts
10282
- import { chmodSync as chmodSync6, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
11002
+ import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
10283
11003
  import { homedir, platform } from "os";
10284
- import { dirname as dirname3, join as join10 } from "path";
11004
+ import { dirname as dirname6, join as join10 } from "path";
10285
11005
  import { spawnSync } from "child_process";
10286
11006
  function escapeXml(value) {
10287
11007
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;").replace(/'/g, "&apos;");
@@ -10291,7 +11011,7 @@ function quoteSystemd(value) {
10291
11011
  }
10292
11012
  function buildServicePath(nodePath, home) {
10293
11013
  return [
10294
- dirname3(nodePath),
11014
+ dirname6(nodePath),
10295
11015
  "/opt/homebrew/bin",
10296
11016
  "/usr/local/bin",
10297
11017
  join10(home, ".local", "bin"),
@@ -10365,7 +11085,7 @@ function persistRecord(dataDir, record) {
10365
11085
  mkdirSync2(dataDir, { recursive: true, mode: 448 });
10366
11086
  const path = join10(dataDir, "service.json");
10367
11087
  writeFileSync5(path, JSON.stringify(record, null, 2) + "\n", { mode: 384 });
10368
- chmodSync6(path, 384);
11088
+ chmodSync3(path, 384);
10369
11089
  }
10370
11090
  function installService(ctx) {
10371
11091
  const home = ctx.home ?? homedir();
@@ -10388,7 +11108,7 @@ function installService(ctx) {
10388
11108
  servicePath
10389
11109
  })
10390
11110
  );
10391
- chmodSync6(path, 420);
11111
+ chmodSync3(path, 420);
10392
11112
  const record = {
10393
11113
  manager: "launchd",
10394
11114
  label,
@@ -10420,7 +11140,7 @@ launchctl unload -w "${path}" # stop`
10420
11140
  servicePath
10421
11141
  })
10422
11142
  );
10423
- chmodSync6(path, 420);
11143
+ chmodSync3(path, 420);
10424
11144
  const record = {
10425
11145
  manager: "systemd",
10426
11146
  label,
@@ -10534,11 +11254,11 @@ function compareVersions(a, b) {
10534
11254
  return 0;
10535
11255
  }
10536
11256
  function atomicWrite(path, value, mode = 384) {
10537
- mkdirSync3(dirname4(path), { recursive: true, mode: 448 });
11257
+ mkdirSync3(dirname7(path), { recursive: true, mode: 448 });
10538
11258
  const temp = `${path}.${process.pid}.${randomUUID5()}.tmp`;
10539
11259
  writeFileSync6(temp, value, { mode });
10540
- chmodSync7(temp, mode);
10541
- renameSync2(temp, path);
11260
+ chmodSync4(temp, mode);
11261
+ renameSync3(temp, path);
10542
11262
  }
10543
11263
  function renderManagedLauncher(root, nodePath) {
10544
11264
  const q = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
@@ -10603,15 +11323,15 @@ function readPreviousVersion(root) {
10603
11323
  }
10604
11324
  }
10605
11325
  function acquireLock(path, now) {
10606
- mkdirSync3(dirname4(path), { recursive: true, mode: 448 });
11326
+ mkdirSync3(dirname7(path), { recursive: true, mode: 448 });
10607
11327
  try {
10608
- const fd = openSync3(path, "wx", 384);
11328
+ const fd = openSync5(path, "wx", 384);
10609
11329
  writeFileSync6(fd, JSON.stringify({ pid: process.pid, at: now }) + "\n");
10610
- closeSync3(fd);
11330
+ closeSync5(fd);
10611
11331
  } catch {
10612
11332
  try {
10613
- if (now - lstatSync3(path).mtimeMs > LOCK_STALE_MS) {
10614
- unlinkSync3(path);
11333
+ if (now - lstatSync5(path).mtimeMs > LOCK_STALE_MS) {
11334
+ unlinkSync4(path);
10615
11335
  return acquireLock(path, now);
10616
11336
  }
10617
11337
  } catch {
@@ -10624,7 +11344,7 @@ function acquireLock(path, now) {
10624
11344
  }
10625
11345
  return () => {
10626
11346
  try {
10627
- unlinkSync3(path);
11347
+ unlinkSync4(path);
10628
11348
  } catch {
10629
11349
  }
10630
11350
  };
@@ -10732,13 +11452,13 @@ async function smokeServer(serverEntry, dataDir, nodePath, log) {
10732
11452
  function replaceSymlink(link2, target) {
10733
11453
  const temp = `${link2}.${process.pid}.${randomUUID5()}.new`;
10734
11454
  try {
10735
- unlinkSync3(temp);
11455
+ unlinkSync4(temp);
10736
11456
  } catch {
10737
11457
  }
10738
- const parent = realpathSync2(dirname4(link2));
11458
+ const parent = realpathSync2(dirname7(link2));
10739
11459
  const canonicalTarget = realpathSync2(target);
10740
11460
  symlinkSync(relative4(parent, canonicalTarget), temp, "dir");
10741
- renameSync2(temp, link2);
11461
+ renameSync3(temp, link2);
10742
11462
  }
10743
11463
  function trimLog(log) {
10744
11464
  return log.split("\n").slice(-20).join("\n").slice(-8e3);
@@ -10829,7 +11549,7 @@ async function installManagedRelease(opts) {
10829
11549
  ) + "\n"
10830
11550
  );
10831
11551
  if (existsSync5(release)) rmSync(release, { recursive: true, force: true });
10832
- renameSync2(stage, release);
11552
+ renameSync3(stage, release);
10833
11553
  } else {
10834
11554
  const serverEntry = join11(release, "node_modules", "@roamcode.ai", "server", "dist", "start.js");
10835
11555
  if (!existsSync5(serverEntry)) throw new Error(`managed release ${opts.version} is incomplete`);
@@ -10861,7 +11581,7 @@ async function installManagedRelease(opts) {
10861
11581
  }
10862
11582
  status("done", "done");
10863
11583
  const keep = new Set([opts.version, fromVersion].filter((value) => !!value));
10864
- for (const name of readdirSync(paths.releases)) {
11584
+ for (const name of readdirSync2(paths.releases)) {
10865
11585
  if (isStableVersion(name) && !keep.has(name))
10866
11586
  rmSync(join11(paths.releases, name), { recursive: true, force: true });
10867
11587
  }
@@ -10890,7 +11610,7 @@ async function installManagedRelease(opts) {
10890
11610
  }
10891
11611
 
10892
11612
  // src/updater.ts
10893
- var RUNNING_VERSION = "1.1.0" ? "1.1.0".replace(/^v/, "") : packageVersion();
11613
+ var RUNNING_VERSION = "1.2.0" ? "1.2.0".replace(/^v/, "") : packageVersion();
10894
11614
  var RUNNING_BUILD = RUNNING_VERSION;
10895
11615
  var RELEASES_API = "https://api.github.com/repos/burakgon/roamcode/releases?per_page=100";
10896
11616
  var RELEASE_MANIFEST_ASSET = "roamcode-release.json";
@@ -11007,7 +11727,7 @@ function manifestIntegrities(value, version) {
11007
11727
  return integrities;
11008
11728
  }
11009
11729
  function moduleDir() {
11010
- return dirname5(fileURLToPath2(import.meta.url));
11730
+ return dirname8(fileURLToPath2(import.meta.url));
11011
11731
  }
11012
11732
  var Updater = class {
11013
11733
  fs;
@@ -11393,7 +12113,7 @@ function createClaudeVersionProbe(deps) {
11393
12113
  }
11394
12114
 
11395
12115
  // src/terminal-manager.ts
11396
- import { unlinkSync as unlinkSync4, readdirSync as readdirSync2 } from "fs";
12116
+ import { unlinkSync as unlinkSync5, readdirSync as readdirSync3 } from "fs";
11397
12117
  import { join as join14 } from "path";
11398
12118
 
11399
12119
  // src/terminal-process.ts
@@ -11402,17 +12122,21 @@ import { EventEmitter } from "events";
11402
12122
  import { createRequire as createRequire12 } from "module";
11403
12123
 
11404
12124
  // src/node-pty-runtime.ts
11405
- import { chmodSync as chmodSync8, existsSync as existsSync6 } from "fs";
12125
+ import { accessSync, chmodSync as chmodSync5, constants as constants6, existsSync as existsSync6 } from "fs";
11406
12126
  import { createRequire as createRequire11 } from "module";
11407
- import { dirname as dirname6, join as join13, resolve as resolve10 } from "path";
12127
+ import { dirname as dirname9, join as join13, resolve as resolve10 } from "path";
11408
12128
  var require12 = createRequire11(import.meta.url);
11409
12129
  function ensureNodePtySpawnHelperExecutable(resolveNodePty = () => require12.resolve("node-pty"), platform2 = process.platform, arch = process.arch) {
11410
- if (platform2 !== "darwin") return;
12130
+ if (platform2 !== "darwin") return true;
11411
12131
  try {
11412
- const packageRoot = resolve10(dirname6(resolveNodePty()), "..");
12132
+ const packageRoot = resolve10(dirname9(resolveNodePty()), "..");
11413
12133
  const helper = join13(packageRoot, "prebuilds", `${platform2}-${arch}`, "spawn-helper");
11414
- if (existsSync6(helper)) chmodSync8(helper, 493);
12134
+ if (!existsSync6(helper)) return true;
12135
+ chmodSync5(helper, 493);
12136
+ accessSync(helper, constants6.X_OK);
12137
+ return true;
11415
12138
  } catch {
12139
+ return false;
11416
12140
  }
11417
12141
  }
11418
12142
 
@@ -11589,7 +12313,7 @@ var TerminalProcess = class extends EventEmitter {
11589
12313
  }
11590
12314
  };
11591
12315
  var defaultPtySpawn = (file, args, opts) => {
11592
- ensureNodePtySpawnHelperExecutable();
12316
+ if (!ensureNodePtySpawnHelperExecutable()) throw new Error("node-pty spawn helper is not executable");
11593
12317
  const pty = require13("node-pty");
11594
12318
  return pty.spawn(file, args, opts);
11595
12319
  };
@@ -12025,7 +12749,7 @@ var TerminalManager = class {
12025
12749
  const dir = this.attachConfig.dataDir;
12026
12750
  let names;
12027
12751
  try {
12028
- names = readdirSync2(dir);
12752
+ names = readdirSync3(dir);
12029
12753
  } catch {
12030
12754
  return 0;
12031
12755
  }
@@ -12036,7 +12760,7 @@ var TerminalManager = class {
12036
12760
  const sessionId = m?.[1] ?? (name.startsWith(CODEX_MCP_TOKEN_PREFIX) ? name.slice(CODEX_MCP_TOKEN_PREFIX.length) : void 0);
12037
12761
  if (!sessionId || liveIds.has(sessionId)) continue;
12038
12762
  try {
12039
- unlinkSync4(join14(dir, name));
12763
+ unlinkSync5(join14(dir, name));
12040
12764
  removed += 1;
12041
12765
  } catch {
12042
12766
  }
@@ -12697,7 +13421,9 @@ function tmuxOnPath() {
12697
13421
  }
12698
13422
  function ptyLoads() {
12699
13423
  try {
12700
- require14.resolve("node-pty");
13424
+ const entry = require14.resolve("node-pty");
13425
+ if (!ensureNodePtySpawnHelperExecutable(() => entry)) return false;
13426
+ require14("node-pty");
12701
13427
  return true;
12702
13428
  } catch {
12703
13429
  return false;
@@ -12729,11 +13455,11 @@ function listTmuxSessions(run = defaultRun) {
12729
13455
  }
12730
13456
 
12731
13457
  // src/providers/provider-artifacts.ts
12732
- import { chmodSync as chmodSync9, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "fs";
13458
+ import { chmodSync as chmodSync6, unlinkSync as unlinkSync6, writeFileSync as writeFileSync7 } from "fs";
12733
13459
  function cleanupProviderArtifacts(paths) {
12734
13460
  for (const path of paths) {
12735
13461
  try {
12736
- unlinkSync5(path);
13462
+ unlinkSync6(path);
12737
13463
  } catch {
12738
13464
  }
12739
13465
  }
@@ -12748,7 +13474,7 @@ function writeProviderArtifact0600(path, content, context, ownedPaths) {
12748
13474
  ownedPaths.push(path);
12749
13475
  try {
12750
13476
  writeFileSync7(path, content, { mode: 384 });
12751
- chmodSync9(path, 384);
13477
+ chmodSync6(path, 384);
12752
13478
  return true;
12753
13479
  } catch {
12754
13480
  cleanupProviderArtifacts([path]);
@@ -13780,6 +14506,30 @@ function buildOpenApiDocument(options) {
13780
14506
  }
13781
14507
  }
13782
14508
  },
14509
+ "/api/v1/relay/pairing/cancel": {
14510
+ post: {
14511
+ operationId: "cancelRelayPairing",
14512
+ description: "Revokes an unused broker bootstrap before deleting its local one-use pairing state. A raced completed enrollment is never silently revoked.",
14513
+ parameters: [idempotency],
14514
+ requestBody: {
14515
+ required: true,
14516
+ content: json({
14517
+ type: "object",
14518
+ required: ["deviceId"],
14519
+ additionalProperties: false,
14520
+ properties: { deviceId: { type: "string", minLength: 1, maxLength: 128 } }
14521
+ })
14522
+ },
14523
+ responses: { "204": response("Cancelled remote pairing"), ...errorResponses }
14524
+ }
14525
+ },
14526
+ "/api/v1/relay/status": {
14527
+ get: {
14528
+ operationId: "getRelayStatus",
14529
+ description: "Returns privacy-bounded host connector health without routing identifiers or capabilities.",
14530
+ responses: { "200": response("Relay connector health", ref("RelayStatus")), ...errorResponses }
14531
+ }
14532
+ },
13783
14533
  "/api/v1/team": {
13784
14534
  get: {
13785
14535
  operationId: "getTeam",
@@ -14335,6 +15085,18 @@ function buildOpenApiDocument(options) {
14335
15085
  hostIdentityFingerprint: { type: "string", pattern: "^sha256:[A-Za-z0-9_-]{43}$" }
14336
15086
  }
14337
15087
  },
15088
+ RelayStatus: {
15089
+ type: "object",
15090
+ required: ["configured", "pairingAvailable", "status", "activeDevices", "reconnects"],
15091
+ additionalProperties: false,
15092
+ properties: {
15093
+ configured: { type: "boolean" },
15094
+ pairingAvailable: { type: "boolean" },
15095
+ status: { enum: ["not-configured", "idle", "connecting", "online", "reconnecting", "stopped"] },
15096
+ activeDevices: { type: "integer", minimum: 0 },
15097
+ reconnects: { type: "integer", minimum: 0 }
15098
+ }
15099
+ },
14338
15100
  WorkspaceCreate: {
14339
15101
  type: "object",
14340
15102
  required: ["cwd"],
@@ -15879,6 +16641,10 @@ function createServer(config, deps = {}) {
15879
16641
  if (device) return { actorType: "device", actorId: device.id, label: device.name };
15880
16642
  return hostPrincipal();
15881
16643
  };
16644
+ const currentDeviceIdForRequest = (request) => {
16645
+ const principal = authenticatedPrincipals.get(request);
16646
+ return principal?.actorType === "device" || principal?.actorType === "relay" ? principal.actorId : void 0;
16647
+ };
15882
16648
  const teamResourceForSession = (sessionId) => ({
15883
16649
  hostId: commandStore.getHost().id,
15884
16650
  ...commandStore.placementForSession(sessionId)?.workspaceId ? { workspaceId: commandStore.placementForSession(sessionId).workspaceId } : {}
@@ -16810,6 +17576,25 @@ function createServer(config, deps = {}) {
16810
17576
  reply.code(500).send({ error: "could not start device pairing" });
16811
17577
  }
16812
17578
  });
17579
+ app.post("/pairing/cancel", { bodyLimit: 8 * 1024 }, async (request, reply) => {
17580
+ const secret = request.body?.secret;
17581
+ if (typeof secret !== "string" || !/^rcp_[A-Za-z0-9_-]{43}$/.test(secret)) {
17582
+ reply.code(400).send({ code: "INVALID_PAIRING", error: "valid pairing capability is required" });
17583
+ return;
17584
+ }
17585
+ let cancelled = false;
17586
+ try {
17587
+ cancelled = deviceStore.cancelPairing(secret);
17588
+ } catch {
17589
+ reply.code(500).send({ code: "PAIRING_CANCEL_FAILED", error: "could not cancel pairing" });
17590
+ return;
17591
+ }
17592
+ if (!cancelled) {
17593
+ reply.code(404).send({ code: "PAIRING_NOT_FOUND", error: "pairing is expired, cancelled, or already used" });
17594
+ return;
17595
+ }
17596
+ reply.header("cache-control", "no-store").code(204).send();
17597
+ });
16813
17598
  app.post("/api/v1/relay/pairing", async (_request, reply) => {
16814
17599
  const relay = deps.relayPairing;
16815
17600
  if (!relay) {
@@ -16819,9 +17604,14 @@ function createServer(config, deps = {}) {
16819
17604
  });
16820
17605
  return;
16821
17606
  }
17607
+ let pendingDeviceId;
16822
17608
  try {
16823
17609
  const pairing = deviceStore.issueRelayPairing();
17610
+ pendingDeviceId = pairing.deviceId;
16824
17611
  const deviceCredential = (relay.generateDeviceCredential ?? (() => generateRelayCredential("rrd")))();
17612
+ if (!/^rrd_[A-Za-z0-9_-]{43}$/.test(deviceCredential)) {
17613
+ throw new Error("invalid generated relay device credential");
17614
+ }
16825
17615
  await relay.provisioner.putDevice(pairing.deviceId, relayCredentialHash(deviceCredential), pairing.expiresAt);
16826
17616
  const payload = {
16827
17617
  v: 1,
@@ -16838,9 +17628,65 @@ function createServer(config, deps = {}) {
16838
17628
  };
16839
17629
  reply.header("cache-control", "no-store").code(201).send({ pairing: payload, url: buildRelayPairingUrl(relay.appUrl, payload) });
16840
17630
  } catch {
17631
+ if (pendingDeviceId) {
17632
+ try {
17633
+ deviceStore.cancelRelayPairing(pendingDeviceId);
17634
+ } catch {
17635
+ }
17636
+ await relay.provisioner.revokeDevice(pendingDeviceId).catch(() => {
17637
+ });
17638
+ }
16841
17639
  reply.code(502).send({ code: "RELAY_PAIRING_FAILED", error: "could not prepare relay pairing" });
16842
17640
  }
16843
17641
  });
17642
+ app.post(
17643
+ "/api/v1/relay/pairing/cancel",
17644
+ { bodyLimit: 8 * 1024 },
17645
+ async (request, reply) => {
17646
+ const relay = deps.relayPairing;
17647
+ if (!relay) {
17648
+ reply.code(409).send({ code: "RELAY_PAIRING_UNAVAILABLE", error: "relay pairing is not configured" });
17649
+ return;
17650
+ }
17651
+ const deviceId = request.body?.deviceId;
17652
+ if (typeof deviceId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(deviceId)) {
17653
+ reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "valid relay device id is required" });
17654
+ return;
17655
+ }
17656
+ try {
17657
+ const cancellation = deviceStore.beginRelayPairingCancellation(deviceId);
17658
+ if (cancellation.status === "busy") {
17659
+ reply.code(409).send({ code: "RELAY_PAIRING_CANCEL_IN_PROGRESS", error: "relay pairing cancellation is in progress" });
17660
+ return;
17661
+ }
17662
+ if (cancellation.status === "missing") {
17663
+ reply.code(404).send({ code: "RELAY_PAIRING_NOT_FOUND", error: "relay pairing is expired or already used" });
17664
+ return;
17665
+ }
17666
+ try {
17667
+ await relay.provisioner.revokeDevice(deviceId);
17668
+ } catch {
17669
+ deviceStore.releaseRelayPairingCancellation(cancellation.reservation);
17670
+ throw new Error("broker revocation failed");
17671
+ }
17672
+ deviceStore.finishRelayPairingCancellation(cancellation.reservation);
17673
+ reply.header("cache-control", "no-store").code(204).send();
17674
+ } catch {
17675
+ reply.code(502).send({ code: "RELAY_PAIRING_CANCEL_FAILED", error: "could not cancel relay pairing" });
17676
+ }
17677
+ }
17678
+ );
17679
+ app.get("/api/v1/relay/status", async (_request, reply) => {
17680
+ const configured = deps.relayEnabled === true;
17681
+ const metrics = configured ? deps.relayStatus?.() : void 0;
17682
+ return reply.header("cache-control", "no-store").send({
17683
+ configured,
17684
+ pairingAvailable: configured && deps.relayPairing !== void 0,
17685
+ status: configured ? metrics?.status ?? "connecting" : "not-configured",
17686
+ activeDevices: metrics?.activeChannels ?? 0,
17687
+ reconnects: metrics?.reconnects ?? 0
17688
+ });
17689
+ });
16844
17690
  app.post(
16845
17691
  "/pairing/claim",
16846
17692
  { bodyLimit: 8 * 1024 },
@@ -16875,8 +17721,7 @@ function createServer(config, deps = {}) {
16875
17721
  }
16876
17722
  );
16877
17723
  app.get("/devices", async (request, reply) => {
16878
- const presented = extractBearerToken(request.headers.authorization);
16879
- const currentDeviceId = presented ? deviceStore.authenticate(presented)?.id : void 0;
17724
+ const currentDeviceId = currentDeviceIdForRequest(request);
16880
17725
  reply.header("cache-control", "no-store").send({
16881
17726
  devices: deviceStore.list(),
16882
17727
  ...currentDeviceId ? { currentDeviceId } : {}
@@ -18248,8 +19093,7 @@ data: ${JSON.stringify(data)}
18248
19093
  }
18249
19094
  });
18250
19095
  app.get("/api/v1/devices", async (request, reply) => {
18251
- const presented = extractBearerToken(request.headers.authorization);
18252
- const currentDeviceId = presented ? deviceStore.authenticate(presented)?.id : void 0;
19096
+ const currentDeviceId = currentDeviceIdForRequest(request);
18253
19097
  reply.header("cache-control", "no-store").send({
18254
19098
  devices: deviceStore.list(),
18255
19099
  ...currentDeviceId ? { currentDeviceId } : {}
@@ -20617,7 +21461,7 @@ function createUsageRunner(opts) {
20617
21461
  let child;
20618
21462
  try {
20619
21463
  const ptySpawn = opts.ptySpawn ?? ((file, args, options) => {
20620
- ensureNodePtySpawnHelperExecutable();
21464
+ if (!ensureNodePtySpawnHelperExecutable()) throw new Error("node-pty spawn helper is not executable");
20621
21465
  const pty = require16("node-pty");
20622
21466
  return pty.spawn(file, args, options);
20623
21467
  });
@@ -22578,10 +23422,10 @@ var CodexLatestService = class {
22578
23422
 
22579
23423
  // src/providers/codex-executable.ts
22580
23424
  import { execFile } from "child_process";
22581
- import { constants as constants4 } from "fs";
23425
+ import { constants as constants7 } from "fs";
22582
23426
  import { access as access2, chmod, copyFile, link, realpath as realpath7, rename as rename3, rm as rm2, rmdir as rmdir2, stat as stat7 } from "fs/promises";
22583
23427
  import { randomUUID as randomUUID10 } from "crypto";
22584
- import { basename as basename5, delimiter, dirname as dirname7, isAbsolute as isAbsolute9, join as join17, sep as sep5 } from "path";
23428
+ import { basename as basename5, delimiter, dirname as dirname10, isAbsolute as isAbsolute9, join as join17, sep as sep5 } from "path";
22585
23429
  var CODEX_EXECUTABLE_PROBE_TIMEOUT_MS = 5e3;
22586
23430
  var OPENAI_CODE_SIGNING_TEAM_ID = "2DC432GLL2";
22587
23431
  var MAX_CODEX_BYTES = 1024 * 1024 * 1024;
@@ -22633,7 +23477,7 @@ async function defaultResolveExecutable(command, env) {
22633
23477
  const candidates = isAbsolute9(command) || command.includes(sep5) ? [command] : (env.PATH ?? process.env.PATH ?? "").split(delimiter).filter(Boolean).map((entry) => join17(entry, command));
22634
23478
  for (const candidate of candidates) {
22635
23479
  try {
22636
- await access2(candidate, constants4.X_OK);
23480
+ await access2(candidate, constants7.X_OK);
22637
23481
  return await realpath7(candidate);
22638
23482
  } catch {
22639
23483
  }
@@ -22691,7 +23535,7 @@ async function removeLegacyManagedCopy(dataDir) {
22691
23535
  async function repairHomebrewExecutable(source, sourceStat, env, deps) {
22692
23536
  if (!await deps.verifyOfficialSignature(source)) return false;
22693
23537
  const nonce = randomUUID10();
22694
- const directory = dirname7(source);
23538
+ const directory = dirname10(source);
22695
23539
  const name = basename5(source);
22696
23540
  const temporary = join17(directory, `.${name}.roamcode-repair-${nonce}`);
22697
23541
  const backup = join17(directory, `.${name}.roamcode-backup-${nonce}`);
@@ -22700,7 +23544,7 @@ async function repairHomebrewExecutable(source, sourceStat, env, deps) {
22700
23544
  let committed = false;
22701
23545
  let preserveBackup = false;
22702
23546
  try {
22703
- await copyFile(source, temporary, constants4.COPYFILE_EXCL);
23547
+ await copyFile(source, temporary, constants7.COPYFILE_EXCL);
22704
23548
  await chmod(temporary, sourceStat.mode & 511);
22705
23549
  if (!await deps.clearExtendedAttributes(temporary)) return false;
22706
23550
  if (!await deps.verifyOfficialSignature(temporary)) return false;
@@ -23128,6 +23972,11 @@ async function startServer(env = process.env) {
23128
23972
  codexThreadResolver: (cwd) => new CodexThreadResolver({ inventory: createCodexThreadInventory(codexRpc, { cwd }) }),
23129
23973
  disposeProviders: () => codexClient.stop(),
23130
23974
  relayEnabled: relayConfig !== void 0,
23975
+ relayStatus: () => relayHost?.metrics() ?? {
23976
+ status: relayConfig ? "connecting" : "stopped",
23977
+ activeChannels: 0,
23978
+ reconnects: 0
23979
+ },
23131
23980
  relayPairing: relayConfig?.appUrl && relayProvisioner ? {
23132
23981
  appUrl: relayConfig.appUrl,
23133
23982
  label: relayConfig.hostLabel,
@@ -23227,6 +24076,7 @@ export {
23227
24076
  BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES,
23228
24077
  BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE,
23229
24078
  BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES,
24079
+ BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS,
23230
24080
  BLIND_RELAY_PROTOCOL_VERSION,
23231
24081
  CHECK_CACHE_MS,
23232
24082
  CLASSIFIER_TESTED_UP_TO,
@@ -23257,6 +24107,8 @@ export {
23257
24107
  PAIRING_TTL_MS,
23258
24108
  PRESENCE_HEARTBEAT_MS,
23259
24109
  PRESENCE_TTL_MS,
24110
+ PWA_BOOT_WATCHDOG_SHA256,
24111
+ PWA_CONTENT_SECURITY_POLICY,
23260
24112
  PeerRequestError,
23261
24113
  PeerRevisionConflictError,
23262
24114
  PluginRuntimeError,