@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/container/relay.js +521 -140
- package/dist/index.d.ts +55 -8
- package/dist/index.js +1130 -278
- package/dist/relay-start.d.ts +15 -3
- package/dist/relay-start.js +521 -140
- package/dist/start.js +569 -109
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -49,9 +49,27 @@ function hookAuthFileContent(token) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// src/data-dir.ts
|
|
52
|
-
import {
|
|
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,
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 =
|
|
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 ||
|
|
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
|
-
|
|
4064
|
-
|
|
4065
|
-
constants as constants2,
|
|
4290
|
+
closeSync as closeSync2,
|
|
4291
|
+
constants as constants3,
|
|
4066
4292
|
existsSync as existsSync2,
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
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
|
|
4308
|
+
let before;
|
|
4080
4309
|
try {
|
|
4081
|
-
|
|
4310
|
+
before = lstatSync2(path);
|
|
4082
4311
|
} catch {
|
|
4083
4312
|
throw new Error("relay identity could not be read");
|
|
4084
4313
|
}
|
|
4085
|
-
if (!
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
4112
|
-
|
|
4372
|
+
fsyncSync2(descriptor);
|
|
4373
|
+
closeSync2(descriptor);
|
|
4113
4374
|
descriptor = void 0;
|
|
4114
|
-
chmodSync2(temporary, 384);
|
|
4115
4375
|
try {
|
|
4116
|
-
|
|
4117
|
-
|
|
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)
|
|
4383
|
+
if (descriptor !== void 0) closeSync2(descriptor);
|
|
4124
4384
|
try {
|
|
4125
|
-
|
|
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 ||
|
|
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 {
|
|
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
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
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
|
|
4590
|
+
credentialHash,
|
|
4274
4591
|
createdAt: current?.createdAt ?? now,
|
|
4275
4592
|
updatedAt: now,
|
|
4276
|
-
...
|
|
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 =
|
|
4285
|
-
return device
|
|
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 =
|
|
4289
|
-
return !!device && (device.
|
|
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
|
|
4758
|
+
credentialHash,
|
|
4405
4759
|
createdAt: current?.created_at ?? now,
|
|
4406
4760
|
updatedAt: now,
|
|
4407
|
-
...
|
|
4761
|
+
...expiresAt === void 0 ? {} : { expiresAt }
|
|
4408
4762
|
};
|
|
4409
4763
|
db.prepare(
|
|
4410
|
-
`INSERT INTO relay_route_devices
|
|
4411
|
-
|
|
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) =>
|
|
4427
|
-
|
|
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
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
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 =
|
|
4468
|
-
return row
|
|
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 =
|
|
4472
|
-
return !!row && (row.
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
4614
|
-
|
|
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 =
|
|
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
|
-
|
|
4756
|
-
|
|
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
|
-
|
|
4778
|
-
|
|
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 === "
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
-
|
|
6225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6240
|
-
|
|
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
|
|
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
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
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
|
|
6439
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
7092
|
+
const value = parseJson(raw, maxEnvelopeBytes);
|
|
6506
7093
|
if (liveDevice) {
|
|
6507
7094
|
touchDevice(liveDevice);
|
|
6508
7095
|
if (value?.t === "ping") {
|
|
6509
|
-
|
|
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
|
-
|
|
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: () => ({
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
|
6816
|
-
if (!
|
|
6817
|
-
if (!
|
|
6818
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
6852
|
-
|
|
7549
|
+
fsyncSync3(descriptor);
|
|
7550
|
+
closeSync4(descriptor);
|
|
6853
7551
|
descriptor = void 0;
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
chmodSync3(path, 384);
|
|
7552
|
+
renameSync2(temporary, path);
|
|
7553
|
+
fsyncDirectory3(dirname3(path));
|
|
6857
7554
|
return { ...document };
|
|
6858
7555
|
} finally {
|
|
6859
|
-
if (descriptor !== void 0)
|
|
7556
|
+
if (descriptor !== void 0) closeSync4(descriptor);
|
|
6860
7557
|
try {
|
|
6861
|
-
|
|
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
|
-
|
|
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
|
|
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:")
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
10983
|
+
chmodSync as chmodSync4,
|
|
10264
10984
|
existsSync as existsSync5,
|
|
10265
|
-
lstatSync as
|
|
10985
|
+
lstatSync as lstatSync5,
|
|
10266
10986
|
mkdirSync as mkdirSync3,
|
|
10267
|
-
openSync as
|
|
10268
|
-
closeSync as
|
|
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
|
|
10992
|
+
renameSync as renameSync3,
|
|
10273
10993
|
rmSync,
|
|
10274
10994
|
symlinkSync,
|
|
10275
|
-
unlinkSync as
|
|
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
|
|
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
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'");
|
|
@@ -10291,7 +11011,7 @@ function quoteSystemd(value) {
|
|
|
10291
11011
|
}
|
|
10292
11012
|
function buildServicePath(nodePath, home) {
|
|
10293
11013
|
return [
|
|
10294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
11257
|
+
mkdirSync3(dirname7(path), { recursive: true, mode: 448 });
|
|
10538
11258
|
const temp = `${path}.${process.pid}.${randomUUID5()}.tmp`;
|
|
10539
11259
|
writeFileSync6(temp, value, { mode });
|
|
10540
|
-
|
|
10541
|
-
|
|
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(
|
|
11326
|
+
mkdirSync3(dirname7(path), { recursive: true, mode: 448 });
|
|
10607
11327
|
try {
|
|
10608
|
-
const fd =
|
|
11328
|
+
const fd = openSync5(path, "wx", 384);
|
|
10609
11329
|
writeFileSync6(fd, JSON.stringify({ pid: process.pid, at: now }) + "\n");
|
|
10610
|
-
|
|
11330
|
+
closeSync5(fd);
|
|
10611
11331
|
} catch {
|
|
10612
11332
|
try {
|
|
10613
|
-
if (now -
|
|
10614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11455
|
+
unlinkSync4(temp);
|
|
10736
11456
|
} catch {
|
|
10737
11457
|
}
|
|
10738
|
-
const parent = realpathSync2(
|
|
11458
|
+
const parent = realpathSync2(dirname7(link2));
|
|
10739
11459
|
const canonicalTarget = realpathSync2(target);
|
|
10740
11460
|
symlinkSync(relative4(parent, canonicalTarget), temp, "dir");
|
|
10741
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
12132
|
+
const packageRoot = resolve10(dirname9(resolveNodePty()), "..");
|
|
11413
12133
|
const helper = join13(packageRoot, "prebuilds", `${platform2}-${arch}`, "spawn-helper");
|
|
11414
|
-
if (existsSync6(helper))
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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,
|
|
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,
|