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/dist/store.js CHANGED
@@ -1,11 +1,13 @@
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 { applyMigrations } from "./migrations.js";
6
+ import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
7
7
  import { operatorErrors } from "./operator-error.js";
8
8
  import { assertCanonicalUuid, assertServerDerivedPrincipal, } from "./principal.js";
9
+ import { patchRoleSectionText } from "./role-section.js";
10
+ import { validateMessageTaxonomy } from "./message-taxonomy.js";
9
11
  export const DEFAULT_CUBE_LIMITS = Object.freeze({
10
12
  maxCubesPerClient: 100,
11
13
  maxCubesTotal: 1_000,
@@ -15,6 +17,15 @@ export const DEFAULT_STORAGE_LIMITS = Object.freeze({
15
17
  maxDatabaseBytes: 1_073_741_824,
16
18
  minFreeDiskBytes: 67_108_864,
17
19
  });
20
+ export class InvitationCubeNotFoundError extends Error {
21
+ }
22
+ export class InvitationCubeAmbiguousError extends Error {
23
+ candidateIds;
24
+ constructor(candidateIds) {
25
+ super("Cube name is ambiguous.");
26
+ this.candidateIds = Object.freeze([...candidateIds]);
27
+ }
28
+ }
18
29
  export class ScopedStoreError extends Error {
19
30
  code = "NOT_FOUND";
20
31
  constructor() {
@@ -22,6 +33,27 @@ export class ScopedStoreError extends Error {
22
33
  this.name = "ScopedStoreError";
23
34
  }
24
35
  }
36
+ export class RoleConflictError extends Error {
37
+ code = "ROLE_ALREADY_EXISTS";
38
+ constructor() {
39
+ super("A role with that name already exists.");
40
+ this.name = "RoleConflictError";
41
+ }
42
+ }
43
+ export class DefaultRoleRequiredError extends Error {
44
+ code = "DEFAULT_ROLE_REQUIRED";
45
+ constructor() {
46
+ super("A cube must retain one default role.");
47
+ this.name = "DefaultRoleRequiredError";
48
+ }
49
+ }
50
+ export class RoleSectionConflictError extends Error {
51
+ code = "ROLE_SECTION_CONFLICT";
52
+ constructor() {
53
+ super("The role section patch conflicts with the current role text.");
54
+ this.name = "RoleSectionConflictError";
55
+ }
56
+ }
25
57
  export class CursorExpiredError extends Error {
26
58
  code = "CURSOR_EXPIRED";
27
59
  constructor() {
@@ -29,11 +61,11 @@ export class CursorExpiredError extends Error {
29
61
  this.name = "CursorExpiredError";
30
62
  }
31
63
  }
32
- export class AttachConflictError extends Error {
33
- code = "ATTACH_CONFLICT";
64
+ export class AttachSessionRejectedError extends Error {
65
+ code = "SESSION_REJECTED";
34
66
  constructor() {
35
- super("The attach request conflicts with an existing attachment.");
36
- this.name = "AttachConflictError";
67
+ super("The saved session is not accepted for this seat.");
68
+ this.name = "AttachSessionRejectedError";
37
69
  }
38
70
  }
39
71
  export class StorageCapacityError extends Error {
@@ -100,8 +132,14 @@ export async function openStore(options) {
100
132
  });
101
133
  const clock = options.clock ?? (() => new Date());
102
134
  try {
103
- configureDatabase(database);
104
- applyMigrations(database);
135
+ if (options.migrationMode === "require-current") {
136
+ configureExistingDatabase(database);
137
+ assertMigrationsCurrent(database);
138
+ }
139
+ else {
140
+ configureDatabase(database);
141
+ applyMigrations(database);
142
+ }
105
143
  }
106
144
  catch (error) {
107
145
  database.close();
@@ -113,9 +151,10 @@ export async function openStore(options) {
113
151
  throw new Error("SQLite page size is unavailable.");
114
152
  }
115
153
  const capacityGuard = new StorageCapacityGuard(storageLimits, capacityProbe, requiredInteger(pageRow, "page_size"));
154
+ const activityHub = new ActivityHub();
116
155
  const maintenance = new SqliteMaintenanceStore(database, clock);
156
+ const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
117
157
  const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
118
- const activityHub = new ActivityHub();
119
158
  return Object.freeze({
120
159
  forPrincipal: (principal) => {
121
160
  assertServerDerivedPrincipal(principal);
@@ -123,6 +162,7 @@ export async function openStore(options) {
123
162
  },
124
163
  maintenance,
125
164
  credentials,
165
+ liveness,
126
166
  diagnostics: () => diagnostics(database),
127
167
  close: () => database.close(),
128
168
  });
@@ -230,7 +270,8 @@ class SqliteScopedStore {
230
270
  listCubes() {
231
271
  const scope = this.#scope("read");
232
272
  const rows = this.#database.prepare(`
233
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
273
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
274
+ c.created_at, c.updated_at
234
275
  FROM cubes AS c
235
276
  WHERE ${scope.sql}
236
277
  ORDER BY c.id
@@ -241,25 +282,43 @@ class SqliteScopedStore {
241
282
  assertCanonicalUuid(cubeId, "Cube id");
242
283
  const scope = this.#scope("read");
243
284
  const row = this.#database.prepare(`
244
- SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
285
+ SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
286
+ c.created_at, c.updated_at
245
287
  FROM cubes AS c
246
288
  WHERE c.id = ? AND ${scope.sql}
247
289
  `).get(cubeId, ...scope.parameters);
248
290
  return row === undefined ? null : cubeRecord(cubeRow(row));
249
291
  }
250
292
  updateDirective(cubeId, directive) {
293
+ this.updateCube(cubeId, { directive });
294
+ }
295
+ updateCube(cubeId, input) {
251
296
  assertCanonicalUuid(cubeId, "Cube id");
252
- validateDirective(directive);
297
+ if (input.directive === undefined && input.messageTaxonomy === undefined) {
298
+ throw new TypeError("At least one cube field is required.");
299
+ }
300
+ if (input.directive !== undefined)
301
+ validateDirective(input.directive);
302
+ const taxonomy = input.messageTaxonomy === undefined
303
+ ? undefined
304
+ : validateMessageTaxonomy(input.messageTaxonomy);
305
+ const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
253
306
  const scope = this.#scope("manage");
254
307
  this.#requireCube(cubeId, "manage");
255
- this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
308
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""));
256
309
  const result = this.#database.prepare(`
257
310
  UPDATE cubes AS c
258
- SET directive = ?, updated_at = ?
311
+ SET directive = COALESCE(?, directive),
312
+ message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
313
+ updated_at = ?
259
314
  WHERE c.id = ? AND ${scope.sql}
260
- `).run(directive, this.#now(), cubeId, ...scope.parameters);
315
+ `).run(input.directive ?? null, serializedTaxonomy === undefined ? 0 : 1, serializedTaxonomy ?? null, this.#now(), cubeId, ...scope.parameters);
261
316
  if (result.changes !== 1)
262
317
  throw new ScopedStoreError();
318
+ const cube = this.getCube(cubeId);
319
+ if (cube === null)
320
+ throw new ScopedStoreError();
321
+ return cube;
263
322
  }
264
323
  appendActivity(cubeId, message) {
265
324
  const entry = this.appendLog(cubeId, { message });
@@ -293,21 +352,269 @@ class SqliteScopedStore {
293
352
  listRoles(cubeId) {
294
353
  this.#requireCube(cubeId, "read");
295
354
  const rows = this.#database.prepare(`
296
- SELECT id, cube_id, name, short_description, is_default, is_human_seat,
297
- role_class, created_at
355
+ SELECT id, cube_id, name, short_description, detailed_description,
356
+ is_default, is_mandatory, is_human_seat, can_broadcast,
357
+ receives_all_direct, role_class, created_at
298
358
  FROM roles WHERE cube_id = ? ORDER BY name, id
299
359
  `).all(cubeId);
300
360
  return rows.map(roleRecord);
301
361
  }
362
+ createRole(cubeId, input) {
363
+ assertCanonicalUuid(cubeId, "Cube id");
364
+ validateRoleName(input.name);
365
+ const shortDescription = input.shortDescription ?? "";
366
+ const detailedDescription = input.detailedDescription ?? "";
367
+ validateRoleShortDescription(shortDescription);
368
+ assertRoleTextWriteAllowed(detailedDescription);
369
+ for (const value of [
370
+ input.isDefault,
371
+ input.isMandatory,
372
+ input.isHumanSeat,
373
+ input.canBroadcast,
374
+ input.receivesAllDirect,
375
+ ]) {
376
+ if (value !== undefined && typeof value !== "boolean")
377
+ throw new TypeError("Role flags must be boolean.");
378
+ }
379
+ validateRoleClass(input.roleClass);
380
+ const isDefault = input.isDefault ?? false;
381
+ const isMandatory = input.isMandatory ?? false;
382
+ const isHumanSeat = input.isHumanSeat ?? false;
383
+ const canBroadcast = input.canBroadcast ?? false;
384
+ const receivesAllDirect = input.receivesAllDirect ?? false;
385
+ this.#requireCube(cubeId, "manage");
386
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name) + Buffer.byteLength(shortDescription) +
387
+ Buffer.byteLength(detailedDescription) + 8_192);
388
+ this.#database.exec("BEGIN IMMEDIATE");
389
+ try {
390
+ this.#requireCube(cubeId, "manage");
391
+ const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ?").get(cubeId, input.name);
392
+ if (duplicate !== undefined)
393
+ throw new RoleConflictError();
394
+ if (isDefault) {
395
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
396
+ .run(cubeId);
397
+ this.#mutationHook?.("role.demote-default");
398
+ }
399
+ const id = randomUUID();
400
+ this.#database.prepare(`
401
+ INSERT INTO roles (
402
+ id, cube_id, name, short_description, detailed_description,
403
+ is_default, is_mandatory, is_human_seat, can_broadcast,
404
+ receives_all_direct, role_class, created_at
405
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
406
+ `).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker", this.#now());
407
+ this.#mutationHook?.("role.insert");
408
+ const row = this.#database.prepare(`
409
+ SELECT id, cube_id, name, short_description, detailed_description,
410
+ is_default, is_mandatory, is_human_seat, can_broadcast,
411
+ receives_all_direct, role_class, created_at
412
+ FROM roles WHERE id = ? AND cube_id = ?
413
+ `).get(id, cubeId);
414
+ if (row === undefined)
415
+ throw new ScopedStoreError();
416
+ this.#database.exec("COMMIT");
417
+ this.#mutationHook?.("role.after-commit");
418
+ return roleRecord(row);
419
+ }
420
+ catch (error) {
421
+ try {
422
+ this.#database.exec("ROLLBACK");
423
+ }
424
+ catch { /* Preserve the original failure. */ }
425
+ throw error;
426
+ }
427
+ }
428
+ updateRole(cubeId, roleId, input) {
429
+ assertCanonicalUuid(cubeId, "Cube id");
430
+ assertCanonicalUuid(roleId, "Role id");
431
+ if (Object.values(input).every((value) => value === undefined)) {
432
+ throw new TypeError("At least one role field is required.");
433
+ }
434
+ if (input.name !== undefined)
435
+ validateRoleName(input.name);
436
+ if (input.shortDescription !== undefined)
437
+ validateRoleShortDescription(input.shortDescription);
438
+ if (input.detailedDescription !== undefined && typeof input.detailedDescription !== "string") {
439
+ throw new TypeError("Role detailed description must be text.");
440
+ }
441
+ for (const value of [
442
+ input.isDefault,
443
+ input.isMandatory,
444
+ input.isHumanSeat,
445
+ input.canBroadcast,
446
+ input.receivesAllDirect,
447
+ ]) {
448
+ if (value !== undefined && typeof value !== "boolean")
449
+ throw new TypeError("Role flags must be boolean.");
450
+ }
451
+ validateRoleClass(input.roleClass);
452
+ this.#requireCube(cubeId, "manage");
453
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
454
+ Buffer.byteLength(input.detailedDescription ?? "") + 8_192);
455
+ this.#database.exec("BEGIN IMMEDIATE");
456
+ try {
457
+ this.#requireCube(cubeId, "manage");
458
+ const existingRow = this.#database.prepare(`
459
+ SELECT id, cube_id, name, short_description, detailed_description,
460
+ is_default, is_mandatory, is_human_seat, can_broadcast,
461
+ receives_all_direct, role_class, created_at
462
+ FROM roles WHERE id = ? AND cube_id = ?
463
+ `).get(roleId, cubeId);
464
+ if (existingRow === undefined)
465
+ throw new ScopedStoreError();
466
+ const existing = roleRecord(existingRow);
467
+ if (input.isDefault === false && existing.is_default) {
468
+ throw new DefaultRoleRequiredError();
469
+ }
470
+ if (input.name !== undefined && input.name !== existing.name) {
471
+ const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ? AND id <> ?").get(cubeId, input.name, roleId);
472
+ if (duplicate !== undefined)
473
+ throw new RoleConflictError();
474
+ }
475
+ if (input.isDefault === true && !existing.is_default) {
476
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
477
+ .run(cubeId);
478
+ this.#mutationHook?.("role.demote-default");
479
+ }
480
+ const nextDetailedDescription = input.detailedDescription ?? existing.detailed_description;
481
+ assertRoleTextWriteAllowed(nextDetailedDescription, existing.detailed_description);
482
+ this.#database.prepare(`
483
+ UPDATE roles SET
484
+ name = ?, short_description = ?, detailed_description = ?, is_default = ?,
485
+ is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
486
+ role_class = ?
487
+ WHERE id = ? AND cube_id = ?
488
+ `).run(input.name ?? existing.name, input.shortDescription ?? existing.short_description, nextDetailedDescription, booleanInteger(input.isDefault ?? existing.is_default), booleanInteger(input.isMandatory ?? existing.is_mandatory), booleanInteger(input.isHumanSeat ?? existing.is_human_seat), booleanInteger(input.canBroadcast ?? existing.can_broadcast), booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct), input.roleClass ?? existing.role_class, roleId, cubeId);
489
+ const row = this.#database.prepare(`
490
+ SELECT id, cube_id, name, short_description, detailed_description,
491
+ is_default, is_mandatory, is_human_seat, can_broadcast,
492
+ receives_all_direct, role_class, created_at
493
+ FROM roles WHERE id = ? AND cube_id = ?
494
+ `).get(roleId, cubeId);
495
+ if (row === undefined)
496
+ throw new ScopedStoreError();
497
+ this.#database.exec("COMMIT");
498
+ this.#mutationHook?.("role.after-commit");
499
+ return roleRecord(row);
500
+ }
501
+ catch (error) {
502
+ try {
503
+ this.#database.exec("ROLLBACK");
504
+ }
505
+ catch { /* Preserve the original failure. */ }
506
+ throw error;
507
+ }
508
+ }
509
+ patchRoleSection(cubeId, roleId, input) {
510
+ assertCanonicalUuid(cubeId, "Cube id");
511
+ assertCanonicalUuid(roleId, "Role id");
512
+ this.#requireCube(cubeId, "manage");
513
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.heading) +
514
+ ("body" in input ? Buffer.byteLength(input.body) : 0) + 8_192);
515
+ this.#database.exec("BEGIN IMMEDIATE");
516
+ try {
517
+ this.#requireCube(cubeId, "manage");
518
+ const existingRow = this.#database.prepare(`
519
+ SELECT id, cube_id, name, short_description, detailed_description,
520
+ is_default, is_mandatory, is_human_seat, can_broadcast,
521
+ receives_all_direct, role_class, created_at
522
+ FROM roles WHERE id = ? AND cube_id = ?
523
+ `).get(roleId, cubeId);
524
+ if (existingRow === undefined)
525
+ throw new ScopedStoreError();
526
+ const existing = roleRecord(existingRow);
527
+ let detailedDescription;
528
+ try {
529
+ detailedDescription = patchRoleSectionText(existing.detailed_description, input);
530
+ }
531
+ catch (error) {
532
+ if (error instanceof TypeError)
533
+ throw error;
534
+ throw new RoleSectionConflictError();
535
+ }
536
+ assertRoleTextWriteAllowed(detailedDescription, existing.detailed_description);
537
+ this.#database.prepare("UPDATE roles SET detailed_description = ? WHERE id = ? AND cube_id = ?").run(detailedDescription, roleId, cubeId);
538
+ const row = this.#database.prepare(`
539
+ SELECT id, cube_id, name, short_description, detailed_description,
540
+ is_default, is_mandatory, is_human_seat, can_broadcast,
541
+ receives_all_direct, role_class, created_at
542
+ FROM roles WHERE id = ? AND cube_id = ?
543
+ `).get(roleId, cubeId);
544
+ if (row === undefined)
545
+ throw new ScopedStoreError();
546
+ this.#database.exec("COMMIT");
547
+ this.#mutationHook?.("role.after-commit");
548
+ return roleRecord(row);
549
+ }
550
+ catch (error) {
551
+ try {
552
+ this.#database.exec("ROLLBACK");
553
+ }
554
+ catch { /* Preserve the original failure. */ }
555
+ throw error;
556
+ }
557
+ }
302
558
  listDrones(cubeId) {
303
559
  this.#requireCube(cubeId, "read");
304
560
  const rows = this.#database.prepare(`
305
- SELECT id, cube_id, role_id, label, COALESCE(last_seen, created_at) AS last_seen,
306
- hostname, created_at
307
- FROM drones WHERE cube_id = ? AND evicted_at IS NULL ORDER BY label, id
561
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
562
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
563
+ drone.hostname, drone.created_at,
564
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
565
+ THEN 'participant' ELSE 'observer' END AS posture
566
+ FROM drones AS drone
567
+ LEFT JOIN clients AS client ON client.id = drone.client_id
568
+ LEFT JOIN client_cube_grants AS grant_row
569
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
570
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
571
+ ORDER BY drone.label, drone.id
308
572
  `).all(cubeId);
309
573
  return rows.map(droneRecord);
310
574
  }
575
+ listDronesSince(cubeId, since) {
576
+ this.#requireCube(cubeId, "read");
577
+ let createdAt;
578
+ let anchorId = null;
579
+ if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
580
+ const anchor = this.#database.prepare("SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?").get(since, cubeId);
581
+ if (anchor === undefined)
582
+ throw new ScopedStoreError();
583
+ anchorId = requiredText(anchor, "id");
584
+ createdAt = requiredText(anchor, "created_at");
585
+ }
586
+ else {
587
+ validateTimestamp(since);
588
+ createdAt = since;
589
+ }
590
+ const rows = this.#database.prepare(`
591
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
592
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
593
+ drone.hostname, drone.created_at,
594
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
595
+ THEN 'participant' ELSE 'observer' END AS posture,
596
+ (SELECT MAX(post.created_at) FROM activity_log AS post
597
+ WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
598
+ EXISTS (SELECT 1 FROM activity_log AS response
599
+ WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
600
+ AND (response.created_at > ? OR
601
+ (? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
602
+ FROM drones AS drone
603
+ LEFT JOIN clients AS client ON client.id = drone.client_id
604
+ LEFT JOIN client_cube_grants AS grant_row
605
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
606
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
607
+ ORDER BY drone.label, drone.id
608
+ `).all(createdAt, anchorId, createdAt, anchorId, cubeId);
609
+ return {
610
+ since: createdAt,
611
+ drones: rows.map((row) => ({
612
+ ...droneRecord(row),
613
+ last_log_post_at: nullableText(row, "last_log_post_at"),
614
+ seen_since: requiredInteger(row, "seen_since") === 1,
615
+ })),
616
+ };
617
+ }
311
618
  appendLog(cubeId, input) {
312
619
  assertCanonicalUuid(cubeId, "Cube id");
313
620
  if (input.message.length === 0 || Buffer.byteLength(input.message) > 10_240) {
@@ -353,11 +660,24 @@ class SqliteScopedStore {
353
660
  `).run(id, droneId, this.#principal.kind, this.#principal.id, input.message, createdAt, visibility, cubeId, ...scope.parameters);
354
661
  if (inserted.changes !== 1)
355
662
  throw new ScopedStoreError();
663
+ if (droneId !== null) {
664
+ this.#database.prepare(`
665
+ UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
666
+ `).run(createdAt, droneId, cubeId);
667
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
668
+ }
356
669
  if (recipients.length > 0) {
357
670
  const valid = this.#database.prepare(`
358
- SELECT COUNT(*) AS count FROM drones
359
- WHERE cube_id = ? AND evicted_at IS NULL
360
- AND id IN (${recipients.map(() => "?").join(", ")})
671
+ SELECT COUNT(*) AS count
672
+ FROM drones AS recipient
673
+ JOIN clients AS recipient_client ON recipient_client.id = recipient.client_id
674
+ JOIN client_cube_grants AS recipient_grant
675
+ ON recipient_grant.client_id = recipient.client_id
676
+ AND recipient_grant.cube_id = recipient.cube_id
677
+ WHERE recipient.cube_id = ? AND recipient.evicted_at IS NULL
678
+ AND recipient_client.revoked_at IS NULL
679
+ AND recipient_grant.access IN ('write', 'manage')
680
+ AND recipient.id IN (${recipients.map(() => "?").join(", ")})
361
681
  `).get(cubeId, ...recipients);
362
682
  if (valid === undefined)
363
683
  throw new Error("Recipient count query returned no row.");
@@ -387,6 +707,7 @@ class SqliteScopedStore {
387
707
  throw new Error("Activity read limit must be an integer from 1 to 500.");
388
708
  }
389
709
  this.#validateCursor(cubeId, cursor);
710
+ const broadcastOnly = !this.#allowsDirectedWork(cubeId);
390
711
  const cursorSql = cursor === null
391
712
  ? { sql: "1 = 1", parameters: [] }
392
713
  : {
@@ -397,6 +718,7 @@ class SqliteScopedStore {
397
718
  SELECT l.id
398
719
  FROM activity_log AS l
399
720
  WHERE l.cube_id = ? AND ${cursorSql.sql}
721
+ AND (${broadcastOnly ? "l.visibility = 'broadcast'" : "1 = 1"})
400
722
  ORDER BY l.created_at, l.id
401
723
  LIMIT ?
402
724
  `).all(cubeId, ...cursorSql.parameters, limit + 1);
@@ -405,13 +727,15 @@ class SqliteScopedStore {
405
727
  const nextCursor = entries.length === 0
406
728
  ? cursor
407
729
  : { id: entries.at(-1).id, created_at: entries.at(-1).created_at };
408
- const behind = nextCursor === null ? this.#countAfter(cubeId, null) : this.#countAfter(cubeId, nextCursor);
730
+ const behind = nextCursor === null
731
+ ? this.#countAfter(cubeId, null, broadcastOnly)
732
+ : this.#countAfter(cubeId, nextCursor, broadcastOnly);
409
733
  return {
410
734
  entries,
411
735
  cursor: nextCursor,
412
736
  behind_by: behind,
413
737
  has_more: behind > 0,
414
- claims: this.#claims(cubeId),
738
+ claims: this.#claims(cubeId, broadcastOnly),
415
739
  };
416
740
  }
417
741
  acknowledge(cubeId, entryId, kind) {
@@ -419,18 +743,58 @@ class SqliteScopedStore {
419
743
  assertCanonicalUuid(entryId, "Activity entry id");
420
744
  if (kind !== "ack" && kind !== "claim")
421
745
  throw new Error("Unknown acknowledgement kind.");
422
- const exists = this.#database.prepare("SELECT 1 AS present FROM activity_log WHERE id = ? AND cube_id = ?").get(entryId, cubeId);
423
- if (exists === undefined)
746
+ const original = this.#database.prepare(`
747
+ SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
748
+ `).get(entryId, cubeId);
749
+ if (original === undefined)
424
750
  throw new ScopedStoreError();
425
751
  this.#capacityGuard.assertCanGrow(512);
426
752
  const claimant = this.#principal.kind === "drone-session"
427
753
  ? this.#principal.droneId
428
754
  : this.#principal.id;
429
- this.#database.prepare(`
755
+ const ackAt = this.#now();
756
+ const inserted = this.#database.prepare(`
430
757
  INSERT OR IGNORE INTO activity_acks (
431
758
  entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
432
759
  ) VALUES (?, ?, ?, ?, ?, ?)
433
- `).run(entryId, this.#principal.kind, this.#principal.id, kind, this.#now(), claimant);
760
+ `).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
761
+ if (inserted.changes !== 1 || this.#principal.kind !== "drone-session")
762
+ return;
763
+ const claimantDroneId = this.#principal.droneId;
764
+ const actor = this.#database.prepare(`
765
+ SELECT drone.label, role.name AS role_name
766
+ FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
767
+ WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
768
+ `).get(claimantDroneId, cubeId);
769
+ const preview = activityPreview(requiredText(original, "message"));
770
+ const originalAuthor = nullableText(original, "drone_id");
771
+ const recipients = kind === "ack"
772
+ ? originalAuthor === null ? [] : [originalAuthor]
773
+ : this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
774
+ .filter((droneId) => droneId !== claimantDroneId);
775
+ if (recipients.length === 0)
776
+ return;
777
+ const roleName = actor === undefined ? null : requiredText(actor, "role_name");
778
+ const notification = {
779
+ kind,
780
+ id: randomUUID(),
781
+ cube_id: cubeId,
782
+ drone_id: claimantDroneId,
783
+ drone_label: actor === undefined ? null : requiredText(actor, "label"),
784
+ role_name: roleName,
785
+ message: `[${kind.toUpperCase()}] ${preview}`,
786
+ created_at: ackAt,
787
+ visibility: "direct",
788
+ recipient_drone_ids: recipients,
789
+ log_entry_id: entryId,
790
+ ack_kind: kind,
791
+ ack_at: ackAt,
792
+ entry_preview: preview,
793
+ ...(kind === "ack"
794
+ ? { author_drone_id: recipients[0] }
795
+ : { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
796
+ };
797
+ this.#activityHub.publish(cubeId, notification);
434
798
  }
435
799
  recordDecision(cubeId, input) {
436
800
  this.#requireCube(cubeId, "manage");
@@ -480,7 +844,6 @@ class SqliteScopedStore {
480
844
  throw new ScopedStoreError();
481
845
  assertCanonicalUuid(input.cubeId, "Cube id");
482
846
  assertCanonicalUuid(input.roleId, "Role id");
483
- assertCanonicalUuid(input.retryKey, "Retry key");
484
847
  if (input.priorDroneId !== undefined)
485
848
  assertCanonicalUuid(input.priorDroneId, "Prior drone id");
486
849
  assertCanonicalUuid(input.droneId, "Drone id");
@@ -508,81 +871,95 @@ class SqliteScopedStore {
508
871
  `).get(input.cubeId, input.roleId, ...scope.parameters);
509
872
  if (roleRow === undefined)
510
873
  throw new ScopedStoreError();
511
- const retryBinding = this.#database.prepare(`
512
- SELECT binding.cube_id AS binding_cube_id,
513
- binding.requested_role_id AS binding_requested_role_id,
514
- binding.prior_drone_id, drone.id, drone.label,
515
- drone.attach_generation, drone.evicted_at
516
- FROM seat_attach_bindings AS binding
517
- JOIN drones AS drone ON drone.id = binding.drone_id
518
- WHERE binding.client_id = ? AND binding.retry_key = ?
519
- `).get(this.#principal.id, input.retryKey);
874
+ const bound = this.#database.prepare(`
875
+ SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
876
+ session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
877
+ session.expires_at, session.revoked_at AS session_revoked_at,
878
+ drone.role_id, drone.label, drone.evicted_at
879
+ FROM drone_session_credentials AS credential
880
+ JOIN drone_sessions AS session ON session.id = credential.session_id
881
+ JOIN drones AS drone ON drone.id = session.drone_id
882
+ AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
883
+ WHERE credential.lookup_digest = ?
884
+ `).get(input.credentialDigest.lookup);
885
+ if (bound !== undefined) {
886
+ const verifier = requiredBuffer(bound, "verifier_digest");
887
+ if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
888
+ optionalText(bound, "credential_revoked_at") !== null ||
889
+ optionalText(bound, "session_revoked_at") !== null ||
890
+ optionalText(bound, "evicted_at") !== null ||
891
+ requiredText(bound, "expires_at") <= now ||
892
+ requiredText(bound, "client_id") !== this.#principal.id ||
893
+ requiredText(bound, "cube_id") !== input.cubeId ||
894
+ requiredText(bound, "role_id") !== input.roleId ||
895
+ (input.priorDroneId !== undefined &&
896
+ requiredText(bound, "drone_id") !== input.priorDroneId)) {
897
+ throw new AttachSessionRejectedError();
898
+ }
899
+ const droneId = requiredText(bound, "drone_id");
900
+ this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
901
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
902
+ const attachedRoleRow = this.#database.prepare(`
903
+ SELECT id AS role_id, name AS role_name, role_class, is_human_seat
904
+ FROM roles WHERE id = ? AND cube_id = ?
905
+ `).get(requiredText(bound, "role_id"), input.cubeId);
906
+ if (attachedRoleRow === undefined)
907
+ throw new Error("Attached drone role is unavailable.");
908
+ const roleClass = requiredText(attachedRoleRow, "role_class");
909
+ if (roleClass !== "queen" && roleClass !== "worker") {
910
+ throw new Error("Database contains invalid role class.");
911
+ }
912
+ this.#database.exec("COMMIT");
913
+ return {
914
+ cube: {
915
+ id: requiredText(roleRow, "cube_id"),
916
+ name: requiredText(roleRow, "cube_name"),
917
+ },
918
+ role: {
919
+ id: requiredText(attachedRoleRow, "role_id"),
920
+ name: requiredText(attachedRoleRow, "role_name"),
921
+ role_class: roleClass,
922
+ is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
923
+ },
924
+ drone: { id: droneId, label: requiredText(bound, "label") },
925
+ sessionId: requiredText(bound, "session_id"),
926
+ expiresAt: requiredText(bound, "expires_at"),
927
+ result: "reused",
928
+ };
929
+ }
930
+ const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
931
+ SELECT id, role_id, label FROM drones
932
+ WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
933
+ `).get(input.priorDroneId, this.#principal.id, input.cubeId);
520
934
  let droneId;
521
935
  let droneLabel;
522
- let reattached;
523
- let generation;
524
- if (retryBinding !== undefined) {
525
- if (optionalText(retryBinding, "evicted_at") != null)
526
- throw new ScopedStoreError();
527
- if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
528
- requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
529
- nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
530
- throw new AttachConflictError();
936
+ if (priorSeat !== undefined) {
937
+ const priorSeatId = requiredText(priorSeat, "id");
938
+ if (requiredText(priorSeat, "role_id") !== input.roleId) {
939
+ throw new AttachSessionRejectedError();
531
940
  }
532
- droneId = requiredText(retryBinding, "id");
533
- droneLabel = requiredText(retryBinding, "label");
534
- generation = requiredInteger(retryBinding, "attach_generation") + 1;
535
- this.#database.prepare(`
536
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
537
- `).run(now, generation, droneId);
538
- reattached = true;
941
+ const occupied = this.#database.prepare(`
942
+ SELECT 1 FROM drone_sessions AS session
943
+ JOIN drone_session_credentials AS credential ON credential.session_id = session.id
944
+ WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
945
+ AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
946
+ AND session.expires_at > ?
947
+ `).get(priorSeatId, this.#principal.id, input.cubeId, now);
948
+ if (occupied !== undefined)
949
+ throw new AttachSessionRejectedError();
950
+ droneId = priorSeatId;
951
+ droneLabel = requiredText(priorSeat, "label");
952
+ this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
539
953
  }
540
954
  else {
541
- const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
542
- SELECT id, label, attach_generation
543
- FROM drones
544
- WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
545
- `).get(input.priorDroneId, this.#principal.id, input.cubeId);
546
- if (priorSeat !== undefined) {
547
- droneId = requiredText(priorSeat, "id");
548
- droneLabel = requiredText(priorSeat, "label");
549
- generation = requiredInteger(priorSeat, "attach_generation") + 1;
550
- this.#database.prepare(`
551
- UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
552
- `).run(now, generation, droneId);
553
- reattached = true;
554
- }
555
- else {
556
- droneId = input.droneId;
557
- droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
558
- this.#database.prepare(`
559
- INSERT INTO drones (
560
- id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
561
- attach_generation
562
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
563
- `).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now, input.retryKey);
564
- reattached = false;
565
- generation = 1;
566
- }
567
- this.#database.prepare(`
568
- INSERT INTO seat_attach_bindings (
569
- client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
570
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
571
- `).run(this.#principal.id, input.retryKey, input.cubeId, input.roleId, droneId, input.priorDroneId ?? null, now);
572
- }
573
- const oldSessions = this.#database.prepare("SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?").all(droneId, this.#principal.id, input.cubeId)
574
- .map((row) => requiredText(row, "id"));
575
- if (oldSessions.length > 0) {
576
- const placeholders = oldSessions.map(() => "?").join(", ");
577
- this.#database.prepare(`
578
- UPDATE drone_session_credentials SET revoked_at = ?
579
- WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
580
- `).run(now, ...oldSessions);
955
+ droneId = input.droneId;
956
+ droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
581
957
  this.#database.prepare(`
582
- UPDATE drone_sessions SET revoked_at = ?
583
- WHERE id IN (${placeholders}) AND revoked_at IS NULL
584
- `).run(now, ...oldSessions);
958
+ INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
959
+ VALUES (?, ?, ?, ?, ?, ?, ?)
960
+ `).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now);
585
961
  }
962
+ this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
586
963
  this.#database.prepare(`
587
964
  INSERT INTO drone_sessions (
588
965
  id, client_id, cube_id, drone_id, created_at, expires_at
@@ -618,9 +995,7 @@ class SqliteScopedStore {
618
995
  drone: { id: droneId, label: droneLabel },
619
996
  sessionId: input.sessionId,
620
997
  expiresAt: input.expiresAt,
621
- generation,
622
- reattached,
623
- revokedSessionIds: oldSessions,
998
+ result: "created",
624
999
  };
625
1000
  }
626
1001
  catch (error) {
@@ -633,7 +1008,47 @@ class SqliteScopedStore {
633
1008
  }
634
1009
  subscribeActivity(cubeId, listener) {
635
1010
  this.#requireCube(cubeId, "read");
636
- return this.#activityHub.subscribe(cubeId, listener);
1011
+ return this.#activityHub.subscribe(cubeId, (entry) => {
1012
+ if ("kind" in entry) {
1013
+ if (this.#principal.kind === "drone-session" &&
1014
+ entry.recipient_drone_ids.includes(this.#principal.droneId))
1015
+ listener(entry);
1016
+ return;
1017
+ }
1018
+ if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId))
1019
+ listener(entry);
1020
+ });
1021
+ }
1022
+ #claimAudience(cubeId, entryId, visibility) {
1023
+ if (visibility === "direct") {
1024
+ return this.#database.prepare(`
1025
+ SELECT recipient.drone_id
1026
+ FROM activity_log_recipients AS recipient
1027
+ JOIN drones AS drone ON drone.id = recipient.drone_id
1028
+ JOIN clients AS client ON client.id = drone.client_id
1029
+ JOIN client_cube_grants AS grant_row
1030
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1031
+ WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
1032
+ AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
1033
+ ORDER BY recipient.drone_id
1034
+ `).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
1035
+ }
1036
+ return this.#database.prepare(`
1037
+ SELECT drone.id AS drone_id
1038
+ FROM drones AS drone
1039
+ JOIN clients AS client ON client.id = drone.client_id
1040
+ JOIN client_cube_grants AS grant_row
1041
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1042
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
1043
+ AND grant_row.access IN ('write', 'manage')
1044
+ ORDER BY drone.id
1045
+ `).all(cubeId).map((row) => requiredText(row, "drone_id"));
1046
+ }
1047
+ #allowsDirectedWork(cubeId) {
1048
+ const scope = this.#scope("write");
1049
+ return this.#database.prepare(`
1050
+ SELECT 1 AS allowed FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
1051
+ `).get(cubeId, ...scope.parameters) !== undefined;
637
1052
  }
638
1053
  #requireCube(cubeId, access) {
639
1054
  assertCanonicalUuid(cubeId, "Cube id");
@@ -641,8 +1056,17 @@ class SqliteScopedStore {
641
1056
  const row = this.#database.prepare(`
642
1057
  SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
643
1058
  `).get(cubeId, ...scope.parameters);
644
- if (row === undefined)
645
- throw new ScopedStoreError();
1059
+ if (row !== undefined)
1060
+ return;
1061
+ if (access === "manage") {
1062
+ const visibleScope = this.#scope("read");
1063
+ const visible = this.#database.prepare(`
1064
+ SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
1065
+ `).get(cubeId, ...visibleScope.parameters);
1066
+ if (visible !== undefined)
1067
+ throw new AccessDeniedError();
1068
+ }
1069
+ throw new ScopedStoreError();
646
1070
  }
647
1071
  #nextActivityTimestamp(cubeId) {
648
1072
  const now = this.#clock().getTime();
@@ -697,12 +1121,14 @@ class SqliteScopedStore {
697
1121
  if (valid === undefined)
698
1122
  throw new ScopedStoreError();
699
1123
  }
700
- #countAfter(cubeId, cursor) {
1124
+ #countAfter(cubeId, cursor, broadcastOnly) {
1125
+ const visibility = broadcastOnly ? "AND visibility = 'broadcast'" : "";
701
1126
  const row = cursor === null
702
- ? this.#database.prepare("SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ?").get(cubeId)
1127
+ ? this.#database.prepare(`SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ? ${visibility}`).get(cubeId)
703
1128
  : this.#database.prepare(`
704
1129
  SELECT COUNT(*) AS count FROM activity_log
705
1130
  WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
1131
+ ${visibility}
706
1132
  `).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
707
1133
  if (row === undefined)
708
1134
  throw new Error("Activity count query returned no row.");
@@ -724,7 +1150,7 @@ class SqliteScopedStore {
724
1150
  `).all(entryId);
725
1151
  return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
726
1152
  }
727
- #claims(cubeId) {
1153
+ #claims(cubeId, broadcastOnly) {
728
1154
  return this.#database.prepare(`
729
1155
  SELECT acknowledgement.entry_id AS log_entry_id,
730
1156
  acknowledgement.claimant_drone_id,
@@ -738,6 +1164,7 @@ class SqliteScopedStore {
738
1164
  LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
739
1165
  WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
740
1166
  AND acknowledgement.claimant_drone_id IS NOT NULL
1167
+ AND (${broadcastOnly ? "entry.visibility = 'broadcast'" : "1 = 1"})
741
1168
  ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
742
1169
  acknowledgement.claimant_drone_id
743
1170
  `).all(cubeId).map(claimRecord);
@@ -868,7 +1295,7 @@ class SqliteMaintenanceStore {
868
1295
  for (const table of [
869
1296
  "cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
870
1297
  "decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
871
- "seat_attach_bindings", "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
1298
+ "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
872
1299
  "enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
873
1300
  "enrollment_invitations",
874
1301
  ])
@@ -1011,6 +1438,70 @@ class ActivityHub {
1011
1438
  }
1012
1439
  }
1013
1440
  }
1441
+ class SqliteLivenessStore {
1442
+ database;
1443
+ clock;
1444
+ hub;
1445
+ capacityGuard;
1446
+ constructor(database, clock, hub, capacityGuard) {
1447
+ this.database = database;
1448
+ this.clock = clock;
1449
+ this.hub = hub;
1450
+ this.capacityGuard = capacityGuard;
1451
+ }
1452
+ scan(options = {}) {
1453
+ const now = this.clock();
1454
+ const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
1455
+ const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
1456
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
1457
+ const candidates = this.database.prepare(`
1458
+ SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
1459
+ FROM drones AS drone
1460
+ JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
1461
+ JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
1462
+ AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
1463
+ LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
1464
+ WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
1465
+ AND COALESCE(state.attempts, 0) < 3
1466
+ AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
1467
+ AND EXISTS (SELECT 1 FROM drone_sessions AS session
1468
+ WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
1469
+ ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
1470
+ `).all(silentBefore, cooldownBefore, now.toISOString(), limit);
1471
+ const published = [];
1472
+ for (const candidate of candidates) {
1473
+ const droneId = requiredText(candidate, "drone_id");
1474
+ const cubeId = requiredText(candidate, "cube_id");
1475
+ const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
1476
+ this.capacityGuard.assertCanGrow(256);
1477
+ const id = randomUUID();
1478
+ const createdAt = now.toISOString();
1479
+ this.database.exec("BEGIN IMMEDIATE");
1480
+ try {
1481
+ this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
1482
+ VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
1483
+ SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
1484
+ `).run(droneId, now.toISOString());
1485
+ this.database.exec("COMMIT");
1486
+ }
1487
+ catch (error) {
1488
+ try {
1489
+ this.database.exec("ROLLBACK");
1490
+ }
1491
+ catch { /* Preserve original failure. */ }
1492
+ throw error;
1493
+ }
1494
+ const entry = {
1495
+ kind: "heartbeat_ping",
1496
+ id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
1497
+ drone_label: null, role_name: null, recipient_drone_ids: [droneId],
1498
+ };
1499
+ this.hub.publish(cubeId, entry);
1500
+ published.push(entry);
1501
+ }
1502
+ return published;
1503
+ }
1504
+ }
1014
1505
  class SqliteCredentialStore {
1015
1506
  #database;
1016
1507
  #clock;
@@ -1046,6 +1537,33 @@ class SqliteCredentialStore {
1046
1537
  validateTimestamp(input.expiresAt);
1047
1538
  this.#database.exec("BEGIN IMMEDIATE");
1048
1539
  try {
1540
+ let scope = null;
1541
+ if (input.cubeSelector !== undefined) {
1542
+ if (input.purpose !== "client" || input.access === undefined) {
1543
+ throw new Error("Only client invitations may carry a complete cube scope.");
1544
+ }
1545
+ if (input.access !== "read" && input.access !== "write" && input.access !== "manage") {
1546
+ throw new Error("Unknown cube access grant.");
1547
+ }
1548
+ const rows = input.cubeSelector.kind === "id"
1549
+ ? this.#database.prepare("SELECT id, name FROM cubes WHERE id = ?").all(input.cubeSelector.value)
1550
+ : this.#database.prepare("SELECT id, name FROM cubes WHERE name = ? ORDER BY id").all(input.cubeSelector.value);
1551
+ if (rows.length === 0)
1552
+ throw new InvitationCubeNotFoundError();
1553
+ if (rows.length > 1) {
1554
+ throw new InvitationCubeAmbiguousError(rows.map((row) => requiredText(row, "id")));
1555
+ }
1556
+ const cubeId = requiredText(rows[0], "id");
1557
+ assertCanonicalUuid(cubeId, "Resolved cube id");
1558
+ scope = {
1559
+ cubeId,
1560
+ cubeName: requiredText(rows[0], "name"),
1561
+ access: input.access,
1562
+ };
1563
+ }
1564
+ else if (input.access !== undefined) {
1565
+ throw new Error("Invitation access requires a cube selector.");
1566
+ }
1049
1567
  let ownerEpoch = null;
1050
1568
  if (input.purpose === "owner") {
1051
1569
  const state = this.#database.prepare("SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1").get();
@@ -1070,10 +1588,12 @@ class SqliteCredentialStore {
1070
1588
  }
1071
1589
  this.#database.prepare(`
1072
1590
  INSERT INTO enrollment_invitations (
1073
- id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch
1074
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
1075
- `).run(input.id, input.digest.lookup, input.digest.verifier, input.expiresAt, this.#now(), input.purpose, ownerEpoch);
1591
+ id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch,
1592
+ cube_id, access
1593
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1594
+ `).run(input.id, input.digest.lookup, input.digest.verifier, input.expiresAt, this.#now(), input.purpose, ownerEpoch, scope?.cubeId ?? null, scope?.access ?? null);
1076
1595
  this.#database.exec("COMMIT");
1596
+ return scope;
1077
1597
  }
1078
1598
  catch (error) {
1079
1599
  try {
@@ -1093,7 +1613,7 @@ class SqliteCredentialStore {
1093
1613
  COALESCE(invitation.expires_at, '') AS expires_at,
1094
1614
  invitation.consumed_at, invitation.revoked_at,
1095
1615
  COALESCE(invitation.purpose, 'client') AS purpose,
1096
- invitation.owner_epoch
1616
+ invitation.owner_epoch, invitation.cube_id, invitation.access
1097
1617
  FROM (SELECT 1) AS seed
1098
1618
  LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
1099
1619
  `).get(lookup);
@@ -1118,7 +1638,8 @@ class SqliteCredentialStore {
1118
1638
  COALESCE(invitation.purpose, 'client') AS purpose,
1119
1639
  invitation.owner_epoch,
1120
1640
  COALESCE(invitation.expires_at, '') AS expires_at,
1121
- invitation.consumed_at, invitation.revoked_at
1641
+ invitation.consumed_at, invitation.revoked_at,
1642
+ invitation.cube_id, invitation.access
1122
1643
  FROM (SELECT 1) AS seed
1123
1644
  LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
1124
1645
  `).get(input.invitationId);
@@ -1166,6 +1687,12 @@ class SqliteCredentialStore {
1166
1687
  const ownerEpoch = invitation["owner_epoch"] === null
1167
1688
  ? null
1168
1689
  : requiredInteger(invitation, "owner_epoch");
1690
+ const cubeId = nullableText(invitation, "cube_id");
1691
+ const accessValue = nullableText(invitation, "access");
1692
+ const access = accessValue === null ? null : cubeAccess(accessValue);
1693
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
1694
+ throw new Error("Invalid invitation cube scope.");
1695
+ }
1169
1696
  if (purpose === "owner") {
1170
1697
  const state = this.#database.prepare(`
1171
1698
  SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
@@ -1176,6 +1703,10 @@ class SqliteCredentialStore {
1176
1703
  return null;
1177
1704
  }
1178
1705
  }
1706
+ if (cubeId !== null && this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(cubeId) === undefined) {
1707
+ this.#database.exec("ROLLBACK");
1708
+ return null;
1709
+ }
1179
1710
  this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
1180
1711
  this.#database.prepare("INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)").run(input.clientId, input.requestedClientName ?? "Local client", now);
1181
1712
  this.#mutationHook?.("enrollment.insert-client");
@@ -1192,6 +1723,13 @@ class SqliteCredentialStore {
1192
1723
  `).run(input.clientId, now);
1193
1724
  this.#mutationHook?.("enrollment.insert-capability");
1194
1725
  }
1726
+ if (cubeId !== null && access !== null) {
1727
+ this.#database.prepare(`
1728
+ INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
1729
+ VALUES (?, ?, ?, ?)
1730
+ `).run(input.clientId, cubeId, access, now);
1731
+ this.#mutationHook?.("enrollment.insert-grant");
1732
+ }
1195
1733
  this.#database.prepare(`
1196
1734
  INSERT INTO enrollment_claims (
1197
1735
  invitation_id, retry_key, client_id, requested_client_name,
@@ -1255,9 +1793,8 @@ class SqliteCredentialStore {
1255
1793
  SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
1256
1794
  credential.session_id, session.client_id, session.cube_id, session.drone_id,
1257
1795
  session.expires_at,
1258
- COALESCE(
1259
- credential.revoked_at, session.revoked_at, client.revoked_at, drone.evicted_at
1260
- ) AS revoked_at
1796
+ COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
1797
+ drone.evicted_at
1261
1798
  FROM drone_session_credentials AS credential
1262
1799
  JOIN drone_sessions AS session ON session.id = credential.session_id
1263
1800
  JOIN clients AS client ON client.id = session.client_id
@@ -1268,6 +1805,20 @@ class SqliteCredentialStore {
1268
1805
  `).get(lookup);
1269
1806
  return row === undefined ? null : storedDroneSessionDigest(row);
1270
1807
  }
1808
+ findActiveDroneSessionExpiry(sessionId) {
1809
+ assertCanonicalUuid(sessionId, "Drone session id");
1810
+ const row = this.#database.prepare(`
1811
+ SELECT session.expires_at
1812
+ FROM drone_sessions AS session
1813
+ JOIN clients AS client ON client.id = session.client_id
1814
+ JOIN drones AS drone ON drone.id = session.drone_id
1815
+ AND drone.client_id = session.client_id
1816
+ AND drone.cube_id = session.cube_id
1817
+ WHERE session.id = ? AND session.revoked_at IS NULL
1818
+ AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
1819
+ `).get(sessionId);
1820
+ return row === undefined ? null : requiredText(row, "expires_at");
1821
+ }
1271
1822
  rotateClientCredential(input) {
1272
1823
  assertCanonicalUuid(input.clientId, "Client id");
1273
1824
  assertCanonicalUuid(input.credentialId, "Credential id");
@@ -1283,6 +1834,16 @@ class SqliteCredentialStore {
1283
1834
  this.#database.prepare(`
1284
1835
  UPDATE client_credentials SET revoked_at = ?
1285
1836
  WHERE client_id = ? AND revoked_at IS NULL
1837
+ `).run(now, input.clientId);
1838
+ this.#database.prepare(`
1839
+ UPDATE drone_session_credentials SET revoked_at = ?
1840
+ WHERE revoked_at IS NULL AND session_id IN (
1841
+ SELECT id FROM drone_sessions WHERE client_id = ?
1842
+ )
1843
+ `).run(now, input.clientId);
1844
+ this.#database.prepare(`
1845
+ UPDATE drone_sessions SET revoked_at = ?
1846
+ WHERE client_id = ? AND revoked_at IS NULL
1286
1847
  `).run(now, input.clientId);
1287
1848
  this.#database.prepare(`
1288
1849
  INSERT INTO client_credentials (
@@ -1332,9 +1893,7 @@ async function prepareDatabasePath(path) {
1332
1893
  if (path === ":memory:")
1333
1894
  throw new Error("The server store requires a file-backed database.");
1334
1895
  const databasePath = resolve(path);
1335
- const directory = dirname(databasePath);
1336
- await ensureDirectoryTree(directory);
1337
- await chmod(directory, 0o700);
1896
+ const directory = await preparePrivateDataDirectory(dirname(databasePath));
1338
1897
  try {
1339
1898
  const handle = await open(databasePath, "ax", 0o600);
1340
1899
  await handle.close();
@@ -1352,6 +1911,12 @@ async function prepareDatabasePath(path) {
1352
1911
  await chmod(databasePath, 0o600);
1353
1912
  return databasePath;
1354
1913
  }
1914
+ export async function preparePrivateDataDirectory(path) {
1915
+ const directory = resolve(path);
1916
+ await ensureDirectoryTree(directory);
1917
+ await chmod(directory, 0o700);
1918
+ return directory;
1919
+ }
1355
1920
  async function ensureDirectoryTree(directory) {
1356
1921
  const { root } = parse(directory);
1357
1922
  let current = root;
@@ -1405,6 +1970,18 @@ function configureDatabase(database) {
1405
1970
  throw new Error("SQLite WAL mode is required.");
1406
1971
  }
1407
1972
  }
1973
+ function configureExistingDatabase(database) {
1974
+ database.exec(`
1975
+ PRAGMA foreign_keys = ON;
1976
+ PRAGMA synchronous = FULL;
1977
+ PRAGMA trusted_schema = OFF;
1978
+ PRAGMA secure_delete = ON;
1979
+ PRAGMA busy_timeout = 5000;
1980
+ `);
1981
+ if (readPragma(database, "journal_mode") !== "wal") {
1982
+ throw new Error("SQLite WAL mode is required.");
1983
+ }
1984
+ }
1408
1985
  function diagnostics(database) {
1409
1986
  const journalMode = readPragma(database, "journal_mode");
1410
1987
  const foreignKeys = readPragma(database, "foreign_keys");
@@ -1425,6 +2002,7 @@ function cubeRecord(row) {
1425
2002
  ownerId: row.owner_id,
1426
2003
  name: row.name,
1427
2004
  directive: row.directive,
2005
+ messageTaxonomy: parseTaxonomy(row.message_taxonomy),
1428
2006
  createdAt: row.created_at,
1429
2007
  updatedAt: row.updated_at,
1430
2008
  };
@@ -1446,6 +2024,7 @@ function cubeRow(row) {
1446
2024
  owner_id: requiredText(row, "owner_id"),
1447
2025
  name: requiredText(row, "name"),
1448
2026
  directive: requiredText(row, "directive"),
2027
+ message_taxonomy: nullableText(row, "message_taxonomy"),
1449
2028
  created_at: requiredText(row, "created_at"),
1450
2029
  updated_at: requiredText(row, "updated_at"),
1451
2030
  };
@@ -1523,8 +2102,12 @@ function roleRecord(row) {
1523
2102
  cube_id: requiredText(row, "cube_id"),
1524
2103
  name: requiredText(row, "name"),
1525
2104
  short_description: requiredText(row, "short_description"),
2105
+ detailed_description: requiredText(row, "detailed_description"),
1526
2106
  is_default: requiredInteger(row, "is_default") === 1,
2107
+ is_mandatory: requiredInteger(row, "is_mandatory") === 1,
1527
2108
  is_human_seat: requiredInteger(row, "is_human_seat") === 1,
2109
+ can_broadcast: requiredInteger(row, "can_broadcast") === 1,
2110
+ receives_all_direct: requiredInteger(row, "receives_all_direct") === 1,
1528
2111
  role_class: roleClass,
1529
2112
  created_at: requiredText(row, "created_at"),
1530
2113
  };
@@ -1538,6 +2121,10 @@ function createCubeRecord(row) {
1538
2121
  };
1539
2122
  }
1540
2123
  function droneRecord(row) {
2124
+ const posture = requiredText(row, "posture");
2125
+ if (posture !== "observer" && posture !== "participant") {
2126
+ throw new Error("Database contains invalid drone posture.");
2127
+ }
1541
2128
  return {
1542
2129
  id: requiredText(row, "id"),
1543
2130
  cube_id: requiredText(row, "cube_id"),
@@ -1545,6 +2132,7 @@ function droneRecord(row) {
1545
2132
  label: requiredText(row, "label"),
1546
2133
  last_seen: requiredText(row, "last_seen"),
1547
2134
  hostname: nullableText(row, "hostname"),
2135
+ posture,
1548
2136
  created_at: requiredText(row, "created_at"),
1549
2137
  };
1550
2138
  }
@@ -1591,7 +2179,19 @@ function storedInvitationDigest(row) {
1591
2179
  }
1592
2180
  const epochValue = row["owner_epoch"];
1593
2181
  const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
1594
- return { ...digest, purpose, ownerEpoch };
2182
+ const cubeId = nullableText(row, "cube_id");
2183
+ const accessValue = nullableText(row, "access");
2184
+ const access = accessValue === null ? null : cubeAccess(accessValue);
2185
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
2186
+ throw new Error("Database contains invalid invitation cube scope.");
2187
+ }
2188
+ return { ...digest, purpose, ownerEpoch, cubeId, access };
2189
+ }
2190
+ function cubeAccess(value) {
2191
+ if (value !== "read" && value !== "write" && value !== "manage") {
2192
+ throw new Error("Database contains invalid cube access.");
2193
+ }
2194
+ return value;
1595
2195
  }
1596
2196
  function enrollmentClaimResult(purpose, clientId) {
1597
2197
  return purpose === "owner"
@@ -1624,6 +2224,7 @@ function storedDroneSessionDigest(row) {
1624
2224
  cubeId: requiredText(row, "cube_id"),
1625
2225
  droneId: requiredText(row, "drone_id"),
1626
2226
  expiresAt: requiredText(row, "expires_at"),
2227
+ evictedAt: nullableText(row, "evicted_at"),
1627
2228
  };
1628
2229
  }
1629
2230
  function requiredBuffer(row, key) {
@@ -1659,6 +2260,30 @@ function validatePresentationName(value) {
1659
2260
  throw new Error("Presentation name is invalid.");
1660
2261
  }
1661
2262
  }
2263
+ function validateRoleName(value) {
2264
+ if (typeof value !== "string" || value.length < 1 || value.length > 64) {
2265
+ throw new Error("Role name must contain 1 to 64 characters.");
2266
+ }
2267
+ }
2268
+ function validateRoleShortDescription(value) {
2269
+ if (typeof value !== "string" || value.length > 1_024) {
2270
+ throw new Error("Role short description must contain at most 1024 characters.");
2271
+ }
2272
+ }
2273
+ export const MAX_ROLE_DETAILED_DESCRIPTION_CHARS = 51_200;
2274
+ export function assertRoleTextWriteAllowed(value, previous) {
2275
+ if (typeof value !== "string")
2276
+ throw new TypeError("Role detailed description must be text.");
2277
+ if (value.length <= MAX_ROLE_DETAILED_DESCRIPTION_CHARS)
2278
+ return;
2279
+ if (previous !== undefined && previous.length > MAX_ROLE_DETAILED_DESCRIPTION_CHARS &&
2280
+ value.length < previous.length)
2281
+ return;
2282
+ throw new RangeError("Role detailed description is too large.");
2283
+ }
2284
+ function booleanInteger(value) {
2285
+ return value ? 1 : 0;
2286
+ }
1662
2287
  function validateBoundedText(value, name, maxBytes) {
1663
2288
  if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
1664
2289
  throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);
@@ -1678,6 +2303,33 @@ function validateDirective(value) {
1678
2303
  throw new Error("Cube directive exceeds 100000 bytes.");
1679
2304
  }
1680
2305
  }
2306
+ function activityPreview(message) {
2307
+ return message.replace(/\s+/gu, " ").trim().slice(0, 200);
2308
+ }
2309
+ function validateRoleClass(value) {
2310
+ if (value !== undefined && value !== "queen" && value !== "worker") {
2311
+ throw new TypeError("Role class must be queen or worker.");
2312
+ }
2313
+ }
2314
+ function serializeTaxonomy(value) {
2315
+ if (value === null)
2316
+ return null;
2317
+ const serialized = JSON.stringify(value);
2318
+ if (Buffer.byteLength(serialized) > 100_000) {
2319
+ throw new RangeError("Message taxonomy exceeds 100000 bytes.");
2320
+ }
2321
+ return serialized;
2322
+ }
2323
+ function parseTaxonomy(value) {
2324
+ if (value === null)
2325
+ return null;
2326
+ try {
2327
+ return validateMessageTaxonomy(JSON.parse(value));
2328
+ }
2329
+ catch {
2330
+ throw new Error("Database contains invalid message taxonomy.");
2331
+ }
2332
+ }
1681
2333
  function validateDigest(digest) {
1682
2334
  validateLookup(digest.lookup);
1683
2335
  if (digest.verifier.length !== 32)