borgmcp-server 0.1.7 → 0.1.9

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.
Files changed (70) hide show
  1. package/README.md +61 -46
  2. package/SECURITY.md +12 -1
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/dist/bootstrap.d.ts +8 -1
  5. package/dist/bootstrap.js +39 -6
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +176 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/coordination-api.js +10 -1
  11. package/dist/coordination-api.js.map +1 -1
  12. package/dist/credentials.d.ts +5 -1
  13. package/dist/credentials.js +35 -3
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/debug-log.d.ts +2 -2
  16. package/dist/debug-log.js +2 -2
  17. package/dist/debug-log.js.map +1 -1
  18. package/dist/https-server.d.ts +5 -1
  19. package/dist/https-server.js +93 -27
  20. package/dist/https-server.js.map +1 -1
  21. package/dist/index.d.ts +14 -1
  22. package/dist/index.js +8 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/main.js +1 -0
  25. package/dist/main.js.map +1 -1
  26. package/dist/managed-service.d.ts +20 -0
  27. package/dist/managed-service.js +80 -0
  28. package/dist/managed-service.js.map +1 -0
  29. package/dist/migrations.js +23 -0
  30. package/dist/migrations.js.map +1 -1
  31. package/dist/portable-credential-store.d.ts +16 -0
  32. package/dist/portable-credential-store.js +305 -0
  33. package/dist/portable-credential-store.js.map +1 -0
  34. package/dist/registry-artifact.d.ts +11 -0
  35. package/dist/registry-artifact.js +99 -0
  36. package/dist/registry-artifact.js.map +1 -0
  37. package/dist/runtime-identity.d.ts +19 -0
  38. package/dist/runtime-identity.js +61 -0
  39. package/dist/runtime-identity.js.map +1 -0
  40. package/dist/runtime-lifecycle.d.ts +56 -0
  41. package/dist/runtime-lifecycle.js +486 -0
  42. package/dist/runtime-lifecycle.js.map +1 -0
  43. package/dist/runtime-operator.d.ts +24 -0
  44. package/dist/runtime-operator.js +95 -0
  45. package/dist/runtime-operator.js.map +1 -0
  46. package/dist/service.d.ts +53 -5
  47. package/dist/service.js +326 -15
  48. package/dist/service.js.map +1 -1
  49. package/dist/store.d.ts +16 -7
  50. package/dist/store.js +93 -83
  51. package/dist/store.js.map +1 -1
  52. package/npm-shrinkwrap.json +6 -6
  53. package/package.json +2 -2
  54. package/src/bootstrap.ts +46 -5
  55. package/src/cli.ts +171 -1
  56. package/src/coordination-api.ts +12 -0
  57. package/src/credentials.ts +36 -5
  58. package/src/debug-log.ts +4 -3
  59. package/src/https-server.ts +124 -27
  60. package/src/index.ts +46 -1
  61. package/src/main.ts +1 -0
  62. package/src/managed-service.ts +107 -0
  63. package/src/migrations.ts +23 -0
  64. package/src/portable-credential-store.ts +306 -0
  65. package/src/registry-artifact.ts +111 -0
  66. package/src/runtime-identity.ts +82 -0
  67. package/src/runtime-lifecycle.ts +567 -0
  68. package/src/runtime-operator.ts +124 -0
  69. package/src/service.ts +401 -20
  70. package/src/store.ts +100 -83
package/src/store.ts CHANGED
@@ -65,7 +65,6 @@ export type ActivityNotificationRecord = EnrichedActivityRecord & (
65
65
  readonly claimant_drone_id?: string;
66
66
  readonly claimant_role?: string | null;
67
67
  }
68
- | { readonly kind: "heartbeat_ping" }
69
68
  );
70
69
 
71
70
  export type ActivityStreamRecord = EnrichedActivityRecord | ActivityNotificationRecord;
@@ -180,11 +179,7 @@ export interface StoreRuntime {
180
179
  }
181
180
 
182
181
  export interface LivenessStore {
183
- readonly scan: (options?: {
184
- readonly silentMs?: number;
185
- readonly cooldownMs?: number;
186
- readonly limit?: number;
187
- }) => ActivityNotificationRecord[];
182
+ readonly scan: () => ActivityNotificationRecord[];
188
183
  }
189
184
 
190
185
  export interface DigestPair {
@@ -237,6 +232,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
237
232
  readonly droneId: string;
238
233
  readonly expiresAt: string;
239
234
  readonly evictedAt: string | null;
235
+ readonly takenOver: boolean;
240
236
  }
241
237
 
