borgmcp-server 0.1.1 → 0.1.4

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 (48) hide show
  1. package/README.md +69 -22
  2. package/dist/cli.js +62 -11
  3. package/dist/cli.js.map +1 -1
  4. package/dist/coordination-api.d.ts +2 -1
  5. package/dist/coordination-api.js +232 -18
  6. package/dist/coordination-api.js.map +1 -1
  7. package/dist/credentials.d.ts +10 -2
  8. package/dist/credentials.js +44 -6
  9. package/dist/credentials.js.map +1 -1
  10. package/dist/debug-log.d.ts +77 -0
  11. package/dist/debug-log.js +125 -0
  12. package/dist/debug-log.js.map +1 -0
  13. package/dist/https-server.d.ts +3 -0
  14. package/dist/https-server.js +80 -4
  15. package/dist/https-server.js.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/migrations.d.ts +4 -0
  18. package/dist/migrations.js +71 -0
  19. package/dist/migrations.js.map +1 -1
  20. package/dist/operator-error.d.ts +2 -1
  21. package/dist/operator-error.js +23 -5
  22. package/dist/operator-error.js.map +1 -1
  23. package/dist/role-section.d.ts +14 -0
  24. package/dist/role-section.js +87 -0
  25. package/dist/role-section.js.map +1 -0
  26. package/dist/service.d.ts +10 -3
  27. package/dist/service.js +218 -15
  28. package/dist/service.js.map +1 -1
  29. package/dist/start-options.d.ts +5 -1
  30. package/dist/start-options.js +17 -3
  31. package/dist/start-options.js.map +1 -1
  32. package/dist/store.d.ts +65 -1
  33. package/dist/store.js +398 -26
  34. package/dist/store.js.map +1 -1
  35. package/npm-shrinkwrap.json +2 -2
  36. package/package.json +1 -1
  37. package/src/cli.ts +61 -11
  38. package/src/coordination-api.ts +236 -14
  39. package/src/credentials.ts +71 -5
  40. package/src/debug-log.ts +165 -0
  41. package/src/https-server.ts +75 -2
  42. package/src/index.ts +1 -1
  43. package/src/migrations.ts +74 -0
  44. package/src/operator-error.ts +40 -6
  45. package/src/role-section.ts +108 -0
  46. package/src/service.ts +239 -18
  47. package/src/start-options.ts +21 -4
  48. package/src/store.ts +462 -28
