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