242
238
  export interface CredentialStore {
@@ -262,6 +258,7 @@ export interface CredentialStore {
262
258
  readonly findClientCredential: (lookup: Buffer) => StoredSecretDigest | null;
263
259
  readonly clientExists: (clientId: string) => boolean;
264
260
  readonly clientIsActive: (clientId: string) => boolean;
261
+ readonly clientHasServerCapability: (clientId: string, capability: "create_cube") => boolean;
265
262
  readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
266
263
  readonly findActiveDroneSessionExpiry: (sessionId: string) => string | null;
267
264
  readonly rotateClientCredential: (input: {
@@ -362,6 +359,7 @@ export interface SeatAttachInput {
362
359
  readonly credentialId: string;
363
360
  readonly credentialDigest: DigestPair;
364
361
  readonly expiresAt: string;
362
+ readonly renewIfExpiresAtOrBefore: string;
365
363
  }
366
364
 
367
365
  export interface SeatAttachRecord {
@@ -527,6 +525,33 @@ export class AttachSessionRejectedError extends Error {
527
525
  }
528
526
  }
529
527
 
528
+ export class AttachSessionExpiredError extends Error {
529
+ readonly code = "AUTH_EXPIRED";
530
+
531
+ constructor() {
532
+ super("Authentication failed.");
533
+ this.name = "AttachSessionExpiredError";
534
+ }
535
+ }
536
+
537
+ export class AttachSessionRevokedError extends Error {
538
+ readonly code = "SESSION_REVOKED";
539
+
540
+ constructor() {
541
+ super("Authentication failed.");
542
+ this.name = "AttachSessionRevokedError";
543
+ }
544
+ }
545
+
546
+ export class AttachDroneEvictedError extends Error {
547
+ readonly code = "DRONE_EVICTED";
548
+
549
+ constructor() {
550
+ super("Authentication failed.");
551
+ this.name = "AttachDroneEvictedError";
552
+ }
553
+ }
554
+
530
555
  export class StorageCapacityError extends Error {
531
556
  readonly code = "CAPACITY_EXCEEDED";
532
557
 
@@ -645,7 +670,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
645
670
  );
646
671
  const activityHub = new ActivityHub();
647
672
  const maintenance = new SqliteMaintenanceStore(database, clock);
648
- const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
673
+ const liveness = new IdleLivenessStore();
649
674
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
650
675
  return Object.freeze({
651
676
  forPrincipal: (principal: Principal) => {
@@ -1283,7 +1308,6 @@ class SqliteScopedStore implements ScopedStore {
1283
1308
  this.#database.prepare(`
1284
1309
  UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
1285
1310
  `).run(createdAt, droneId, cubeId);
1286
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1287
1311
  }
1288
1312
  if (recipients.length > 0) {
1289
1313
  const valid = this.#database.prepare(`
@@ -1470,6 +1494,7 @@ class SqliteScopedStore implements ScopedStore {
1470
1494
  assertCanonicalUuid(input.credentialId, "Drone session credential id");
1471
1495
  validateDigest(input.credentialDigest);
1472
1496
  validateTimestamp(input.expiresAt);
1497
+ validateTimestamp(input.renewIfExpiresAtOrBefore);
1473
1498
  const scope = this.#scope("read");
1474
1499
  const authorizedRole = this.#database.prepare(`
1475
1500
  SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
@@ -1492,7 +1517,7 @@ class SqliteScopedStore implements ScopedStore {
1492
1517
  SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
1493
1518
  session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
1494
1519
  session.expires_at, session.revoked_at AS session_revoked_at,
1495
- drone.role_id, drone.label, drone.evicted_at
1520
+ session.superseded_at, drone.role_id, drone.label, drone.evicted_at
1496
1521
  FROM drone_session_credentials AS credential
1497
1522
  JOIN drone_sessions AS session ON session.id = credential.session_id
1498
1523
  JOIN drones AS drone ON drone.id = session.drone_id
@@ -1502,10 +1527,6 @@ class SqliteScopedStore implements ScopedStore {
1502
1527
  if (bound !== undefined) {
1503
1528
  const verifier = requiredBuffer(bound, "verifier_digest");
1504
1529
  if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
1505
- optionalText(bound, "credential_revoked_at") !== null ||
1506
- optionalText(bound, "session_revoked_at") !== null ||
1507
- optionalText(bound, "evicted_at") !== null ||
1508
- requiredText(bound, "expires_at") <= now ||
1509
1530
  requiredText(bound, "client_id") !== this.#principal.id ||
1510
1531
  requiredText(bound, "cube_id") !== input.cubeId ||
1511
1532
  requiredText(bound, "role_id") !== input.roleId ||
@@ -1513,9 +1534,27 @@ class SqliteScopedStore implements ScopedStore {
1513
1534
  requiredText(bound, "drone_id") !== input.priorDroneId)) {
1514
1535
  throw new AttachSessionRejectedError();
1515
1536
  }
1537
+ if (optionalText(bound, "evicted_at") !== null) throw new AttachDroneEvictedError();
1538
+ if (optionalText(bound, "credential_revoked_at") !== null ||
1539
+ optionalText(bound, "session_revoked_at") !== null) {
1540
+ throw new AttachSessionRevokedError();
1541
+ }
1542
+ if (optionalText(bound, "superseded_at") !== null) {
1543
+ throw new AttachSessionRejectedError();
1544
+ }
1545
+ if (requiredText(bound, "expires_at") <= now) throw new AttachSessionExpiredError();
1516
1546
  const droneId = requiredText(bound, "drone_id");
1547
+ const sessionId = requiredText(bound, "session_id");
1548
+ const previousExpiry = requiredText(bound, "expires_at");
1549
+ const committedExpiry = input.expiresAt > previousExpiry &&
1550
+ previousExpiry <= input.renewIfExpiresAtOrBefore
1551
+ ? input.expiresAt
1552
+ : previousExpiry;
1553
+ if (committedExpiry !== previousExpiry) {
1554
+ this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
1555
+ .run(committedExpiry, sessionId);
1556
+ }
1517
1557
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1518
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1519
1558
  const attachedRoleRow = this.#database.prepare(`
1520
1559
  SELECT id AS role_id, name AS role_name, role_class, is_human_seat
1521
1560
  FROM roles WHERE id = ? AND cube_id = ?
@@ -1538,33 +1577,53 @@ class SqliteScopedStore implements ScopedStore {
1538
1577
  is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
1539
1578
  },
1540
1579
  drone: { id: droneId, label: requiredText(bound, "label") },
1541
- sessionId: requiredText(bound, "session_id"),
1542
- expiresAt: requiredText(bound, "expires_at"),
1580
+ sessionId,
1581
+ expiresAt: committedExpiry,
1543
1582
  result: "reused",
1544
1583
  };
1545
1584
  }
1546
1585
 
1547
1586
  const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
1548
- SELECT id, role_id, label FROM drones
1549
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
1587
+ SELECT id, role_id, label, evicted_at FROM drones
1588
+ WHERE id = ? AND client_id = ? AND cube_id = ?
1550
1589
  `).get(input.priorDroneId, this.#principal.id, input.cubeId);
1551
1590
  let droneId: string;
1552
1591
  let droneLabel: string;
1553
- if (priorSeat !== undefined) {
1592
+ if (input.priorDroneId !== undefined) {
1593
+ if (priorSeat === undefined) throw new AttachSessionRejectedError();
1594
+ if (optionalText(priorSeat, "evicted_at") !== null) throw new AttachDroneEvictedError();
1554
1595
  const priorSeatId = requiredText(priorSeat, "id");
1555
1596
  if (requiredText(priorSeat, "role_id") !== input.roleId) {
1556
1597
  throw new AttachSessionRejectedError();
1557
1598
  }
1558
- const occupied = this.#database.prepare(`
1559
- SELECT 1 FROM drone_sessions AS session
1599
+ const latestSession = this.#database.prepare(`
1600
+ SELECT session.id, session.expires_at, session.revoked_at AS session_revoked_at,
1601
+ MAX(CASE WHEN credential.revoked_at IS NOT NULL THEN 1 ELSE 0 END)
1602
+ AS credential_revoked
1603
+ FROM drone_sessions AS session
1560
1604
  JOIN drone_session_credentials AS credential ON credential.session_id = session.id
1561
1605
  WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
1562
- AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
1563
- AND session.expires_at > ?
1564
- `).get(priorSeatId, this.#principal.id, input.cubeId, now);
1565
- if (occupied !== undefined) throw new AttachSessionRejectedError();
1606
+ AND session.superseded_at IS NULL
1607
+ GROUP BY session.id
1608
+ LIMIT 1
1609
+ `).get(priorSeatId, this.#principal.id, input.cubeId);
1610
+ if (latestSession === undefined) throw new AttachSessionRejectedError();
1611
+ if (optionalText(latestSession, "session_revoked_at") !== null ||
1612
+ requiredInteger(latestSession, "credential_revoked") === 1) {
1613
+ throw new AttachSessionRevokedError();
1614
+ }
1615
+ if (requiredText(latestSession, "expires_at") > now) {
1616
+ throw new AttachSessionRejectedError();
1617
+ }
1566
1618
  droneId = priorSeatId;
1567
1619
  droneLabel = requiredText(priorSeat, "label");
1620
+ const superseded = this.#database.prepare(`
1621
+ UPDATE drone_sessions SET superseded_at = ?
1622
+ WHERE id = ? AND superseded_at IS NULL
1623
+ `).run(now, requiredText(latestSession, "id"));
1624
+ if (Number(superseded.changes) !== 1) {
1625
+ throw new Error("Expired session supersession was not atomic.");
1626
+ }
1568
1627
  this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1569
1628
  } else {
1570
1629
  droneId = input.droneId;
@@ -1576,8 +1635,6 @@ class SqliteScopedStore implements ScopedStore {
1576
1635
  droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
1577
1636
  );
1578
1637
  }
1579
- this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1580
-
1581
1638
  this.#database.prepare(`
1582
1639
  INSERT INTO drone_sessions (
1583
1640
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -2194,61 +2251,11 @@ class ActivityHub {
2194
2251
  }
2195
2252
  }
2196
2253
 
2197
- class SqliteLivenessStore implements LivenessStore {
2198
- constructor(
2199
- readonly database: DatabaseSync,
2200
- readonly clock: () => Date,
2201
- readonly hub: ActivityHub,
2202
- readonly capacityGuard: StorageCapacityGuard,
2203
- ) {}
2204
-
2205
- scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
2206
- const now = this.clock();
2207
- const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
2208
- const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
2209
- const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
2210
- const candidates = this.database.prepare(`
2211
- SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
2212
- FROM drones AS drone
2213
- JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
2214
- JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
2215
- AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
2216
- LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
2217
- WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
2218
- AND COALESCE(state.attempts, 0) < 3
2219
- AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
2220
- AND EXISTS (SELECT 1 FROM drone_sessions AS session
2221
- WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
2222
- ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
2223
- `).all(silentBefore, cooldownBefore, now.toISOString(), limit);
2224
- const published: ActivityNotificationRecord[] = [];
2225
- for (const candidate of candidates) {
2226
- const droneId = requiredText(candidate, "drone_id");
2227
- const cubeId = requiredText(candidate, "cube_id");
2228
- const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
2229
- this.capacityGuard.assertCanGrow(256);
2230
- const id = randomUUID();
2231
- const createdAt = now.toISOString();
2232
- this.database.exec("BEGIN IMMEDIATE");
2233
- try {
2234
- this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
2235
- VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
2236
- SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
2237
- `).run(droneId, now.toISOString());
2238
- this.database.exec("COMMIT");
2239
- } catch (error) {
2240
- try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
2241
- throw error;
2242
- }
2243
- const entry: ActivityNotificationRecord = {
2244
- kind: "heartbeat_ping",
2245
- id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
2246
- drone_label: null, role_name: null, recipient_drone_ids: [droneId],
2247
- };
2248
- this.hub.publish(cubeId, entry);
2249
- published.push(entry);
2250
- }
2251
- return published;
2254
+ class IdleLivenessStore implements LivenessStore {
2255
+ scan(): ActivityNotificationRecord[] {
2256
+ // Silence is not a delivery failure. Keep the scheduler's lifecycle seam without
2257
+ // creating a directed event that crosses the client/model wake boundary.
2258
+ return [];
2252
2259
  }
2253
2260
  }
2254
2261
 
@@ -2570,6 +2577,15 @@ class SqliteCredentialStore implements CredentialStore {
2570
2577
  ).get(clientId) !== undefined;
2571
2578
  }
2572
2579
 
2580
+ clientHasServerCapability(clientId: string, capability: "create_cube"): boolean {
2581
+ assertCanonicalUuid(clientId, "Client id");
2582
+ return this.#database.prepare(`
2583
+ SELECT 1 FROM client_server_capabilities AS capability
2584
+ JOIN clients AS client ON client.id = capability.client_id
2585
+ WHERE capability.client_id = ? AND capability.capability = ? AND client.revoked_at IS NULL
2586
+ `).get(clientId, capability) !== undefined;
2587
+ }
2588
+
2573
2589
  findDroneSessionCredential(lookup: Buffer): StoredDroneSessionDigest | null {
2574
2590
  validateLookup(lookup);
2575
2591
  const row = this.#database.prepare(`
@@ -2577,7 +2593,7 @@ class SqliteCredentialStore implements CredentialStore {
2577
2593
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
2578
2594
  session.expires_at,
2579
2595
  COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
2580
- drone.evicted_at
2596
+ drone.evicted_at, session.superseded_at
2581
2597
  FROM drone_session_credentials AS credential
2582
2598
  JOIN drone_sessions AS session ON session.id = credential.session_id
2583
2599
  JOIN clients AS client ON client.id = session.client_id
@@ -3055,6 +3071,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
3055
3071
  droneId: requiredText(row, "drone_id"),
3056
3072
  expiresAt: requiredText(row, "expires_at"),
3057
3073
  evictedAt: nullableText(row, "evicted_at"),
3074
+ takenOver: nullableText(row, "superseded_at") !== null,
3058
3075
  };
3059
3076
  }
3060
3077