package/dist/store.js CHANGED
@@ -3,9 +3,10 @@ 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";
9
10
  export const DEFAULT_CUBE_LIMITS = Object.freeze({
10
11
  maxCubesPerClient: 100,
11
12
  maxCubesTotal: 1_000,
@@ -15,6 +16,15 @@ export const DEFAULT_STORAGE_LIMITS = Object.freeze({
15
16
  maxDatabaseBytes: 1_073_741_824,
16
17
  minFreeDiskBytes: 67_108_864,
17
18
  });
19
+ export class InvitationCubeNotFoundError extends Error {
20
+ }
21
+ export class InvitationCubeAmbiguousError extends Error {
22
+ candidateIds;
23
+ constructor(candidateIds) {
24
+ super("Cube name is ambiguous.");
25
+ this.candidateIds = Object.freeze([...candidateIds]);
26
+ }
27
+ }
18
28
  export class ScopedStoreError extends Error {
19
29
  code = "NOT_FOUND";
20
30
  constructor() {
@@ -22,6 +32,27 @@ export class ScopedStoreError extends Error {
22
32
  this.name = "ScopedStoreError";
23
33
  }
24
34
  }
35
+ export class RoleConflictError extends Error {
36
+ code = "ROLE_ALREADY_EXISTS";
37
+ constructor() {
38
+ super("A role with that name already exists.");
39
+ this.name = "RoleConflictError";
40
+ }
41
+ }
42
+ export class DefaultRoleRequiredError extends Error {
43
+ code = "DEFAULT_ROLE_REQUIRED";
44
+ constructor() {
45
+ super("A cube must retain one default role.");
46
+ this.name = "DefaultRoleRequiredError";
47
+ }
48
+ }
49
+ export class RoleSectionConflictError extends Error {
50
+ code = "ROLE_SECTION_CONFLICT";
51
+ constructor() {
52
+ super("The role section patch conflicts with the current role text.");
53
+ this.name = "RoleSectionConflictError";
54
+ }
55
+ }
25
56
  export class CursorExpiredError extends Error {
26
57
  code = "CURSOR_EXPIRED";
27
58
  constructor() {
@@ -100,8 +131,14 @@ export async function openStore(options) {
100
131
  });
101
132
  const clock = options.clock ?? (() => new Date());
102
133
  try {
103
- configureDatabase(database);
104
- applyMigrations(database);
134
+ if (options.migrationMode === "require-current") {
135
+ configureExistingDatabase(database);
136
+ assertMigrationsCurrent(database);
137
+ }
138
+ else {
139
+ configureDatabase(database);
140
+ applyMigrations(database);
141
+ }
105
142
  }
106
143
  catch (error) {
107
144
  database.close();
@@ -293,18 +330,220 @@ class SqliteScopedStore {
293
330
  listRoles(cubeId) {
294
331
  this.#requireCube(cubeId, "read");
295
332
  const rows = this.#database.prepare(`
296
- SELECT id, cube_id, name, short_description, is_default, is_human_seat,
297
- role_class, created_at
333
+ SELECT id, cube_id, name, short_description, detailed_description,
334
+ is_default, is_mandatory, is_human_seat, can_broadcast,
335
+ receives_all_direct, role_class, created_at
298
336
  FROM roles WHERE cube_id = ? ORDER BY name, id
299
337
  `).all(cubeId);
300
338
  return rows.map(roleRecord);
301
339
  }
340
+ createRole(cubeId, input) {
341
+ assertCanonicalUuid(cubeId, "Cube id");
342
+ validateRoleName(input.name);
343
+ const shortDescription = input.shortDescription ?? "";
344
+ const detailedDescription = input.detailedDescription ?? "";
345
+ validateRoleShortDescription(shortDescription);
346
+ assertRoleTextWriteAllowed(detailedDescription);
347
+ for (const value of [
348
+ input.isDefault,
349
+ input.isMandatory,
350
+ input.isHumanSeat,
351
+ input.canBroadcast,
352
+ input.receivesAllDirect,
353
+ ]) {
354
+ if (value !== undefined && typeof value !== "boolean")
355
+ throw new TypeError("Role flags must be boolean.");
356
+ }
357
+ const isDefault = input.isDefault ?? false;
358
+ const isMandatory = input.isMandatory ?? false;
359
+ const isHumanSeat = input.isHumanSeat ?? false;
360
+ const canBroadcast = input.canBroadcast ?? false;
361
+ const receivesAllDirect = input.receivesAllDirect ?? false;
362
+ this.#requireCube(cubeId, "manage");
363
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name) + Buffer.byteLength(shortDescription) +
364
+ Buffer.byteLength(detailedDescription) + 8_192);
365
+ this.#database.exec("BEGIN IMMEDIATE");
366
+ try {
367
+ this.#requireCube(cubeId, "manage");
368
+ const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ?").get(cubeId, input.name);
369
+ if (duplicate !== undefined)
370
+ throw new RoleConflictError();
371
+ if (isDefault) {
372
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
373
+ .run(cubeId);
374
+ this.#mutationHook?.("role.demote-default");
375
+ }
376
+ const id = randomUUID();
377
+ this.#database.prepare(`
378
+ INSERT INTO roles (
379
+ id, cube_id, name, short_description, detailed_description,
380
+ is_default, is_mandatory, is_human_seat, can_broadcast,
381
+ receives_all_direct, role_class, created_at
382
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'worker', ?)
383
+ `).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), this.#now());
384
+ this.#mutationHook?.("role.insert");
385
+ const row = this.#database.prepare(`
386
+ SELECT id, cube_id, name, short_description, detailed_description,
387
+ is_default, is_mandatory, is_human_seat, can_broadcast,
388
+ receives_all_direct, role_class, created_at
389
+ FROM roles WHERE id = ? AND cube_id = ?
390
+ `).get(id, cubeId);
391
+ if (row === undefined)
392
+ throw new ScopedStoreError();
393
+ this.#database.exec("COMMIT");
394
+ this.#mutationHook?.("role.after-commit");
395
+ return roleRecord(row);
396
+ }
397
+ catch (error) {
398
+ try {
399
+ this.#database.exec("ROLLBACK");
400
+ }
401
+ catch { /* Preserve the original failure. */ }
402
+ throw error;
403
+ }
404
+ }
405
+ updateRole(cubeId, roleId, input) {
406
+ assertCanonicalUuid(cubeId, "Cube id");
407
+ assertCanonicalUuid(roleId, "Role id");
408
+ if (Object.values(input).every((value) => value === undefined)) {
409
+ throw new TypeError("At least one role field is required.");
410
+ }
411
+ if (input.name !== undefined)
412
+ validateRoleName(input.name);
413
+ if (input.shortDescription !== undefined)
414
+ validateRoleShortDescription(input.shortDescription);
415
+ if (input.detailedDescription !== undefined && typeof input.detailedDescription !== "string") {
416
+ throw new TypeError("Role detailed description must be text.");
417
+ }
418
+ for (const value of [
419
+ input.isDefault,
420
+ input.isMandatory,
421
+ input.isHumanSeat,
422
+ input.canBroadcast,
423
+ input.receivesAllDirect,
424
+ ]) {
425
+ if (value !== undefined && typeof value !== "boolean")
426
+ throw new TypeError("Role flags must be boolean.");
427
+ }
428
+ this.#requireCube(cubeId, "manage");
429
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
430
+ Buffer.byteLength(input.detailedDescription ?? "") + 8_192);
431
+ this.#database.exec("BEGIN IMMEDIATE");
432
+ try {
433
+ this.#requireCube(cubeId, "manage");
434
+ const existingRow = this.#database.prepare(`
435
+ SELECT id, cube_id, name, short_description, detailed_description,
436
+ is_default, is_mandatory, is_human_seat, can_broadcast,
437
+ receives_all_direct, role_class, created_at
438
+ FROM roles WHERE id = ? AND cube_id = ?
439
+ `).get(roleId, cubeId);
440
+ if (existingRow === undefined)
441
+ throw new ScopedStoreError();
442
+ const existing = roleRecord(existingRow);
443
+ if (input.isDefault === false && existing.is_default) {
444
+ throw new DefaultRoleRequiredError();
445
+ }
446
+ if (input.name !== undefined && input.name !== existing.name) {
447
+ const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ? AND id <> ?").get(cubeId, input.name, roleId);
448
+ if (duplicate !== undefined)
449
+ throw new RoleConflictError();
450
+ }
451
+ if (input.isDefault === true && !existing.is_default) {
452
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
453
+ .run(cubeId);
454
+ this.#mutationHook?.("role.demote-default");
455
+ }
456
+ const nextDetailedDescription = input.detailedDescription ?? existing.detailed_description;
457
+ assertRoleTextWriteAllowed(nextDetailedDescription, existing.detailed_description);
458
+ this.#database.prepare(`
459
+ UPDATE roles SET
460
+ name = ?, short_description = ?, detailed_description = ?, is_default = ?,
461
+ is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?
462
+ WHERE id = ? AND cube_id = ?
463
+ `).run(input.name ?? existing.name, input.shortDescription ?? existing.short_description, nextDetailedDescription, booleanInteger(input.isDefault ?? existing.is_default), booleanInteger(input.isMandatory ?? existing.is_mandatory), booleanInteger(input.isHumanSeat ?? existing.is_human_seat), booleanInteger(input.canBroadcast ?? existing.can_broadcast), booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct), roleId, cubeId);
464
+ const row = this.#database.prepare(`
465
+ SELECT id, cube_id, name, short_description, detailed_description,
466
+ is_default, is_mandatory, is_human_seat, can_broadcast,
467
+ receives_all_direct, role_class, created_at
468
+ FROM roles WHERE id = ? AND cube_id = ?
469
+ `).get(roleId, cubeId);
470
+ if (row === undefined)
471
+ throw new ScopedStoreError();
472
+ this.#database.exec("COMMIT");
473
+ this.#mutationHook?.("role.after-commit");
474
+ return roleRecord(row);
475
+ }
476
+ catch (error) {
477
+ try {
478
+ this.#database.exec("ROLLBACK");
479
+ }
480
+ catch { /* Preserve the original failure. */ }
481
+ throw error;
482
+ }
483
+ }
484
+ patchRoleSection(cubeId, roleId, input) {
485
+ assertCanonicalUuid(cubeId, "Cube id");
486
+ assertCanonicalUuid(roleId, "Role id");
487
+ this.#requireCube(cubeId, "manage");
488
+ this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.heading) +
489
+ ("body" in input ? Buffer.byteLength(input.body) : 0) + 8_192);
490
+ this.#database.exec("BEGIN IMMEDIATE");
491
+ try {
492
+ this.#requireCube(cubeId, "manage");
493
+ const existingRow = this.#database.prepare(`
494
+ SELECT id, cube_id, name, short_description, detailed_description,
495
+ is_default, is_mandatory, is_human_seat, can_broadcast,
496
+ receives_all_direct, role_class, created_at
497
+ FROM roles WHERE id = ? AND cube_id = ?
498
+ `).get(roleId, cubeId);
499
+ if (existingRow === undefined)
500
+ throw new ScopedStoreError();
501
+ const existing = roleRecord(existingRow);
502
+ let detailedDescription;
503
+ try {
504
+ detailedDescription = patchRoleSectionText(existing.detailed_description, input);
505
+ }
506
+ catch (error) {
507
+ if (error instanceof TypeError)
508
+ throw error;
509
+ throw new RoleSectionConflictError();
510
+ }
511
+ assertRoleTextWriteAllowed(detailedDescription, existing.detailed_description);
512
+ this.#database.prepare("UPDATE roles SET detailed_description = ? WHERE id = ? AND cube_id = ?").run(detailedDescription, roleId, cubeId);
513
+ const row = this.#database.prepare(`
514
+ SELECT id, cube_id, name, short_description, detailed_description,
515
+ is_default, is_mandatory, is_human_seat, can_broadcast,
516
+ receives_all_direct, role_class, created_at
517
+ FROM roles WHERE id = ? AND cube_id = ?
518
+ `).get(roleId, cubeId);
519
+ if (row === undefined)
520
+ throw new ScopedStoreError();
521
+ this.#database.exec("COMMIT");
522
+ this.#mutationHook?.("role.after-commit");
523
+ return roleRecord(row);
524
+ }
525
+ catch (error) {
526
+ try {
527
+ this.#database.exec("ROLLBACK");
528
+ }
529
+ catch { /* Preserve the original failure. */ }
530
+ throw error;
531
+ }
532
+ }
302
533
  listDrones(cubeId) {
303
534
  this.#requireCube(cubeId, "read");
304
535
  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
536
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
537
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
538
+ drone.hostname, drone.created_at,
539
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
540
+ THEN 'participant' ELSE 'observer' END AS posture
541
+ FROM drones AS drone
542
+ LEFT JOIN clients AS client ON client.id = drone.client_id
543
+ LEFT JOIN client_cube_grants AS grant_row
544
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
545
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
546
+ ORDER BY drone.label, drone.id
308
547
  `).all(cubeId);
309
548
  return rows.map(droneRecord);
310
549
  }
@@ -355,9 +594,16 @@ class SqliteScopedStore {
355
594
  throw new ScopedStoreError();
356
595
  if (recipients.length > 0) {
357
596
  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(", ")})
597
+ SELECT COUNT(*) AS count
598
+ FROM drones AS recipient
599
+ JOIN clients AS recipient_client ON recipient_client.id = recipient.client_id
600
+ JOIN client_cube_grants AS recipient_grant
601
+ ON recipient_grant.client_id = recipient.client_id
602
+ AND recipient_grant.cube_id = recipient.cube_id
603
+ WHERE recipient.cube_id = ? AND recipient.evicted_at IS NULL
604
+ AND recipient_client.revoked_at IS NULL
605
+ AND recipient_grant.access IN ('write', 'manage')
606
+ AND recipient.id IN (${recipients.map(() => "?").join(", ")})
361
607
  `).get(cubeId, ...recipients);
362
608
  if (valid === undefined)
363
609
  throw new Error("Recipient count query returned no row.");
@@ -387,6 +633,7 @@ class SqliteScopedStore {
387
633
  throw new Error("Activity read limit must be an integer from 1 to 500.");
388
634
  }
389
635
  this.#validateCursor(cubeId, cursor);
636
+ const broadcastOnly = !this.#allowsDirectedWork(cubeId);
390
637
  const cursorSql = cursor === null
391
638
  ? { sql: "1 = 1", parameters: [] }
392
639
  : {
@@ -397,6 +644,7 @@ class SqliteScopedStore {
397
644
  SELECT l.id
398
645
  FROM activity_log AS l
399
646
  WHERE l.cube_id = ? AND ${cursorSql.sql}
647
+ AND (${broadcastOnly ? "l.visibility = 'broadcast'" : "1 = 1"})
400
648
  ORDER BY l.created_at, l.id
401
649
  LIMIT ?
402
650
  `).all(cubeId, ...cursorSql.parameters, limit + 1);
@@ -405,13 +653,15 @@ class SqliteScopedStore {
405
653
  const nextCursor = entries.length === 0
406
654
  ? cursor
407
655
  : { 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);
656
+ const behind = nextCursor === null
657
+ ? this.#countAfter(cubeId, null, broadcastOnly)
658
+ : this.#countAfter(cubeId, nextCursor, broadcastOnly);
409
659
  return {
410
660
  entries,
411
661
  cursor: nextCursor,
412
662
  behind_by: behind,
413
663
  has_more: behind > 0,
414
- claims: this.#claims(cubeId),
664
+ claims: this.#claims(cubeId, broadcastOnly),
415
665
  };
416
666
  }
417
667
  acknowledge(cubeId, entryId, kind) {
@@ -508,6 +758,7 @@ class SqliteScopedStore {
508
758
  `).get(input.cubeId, input.roleId, ...scope.parameters);
509
759
  if (roleRow === undefined)
510
760
  throw new ScopedStoreError();
761
+ const posture = this.#allowsDirectedWork(input.cubeId) ? "participant" : "observer";
511
762
  const retryBinding = this.#database.prepare(`
512
763
  SELECT binding.cube_id AS binding_cube_id,
513
764
  binding.requested_role_id AS binding_requested_role_id,
@@ -619,6 +870,7 @@ class SqliteScopedStore {
619
870
  sessionId: input.sessionId,
620
871
  expiresAt: input.expiresAt,
621
872
  generation,
873
+ posture,
622
874
  reattached,
623
875
  revokedSessionIds: oldSessions,
624
876
  };
@@ -633,7 +885,16 @@ class SqliteScopedStore {
633
885
  }
634
886
  subscribeActivity(cubeId, listener) {
635
887
  this.#requireCube(cubeId, "read");
636
- return this.#activityHub.subscribe(cubeId, listener);
888
+ return this.#activityHub.subscribe(cubeId, (entry) => {
889
+ if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId))
890
+ listener(entry);
891
+ });
892
+ }
893
+ #allowsDirectedWork(cubeId) {
894
+ const scope = this.#scope("write");
895
+ return this.#database.prepare(`
896
+ SELECT 1 AS allowed FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
897
+ `).get(cubeId, ...scope.parameters) !== undefined;
637
898
  }
638
899
  #requireCube(cubeId, access) {
639
900
  assertCanonicalUuid(cubeId, "Cube id");
@@ -697,12 +958,14 @@ class SqliteScopedStore {
697
958
  if (valid === undefined)
698
959
  throw new ScopedStoreError();
699
960
  }
700
- #countAfter(cubeId, cursor) {
961
+ #countAfter(cubeId, cursor, broadcastOnly) {
962
+ const visibility = broadcastOnly ? "AND visibility = 'broadcast'" : "";
701
963
  const row = cursor === null
702
- ? this.#database.prepare("SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ?").get(cubeId)
964
+ ? this.#database.prepare(`SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ? ${visibility}`).get(cubeId)
703
965
  : this.#database.prepare(`
704
966
  SELECT COUNT(*) AS count FROM activity_log
705
967
  WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
968
+ ${visibility}
706
969
  `).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
707
970
  if (row === undefined)
708
971
  throw new Error("Activity count query returned no row.");
@@ -724,7 +987,7 @@ class SqliteScopedStore {
724
987
  `).all(entryId);
725
988
  return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
726
989
  }
