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/src/store.ts CHANGED
@@ -4,13 +4,14 @@ 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
6
 
7
- import { applyMigrations } from "./migrations.js";
7
+ import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
8
8
  import { operatorErrors } from "./operator-error.js";
9
9
  import {
10
10
  assertCanonicalUuid,
11
11
  assertServerDerivedPrincipal,
12
12
  type Principal,
13
13
  } from "./principal.js";
14
+ import { patchRoleSectionText, type RoleSectionPatchOp } from "./role-section.js";
14
15
 
15
16
  export type CubeAccess = "read" | "write" | "manage";
16
17
 
@@ -84,8 +85,12 @@ export interface RoleRecord {
84
85
  readonly cube_id: string;
85
86
  readonly name: string;
86
87
  readonly short_description: string;
88
+ readonly detailed_description: string;
87
89
  readonly is_default: boolean;
90
+ readonly is_mandatory: boolean;
88
91
  readonly is_human_seat: boolean;
92
+ readonly can_broadcast: boolean;
93
+ readonly receives_all_direct: boolean;
89
94
  readonly role_class: "queen" | "worker";
90
95
  readonly created_at: string;
91
96
  }
@@ -97,6 +102,7 @@ export interface DroneRecord {
97
102
  readonly label: string;
98
103
  readonly last_seen: string;
99
104
  readonly hostname: string | null;
105
+ readonly posture: "observer" | "participant";
100
106
  readonly created_at: string;
101
107
  }
102
108
 
@@ -113,6 +119,7 @@ export interface OpenStoreOptions {
113
119
  readonly capacityProbe?: () => StorageCapacity;
114
120
  readonly cubeLimits?: CubeLimits;
115
121
  readonly mutationHook?: (phase: string) => void;
122
+ readonly migrationMode?: "apply" | "require-current";
116
123
  }
117
124
 
118
125
  export interface CubeLimits {
@@ -166,6 +173,25 @@ export interface StoredSecretDigest extends DigestPair {
166
173
  export interface StoredInvitationDigest extends StoredSecretDigest {
167
174
  readonly purpose: "owner" | "client";
168
175
  readonly ownerEpoch: number | null;
176
+ readonly cubeId: string | null;
177
+ readonly access: CubeAccess | null;
178
+ }
179
+
180
+ export interface InvitationCubeScope {
181
+ readonly cubeId: string;
182
+ readonly cubeName: string;
183
+ readonly access: CubeAccess;
184
+ }
185
+
186
+ export class InvitationCubeNotFoundError extends Error {}
187
+
188
+ export class InvitationCubeAmbiguousError extends Error {
189
+ readonly candidateIds: readonly string[];
190
+
191
+ constructor(candidateIds: readonly string[]) {
192
+ super("Cube name is ambiguous.");
193
+ this.candidateIds = Object.freeze([...candidateIds]);
194
+ }
169
195
  }
170
196
 
171
197
  export interface EnrollmentClaimResult {
@@ -190,7 +216,9 @@ export interface CredentialStore {
190
216
  readonly digest: DigestPair;
191
217
  readonly expiresAt: string;
192
218
  readonly purpose: "owner" | "client";
193
- }) => void;
219
+ readonly cubeSelector?: { readonly kind: "id" | "name"; readonly value: string };
220
+ readonly access?: CubeAccess;
221
+ }) => InvitationCubeScope | null;
194
222
  readonly findInvitation: (lookup: Buffer) => StoredInvitationDigest | null;
