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/src/store.ts CHANGED
@@ -1,8 +1,9 @@
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";
5
5
  import { DatabaseSync } from "node:sqlite";
6
+ import type { MessageTaxonomy } from "borgmcp-shared/domain";
6
7
 
7
8
  import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
8
9
  import { operatorErrors } from "./operator-error.js";
@@ -12,6 +13,7 @@ import {
12
13
  type Principal,
13
14
  } from "./principal.js";
14
15
  import { patchRoleSectionText, type RoleSectionPatchOp } from "./role-section.js";
16
+ import { validateMessageTaxonomy } from "./message-taxonomy.js";
15
17
 
16
18
  export type CubeAccess = "read" | "write" | "manage";
17
19
 
@@ -20,6 +22,7 @@ export interface CubeRecord {
20
22
  readonly ownerId: string;
21
23
  readonly name: string;
22
24
  readonly directive: string;
25
+ readonly messageTaxonomy: MessageTaxonomy | null;
23
26
  readonly createdAt: string;
24
27
  readonly updatedAt: string;
25
28
  }
@@ -51,6 +54,22 @@ export interface EnrichedActivityRecord {
51
54
  readonly recipient_drone_ids: string[];
52
55
  }
53
56
 
57
+ export type ActivityNotificationRecord = EnrichedActivityRecord & (
58
+ | {
59
+ readonly kind: "ack" | "claim";
60
+ readonly log_entry_id: string;
61
+ readonly ack_kind: "ack" | "claim";
62
+ readonly ack_at: string;
63
+ readonly entry_preview: string;
64
+ readonly author_drone_id?: string;
65
+ readonly claimant_drone_id?: string;
66
+ readonly claimant_role?: string | null;
67
+ }
68
+ | { readonly kind: "heartbeat_ping" }
69
+ );
70
+
71
+ export type ActivityStreamRecord = EnrichedActivityRecord | ActivityNotificationRecord;
72
+
54
73
  export interface ClaimRecord {
55
74
  readonly log_entry_id: string;
56
75
  readonly claimant_drone_id: string;
@@ -104,6 +123,8 @@ export interface DroneRecord {
104
123
  readonly hostname: string | null;
105
124
  readonly posture: "observer" | "participant";
106
125
  readonly created_at: string;
126
+ readonly last_log_post_at?: string | null;
127
+ readonly seen_since?: boolean;
107
128
  }
108
129
 
109
130
  export interface StoreDiagnostics {
@@ -153,10 +174,19 @@ export interface StoreRuntime {
153
174
  readonly forPrincipal: (principal: Principal) => ScopedStore;
154
175
  readonly maintenance: MaintenanceStore;
155
176
  readonly credentials: CredentialStore;
177
+ readonly liveness: LivenessStore;
156
178
  readonly diagnostics: () => StoreDiagnostics;
157
179
  readonly close: () => void;
158
180
  }
159
181
 
182
+ export interface LivenessStore {
183
+ readonly scan: (options?: {
184
+ readonly silentMs?: number;
185
+ readonly cooldownMs?: number;
186
+ readonly limit?: number;
187
+ }) => ActivityNotificationRecord[];
188
+ }
189
+
160
190
  export interface DigestPair {
161
191
  readonly lookup: Buffer;
162
192
  readonly verifier: Buffer;
@@ -206,6 +236,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
206
236
  readonly cubeId: string;
207
237
  readonly droneId: string;
208
238
  readonly expiresAt: string;
239
+ readonly evictedAt: string | null;
209
240
  }
210
241
 
211
242
  export interface CredentialStore {
@@ -232,6 +263,7 @@ export interface CredentialStore {
232
263
  readonly clientExists: (clientId: string) => boolean;
233
264
  readonly clientIsActive: (clientId: string) => boolean;
234
265
  readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
266
+ readonly findActiveDroneSessionExpiry: (sessionId: string) => string | null;
235
267
  readonly rotateClientCredential: (input: {
236
268
  readonly clientId: string;
237
269
  readonly credentialId: string;
@@ -244,6 +276,7 @@ export interface ScopedStore {
244
276
  readonly createCube: (input: CreateCubeInput) => CreateCubeRecord;
245
277
  readonly listCubes: () => CubeRecord[];
246
278
  readonly getCube: (cubeId: string) => CubeRecord | null;
279
+ readonly updateCube: (cubeId: string, input: UpdateCubeInput) => CubeRecord;
247
280
  readonly updateDirective: (cubeId: string, directive: string) => void;
248
281
  readonly appendActivity: (cubeId: string, message: string) => ActivityRecord;
249
282
  readonly readActivity: (cubeId: string, limit: number) => ActivityRecord[];
@@ -252,6 +285,10 @@ export interface ScopedStore {
252
285
  readonly updateRole: (cubeId: string, roleId: string, input: UpdateRoleInput) => RoleRecord;
253
286
  readonly patchRoleSection: (cubeId: string, roleId: string, input: RoleSectionPatchOp) => RoleRecord;
254
287
  readonly listDrones: (cubeId: string) => DroneRecord[];
288
+ readonly listDronesSince: (cubeId: string, since: string) => {
289
+ readonly drones: DroneRecord[];
290
+ readonly since: string;
291
+ };
255
292
  readonly appendLog: (cubeId: string, input: {
256
293
  readonly message: string;
257
294
  readonly visibility?: "broadcast" | "direct";
@@ -267,7 +304,7 @@ export interface ScopedStore {
267
304
  readonly listDecisions: (cubeId: string) => DecisionRecord[];
268
305
  readonly subscribeActivity: (
269
306
  cubeId: string,
270
- listener: (entry: EnrichedActivityRecord) => void,
307
+ listener: (entry: ActivityStreamRecord) => void,
271
308
  ) => (() => void);
272
309
  readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
273
310
  }
@@ -281,6 +318,7 @@ export interface CreateRoleInput {
281
318
  readonly isHumanSeat?: boolean;
282
319
  readonly canBroadcast?: boolean;
283
320
  readonly receivesAllDirect?: boolean;
321
+ readonly roleClass?: "queen" | "worker";
284
322
  }
285
323
 
286
324
  export interface UpdateRoleInput {
@@ -292,6 +330,12 @@ export interface UpdateRoleInput {
292
330
  readonly isHumanSeat?: boolean;
293
331
  readonly canBroadcast?: boolean;
294
332
  readonly receivesAllDirect?: boolean;
333
+ readonly roleClass?: "queen" | "worker";
334
+ }
335
+
336
+ export interface UpdateCubeInput {
337
+ readonly directive?: string;
338
+ readonly messageTaxonomy?: MessageTaxonomy | null;
295
339
  }
296
340
 
297
341
  export interface CreateCubeInput {
@@ -310,7 +354,6 @@ export interface CreateCubeRecord {
310
354
  export interface SeatAttachInput {
311
355
  readonly cubeId: string;
312
356
  readonly roleId: string;
313
- readonly retryKey: string;
314
357
  readonly priorDroneId?: string;
315
358
  readonly droneId: string;
316
359
  readonly sessionId: string;
@@ -336,10 +379,7 @@ export interface SeatAttachRecord {
336
379
  };
337
380
  readonly sessionId: string;
338
381
  readonly expiresAt: string;
339
- readonly generation: number;
340
- readonly posture: "observer" | "participant";
341
- readonly reattached: boolean;
342
- readonly revokedSessionIds: readonly string[];
382
+ readonly result: "created" | "reused";
343
383
  }
344
384
 
345
385
  export interface MaintenanceStore {
@@ -449,12 +489,12 @@ export class CursorExpiredError extends Error {
449
489
  }
450
490
  }
451
491
 
452
- export class AttachConflictError extends Error {
453
- readonly code = "ATTACH_CONFLICT";
492
+ export class AttachSessionRejectedError extends Error {
493
+ readonly code = "SESSION_REJECTED";
454
494
 
455
495
  constructor() {
456
- super("The attach request conflicts with an existing attachment.");
457
- this.name = "AttachConflictError";
496
+ super("The saved session is not accepted for this seat.");
497
+ this.name = "AttachSessionRejectedError";
458
498
  }
459
499
  }
460
500
 
@@ -482,6 +522,7 @@ interface CubeRow {
482
522
  readonly owner_id: string;
483
523
  readonly name: string;
484
524
  readonly directive: string;
525
+ readonly message_taxonomy: string | null;
485
526
  readonly created_at: string;
486
527
  readonly updated_at: string;
487
528
  }
@@ -573,9 +614,10 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
573
614
  capacityProbe,
574
615
  requiredInteger(pageRow, "page_size"),
575
616
  );
617
+ const activityHub = new ActivityHub();
576
618
  const maintenance = new SqliteMaintenanceStore(database, clock);
619
+ const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
577
620
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
578
- const activityHub = new ActivityHub();
579
621
  return Object.freeze({
580
622
  forPrincipal: (principal: Principal) => {
581
623
  assertServerDerivedPrincipal(principal);
@@ -592,6 +634,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
592
634
  },
593
635
  maintenance,
594
636
  credentials,
637
+ liveness,
595
638
  diagnostics: () => diagnostics(database),
596
639
  close: () => database.close(),
597
640
  });
@@ -712,7 +755,8 @@ class SqliteScopedStore implements ScopedStore {
712
755
  listCubes(): CubeRecord[] {
713
756
  const scope = this.#scope("read");
714
757
  const rows = this.#database.prepare(`
715
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
758
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
759
+ c.created_at, c.updated_at
716
760
  FROM cubes AS c
717
761
  WHERE ${scope.sql}
718
762
  ORDER BY c.id
@@ -724,7 +768,8 @@ class SqliteScopedStore implements ScopedStore {
724
768
  assertCanonicalUuid(cubeId, "Cube id");
725
769
  const scope = this.#scope("read");
726
770
  const row = this.#database.prepare(`
727
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
771
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
772
+ c.created_at, c.updated_at
728
773
  FROM cubes AS c
729
774
  WHERE c.id = ? AND ${scope.sql}
730
775
  `).get(cubeId, ...scope.parameters);
@@ -732,17 +777,42 @@ class SqliteScopedStore implements ScopedStore {
732
777
  }
733
778
 
734
779
  updateDirective(cubeId: string, directive: string): void {
780
+ this.updateCube(cubeId, { directive });
781
+ }
782
+
783
+ updateCube(cubeId: string, input: UpdateCubeInput): CubeRecord {
735
784
  assertCanonicalUuid(cubeId, "Cube id");
736
- validateDirective(directive);
785
+ if (input.directive === undefined && input.messageTaxonomy === undefined) {
786
+ throw new TypeError("At least one cube field is required.");
787
+ }
788
+ if (input.directive !== undefined) validateDirective(input.directive);
789
+ const taxonomy = input.messageTaxonomy === undefined
790
+ ? undefined
791
+ : validateMessageTaxonomy(input.messageTaxonomy);
792
+ const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
737
793
  const scope = this.#scope("manage");
738
794
  this.#requireCube(cubeId, "manage");
739
- this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
795
+ this.#capacityGuard.assertCanGrow(
796
+ Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""),
797
+ );
740
798
  const result = this.#database.prepare(`
741
799
  UPDATE cubes AS c
742
- SET directive = ?, updated_at = ?
800
+ SET directive = COALESCE(?, directive),
801
+ message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
802
+ updated_at = ?
743
803
  WHERE c.id = ? AND ${scope.sql}
744
- `).run(directive, this.#now(), cubeId, ...scope.parameters);
804
+ `).run(
805
+ input.directive ?? null,
806
+ serializedTaxonomy === undefined ? 0 : 1,
807
+ serializedTaxonomy ?? null,
808
+ this.#now(),
809
+ cubeId,
810
+ ...scope.parameters,
811
+ );
745
812
  if (result.changes !== 1) throw new ScopedStoreError();
813
+ const cube = this.getCube(cubeId);
814
+ if (cube === null) throw new ScopedStoreError();
815
+ return cube;
746
816
  }
747
817
 
748
818
  appendActivity(cubeId: string, message: string): ActivityRecord {
@@ -803,6 +873,7 @@ class SqliteScopedStore implements ScopedStore {
803
873
  ]) {
804
874
  if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
805
875
  }
876
+ validateRoleClass(input.roleClass);
806
877
  const isDefault = input.isDefault ?? false;
807
878
  const isMandatory = input.isMandatory ?? false;
808
879
  const isHumanSeat = input.isHumanSeat ?? false;
@@ -832,11 +903,12 @@ class SqliteScopedStore implements ScopedStore {
832
903
  id, cube_id, name, short_description, detailed_description,
833
904
  is_default, is_mandatory, is_human_seat, can_broadcast,
834
905
  receives_all_direct, role_class, created_at
835
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'worker', ?)
906
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
836
907
  `).run(
837
908
  id, cubeId, input.name, shortDescription, detailedDescription,
838
909
  booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat),
839
- booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), this.#now(),
910
+ booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker",
911
+ this.#now(),
840
912
  );
841
913
  this.#mutationHook?.("role.insert");
842
914
  const row = this.#database.prepare(`
@@ -875,6 +947,7 @@ class SqliteScopedStore implements ScopedStore {
875
947
  ]) {
876
948
  if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
877
949
  }
950
+ validateRoleClass(input.roleClass);
878
951
  this.#requireCube(cubeId, "manage");
879
952
  this.#capacityGuard.assertCanGrow(
880
953
  Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
@@ -911,7 +984,8 @@ class SqliteScopedStore implements ScopedStore {
911
984
  this.#database.prepare(`
912
985
  UPDATE roles SET
913
986
  name = ?, short_description = ?, detailed_description = ?, is_default = ?,
914
- is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?
987
+ is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
988
+ role_class = ?
915
989
  WHERE id = ? AND cube_id = ?
916
990
  `).run(
917
991
  input.name ?? existing.name,
@@ -922,6 +996,7 @@ class SqliteScopedStore implements ScopedStore {
922
996
  booleanInteger(input.isHumanSeat ?? existing.is_human_seat),
923
997
  booleanInteger(input.canBroadcast ?? existing.can_broadcast),
924
998
  booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct),
999
+ input.roleClass ?? existing.role_class,
925
1000
  roleId,
926
1001
  cubeId,
927
1002
  );
@@ -1006,6 +1081,50 @@ class SqliteScopedStore implements ScopedStore {
1006
1081
  return rows.map(droneRecord);
1007
1082
  }
1008
1083
 
1084
+ listDronesSince(cubeId: string, since: string): { drones: DroneRecord[]; since: string } {
1085
+ this.#requireCube(cubeId, "read");
1086
+ let createdAt: string;
1087
+ let anchorId: string | null = null;
1088
+ if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
1089
+ const anchor = this.#database.prepare(
1090
+ "SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?",
1091
+ ).get(since, cubeId);
1092
+ if (anchor === undefined) throw new ScopedStoreError();
1093
+ anchorId = requiredText(anchor, "id");
1094
+ createdAt = requiredText(anchor, "created_at");
1095
+ } else {
1096
+ validateTimestamp(since);
1097
+ createdAt = since;
1098
+ }
1099
+ const rows = this.#database.prepare(`
1100
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
1101
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
1102
+ drone.hostname, drone.created_at,
1103
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1104
+ THEN 'participant' ELSE 'observer' END AS posture,
1105
+ (SELECT MAX(post.created_at) FROM activity_log AS post
1106
+ WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
1107
+ EXISTS (SELECT 1 FROM activity_log AS response
1108
+ WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
1109
+ AND (response.created_at > ? OR
1110
+ (? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
1111
+ FROM drones AS drone
1112
+ LEFT JOIN clients AS client ON client.id = drone.client_id
1113
+ LEFT JOIN client_cube_grants AS grant_row
1114
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1115
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
1116
+ ORDER BY drone.label, drone.id
1117
+ `).all(createdAt, anchorId, createdAt, anchorId, cubeId);
1118
+ return {
1119
+ since: createdAt,
1120
+ drones: rows.map((row) => ({
1121
+ ...droneRecord(row),
1122
+ last_log_post_at: nullableText(row, "last_log_post_at"),
1123
+ seen_since: requiredInteger(row, "seen_since") === 1,
1124
+ })),
1125
+ };
1126
+ }
1127
+
1009
1128
  appendLog(cubeId: string, input: {
1010
1129
  readonly message: string;
1011
1130
  readonly visibility?: "broadcast" | "direct";
@@ -1052,6 +1171,12 @@ class SqliteScopedStore implements ScopedStore {
1052
1171
  createdAt, visibility, cubeId, ...scope.parameters,
1053
1172
  );
1054
1173
  if (inserted.changes !== 1) throw new ScopedStoreError();
1174
+ if (droneId !== null) {
1175
+ this.#database.prepare(`
1176
+ UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
1177
+ `).run(createdAt, droneId, cubeId);
1178
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1179
+ }
1055
1180
  if (recipients.length > 0) {
1056
1181
  const valid = this.#database.prepare(`
1057
1182
  SELECT COUNT(*) AS count
@@ -1125,19 +1250,56 @@ class SqliteScopedStore implements ScopedStore {
1125
1250
  this.#requireCube(cubeId, "write");
1126
1251
  assertCanonicalUuid(entryId, "Activity entry id");
1127
1252
  if (kind !== "ack" && kind !== "claim") throw new Error("Unknown acknowledgement kind.");
1128
- const exists = this.#database.prepare(
1129
- "SELECT 1 AS present FROM activity_log WHERE id = ? AND cube_id = ?",
1130
- ).get(entryId, cubeId);
1131
- if (exists === undefined) throw new ScopedStoreError();
1253
+ const original = this.#database.prepare(`
1254
+ SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
1255
+ `).get(entryId, cubeId);
1256
+ if (original === undefined) throw new ScopedStoreError();
1132
1257
  this.#capacityGuard.assertCanGrow(512);
1133
1258
  const claimant = this.#principal.kind === "drone-session"
1134
1259
  ? this.#principal.droneId
1135
1260
  : this.#principal.id;
1136
- this.#database.prepare(`
1261
+ const ackAt = this.#now();
1262
+ const inserted = this.#database.prepare(`
1137
1263
  INSERT OR IGNORE INTO activity_acks (
1138
1264
  entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
1139
1265
  ) VALUES (?, ?, ?, ?, ?, ?)
1140
- `).run(entryId, this.#principal.kind, this.#principal.id, kind, this.#now(), claimant);
1266
+ `).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
1267
+ if (inserted.changes !== 1 || this.#principal.kind !== "drone-session") return;
1268
+ const claimantDroneId = this.#principal.droneId;
1269
+
1270
+ const actor = this.#database.prepare(`
1271
+ SELECT drone.label, role.name AS role_name
1272
+ FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
1273
+ WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
1274
+ `).get(claimantDroneId, cubeId);
1275
+ const preview = activityPreview(requiredText(original, "message"));
1276
+ const originalAuthor = nullableText(original, "drone_id");
1277
+ const recipients = kind === "ack"
1278
+ ? originalAuthor === null ? [] : [originalAuthor]
1279
+ : this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
1280
+ .filter((droneId) => droneId !== claimantDroneId);
1281
+ if (recipients.length === 0) return;
1282
+ const roleName = actor === undefined ? null : requiredText(actor, "role_name");
1283
+ const notification: ActivityNotificationRecord = {
1284
+ kind,
1285
+ id: randomUUID(),
1286
+ cube_id: cubeId,
1287
+ drone_id: claimantDroneId,
1288
+ drone_label: actor === undefined ? null : requiredText(actor, "label"),
1289
+ role_name: roleName,
1290
+ message: `[${kind.toUpperCase()}] ${preview}`,
1291
+ created_at: ackAt,
1292
+ visibility: "direct",
1293
+ recipient_drone_ids: recipients,
1294
+ log_entry_id: entryId,
1295
+ ack_kind: kind,
1296
+ ack_at: ackAt,
1297
+ entry_preview: preview,
1298
+ ...(kind === "ack"
1299
+ ? { author_drone_id: recipients[0]! }
1300
+ : { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
1301
+ };
1302
+ this.#activityHub.publish(cubeId, notification);
1141
1303
  }
1142
1304
 
1143
1305
  recordDecision(cubeId: string, input: {
@@ -1194,7 +1356,6 @@ class SqliteScopedStore implements ScopedStore {
1194
1356
  if (this.#principal.kind !== "client") throw new ScopedStoreError();
1195
1357
  assertCanonicalUuid(input.cubeId, "Cube id");
1196
1358
  assertCanonicalUuid(input.roleId, "Role id");
1197
- assertCanonicalUuid(input.retryKey, "Retry key");
1198
1359
  if (input.priorDroneId !== undefined) assertCanonicalUuid(input.priorDroneId, "Prior drone id");
1199
1360
  assertCanonicalUuid(input.droneId, "Drone id");
1200
1361
  assertCanonicalUuid(input.sessionId, "Drone session id");
@@ -1219,95 +1380,96 @@ class SqliteScopedStore implements ScopedStore {
1219
1380
  WHERE c.id = ? AND role.id = ? AND ${scope.sql}
1220
1381
  `).get(input.cubeId, input.roleId, ...scope.parameters);
1221
1382
  if (roleRow === undefined) throw new ScopedStoreError();
1222
- const posture = this.#allowsDirectedWork(input.cubeId) ? "participant" : "observer";
1223
-
1224
- const retryBinding = this.#database.prepare(`
1225
- SELECT binding.cube_id AS binding_cube_id,
1226
- binding.requested_role_id AS binding_requested_role_id,
1227
- binding.prior_drone_id, drone.id, drone.label,
1228
- drone.attach_generation, drone.evicted_at
1229
- FROM seat_attach_bindings AS binding
1230
- JOIN drones AS drone ON drone.id = binding.drone_id
1231
- WHERE binding.client_id = ? AND binding.retry_key = ?
1232
- `).get(this.#principal.id, input.retryKey);
1383
+ const bound = this.#database.prepare(`
1384
+ SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
1385
+ session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
1386
+ session.expires_at, session.revoked_at AS session_revoked_at,
1387
+ drone.role_id, drone.label, drone.evicted_at
1388
+ FROM drone_session_credentials AS credential
1389
+ JOIN drone_sessions AS session ON session.id = credential.session_id
1390
+ JOIN drones AS drone ON drone.id = session.drone_id
1391
+ AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
1392
+ WHERE credential.lookup_digest = ?
1393
+ `).get(input.credentialDigest.lookup);
1394
+ if (bound !== undefined) {
1395
+ const verifier = requiredBuffer(bound, "verifier_digest");
1396
+ if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
1397
+ optionalText(bound, "credential_revoked_at") !== null ||
1398
+ optionalText(bound, "session_revoked_at") !== null ||
1399
+ optionalText(bound, "evicted_at") !== null ||
1400
+ requiredText(bound, "expires_at") <= now ||
1401
+ requiredText(bound, "client_id") !== this.#principal.id ||
1402
+ requiredText(bound, "cube_id") !== input.cubeId ||
1403
+ requiredText(bound, "role_id") !== input.roleId ||
1404
+ (input.priorDroneId !== undefined &&
1405
+ requiredText(bound, "drone_id") !== input.priorDroneId)) {
1406
+ throw new AttachSessionRejectedError();
1407
+ }
1408
+ const droneId = requiredText(bound, "drone_id");
1409
+ this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1410
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1411
+ const attachedRoleRow = this.#database.prepare(`
1412
+ SELECT id AS role_id, name AS role_name, role_class, is_human_seat
1413
+ FROM roles WHERE id = ? AND cube_id = ?
1414
+ `).get(requiredText(bound, "role_id"), input.cubeId);
1415
+ if (attachedRoleRow === undefined) throw new Error("Attached drone role is unavailable.");
1416
+ const roleClass = requiredText(attachedRoleRow, "role_class");
1417
+ if (roleClass !== "queen" && roleClass !== "worker") {
1418
+ throw new Error("Database contains invalid role class.");
1419
+ }
1420
+ this.#database.exec("COMMIT");
1421
+ return {
1422
+ cube: {
1423
+ id: requiredText(roleRow, "cube_id"),
1424
+ name: requiredText(roleRow, "cube_name"),
1425
+ },
1426
+ role: {
1427
+ id: requiredText(attachedRoleRow, "role_id"),
1428
+ name: requiredText(attachedRoleRow, "role_name"),
1429
+ role_class: roleClass,
1430
+ is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
1431
+ },
1432
+ drone: { id: droneId, label: requiredText(bound, "label") },
1433
+ sessionId: requiredText(bound, "session_id"),
1434
+ expiresAt: requiredText(bound, "expires_at"),
1435
+ result: "reused",
1436
+ };
1437
+ }
1438
+
1439
+ const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
1440
+ SELECT id, role_id, label FROM drones
1441
+ WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
1442
+ `).get(input.priorDroneId, this.#principal.id, input.cubeId);
1233
1443
  let droneId: string;
1234
1444
  let droneLabel: string;
1235
- let reattached: boolean;
1236
- let generation: number;
1237
- if (retryBinding !== undefined) {
1238
- if (optionalText(retryBinding, "evicted_at") != null) throw new ScopedStoreError();
1239
- if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
1240
- requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
1241
- nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
1242
- throw new AttachConflictError();
1445
+ if (priorSeat !== undefined) {
1446
+ const priorSeatId = requiredText(priorSeat, "id");
1447
+ if (requiredText(priorSeat, "role_id") !== input.roleId) {
1448
+ throw new AttachSessionRejectedError();
1243
1449
  }
1244
- droneId = requiredText(retryBinding, "id");
1245
- droneLabel = requiredText(retryBinding, "label");
1246
- generation = requiredInteger(retryBinding, "attach_generation") + 1;
1247
- this.#database.prepare(`
1248
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
1249
- `).run(now, generation, droneId);
1250
- reattached = true;
1450
+ const occupied = this.#database.prepare(`
1451
+ SELECT 1 FROM drone_sessions AS session
1452
+ JOIN drone_session_credentials AS credential ON credential.session_id = session.id
1453
+ WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
1454
+ AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
1455
+ AND session.expires_at > ?
1456
+ `).get(priorSeatId, this.#principal.id, input.cubeId, now);
1457
+ if (occupied !== undefined) throw new AttachSessionRejectedError();
1458
+ droneId = priorSeatId;
1459
+ droneLabel = requiredText(priorSeat, "label");
1460
+ this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
1251
1461
  } else {
1252
- const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
1253
- SELECT id, label, attach_generation
1254
- FROM drones
1255
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
1256
- `).get(input.priorDroneId, this.#principal.id, input.cubeId);
1257
- if (priorSeat !== undefined) {
1258
- droneId = requiredText(priorSeat, "id");
1259
- droneLabel = requiredText(priorSeat, "label");
1260
- generation = requiredInteger(priorSeat, "attach_generation") + 1;
1261
- this.#database.prepare(`
1262
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
1263
- `).run(now, generation, droneId);
1264
- reattached = true;
1265
- } else {
1266
- droneId = input.droneId;
1267
- droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
1268
- this.#database.prepare(`
1269
- INSERT INTO drones (
1270
- id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
1271
- attach_generation
1272
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
1273
- `).run(
1274
- droneId,
1275
- input.cubeId,
1276
- input.roleId,
1277
- this.#principal.id,
1278
- droneLabel,
1279
- now,
1280
- now,
1281
- input.retryKey,
1282
- );
1283
- reattached = false;
1284
- generation = 1;
1285
- }
1462
+ droneId = input.droneId;
1463
+ droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
1286
1464
  this.#database.prepare(`
1287
- INSERT INTO seat_attach_bindings (
1288
- client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
1289
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
1465
+ INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
1466
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1290
1467
  `).run(
1291
- this.#principal.id, input.retryKey, input.cubeId, input.roleId,
1292
- droneId, input.priorDroneId ?? null, now,
1468
+ droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
1293
1469
  );
1294
1470
  }
1471
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
1295
1472
 
1296
- const oldSessions = this.#database.prepare(
1297
- "SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?",
1298
- ).all(droneId, this.#principal.id, input.cubeId)
1299
- .map((row) => requiredText(row, "id"));
1300
- if (oldSessions.length > 0) {
1301
- const placeholders = oldSessions.map(() => "?").join(", ");
1302
- this.#database.prepare(`
1303
- UPDATE drone_session_credentials SET revoked_at = ?
1304
- WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
1305
- `).run(now, ...oldSessions);
1306
- this.#database.prepare(`
1307
- UPDATE drone_sessions SET revoked_at = ?
1308
- WHERE id IN (${placeholders}) AND revoked_at IS NULL
1309
- `).run(now, ...oldSessions);
1310
- }
1311
1473
  this.#database.prepare(`
1312
1474
  INSERT INTO drone_sessions (
1313
1475
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -1355,10 +1517,7 @@ class SqliteScopedStore implements ScopedStore {
1355
1517
  drone: { id: droneId, label: droneLabel },
1356
1518
  sessionId: input.sessionId,
1357
1519
  expiresAt: input.expiresAt,
1358
- generation,
1359
- posture,
1360
- reattached,
1361
- revokedSessionIds: oldSessions,
1520
+ result: "created",
1362
1521
  };
1363
1522
  } catch (error) {
1364
1523
  try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
@@ -1366,13 +1525,44 @@ class SqliteScopedStore implements ScopedStore {
1366
1525
  }
1367
1526
  }
1368
1527
 
1369
- subscribeActivity(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
1528
+ subscribeActivity(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
1370
1529
  this.#requireCube(cubeId, "read");
1371
1530
  return this.#activityHub.subscribe(cubeId, (entry) => {
1531
+ if ("kind" in entry) {
1532
+ if (this.#principal.kind === "drone-session" &&
1533
+ entry.recipient_drone_ids.includes(this.#principal.droneId)) listener(entry);
1534
+ return;
1535
+ }
1372
1536
  if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId)) listener(entry);
1373
1537
  });
1374
1538
  }
1375
1539
 
1540
+ #claimAudience(cubeId: string, entryId: string, visibility: string): string[] {
1541
+ if (visibility === "direct") {
1542
+ return this.#database.prepare(`
1543
+ SELECT recipient.drone_id
1544
+ FROM activity_log_recipients AS recipient
1545
+ JOIN drones AS drone ON drone.id = recipient.drone_id
1546
+ JOIN clients AS client ON client.id = drone.client_id
1547
+ JOIN client_cube_grants AS grant_row
1548
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1549
+ WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
1550
+ AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1551
+ ORDER BY recipient.drone_id
1552
+ `).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
1553
+ }
1554
+ return this.#database.prepare(`
1555
+ SELECT drone.id AS drone_id
1556
+ FROM drones AS drone
1557
+ JOIN clients AS client ON client.id = drone.client_id
1558
+ JOIN client_cube_grants AS grant_row
1559
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1560
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
1561
+ AND grant_row.access IN ('write', 'manage')
1562
+ ORDER BY drone.id
1563
+ `).all(cubeId).map((row) => requiredText(row, "drone_id"));
1564
+ }
1565
+
1376
1566
  #allowsDirectedWork(cubeId: string): boolean {
1377
1567
  const scope = this.#scope("write");
1378
1568
  return this.#database.prepare(`
@@ -1386,7 +1576,15 @@ class SqliteScopedStore implements ScopedStore {
1386
1576
  const row = this.#database.prepare(`
1387
1577
  SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
1388
1578
  `).get(cubeId, ...scope.parameters);
1389
- if (row === undefined) throw new ScopedStoreError();
1579
+ if (row !== undefined) return;
1580
+ if (access === "manage") {
1581
+ const visibleScope = this.#scope("read");
1582
+ const visible = this.#database.prepare(`
1583
+ SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
1584
+ `).get(cubeId, ...visibleScope.parameters);
1585
+ if (visible !== undefined) throw new AccessDeniedError();
1586
+ }
1587
+ throw new ScopedStoreError();
1390
1588
  }
1391
1589
 
1392
1590
  #nextActivityTimestamp(cubeId: string): string {
@@ -1644,7 +1842,7 @@ class SqliteMaintenanceStore implements MaintenanceStore {
1644
1842
  for (const table of [
1645
1843
  "cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
1646
1844
  "decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
1647
- "seat_attach_bindings", "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1845
+ "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1648
1846
  "enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
1649
1847
  "enrollment_invitations",
1650
1848
  ]) this.#database.exec(`DELETE FROM ${table}`);
@@ -1812,9 +2010,9 @@ class SqliteMaintenanceStore implements MaintenanceStore {
1812
2010
  }
1813
2011
 
1814
2012
  class ActivityHub {
1815
- readonly #listeners = new Map<string, Set<(entry: EnrichedActivityRecord) => void>>();
2013
+ readonly #listeners = new Map<string, Set<(entry: ActivityStreamRecord) => void>>();
1816
2014
 
1817
- subscribe(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
2015
+ subscribe(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
1818
2016
  const listeners = this.#listeners.get(cubeId) ?? new Set();
1819
2017
  listeners.add(listener);
1820
2018
  this.#listeners.set(cubeId, listeners);
@@ -1824,7 +2022,7 @@ class ActivityHub {
1824
2022
  };
1825
2023
  }
1826
2024
 
1827
- publish(cubeId: string, entry: EnrichedActivityRecord): void {
2025
+ publish(cubeId: string, entry: ActivityStreamRecord): void {
1828
2026
  for (const listener of this.#listeners.get(cubeId) ?? []) {
1829
2027
  try {
1830
2028
  listener(entry);
@@ -1835,6 +2033,64 @@ class ActivityHub {
1835
2033
  }
1836
2034
  }
1837
2035
 
2036
+ class SqliteLivenessStore implements LivenessStore {
2037
+ constructor(
2038
+ readonly database: DatabaseSync,
2039
+ readonly clock: () => Date,
2040
+ readonly hub: ActivityHub,
2041
+ readonly capacityGuard: StorageCapacityGuard,
2042
+ ) {}
2043
+
2044
+ scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
2045
+ const now = this.clock();
2046
+ const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
2047
+ const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
2048
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
2049
+ const candidates = this.database.prepare(`
2050
+ SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
2051
+ FROM drones AS drone
2052
+ JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
2053
+ JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
2054
+ AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
2055
+ LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
2056
+ WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
2057
+ AND COALESCE(state.attempts, 0) < 3
2058
+ AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
2059
+ AND EXISTS (SELECT 1 FROM drone_sessions AS session
2060
+ WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
2061
+ ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
2062
+ `).all(silentBefore, cooldownBefore, now.toISOString(), limit);
2063
+ const published: ActivityNotificationRecord[] = [];
2064
+ for (const candidate of candidates) {
2065
+ const droneId = requiredText(candidate, "drone_id");
2066
+ const cubeId = requiredText(candidate, "cube_id");
2067
+ const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
2068
+ this.capacityGuard.assertCanGrow(256);
2069
+ const id = randomUUID();
2070
+ const createdAt = now.toISOString();
2071
+ this.database.exec("BEGIN IMMEDIATE");
2072
+ try {
2073
+ this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
2074
+ VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
2075
+ SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
2076
+ `).run(droneId, now.toISOString());
2077
+ this.database.exec("COMMIT");
2078
+ } catch (error) {
2079
+ try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
2080
+ throw error;
2081
+ }
2082
+ const entry: ActivityNotificationRecord = {
2083
+ kind: "heartbeat_ping",
2084
+ id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
2085
+ drone_label: null, role_name: null, recipient_drone_ids: [droneId],
2086
+ };
2087
+ this.hub.publish(cubeId, entry);
2088
+ published.push(entry);
2089
+ }
2090
+ return published;
2091
+ }
2092
+ }
2093
+
1838
2094
  class SqliteCredentialStore implements CredentialStore {
1839
2095
  readonly #database: DatabaseSync;
1840
2096
  readonly #clock: () => Date;
@@ -2159,9 +2415,8 @@ class SqliteCredentialStore implements CredentialStore {
2159
2415
  SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
2160
2416
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
2161
2417
  session.expires_at,
2162
- COALESCE(
2163
- credential.revoked_at, session.revoked_at, client.revoked_at, drone.evicted_at
2164
- ) AS revoked_at
2418
+ COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
2419
+ drone.evicted_at
2165
2420
  FROM drone_session_credentials AS credential
2166
2421
  JOIN drone_sessions AS session ON session.id = credential.session_id
2167
2422
  JOIN clients AS client ON client.id = session.client_id
@@ -2173,6 +2428,21 @@ class SqliteCredentialStore implements CredentialStore {
2173
2428
  return row === undefined ? null : storedDroneSessionDigest(row);
2174
2429
  }
2175
2430
 
2431
+ findActiveDroneSessionExpiry(sessionId: string): string | null {
2432
+ assertCanonicalUuid(sessionId, "Drone session id");
2433
+ const row = this.#database.prepare(`
2434
+ SELECT session.expires_at
2435
+ FROM drone_sessions AS session
2436
+ JOIN clients AS client ON client.id = session.client_id
2437
+ JOIN drones AS drone ON drone.id = session.drone_id
2438
+ AND drone.client_id = session.client_id
2439
+ AND drone.cube_id = session.cube_id
2440
+ WHERE session.id = ? AND session.revoked_at IS NULL
2441
+ AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
2442
+ `).get(sessionId);
2443
+ return row === undefined ? null : requiredText(row, "expires_at");
2444
+ }
2445
+
2176
2446
  rotateClientCredential(input: {
2177
2447
  readonly clientId: string;
2178
2448
  readonly credentialId: string;
@@ -2195,6 +2465,16 @@ class SqliteCredentialStore implements CredentialStore {
2195
2465
  UPDATE client_credentials SET revoked_at = ?
2196
2466
  WHERE client_id = ? AND revoked_at IS NULL
2197
2467
  `).run(now, input.clientId);
2468
+ this.#database.prepare(`
2469
+ UPDATE drone_session_credentials SET revoked_at = ?
2470
+ WHERE revoked_at IS NULL AND session_id IN (
2471
+ SELECT id FROM drone_sessions WHERE client_id = ?
2472
+ )
2473
+ `).run(now, input.clientId);
2474
+ this.#database.prepare(`
2475
+ UPDATE drone_sessions SET revoked_at = ?
2476
+ WHERE client_id = ? AND revoked_at IS NULL
2477
+ `).run(now, input.clientId);
2198
2478
  this.#database.prepare(`
2199
2479
  INSERT INTO client_credentials (
2200
2480
  id, client_id, lookup_digest, verifier_digest, created_at
@@ -2361,6 +2641,7 @@ function cubeRecord(row: CubeRow): CubeRecord {
2361
2641
  ownerId: row.owner_id,
2362
2642
  name: row.name,
2363
2643
  directive: row.directive,
2644
+ messageTaxonomy: parseTaxonomy(row.message_taxonomy),
2364
2645
  createdAt: row.created_at,
2365
2646
  updatedAt: row.updated_at,
2366
2647
  };
@@ -2384,6 +2665,7 @@ function cubeRow(row: Record<string, unknown>): CubeRow {
2384
2665
  owner_id: requiredText(row, "owner_id"),
2385
2666
  name: requiredText(row, "name"),
2386
2667
  directive: requiredText(row, "directive"),
2668
+ message_taxonomy: nullableText(row, "message_taxonomy"),
2387
2669
  created_at: requiredText(row, "created_at"),
2388
2670
  updated_at: requiredText(row, "updated_at"),
2389
2671
  };
@@ -2611,6 +2893,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
2611
2893
  cubeId: requiredText(row, "cube_id"),
2612
2894
  droneId: requiredText(row, "drone_id"),
2613
2895
  expiresAt: requiredText(row, "expires_at"),
2896
+ evictedAt: nullableText(row, "evicted_at"),
2614
2897
  };
2615
2898
  }
2616
2899
 
@@ -2696,6 +2979,34 @@ function validateDirective(value: string): void {
2696
2979
  }
2697
2980
  }
2698
2981
 
2982
+ function activityPreview(message: string): string {
2983
+ return message.replace(/\s+/gu, " ").trim().slice(0, 200);
2984
+ }
2985
+
2986
+ function validateRoleClass(value: unknown): void {
2987
+ if (value !== undefined && value !== "queen" && value !== "worker") {
2988
+ throw new TypeError("Role class must be queen or worker.");
2989
+ }
2990
+ }
2991
+
2992
+ function serializeTaxonomy(value: MessageTaxonomy | null): string | null {
2993
+ if (value === null) return null;
2994
+ const serialized = JSON.stringify(value);
2995
+ if (Buffer.byteLength(serialized) > 100_000) {
2996
+ throw new RangeError("Message taxonomy exceeds 100000 bytes.");
2997
+ }
2998
+ return serialized;
2999
+ }
3000
+
3001
+ function parseTaxonomy(value: string | null): MessageTaxonomy | null {
3002
+ if (value === null) return null;
3003
+ try {
3004
+ return validateMessageTaxonomy(JSON.parse(value));
3005
+ } catch {
3006
+ throw new Error("Database contains invalid message taxonomy.");
3007
+ }
3008
+ }
3009
+
2699
3010
  function validateDigest(digest: DigestPair): void {
2700
3011
  validateLookup(digest.lookup);
2701
3012
  if (digest.verifier.length !== 32) throw new Error("Verifier digest must contain 32 bytes.");