727
- #claims(cubeId) {
990
+ #claims(cubeId, broadcastOnly) {
728
991
  return this.#database.prepare(`
729
992
  SELECT acknowledgement.entry_id AS log_entry_id,
730
993
  acknowledgement.claimant_drone_id,
@@ -738,6 +1001,7 @@ class SqliteScopedStore {
738
1001
  LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
739
1002
  WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
740
1003
  AND acknowledgement.claimant_drone_id IS NOT NULL
1004
+ AND (${broadcastOnly ? "entry.visibility = 'broadcast'" : "1 = 1"})
741
1005
  ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
742
1006
  acknowledgement.claimant_drone_id
743
1007
  `).all(cubeId).map(claimRecord);
@@ -1046,6 +1310,33 @@ class SqliteCredentialStore {
1046
1310
  validateTimestamp(input.expiresAt);
1047
1311
  this.#database.exec("BEGIN IMMEDIATE");
1048
1312
  try {
1313
+ let scope = null;
1314
+ if (input.cubeSelector !== undefined) {
1315
+ if (input.purpose !== "client" || input.access === undefined) {
1316
+ throw new Error("Only client invitations may carry a complete cube scope.");
1317
+ }
1318
+ if (input.access !== "read" && input.access !== "write" && input.access !== "manage") {
1319
+ throw new Error("Unknown cube access grant.");
1320
+ }
1321
+ const rows = input.cubeSelector.kind === "id"
1322
+ ? this.#database.prepare("SELECT id, name FROM cubes WHERE id = ?").all(input.cubeSelector.value)
1323
+ : this.#database.prepare("SELECT id, name FROM cubes WHERE name = ? ORDER BY id").all(input.cubeSelector.value);
1324
+ if (rows.length === 0)
1325
+ throw new InvitationCubeNotFoundError();
1326
+ if (rows.length > 1) {
1327
+ throw new InvitationCubeAmbiguousError(rows.map((row) => requiredText(row, "id")));
1328
+ }
1329
+ const cubeId = requiredText(rows[0], "id");
1330
+ assertCanonicalUuid(cubeId, "Resolved cube id");
1331
+ scope = {
1332
+ cubeId,
1333
+ cubeName: requiredText(rows[0], "name"),
1334
+ access: input.access,
1335
+ };
1336
+ }
1337
+ else if (input.access !== undefined) {
1338
+ throw new Error("Invitation access requires a cube selector.");
1339
+ }
1049
1340
  let ownerEpoch = null;
1050
1341
  if (input.purpose === "owner") {
1051
1342
  const state = this.#database.prepare("SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1").get();
@@ -1070,10 +1361,12 @@ class SqliteCredentialStore {
1070
1361
  }
1071
1362
  this.#database.prepare(`
1072
1363
  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);
