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