@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/container/relay.js
CHANGED
|
@@ -109,6 +109,18 @@ function hashMatches(expected, credential) {
|
|
|
109
109
|
return false;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
function storedCredentialMatches(expectedHash, expectedLookup, credential) {
|
|
113
|
+
try {
|
|
114
|
+
const actual = credentialMaterial(credential);
|
|
115
|
+
const expectedHashBytes = Buffer.from(safeHash(expectedHash));
|
|
116
|
+
const actualHashBytes = Buffer.from(actual.credentialHash);
|
|
117
|
+
const expectedLookupBytes = Buffer.from(safeLookup(expectedLookup));
|
|
118
|
+
const actualLookupBytes = Buffer.from(actual.credentialLookup);
|
|
119
|
+
return expectedHashBytes.length === actualHashBytes.length && expectedLookupBytes.length === actualLookupBytes.length && timingSafeEqual(expectedHashBytes, actualHashBytes) && timingSafeEqual(expectedLookupBytes, actualLookupBytes);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
112
124
|
function safePlan(value) {
|
|
113
125
|
if (value !== "free" && value !== "team" && value !== "enterprise") throw new Error("invalid relay account plan");
|
|
114
126
|
return value;
|
|
@@ -127,7 +139,17 @@ function safeStatus(value) {
|
|
|
127
139
|
return value;
|
|
128
140
|
}
|
|
129
141
|
function clone(record) {
|
|
130
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
id: record.id,
|
|
144
|
+
label: record.label,
|
|
145
|
+
status: record.status,
|
|
146
|
+
plan: record.plan,
|
|
147
|
+
maxRoutes: record.maxRoutes,
|
|
148
|
+
maxDevicesPerRoute: record.maxDevicesPerRoute,
|
|
149
|
+
revision: record.revision,
|
|
150
|
+
createdAt: record.createdAt,
|
|
151
|
+
updatedAt: record.updatedAt
|
|
152
|
+
};
|
|
131
153
|
}
|
|
132
154
|
function createMemoryStore(options) {
|
|
133
155
|
const accounts = /* @__PURE__ */ new Map();
|
|
@@ -147,7 +169,7 @@ function createMemoryStore(options) {
|
|
|
147
169
|
return {
|
|
148
170
|
mode: "memory",
|
|
149
171
|
createAccount(input, now = Date.now()) {
|
|
150
|
-
const id = safeId(generateAccountId());
|
|
172
|
+
const id = safeId(input.id ?? generateAccountId());
|
|
151
173
|
if (accounts.has(id)) throw new Error("relay account already exists");
|
|
152
174
|
const plan = safePlan(input.plan ?? "free");
|
|
153
175
|
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
@@ -187,6 +209,14 @@ function createMemoryStore(options) {
|
|
|
187
209
|
const record = verifyCredential(credential);
|
|
188
210
|
return record?.status === "active" ? record : void 0;
|
|
189
211
|
},
|
|
212
|
+
credentialMatches(id, credential) {
|
|
213
|
+
try {
|
|
214
|
+
const record = accounts.get(safeId(id));
|
|
215
|
+
return !!record && storedCredentialMatches(record.credentialHash, record.credentialLookup, credential);
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
190
220
|
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
191
221
|
const current = accounts.get(safeId(id));
|
|
192
222
|
if (!current) return void 0;
|
|
@@ -244,6 +274,17 @@ function createMemoryStore(options) {
|
|
|
244
274
|
}
|
|
245
275
|
};
|
|
246
276
|
}
|
|
277
|
+
function normalizeSqliteAccountConstraint(error) {
|
|
278
|
+
const code = error?.code;
|
|
279
|
+
const message = error instanceof Error ? error.message : "";
|
|
280
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE" && message.includes("relay_accounts.credential_lookup")) {
|
|
281
|
+
throw new Error("relay account credential already exists");
|
|
282
|
+
}
|
|
283
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE" && message.includes("relay_accounts.id")) {
|
|
284
|
+
throw new Error("relay account already exists");
|
|
285
|
+
}
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
247
288
|
function fromRow(row) {
|
|
248
289
|
return {
|
|
249
290
|
id: row.id,
|
|
@@ -300,7 +341,7 @@ function openRelayAccountStore(options) {
|
|
|
300
341
|
return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
|
|
301
342
|
};
|
|
302
343
|
const createAccount = db.transaction((input, now) => {
|
|
303
|
-
const id = safeId(generateAccountId());
|
|
344
|
+
const id = safeId(input.id ?? generateAccountId());
|
|
304
345
|
if (rowById(id)) throw new Error("relay account already exists");
|
|
305
346
|
const plan = safePlan(input.plan ?? "free");
|
|
306
347
|
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
@@ -316,24 +357,28 @@ function openRelayAccountStore(options) {
|
|
|
316
357
|
createdAt: now,
|
|
317
358
|
updatedAt: now
|
|
318
359
|
};
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
360
|
+
try {
|
|
361
|
+
db.prepare(
|
|
362
|
+
`INSERT INTO relay_accounts
|
|
363
|
+
(id, label, status, plan, max_routes, max_devices_per_route, revision, credential_hash,
|
|
364
|
+
credential_lookup, created_at, updated_at)
|
|
365
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
366
|
+
).run(
|
|
367
|
+
record.id,
|
|
368
|
+
record.label,
|
|
369
|
+
record.status,
|
|
370
|
+
record.plan,
|
|
371
|
+
record.maxRoutes,
|
|
372
|
+
record.maxDevicesPerRoute,
|
|
373
|
+
record.revision,
|
|
374
|
+
credentialHash,
|
|
375
|
+
credentialLookup,
|
|
376
|
+
record.createdAt,
|
|
377
|
+
record.updatedAt
|
|
378
|
+
);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
normalizeSqliteAccountConstraint(error);
|
|
381
|
+
}
|
|
337
382
|
return record;
|
|
338
383
|
});
|
|
339
384
|
return {
|
|
@@ -355,9 +400,17 @@ function openRelayAccountStore(options) {
|
|
|
355
400
|
const record = verifyCredential(credential);
|
|
356
401
|
return record?.status === "active" ? record : void 0;
|
|
357
402
|
},
|
|
403
|
+
credentialMatches(id, credential) {
|
|
404
|
+
try {
|
|
405
|
+
const row = rowById(safeId(id));
|
|
406
|
+
return !!row && storedCredentialMatches(row.credential_hash, row.credential_lookup, credential);
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
},
|
|
358
411
|
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
359
|
-
const
|
|
360
|
-
const currentRow = rowById(
|
|
412
|
+
const safeAccountId2 = safeId(id);
|
|
413
|
+
const currentRow = rowById(safeAccountId2);
|
|
361
414
|
if (!currentRow) return void 0;
|
|
362
415
|
const current = fromRow(currentRow);
|
|
363
416
|
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
|
|
@@ -399,30 +452,35 @@ function openRelayAccountStore(options) {
|
|
|
399
452
|
expectedRevision
|
|
400
453
|
);
|
|
401
454
|
if (result.changes !== 1) {
|
|
402
|
-
const latest = rowById(
|
|
455
|
+
const latest = rowById(safeAccountId2);
|
|
403
456
|
if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
|
|
404
457
|
return void 0;
|
|
405
458
|
}
|
|
406
459
|
return next;
|
|
407
460
|
},
|
|
408
461
|
rotateCredential(id, credential, expectedRevision, now = Date.now()) {
|
|
409
|
-
const
|
|
410
|
-
const currentRow = rowById(
|
|
462
|
+
const safeAccountId2 = safeId(id);
|
|
463
|
+
const currentRow = rowById(safeAccountId2);
|
|
411
464
|
if (!currentRow) return void 0;
|
|
412
465
|
const current = fromRow(currentRow);
|
|
413
466
|
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
|
|
414
467
|
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
415
468
|
const { credentialHash, credentialLookup } = credentialMaterial(credential);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
469
|
+
let result;
|
|
470
|
+
try {
|
|
471
|
+
result = db.prepare(
|
|
472
|
+
`UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
|
|
473
|
+
updated_at = ? WHERE id = ? AND revision = ?`
|
|
474
|
+
).run(credentialHash, credentialLookup, now, safeAccountId2, expectedRevision);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
normalizeSqliteAccountConstraint(error);
|
|
477
|
+
}
|
|
420
478
|
if (result.changes !== 1) {
|
|
421
|
-
const latest = rowById(
|
|
479
|
+
const latest = rowById(safeAccountId2);
|
|
422
480
|
if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
|
|
423
481
|
return void 0;
|
|
424
482
|
}
|
|
425
|
-
return fromRow(rowById(
|
|
483
|
+
return fromRow(rowById(safeAccountId2));
|
|
426
484
|
},
|
|
427
485
|
close: () => db.close()
|
|
428
486
|
};
|
|
@@ -915,6 +973,59 @@ function safeRevision(value) {
|
|
|
915
973
|
if (!Number.isSafeInteger(value) || value < 1) throw new Error("invalid relay account revision");
|
|
916
974
|
return value;
|
|
917
975
|
}
|
|
976
|
+
function safeAccountId(value) {
|
|
977
|
+
if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
|
|
978
|
+
throw new Error("invalid relay account id");
|
|
979
|
+
}
|
|
980
|
+
return value;
|
|
981
|
+
}
|
|
982
|
+
function safeAccountLabel(value) {
|
|
983
|
+
if (typeof value !== "string") throw new Error("relay account label is required");
|
|
984
|
+
const label = value.trim().replace(/\s+/g, " ");
|
|
985
|
+
if (!label || label.length > 120 || UNSAFE_DISPLAY_TEXT2.test(label)) throw new Error("invalid relay account label");
|
|
986
|
+
return label;
|
|
987
|
+
}
|
|
988
|
+
function safeAccountPlan(value) {
|
|
989
|
+
if (value !== "free" && value !== "team" && value !== "enterprise") {
|
|
990
|
+
throw new Error("invalid relay account plan");
|
|
991
|
+
}
|
|
992
|
+
return value;
|
|
993
|
+
}
|
|
994
|
+
function safeAccountLimit(value, maximum) {
|
|
995
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
996
|
+
throw new Error("invalid relay account limit");
|
|
997
|
+
}
|
|
998
|
+
return value;
|
|
999
|
+
}
|
|
1000
|
+
function strictInternalBody(value, allowedFields) {
|
|
1001
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid internal request body");
|
|
1002
|
+
const body = value;
|
|
1003
|
+
if (Object.keys(body).some((field) => !allowedFields.includes(field))) {
|
|
1004
|
+
throw new Error("unknown internal request field");
|
|
1005
|
+
}
|
|
1006
|
+
return body;
|
|
1007
|
+
}
|
|
1008
|
+
function safeCredentialLookup(value) {
|
|
1009
|
+
if (typeof value !== "string" || !/^lookup:[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
1010
|
+
throw new Error("invalid relay credential lookup");
|
|
1011
|
+
}
|
|
1012
|
+
return value;
|
|
1013
|
+
}
|
|
1014
|
+
function safeAccountCredentialMaterial(body) {
|
|
1015
|
+
if (!body || body.credential !== void 0 || body.accountCredential !== void 0) {
|
|
1016
|
+
throw new Error("client-hashed account credential material is required");
|
|
1017
|
+
}
|
|
1018
|
+
return {
|
|
1019
|
+
credentialHash: safeCredentialHash2(body.credentialHash),
|
|
1020
|
+
credentialLookup: safeCredentialLookup(body.credentialLookup)
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
function safeRouteCredentialHash(body, field = "credentialHash") {
|
|
1024
|
+
if (!body || body.credential !== void 0 || body.hostCredential !== void 0) {
|
|
1025
|
+
throw new Error("client-hashed route credential material is required");
|
|
1026
|
+
}
|
|
1027
|
+
return safeCredentialHash2(body[field]);
|
|
1028
|
+
}
|
|
918
1029
|
function bearer(request) {
|
|
919
1030
|
const value = request.headers.authorization;
|
|
920
1031
|
if (!value || Array.isArray(value)) return;
|
|
@@ -1227,21 +1338,89 @@ function createBlindRelayServer(options) {
|
|
|
1227
1338
|
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device route" });
|
|
1228
1339
|
}
|
|
1229
1340
|
});
|
|
1341
|
+
app.post("/v1/routes/:routeId/devices/:deviceId/promote", { preHandler: requireHost }, async (request, reply) => {
|
|
1342
|
+
try {
|
|
1343
|
+
const body = strictInternalBody(request.body, ["expectedCredentialHash", "credentialHash"]);
|
|
1344
|
+
const routeId = safeId3(request.params.routeId, "route id");
|
|
1345
|
+
const deviceId = safeId3(request.params.deviceId, "device id");
|
|
1346
|
+
const expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
|
|
1347
|
+
const credentialHash = safeCredentialHash2(body.credentialHash);
|
|
1348
|
+
if (tokenMatches(expectedCredentialHash, credentialHash)) throw new Error("relay credentials must differ");
|
|
1349
|
+
const current = store.getDevice(routeId, deviceId, now());
|
|
1350
|
+
if (!current) {
|
|
1351
|
+
reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (current.expiresAt === void 0 && tokenMatches(current.credentialHash, credentialHash)) {
|
|
1355
|
+
reply.code(200).send({
|
|
1356
|
+
device: {
|
|
1357
|
+
routeId: current.routeId,
|
|
1358
|
+
deviceId: current.deviceId,
|
|
1359
|
+
createdAt: current.createdAt,
|
|
1360
|
+
updatedAt: current.updatedAt,
|
|
1361
|
+
expiresAt: null
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (current.expiresAt === void 0 || !tokenMatches(current.credentialHash, expectedCredentialHash)) {
|
|
1367
|
+
reply.code(409).send({
|
|
1368
|
+
code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
|
|
1369
|
+
error: "relay device credential conflict"
|
|
1370
|
+
});
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const device = store.putDevice({ routeId, deviceId, credentialHash }, now());
|
|
1374
|
+
reply.code(200).send({
|
|
1375
|
+
device: {
|
|
1376
|
+
routeId: device.routeId,
|
|
1377
|
+
deviceId: device.deviceId,
|
|
1378
|
+
createdAt: device.createdAt,
|
|
1379
|
+
updatedAt: device.updatedAt,
|
|
1380
|
+
expiresAt: null
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
} catch {
|
|
1384
|
+
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device promotion" });
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1230
1387
|
app.delete(
|
|
1231
1388
|
"/v1/routes/:routeId/devices/:deviceId",
|
|
1232
1389
|
{ preHandler: requireHost },
|
|
1233
1390
|
async (request, reply) => {
|
|
1234
|
-
let
|
|
1391
|
+
let routeId;
|
|
1392
|
+
let deviceId;
|
|
1393
|
+
let expectedCredentialHash;
|
|
1235
1394
|
try {
|
|
1236
|
-
|
|
1395
|
+
routeId = safeId3(request.params.routeId, "route id");
|
|
1396
|
+
deviceId = safeId3(request.params.deviceId, "device id");
|
|
1397
|
+
if (request.body !== void 0) {
|
|
1398
|
+
const body = strictInternalBody(request.body, ["expectedCredentialHash"]);
|
|
1399
|
+
expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
|
|
1400
|
+
}
|
|
1237
1401
|
} catch {
|
|
1402
|
+
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device" });
|
|
1403
|
+
return;
|
|
1238
1404
|
}
|
|
1239
|
-
|
|
1405
|
+
const current = store.getDevice(routeId, deviceId, now());
|
|
1406
|
+
if (!current) {
|
|
1407
|
+
if (expectedCredentialHash) {
|
|
1408
|
+
reply.code(204).send();
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1240
1411
|
reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
|
|
1241
1412
|
return;
|
|
1242
1413
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1414
|
+
if (expectedCredentialHash && !tokenMatches(current.credentialHash, expectedCredentialHash)) {
|
|
1415
|
+
reply.code(409).send({
|
|
1416
|
+
code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
|
|
1417
|
+
error: "relay device credential conflict"
|
|
1418
|
+
});
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
store.revokeDevice(routeId, deviceId);
|
|
1422
|
+
deviceRates.delete(deviceRateKey(routeId, deviceId));
|
|
1423
|
+
const live = hosts.get(routeId)?.devices.get(deviceId);
|
|
1245
1424
|
live?.socket.close(4403, "device revoked");
|
|
1246
1425
|
reply.code(204).send();
|
|
1247
1426
|
}
|
|
@@ -1307,6 +1486,541 @@ function createBlindRelayServer(options) {
|
|
|
1307
1486
|
const route = store.getRoute(routeId);
|
|
1308
1487
|
return route?.ownerAccountId === accountId ? route : void 0;
|
|
1309
1488
|
};
|
|
1489
|
+
const internalRouteEnvelope = (accountId, routeId) => {
|
|
1490
|
+
const route = ownedRoute(accountId, routeId);
|
|
1491
|
+
if (!route) return;
|
|
1492
|
+
return {
|
|
1493
|
+
accountId,
|
|
1494
|
+
route: publicRoute({ ...route, deviceCount: store.countDevices(route.id, now()) }),
|
|
1495
|
+
status: {
|
|
1496
|
+
hostOnline: hosts.has(route.id),
|
|
1497
|
+
activeDevices: hosts.get(route.id)?.devices.size ?? 0
|
|
1498
|
+
},
|
|
1499
|
+
connection: { path: "/v1/connect", protocolVersion: BLIND_RELAY_PROTOCOL_VERSION }
|
|
1500
|
+
};
|
|
1501
|
+
};
|
|
1502
|
+
const internalDeviceEnvelope = (accountId, device) => ({
|
|
1503
|
+
accountId,
|
|
1504
|
+
device: {
|
|
1505
|
+
routeId: device.routeId,
|
|
1506
|
+
deviceId: device.deviceId,
|
|
1507
|
+
createdAt: device.createdAt,
|
|
1508
|
+
updatedAt: device.updatedAt,
|
|
1509
|
+
expiresAt: device.expiresAt ?? null
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
const closeAndDeleteRoute = (routeId, reason) => {
|
|
1513
|
+
const route = store.getRoute(routeId);
|
|
1514
|
+
if (!route) return false;
|
|
1515
|
+
const host = hosts.get(routeId);
|
|
1516
|
+
if (host) closeHost(host, 4403, reason);
|
|
1517
|
+
const deleted = store.deleteRoute(routeId);
|
|
1518
|
+
if (deleted) clearRouteRates(routeId);
|
|
1519
|
+
return deleted;
|
|
1520
|
+
};
|
|
1521
|
+
const accountRevisionConflict = (reply, account) => reply.code(409).send({
|
|
1522
|
+
code: "RELAY_ACCOUNT_REVISION_CONFLICT",
|
|
1523
|
+
error: "relay account revision conflict",
|
|
1524
|
+
current: accountEnvelope(account)
|
|
1525
|
+
});
|
|
1526
|
+
app.put(
|
|
1527
|
+
"/internal/v1/accounts/:accountId",
|
|
1528
|
+
{ preHandler: requireRoot },
|
|
1529
|
+
async (request, reply) => {
|
|
1530
|
+
let accountId;
|
|
1531
|
+
let input;
|
|
1532
|
+
try {
|
|
1533
|
+
const body = strictInternalBody(request.body, [
|
|
1534
|
+
"label",
|
|
1535
|
+
"plan",
|
|
1536
|
+
"maxRoutes",
|
|
1537
|
+
"maxDevicesPerRoute",
|
|
1538
|
+
"credentialHash",
|
|
1539
|
+
"credentialLookup"
|
|
1540
|
+
]);
|
|
1541
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1542
|
+
input = {
|
|
1543
|
+
id: accountId,
|
|
1544
|
+
label: safeAccountLabel(body.label),
|
|
1545
|
+
plan: safeAccountPlan(body.plan),
|
|
1546
|
+
maxRoutes: safeAccountLimit(body.maxRoutes, 1e4),
|
|
1547
|
+
maxDevicesPerRoute: safeAccountLimit(body.maxDevicesPerRoute, 1e5),
|
|
1548
|
+
...safeAccountCredentialMaterial(body)
|
|
1549
|
+
};
|
|
1550
|
+
} catch {
|
|
1551
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
const existing = accountStore.getAccount(accountId);
|
|
1555
|
+
if (existing) {
|
|
1556
|
+
const matches = existing.status !== "deleted" && existing.label === input.label && existing.plan === input.plan && existing.maxRoutes === input.maxRoutes && existing.maxDevicesPerRoute === input.maxDevicesPerRoute && accountStore.credentialMatches(accountId, input);
|
|
1557
|
+
if (!matches) {
|
|
1558
|
+
reply.code(409).send({ code: "RELAY_ACCOUNT_EXISTS", error: "relay account already exists" });
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
reply.code(200).send(accountEnvelope(existing));
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
try {
|
|
1565
|
+
const account = accountStore.createAccount(input);
|
|
1566
|
+
reply.code(201).send(accountEnvelope(account));
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
if (error.message === "relay account already exists" || error.message === "relay account credential already exists") {
|
|
1569
|
+
reply.code(409).send({ code: "RELAY_ACCOUNT_EXISTS", error: "relay account already exists" });
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
throw error;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
);
|
|
1576
|
+
app.get(
|
|
1577
|
+
"/internal/v1/accounts/:accountId/status",
|
|
1578
|
+
{ preHandler: requireRoot },
|
|
1579
|
+
async (request, reply) => {
|
|
1580
|
+
let account;
|
|
1581
|
+
try {
|
|
1582
|
+
account = accountStore.getAccount(safeAccountId(request.params.accountId));
|
|
1583
|
+
} catch {
|
|
1584
|
+
}
|
|
1585
|
+
if (!account || account.status === "deleted") {
|
|
1586
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
reply.code(200).send(accountEnvelope(account));
|
|
1590
|
+
}
|
|
1591
|
+
);
|
|
1592
|
+
app.put("/internal/v1/accounts/:accountId/metadata", { preHandler: requireRoot }, async (request, reply) => {
|
|
1593
|
+
let accountId;
|
|
1594
|
+
let expectedRevision;
|
|
1595
|
+
let update;
|
|
1596
|
+
try {
|
|
1597
|
+
const body = strictInternalBody(request.body, [
|
|
1598
|
+
"expectedRevision",
|
|
1599
|
+
"label",
|
|
1600
|
+
"plan",
|
|
1601
|
+
"maxRoutes",
|
|
1602
|
+
"maxDevicesPerRoute"
|
|
1603
|
+
]);
|
|
1604
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1605
|
+
expectedRevision = safeRevision(body.expectedRevision);
|
|
1606
|
+
update = {
|
|
1607
|
+
label: safeAccountLabel(body.label),
|
|
1608
|
+
plan: safeAccountPlan(body.plan),
|
|
1609
|
+
maxRoutes: safeAccountLimit(body.maxRoutes, 1e4),
|
|
1610
|
+
maxDevicesPerRoute: safeAccountLimit(body.maxDevicesPerRoute, 1e5)
|
|
1611
|
+
};
|
|
1612
|
+
} catch {
|
|
1613
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const current = accountStore.getAccount(accountId);
|
|
1617
|
+
if (!current || current.status === "deleted") {
|
|
1618
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
const matches = current.label === update.label && current.plan === update.plan && current.maxRoutes === update.maxRoutes && current.maxDevicesPerRoute === update.maxDevicesPerRoute;
|
|
1622
|
+
if (matches && (current.revision === expectedRevision || current.revision === expectedRevision + 1)) {
|
|
1623
|
+
reply.code(200).send(accountEnvelope(current));
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
if (current.revision !== expectedRevision) {
|
|
1627
|
+
accountRevisionConflict(reply, current);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const account = accountStore.updateAccount(accountId, update, expectedRevision);
|
|
1632
|
+
if (!account) {
|
|
1633
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
reply.code(200).send(accountEnvelope(account));
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
if (error instanceof RelayAccountRevisionConflictError) {
|
|
1639
|
+
accountRevisionConflict(reply, error.current);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
throw error;
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
app.put("/internal/v1/accounts/:accountId/credential", { preHandler: requireRoot }, async (request, reply) => {
|
|
1646
|
+
let accountId;
|
|
1647
|
+
let expectedRevision;
|
|
1648
|
+
let credential;
|
|
1649
|
+
try {
|
|
1650
|
+
const body = strictInternalBody(request.body, ["expectedRevision", "credentialHash", "credentialLookup"]);
|
|
1651
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1652
|
+
expectedRevision = safeRevision(body.expectedRevision);
|
|
1653
|
+
credential = safeAccountCredentialMaterial(body);
|
|
1654
|
+
} catch {
|
|
1655
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const current = accountStore.getAccount(accountId);
|
|
1659
|
+
if (!current || current.status === "deleted") {
|
|
1660
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
if (accountStore.credentialMatches(accountId, credential)) {
|
|
1664
|
+
if (current.revision === expectedRevision || current.revision === expectedRevision + 1) {
|
|
1665
|
+
reply.code(200).send(accountEnvelope(current));
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
accountRevisionConflict(reply, current);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (current.revision !== expectedRevision) {
|
|
1672
|
+
accountRevisionConflict(reply, current);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
try {
|
|
1676
|
+
const account = accountStore.rotateCredential(accountId, credential, expectedRevision);
|
|
1677
|
+
if (!account) {
|
|
1678
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
reply.code(200).send(accountEnvelope(account));
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
if (error instanceof RelayAccountRevisionConflictError) {
|
|
1684
|
+
accountRevisionConflict(reply, error.current);
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
if (error.message === "relay account credential already exists") {
|
|
1688
|
+
reply.code(409).send({ code: "RELAY_ACCOUNT_CREDENTIAL_CONFLICT", error: "relay credential conflict" });
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
throw error;
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
app.delete("/internal/v1/accounts/:accountId", { preHandler: requireRoot }, async (request, reply) => {
|
|
1695
|
+
let accountId;
|
|
1696
|
+
let expectedRevision;
|
|
1697
|
+
try {
|
|
1698
|
+
const body = strictInternalBody(request.body, ["expectedRevision"]);
|
|
1699
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1700
|
+
expectedRevision = safeRevision(body.expectedRevision);
|
|
1701
|
+
} catch {
|
|
1702
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const current = accountStore.getAccount(accountId);
|
|
1706
|
+
if (!current || current.status === "deleted") {
|
|
1707
|
+
reply.code(204).send();
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (current.revision !== expectedRevision) {
|
|
1711
|
+
accountRevisionConflict(reply, current);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
try {
|
|
1715
|
+
const account = accountStore.updateAccount(accountId, { status: "deleted" }, expectedRevision);
|
|
1716
|
+
if (account) purgeAccountRoutes(account.id);
|
|
1717
|
+
reply.code(204).send();
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
if (error instanceof RelayAccountRevisionConflictError) {
|
|
1720
|
+
accountRevisionConflict(reply, error.current);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
throw error;
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
app.put("/internal/v1/accounts/:accountId/routes/:routeId", { preHandler: requireRoot }, async (request, reply) => {
|
|
1727
|
+
let accountId;
|
|
1728
|
+
let routeId;
|
|
1729
|
+
let label;
|
|
1730
|
+
let credentialHash;
|
|
1731
|
+
try {
|
|
1732
|
+
const body = strictInternalBody(request.body, ["label", "credentialHash"]);
|
|
1733
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1734
|
+
routeId = safeId3(request.params.routeId, "route id");
|
|
1735
|
+
label = safeLabel3(body.label);
|
|
1736
|
+
credentialHash = safeRouteCredentialHash(body);
|
|
1737
|
+
} catch {
|
|
1738
|
+
reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const account = accountStore.getAccount(accountId);
|
|
1742
|
+
if (!account || account.status === "deleted") {
|
|
1743
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
if (account.status !== "active") {
|
|
1747
|
+
reply.code(409).send({ code: "RELAY_ACCOUNT_UNAVAILABLE", error: "relay account unavailable" });
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
const existing = store.getRoute(routeId);
|
|
1751
|
+
if (existing) {
|
|
1752
|
+
if (existing.ownerAccountId !== accountId || existing.label !== label || !tokenMatches(existing.hostCredentialHash, credentialHash)) {
|
|
1753
|
+
reply.code(409).send({ code: "RELAY_ROUTE_EXISTS", error: "relay route already exists" });
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
reply.code(200).send(internalRouteEnvelope(accountId, routeId));
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (store.listRoutesByOwner(accountId, now()).length >= account.maxRoutes) {
|
|
1760
|
+
reply.code(429).send({ code: "RELAY_ROUTE_LIMIT", error: "relay route limit reached" });
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
try {
|
|
1764
|
+
store.createRoute({ id: routeId, label, hostCredentialHash: credentialHash, ownerAccountId: accountId });
|
|
1765
|
+
reply.code(201).send(internalRouteEnvelope(accountId, routeId));
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
if (error.message === "relay route already exists") {
|
|
1768
|
+
reply.code(409).send({ code: "RELAY_ROUTE_EXISTS", error: "relay route already exists" });
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
throw error;
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
app.get(
|
|
1775
|
+
"/internal/v1/accounts/:accountId/routes/:routeId/status",
|
|
1776
|
+
{ preHandler: requireRoot },
|
|
1777
|
+
async (request, reply) => {
|
|
1778
|
+
let route;
|
|
1779
|
+
try {
|
|
1780
|
+
route = internalRouteEnvelope(
|
|
1781
|
+
safeAccountId(request.params.accountId),
|
|
1782
|
+
safeId3(request.params.routeId, "route id")
|
|
1783
|
+
);
|
|
1784
|
+
} catch {
|
|
1785
|
+
}
|
|
1786
|
+
if (!route) {
|
|
1787
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
reply.code(200).send(route);
|
|
1791
|
+
}
|
|
1792
|
+
);
|
|
1793
|
+
app.put(
|
|
1794
|
+
"/internal/v1/accounts/:accountId/routes/:routeId/devices/:deviceId",
|
|
1795
|
+
{ preHandler: requireRoot },
|
|
1796
|
+
async (request, reply) => {
|
|
1797
|
+
let accountId;
|
|
1798
|
+
let routeId;
|
|
1799
|
+
let deviceId;
|
|
1800
|
+
let credentialHash;
|
|
1801
|
+
let expiresAt;
|
|
1802
|
+
try {
|
|
1803
|
+
const body = strictInternalBody(request.body, ["credentialHash", "expiresAt"]);
|
|
1804
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1805
|
+
routeId = safeId3(request.params.routeId, "route id");
|
|
1806
|
+
deviceId = safeId3(request.params.deviceId, "device id");
|
|
1807
|
+
credentialHash = safeCredentialHash2(body.credentialHash);
|
|
1808
|
+
const parsedExpiry = safeExpiry2(body.expiresAt, now());
|
|
1809
|
+
if (parsedExpiry === void 0) throw new Error("temporary relay device expiry is required");
|
|
1810
|
+
expiresAt = parsedExpiry;
|
|
1811
|
+
} catch {
|
|
1812
|
+
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device" });
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
const account = accountStore.getAccount(accountId);
|
|
1816
|
+
if (!account || account.status === "deleted") {
|
|
1817
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (account.status !== "active") {
|
|
1821
|
+
reply.code(409).send({ code: "RELAY_ACCOUNT_UNAVAILABLE", error: "relay account unavailable" });
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
if (!ownedRoute(accountId, routeId)) {
|
|
1825
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
const current = store.getDevice(routeId, deviceId, now());
|
|
1829
|
+
if (current) {
|
|
1830
|
+
if (tokenMatches(current.credentialHash, credentialHash) && current.expiresAt === expiresAt) {
|
|
1831
|
+
reply.code(200).send(internalDeviceEnvelope(accountId, current));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
reply.code(409).send({
|
|
1835
|
+
code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
|
|
1836
|
+
error: "relay device credential conflict",
|
|
1837
|
+
current: internalDeviceEnvelope(accountId, current)
|
|
1838
|
+
});
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
if (store.countDevices(routeId, now()) >= account.maxDevicesPerRoute) {
|
|
1842
|
+
reply.code(429).send({ code: "RELAY_DEVICE_LIMIT", error: "relay device limit reached" });
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
const device = store.putDevice({ routeId, deviceId, credentialHash, expiresAt }, now());
|
|
1846
|
+
reply.code(201).send(internalDeviceEnvelope(accountId, device));
|
|
1847
|
+
}
|
|
1848
|
+
);
|
|
1849
|
+
app.post(
|
|
1850
|
+
"/internal/v1/accounts/:accountId/routes/:routeId/devices/:deviceId/promote",
|
|
1851
|
+
{ preHandler: requireRoot },
|
|
1852
|
+
async (request, reply) => {
|
|
1853
|
+
let accountId;
|
|
1854
|
+
let routeId;
|
|
1855
|
+
let deviceId;
|
|
1856
|
+
let expectedCredentialHash;
|
|
1857
|
+
let credentialHash;
|
|
1858
|
+
try {
|
|
1859
|
+
const body = strictInternalBody(request.body, ["expectedCredentialHash", "credentialHash"]);
|
|
1860
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1861
|
+
routeId = safeId3(request.params.routeId, "route id");
|
|
1862
|
+
deviceId = safeId3(request.params.deviceId, "device id");
|
|
1863
|
+
expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
|
|
1864
|
+
credentialHash = safeCredentialHash2(body.credentialHash);
|
|
1865
|
+
if (tokenMatches(expectedCredentialHash, credentialHash)) throw new Error("relay credentials must differ");
|
|
1866
|
+
} catch {
|
|
1867
|
+
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device promotion" });
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
const account = accountStore.getAccount(accountId);
|
|
1871
|
+
if (!account || account.status === "deleted") {
|
|
1872
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
if (account.status !== "active") {
|
|
1876
|
+
reply.code(409).send({ code: "RELAY_ACCOUNT_UNAVAILABLE", error: "relay account unavailable" });
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (!ownedRoute(accountId, routeId)) {
|
|
1880
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
const current = store.getDevice(routeId, deviceId, now());
|
|
1884
|
+
if (!current) {
|
|
1885
|
+
reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
if (current.expiresAt === void 0 && tokenMatches(current.credentialHash, credentialHash)) {
|
|
1889
|
+
reply.code(200).send(internalDeviceEnvelope(accountId, current));
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
if (current.expiresAt === void 0 || !tokenMatches(current.credentialHash, expectedCredentialHash)) {
|
|
1893
|
+
reply.code(409).send({
|
|
1894
|
+
code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
|
|
1895
|
+
error: "relay device credential conflict",
|
|
1896
|
+
current: internalDeviceEnvelope(accountId, current)
|
|
1897
|
+
});
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const device = store.putDevice({ routeId, deviceId, credentialHash }, now());
|
|
1901
|
+
reply.code(200).send(internalDeviceEnvelope(accountId, device));
|
|
1902
|
+
}
|
|
1903
|
+
);
|
|
1904
|
+
app.delete(
|
|
1905
|
+
"/internal/v1/accounts/:accountId/routes/:routeId/devices/:deviceId",
|
|
1906
|
+
{ preHandler: requireRoot },
|
|
1907
|
+
async (request, reply) => {
|
|
1908
|
+
let accountId;
|
|
1909
|
+
let routeId;
|
|
1910
|
+
let deviceId;
|
|
1911
|
+
let expectedCredentialHash;
|
|
1912
|
+
try {
|
|
1913
|
+
const body = strictInternalBody(request.body, ["expectedCredentialHash"]);
|
|
1914
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1915
|
+
routeId = safeId3(request.params.routeId, "route id");
|
|
1916
|
+
deviceId = safeId3(request.params.deviceId, "device id");
|
|
1917
|
+
expectedCredentialHash = safeCredentialHash2(body.expectedCredentialHash);
|
|
1918
|
+
} catch {
|
|
1919
|
+
reply.code(400).send({ code: "INVALID_RELAY_DEVICE", error: "invalid relay device" });
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
const account = accountStore.getAccount(accountId);
|
|
1923
|
+
if (!account || account.status === "deleted") {
|
|
1924
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
if (!ownedRoute(accountId, routeId)) {
|
|
1928
|
+
reply.code(204).send();
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
const current = store.getDevice(routeId, deviceId, now());
|
|
1932
|
+
if (!current) {
|
|
1933
|
+
reply.code(204).send();
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
if (!tokenMatches(current.credentialHash, expectedCredentialHash)) {
|
|
1937
|
+
reply.code(409).send({
|
|
1938
|
+
code: "RELAY_DEVICE_CREDENTIAL_CONFLICT",
|
|
1939
|
+
error: "relay device credential conflict",
|
|
1940
|
+
current: internalDeviceEnvelope(accountId, current)
|
|
1941
|
+
});
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
store.revokeDevice(routeId, deviceId);
|
|
1945
|
+
deviceRates.delete(deviceRateKey(routeId, deviceId));
|
|
1946
|
+
const live = hosts.get(routeId)?.devices.get(deviceId);
|
|
1947
|
+
live?.socket.close(4403, "device revoked");
|
|
1948
|
+
reply.code(204).send();
|
|
1949
|
+
}
|
|
1950
|
+
);
|
|
1951
|
+
app.put(
|
|
1952
|
+
"/internal/v1/accounts/:accountId/routes/:routeId/credential",
|
|
1953
|
+
{ preHandler: requireRoot },
|
|
1954
|
+
async (request, reply) => {
|
|
1955
|
+
let accountId;
|
|
1956
|
+
let routeId;
|
|
1957
|
+
let expectedCredentialHash;
|
|
1958
|
+
let credentialHash;
|
|
1959
|
+
try {
|
|
1960
|
+
const body = strictInternalBody(request.body, ["expectedCredentialHash", "credentialHash"]);
|
|
1961
|
+
accountId = safeAccountId(request.params.accountId);
|
|
1962
|
+
routeId = safeId3(request.params.routeId, "route id");
|
|
1963
|
+
expectedCredentialHash = safeRouteCredentialHash(body, "expectedCredentialHash");
|
|
1964
|
+
credentialHash = safeRouteCredentialHash(body);
|
|
1965
|
+
} catch {
|
|
1966
|
+
reply.code(400).send({ code: "INVALID_RELAY_CREDENTIAL", error: "invalid relay credential hash" });
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const route = ownedRoute(accountId, routeId);
|
|
1970
|
+
if (!route) {
|
|
1971
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
if (tokenMatches(route.hostCredentialHash, credentialHash)) {
|
|
1975
|
+
reply.code(200).send(internalRouteEnvelope(accountId, routeId));
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (!tokenMatches(route.hostCredentialHash, expectedCredentialHash)) {
|
|
1979
|
+
reply.code(409).send({
|
|
1980
|
+
code: "RELAY_ROUTE_CREDENTIAL_CONFLICT",
|
|
1981
|
+
error: "relay route credential conflict",
|
|
1982
|
+
current: internalRouteEnvelope(accountId, routeId)
|
|
1983
|
+
});
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
if (!store.rotateHostCredential(routeId, credentialHash, now())) {
|
|
1987
|
+
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const host = hosts.get(routeId);
|
|
1991
|
+
if (host) closeHost(host, 4409, "host credential rotated");
|
|
1992
|
+
reply.code(200).send(internalRouteEnvelope(accountId, routeId));
|
|
1993
|
+
}
|
|
1994
|
+
);
|
|
1995
|
+
app.delete("/internal/v1/accounts/:accountId/routes/:routeId", { preHandler: requireRoot }, async (request, reply) => {
|
|
1996
|
+
let accountId;
|
|
1997
|
+
let routeId;
|
|
1998
|
+
let expectedCredentialHash;
|
|
1999
|
+
try {
|
|
2000
|
+
const body = strictInternalBody(request.body, ["expectedCredentialHash"]);
|
|
2001
|
+
accountId = safeAccountId(request.params.accountId);
|
|
2002
|
+
routeId = safeId3(request.params.routeId, "route id");
|
|
2003
|
+
expectedCredentialHash = safeRouteCredentialHash(body, "expectedCredentialHash");
|
|
2004
|
+
} catch {
|
|
2005
|
+
reply.code(400).send({ code: "INVALID_RELAY_ROUTE", error: "invalid relay route" });
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const route = ownedRoute(accountId, routeId);
|
|
2009
|
+
if (!route) {
|
|
2010
|
+
reply.code(204).send();
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
if (!tokenMatches(route.hostCredentialHash, expectedCredentialHash)) {
|
|
2014
|
+
reply.code(409).send({
|
|
2015
|
+
code: "RELAY_ROUTE_CREDENTIAL_CONFLICT",
|
|
2016
|
+
error: "relay route credential conflict",
|
|
2017
|
+
current: internalRouteEnvelope(accountId, routeId)
|
|
2018
|
+
});
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
closeAndDeleteRoute(routeId, "route deleted");
|
|
2022
|
+
reply.code(204).send();
|
|
2023
|
+
});
|
|
1310
2024
|
app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
|
|
1311
2025
|
accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
|
|
1312
2026
|
}));
|