1364
+ id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch,
1365
+ cube_id, access
1366
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1367
+ `).run(input.id, input.digest.lookup, input.digest.verifier, input.expiresAt, this.#now(), input.purpose, ownerEpoch, scope?.cubeId ?? null, scope?.access ?? null);
1076
1368
  this.#database.exec("COMMIT");
1369
+ return scope;
1077
1370
  }
1078
1371
  catch (error) {
1079
1372
  try {
@@ -1093,7 +1386,7 @@ class SqliteCredentialStore {
1093
1386
  COALESCE(invitation.expires_at, '') AS expires_at,
1094
1387
  invitation.consumed_at, invitation.revoked_at,
1095
1388
  COALESCE(invitation.purpose, 'client') AS purpose,
1096
- invitation.owner_epoch
1389
+ invitation.owner_epoch, invitation.cube_id, invitation.access
1097
1390
  FROM (SELECT 1) AS seed
1098
1391
  LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
1099
1392
  `).get(lookup);
@@ -1118,7 +1411,8 @@ class SqliteCredentialStore {
1118
1411
  COALESCE(invitation.purpose, 'client') AS purpose,
1119
1412
  invitation.owner_epoch,
1120
1413
  COALESCE(invitation.expires_at, '') AS expires_at,
1121
- invitation.consumed_at, invitation.revoked_at
1414
+ invitation.consumed_at, invitation.revoked_at,
1415
+ invitation.cube_id, invitation.access
1122
1416
  FROM (SELECT 1) AS seed
1123
1417
  LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
1124
1418
  `).get(input.invitationId);
@@ -1166,6 +1460,12 @@ class SqliteCredentialStore {
1166
1460
  const ownerEpoch = invitation["owner_epoch"] === null
1167
1461
  ? null
1168
1462
  : requiredInteger(invitation, "owner_epoch");
1463
+ const cubeId = nullableText(invitation, "cube_id");
1464
+ const accessValue = nullableText(invitation, "access");
1465
+ const access = accessValue === null ? null : cubeAccess(accessValue);
1466
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
1467
+ throw new Error("Invalid invitation cube scope.");
1468
+ }
1169
1469
  if (purpose === "owner") {
1170
1470
  const state = this.#database.prepare(`
1171
1471
  SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
@@ -1176,6 +1476,10 @@ class SqliteCredentialStore {
1176
1476
  return null;
1177
1477
  }
1178
1478
  }
