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.
- package/README.md +69 -22
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +232 -18
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +10 -2
- package/dist/credentials.js +44 -6
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +77 -0
- package/dist/debug-log.js +125 -0
- package/dist/debug-log.js.map +1 -0
- package/dist/https-server.d.ts +3 -0
- package/dist/https-server.js +80 -4
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +71 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +10 -3
- package/dist/service.js +218 -15
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +65 -1
- package/dist/store.js +398 -26
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +236 -14
- package/src/credentials.ts +71 -5
- package/src/debug-log.ts +165 -0
- package/src/https-server.ts +75 -2
- package/src/index.ts +1 -1
- package/src/migrations.ts +74 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +239 -18
- package/src/start-options.ts +21 -4
- 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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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,
|
|
697
|
-
|
|
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,
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.`);
|