borgmcp-server 0.1.4 → 0.1.5

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 (43) hide show
  1. package/README.md +7 -6
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/coordination-api.d.ts +2 -1
  4. package/dist/coordination-api.js +269 -85
  5. package/dist/coordination-api.js.map +1 -1
  6. package/dist/credentials.d.ts +4 -6
  7. package/dist/credentials.js +43 -19
  8. package/dist/credentials.js.map +1 -1
  9. package/dist/debug-log.d.ts +2 -3
  10. package/dist/debug-log.js +2 -3
  11. package/dist/debug-log.js.map +1 -1
  12. package/dist/enrollment.d.ts +3 -11
  13. package/dist/enrollment.js +21 -60
  14. package/dist/enrollment.js.map +1 -1
  15. package/dist/https-server.d.ts +2 -20
  16. package/dist/https-server.js +33 -51
  17. package/dist/https-server.js.map +1 -1
  18. package/dist/message-taxonomy.d.ts +38 -0
  19. package/dist/message-taxonomy.js +218 -0
  20. package/dist/message-taxonomy.js.map +1 -0
  21. package/dist/migrations.js +28 -0
  22. package/dist/migrations.js.map +1 -1
  23. package/dist/service.d.ts +4 -1
  24. package/dist/service.js +25 -10
  25. package/dist/service.js.map +1 -1
  26. package/dist/store.d.ts +42 -8
  27. package/dist/store.js +380 -100
  28. package/dist/store.js.map +1 -1
  29. package/npm-shrinkwrap.json +6 -7
  30. package/package.json +2 -2
  31. package/src/coordination-api.ts +278 -82
  32. package/src/credentials.ts +44 -26
  33. package/src/debug-log.ts +5 -5
  34. package/src/enrollment.ts +32 -78
  35. package/src/https-server.ts +44 -82
  36. package/src/message-taxonomy.ts +284 -0
  37. package/src/migrations.ts +28 -0
  38. package/src/service.ts +29 -9
  39. package/src/store.ts +432 -121
  40. package/dist/protocol-draft.d.ts +0 -2
  41. package/dist/protocol-draft.js +0 -31
  42. package/dist/protocol-draft.js.map +0 -1
  43. package/src/protocol-draft.ts +0 -32
package/dist/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { statfsSync, statSync } from "node:fs";
3
3
  import { chmod, lstat, mkdir, open } from "node:fs/promises";
4
4
  import { dirname, join, parse, relative, resolve, sep } from "node:path";
@@ -7,6 +7,7 @@ import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
7
7
  import { operatorErrors } from "./operator-error.js";
8
8
  import { assertCanonicalUuid, assertServerDerivedPrincipal, } from "./principal.js";
9
9
  import { patchRoleSectionText } from "./role-section.js";
