borgmcp-server 0.1.5 → 0.1.8

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/store.js CHANGED
@@ -54,6 +54,13 @@ export class RoleSectionConflictError extends Error {
54
54
  this.name = "RoleSectionConflictError";
55
55
  }
56
56
  }
57
+ export class RoleInUseError extends Error {
58
+ code = "ROLE_IN_USE";
59
+ constructor() {
60
+ super("The human-seat role is already occupied.");
61
+ this.name = "RoleInUseError";
62
+ }
63
+ }
57
64
  export class CursorExpiredError extends Error {
58
65
  code = "CURSOR_EXPIRED";
59
66
  constructor() {
@@ -68,6 +75,27 @@ export class AttachSessionRejectedError extends Error {
68
75
  this.name = "AttachSessionRejectedError";
69
76
  }
70
77
  }
78
+ export class AttachSessionExpiredError extends Error {
79
+ code = "AUTH_EXPIRED";
80
+ constructor() {
81
+ super("Authentication failed.");
82
+ this.name = "AttachSessionExpiredError";
83
+ }
84
+ }
85
+ export class AttachSessionRevokedError extends Error {
86
+ code = "SESSION_REVOKED";
87
+ constructor() {
88
+ super("Authentication failed.");
89
+ this.name = "AttachSessionRevokedError";
90
+ }
91
+ }
92
+ export class AttachDroneEvictedError extends Error {
93
+ code = "DRONE_EVICTED";
94
+ constructor() {
95
+ super("Authentication failed.");
96
+ this.name = "AttachDroneEvictedError";
97
+ }
98
+ }
71
99
  export class StorageCapacityError extends Error {
72
100
  code = "CAPACITY_EXCEEDED";
73
101
  constructor() {
@@ -153,7 +181,7 @@ export async function openStore(options) {
153
181
  const capacityGuard = new StorageCapacityGuard(storageLimits, capacityProbe, requiredInteger(pageRow, "page_size"));
154
182
  const activityHub = new ActivityHub();
155
183
  const maintenance = new SqliteMaintenanceStore(database, clock);
156
- const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
184
+ const liveness = new IdleLivenessStore();
157
185
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
158
186
  return Object.freeze({
159
187
  forPrincipal: (principal) => {
@@ -615,6 +643,96 @@ class SqliteScopedStore {
615
643
  })),
616
644
  };
617
645
  }
646
+ reassignDrone(cubeId, droneId, roleId) {
647
+ assertCanonicalUuid(cubeId, "Cube id");
648
+ assertCanonicalUuid(droneId, "Drone id");
649
+ assertCanonicalUuid(roleId, "Role id");
650
+ this.#requireCube(cubeId, "manage");
651
+ this.#database.exec("BEGIN IMMEDIATE");
652
+ try {
653
+ this.#requireCube(cubeId, "manage");
654
+ const droneRow = this.#database.prepare(`
655
+ SELECT id, cube_id, role_id, label FROM drones
656
+ WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
657
+ `).get(droneId, cubeId);
658
+ const targetRoleRow = this.#database.prepare(`
659
+ SELECT id, is_human_seat, role_class FROM roles WHERE id = ? AND cube_id = ?
660
+ `).get(roleId, cubeId);
661
+ if (droneRow === undefined || targetRoleRow === undefined)
662
+ throw new ScopedStoreError();
663
+ const sourceRoleRow = this.#database.prepare(`
664
+ SELECT is_human_seat FROM roles WHERE id = ? AND cube_id = ?
665
+ `).get(requiredText(droneRow, "role_id"), cubeId);
666
+ if (sourceRoleRow === undefined)
667
+ throw new ScopedStoreError();
668
+ if (requiredText(targetRoleRow, "role_class") === "queen" &&
669
+ requiredInteger(sourceRoleRow, "is_human_seat") !== 1) {
670
+ throw new AccessDeniedError();
671
+ }
672
+ if (requiredInteger(targetRoleRow, "is_human_seat") === 1) {
673
+ const occupied = this.#database.prepare(`
674
+ SELECT 1 AS present FROM drones
675
+ WHERE cube_id = ? AND role_id = ? AND id <> ? AND evicted_at IS NULL
676
+ `).get(cubeId, roleId, droneId);
677
+ if (occupied !== undefined)
678
+ throw new RoleInUseError();
679
+ }
680
+ this.#database.prepare("UPDATE drones SET role_id = ? WHERE id = ? AND cube_id = ?")
681
+ .run(roleId, droneId, cubeId);
682
+ const row = this.#database.prepare(`
683
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
684
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
685
+ drone.hostname, drone.created_at,
686
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
687
+ THEN 'participant' ELSE 'observer' END AS posture
688
+ FROM drones AS drone
689
+ LEFT JOIN clients AS client ON client.id = drone.client_id
690
+ LEFT JOIN client_cube_grants AS grant_row
691
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
692
+ WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
693
+ `).get(droneId, cubeId);
694
+ if (row === undefined)
695
+ throw new ScopedStoreError();
696
+ this.#database.exec("COMMIT");
697
+ return droneRecord(row);
698
+ }
699
+ catch (error) {
700
+ try {
701
+ this.#database.exec("ROLLBACK");
702
+ }
703
+ catch { /* Preserve the original failure. */ }
704
+ throw error;
705
+ }
706
+ }
707
+ evictDrone(cubeId, droneId) {
708
+ assertCanonicalUuid(cubeId, "Cube id");
709
+ assertCanonicalUuid(droneId, "Drone id");
710
+ this.#requireCube(cubeId, "manage");
711
+ this.#database.exec("BEGIN IMMEDIATE");
712
+ try {
713
+ this.#requireCube(cubeId, "manage");
714
+ const active = this.#database.prepare(`
715
+ SELECT 1 AS present FROM drones WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
716
+ `).get(droneId, cubeId);
717
+ if (active === undefined)
718
+ throw new ScopedStoreError();
719
+ const now = this.#now();
720
+ this.#database.prepare("UPDATE drones SET evicted_at = ? WHERE id = ? AND cube_id = ?")
721
+ .run(now, droneId, cubeId);
722
+ this.#database.prepare(`
723
+ UPDATE drone_sessions SET revoked_at = ?
724
+ WHERE drone_id = ? AND cube_id = ? AND revoked_at IS NULL
725
+ `).run(now, droneId, cubeId);
726
+ this.#database.exec("COMMIT");
727
+ }
728
+ catch (error) {
729
+ try {
730
+ this.#database.exec("ROLLBACK");
731
+ }
732
+ catch { /* Preserve the original failure. */ }
733
+ throw error;
734
+ }
735
+ }
618
736
  appendLog(cubeId, input) {
619
737
  assertCanonicalUuid(cubeId, "Cube id");
620
738
  if (input.message.length === 0 || Buffer.byteLength(input.message) > 10_240) {
@@ -664,7 +782,6 @@ class SqliteScopedStore {
664
782
  this.#database.prepare(`
665
783
  UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
666
784
  `).run(createdAt, droneId, cubeId);
667
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
668
785
  }
669
786
  if (recipients.length > 0) {
670
787
  const valid = this.#database.prepare(`
@@ -851,6 +968,7 @@ class SqliteScopedStore {
851
968
  assertCanonicalUuid(input.credentialId, "Drone session credential id");
852
969
  validateDigest(input.credentialDigest);
853
970
  validateTimestamp(input.expiresAt);
971
+ validateTimestamp(input.renewIfExpiresAtOrBefore);
854
972
  const scope = this.#scope("read");
855
973
  const authorizedRole = this.#database.prepare(`
856
974
  SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
@@ -875,7 +993,7 @@ class SqliteScopedStore {
875
993
  SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
876
994
  session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
877
995
  session.expires_at, session.revoked_at AS session_revoked_at,
878
- drone.role_id, drone.label, drone.evicted_at
996
+ session.superseded_at, drone.role_id, drone.label, drone.evicted_at
879
997
  FROM drone_session_credentials AS credential
880
998
  JOIN drone_sessions AS session ON session.id = credential.session_id
881
999
  JOIN drones AS drone ON drone.id = session.drone_id
@@ -885,10 +1003,6 @@ class SqliteScopedStore {
885
1003
  if (bound !== undefined) {
886
1004
  const verifier = requiredBuffer(bound, "verifier_digest");
887
1005
  if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
888
- optionalText(bound, "credential_revoked_at") !== null ||
889
- optionalText(bound, "session_revoked_at") !== null ||
890
- optionalText(bound, "evicted_at") !== null ||
891
- requiredText(bound, "expires_at") <= now ||
892
1006
  requiredText(bound, "client_id") !== this.#principal.id ||
893
1007
  requiredText(bound, "cube_id") !== input.cubeId ||
894
1008
  requiredText(bound, "role_id") !== input.roleId ||
@@ -896,9 +1010,29 @@ class SqliteScopedStore {
896
1010
  requiredText(bound, "drone_id") !== input.priorDroneId)) {
897
1011
  throw new AttachSessionRejectedError();
898
1012
  }
1013
+ if (optionalText(bound, "evicted_at") !== null)
1014
+ throw new AttachDroneEvictedError();
1015
+ if (optionalText(bound, "credential_revoked_at") !== null ||
1016
+ optionalText(bound, "session_revoked_at") !== null) {
1017
+ throw new AttachSessionRevokedError();
1018
+ }
1019
+ if (optionalText(bound, "superseded_at") !== null) {
1020
+ throw new AttachSessionRejectedError();
1021
+ }
1022
+ if (requiredText(bound, "expires_at") <= now)
1023
+ throw new AttachSessionExpiredError();
899
1024
  const droneId = requiredText(bound, "drone_id");
1025
+ const sessionId = requiredText(bound, "session_id");
1026
+ const previousExpiry = requiredText(bound, "expires_at");
1027
+ const committedExpiry = input.expiresAt > previousExpiry &&
1028
+ previousExpiry <= input.renewIfExpiresAtOrBefore
1029
+ ? input.expiresAt
1030
+ : previousExpiry;
1031
+ if (committedExpiry !== previousExpiry) {
1032
+ this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
1033
+ .run(committedExpiry, sessionId);
1034
+ }
900
1035
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
901
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
902
1036
  const attachedRoleRow = this.#database.prepare(`
903
1037
  SELECT id AS role_id, name AS role_name, role_class, is_human_seat
904
1038
  FROM roles WHERE id = ? AND cube_id = ?
@@ -922,33 +1056,55 @@ class SqliteScopedStore {
922
1056
  is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
923
1057
  },
924
1058
  drone: { id: droneId, label: requiredText(bound, "label") },
925
- sessionId: requiredText(bound, "session_id"),
926
- expiresAt: requiredText(bound, "expires_at"),
1059
+ sessionId,
1060
+ expiresAt: committedExpiry,
927
1061
  result: "reused",
928
1062
  };
929
1063
  }
930
1064
  const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
931
- SELECT id, role_id, label FROM drones
932
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
1065
+ SELECT id, role_id, label, evicted_at FROM drones
1066
+ WHERE id = ? AND client_id = ? AND cube_id = ?
933
1067
  `).get(input.priorDroneId, this.#principal.id, input.cubeId);
934
1068
  let droneId;
935
1069
  let droneLabel;
936
- if (priorSeat !== undefined) {
1070
+ if (input.priorDroneId !== undefined) {
1071
+ if (priorSeat === undefined)
1072
+ throw new AttachSessionRejectedError();
1073
+ if (optionalText(priorSeat, "evicted_at") !== null)
1074
+ throw new AttachDroneEvictedError();
937
1075
  const priorSeatId = requiredText(priorSeat, "id");
938
1076
  if (requiredText(priorSeat, "role_id") !== input.roleId) {
939
1077
  throw new AttachSessionRejectedError();
940
1078
  }
941
- const occupied = this.#database.prepare(`
942
- SELECT 1 FROM drone_sessions AS session
1079
+ const latestSession = this.#database.prepare(`
1080
+ SELECT session.id, session.expires_at, session.revoked_at AS session_revoked_at,
1081
+ MAX(CASE WHEN credential.revoked_at IS NOT NULL THEN 1 ELSE 0 END)
1082
+ AS credential_revoked
1083
+ FROM drone_sessions AS session
943
1084
  JOIN drone_session_credentials AS credential ON credential.session_id = session.id
944
1085
  WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
945
- AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
946
- AND session.expires_at > ?
947
- `).get(priorSeatId, this.#principal.id, input.cubeId, now);
948
- if (occupied !== undefined)
1086
+ AND session.superseded_at IS NULL
1087
+ GROUP BY session.id
1088
+ LIMIT 1
1089
+ `).get(priorSeatId, this.#principal.id, input.cubeId);
1090
+ if (latestSession === undefined)
1091
+ throw new AttachSessionRejectedError();
1092
+ if (optionalText(latestSession, "session_revoked_at") !== null ||
1093
+ requiredInteger(latestSession, "credential_revoked") === 1) {
1094
+ throw new AttachSessionRevokedError();
1095
+ }
1096
+ if (requiredText(latestSession, "expires_at") > now) {
949
1097
  throw new AttachSessionRejectedError();
1098
+ }
950
1099
  droneId = priorSeatId;
951
1100
  droneLabel = requiredText(priorSeat, "label");
1101
+ const superseded = this.#database.prepare(`
1102
+ UPDATE drone_sessions SET superseded_at = ?
1103
+ WHERE id = ? AND superseded_at IS NULL
1104
+ `).run(now, requiredText(latestSession, "id"));
1105
+ if (Number(superseded.changes) !== 1) {
1106
+ throw new Error("Expired session supersession was not atomic.");
1107
+ }
952
1108
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
953
1109
  }
954
1110
  else {
@@ -959,7 +1115,6 @@ class SqliteScopedStore {
959
1115
  VALUES (?, ?, ?, ?, ?, ?, ?)
960
1116
  `).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now);
961
1117
  }
962
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
963
1118
  this.#database.prepare(`
964
1119
  INSERT INTO drone_sessions (
965
1120
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -1397,6 +1552,54 @@ class SqliteMaintenanceStore {
1397
1552
  assertCanonicalUuid(sessionId, "Drone session id");
1398
1553
  this.#database.prepare("UPDATE drone_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(this.#now(), sessionId);
1399
1554
  }
1555
+ expireDroneSession(sessionId) {
1556
+ assertCanonicalUuid(sessionId, "Drone session id");
1557
+ this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
1558
+ .run(new Date(0).toISOString(), sessionId);
1559
+ }
1560
+ inspectManagedDrone(droneId) {
1561
+ assertCanonicalUuid(droneId, "Drone id");
1562
+ const row = this.#database.prepare(`
1563
+ SELECT drone.role_id, drone.evicted_at,
1564
+ EXISTS(SELECT 1 FROM drone_sessions AS session
1565
+ WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
1566
+ FROM drones AS drone WHERE drone.id = ?
1567
+ `).get(droneId);
1568
+ if (row === undefined)
1569
+ throw new ScopedStoreError();
1570
+ return {
1571
+ role_id: requiredText(row, "role_id"),
1572
+ evicted: nullableText(row, "evicted_at") !== null,
1573
+ session_revoked: requiredInteger(row, "session_revoked") === 1,
1574
+ };
1575
+ }
1576
+ inspectCubeManagementState(cubeId) {
1577
+ assertCanonicalUuid(cubeId, "Cube id");
1578
+ const cube = this.#database.prepare("SELECT directive, message_taxonomy FROM cubes WHERE id = ?").get(cubeId);
1579
+ if (cube === undefined)
1580
+ throw new ScopedStoreError();
1581
+ const taxonomy = nullableText(cube, "message_taxonomy");
1582
+ const parsed = taxonomy === null ? null : JSON.parse(taxonomy);
1583
+ return {
1584
+ directive: requiredText(cube, "directive"),
1585
+ taxonomy_marker: typeof parsed?.[0]?.class === "string" ? parsed[0].class : null,
1586
+ role_ids: this.#database.prepare("SELECT id FROM roles WHERE cube_id = ? ORDER BY id").all(cubeId).map((row) => requiredText(row, "id")),
1587
+ active_decision_ids: this.#database.prepare(`
1588
+ SELECT id FROM decisions WHERE cube_id = ? AND status = 'active' ORDER BY id
1589
+ `).all(cubeId).map((row) => requiredText(row, "id")),
1590
+ drones: this.#database.prepare(`
1591
+ SELECT drone.id, drone.role_id, drone.evicted_at,
1592
+ EXISTS(SELECT 1 FROM drone_sessions AS session
1593
+ WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
1594
+ FROM drones AS drone WHERE drone.cube_id = ? ORDER BY drone.id
1595
+ `).all(cubeId).map((row) => ({
1596
+ id: requiredText(row, "id"),
1597
+ role_id: requiredText(row, "role_id"),
1598
+ evicted: nullableText(row, "evicted_at") !== null,
1599
+ session_revoked: requiredInteger(row, "session_revoked") === 1,
1600
+ })),
1601
+ };
1602
+ }
1400
1603
  expireActivityCursor(cubeId, cursor) {
1401
1604
  assertCanonicalUuid(cubeId, "Cube id");
1402
1605
  assertCanonicalUuid(cursor.id, "Activity cursor id");
@@ -1438,68 +1641,11 @@ class ActivityHub {
1438
1641
  }
1439
1642
  }
1440
1643
  }
1441
- class SqliteLivenessStore {
1442
- database;
1443
- clock;
1444
- hub;
1445
- capacityGuard;
1446
- constructor(database, clock, hub, capacityGuard) {
1447
- this.database = database;
1448
- this.clock = clock;
1449
- this.hub = hub;
1450
- this.capacityGuard = capacityGuard;
1451
- }
1452
- scan(options = {}) {
1453
- const now = this.clock();
1454
- const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
1455
- const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
1456
- const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
1457
- const candidates = this.database.prepare(`
1458
- SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
1459
- FROM drones AS drone
1460
- JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
1461
- JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
1462
- AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
1463
- LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
1464
- WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
1465
- AND COALESCE(state.attempts, 0) < 3
1466
- AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
1467
- AND EXISTS (SELECT 1 FROM drone_sessions AS session
1468
- WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
1469
- ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
1470
- `).all(silentBefore, cooldownBefore, now.toISOString(), limit);
1471
- const published = [];
1472
- for (const candidate of candidates) {
1473
- const droneId = requiredText(candidate, "drone_id");
1474
- const cubeId = requiredText(candidate, "cube_id");
1475
- const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
1476
- this.capacityGuard.assertCanGrow(256);
1477
- const id = randomUUID();
1478
- const createdAt = now.toISOString();
1479
- this.database.exec("BEGIN IMMEDIATE");
1480
- try {
1481
- this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
1482
- VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
1483
- SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
1484
- `).run(droneId, now.toISOString());
1485
- this.database.exec("COMMIT");
1486
- }
1487
- catch (error) {
1488
- try {
1489
- this.database.exec("ROLLBACK");
1490
- }
1491
- catch { /* Preserve original failure. */ }
1492
- throw error;
1493
- }
1494
- const entry = {
1495
- kind: "heartbeat_ping",
1496
- id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
1497
- drone_label: null, role_name: null, recipient_drone_ids: [droneId],
1498
- };
1499
- this.hub.publish(cubeId, entry);
1500
- published.push(entry);
1501
- }
1502
- return published;
1644
+ class IdleLivenessStore {
1645
+ scan() {
1646
+ // Silence is not a delivery failure. Keep the scheduler's lifecycle seam without
1647
+ // creating a directed event that crosses the client/model wake boundary.
1648
+ return [];
1503
1649
  }
1504
1650
  }
1505
1651
  class SqliteCredentialStore {
@@ -1794,7 +1940,7 @@ class SqliteCredentialStore {
1794
1940
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
1795
1941
  session.expires_at,
1796
1942
  COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
1797
- drone.evicted_at
1943
+ drone.evicted_at, session.superseded_at
1798
1944
  FROM drone_session_credentials AS credential
1799
1945
  JOIN drone_sessions AS session ON session.id = credential.session_id
1800
1946
  JOIN clients AS client ON client.id = session.client_id
@@ -2225,6 +2371,7 @@ function storedDroneSessionDigest(row) {
2225
2371
  droneId: requiredText(row, "drone_id"),
2226
2372
  expiresAt: requiredText(row, "expires_at"),
2227
2373
  evictedAt: nullableText(row, "evicted_at"),
2374
+ takenOver: nullableText(row, "superseded_at") !== null,
2228
2375
  };
2229
2376
  }
2230
2377
  function requiredBuffer(row, key) {