@roamcode.ai/server 1.2.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.
@@ -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 { ...record };
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
- db.prepare(
322
- `INSERT INTO relay_accounts
323
- (id, label, status, plan, max_routes, max_devices_per_route, revision, credential_hash,
324
- credential_lookup, created_at, updated_at)
325
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
326
- ).run(
327
- record.id,
328
- record.label,
329
- record.status,
330
- record.plan,
331
- record.maxRoutes,
332
- record.maxDevicesPerRoute,
333
- record.revision,
334
- credentialHash,
335
- credentialLookup,
336
- record.createdAt,
337
- record.updatedAt
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 safeAccountId = safeId(id);
362
- const currentRow = rowById(safeAccountId);
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(safeAccountId);
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 safeAccountId = safeId(id);
412
- const currentRow = rowById(safeAccountId);
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
- const result = db.prepare(
419
- `UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
420
- updated_at = ? WHERE id = ? AND revision = ?`
421
- ).run(credentialHash, credentialLookup, now, safeAccountId, expectedRevision);
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(safeAccountId);
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(safeAccountId));
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 removed = false;
1393
+ let routeId;
1394
+ let deviceId;
1395
+ let expectedCredentialHash;
1237
1396
  try {
1238
- removed = store.revokeDevice(request.params.routeId, request.params.deviceId);
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
- if (!removed) {
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
- deviceRates.delete(deviceRateKey(request.params.routeId, request.params.deviceId));
1246
- const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
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
  }));