1479
+ if (cubeId !== null && this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(cubeId) === undefined) {
1480
+ this.#database.exec("ROLLBACK");
1481
+ return null;
1482
+ }
1179
1483
  this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
1180
1484
  this.#database.prepare("INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)").run(input.clientId, input.requestedClientName ?? "Local client", now);
1181
1485
  this.#mutationHook?.("enrollment.insert-client");
@@ -1192,6 +1496,13 @@ class SqliteCredentialStore {
1192
1496
  `).run(input.clientId, now);
1193
1497
  this.#mutationHook?.("enrollment.insert-capability");
1194
1498
  }
1499
+ if (cubeId !== null && access !== null) {
1500
+ this.#database.prepare(`
1501
+ INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
1502
+ VALUES (?, ?, ?, ?)
1503
+ `).run(input.clientId, cubeId, access, now);
1504
+ this.#mutationHook?.("enrollment.insert-grant");
1505
+ }
1195
1506
  this.#database.prepare(`
1196
1507
  INSERT INTO enrollment_claims (
1197
1508
  invitation_id, retry_key, client_id, requested_client_name,
@@ -1332,9 +1643,7 @@ async function prepareDatabasePath(path) {
1332
1643
  if (path === ":memory:")
1333
1644
  throw new Error("The server store requires a file-backed database.");
1334
1645
  const databasePath = resolve(path);
1335
- const directory = dirname(databasePath);
1336
- await ensureDirectoryTree(directory);
1337
- await chmod(directory, 0o700);
1646
+ const directory = await preparePrivateDataDirectory(dirname(databasePath));
1338
1647
  try {
1339
1648
  const handle = await open(databasePath, "ax", 0o600);
1340
1649
  await handle.close();
@@ -1352,6 +1661,12 @@ async function prepareDatabasePath(path) {
1352
1661
  await chmod(databasePath, 0o600);
1353
1662
  return databasePath;
1354
1663
  }
1664
+ export async function preparePrivateDataDirectory(path) {
1665
+ const directory = resolve(path);
1666
+ await ensureDirectoryTree(directory);
1667
+ await chmod(directory, 0o700);
1668
+ return directory;
1669
+ }
1355
1670
  async function ensureDirectoryTree(directory) {
1356
1671
  const { root } = parse(directory);
1357
1672
  let current = root;
@@ -1405,6 +1720,18 @@ function configureDatabase(database) {
1405
1720
  throw new Error("SQLite WAL mode is required.");
1406
1721
  }
1407
1722
  }
1723
+ function configureExistingDatabase(database) {
1724
+ database.exec(`
1725
+ PRAGMA foreign_keys = ON;
1726
+ PRAGMA synchronous = FULL;
1727
+ PRAGMA trusted_schema = OFF;
1728
+ PRAGMA secure_delete = ON;
1729
+ PRAGMA busy_timeout = 5000;
1730
+ `);
1731
+ if (readPragma(database, "journal_mode") !== "wal") {
1732
+ throw new Error("SQLite WAL mode is required.");
1733
+ }
1734
+ }
1408
1735
  function diagnostics(database) {
1409
1736
  const journalMode = readPragma(database, "journal_mode");
1410
1737
  const foreignKeys = readPragma(database, "foreign_keys");
@@ -1523,8 +1850,12 @@ function roleRecord(row) {
1523
1850
  cube_id: requiredText(row, "cube_id"),
1524
1851
  name: requiredText(row, "name"),
1525
1852
  short_description: requiredText(row, "short_description"),
1853
+ detailed_description: requiredText(row, "detailed_description"),
1526
1854
  is_default: requiredInteger(row, "is_default") === 1,
1855
+ is_mandatory: requiredInteger(row, "is_mandatory") === 1,
1527
1856
  is_human_seat: requiredInteger(row, "is_human_seat") === 1,
1857
+ can_broadcast: requiredInteger(row, "can_broadcast") === 1,
1858
+ receives_all_direct: requiredInteger(row, "receives_all_direct") === 1,
1528
1859
  role_class: roleClass,
1529
1860
  created_at: requiredText(row, "created_at"),
1530
1861
  };
@@ -1538,6 +1869,10 @@ function createCubeRecord(row) {
1538
1869
  };
1539
1870
  }
1540
1871
  function droneRecord(row) {
1872
+ const posture = requiredText(row, "posture");
1873
+ if (posture !== "observer" && posture !== "participant") {
1874
+ throw new Error("Database contains invalid drone posture.");
1875
+ }
1541
1876
  return {
1542
1877
  id: requiredText(row, "id"),
1543
1878
  cube_id: requiredText(row, "cube_id"),
@@ -1545,6 +1880,7 @@ function droneRecord(row) {
1545
1880
  label: requiredText(row, "label"),
1546
1881
  last_seen: requiredText(row, "last_seen"),
1547
1882
  hostname: nullableText(row, "hostname"),
1883
+ posture,
1548
1884
  created_at: requiredText(row, "created_at"),
1549
1885
  };
1550
1886
  }
@@ -1591,7 +1927,19 @@ function storedInvitationDigest(row) {
1591
1927
  }
1592
1928
  const epochValue = row["owner_epoch"];
1593
1929
  const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
1594
- return { ...digest, purpose, ownerEpoch };
1930
+ const cubeId = nullableText(row, "cube_id");
1931
+ const accessValue = nullableText(row, "access");
1932
+ const access = accessValue === null ? null : cubeAccess(accessValue);
1933
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
1934
+ throw new Error("Database contains invalid invitation cube scope.");
1935
+ }
1936
+ return { ...digest, purpose, ownerEpoch, cubeId, access };
1937
+ }
1938
+ function cubeAccess(value) {
1939
+ if (value !== "read" && value !== "write" && value !== "manage") {
1940
+ throw new Error("Database contains invalid cube access.");
1941
+ }
1942
+ return value;
1595
1943
  }
1596
1944
  function enrollmentClaimResult(purpose, clientId) {
1597
1945
  return purpose === "owner"
@@ -1659,6 +2007,30 @@ function validatePresentationName(value) {
1659
2007
  throw new Error("Presentation name is invalid.");
1660
2008
  }
1661
2009
  }
2010
+ function validateRoleName(value) {
2011
+ if (typeof value !== "string" || value.length < 1 || value.length > 64) {
2012
+ throw new Error("Role name must contain 1 to 64 characters.");
2013
+ }
2014
+ }
2015
+ function validateRoleShortDescription(value) {
2016
+ if (typeof value !== "string" || value.length > 1_024) {
2017
+ throw new Error("Role short description must contain at most 1024 characters.");
2018
+ }
2019
+ }
2020
+ export const MAX_ROLE_DETAILED_DESCRIPTION_CHARS = 51_200;
2021
+ export function assertRoleTextWriteAllowed(value, previous) {
2022
+ if (typeof value !== "string")
2023
+ throw new TypeError("Role detailed description must be text.");
2024
+ if (value.length <= MAX_ROLE_DETAILED_DESCRIPTION_CHARS)
2025
+ return;
2026
+ if (previous !== undefined && previous.length > MAX_ROLE_DETAILED_DESCRIPTION_CHARS &&
2027
+ value.length < previous.length)
2028
+ return;
2029
+ throw new RangeError("Role detailed description is too large.");
2030
+ }
2031
+ function booleanInteger(value) {
2032
+ return value ? 1 : 0;
2033
+ }
1662
2034
  function validateBoundedText(value, name, maxBytes) {
1663
2035
  if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
1664
2036
  throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);