10
+ import { validateMessageTaxonomy } from "./message-taxonomy.js";
10
11
  export const DEFAULT_CUBE_LIMITS = Object.freeze({
11
12
  maxCubesPerClient: 100,
12
13
  maxCubesTotal: 1_000,
@@ -60,11 +61,11 @@ export class CursorExpiredError extends Error {
60
61
  this.name = "CursorExpiredError";
61
62
  }
62
63
  }
63
- export class AttachConflictError extends Error {
64
- code = "ATTACH_CONFLICT";
64
+ export class AttachSessionRejectedError extends Error {
65
+ code = "SESSION_REJECTED";
65
66
  constructor() {
66
- super("The attach request conflicts with an existing attachment.");
67
- this.name = "AttachConflictError";
67
+ super("The saved session is not accepted for this seat.");
68
+ this.name = "AttachSessionRejectedError";
68
69
  }
69
70
  }
70
71
  export class StorageCapacityError extends Error {
@@ -150,9 +151,10 @@ export async function openStore(options) {
150
151
  throw new Error("SQLite page size is unavailable.");
151
152
  }
152
153
  const capacityGuard = new StorageCapacityGuard(storageLimits, capacityProbe, requiredInteger(pageRow, "page_size"));
154
+ const activityHub = new ActivityHub();
153
155
  const maintenance = new SqliteMaintenanceStore(database, clock);
156
+ const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
154
157
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
155
- const activityHub = new ActivityHub();
156
158
  return Object.freeze({
157
159
  forPrincipal: (principal) => {
158
160
  assertServerDerivedPrincipal(principal);
@@ -160,6 +162,7 @@ export async function openStore(options) {
160
162
  },
161
163
  maintenance,
162
164
  credentials,
165
+ liveness,
163
166
  diagnostics: () => diagnostics(database),
164
167
  close: () => database.close(),
165
168
  });
@@ -267,7 +270,8 @@ class SqliteScopedStore {
267
270
  listCubes() {
268
271
  const scope = this.#scope("read");
269
272
  const rows = this.#database.prepare(`
270
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
273
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
274
+ c.created_at, c.updated_at
271
275
  FROM cubes AS c
272
276
  WHERE ${scope.sql}
273
277
  ORDER BY c.id
@@ -278,25 +282,43 @@ class SqliteScopedStore {
278
282
  assertCanonicalUuid(cubeId, "Cube id");
279
283
  const scope = this.#scope("read");
280
284
  const row = this.#database.prepare(`
281
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
285
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
286
+ c.created_at, c.updated_at
282
287
  FROM cubes AS c
283
288
  WHERE c.id = ? AND ${scope.sql}
284
289
  `).get(cubeId, ...scope.parameters);
285
290
  return row === undefined ? null : cubeRecord(cubeRow(row));
286
291
  }
287
292
  updateDirective(cubeId, directive) {
293
+ this.updateCube(cubeId, { directive });
294
+ }
295
+ updateCube(cubeId, input) {
288
296
  assertCanonicalUuid(cubeId, "Cube id");
289
- validateDirective(directive);
297
+ if (input.directive === undefined && input.messageTaxonomy === undefined) {
298
+ throw new TypeError("At least one cube field is required.");
299
+ }
300
+ if (input.directive !== undefined)
301
+ validateDirective(input.directive);
302
+ const taxonomy = input.messageTaxonomy === undefined
303
+ ? undefined
304
+ : validateMessageTaxonomy(input.messageTaxonomy);
305
+ const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
290
306
  const scope = this.#scope("manage");
291
307
  this.#requireCube(cubeId, "manage");
292
- this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
308
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""));
293
309
  const result = this.#database.prepare(`
294
310
  UPDATE cubes AS c
295
- SET directive = ?, updated_at = ?
311
+ SET directive = COALESCE(?, directive),
312
+ message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
313
+ updated_at = ?
296
314
  WHERE c.id = ? AND ${scope.sql}
297
- `).run(directive, this.#now(), cubeId, ...scope.parameters);
315
+ `).run(input.directive ?? null, serializedTaxonomy === undefined ? 0 : 1, serializedTaxonomy ?? null, this.#now(), cubeId, ...scope.parameters);
298
316
  if (result.changes !== 1)
299
317
  throw new ScopedStoreError();
318
+ const cube = this.getCube(cubeId);
319
+ if (cube === null)
320
+ throw new ScopedStoreError();
321
+ return cube;
300
322
  }
301
323
  appendActivity(cubeId, message) {
302
324
  const entry = this.appendLog(cubeId, { message });
@@ -354,6 +376,7 @@ class SqliteScopedStore {
354
376
  if (value !== undefined && typeof value !== "boolean")
355
377
  throw new TypeError("Role flags must be boolean.");
356
378
  }
379
+ validateRoleClass(input.roleClass);
357
380
  const isDefault = input.isDefault ?? false;
358
381
  const isMandatory = input.isMandatory ?? false;
359
382
  const isHumanSeat = input.isHumanSeat ?? false;
@@ -379,8 +402,8 @@ class SqliteScopedStore {
379
402
  id, cube_id, name, short_description, detailed_description,
380
403
  is_default, is_mandatory, is_human_seat, can_broadcast,
381
404
  receives_all_direct, role_class, created_at
382
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'worker', ?)
383
- `).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), this.#now());
405
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
406
+ `).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker", this.#now());
384
407
  this.#mutationHook?.("role.insert");
385
408
  const row = this.#database.prepare(`
386
409
  SELECT id, cube_id, name, short_description, detailed_description,
@@ -425,6 +448,7 @@ class SqliteScopedStore {
425
448
  if (value !== undefined && typeof value !== "boolean")
426
449
  throw new TypeError("Role flags must be boolean.");
427
450
  }
451
+ validateRoleClass(input.roleClass);
428
452
  this.#requireCube(cubeId, "manage");
429
453
  this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
430
454
  Buffer.byteLength(input.detailedDescription ?? "") + 8_192);
@@ -458,9 +482,10 @@ class SqliteScopedStore {
458
482
  this.#database.prepare(`
459
483
  UPDATE roles SET
460
484
  name = ?, short_description = ?, detailed_description = ?, is_default = ?,
461
- is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?
485
+ is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
486
+ role_class = ?
462
487
  WHERE id = ? AND cube_id = ?
463
- `).run(input.name ?? existing.name, input.shortDescription ?? existing.short_description, nextDetailedDescription, booleanInteger(input.isDefault ?? existing.is_default), booleanInteger(input.isMandatory ?? existing.is_mandatory), booleanInteger(input.isHumanSeat ?? existing.is_human_seat), booleanInteger(input.canBroadcast ?? existing.can_broadcast), booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct), roleId, cubeId);
488
+ `).run(input.name ?? existing.name, input.shortDescription ?? existing.short_description, nextDetailedDescription, booleanInteger(input.isDefault ?? existing.is_default), booleanInteger(input.isMandatory ?? existing.is_mandatory), booleanInteger(input.isHumanSeat ?? existing.is_human_seat), booleanInteger(input.canBroadcast ?? existing.can_broadcast), booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct), input.roleClass ?? existing.role_class, roleId, cubeId);
464
489
  const row = this.#database.prepare(`
465
490
  SELECT id, cube_id, name, short_description, detailed_description,
466
491
  is_default, is_mandatory, is_human_seat, can_broadcast,
@@ -547,6 +572,49 @@ class SqliteScopedStore {
547
572
  `).all(cubeId);
548
573
  return rows.map(droneRecord);
549
574
  }
575
+ listDronesSince(cubeId, since) {
576
+ this.#requireCube(cubeId, "read");
577
+ let createdAt;
578
+ let anchorId = null;
579
+ if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
580
+ const anchor = this.#database.prepare("SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?").get(since, cubeId);
581
+ if (anchor === undefined)
582
+ throw new ScopedStoreError();
583
+ anchorId = requiredText(anchor, "id");
584
+ createdAt = requiredText(anchor, "created_at");
585
+ }
586
+ else {
587
+ validateTimestamp(since);
588
+ createdAt = since;
589
+ }
590
+ const rows = this.#database.prepare(`
591
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
592
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
593
+ drone.hostname, drone.created_at,
594
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
595
+ THEN 'participant' ELSE 'observer' END AS posture,
596
+ (SELECT MAX(post.created_at) FROM activity_log AS post
597
+ WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
598
+ EXISTS (SELECT 1 FROM activity_log AS response
599
+ WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
600
+ AND (response.created_at > ? OR
601
+ (? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
602
+ FROM drones AS drone
603
+ LEFT JOIN clients AS client ON client.id = drone.client_id
604
+ LEFT JOIN client_cube_grants AS grant_row
605
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
606
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
607
+ ORDER BY drone.label, drone.id
608
+ `).all(createdAt, anchorId, createdAt, anchorId, cubeId);
609
+ return {
610
+ since: createdAt,
611
+ drones: rows.map((row) => ({
612
+ ...droneRecord(row),
613
+ last_log_post_at: nullableText(row, "last_log_post_at"),
614
+ seen_since: requiredInteger(row, "seen_since") === 1,
615
+ })),
616
+ };
617
+ }
550
618
  appendLog(cubeId, input) {
551
619
  assertCanonicalUuid(cubeId, "Cube id");
552
620
  if (input.message.length === 0 || Buffer.byteLength(input.message) > 10_240) {
@@ -592,6 +660,12 @@ class SqliteScopedStore {
592
660
  `).run(id, droneId, this.#principal.kind, this.#principal.id, input.message, createdAt, visibility, cubeId, ...scope.parameters);
593
661
  if (inserted.changes !== 1)
594
662
  throw new ScopedStoreError();
663
+ if (droneId !== null) {
664
+ this.#database.prepare(`
665
+ UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
666
+ `).run(createdAt, droneId, cubeId);
667
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
668
+ }
595
669
  if (recipients.length > 0) {
596
670
  const valid = this.#database.prepare(`
597
671
  SELECT COUNT(*) AS count
@@ -669,18 +743,58 @@ class SqliteScopedStore {
669
743
  assertCanonicalUuid(entryId, "Activity entry id");
670
744
  if (kind !== "ack" && kind !== "claim")
671
745
  throw new Error("Unknown acknowledgement kind.");
672
- const exists = this.#database.prepare("SELECT 1 AS present FROM activity_log WHERE id = ? AND cube_id = ?").get(entryId, cubeId);
673
- if (exists === undefined)
746
+ const original = this.#database.prepare(`
747
+ SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
748
+ `).get(entryId, cubeId);
749
+ if (original === undefined)
674
750
  throw new ScopedStoreError();
675
751
  this.#capacityGuard.assertCanGrow(512);
676
752
  const claimant = this.#principal.kind === "drone-session"
677
753
  ? this.#principal.droneId
678
754
  : this.#principal.id;
679
- this.#database.prepare(`
755
+ const ackAt = this.#now();
756
+ const inserted = this.#database.prepare(`
680
757
  INSERT OR IGNORE INTO activity_acks (
681
758
  entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
682
759
  ) VALUES (?, ?, ?, ?, ?, ?)
683
- `).run(entryId, this.#principal.kind, this.#principal.id, kind, this.#now(), claimant);
760
+ `).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
761
+ if (inserted.changes !== 1 || this.#principal.kind !== "drone-session")
762
+ return;
763
+ const claimantDroneId = this.#principal.droneId;
764
+ const actor = this.#database.prepare(`
765
+ SELECT drone.label, role.name AS role_name
766
+ FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
767
+ WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
768
+ `).get(claimantDroneId, cubeId);
769
+ const preview = activityPreview(requiredText(original, "message"));
770
+ const originalAuthor = nullableText(original, "drone_id");
771
+ const recipients = kind === "ack"
772
+ ? originalAuthor === null ? [] : [originalAuthor]
773
+ : this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
774
+ .filter((droneId) => droneId !== claimantDroneId);
775
+ if (recipients.length === 0)
776
+ return;
777
+ const roleName = actor === undefined ? null : requiredText(actor, "role_name");
778
+ const notification = {
779
+ kind,
780
+ id: randomUUID(),
781
+ cube_id: cubeId,
782
+ drone_id: claimantDroneId,
783
+ drone_label: actor === undefined ? null : requiredText(actor, "label"),
784
+ role_name: roleName,
785
+ message: `[${kind.toUpperCase()}] ${preview}`,
786
+ created_at: ackAt,
787
+ visibility: "direct",
788
+ recipient_drone_ids: recipients,
789
+ log_entry_id: entryId,
790
+ ack_kind: kind,
791
+ ack_at: ackAt,
792
+ entry_preview: preview,
793
+ ...(kind === "ack"
794
+ ? { author_drone_id: recipients[0] }
795
+ : { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
796
+ };
797
+ this.#activityHub.publish(cubeId, notification);
684
798
  }
685
799
  recordDecision(cubeId, input) {
686
800
  this.#requireCube(cubeId, "manage");
@@ -730,7 +844,6 @@ class SqliteScopedStore {
730
844
  throw new ScopedStoreError();
731
845
  assertCanonicalUuid(input.cubeId, "Cube id");
732
846
  assertCanonicalUuid(input.roleId, "Role id");
733
- assertCanonicalUuid(input.retryKey, "Retry key");
734
847
  if (input.priorDroneId !== undefined)
735
848
  assertCanonicalUuid(input.priorDroneId, "Prior drone id");
736
849
  assertCanonicalUuid(input.droneId, "Drone id");
@@ -758,82 +871,95 @@ class SqliteScopedStore {
758
871
  `).get(input.cubeId, input.roleId, ...scope.parameters);
759
872
  if (roleRow === undefined)
760
873
  throw new ScopedStoreError();
761
- const posture = this.#allowsDirectedWork(input.cubeId) ? "participant" : "observer";
762
- const retryBinding = this.#database.prepare(`
763
- SELECT binding.cube_id AS binding_cube_id,
764
- binding.requested_role_id AS binding_requested_role_id,
765
- binding.prior_drone_id, drone.id, drone.label,
766
- drone.attach_generation, drone.evicted_at
767
- FROM seat_attach_bindings AS binding
768
- JOIN drones AS drone ON drone.id = binding.drone_id
769
- WHERE binding.client_id = ? AND binding.retry_key = ?
770
- `).get(this.#principal.id, input.retryKey);
874
+ const bound = this.#database.prepare(`
875
+ SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
876
+ session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
877
+ session.expires_at, session.revoked_at AS session_revoked_at,
878
+ drone.role_id, drone.label, drone.evicted_at
879
+ FROM drone_session_credentials AS credential
880
+ JOIN drone_sessions AS session ON session.id = credential.session_id
881
+ JOIN drones AS drone ON drone.id = session.drone_id
882
+ AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
883
+ WHERE credential.lookup_digest = ?
884
+ `).get(input.credentialDigest.lookup);
885
+ if (bound !== undefined) {
886
+ const verifier = requiredBuffer(bound, "verifier_digest");
887
+ 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
+ requiredText(bound, "client_id") !== this.#principal.id ||
893
+ requiredText(bound, "cube_id") !== input.cubeId ||
894
+ requiredText(bound, "role_id") !== input.roleId ||
895
+ (input.priorDroneId !== undefined &&
896
+ requiredText(bound, "drone_id") !== input.priorDroneId)) {
897
+ throw new AttachSessionRejectedError();
898
+ }
899
+ const droneId = requiredText(bound, "drone_id");
900
+ 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
+ const attachedRoleRow = this.#database.prepare(`
903
+ SELECT id AS role_id, name AS role_name, role_class, is_human_seat
904
+ FROM roles WHERE id = ? AND cube_id = ?
905
+ `).get(requiredText(bound, "role_id"), input.cubeId);
906
+ if (attachedRoleRow === undefined)
907
+ throw new Error("Attached drone role is unavailable.");
908
+ const roleClass = requiredText(attachedRoleRow, "role_class");
909
+ if (roleClass !== "queen" && roleClass !== "worker") {
910
+ throw new Error("Database contains invalid role class.");
911
+ }
912
+ this.#database.exec("COMMIT");
913
+ return {
914
+ cube: {
915
+ id: requiredText(roleRow, "cube_id"),
916
+ name: requiredText(roleRow, "cube_name"),
917
+ },
918
+ role: {
919
+ id: requiredText(attachedRoleRow, "role_id"),
920
+ name: requiredText(attachedRoleRow, "role_name"),
921
+ role_class: roleClass,
922
+ is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
923
+ },
924
+ drone: { id: droneId, label: requiredText(bound, "label") },
925
+ sessionId: requiredText(bound, "session_id"),
926
+ expiresAt: requiredText(bound, "expires_at"),
927
+ result: "reused",
928
+ };
929
+ }
930
+ 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
933
+ `).get(input.priorDroneId, this.#principal.id, input.cubeId);
771
934
  let droneId;
772
935
  let droneLabel;
773
- let reattached;
774
- let generation;
775
- if (retryBinding !== undefined) {
776
- if (optionalText(retryBinding, "evicted_at") != null)
777
- throw new ScopedStoreError();
778
- if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
779
- requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
780
- nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
781
- throw new AttachConflictError();
936
+ if (priorSeat !== undefined) {
937
+ const priorSeatId = requiredText(priorSeat, "id");
938
+ if (requiredText(priorSeat, "role_id") !== input.roleId) {
939
+ throw new AttachSessionRejectedError();
782
940
  }
783
- droneId = requiredText(retryBinding, "id");
784
- droneLabel = requiredText(retryBinding, "label");
785
- generation = requiredInteger(retryBinding, "attach_generation") + 1;
786
- this.#database.prepare(`
787
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
788
- `).run(now, generation, droneId);
789
- reattached = true;
941
+ const occupied = this.#database.prepare(`
942
+ SELECT 1 FROM drone_sessions AS session
943
+ JOIN drone_session_credentials AS credential ON credential.session_id = session.id
944
+ 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)
949
+ throw new AttachSessionRejectedError();
950
+ droneId = priorSeatId;
951
+ droneLabel = requiredText(priorSeat, "label");
952
+ this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
790
953
  }
791
954
  else {
792
- const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
793
- SELECT id, label, attach_generation
794
- FROM drones
795
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
796
- `).get(input.priorDroneId, this.#principal.id, input.cubeId);
797
- if (priorSeat !== undefined) {
798
- droneId = requiredText(priorSeat, "id");
799
- droneLabel = requiredText(priorSeat, "label");
800
- generation = requiredInteger(priorSeat, "attach_generation") + 1;
801
- this.#database.prepare(`
802
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
803
- `).run(now, generation, droneId);
804
- reattached = true;
805
- }
806
- else {
807
- droneId = input.droneId;
808
- droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
809
- this.#database.prepare(`
810
- INSERT INTO drones (
811
- id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
812
- attach_generation
813
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
814
- `).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now, input.retryKey);
815
- reattached = false;
816
- generation = 1;
817
- }
818
- this.#database.prepare(`
819
- INSERT INTO seat_attach_bindings (
820
- client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
821
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
822
- `).run(this.#principal.id, input.retryKey, input.cubeId, input.roleId, droneId, input.priorDroneId ?? null, now);
823
- }
824
- const oldSessions = this.#database.prepare("SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?").all(droneId, this.#principal.id, input.cubeId)
825
- .map((row) => requiredText(row, "id"));
826
- if (oldSessions.length > 0) {
827
- const placeholders = oldSessions.map(() => "?").join(", ");
828
- this.#database.prepare(`
829
- UPDATE drone_session_credentials SET revoked_at = ?
830
- WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
831
- `).run(now, ...oldSessions);
955
+ droneId = input.droneId;
956
+ droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
832
957
  this.#database.prepare(`
833
- UPDATE drone_sessions SET revoked_at = ?
834
- WHERE id IN (${placeholders}) AND revoked_at IS NULL
835
- `).run(now, ...oldSessions);
958
+ INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
959
+ VALUES (?, ?, ?, ?, ?, ?, ?)
960
+ `).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now);
836
961
  }
962
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
837
963
  this.#database.prepare(`
838
964
  INSERT INTO drone_sessions (
839
965
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -869,10 +995,7 @@ class SqliteScopedStore {
869
995
  drone: { id: droneId, label: droneLabel },
870
996
  sessionId: input.sessionId,
871
997
  expiresAt: input.expiresAt,
872
- generation,
873
- posture,
874
- reattached,
875
- revokedSessionIds: oldSessions,
998
+ result: "created",
876
999
  };
877
1000
  }
878
1001
  catch (error) {
@@ -886,10 +1009,41 @@ class SqliteScopedStore {
886
1009
  subscribeActivity(cubeId, listener) {
887
1010
  this.#requireCube(cubeId, "read");
888
1011
  return this.#activityHub.subscribe(cubeId, (entry) => {
1012
+ if ("kind" in entry) {
1013
+ if (this.#principal.kind === "drone-session" &&
1014
+ entry.recipient_drone_ids.includes(this.#principal.droneId))
1015
+ listener(entry);
1016
+ return;
1017
+ }
889
1018
  if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId))
890
1019
  listener(entry);
891
1020
  });
892
1021
  }
1022
+ #claimAudience(cubeId, entryId, visibility) {
1023
+ if (visibility === "direct") {
1024
+ return this.#database.prepare(`
1025
+ SELECT recipient.drone_id
1026
+ FROM activity_log_recipients AS recipient
1027
+ JOIN drones AS drone ON drone.id = recipient.drone_id
1028
+ JOIN clients AS client ON client.id = drone.client_id
1029
+ JOIN client_cube_grants AS grant_row
1030
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1031
+ WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
1032
+ AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1033
+ ORDER BY recipient.drone_id
1034
+ `).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
1035
+ }
1036
+ return this.#database.prepare(`
1037
+ SELECT drone.id AS drone_id
1038
+ FROM drones AS drone
1039
+ JOIN clients AS client ON client.id = drone.client_id
1040
+ JOIN client_cube_grants AS grant_row
1041
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1042
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
1043
+ AND grant_row.access IN ('write', 'manage')
1044
+ ORDER BY drone.id
1045
+ `).all(cubeId).map((row) => requiredText(row, "drone_id"));
1046
+ }
893
1047
  #allowsDirectedWork(cubeId) {
894
1048
  const scope = this.#scope("write");
895
1049
  return this.#database.prepare(`
@@ -902,8 +1056,17 @@ class SqliteScopedStore {
902
1056
  const row = this.#database.prepare(`
903
1057
  SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
904
1058
  `).get(cubeId, ...scope.parameters);
905
- if (row === undefined)
906
- throw new ScopedStoreError();
1059
+ if (row !== undefined)
1060
+ return;
1061
+ if (access === "manage") {
1062
+ const visibleScope = this.#scope("read");
1063
+ const visible = this.#database.prepare(`
1064
+ SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
1065
+ `).get(cubeId, ...visibleScope.parameters);
1066
+ if (visible !== undefined)
1067
+ throw new AccessDeniedError();
1068
+ }
1069
+ throw new ScopedStoreError();
907
1070
  }
908
1071
  #nextActivityTimestamp(cubeId) {
909
1072
  const now = this.#clock().getTime();
@@ -1132,7 +1295,7 @@ class SqliteMaintenanceStore {
1132
1295
  for (const table of [
1133
1296
  "cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
1134
1297
  "decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
1135
- "seat_attach_bindings", "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1298
+ "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1136
1299
  "enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
1137
1300
  "enrollment_invitations",
1138
1301
  ])
@@ -1275,6 +1438,70 @@ class ActivityHub {
1275
1438
  }
1276
1439
  }
1277
1440
  }
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;
1503
+ }
1504
+ }
1278
1505
  class SqliteCredentialStore {
1279
1506
  #database;
1280
1507
  #clock;
@@ -1566,9 +1793,8 @@ class SqliteCredentialStore {
1566
1793
  SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
1567
1794
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
1568
1795
  session.expires_at,
1569
- COALESCE(
1570
- credential.revoked_at, session.revoked_at, client.revoked_at, drone.evicted_at
1571
- ) AS revoked_at
1796
+ COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
1797
+ drone.evicted_at
1572
1798
  FROM drone_session_credentials AS credential
1573
1799
  JOIN drone_sessions AS session ON session.id = credential.session_id
1574
1800
  JOIN clients AS client ON client.id = session.client_id
@@ -1579,6 +1805,20 @@ class SqliteCredentialStore {
1579
1805
  `).get(lookup);
1580
1806
  return row === undefined ? null : storedDroneSessionDigest(row);
1581
1807
  }
1808
+ findActiveDroneSessionExpiry(sessionId) {
1809
+ assertCanonicalUuid(sessionId, "Drone session id");
1810
+ const row = this.#database.prepare(`
1811
+ SELECT session.expires_at
1812
+ FROM drone_sessions AS session
1813
+ JOIN clients AS client ON client.id = session.client_id
1814
+ JOIN drones AS drone ON drone.id = session.drone_id
1815
+ AND drone.client_id = session.client_id
1816
+ AND drone.cube_id = session.cube_id
1817
+ WHERE session.id = ? AND session.revoked_at IS NULL
1818
+ AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
1819
+ `).get(sessionId);
1820
+ return row === undefined ? null : requiredText(row, "expires_at");
1821
+ }
1582
1822
  rotateClientCredential(input) {
1583
1823
  assertCanonicalUuid(input.clientId, "Client id");
1584
1824
  assertCanonicalUuid(input.credentialId, "Credential id");
@@ -1594,6 +1834,16 @@ class SqliteCredentialStore {
1594
1834
  this.#database.prepare(`
1595
1835
  UPDATE client_credentials SET revoked_at = ?
1596
1836
  WHERE client_id = ? AND revoked_at IS NULL
1837
+ `).run(now, input.clientId);
1838
+ this.#database.prepare(`
1839
+ UPDATE drone_session_credentials SET revoked_at = ?
1840
+ WHERE revoked_at IS NULL AND session_id IN (
1841
+ SELECT id FROM drone_sessions WHERE client_id = ?
1842
+ )
1843
+ `).run(now, input.clientId);
1844
+ this.#database.prepare(`
1845
+ UPDATE drone_sessions SET revoked_at = ?
1846
+ WHERE client_id = ? AND revoked_at IS NULL
1597
1847
  `).run(now, input.clientId);
1598
1848
  this.#database.prepare(`
1599
1849
  INSERT INTO client_credentials (
@@ -1752,6 +2002,7 @@ function cubeRecord(row) {
1752
2002
  ownerId: row.owner_id,
1753
2003
  name: row.name,
1754
2004
  directive: row.directive,
2005
+ messageTaxonomy: parseTaxonomy(row.message_taxonomy),
1755
2006
  createdAt: row.created_at,
1756
2007
  updatedAt: row.updated_at,
1757
2008
  };
@@ -1773,6 +2024,7 @@ function cubeRow(row) {
1773
2024
  owner_id: requiredText(row, "owner_id"),
1774
2025
  name: requiredText(row, "name"),
1775
2026
  directive: requiredText(row, "directive"),
2027
+ message_taxonomy: nullableText(row, "message_taxonomy"),
1776
2028
  created_at: requiredText(row, "created_at"),
1777
2029
  updated_at: requiredText(row, "updated_at"),
1778
2030
  };
@@ -1972,6 +2224,7 @@ function storedDroneSessionDigest(row) {
1972
2224
  cubeId: requiredText(row, "cube_id"),
1973
2225
  droneId: requiredText(row, "drone_id"),
1974
2226
  expiresAt: requiredText(row, "expires_at"),
2227
+ evictedAt: nullableText(row, "evicted_at"),
1975
2228
  };
1976
2229
  }
1977
2230
  function requiredBuffer(row, key) {
@@ -2050,6 +2303,33 @@ function validateDirective(value) {
2050
2303
  throw new Error("Cube directive exceeds 100000 bytes.");
2051
2304
  }
2052
2305
  }
2306
+ function activityPreview(message) {
2307
+ return message.replace(/\s+/gu, " ").trim().slice(0, 200);
2308
+ }
2309
+ function validateRoleClass(value) {
2310
+ if (value !== undefined && value !== "queen" && value !== "worker") {
2311
+ throw new TypeError("Role class must be queen or worker.");
2312
+ }
2313
+ }
2314
+ function serializeTaxonomy(value) {
2315
+ if (value === null)
2316
+ return null;
2317
+ const serialized = JSON.stringify(value);
2318
+ if (Buffer.byteLength(serialized) > 100_000) {
2319
+ throw new RangeError("Message taxonomy exceeds 100000 bytes.");
2320
+ }
2321
+ return serialized;
2322
+ }
2323
+ function parseTaxonomy(value) {
2324
+ if (value === null)
2325
+ return null;
2326
+ try {
2327
+ return validateMessageTaxonomy(JSON.parse(value));
2328
+ }
2329
+ catch {
2330
+ throw new Error("Database contains invalid message taxonomy.");
2331
+ }
2332
+ }
2053
2333
  function validateDigest(digest) {
2054
2334
  validateLookup(digest.lookup);
2055
2335
  if (digest.verifier.length !== 32)