borgmcp-server 0.1.1 → 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 (61) hide show
  1. package/README.md +70 -22
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/cli.js +62 -11
  4. package/dist/cli.js.map +1 -1
  5. package/dist/coordination-api.d.ts +3 -1
  6. package/dist/coordination-api.js +476 -78
  7. package/dist/coordination-api.js.map +1 -1
  8. package/dist/credentials.d.ts +13 -7
  9. package/dist/credentials.js +77 -15
  10. package/dist/credentials.js.map +1 -1
  11. package/dist/debug-log.d.ts +76 -0
  12. package/dist/debug-log.js +124 -0
  13. package/dist/debug-log.js.map +1 -0
  14. package/dist/enrollment.d.ts +3 -11
  15. package/dist/enrollment.js +21 -60
  16. package/dist/enrollment.js.map +1 -1
  17. package/dist/https-server.d.ts +5 -20
  18. package/dist/https-server.js +108 -50
  19. package/dist/https-server.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/message-taxonomy.d.ts +38 -0
  22. package/dist/message-taxonomy.js +218 -0
  23. package/dist/message-taxonomy.js.map +1 -0
  24. package/dist/migrations.d.ts +4 -0
  25. package/dist/migrations.js +99 -0
  26. package/dist/migrations.js.map +1 -1
  27. package/dist/operator-error.d.ts +2 -1
  28. package/dist/operator-error.js +23 -5
  29. package/dist/operator-error.js.map +1 -1
  30. package/dist/role-section.d.ts +14 -0
  31. package/dist/role-section.js +87 -0
  32. package/dist/role-section.js.map +1 -0
  33. package/dist/service.d.ts +14 -4
  34. package/dist/service.js +243 -25
  35. package/dist/service.js.map +1 -1
  36. package/dist/start-options.d.ts +5 -1
  37. package/dist/start-options.js +17 -3
  38. package/dist/start-options.js.map +1 -1
  39. package/dist/store.d.ts +106 -8
  40. package/dist/store.js +772 -120
  41. package/dist/store.js.map +1 -1
  42. package/npm-shrinkwrap.json +6 -7
  43. package/package.json +2 -2
  44. package/src/cli.ts +61 -11
  45. package/src/coordination-api.ts +490 -72
  46. package/src/credentials.ts +103 -19
  47. package/src/debug-log.ts +165 -0
  48. package/src/enrollment.ts +32 -78
  49. package/src/https-server.ts +113 -78
  50. package/src/index.ts +1 -1
  51. package/src/message-taxonomy.ts +284 -0
  52. package/src/migrations.ts +102 -0
  53. package/src/operator-error.ts +40 -6
  54. package/src/role-section.ts +108 -0
  55. package/src/service.ts +268 -27
  56. package/src/start-options.ts +21 -4
  57. package/src/store.ts +887 -142
  58. package/dist/protocol-draft.d.ts +0 -2
  59. package/dist/protocol-draft.js +0 -31
  60. package/dist/protocol-draft.js.map +0 -1
  61. package/src/protocol-draft.ts +0 -32
package/src/store.ts CHANGED
@@ -1,16 +1,19 @@
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
- import { applyMigrations } from "./migrations.js";
8
+ import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
8
9
  import { operatorErrors } from "./operator-error.js";
9
10
  import {
10
11
  assertCanonicalUuid,
11
12
  assertServerDerivedPrincipal,
12
13
  type Principal,
13
14
  } from "./principal.js";
15
+ import { patchRoleSectionText, type RoleSectionPatchOp } from "./role-section.js";
16
+ import { validateMessageTaxonomy } from "./message-taxonomy.js";
14
17
 
15
18
  export type CubeAccess = "read" | "write" | "manage";
16
19
 
@@ -19,6 +22,7 @@ export interface CubeRecord {
19
22
  readonly ownerId: string;
20
23
  readonly name: string;
21
24
  readonly directive: string;
25
+ readonly messageTaxonomy: MessageTaxonomy | null;
22
26
  readonly createdAt: string;
23
27
  readonly updatedAt: string;
24
28
  }
@@ -50,6 +54,22 @@ export interface EnrichedActivityRecord {
50
54
  readonly recipient_drone_ids: string[];
51
55
  }
