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/README.md +10 -9
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/coordination-api.js +10 -1
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -1
- package/dist/credentials.js +28 -3
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +1 -1
- package/dist/debug-log.js +1 -1
- package/dist/debug-log.js.map +1 -1
- package/dist/https-server.d.ts +2 -1
- package/dist/https-server.js +68 -20
- package/dist/https-server.js.map +1 -1
- package/dist/migrations.js +23 -0
- package/dist/migrations.js.map +1 -1
- package/dist/store.d.ts +15 -7
- package/dist/store.js +85 -83
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/src/coordination-api.ts +12 -0
- package/src/credentials.ts +29 -5
- package/src/debug-log.ts +2 -2
- package/src/https-server.ts +97 -20
- package/src/migrations.ts +23 -0
- package/src/store.ts +90 -83
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
|
|
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
|
|
1023
|
-
expiresAt:
|
|
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 = ?
|
|
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 (
|
|
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
|
|
1039
|
-
SELECT
|
|
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.
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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) {
|