@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.
@@ -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 { ...record };
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
- db.prepare(
320
- `INSERT INTO relay_accounts
321
- (id, label, status, plan, max_routes, max_devices_per_route, revision, credential_hash,
322
- credential_lookup, created_at, updated_at)
323
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
324
- ).run(
325
- record.id,
326
- record.label,
327
- record.status,
328
- record.plan,
329
- record.maxRoutes,
330
- record.maxDevicesPerRoute,
331
- record.revision,
332
- credentialHash,
333
- credentialLookup,
334
- record.createdAt,
335
- record.updatedAt
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 safeAccountId = safeId(id);
360
- const currentRow = rowById(safeAccountId);
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(safeAccountId);
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 safeAccountId = safeId(id);
410
- const currentRow = rowById(safeAccountId);
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
- const result = db.prepare(
417
- `UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
418
- updated_at = ? WHERE id = ? AND revision = ?`
419
- ).run(credentialHash, credentialLookup, now, safeAccountId, expectedRevision);
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(safeAccountId);
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(safeAccountId));
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 removed = false;
1391
+ let routeId;
1392
+ let deviceId;
1393
+ let expectedCredentialHash;
1235
1394
  try {
1236
- removed = store.revokeDevice(request.params.routeId, request.params.deviceId);
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
- if (!removed) {
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
- deviceRates.delete(deviceRateKey(request.params.routeId, request.params.deviceId));
1244
- const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
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
  }));