52
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
+
53
73
  export interface ClaimRecord {
54
74
  readonly log_entry_id: string;
55
75
  readonly claimant_drone_id: string;
@@ -84,8 +104,12 @@ export interface RoleRecord {
84
104
  readonly cube_id: string;
85
105
  readonly name: string;
86
106
  readonly short_description: string;
107
+ readonly detailed_description: string;
87
108
  readonly is_default: boolean;
109
+ readonly is_mandatory: boolean;
88
110
  readonly is_human_seat: boolean;
111
+ readonly can_broadcast: boolean;
112
+ readonly receives_all_direct: boolean;
89
113
  readonly role_class: "queen" | "worker";
90
114
  readonly created_at: string;
91
115
  }
@@ -97,7 +121,10 @@ export interface DroneRecord {
97
121
  readonly label: string;
98
122
  readonly last_seen: string;
99
123
  readonly hostname: string | null;
124
+ readonly posture: "observer" | "participant";
100
125
  readonly created_at: string;
126
+ readonly last_log_post_at?: string | null;
127
+ readonly seen_since?: boolean;
101
128
  }
102
129
 
103
130
  export interface StoreDiagnostics {
@@ -113,6 +140,7 @@ export interface OpenStoreOptions {
113
140
  readonly capacityProbe?: () => StorageCapacity;
114
141
  readonly cubeLimits?: CubeLimits;
115
142
  readonly mutationHook?: (phase: string) => void;
143
+ readonly migrationMode?: "apply" | "require-current";
116
144
  }
117
145
 
118
146
  export interface CubeLimits {
@@ -146,10 +174,19 @@ export interface StoreRuntime {
146
174
  readonly forPrincipal: (principal: Principal) => ScopedStore;
147
175
  readonly maintenance: MaintenanceStore;
148
176
  readonly credentials: CredentialStore;
177
+ readonly liveness: LivenessStore;
149
178
  readonly diagnostics: () => StoreDiagnostics;
150
179
  readonly close: () => void;
151
180
  }
152
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
+
153
190
  export interface DigestPair {
154
191
  readonly lookup: Buffer;
155
192
  readonly verifier: Buffer;
@@ -166,6 +203,25 @@ export interface StoredSecretDigest extends DigestPair {
166
203
  export interface StoredInvitationDigest extends StoredSecretDigest {
167
204
  readonly purpose: "owner" | "client";
168
205
  readonly ownerEpoch: number | null;
206
+ readonly cubeId: string | null;
207
+ readonly access: CubeAccess | null;
208
+ }
209
+
210
+ export interface InvitationCubeScope {
211
+ readonly cubeId: string;
212
+ readonly cubeName: string;
213
+ readonly access: CubeAccess;
214
+ }
215
+
216
+ export class InvitationCubeNotFoundError extends Error {}
217
+
218
+ export class InvitationCubeAmbiguousError extends Error {
219
+ readonly candidateIds: readonly string[];
220
+
221
+ constructor(candidateIds: readonly string[]) {
222
+ super("Cube name is ambiguous.");
223
+ this.candidateIds = Object.freeze([...candidateIds]);
224
+ }
169
225
  }
170
226
 
171
227
  export interface EnrollmentClaimResult {
@@ -180,6 +236,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
180
236
  readonly cubeId: string;
181
237
  readonly droneId: string;
182
238
  readonly expiresAt: string;
239
+ readonly evictedAt: string | null;
183
240
  }
184
241
 
185
242
  export interface CredentialStore {
@@ -190,7 +247,9 @@ export interface CredentialStore {
190
247
  readonly digest: DigestPair;
191
248
  readonly expiresAt: string;
192
249
  readonly purpose: "owner" | "client";
193
- }) => void;
250
+ readonly cubeSelector?: { readonly kind: "id" | "name"; readonly value: string };
251
+ readonly access?: CubeAccess;
252
+ }) => InvitationCubeScope | null;
194
253
  readonly findInvitation: (lookup: Buffer) => StoredInvitationDigest | null;
195
254
  readonly claimInvitation: (input: {
196
255
  readonly invitationId: string;
@@ -204,6 +263,7 @@ export interface CredentialStore {
204
263
  readonly clientExists: (clientId: string) => boolean;
205
264
  readonly clientIsActive: (clientId: string) => boolean;
206
265
  readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
266
+ readonly findActiveDroneSessionExpiry: (sessionId: string) => string | null;
207
267
  readonly rotateClientCredential: (input: {
208
268
  readonly clientId: string;
209
269
  readonly credentialId: string;
@@ -216,11 +276,19 @@ export interface ScopedStore {
216
276
  readonly createCube: (input: CreateCubeInput) => CreateCubeRecord;
217
277
  readonly listCubes: () => CubeRecord[];
218
278
  readonly getCube: (cubeId: string) => CubeRecord | null;
279
+ readonly updateCube: (cubeId: string, input: UpdateCubeInput) => CubeRecord;
219
280
  readonly updateDirective: (cubeId: string, directive: string) => void;
220
281
  readonly appendActivity: (cubeId: string, message: string) => ActivityRecord;
221
282
  readonly readActivity: (cubeId: string, limit: number) => ActivityRecord[];
222
283
  readonly listRoles: (cubeId: string) => RoleRecord[];
284
+ readonly createRole: (cubeId: string, input: CreateRoleInput) => RoleRecord;
285
+ readonly updateRole: (cubeId: string, roleId: string, input: UpdateRoleInput) => RoleRecord;
286
+ readonly patchRoleSection: (cubeId: string, roleId: string, input: RoleSectionPatchOp) => RoleRecord;
223
287
  readonly listDrones: (cubeId: string) => DroneRecord[];
288
+ readonly listDronesSince: (cubeId: string, since: string) => {
289
+ readonly drones: DroneRecord[];
290
+ readonly since: string;
291
+ };
224
292
  readonly appendLog: (cubeId: string, input: {
225
293
  readonly message: string;
226
294
  readonly visibility?: "broadcast" | "direct";
@@ -236,11 +304,40 @@ export interface ScopedStore {
236
304
  readonly listDecisions: (cubeId: string) => DecisionRecord[];
237
305
  readonly subscribeActivity: (
238
306
  cubeId: string,
239
- listener: (entry: EnrichedActivityRecord) => void,
307
+ listener: (entry: ActivityStreamRecord) => void,
240
308
  ) => (() => void);
241
309
  readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
242
310
  }
243
311
 
312
+ export interface CreateRoleInput {
313
+ readonly name: string;
314
+ readonly shortDescription?: string;
315
+ readonly detailedDescription?: string;
316
+ readonly isDefault?: boolean;
317
+ readonly isMandatory?: boolean;
318
+ readonly isHumanSeat?: boolean;
319
+ readonly canBroadcast?: boolean;
320
+ readonly receivesAllDirect?: boolean;
321
+ readonly roleClass?: "queen" | "worker";
322
+ }
323
+
324
+ export interface UpdateRoleInput {
325
+ readonly name?: string;
326
+ readonly shortDescription?: string;
327
+ readonly detailedDescription?: string;
328
+ readonly isDefault?: boolean;
329
+ readonly isMandatory?: boolean;
330
+ readonly isHumanSeat?: boolean;
331
+ readonly canBroadcast?: boolean;
332
+ readonly receivesAllDirect?: boolean;
333
+ readonly roleClass?: "queen" | "worker";
334
+ }
335
+
336
+ export interface UpdateCubeInput {
337
+ readonly directive?: string;
338
+ readonly messageTaxonomy?: MessageTaxonomy | null;
339
+ }
340
+
244
341
  export interface CreateCubeInput {
245
342
  readonly retryKey: string;
246
343
  readonly name: string;
@@ -257,7 +354,6 @@ export interface CreateCubeRecord {
257
354
  export interface SeatAttachInput {
258
355
  readonly cubeId: string;
259
356
  readonly roleId: string;
260
- readonly retryKey: string;
261
357
  readonly priorDroneId?: string;
262
358
  readonly droneId: string;
263
359
  readonly sessionId: string;
@@ -283,9 +379,7 @@ export interface SeatAttachRecord {
283
379
  };
284
380
  readonly sessionId: string;
285
381
  readonly expiresAt: string;
286
- readonly generation: number;
287
- readonly reattached: boolean;
288
- readonly revokedSessionIds: readonly string[];
382
+ readonly result: "created" | "reused";
289
383
  }
290
384
 
291
385
  export interface MaintenanceStore {
@@ -359,6 +453,33 @@ export class ScopedStoreError extends Error {
359
453
  }
360
454
  }
361
455
 
456
+ export class RoleConflictError extends Error {
457
+ readonly code = "ROLE_ALREADY_EXISTS";
458
+
459
+ constructor() {
460
+ super("A role with that name already exists.");
461
+ this.name = "RoleConflictError";
462
+ }
463
+ }
464
+
465
+ export class DefaultRoleRequiredError extends Error {
466
+ readonly code = "DEFAULT_ROLE_REQUIRED";
467
+
468
+ constructor() {
469
+ super("A cube must retain one default role.");
470
+ this.name = "DefaultRoleRequiredError";
471
+ }
472
+ }
473
+
474
+ export class RoleSectionConflictError extends Error {
475
+ readonly code = "ROLE_SECTION_CONFLICT";
476
+
477
+ constructor() {
478
+ super("The role section patch conflicts with the current role text.");
479
+ this.name = "RoleSectionConflictError";
480
+ }
481
+ }
482
+
362
483
  export class CursorExpiredError extends Error {
363
484
  readonly code = "CURSOR_EXPIRED";
364
485
 
@@ -368,12 +489,12 @@ export class CursorExpiredError extends Error {
368
489
  }
369
490
  }
370
491
 
371
- export class AttachConflictError extends Error {
372
- readonly code = "ATTACH_CONFLICT";
492
+ export class AttachSessionRejectedError extends Error {
493
+ readonly code = "SESSION_REJECTED";
373
494
 
374
495
  constructor() {
375
- super("The attach request conflicts with an existing attachment.");
376
- this.name = "AttachConflictError";
496
+ super("The saved session is not accepted for this seat.");
497
+ this.name = "AttachSessionRejectedError";
377
498
  }
378
499
  }
379
500
 
@@ -401,6 +522,7 @@ interface CubeRow {
401
522
  readonly owner_id: string;
402
523
  readonly name: string;
403
524
  readonly directive: string;
525
+ readonly message_taxonomy: string | null;
404
526
  readonly created_at: string;
405
527
  readonly updated_at: string;
406
528
  }
@@ -470,8 +592,13 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
470
592
  });
471
593
  const clock = options.clock ?? (() => new Date());
472
594
  try {
473
- configureDatabase(database);
474
- applyMigrations(database);
595
+ if (options.migrationMode === "require-current") {
596
+ configureExistingDatabase(database);
597
+ assertMigrationsCurrent(database);
598
+ } else {
599
+ configureDatabase(database);
600
+ applyMigrations(database);
601
+ }
475
602
  } catch (error) {
476
603
  database.close();
477
604
  throw error;
@@ -487,9 +614,10 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
487
614
  capacityProbe,
488
615
  requiredInteger(pageRow, "page_size"),
489
616
  );
617
+ const activityHub = new ActivityHub();
490
618
  const maintenance = new SqliteMaintenanceStore(database, clock);
619
+ const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
491
620
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
492
- const activityHub = new ActivityHub();
493
621
  return Object.freeze({
494
622
  forPrincipal: (principal: Principal) => {
495
623
  assertServerDerivedPrincipal(principal);
@@ -506,6 +634,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
506
634
  },
507
635
  maintenance,
508
636
  credentials,
637
+ liveness,
509
638
  diagnostics: () => diagnostics(database),
510
639
  close: () => database.close(),
511
640
  });
@@ -626,7 +755,8 @@ class SqliteScopedStore implements ScopedStore {
626
755
  listCubes(): CubeRecord[] {
627
756
  const scope = this.#scope("read");
628
757
  const rows = this.#database.prepare(`
629
- 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
630
760
  FROM cubes AS c
631
761
  WHERE ${scope.sql}
632
762
  ORDER BY c.id
@@ -638,7 +768,8 @@ class SqliteScopedStore implements ScopedStore {
638
768
  assertCanonicalUuid(cubeId, "Cube id");
639
769
  const scope = this.#scope("read");
640
770
  const row = this.#database.prepare(`
641
- 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
642
773
  FROM cubes AS c
643
774
  WHERE c.id = ? AND ${scope.sql}
644
775
  `).get(cubeId, ...scope.parameters);
@@ -646,17 +777,42 @@ class SqliteScopedStore implements ScopedStore {
646
777
  }
647
778
 
648
779
  updateDirective(cubeId: string, directive: string): void {
780
+ this.updateCube(cubeId, { directive });
781
+ }
782
+
783
+ updateCube(cubeId: string, input: UpdateCubeInput): CubeRecord {
649
784
  assertCanonicalUuid(cubeId, "Cube id");
650
- 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);
651
793
  const scope = this.#scope("manage");
652
794
  this.#requireCube(cubeId, "manage");
653
- this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
795
+ this.#capacityGuard.assertCanGrow(
796
+ Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""),
797
+ );
654
798
  const result = this.#database.prepare(`
655
799
  UPDATE cubes AS c
656
- SET directive = ?, updated_at = ?
800
+ SET directive = COALESCE(?, directive),
801
+ message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
802
+ updated_at = ?
657
803
  WHERE c.id = ? AND ${scope.sql}
658
- `).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
+ );
659
812
  if (result.changes !== 1) throw new ScopedStoreError();
813
+ const cube = this.getCube(cubeId);
814
+ if (cube === null) throw new ScopedStoreError();
815
+ return cube;
660
816
  }
661
817
 
662
818
  appendActivity(cubeId: string, message: string): ActivityRecord {
@@ -693,23 +849,282 @@ class SqliteScopedStore implements ScopedStore {
693
849
  listRoles(cubeId: string): RoleRecord[] {
694
850
  this.#requireCube(cubeId, "read");
695
851
  const rows = this.#database.prepare(`
696
- SELECT id, cube_id, name, short_description, is_default, is_human_seat,
697
- role_class, created_at
852
+ SELECT id, cube_id, name, short_description, detailed_description,
853
+ is_default, is_mandatory, is_human_seat, can_broadcast,
854
+ receives_all_direct, role_class, created_at
698
855
  FROM roles WHERE cube_id = ? ORDER BY name, id
699
856
  `).all(cubeId);
700
857
  return rows.map(roleRecord);
701
858
  }
702
859
 
860
+ createRole(cubeId: string, input: CreateRoleInput): RoleRecord {
861
+ assertCanonicalUuid(cubeId, "Cube id");
862
+ validateRoleName(input.name);
863
+ const shortDescription = input.shortDescription ?? "";
864
+ const detailedDescription = input.detailedDescription ?? "";
865
+ validateRoleShortDescription(shortDescription);
866
+ assertRoleTextWriteAllowed(detailedDescription);
867
+ for (const value of [
868
+ input.isDefault,
869
+ input.isMandatory,
870
+ input.isHumanSeat,
871
+ input.canBroadcast,
872
+ input.receivesAllDirect,
873
+ ]) {
874
+ if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
875
+ }
876
+ validateRoleClass(input.roleClass);
877
+ const isDefault = input.isDefault ?? false;
878
+ const isMandatory = input.isMandatory ?? false;
879
+ const isHumanSeat = input.isHumanSeat ?? false;
880
+ const canBroadcast = input.canBroadcast ?? false;
881
+ const receivesAllDirect = input.receivesAllDirect ?? false;
882
+ this.#requireCube(cubeId, "manage");
883
+ this.#capacityGuard.assertCanGrow(
884
+ Buffer.byteLength(input.name) + Buffer.byteLength(shortDescription) +
885
+ Buffer.byteLength(detailedDescription) + 8_192,
886
+ );
887
+
888
+ this.#database.exec("BEGIN IMMEDIATE");
889
+ try {
890
+ this.#requireCube(cubeId, "manage");
891
+ const duplicate = this.#database.prepare(
892
+ "SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ?",
893
+ ).get(cubeId, input.name);
894
+ if (duplicate !== undefined) throw new RoleConflictError();
895
+ if (isDefault) {
896
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
897
+ .run(cubeId);
898
+ this.#mutationHook?.("role.demote-default");
899
+ }
900
+ const id = randomUUID();
901
+ this.#database.prepare(`
902
+ INSERT INTO roles (
903
+ id, cube_id, name, short_description, detailed_description,
904
+ is_default, is_mandatory, is_human_seat, can_broadcast,
905
+ receives_all_direct, role_class, created_at
906
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
907
+ `).run(
908
+ id, cubeId, input.name, shortDescription, detailedDescription,
909
+ booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat),
910
+ booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker",
911
+ this.#now(),
912
+ );
913
+ this.#mutationHook?.("role.insert");
914
+ const row = this.#database.prepare(`
915
+ SELECT id, cube_id, name, short_description, detailed_description,
916
+ is_default, is_mandatory, is_human_seat, can_broadcast,
917
+ receives_all_direct, role_class, created_at
918
+ FROM roles WHERE id = ? AND cube_id = ?
919
+ `).get(id, cubeId);
920
+ if (row === undefined) throw new ScopedStoreError();
921
+ this.#database.exec("COMMIT");
922
+ this.#mutationHook?.("role.after-commit");
923
+ return roleRecord(row);
924
+ } catch (error) {
925
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
926
+ throw error;
927
+ }
928
+ }
929
+
930
+ updateRole(cubeId: string, roleId: string, input: UpdateRoleInput): RoleRecord {
931
+ assertCanonicalUuid(cubeId, "Cube id");
932
+ assertCanonicalUuid(roleId, "Role id");
933
+ if (Object.values(input).every((value) => value === undefined)) {
934
+ throw new TypeError("At least one role field is required.");
935
+ }
936
+ if (input.name !== undefined) validateRoleName(input.name);
937
+ if (input.shortDescription !== undefined) validateRoleShortDescription(input.shortDescription);
938
+ if (input.detailedDescription !== undefined && typeof input.detailedDescription !== "string") {
939
+ throw new TypeError("Role detailed description must be text.");
940
+ }
941
+ for (const value of [
942
+ input.isDefault,
943
+ input.isMandatory,
944
+ input.isHumanSeat,
945
+ input.canBroadcast,
946
+ input.receivesAllDirect,
947
+ ]) {
948
+ if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
949
+ }
950
+ validateRoleClass(input.roleClass);
951
+ this.#requireCube(cubeId, "manage");
952
+ this.#capacityGuard.assertCanGrow(
953
+ Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
954
+ Buffer.byteLength(input.detailedDescription ?? "") + 8_192,
955
+ );
956
+
957
+ this.#database.exec("BEGIN IMMEDIATE");
958
+ try {
959
+ this.#requireCube(cubeId, "manage");
960
+ const existingRow = this.#database.prepare(`
961
+ SELECT id, cube_id, name, short_description, detailed_description,
962
+ is_default, is_mandatory, is_human_seat, can_broadcast,
963
+ receives_all_direct, role_class, created_at
964
+ FROM roles WHERE id = ? AND cube_id = ?
965
+ `).get(roleId, cubeId);
966
+ if (existingRow === undefined) throw new ScopedStoreError();
967
+ const existing = roleRecord(existingRow);
968
+ if (input.isDefault === false && existing.is_default) {
969
+ throw new DefaultRoleRequiredError();
970
+ }
971
+ if (input.name !== undefined && input.name !== existing.name) {
972
+ const duplicate = this.#database.prepare(
973
+ "SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ? AND id <> ?",
974
+ ).get(cubeId, input.name, roleId);
975
+ if (duplicate !== undefined) throw new RoleConflictError();
976
+ }
977
+ if (input.isDefault === true && !existing.is_default) {
978
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
979
+ .run(cubeId);
980
+ this.#mutationHook?.("role.demote-default");
981
+ }
982
+ const nextDetailedDescription = input.detailedDescription ?? existing.detailed_description;
983
+ assertRoleTextWriteAllowed(nextDetailedDescription, existing.detailed_description);
984
+ this.#database.prepare(`
985
+ UPDATE roles SET
986
+ name = ?, short_description = ?, detailed_description = ?, is_default = ?,
987
+ is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
988
+ role_class = ?
989
+ WHERE id = ? AND cube_id = ?
990
+ `).run(
991
+ input.name ?? existing.name,
992
+ input.shortDescription ?? existing.short_description,
993
+ nextDetailedDescription,
994
+ booleanInteger(input.isDefault ?? existing.is_default),
995
+ booleanInteger(input.isMandatory ?? existing.is_mandatory),
996
+ booleanInteger(input.isHumanSeat ?? existing.is_human_seat),
997
+ booleanInteger(input.canBroadcast ?? existing.can_broadcast),
998
+ booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct),
999
+ input.roleClass ?? existing.role_class,
1000
+ roleId,
1001
+ cubeId,
1002
+ );
1003
+ const row = this.#database.prepare(`
1004
+ SELECT id, cube_id, name, short_description, detailed_description,
1005
+ is_default, is_mandatory, is_human_seat, can_broadcast,
1006
+ receives_all_direct, role_class, created_at
1007
+ FROM roles WHERE id = ? AND cube_id = ?
1008
+ `).get(roleId, cubeId);
1009
+ if (row === undefined) throw new ScopedStoreError();
1010
+ this.#database.exec("COMMIT");
1011
+ this.#mutationHook?.("role.after-commit");
1012
+ return roleRecord(row);
1013
+ } catch (error) {
1014
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
1015
+ throw error;
1016
+ }
1017
+ }
1018
+
1019
+ patchRoleSection(cubeId: string, roleId: string, input: RoleSectionPatchOp): RoleRecord {
1020
+ assertCanonicalUuid(cubeId, "Cube id");
1021
+ assertCanonicalUuid(roleId, "Role id");
1022
+ this.#requireCube(cubeId, "manage");
1023
+ this.#capacityGuard.assertCanGrow(
1024
+ Buffer.byteLength(input.heading) +
1025
+ ("body" in input ? Buffer.byteLength(input.body) : 0) + 8_192,
1026
+ );
1027
+
1028
+ this.#database.exec("BEGIN IMMEDIATE");
1029
+ try {
1030
+ this.#requireCube(cubeId, "manage");
1031
+ const existingRow = this.#database.prepare(`
1032
+ SELECT id, cube_id, name, short_description, detailed_description,
1033
+ is_default, is_mandatory, is_human_seat, can_broadcast,
1034
+ receives_all_direct, role_class, created_at
1035
+ FROM roles WHERE id = ? AND cube_id = ?
1036
+ `).get(roleId, cubeId);
1037
+ if (existingRow === undefined) throw new ScopedStoreError();
1038
+ const existing = roleRecord(existingRow);
1039
+ let detailedDescription: string;
1040
+ try {
1041
+ detailedDescription = patchRoleSectionText(existing.detailed_description, input);
1042
+ } catch (error) {
1043
+ if (error instanceof TypeError) throw error;
1044
+ throw new RoleSectionConflictError();
1045
+ }
1046
+ assertRoleTextWriteAllowed(detailedDescription, existing.detailed_description);
1047
+ this.#database.prepare(
1048
+ "UPDATE roles SET detailed_description = ? WHERE id = ? AND cube_id = ?",
1049
+ ).run(detailedDescription, roleId, cubeId);
1050
+ const row = this.#database.prepare(`
1051
+ SELECT id, cube_id, name, short_description, detailed_description,
1052
+ is_default, is_mandatory, is_human_seat, can_broadcast,
1053
+ receives_all_direct, role_class, created_at
1054
+ FROM roles WHERE id = ? AND cube_id = ?
1055
+ `).get(roleId, cubeId);
1056
+ if (row === undefined) throw new ScopedStoreError();
1057
+ this.#database.exec("COMMIT");
1058
+ this.#mutationHook?.("role.after-commit");
1059
+ return roleRecord(row);
1060
+ } catch (error) {
1061
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
1062
+ throw error;
1063
+ }
1064
+ }
1065
+
703
1066
  listDrones(cubeId: string): DroneRecord[] {
704
1067
  this.#requireCube(cubeId, "read");
705
1068
  const rows = this.#database.prepare(`
706
- SELECT id, cube_id, role_id, label, COALESCE(last_seen, created_at) AS last_seen,
707
- hostname, created_at
708
- FROM drones WHERE cube_id = ? AND evicted_at IS NULL ORDER BY label, id
1069
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
1070
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
1071
+ drone.hostname, drone.created_at,
1072
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1073
+ THEN 'participant' ELSE 'observer' END AS posture
1074
+ FROM drones AS drone
1075
+ LEFT JOIN clients AS client ON client.id = drone.client_id
1076
+ LEFT JOIN client_cube_grants AS grant_row
1077
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1078
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
1079
+ ORDER BY drone.label, drone.id
709
1080
  `).all(cubeId);
710
1081
  return rows.map(droneRecord);
711
1082
  }
712
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
+
713
1128
  appendLog(cubeId: string, input: {
714
1129
  readonly message: string;
715
1130
  readonly visibility?: "broadcast" | "direct";
@@ -756,11 +1171,24 @@ class SqliteScopedStore implements ScopedStore {
756
1171
  createdAt, visibility, cubeId, ...scope.parameters,
757
1172
  );
758
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
+ }
759
1180
  if (recipients.length > 0) {
760
1181
  const valid = this.#database.prepare(`
761
- SELECT COUNT(*) AS count FROM drones
762
- WHERE cube_id = ? AND evicted_at IS NULL
763
- AND id IN (${recipients.map(() => "?").join(", ")})
1182
+ SELECT COUNT(*) AS count
1183
+ FROM drones AS recipient
1184
+ JOIN clients AS recipient_client ON recipient_client.id = recipient.client_id
1185
+ JOIN client_cube_grants AS recipient_grant
1186
+ ON recipient_grant.client_id = recipient.client_id
1187
+ AND recipient_grant.cube_id = recipient.cube_id
1188
+ WHERE recipient.cube_id = ? AND recipient.evicted_at IS NULL
1189
+ AND recipient_client.revoked_at IS NULL
1190
+ AND recipient_grant.access IN ('write', 'manage')
1191
+ AND recipient.id IN (${recipients.map(() => "?").join(", ")})
764
1192
  `).get(cubeId, ...recipients);
765
1193
  if (valid === undefined) throw new Error("Recipient count query returned no row.");
766
1194
  if (requiredInteger(valid, "count") !== recipients.length) throw new ScopedStoreError();
@@ -786,6 +1214,7 @@ class SqliteScopedStore implements ScopedStore {
786
1214
  throw new Error("Activity read limit must be an integer from 1 to 500.");
787
1215
  }
788
1216
  this.#validateCursor(cubeId, cursor);
1217
+ const broadcastOnly = !this.#allowsDirectedWork(cubeId);
789
1218
  const cursorSql = cursor === null
790
1219
  ? { sql: "1 = 1", parameters: [] as string[] }
791
1220
  : {
@@ -796,6 +1225,7 @@ class SqliteScopedStore implements ScopedStore {
796
1225
  SELECT l.id
797
1226
  FROM activity_log AS l
798
1227
  WHERE l.cube_id = ? AND ${cursorSql.sql}
1228
+ AND (${broadcastOnly ? "l.visibility = 'broadcast'" : "1 = 1"})
799
1229
  ORDER BY l.created_at, l.id
800
1230
  LIMIT ?
801
1231
  `).all(cubeId, ...cursorSql.parameters, limit + 1);
@@ -804,13 +1234,15 @@ class SqliteScopedStore implements ScopedStore {
804
1234
  const nextCursor = entries.length === 0
805
1235
  ? cursor
806
1236
  : { id: entries.at(-1)!.id, created_at: entries.at(-1)!.created_at };
807
- const behind = nextCursor === null ? this.#countAfter(cubeId, null) : this.#countAfter(cubeId, nextCursor);
1237
+ const behind = nextCursor === null
1238
+ ? this.#countAfter(cubeId, null, broadcastOnly)
1239
+ : this.#countAfter(cubeId, nextCursor, broadcastOnly);
808
1240
  return {
809
1241
  entries,
810
1242
  cursor: nextCursor,
811
1243
  behind_by: behind,
812
1244
  has_more: behind > 0,
813
- claims: this.#claims(cubeId),
1245
+ claims: this.#claims(cubeId, broadcastOnly),
814
1246
  };
815
1247
  }
816
1248
 
@@ -818,19 +1250,56 @@ class SqliteScopedStore implements ScopedStore {
818
1250
  this.#requireCube(cubeId, "write");
819
1251
  assertCanonicalUuid(entryId, "Activity entry id");
820
1252
  if (kind !== "ack" && kind !== "claim") throw new Error("Unknown acknowledgement kind.");
821
- const exists = this.#database.prepare(
822
- "SELECT 1 AS present FROM activity_log WHERE id = ? AND cube_id = ?",
823
- ).get(entryId, cubeId);
824
- 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();
825
1257
  this.#capacityGuard.assertCanGrow(512);
826
1258
  const claimant = this.#principal.kind === "drone-session"
827
1259
  ? this.#principal.droneId
828
1260
  : this.#principal.id;
829
- this.#database.prepare(`
1261
+ const ackAt = this.#now();
1262
+ const inserted = this.#database.prepare(`
830
1263
  INSERT OR IGNORE INTO activity_acks (
831
1264
  entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
832
1265
  ) VALUES (?, ?, ?, ?, ?, ?)
833
- `).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);
834
1303
  }
835
1304
 
836
1305
  recordDecision(cubeId: string, input: {
@@ -887,7 +1356,6 @@ class SqliteScopedStore implements ScopedStore {
887
1356
  if (this.#principal.kind !== "client") throw new ScopedStoreError();
888
1357
  assertCanonicalUuid(input.cubeId, "Cube id");
889
1358
  assertCanonicalUuid(input.roleId, "Role id");
890
- assertCanonicalUuid(input.retryKey, "Retry key");
891
1359
  if (input.priorDroneId !== undefined) assertCanonicalUuid(input.priorDroneId, "Prior drone id");
892
1360
  assertCanonicalUuid(input.droneId, "Drone id");
893
1361
  assertCanonicalUuid(input.sessionId, "Drone session id");
@@ -912,94 +1380,96 @@ class SqliteScopedStore implements ScopedStore {
912
1380
  WHERE c.id = ? AND role.id = ? AND ${scope.sql}
913
1381
  `).get(input.cubeId, input.roleId, ...scope.parameters);
914
1382
  if (roleRow === undefined) throw new ScopedStoreError();
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
+ }
915
1438
 
916
- const retryBinding = this.#database.prepare(`
917
- SELECT binding.cube_id AS binding_cube_id,
918
- binding.requested_role_id AS binding_requested_role_id,
919
- binding.prior_drone_id, drone.id, drone.label,
920
- drone.attach_generation, drone.evicted_at
921
- FROM seat_attach_bindings AS binding
922
- JOIN drones AS drone ON drone.id = binding.drone_id
923
- WHERE binding.client_id = ? AND binding.retry_key = ?
924
- `).get(this.#principal.id, input.retryKey);
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);
925
1443
  let droneId: string;
926
1444
  let droneLabel: string;
927
- let reattached: boolean;
928
- let generation: number;
929
- if (retryBinding !== undefined) {
930
- if (optionalText(retryBinding, "evicted_at") != null) throw new ScopedStoreError();
931
- if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
932
- requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
933
- nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
934
- 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();
935
1449
  }
936
- droneId = requiredText(retryBinding, "id");
937
- droneLabel = requiredText(retryBinding, "label");
938
- generation = requiredInteger(retryBinding, "attach_generation") + 1;
939
- this.#database.prepare(`
940
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
941
- `).run(now, generation, droneId);
942
- 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);
943
1461
  } else {
944
- const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
945
- SELECT id, label, attach_generation
946
- FROM drones
947
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
948
- `).get(input.priorDroneId, this.#principal.id, input.cubeId);
949
- if (priorSeat !== undefined) {
950
- droneId = requiredText(priorSeat, "id");
951
- droneLabel = requiredText(priorSeat, "label");
952
- generation = requiredInteger(priorSeat, "attach_generation") + 1;
953
- this.#database.prepare(`
954
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
955
- `).run(now, generation, droneId);
956
- reattached = true;
957
- } else {
958
- droneId = input.droneId;
959
- droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
960
- this.#database.prepare(`
961
- INSERT INTO drones (
962
- id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
963
- attach_generation
964
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
965
- `).run(
966
- droneId,
967
- input.cubeId,
968
- input.roleId,
969
- this.#principal.id,
970
- droneLabel,
971
- now,
972
- now,
973
- input.retryKey,
974
- );
975
- reattached = false;
976
- generation = 1;
977
- }
1462
+ droneId = input.droneId;
1463
+ droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
978
1464
  this.#database.prepare(`
979
- INSERT INTO seat_attach_bindings (
980
- client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
981
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
1465
+ INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
1466
+ VALUES (?, ?, ?, ?, ?, ?, ?)
982
1467
  `).run(
983
- this.#principal.id, input.retryKey, input.cubeId, input.roleId,
984
- droneId, input.priorDroneId ?? null, now,
1468
+ droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
985
1469
  );
986
1470
  }
1471
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
987
1472
 
988
- const oldSessions = this.#database.prepare(
989
- "SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?",
990
- ).all(droneId, this.#principal.id, input.cubeId)
991
- .map((row) => requiredText(row, "id"));
992
- if (oldSessions.length > 0) {
993
- const placeholders = oldSessions.map(() => "?").join(", ");
994
- this.#database.prepare(`
995
- UPDATE drone_session_credentials SET revoked_at = ?
996
- WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
997
- `).run(now, ...oldSessions);
998
- this.#database.prepare(`
999
- UPDATE drone_sessions SET revoked_at = ?
1000
- WHERE id IN (${placeholders}) AND revoked_at IS NULL
1001
- `).run(now, ...oldSessions);
1002
- }
1003
1473
  this.#database.prepare(`
1004
1474
  INSERT INTO drone_sessions (
1005
1475
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -1047,9 +1517,7 @@ class SqliteScopedStore implements ScopedStore {
1047
1517
  drone: { id: droneId, label: droneLabel },
1048
1518
  sessionId: input.sessionId,
1049
1519
  expiresAt: input.expiresAt,
1050
- generation,
1051
- reattached,
1052
- revokedSessionIds: oldSessions,
1520
+ result: "created",
1053
1521
  };
1054
1522
  } catch (error) {
1055
1523
  try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
@@ -1057,9 +1525,49 @@ class SqliteScopedStore implements ScopedStore {
1057
1525
  }
1058
1526
  }
1059
1527
 
1060
- subscribeActivity(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
1528
+ subscribeActivity(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
1061
1529
  this.#requireCube(cubeId, "read");
1062
- return this.#activityHub.subscribe(cubeId, listener);
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
+ }
1536
+ if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId)) listener(entry);
1537
+ });
1538
+ }
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
+
1566
+ #allowsDirectedWork(cubeId: string): boolean {
1567
+ const scope = this.#scope("write");
1568
+ return this.#database.prepare(`
1569
+ SELECT 1 AS allowed FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
1570
+ `).get(cubeId, ...scope.parameters) !== undefined;
1063
1571
  }
1064
1572
 
1065
1573
  #requireCube(cubeId: string, access: CubeAccess): void {
@@ -1068,7 +1576,15 @@ class SqliteScopedStore implements ScopedStore {
1068
1576
  const row = this.#database.prepare(`
1069
1577
  SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
1070
1578
  `).get(cubeId, ...scope.parameters);
1071
- 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();
1072
1588
  }
1073
1589
 
1074
1590
  #nextActivityTimestamp(cubeId: string): string {
@@ -1123,14 +1639,16 @@ class SqliteScopedStore implements ScopedStore {
1123
1639
  if (valid === undefined) throw new ScopedStoreError();
1124
1640
  }
1125
1641
 
1126
- #countAfter(cubeId: string, cursor: LogCursor | null): number {
1642
+ #countAfter(cubeId: string, cursor: LogCursor | null, broadcastOnly: boolean): number {
1643
+ const visibility = broadcastOnly ? "AND visibility = 'broadcast'" : "";
1127
1644
  const row = cursor === null
1128
1645
  ? this.#database.prepare(
1129
- "SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ?",
1646
+ `SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ? ${visibility}`,
1130
1647
  ).get(cubeId)
1131
1648
  : this.#database.prepare(`
1132
1649
  SELECT COUNT(*) AS count FROM activity_log
1133
1650
  WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
1651
+ ${visibility}
1134
1652
  `).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
1135
1653
  if (row === undefined) throw new Error("Activity count query returned no row.");
1136
1654
  return requiredInteger(row, "count");
@@ -1152,7 +1670,7 @@ class SqliteScopedStore implements ScopedStore {
1152
1670
  return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
1153
1671
  }
1154
1672
 
1155
- #claims(cubeId: string): ClaimRecord[] {
1673
+ #claims(cubeId: string, broadcastOnly: boolean): ClaimRecord[] {
1156
1674
  return this.#database.prepare(`
1157
1675
  SELECT acknowledgement.entry_id AS log_entry_id,
1158
1676
  acknowledgement.claimant_drone_id,
@@ -1166,6 +1684,7 @@ class SqliteScopedStore implements ScopedStore {
1166
1684
  LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
1167
1685
  WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
1168
1686
  AND acknowledgement.claimant_drone_id IS NOT NULL
1687
+ AND (${broadcastOnly ? "entry.visibility = 'broadcast'" : "1 = 1"})
1169
1688
  ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
1170
1689
  acknowledgement.claimant_drone_id
1171
1690
  `).all(cubeId).map(claimRecord);
@@ -1323,7 +1842,7 @@ class SqliteMaintenanceStore implements MaintenanceStore {
1323
1842
  for (const table of [
1324
1843
  "cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
1325
1844
  "decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
1326
- "seat_attach_bindings", "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1845
+ "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1327
1846
  "enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
1328
1847
  "enrollment_invitations",
1329
1848
  ]) this.#database.exec(`DELETE FROM ${table}`);
@@ -1491,9 +2010,9 @@ class SqliteMaintenanceStore implements MaintenanceStore {
1491
2010
  }
1492
2011
 
1493
2012
  class ActivityHub {
1494
- readonly #listeners = new Map<string, Set<(entry: EnrichedActivityRecord) => void>>();
2013
+ readonly #listeners = new Map<string, Set<(entry: ActivityStreamRecord) => void>>();
1495
2014
 
1496
- subscribe(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
2015
+ subscribe(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
1497
2016
  const listeners = this.#listeners.get(cubeId) ?? new Set();
1498
2017
  listeners.add(listener);
1499
2018
  this.#listeners.set(cubeId, listeners);
@@ -1503,7 +2022,7 @@ class ActivityHub {
1503
2022
  };
1504
2023
  }
1505
2024
 
1506
- publish(cubeId: string, entry: EnrichedActivityRecord): void {
2025
+ publish(cubeId: string, entry: ActivityStreamRecord): void {
1507
2026
  for (const listener of this.#listeners.get(cubeId) ?? []) {
1508
2027
  try {
1509
2028
  listener(entry);
@@ -1514,6 +2033,64 @@ class ActivityHub {
1514
2033
  }
1515
2034
  }
1516
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
+
1517
2094
  class SqliteCredentialStore implements CredentialStore {
1518
2095
  readonly #database: DatabaseSync;
1519
2096
  readonly #clock: () => Date;
@@ -1557,12 +2134,39 @@ class SqliteCredentialStore implements CredentialStore {
1557
2134
  readonly digest: DigestPair;
1558
2135
  readonly expiresAt: string;
1559
2136
  readonly purpose: "owner" | "client";
1560
- }): void {
2137
+ readonly cubeSelector?: { readonly kind: "id" | "name"; readonly value: string };
2138
+ readonly access?: CubeAccess;
2139
+ }): InvitationCubeScope | null {
1561
2140
  assertCanonicalUuid(input.id, "Invitation id");
1562
2141
  validateDigest(input.digest);
1563
2142
  validateTimestamp(input.expiresAt);
1564
2143
  this.#database.exec("BEGIN IMMEDIATE");
1565
2144
  try {
2145
+ let scope: InvitationCubeScope | null = null;
2146
+ if (input.cubeSelector !== undefined) {
2147
+ if (input.purpose !== "client" || input.access === undefined) {
2148
+ throw new Error("Only client invitations may carry a complete cube scope.");
2149
+ }
2150
+ if (input.access !== "read" && input.access !== "write" && input.access !== "manage") {
2151
+ throw new Error("Unknown cube access grant.");
2152
+ }
2153
+ const rows = input.cubeSelector.kind === "id"
2154
+ ? this.#database.prepare("SELECT id, name FROM cubes WHERE id = ?").all(input.cubeSelector.value)
2155
+ : this.#database.prepare("SELECT id, name FROM cubes WHERE name = ? ORDER BY id").all(input.cubeSelector.value);
2156
+ if (rows.length === 0) throw new InvitationCubeNotFoundError();
2157
+ if (rows.length > 1) {
2158
+ throw new InvitationCubeAmbiguousError(rows.map((row) => requiredText(row, "id")));
2159
+ }
2160
+ const cubeId = requiredText(rows[0]!, "id");
2161
+ assertCanonicalUuid(cubeId, "Resolved cube id");
2162
+ scope = {
2163
+ cubeId,
2164
+ cubeName: requiredText(rows[0]!, "name"),
2165
+ access: input.access,
2166
+ };
2167
+ } else if (input.access !== undefined) {
2168
+ throw new Error("Invitation access requires a cube selector.");
2169
+ }
1566
2170
  let ownerEpoch: number | null = null;
1567
2171
  if (input.purpose === "owner") {
1568
2172
  const state = this.#database.prepare(
@@ -1587,13 +2191,15 @@ class SqliteCredentialStore implements CredentialStore {
1587
2191
  }
1588
2192
  this.#database.prepare(`
1589
2193
  INSERT INTO enrollment_invitations (
1590
- id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch
1591
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
2194
+ id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch,
2195
+ cube_id, access
2196
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1592
2197
  `).run(
1593
2198
  input.id, input.digest.lookup, input.digest.verifier, input.expiresAt,
1594
- this.#now(), input.purpose, ownerEpoch,
2199
+ this.#now(), input.purpose, ownerEpoch, scope?.cubeId ?? null, scope?.access ?? null,
1595
2200
  );
1596
2201
  this.#database.exec("COMMIT");
2202
+ return scope;
1597
2203
  } catch (error) {
1598
2204
  try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
1599
2205
  throw error;
@@ -1610,7 +2216,7 @@ class SqliteCredentialStore implements CredentialStore {
1610
2216
  COALESCE(invitation.expires_at, '') AS expires_at,
1611
2217
  invitation.consumed_at, invitation.revoked_at,
1612
2218
  COALESCE(invitation.purpose, 'client') AS purpose,
1613
- invitation.owner_epoch
2219
+ invitation.owner_epoch, invitation.cube_id, invitation.access
1614
2220
  FROM (SELECT 1) AS seed
1615
2221
  LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
1616
2222
  `).get(lookup);
@@ -1641,7 +2247,8 @@ class SqliteCredentialStore implements CredentialStore {
1641
2247
  COALESCE(invitation.purpose, 'client') AS purpose,
1642
2248
  invitation.owner_epoch,
1643
2249
  COALESCE(invitation.expires_at, '') AS expires_at,
1644
- invitation.consumed_at, invitation.revoked_at
2250
+ invitation.consumed_at, invitation.revoked_at,
2251
+ invitation.cube_id, invitation.access
1645
2252
  FROM (SELECT 1) AS seed
1646
2253
  LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
1647
2254
  `).get(input.invitationId);
@@ -1689,6 +2296,12 @@ class SqliteCredentialStore implements CredentialStore {
1689
2296
  const ownerEpoch = invitation["owner_epoch"] === null
1690
2297
  ? null
1691
2298
  : requiredInteger(invitation, "owner_epoch");
2299
+ const cubeId = nullableText(invitation, "cube_id");
2300
+ const accessValue = nullableText(invitation, "access");
2301
+ const access = accessValue === null ? null : cubeAccess(accessValue);
2302
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
2303
+ throw new Error("Invalid invitation cube scope.");
2304
+ }
1692
2305
  if (purpose === "owner") {
1693
2306
  const state = this.#database.prepare(`
1694
2307
  SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
@@ -1699,6 +2312,10 @@ class SqliteCredentialStore implements CredentialStore {
1699
2312
  return null;
1700
2313
  }
1701
2314
  }
2315
+ if (cubeId !== null && this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(cubeId) === undefined) {
2316
+ this.#database.exec("ROLLBACK");
2317
+ return null;
2318
+ }
1702
2319
  this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
1703
2320
  this.#database.prepare(
1704
2321
  "INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)",
@@ -1723,6 +2340,13 @@ class SqliteCredentialStore implements CredentialStore {
1723
2340
  `).run(input.clientId, now);
1724
2341
  this.#mutationHook?.("enrollment.insert-capability");
1725
2342
  }
2343
+ if (cubeId !== null && access !== null) {
2344
+ this.#database.prepare(`
2345
+ INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
2346
+ VALUES (?, ?, ?, ?)
2347
+ `).run(input.clientId, cubeId, access, now);
2348
+ this.#mutationHook?.("enrollment.insert-grant");
2349
+ }
1726
2350
  this.#database.prepare(`
1727
2351
  INSERT INTO enrollment_claims (
1728
2352
  invitation_id, retry_key, client_id, requested_client_name,
@@ -1791,9 +2415,8 @@ class SqliteCredentialStore implements CredentialStore {
1791
2415
  SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
1792
2416
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
1793
2417
  session.expires_at,
1794
- COALESCE(
1795
- credential.revoked_at, session.revoked_at, client.revoked_at, drone.evicted_at
1796
- ) AS revoked_at
2418
+ COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
2419
+ drone.evicted_at
1797
2420
  FROM drone_session_credentials AS credential
1798
2421
  JOIN drone_sessions AS session ON session.id = credential.session_id
1799
2422
  JOIN clients AS client ON client.id = session.client_id
@@ -1805,6 +2428,21 @@ class SqliteCredentialStore implements CredentialStore {
1805
2428
  return row === undefined ? null : storedDroneSessionDigest(row);
1806
2429
  }
1807
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
+
1808
2446
  rotateClientCredential(input: {
1809
2447
  readonly clientId: string;
1810
2448
  readonly credentialId: string;
@@ -1827,6 +2465,16 @@ class SqliteCredentialStore implements CredentialStore {
1827
2465
  UPDATE client_credentials SET revoked_at = ?
1828
2466
  WHERE client_id = ? AND revoked_at IS NULL
1829
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);
1830
2478
  this.#database.prepare(`
1831
2479
  INSERT INTO client_credentials (
1832
2480
  id, client_id, lookup_digest, verifier_digest, created_at
@@ -1881,9 +2529,7 @@ class SqliteCredentialStore implements CredentialStore {
1881
2529
  async function prepareDatabasePath(path: string): Promise<string> {
1882
2530
  if (path === ":memory:") throw new Error("The server store requires a file-backed database.");
1883
2531
  const databasePath = resolve(path);
1884
- const directory = dirname(databasePath);
1885
- await ensureDirectoryTree(directory);
1886
- await chmod(directory, 0o700);
2532
+ const directory = await preparePrivateDataDirectory(dirname(databasePath));
1887
2533
  try {
1888
2534
  const handle = await open(databasePath, "ax", 0o600);
1889
2535
  await handle.close();
@@ -1898,6 +2544,13 @@ async function prepareDatabasePath(path: string): Promise<string> {
1898
2544
  return databasePath;
1899
2545
  }
1900
2546
 
2547
+ export async function preparePrivateDataDirectory(path: string): Promise<string> {
2548
+ const directory = resolve(path);
2549
+ await ensureDirectoryTree(directory);
2550
+ await chmod(directory, 0o700);
2551
+ return directory;
2552
+ }
2553
+
1901
2554
  async function ensureDirectoryTree(directory: string): Promise<void> {
1902
2555
  const { root } = parse(directory);
1903
2556
  let current = root;
@@ -1951,6 +2604,19 @@ function configureDatabase(database: DatabaseSync): void {
1951
2604
  }
1952
2605
  }
1953
2606
 
2607
+ function configureExistingDatabase(database: DatabaseSync): void {
2608
+ database.exec(`
2609
+ PRAGMA foreign_keys = ON;
2610
+ PRAGMA synchronous = FULL;
2611
+ PRAGMA trusted_schema = OFF;
2612
+ PRAGMA secure_delete = ON;
2613
+ PRAGMA busy_timeout = 5000;
2614
+ `);
2615
+ if (readPragma(database, "journal_mode") !== "wal") {
2616
+ throw new Error("SQLite WAL mode is required.");
2617
+ }
2618
+ }
2619
+
1954
2620
  function diagnostics(database: DatabaseSync): StoreDiagnostics {
1955
2621
  const journalMode = readPragma(database, "journal_mode");
1956
2622
  const foreignKeys = readPragma(database, "foreign_keys");
@@ -1975,6 +2641,7 @@ function cubeRecord(row: CubeRow): CubeRecord {
1975
2641
  ownerId: row.owner_id,
1976
2642
  name: row.name,
1977
2643
  directive: row.directive,
2644
+ messageTaxonomy: parseTaxonomy(row.message_taxonomy),
1978
2645
  createdAt: row.created_at,
1979
2646
  updatedAt: row.updated_at,
1980
2647
  };
@@ -1998,6 +2665,7 @@ function cubeRow(row: Record<string, unknown>): CubeRow {
1998
2665
  owner_id: requiredText(row, "owner_id"),
1999
2666
  name: requiredText(row, "name"),
2000
2667
  directive: requiredText(row, "directive"),
2668
+ message_taxonomy: nullableText(row, "message_taxonomy"),
2001
2669
  created_at: requiredText(row, "created_at"),
2002
2670
  updated_at: requiredText(row, "updated_at"),
2003
2671
  };
@@ -2083,8 +2751,12 @@ function roleRecord(row: Record<string, unknown>): RoleRecord {
2083
2751
  cube_id: requiredText(row, "cube_id"),
2084
2752
  name: requiredText(row, "name"),
2085
2753
  short_description: requiredText(row, "short_description"),
2754
+ detailed_description: requiredText(row, "detailed_description"),
2086
2755
  is_default: requiredInteger(row, "is_default") === 1,
2756
+ is_mandatory: requiredInteger(row, "is_mandatory") === 1,
2087
2757
  is_human_seat: requiredInteger(row, "is_human_seat") === 1,
2758
+ can_broadcast: requiredInteger(row, "can_broadcast") === 1,
2759
+ receives_all_direct: requiredInteger(row, "receives_all_direct") === 1,
2088
2760
  role_class: roleClass,
2089
2761
  created_at: requiredText(row, "created_at"),
2090
2762
  };
@@ -2100,6 +2772,10 @@ function createCubeRecord(row: Record<string, unknown>): CreateCubeRecord {
2100
2772
  }
2101
2773
 
2102
2774
  function droneRecord(row: Record<string, unknown>): DroneRecord {
2775
+ const posture = requiredText(row, "posture");
2776
+ if (posture !== "observer" && posture !== "participant") {
2777
+ throw new Error("Database contains invalid drone posture.");
2778
+ }
2103
2779
  return {
2104
2780
  id: requiredText(row, "id"),
2105
2781
  cube_id: requiredText(row, "cube_id"),
@@ -2107,6 +2783,7 @@ function droneRecord(row: Record<string, unknown>): DroneRecord {
2107
2783
  label: requiredText(row, "label"),
2108
2784
  last_seen: requiredText(row, "last_seen"),
2109
2785
  hostname: nullableText(row, "hostname"),
2786
+ posture,
2110
2787
  created_at: requiredText(row, "created_at"),
2111
2788
  };
2112
2789
  }
@@ -2155,7 +2832,20 @@ function storedInvitationDigest(row: Record<string, unknown>): StoredInvitationD
2155
2832
  }
2156
2833
  const epochValue = row["owner_epoch"];
2157
2834
  const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
2158
- return { ...digest, purpose, ownerEpoch };
2835
+ const cubeId = nullableText(row, "cube_id");
2836
+ const accessValue = nullableText(row, "access");
2837
+ const access = accessValue === null ? null : cubeAccess(accessValue);
2838
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
2839
+ throw new Error("Database contains invalid invitation cube scope.");
2840
+ }
2841
+ return { ...digest, purpose, ownerEpoch, cubeId, access };
2842
+ }
2843
+
2844
+ function cubeAccess(value: string): CubeAccess {
2845
+ if (value !== "read" && value !== "write" && value !== "manage") {
2846
+ throw new Error("Database contains invalid cube access.");
2847
+ }
2848
+ return value;
2159
2849
  }
2160
2850
 
2161
2851
  function enrollmentClaimResult(
@@ -2203,6 +2893,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
2203
2893
  cubeId: requiredText(row, "cube_id"),
2204
2894
  droneId: requiredText(row, "drone_id"),
2205
2895
  expiresAt: requiredText(row, "expires_at"),
2896
+ evictedAt: nullableText(row, "evicted_at"),
2206
2897
  };
2207
2898
  }
2208
2899
 
@@ -2239,6 +2930,32 @@ function validatePresentationName(value: string): void {
2239
2930
  }
2240
2931
  }
2241
2932
 
2933
+ function validateRoleName(value: string): void {
2934
+ if (typeof value !== "string" || value.length < 1 || value.length > 64) {
2935
+ throw new Error("Role name must contain 1 to 64 characters.");
2936
+ }
2937
+ }
2938
+
2939
+ function validateRoleShortDescription(value: string): void {
2940
+ if (typeof value !== "string" || value.length > 1_024) {
2941
+ throw new Error("Role short description must contain at most 1024 characters.");
2942
+ }
2943
+ }
2944
+
2945
+ export const MAX_ROLE_DETAILED_DESCRIPTION_CHARS = 51_200;
2946
+
2947
+ export function assertRoleTextWriteAllowed(value: string, previous?: string): void {
2948
+ if (typeof value !== "string") throw new TypeError("Role detailed description must be text.");
2949
+ if (value.length <= MAX_ROLE_DETAILED_DESCRIPTION_CHARS) return;
2950
+ if (previous !== undefined && previous.length > MAX_ROLE_DETAILED_DESCRIPTION_CHARS &&
2951
+ value.length < previous.length) return;
2952
+ throw new RangeError("Role detailed description is too large.");
2953
+ }
2954
+
2955
+ function booleanInteger(value: boolean): 0 | 1 {
2956
+ return value ? 1 : 0;
2957
+ }
2958
+
2242
2959
  function validateBoundedText(value: string, name: string, maxBytes: number): void {
2243
2960
  if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
2244
2961
  throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);
@@ -2262,6 +2979,34 @@ function validateDirective(value: string): void {
2262
2979
  }
2263
2980
  }
2264
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
+
2265
3010
  function validateDigest(digest: DigestPair): void {
2266
3011
  validateLookup(digest.lookup);
2267
3012
  if (digest.verifier.length !== 32) throw new Error("Verifier digest must contain 32 bytes.");