195
223
  readonly claimInvitation: (input: {
196
224
  readonly invitationId: string;
@@ -220,6 +248,9 @@ export interface ScopedStore {
220
248
  readonly appendActivity: (cubeId: string, message: string) => ActivityRecord;
221
249
  readonly readActivity: (cubeId: string, limit: number) => ActivityRecord[];
222
250
  readonly listRoles: (cubeId: string) => RoleRecord[];
251
+ readonly createRole: (cubeId: string, input: CreateRoleInput) => RoleRecord;
252
+ readonly updateRole: (cubeId: string, roleId: string, input: UpdateRoleInput) => RoleRecord;
253
+ readonly patchRoleSection: (cubeId: string, roleId: string, input: RoleSectionPatchOp) => RoleRecord;
223
254
  readonly listDrones: (cubeId: string) => DroneRecord[];
224
255
  readonly appendLog: (cubeId: string, input: {
225
256
  readonly message: string;
@@ -241,6 +272,28 @@ export interface ScopedStore {
241
272
  readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
242
273
  }
243
274
 
275
+ export interface CreateRoleInput {
276
+ readonly name: string;
277
+ readonly shortDescription?: string;
278
+ readonly detailedDescription?: string;
279
+ readonly isDefault?: boolean;
280
+ readonly isMandatory?: boolean;
281
+ readonly isHumanSeat?: boolean;
282
+ readonly canBroadcast?: boolean;
283
+ readonly receivesAllDirect?: boolean;
284
+ }
285
+
286
+ export interface UpdateRoleInput {
287
+ readonly name?: string;
288
+ readonly shortDescription?: string;
289
+ readonly detailedDescription?: string;
290
+ readonly isDefault?: boolean;
291
+ readonly isMandatory?: boolean;
292
+ readonly isHumanSeat?: boolean;
293
+ readonly canBroadcast?: boolean;
294
+ readonly receivesAllDirect?: boolean;
295
+ }
296
+
244
297
  export interface CreateCubeInput {
245
298
  readonly retryKey: string;
246
299
  readonly name: string;
@@ -284,6 +337,7 @@ export interface SeatAttachRecord {
284
337
  readonly sessionId: string;
285
338
  readonly expiresAt: string;
286
339
  readonly generation: number;
340
+ readonly posture: "observer" | "participant";
287
341
  readonly reattached: boolean;
288
342
  readonly revokedSessionIds: readonly string[];
289
343
  }
@@ -359,6 +413,33 @@ export class ScopedStoreError extends Error {
359
413
  }
360
414
  }
361
415
 
416
+ export class RoleConflictError extends Error {
417
+ readonly code = "ROLE_ALREADY_EXISTS";
418
+
419
+ constructor() {
420
+ super("A role with that name already exists.");
421
+ this.name = "RoleConflictError";
422
+ }
423
+ }
424
+
425
+ export class DefaultRoleRequiredError extends Error {
426
+ readonly code = "DEFAULT_ROLE_REQUIRED";
427
+
428
+ constructor() {
429
+ super("A cube must retain one default role.");
430
+ this.name = "DefaultRoleRequiredError";
431
+ }
432
+ }
433
+
434
+ export class RoleSectionConflictError extends Error {
435
+ readonly code = "ROLE_SECTION_CONFLICT";
436
+
437
+ constructor() {
438
+ super("The role section patch conflicts with the current role text.");
439
+ this.name = "RoleSectionConflictError";
440
+ }
441
+ }
442
+
362
443
  export class CursorExpiredError extends Error {
363
444
  readonly code = "CURSOR_EXPIRED";
364
445
 
@@ -470,8 +551,13 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
470
551
  });
471
552
  const clock = options.clock ?? (() => new Date());
472
553
  try {
473
- configureDatabase(database);
474
- applyMigrations(database);
554
+ if (options.migrationMode === "require-current") {
555
+ configureExistingDatabase(database);
556
+ assertMigrationsCurrent(database);
557
+ } else {
558
+ configureDatabase(database);
559
+ applyMigrations(database);
560
+ }
475
561
  } catch (error) {
476
562
  database.close();
477
563
  throw error;
@@ -693,19 +779,229 @@ class SqliteScopedStore implements ScopedStore {
693
779
  listRoles(cubeId: string): RoleRecord[] {
694
780
  this.#requireCube(cubeId, "read");
695
781
  const rows = this.#database.prepare(`
696
- SELECT id, cube_id, name, short_description, is_default, is_human_seat,
697
- role_class, created_at
782
+ SELECT id, cube_id, name, short_description, detailed_description,
783
+ is_default, is_mandatory, is_human_seat, can_broadcast,
784
+ receives_all_direct, role_class, created_at
698
785
  FROM roles WHERE cube_id = ? ORDER BY name, id
699
786
  `).all(cubeId);
700
787
  return rows.map(roleRecord);
701
788
  }
702
789
 
790
+ createRole(cubeId: string, input: CreateRoleInput): RoleRecord {
791
+ assertCanonicalUuid(cubeId, "Cube id");
792
+ validateRoleName(input.name);
793
+ const shortDescription = input.shortDescription ?? "";
794
+ const detailedDescription = input.detailedDescription ?? "";
795
+ validateRoleShortDescription(shortDescription);
796
+ assertRoleTextWriteAllowed(detailedDescription);
797
+ for (const value of [
798
+ input.isDefault,
799
+ input.isMandatory,
800
+ input.isHumanSeat,
801
+ input.canBroadcast,
802
+ input.receivesAllDirect,
803
+ ]) {
804
+ if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
805
+ }
806
+ const isDefault = input.isDefault ?? false;
807
+ const isMandatory = input.isMandatory ?? false;
808
+ const isHumanSeat = input.isHumanSeat ?? false;
809
+ const canBroadcast = input.canBroadcast ?? false;
810
+ const receivesAllDirect = input.receivesAllDirect ?? false;
811
+ this.#requireCube(cubeId, "manage");
812
+ this.#capacityGuard.assertCanGrow(
813
+ Buffer.byteLength(input.name) + Buffer.byteLength(shortDescription) +
814
+ Buffer.byteLength(detailedDescription) + 8_192,
815
+ );
816
+
817
+ this.#database.exec("BEGIN IMMEDIATE");
818
+ try {
819
+ this.#requireCube(cubeId, "manage");
820
+ const duplicate = this.#database.prepare(
821
+ "SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ?",
822
+ ).get(cubeId, input.name);
823
+ if (duplicate !== undefined) throw new RoleConflictError();
824
+ if (isDefault) {
825
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
826
+ .run(cubeId);
827
+ this.#mutationHook?.("role.demote-default");
828
+ }
829
+ const id = randomUUID();
830
+ this.#database.prepare(`
831
+ INSERT INTO roles (
832
+ id, cube_id, name, short_description, detailed_description,
833
+ is_default, is_mandatory, is_human_seat, can_broadcast,
834
+ receives_all_direct, role_class, created_at
835
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'worker', ?)
836
+ `).run(
837
+ id, cubeId, input.name, shortDescription, detailedDescription,
838
+ booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat),
839
+ booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), this.#now(),
840
+ );
841
+ this.#mutationHook?.("role.insert");
842
+ const row = this.#database.prepare(`
843
+ SELECT id, cube_id, name, short_description, detailed_description,
844
+ is_default, is_mandatory, is_human_seat, can_broadcast,
845
+ receives_all_direct, role_class, created_at
846
+ FROM roles WHERE id = ? AND cube_id = ?
847
+ `).get(id, cubeId);
848
+ if (row === undefined) throw new ScopedStoreError();
849
+ this.#database.exec("COMMIT");
850
+ this.#mutationHook?.("role.after-commit");
851
+ return roleRecord(row);
852
+ } catch (error) {
853
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
854
+ throw error;
855
+ }
856
+ }
857
+
858
+ updateRole(cubeId: string, roleId: string, input: UpdateRoleInput): RoleRecord {
859
+ assertCanonicalUuid(cubeId, "Cube id");
860
+ assertCanonicalUuid(roleId, "Role id");
861
+ if (Object.values(input).every((value) => value === undefined)) {
862
+ throw new TypeError("At least one role field is required.");
863
+ }
864
+ if (input.name !== undefined) validateRoleName(input.name);
865
+ if (input.shortDescription !== undefined) validateRoleShortDescription(input.shortDescription);
866
+ if (input.detailedDescription !== undefined && typeof input.detailedDescription !== "string") {
867
+ throw new TypeError("Role detailed description must be text.");
868
+ }
869
+ for (const value of [
870
+ input.isDefault,
871
+ input.isMandatory,
872
+ input.isHumanSeat,
873
+ input.canBroadcast,
874
+ input.receivesAllDirect,
875
+ ]) {
876
+ if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
877
+ }
878
+ this.#requireCube(cubeId, "manage");
879
+ this.#capacityGuard.assertCanGrow(
880
+ Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
881
+ Buffer.byteLength(input.detailedDescription ?? "") + 8_192,
882
+ );
883
+
884
+ this.#database.exec("BEGIN IMMEDIATE");
885
+ try {
886
+ this.#requireCube(cubeId, "manage");
887
+ const existingRow = this.#database.prepare(`
888
+ SELECT id, cube_id, name, short_description, detailed_description,
889
+ is_default, is_mandatory, is_human_seat, can_broadcast,
890
+ receives_all_direct, role_class, created_at
891
+ FROM roles WHERE id = ? AND cube_id = ?
892
+ `).get(roleId, cubeId);
893
+ if (existingRow === undefined) throw new ScopedStoreError();
894
+ const existing = roleRecord(existingRow);
895
+ if (input.isDefault === false && existing.is_default) {
896
+ throw new DefaultRoleRequiredError();
897
+ }
898
+ if (input.name !== undefined && input.name !== existing.name) {
899
+ const duplicate = this.#database.prepare(
900
+ "SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ? AND id <> ?",
901
+ ).get(cubeId, input.name, roleId);
902
+ if (duplicate !== undefined) throw new RoleConflictError();
903
+ }
904
+ if (input.isDefault === true && !existing.is_default) {
905
+ this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
906
+ .run(cubeId);
907
+ this.#mutationHook?.("role.demote-default");
908
+ }
909
+ const nextDetailedDescription = input.detailedDescription ?? existing.detailed_description;
910
+ assertRoleTextWriteAllowed(nextDetailedDescription, existing.detailed_description);
911
+ this.#database.prepare(`
912
+ UPDATE roles SET
913
+ name = ?, short_description = ?, detailed_description = ?, is_default = ?,
914
+ is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?
915
+ WHERE id = ? AND cube_id = ?
916
+ `).run(
917
+ input.name ?? existing.name,
918
+ input.shortDescription ?? existing.short_description,
919
+ nextDetailedDescription,
920
+ booleanInteger(input.isDefault ?? existing.is_default),
921
+ booleanInteger(input.isMandatory ?? existing.is_mandatory),
922
+ booleanInteger(input.isHumanSeat ?? existing.is_human_seat),
923
+ booleanInteger(input.canBroadcast ?? existing.can_broadcast),
924
+ booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct),
925
+ roleId,
926
+ cubeId,
927
+ );
928
+ const row = this.#database.prepare(`
929
+ SELECT id, cube_id, name, short_description, detailed_description,
930
+ is_default, is_mandatory, is_human_seat, can_broadcast,
931
+ receives_all_direct, role_class, created_at
932
+ FROM roles WHERE id = ? AND cube_id = ?
933
+ `).get(roleId, cubeId);
934
+ if (row === undefined) throw new ScopedStoreError();
935
+ this.#database.exec("COMMIT");
936
+ this.#mutationHook?.("role.after-commit");
937
+ return roleRecord(row);
938
+ } catch (error) {
939
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
940
+ throw error;
941
+ }
942
+ }
943
+
944
+ patchRoleSection(cubeId: string, roleId: string, input: RoleSectionPatchOp): RoleRecord {
945
+ assertCanonicalUuid(cubeId, "Cube id");
946
+ assertCanonicalUuid(roleId, "Role id");
947
+ this.#requireCube(cubeId, "manage");
948
+ this.#capacityGuard.assertCanGrow(
949
+ Buffer.byteLength(input.heading) +
950
+ ("body" in input ? Buffer.byteLength(input.body) : 0) + 8_192,
951
+ );
952
+
953
+ this.#database.exec("BEGIN IMMEDIATE");
954
+ try {
955
+ this.#requireCube(cubeId, "manage");
956
+ const existingRow = this.#database.prepare(`
957
+ SELECT id, cube_id, name, short_description, detailed_description,
958
+ is_default, is_mandatory, is_human_seat, can_broadcast,
959
+ receives_all_direct, role_class, created_at
960
+ FROM roles WHERE id = ? AND cube_id = ?
961
+ `).get(roleId, cubeId);
962
+ if (existingRow === undefined) throw new ScopedStoreError();
963
+ const existing = roleRecord(existingRow);
964
+ let detailedDescription: string;
965
+ try {
966
+ detailedDescription = patchRoleSectionText(existing.detailed_description, input);
967
+ } catch (error) {
968
+ if (error instanceof TypeError) throw error;
969
+ throw new RoleSectionConflictError();
970
+ }
971
+ assertRoleTextWriteAllowed(detailedDescription, existing.detailed_description);
972
+ this.#database.prepare(
973
+ "UPDATE roles SET detailed_description = ? WHERE id = ? AND cube_id = ?",
974
+ ).run(detailedDescription, roleId, cubeId);
975
+ const row = this.#database.prepare(`
976
+ SELECT id, cube_id, name, short_description, detailed_description,
977
+ is_default, is_mandatory, is_human_seat, can_broadcast,
978
+ receives_all_direct, role_class, created_at
979
+ FROM roles WHERE id = ? AND cube_id = ?
980
+ `).get(roleId, cubeId);
981
+ if (row === undefined) throw new ScopedStoreError();
982
+ this.#database.exec("COMMIT");
983
+ this.#mutationHook?.("role.after-commit");
984
+ return roleRecord(row);
985
+ } catch (error) {
986
+ try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
987
+ throw error;
988
+ }
989
+ }
990
+
703
991
  listDrones(cubeId: string): DroneRecord[] {
704
992
  this.#requireCube(cubeId, "read");
705
993
  const rows = this.#database.prepare(`
706
- SELECT id, cube_id, role_id, label, COALESCE(last_seen, created_at) AS last_seen,
707
- hostname, created_at
708
- FROM drones WHERE cube_id = ? AND evicted_at IS NULL ORDER BY label, id
994
+ SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
995
+ COALESCE(drone.last_seen, drone.created_at) AS last_seen,
996
+ drone.hostname, drone.created_at,
997
+ CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
998
+ THEN 'participant' ELSE 'observer' END AS posture
999
+ FROM drones AS drone
1000
+ LEFT JOIN clients AS client ON client.id = drone.client_id
1001
+ LEFT JOIN client_cube_grants AS grant_row
1002
+ ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
1003
+ WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
1004
+ ORDER BY drone.label, drone.id
709
1005
  `).all(cubeId);
710
1006
  return rows.map(droneRecord);
711
1007
  }
@@ -758,9 +1054,16 @@ class SqliteScopedStore implements ScopedStore {
758
1054
  if (inserted.changes !== 1) throw new ScopedStoreError();
759
1055
  if (recipients.length > 0) {
760
1056
  const valid = this.#database.prepare(`
761
- SELECT COUNT(*) AS count FROM drones
762
- WHERE cube_id = ? AND evicted_at IS NULL
763
- AND id IN (${recipients.map(() => "?").join(", ")})
1057
+ SELECT COUNT(*) AS count
1058
+ FROM drones AS recipient
1059
+ JOIN clients AS recipient_client ON recipient_client.id = recipient.client_id
1060
+ JOIN client_cube_grants AS recipient_grant
1061
+ ON recipient_grant.client_id = recipient.client_id
1062
+ AND recipient_grant.cube_id = recipient.cube_id
1063
+ WHERE recipient.cube_id = ? AND recipient.evicted_at IS NULL
1064
+ AND recipient_client.revoked_at IS NULL
1065
+ AND recipient_grant.access IN ('write', 'manage')
1066
+ AND recipient.id IN (${recipients.map(() => "?").join(", ")})
764
1067
  `).get(cubeId, ...recipients);
765
1068
  if (valid === undefined) throw new Error("Recipient count query returned no row.");
766
1069
  if (requiredInteger(valid, "count") !== recipients.length) throw new ScopedStoreError();
@@ -786,6 +1089,7 @@ class SqliteScopedStore implements ScopedStore {
786
1089
  throw new Error("Activity read limit must be an integer from 1 to 500.");
787
1090
  }
788
1091
  this.#validateCursor(cubeId, cursor);
1092
+ const broadcastOnly = !this.#allowsDirectedWork(cubeId);
789
1093
  const cursorSql = cursor === null
790
1094
  ? { sql: "1 = 1", parameters: [] as string[] }
791
1095
  : {
@@ -796,6 +1100,7 @@ class SqliteScopedStore implements ScopedStore {
796
1100
  SELECT l.id
797
1101
  FROM activity_log AS l
798
1102
  WHERE l.cube_id = ? AND ${cursorSql.sql}
1103
+ AND (${broadcastOnly ? "l.visibility = 'broadcast'" : "1 = 1"})
799
1104
  ORDER BY l.created_at, l.id
800
1105
  LIMIT ?
801
1106
  `).all(cubeId, ...cursorSql.parameters, limit + 1);
@@ -804,13 +1109,15 @@ class SqliteScopedStore implements ScopedStore {
804
1109
  const nextCursor = entries.length === 0
805
1110
  ? cursor
806
1111
  : { id: entries.at(-1)!.id, created_at: entries.at(-1)!.created_at };
807
- const behind = nextCursor === null ? this.#countAfter(cubeId, null) : this.#countAfter(cubeId, nextCursor);
1112
+ const behind = nextCursor === null
1113
+ ? this.#countAfter(cubeId, null, broadcastOnly)
1114
+ : this.#countAfter(cubeId, nextCursor, broadcastOnly);
808
1115
  return {
809
1116
  entries,
810
1117
  cursor: nextCursor,
811
1118
  behind_by: behind,
812
1119
  has_more: behind > 0,
813
- claims: this.#claims(cubeId),
1120
+ claims: this.#claims(cubeId, broadcastOnly),
814
1121
  };
815
1122
  }
816
1123
 
@@ -912,6 +1219,7 @@ class SqliteScopedStore implements ScopedStore {
912
1219
  WHERE c.id = ? AND role.id = ? AND ${scope.sql}
913
1220
  `).get(input.cubeId, input.roleId, ...scope.parameters);
914
1221
  if (roleRow === undefined) throw new ScopedStoreError();
1222
+ const posture = this.#allowsDirectedWork(input.cubeId) ? "participant" : "observer";
915
1223
 
916
1224
  const retryBinding = this.#database.prepare(`
917
1225
  SELECT binding.cube_id AS binding_cube_id,
@@ -1048,6 +1356,7 @@ class SqliteScopedStore implements ScopedStore {
1048
1356
  sessionId: input.sessionId,
1049
1357
  expiresAt: input.expiresAt,
1050
1358
  generation,
1359
+ posture,
1051
1360
  reattached,
1052
1361
  revokedSessionIds: oldSessions,
1053
1362
  };
@@ -1059,7 +1368,16 @@ class SqliteScopedStore implements ScopedStore {
1059
1368
 
1060
1369
  subscribeActivity(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
1061
1370
  this.#requireCube(cubeId, "read");
1062
- return this.#activityHub.subscribe(cubeId, listener);
1371
+ return this.#activityHub.subscribe(cubeId, (entry) => {
1372
+ if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId)) listener(entry);
1373
+ });
1374
+ }
1375
+
1376
+ #allowsDirectedWork(cubeId: string): boolean {
1377
+ const scope = this.#scope("write");
1378
+ return this.#database.prepare(`
1379
+ SELECT 1 AS allowed FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
1380
+ `).get(cubeId, ...scope.parameters) !== undefined;
1063
1381
  }
1064
1382
 
1065
1383
  #requireCube(cubeId: string, access: CubeAccess): void {
@@ -1123,14 +1441,16 @@ class SqliteScopedStore implements ScopedStore {
1123
1441
  if (valid === undefined) throw new ScopedStoreError();
1124
1442
  }
1125
1443
 
1126
- #countAfter(cubeId: string, cursor: LogCursor | null): number {
1444
+ #countAfter(cubeId: string, cursor: LogCursor | null, broadcastOnly: boolean): number {
1445
+ const visibility = broadcastOnly ? "AND visibility = 'broadcast'" : "";
1127
1446
  const row = cursor === null
1128
1447
  ? this.#database.prepare(
1129
- "SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ?",
1448
+ `SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ? ${visibility}`,
1130
1449
  ).get(cubeId)
1131
1450
  : this.#database.prepare(`
1132
1451
  SELECT COUNT(*) AS count FROM activity_log
1133
1452
  WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
1453
+ ${visibility}
1134
1454
  `).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
1135
1455
  if (row === undefined) throw new Error("Activity count query returned no row.");
1136
1456
  return requiredInteger(row, "count");
@@ -1152,7 +1472,7 @@ class SqliteScopedStore implements ScopedStore {
1152
1472
  return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
1153
1473
  }
1154
1474
 
1155
- #claims(cubeId: string): ClaimRecord[] {
1475
+ #claims(cubeId: string, broadcastOnly: boolean): ClaimRecord[] {
1156
1476
  return this.#database.prepare(`
1157
1477
  SELECT acknowledgement.entry_id AS log_entry_id,
1158
1478
  acknowledgement.claimant_drone_id,
@@ -1166,6 +1486,7 @@ class SqliteScopedStore implements ScopedStore {
1166
1486
  LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
1167
1487
  WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
1168
1488
  AND acknowledgement.claimant_drone_id IS NOT NULL
1489
+ AND (${broadcastOnly ? "entry.visibility = 'broadcast'" : "1 = 1"})
1169
1490
  ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
1170
1491
  acknowledgement.claimant_drone_id
1171
1492
  `).all(cubeId).map(claimRecord);
@@ -1557,12 +1878,39 @@ class SqliteCredentialStore implements CredentialStore {
1557
1878
  readonly digest: DigestPair;
1558
1879
  readonly expiresAt: string;
1559
1880
  readonly purpose: "owner" | "client";
1560
- }): void {
1881
+ readonly cubeSelector?: { readonly kind: "id" | "name"; readonly value: string };
1882
+ readonly access?: CubeAccess;
1883
+ }): InvitationCubeScope | null {
1561
1884
  assertCanonicalUuid(input.id, "Invitation id");
1562
1885
  validateDigest(input.digest);
1563
1886
  validateTimestamp(input.expiresAt);
1564
1887
  this.#database.exec("BEGIN IMMEDIATE");
1565
1888
  try {
1889
+ let scope: InvitationCubeScope | null = null;
1890
+ if (input.cubeSelector !== undefined) {
1891
+ if (input.purpose !== "client" || input.access === undefined) {
1892
+ throw new Error("Only client invitations may carry a complete cube scope.");
1893
+ }
1894
+ if (input.access !== "read" && input.access !== "write" && input.access !== "manage") {
1895
+ throw new Error("Unknown cube access grant.");
1896
+ }
1897
+ const rows = input.cubeSelector.kind === "id"
1898
+ ? this.#database.prepare("SELECT id, name FROM cubes WHERE id = ?").all(input.cubeSelector.value)
1899
+ : this.#database.prepare("SELECT id, name FROM cubes WHERE name = ? ORDER BY id").all(input.cubeSelector.value);
1900
+ if (rows.length === 0) throw new InvitationCubeNotFoundError();
1901
+ if (rows.length > 1) {
1902
+ throw new InvitationCubeAmbiguousError(rows.map((row) => requiredText(row, "id")));
1903
+ }
1904
+ const cubeId = requiredText(rows[0]!, "id");
1905
+ assertCanonicalUuid(cubeId, "Resolved cube id");
1906
+ scope = {
1907
+ cubeId,
1908
+ cubeName: requiredText(rows[0]!, "name"),
1909
+ access: input.access,
1910
+ };
1911
+ } else if (input.access !== undefined) {
1912
+ throw new Error("Invitation access requires a cube selector.");
1913
+ }
1566
1914
  let ownerEpoch: number | null = null;
1567
1915
  if (input.purpose === "owner") {
1568
1916
  const state = this.#database.prepare(
@@ -1587,13 +1935,15 @@ class SqliteCredentialStore implements CredentialStore {
1587
1935
  }
1588
1936
  this.#database.prepare(`
1589
1937
  INSERT INTO enrollment_invitations (
1590
- id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch
1591
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
1938
+ id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch,
1939
+ cube_id, access
1940
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1592
1941
  `).run(
1593
1942
  input.id, input.digest.lookup, input.digest.verifier, input.expiresAt,
1594
- this.#now(), input.purpose, ownerEpoch,
1943
+ this.#now(), input.purpose, ownerEpoch, scope?.cubeId ?? null, scope?.access ?? null,
1595
1944
  );
1596
1945
  this.#database.exec("COMMIT");
1946
+ return scope;
1597
1947
  } catch (error) {
1598
1948
  try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
1599
1949
  throw error;
@@ -1610,7 +1960,7 @@ class SqliteCredentialStore implements CredentialStore {
1610
1960
  COALESCE(invitation.expires_at, '') AS expires_at,
1611
1961
  invitation.consumed_at, invitation.revoked_at,
1612
1962
  COALESCE(invitation.purpose, 'client') AS purpose,
1613
- invitation.owner_epoch
1963
+ invitation.owner_epoch, invitation.cube_id, invitation.access
1614
1964
  FROM (SELECT 1) AS seed
1615
1965
  LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
1616
1966
  `).get(lookup);
@@ -1641,7 +1991,8 @@ class SqliteCredentialStore implements CredentialStore {
1641
1991
  COALESCE(invitation.purpose, 'client') AS purpose,
1642
1992
  invitation.owner_epoch,
1643
1993
  COALESCE(invitation.expires_at, '') AS expires_at,
1644
- invitation.consumed_at, invitation.revoked_at
1994
+ invitation.consumed_at, invitation.revoked_at,
1995
+ invitation.cube_id, invitation.access
1645
1996
  FROM (SELECT 1) AS seed
1646
1997
  LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
1647
1998
  `).get(input.invitationId);
@@ -1689,6 +2040,12 @@ class SqliteCredentialStore implements CredentialStore {
1689
2040
  const ownerEpoch = invitation["owner_epoch"] === null
1690
2041
  ? null
1691
2042
  : requiredInteger(invitation, "owner_epoch");
2043
+ const cubeId = nullableText(invitation, "cube_id");
2044
+ const accessValue = nullableText(invitation, "access");
2045
+ const access = accessValue === null ? null : cubeAccess(accessValue);
2046
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
2047
+ throw new Error("Invalid invitation cube scope.");
2048
+ }
1692
2049
  if (purpose === "owner") {
1693
2050
  const state = this.#database.prepare(`
1694
2051
  SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
@@ -1699,6 +2056,10 @@ class SqliteCredentialStore implements CredentialStore {
1699
2056
  return null;
1700
2057
  }
1701
2058
  }
2059
+ if (cubeId !== null && this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(cubeId) === undefined) {
2060
+ this.#database.exec("ROLLBACK");
2061
+ return null;
2062
+ }
1702
2063
  this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
1703
2064
  this.#database.prepare(
1704
2065
  "INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)",
@@ -1723,6 +2084,13 @@ class SqliteCredentialStore implements CredentialStore {
1723
2084
  `).run(input.clientId, now);
1724
2085
  this.#mutationHook?.("enrollment.insert-capability");
1725
2086
  }
2087
+ if (cubeId !== null && access !== null) {
2088
+ this.#database.prepare(`
2089
+ INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
2090
+ VALUES (?, ?, ?, ?)
2091
+ `).run(input.clientId, cubeId, access, now);
2092
+ this.#mutationHook?.("enrollment.insert-grant");
2093
+ }
1726
2094
  this.#database.prepare(`
1727
2095
  INSERT INTO enrollment_claims (
1728
2096
  invitation_id, retry_key, client_id, requested_client_name,
@@ -1881,9 +2249,7 @@ class SqliteCredentialStore implements CredentialStore {
1881
2249
  async function prepareDatabasePath(path: string): Promise<string> {
1882
2250
  if (path === ":memory:") throw new Error("The server store requires a file-backed database.");
1883
2251
  const databasePath = resolve(path);
1884
- const directory = dirname(databasePath);
1885
- await ensureDirectoryTree(directory);
1886
- await chmod(directory, 0o700);
2252
+ const directory = await preparePrivateDataDirectory(dirname(databasePath));
1887
2253
  try {
1888
2254
  const handle = await open(databasePath, "ax", 0o600);
1889
2255
  await handle.close();
@@ -1898,6 +2264,13 @@ async function prepareDatabasePath(path: string): Promise<string> {
1898
2264
  return databasePath;
1899
2265
  }
1900
2266
 
2267
+ export async function preparePrivateDataDirectory(path: string): Promise<string> {
2268
+ const directory = resolve(path);
2269
+ await ensureDirectoryTree(directory);
2270
+ await chmod(directory, 0o700);
2271
+ return directory;
2272
+ }
2273
+
1901
2274
  async function ensureDirectoryTree(directory: string): Promise<void> {
1902
2275
  const { root } = parse(directory);
1903
2276
  let current = root;
@@ -1951,6 +2324,19 @@ function configureDatabase(database: DatabaseSync): void {
1951
2324
  }
1952
2325
  }
1953
2326
 
2327
+ function configureExistingDatabase(database: DatabaseSync): void {
2328
+ database.exec(`
2329
+ PRAGMA foreign_keys = ON;
2330
+ PRAGMA synchronous = FULL;
2331
+ PRAGMA trusted_schema = OFF;
2332
+ PRAGMA secure_delete = ON;
2333
+ PRAGMA busy_timeout = 5000;
2334
+ `);
2335
+ if (readPragma(database, "journal_mode") !== "wal") {
2336
+ throw new Error("SQLite WAL mode is required.");
2337
+ }
2338
+ }
2339
+
1954
2340
  function diagnostics(database: DatabaseSync): StoreDiagnostics {
1955
2341
  const journalMode = readPragma(database, "journal_mode");
1956
2342
  const foreignKeys = readPragma(database, "foreign_keys");
@@ -2083,8 +2469,12 @@ function roleRecord(row: Record<string, unknown>): RoleRecord {
2083
2469
  cube_id: requiredText(row, "cube_id"),
2084
2470
  name: requiredText(row, "name"),
2085
2471
  short_description: requiredText(row, "short_description"),
2472
+ detailed_description: requiredText(row, "detailed_description"),
2086
2473
  is_default: requiredInteger(row, "is_default") === 1,
2474
+ is_mandatory: requiredInteger(row, "is_mandatory") === 1,
2087
2475
  is_human_seat: requiredInteger(row, "is_human_seat") === 1,
2476
+ can_broadcast: requiredInteger(row, "can_broadcast") === 1,
2477
+ receives_all_direct: requiredInteger(row, "receives_all_direct") === 1,
2088
2478
  role_class: roleClass,
2089
2479
  created_at: requiredText(row, "created_at"),
2090
2480
  };
@@ -2100,6 +2490,10 @@ function createCubeRecord(row: Record<string, unknown>): CreateCubeRecord {
2100
2490
  }
2101
2491
 
2102
2492
  function droneRecord(row: Record<string, unknown>): DroneRecord {
2493
+ const posture = requiredText(row, "posture");
2494
+ if (posture !== "observer" && posture !== "participant") {
2495
+ throw new Error("Database contains invalid drone posture.");
2496
+ }
2103
2497
  return {
2104
2498
  id: requiredText(row, "id"),
2105
2499
  cube_id: requiredText(row, "cube_id"),
@@ -2107,6 +2501,7 @@ function droneRecord(row: Record<string, unknown>): DroneRecord {
2107
2501
  label: requiredText(row, "label"),
2108
2502
  last_seen: requiredText(row, "last_seen"),
2109
2503
  hostname: nullableText(row, "hostname"),
2504
+ posture,
2110
2505
  created_at: requiredText(row, "created_at"),
2111
2506
  };
2112
2507
  }
@@ -2155,7 +2550,20 @@ function storedInvitationDigest(row: Record<string, unknown>): StoredInvitationD
2155
2550
  }
2156
2551
  const epochValue = row["owner_epoch"];
2157
2552
  const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
2158
- return { ...digest, purpose, ownerEpoch };
2553
+ const cubeId = nullableText(row, "cube_id");
2554
+ const accessValue = nullableText(row, "access");
2555
+ const access = accessValue === null ? null : cubeAccess(accessValue);
2556
+ if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
2557
+ throw new Error("Database contains invalid invitation cube scope.");
2558
+ }
2559
+ return { ...digest, purpose, ownerEpoch, cubeId, access };
2560
+ }
2561
+
2562
+ function cubeAccess(value: string): CubeAccess {
2563
+ if (value !== "read" && value !== "write" && value !== "manage") {
2564
+ throw new Error("Database contains invalid cube access.");
2565
+ }
2566
+ return value;
2159
2567
  }
2160
2568
 
2161
2569
  function enrollmentClaimResult(
@@ -2239,6 +2647,32 @@ function validatePresentationName(value: string): void {
2239
2647
  }
2240
2648
  }
2241
2649
 
2650
+ function validateRoleName(value: string): void {
2651
+ if (typeof value !== "string" || value.length < 1 || value.length > 64) {
2652
+ throw new Error("Role name must contain 1 to 64 characters.");
2653
+ }
2654
+ }
2655
+
2656
+ function validateRoleShortDescription(value: string): void {
2657
+ if (typeof value !== "string" || value.length > 1_024) {
2658
+ throw new Error("Role short description must contain at most 1024 characters.");
2659
+ }
2660
+ }
2661
+
2662
+ export const MAX_ROLE_DETAILED_DESCRIPTION_CHARS = 51_200;
2663
+
2664
+ export function assertRoleTextWriteAllowed(value: string, previous?: string): void {
2665
+ if (typeof value !== "string") throw new TypeError("Role detailed description must be text.");
2666
+ if (value.length <= MAX_ROLE_DETAILED_DESCRIPTION_CHARS) return;
2667
+ if (previous !== undefined && previous.length > MAX_ROLE_DETAILED_DESCRIPTION_CHARS &&
2668
+ value.length < previous.length) return;
2669
+ throw new RangeError("Role detailed description is too large.");
2670
+ }
2671
+
2672
+ function booleanInteger(value: boolean): 0 | 1 {
2673
+ return value ? 1 : 0;
2674
+ }
2675
+
2242
2676
  function validateBoundedText(value: string, name: string, maxBytes: number): void {
2243
2677
  if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
2244
2678
  throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);