borgmcp-server 0.1.7 → 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
@@ -75,6 +75,27 @@ export class AttachSessionRejectedError extends Error {
75
75
  this.name = "AttachSessionRejectedError";
76
76
  }
77
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
+ }
78
99
  export class StorageCapacityError extends Error {
79
100
  code = "CAPACITY_EXCEEDED";
80
101
  constructor() {
@@ -160,7 +181,7 @@ export async function openStore(options) {
160
181
  const capacityGuard = new StorageCapacityGuard(storageLimits, capacityProbe, requiredInteger(pageRow, "page_size"));
161
182
  const activityHub = new ActivityHub();
162
183
  const maintenance = new SqliteMaintenanceStore(database, clock);
163
- const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
184
+ const liveness = new IdleLivenessStore();
164
185
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
165
186
  return Object.freeze({
166
187
  forPrincipal: (principal) => {
@@ -761,7 +782,6 @@ class SqliteScopedStore {
761
782
  this.#database.prepare(`
762
783
  UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
763
784
  `).run(createdAt, droneId, cubeId);
764
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
765
785
  }
766
786
  if (recipients.length > 0) {
767
787
  const valid = this.#database.prepare(`
@@ -948,6 +968,7 @@ class SqliteScopedStore {
948
968
  assertCanonicalUuid(input.credentialId, "Drone session credential id");
949
969
  validateDigest(input.credentialDigest);
950
970
  validateTimestamp(input.expiresAt);
971
+ validateTimestamp(input.renewIfExpiresAtOrBefore);
951
972
  const scope = this.#scope("read");
952
973
  const authorizedRole = this.#database.prepare(`
953
974
  SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
@@ -972,7 +993,7 @@ class SqliteScopedStore {
972
993
  SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
973
994
  session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
974
995
  session.expires_at, session.revoked_at AS session_revoked_at,
975
- drone.role_id, drone.label, drone.evicted_at
996
+ session.superseded_at, drone.role_id, drone.label, drone.evicted_at
976
997
  FROM drone_session_credentials AS credential
977
998
  JOIN drone_sessions AS session ON session.id = credential.session_id
978
999
  JOIN drones AS drone ON drone.id = session.drone_id
@@ -982,10 +1003,6 @@ class SqliteScopedStore {
982
1003
  if (bound !== undefined) {
983
1004
  const verifier = requiredBuffer(bound, "verifier_digest");
984
1005
  if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
985
- optionalText(bound, "credential_revoked_at") !== null ||
986
- optionalText(bound, "session_revoked_at") !== null ||
987
- optionalText(bound, "evicted_at") !== null ||
988
- requiredText(bound, "expires_at") <= now ||
989
1006
  requiredText(bound, "client_id") !== this.#principal.id ||
990
1007
  requiredText(bound, "cube_id") !== input.cubeId ||
991
1008
  requiredText(bound, "role_id") !== input.roleId ||
@@ -993,9 +1010,29 @@ class SqliteScopedStore {
993
1010
  requiredText(bound, "drone_id") !== input.priorDroneId)) {
994
1011
  throw new AttachSessionRejectedError();
995
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();
996
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
+ }
997
1035
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
998
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
999
1036
  const attachedRoleRow = this.#database.prepare(`
1000
1037
  SELECT id AS role_id, name AS role_name, role_class, is_human_seat
1001
1038
  FROM roles WHERE id = ? AND cube_id = ?
@@ -1019,33 +1056,55 @@ class SqliteScopedStore {
1019
1056
  is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
1020
1057
  },
1021
1058
  drone: { id: droneId, label: requiredText(bound, "label") },
1022
- sessionId: requiredText(bound, "session_id"),
1023
- expiresAt: requiredText(bound, "expires_at"),
1059
+ sessionId,
1060
+ expiresAt: committedExpiry,
1024
1061
  result: "reused",
1025
1062
  };
1026
1063
  }
1027
1064
  const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
1028
- SELECT id, role_id, label FROM drones
1029
- 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 = ?
1030
1067
  `).get(input.priorDroneId, this.#principal.id, input.cubeId);
1031
1068
  let droneId;
1032
1069
  let droneLabel;
1033
- 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();
1034
1075
  const priorSeatId = requiredText(priorSeat, "id");
1035
1076
  if (requiredText(priorSeat, "role_id") !== input.roleId) {
1036
1077
  throw new AttachSessionRejectedError();
1037
1078
  }
1038
- const occupied = this.#database.prepare(`
1039
- 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
1040
1084
  JOIN drone_session_credentials AS credential ON credential.session_id = session.id
1041
1085
  WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
1042
- AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
1043
- AND session.expires_at > ?
1044
- `).get(priorSeatId, this.#principal.id, input.cubeId, now);
1045
- 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) {
1046
1097
  throw new AttachSessionRejectedError();
1098
+ }
1047
1099
  droneId = priorSeatId;
1048
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
+ }
1049
1108
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1050
1109
  }
1051
1110
  else {
@@ -1056,7 +1115,6 @@ class SqliteScopedStore {
1056
1115
  VALUES (?, ?, ?, ?, ?, ?, ?)
1057
1116
  `).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now);
1058
1117
  }
1059
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1060
1118
  this.#database.prepare(`
1061
1119
  INSERT INTO drone_sessions (
1062
1120
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -1583,68 +1641,11 @@ class ActivityHub {
1583
1641
  }
1584
1642
  }
1585
1643
  }
1586
- class SqliteLivenessStore {
1587
- database;
1588
- clock;
1589
- hub;
1590
- capacityGuard;
1591
- constructor(database, clock, hub, capacityGuard) {
1592
- this.database = database;
1593
- this.clock = clock;
1594
- this.hub = hub;
1595
- this.capacityGuard = capacityGuard;
1596
- }
1597
- scan(options = {}) {
1598
- const now = this.clock();
1599
- const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
1600
- const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
1601
- const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
1602
- const candidates = this.database.prepare(`
1603
- SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
1604
- FROM drones AS drone
1605
- JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
1606
- JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
1607
- AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
1608
- LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
1609
- WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
1610
- AND COALESCE(state.attempts, 0) < 3
1611
- AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
1612
- AND EXISTS (SELECT 1 FROM drone_sessions AS session
1613
- WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
1614
- ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
1615
- `).all(silentBefore, cooldownBefore, now.toISOString(), limit);
1616
- const published = [];
1617
- for (const candidate of candidates) {
1618
- const droneId = requiredText(candidate, "drone_id");
1619
- const cubeId = requiredText(candidate, "cube_id");
1620
- const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
1621
- this.capacityGuard.assertCanGrow(256);
1622
- const id = randomUUID();
1623
- const createdAt = now.toISOString();
1624
- this.database.exec("BEGIN IMMEDIATE");
1625
- try {
1626
- this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
1627
- VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
1628
- SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
1629
- `).run(droneId, now.toISOString());
1630
- this.database.exec("COMMIT");
1631
- }
1632
- catch (error) {
1633
- try {
1634
- this.database.exec("ROLLBACK");
1635
- }
1636
- catch { /* Preserve original failure. */ }
1637
- throw error;
1638
- }
1639
- const entry = {
1640
- kind: "heartbeat_ping",
1641
- id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
1642
- drone_label: null, role_name: null, recipient_drone_ids: [droneId],
1643
- };
1644
- this.hub.publish(cubeId, entry);
1645
- published.push(entry);
1646
- }
1647
- 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 [];
1648
1649
  }
1649
1650
  }
1650
1651
  class SqliteCredentialStore {
@@ -1939,7 +1940,7 @@ class SqliteCredentialStore {
1939
1940
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
1940
1941
  session.expires_at,
1941
1942
  COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
1942
- drone.evicted_at
1943
+ drone.evicted_at, session.superseded_at
1943
1944
  FROM drone_session_credentials AS credential
1944
1945
  JOIN drone_sessions AS session ON session.id = credential.session_id
1945
1946
  JOIN clients AS client ON client.id = session.client_id
@@ -2370,6 +2371,7 @@ function storedDroneSessionDigest(row) {
2370
2371
  droneId: requiredText(row, "drone_id"),
2371
2372
  expiresAt: requiredText(row, "expires_at"),
2372
2373
  evictedAt: nullableText(row, "evicted_at"),
2374
+ takenOver: nullableText(row, "superseded_at") !== null,
2373
2375
  };
2374
2376
  }
2375
2377
  function requiredBuffer(row, key) {