@roamcode.ai/server 1.2.0 → 1.4.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 +751 -37
- package/dist/index.d.ts +1796 -8
- package/dist/index.js +10691 -3454
- package/dist/relay-start.d.ts +4 -0
- package/dist/relay-start.js +751 -37
- package/dist/start.d.ts +179 -99
- package/dist/start.js +11626 -5215
- package/package.json +2 -2
package/dist/relay-start.js
CHANGED
|
@@ -111,6 +111,18 @@ function hashMatches(expected, credential) {
|
|
|
111
111
|
return false;
|
|
112
112
|
}
|
|
113
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
|
+
}
|
|
114
126
|
function safePlan(value) {
|
|
115
127
|
if (value !== "free" && value !== "team" && value !== "enterprise") throw new Error("invalid relay account plan");
|
|
116
128
|
return value;
|
|
@@ -129,7 +141,17 @@ function safeStatus(value) {
|
|
|
129
141
|
return value;
|
|
130
142
|
}
|
|
131
143
|
function clone(record) {
|
|
132
|
-
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
|
+
};
|
|
133
155
|
}
|
|
134
156
|
function createMemoryStore(options) {
|
|
135
157
|
const accounts = /* @__PURE__ */ new Map();
|
|
@@ -149,7 +171,7 @@ function createMemoryStore(options) {
|
|
|
149
171
|
return {
|
|
150
172
|
mode: "memory",
|
|
151
173
|
createAccount(input, now = Date.now()) {
|
|
152
|
-
const id = safeId(generateAccountId());
|
|
174
|
+
const id = safeId(input.id ?? generateAccountId());
|
|
153
175
|
if (accounts.has(id)) throw new Error("relay account already exists");
|
|
154
176
|
const plan = safePlan(input.plan ?? "free");
|
|
155
177
|
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
@@ -189,6 +211,14 @@ function createMemoryStore(options) {
|
|
|
189
211
|
const record = verifyCredential(credential);
|
|
190
212
|
return record?.status === "active" ? record : void 0;
|
|
191
213
|
},
|
|
214
|
+
credentialMatches(id, credential) {
|
|
215
|
+
try {
|
|
216
|
+
const record = accounts.get(safeId(id));
|
|
217
|
+
return !!record && storedCredentialMatches(record.credentialHash, record.credentialLookup, credential);
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
},
|
|
192
222
|
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
193
223
|
const current = accounts.get(safeId(id));
|
|
194
224
|
if (!current) return void 0;
|
|
@@ -246,6 +276,17 @@ function createMemoryStore(options) {
|
|
|
246
276
|
}
|
|
247
277
|
};
|
|
248
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
|
+
}
|
|
249
290
|
function fromRow(row) {
|
|
250
291
|
return {
|
|
251
292
|
id: row.id,
|
|
@@ -302,7 +343,7 @@ function openRelayAccountStore(options) {
|
|
|
302
343
|
return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
|
|
303
344
|
};
|
|
304
345
|
const createAccount = db.transaction((input, now) => {
|
|
305
|
-
const id = safeId(generateAccountId());
|
|
346
|
+
const id = safeId(input.id ?? generateAccountId());
|
|
306
347
|
if (rowById(id)) throw new Error("relay account already exists");
|
|
307
348
|
const plan = safePlan(input.plan ?? "free");
|
|
308
349
|
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
@@ -318,24 +359,28 @@ function openRelayAccountStore(options) {
|
|
|
318
359
|
createdAt: now,
|
|
319
360
|
updatedAt: now
|
|
320
361
|
};
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
}
|
|
339
384
|
return record;
|
|
340
385
|
});
|
|
341
386
|
return {
|
|
@@ -357,9 +402,17 @@ function openRelayAccountStore(options) {
|
|
|
357
402
|
const record = verifyCredential(credential);
|
|
358
403
|
return record?.status === "active" ? record : void 0;
|
|
359
404
|
},
|
|
405
|
+
credentialMatches(id, credential) {
|
|
406
|
+
try {
|
|
407
|
+
const row = rowById(safeId(id));
|
|
408
|
+
return !!row && storedCredentialMatches(row.credential_hash, row.credential_lookup, credential);
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
},
|
|
360
413
|
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
361
|
-
const
|
|
362
|
-
const currentRow = rowById(
|
|
414
|
+
const safeAccountId2 = safeId(id);
|
|
415
|
+
const currentRow = rowById(safeAccountId2);
|
|
363
416
|
if (!currentRow) return void 0;
|
|
364
417
|
const current = fromRow(currentRow);
|
|
365
418
|
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
|
|
@@ -401,30 +454,35 @@ function openRelayAccountStore(options) {
|
|
|
401
454
|
expectedRevision
|
|
402
455
|
);
|
|
403
456
|
if (result.changes !== 1) {
|
|
404
|
-
const latest = rowById(
|
|
457
|
+
const latest = rowById(safeAccountId2);
|
|
405
458
|
if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
|
|
406
459
|
return void 0;
|
|
407
460
|
}
|
|
408
461
|
return next;
|
|
409
462
|
},
|
|
410
463
|
rotateCredential(id, credential, expectedRevision, now = Date.now()) {
|
|
411
|
-
const
|
|
412
|
-
const currentRow = rowById(
|
|
464
|
+
const safeAccountId2 = safeId(id);
|
|
465
|
+
const currentRow = rowById(safeAccountId2);
|
|
413
466
|
if (!currentRow) return void 0;
|
|
414
467
|
const current = fromRow(currentRow);
|
|
415
468
|
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
|
|
416
469
|
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
417
470
|
const { credentialHash, credentialLookup } = credentialMaterial(credential);
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
+
}
|
|
422
480
|
if (result.changes !== 1) {
|
|
423
|
-
const latest = rowById(
|
|
481
|
+
const latest = rowById(safeAccountId2);
|
|
424
482
|
if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
|
|
425
483
|
return void 0;
|
|
426
484
|
}
|
|
427
|
-
return fromRow(rowById(
|
|
485
|
+
return fromRow(rowById(safeAccountId2));
|
|
428
486
|
},
|
|
429
487
|
close: () => db.close()
|
|
430
488
|
};
|
|
@@ -917,6 +975,59 @@ function safeRevision(value) {
|
|
|
917
975
|
if (!Number.isSafeInteger(value) || value < 1) throw new Error("invalid relay account revision");
|
|
918
976
|
return value;
|
|
919
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
|
+
}
|
|
920
1031
|
function bearer(request) {
|
|
921
1032
|
const value = request.headers.authorization;
|
|
922
1033
|
if (!value || Array.isArray(value)) return;
|
|
@@ -1229,21 +1340,89 @@ function createBlindRelayServer(options) {
|
|
|
1229
1340
|
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device route" });
|
|
1230
1341
|
}
|
|
1231
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
|
+
});
|
|
1232
1389
|
app.delete(
|
|
1233
1390
|
"/v1/routes/:routeId/devices/:deviceId",
|
|
1234
1391
|
{ preHandler: requireHost },
|
|
1235
1392
|
async (request, reply) => {
|
|
1236
|
-
let
|
|
1393
|
+
let routeId;
|
|
1394
|
+
let deviceId;
|
|
1395
|
+
let expectedCredentialHash;
|
|
1237
1396
|
try {
|
|
1238
|
-
|
|
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
|
+
}
|
|
1239
1403
|
} catch {
|
|
1404
|
+
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device" });
|
|
1405
|
+
return;
|
|
1240
1406
|
}
|
|
1241
|
-
|
|
1407
|
+
const current = store.getDevice(routeId, deviceId, now());
|
|
1408
|
+
if (!current) {
|
|
1409
|
+
if (expectedCredentialHash) {
|
|
1410
|
+
reply.code(204).send();
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1242
1413
|
reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
|
|
1243
1414
|
return;
|
|
1244
1415
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
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);
|
|
1247
1426
|
live?.socket.close(4403, "device revoked");
|
|
1248
1427
|
reply.code(204).send();
|
|
1249
1428
|
}
|
|
@@ -1309,6 +1488,541 @@ function createBlindRelayServer(options) {
|
|
|
1309
1488
|
const route = store.getRoute(routeId);
|
|
1310
1489
|
return route?.ownerAccountId === accountId ? route : void 0;
|
|
1311
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
|
+
});
|
|
1312
2026
|
app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
|
|
1313
2027
|
accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
|
|
1314
2028
|
}));
|