borgmcp-server 0.1.4 → 0.1.7
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 +32 -11
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +298 -89
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -6
- package/dist/credentials.js +43 -19
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +2 -3
- package/dist/debug-log.js +2 -3
- package/dist/debug-log.js.map +1 -1
- package/dist/enrollment.d.ts +3 -11
- package/dist/enrollment.js +21 -60
- package/dist/enrollment.js.map +1 -1
- package/dist/https-server.d.ts +2 -20
- package/dist/https-server.js +33 -51
- package/dist/https-server.js.map +1 -1
- package/dist/message-taxonomy.d.ts +38 -0
- package/dist/message-taxonomy.js +218 -0
- package/dist/message-taxonomy.js.map +1 -0
- package/dist/migrations.js +28 -0
- package/dist/migrations.js.map +1 -1
- package/dist/service.d.ts +4 -1
- package/dist/service.js +25 -10
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +66 -8
- package/dist/store.js +525 -100
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/coordination-api.ts +312 -86
- package/src/credentials.ts +44 -26
- package/src/debug-log.ts +5 -5
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +44 -82
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +28 -0
- package/src/service.ts +29 -9
- package/src/store.ts +593 -121
- package/dist/protocol-draft.d.ts +0 -2
- package/dist/protocol-draft.js +0 -31
- package/dist/protocol-draft.js.map +0 -1
- package/src/protocol-draft.ts +0 -32
package/src/store.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { statfsSync, statSync } from "node:fs";
|
|
3
3
|
import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, parse, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import type { MessageTaxonomy } from "borgmcp-shared/domain";
|
|
6
7
|
|
|
7
8
|
import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
|
|
8
9
|
import { operatorErrors } from "./operator-error.js";
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
type Principal,
|
|
13
14
|
} from "./principal.js";
|
|
14
15
|
import { patchRoleSectionText, type RoleSectionPatchOp } from "./role-section.js";
|
|
16
|
+
import { validateMessageTaxonomy } from "./message-taxonomy.js";
|
|
15
17
|
|
|
16
18
|
export type CubeAccess = "read" | "write" | "manage";
|
|
17
19
|
|
|
@@ -20,6 +22,7 @@ export interface CubeRecord {
|
|
|
20
22
|
readonly ownerId: string;
|
|
21
23
|
readonly name: string;
|
|
22
24
|
readonly directive: string;
|
|
25
|
+
readonly messageTaxonomy: MessageTaxonomy | null;
|
|
23
26
|
readonly createdAt: string;
|
|
24
27
|
readonly updatedAt: string;
|
|
25
28
|
}
|
|
@@ -51,6 +54,22 @@ export interface EnrichedActivityRecord {
|
|
|
51
54
|
readonly recipient_drone_ids: string[];
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
export type ActivityNotificationRecord = EnrichedActivityRecord & (
|
|
58
|
+
| {
|
|
59
|
+
readonly kind: "ack" | "claim";
|
|
60
|
+
readonly log_entry_id: string;
|
|
61
|
+
readonly ack_kind: "ack" | "claim";
|
|
62
|
+
readonly ack_at: string;
|
|
63
|
+
readonly entry_preview: string;
|
|
64
|
+
readonly author_drone_id?: string;
|
|
65
|
+
readonly claimant_drone_id?: string;
|
|
66
|
+
readonly claimant_role?: string | null;
|
|
67
|
+
}
|
|
68
|
+
| { readonly kind: "heartbeat_ping" }
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
export type ActivityStreamRecord = EnrichedActivityRecord | ActivityNotificationRecord;
|
|
72
|
+
|
|
54
73
|
export interface ClaimRecord {
|
|
55
74
|
readonly log_entry_id: string;
|
|
56
75
|
readonly claimant_drone_id: string;
|
|
@@ -104,6 +123,8 @@ export interface DroneRecord {
|
|
|
104
123
|
readonly hostname: string | null;
|
|
105
124
|
readonly posture: "observer" | "participant";
|
|
106
125
|
readonly created_at: string;
|
|
126
|
+
readonly last_log_post_at?: string | null;
|
|
127
|
+
readonly seen_since?: boolean;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
export interface StoreDiagnostics {
|
|
@@ -153,10 +174,19 @@ export interface StoreRuntime {
|
|
|
153
174
|
readonly forPrincipal: (principal: Principal) => ScopedStore;
|
|
154
175
|
readonly maintenance: MaintenanceStore;
|
|
155
176
|
readonly credentials: CredentialStore;
|
|
177
|
+
readonly liveness: LivenessStore;
|
|
156
178
|
readonly diagnostics: () => StoreDiagnostics;
|
|
157
179
|
readonly close: () => void;
|
|
158
180
|
}
|
|
159
181
|
|
|
182
|
+
export interface LivenessStore {
|
|
183
|
+
readonly scan: (options?: {
|
|
184
|
+
readonly silentMs?: number;
|
|
185
|
+
readonly cooldownMs?: number;
|
|
186
|
+
readonly limit?: number;
|
|
187
|
+
}) => ActivityNotificationRecord[];
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
export interface DigestPair {
|
|
161
191
|
readonly lookup: Buffer;
|
|
162
192
|
readonly verifier: Buffer;
|
|
@@ -206,6 +236,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
|
|
|
206
236
|
readonly cubeId: string;
|
|
207
237
|
readonly droneId: string;
|
|
208
238
|
readonly expiresAt: string;
|
|
239
|
+
readonly evictedAt: string | null;
|
|
209
240
|
}
|
|
210
241
|
|
|
211
242
|
export interface CredentialStore {
|
|
@@ -232,6 +263,7 @@ export interface CredentialStore {
|
|
|
232
263
|
readonly clientExists: (clientId: string) => boolean;
|
|
233
264
|
readonly clientIsActive: (clientId: string) => boolean;
|
|
234
265
|
readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
|
|
266
|
+
readonly findActiveDroneSessionExpiry: (sessionId: string) => string | null;
|
|
235
267
|
readonly rotateClientCredential: (input: {
|
|
236
268
|
readonly clientId: string;
|
|
237
269
|
readonly credentialId: string;
|
|
@@ -244,6 +276,7 @@ export interface ScopedStore {
|
|
|
244
276
|
readonly createCube: (input: CreateCubeInput) => CreateCubeRecord;
|
|
245
277
|
readonly listCubes: () => CubeRecord[];
|
|
246
278
|
readonly getCube: (cubeId: string) => CubeRecord | null;
|
|
279
|
+
readonly updateCube: (cubeId: string, input: UpdateCubeInput) => CubeRecord;
|
|
247
280
|
readonly updateDirective: (cubeId: string, directive: string) => void;
|
|
248
281
|
readonly appendActivity: (cubeId: string, message: string) => ActivityRecord;
|
|
249
282
|
readonly readActivity: (cubeId: string, limit: number) => ActivityRecord[];
|
|
@@ -252,6 +285,12 @@ export interface ScopedStore {
|
|
|
252
285
|
readonly updateRole: (cubeId: string, roleId: string, input: UpdateRoleInput) => RoleRecord;
|
|
253
286
|
readonly patchRoleSection: (cubeId: string, roleId: string, input: RoleSectionPatchOp) => RoleRecord;
|
|
254
287
|
readonly listDrones: (cubeId: string) => DroneRecord[];
|
|
288
|
+
readonly listDronesSince: (cubeId: string, since: string) => {
|
|
289
|
+
readonly drones: DroneRecord[];
|
|
290
|
+
readonly since: string;
|
|
291
|
+
};
|
|
292
|
+
readonly reassignDrone: (cubeId: string, droneId: string, roleId: string) => DroneRecord;
|
|
293
|
+
readonly evictDrone: (cubeId: string, droneId: string) => void;
|
|
255
294
|
readonly appendLog: (cubeId: string, input: {
|
|
256
295
|
readonly message: string;
|
|
257
296
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -267,7 +306,7 @@ export interface ScopedStore {
|
|
|
267
306
|
readonly listDecisions: (cubeId: string) => DecisionRecord[];
|
|
268
307
|
readonly subscribeActivity: (
|
|
269
308
|
cubeId: string,
|
|
270
|
-
listener: (entry:
|
|
309
|
+
listener: (entry: ActivityStreamRecord) => void,
|
|
271
310
|
) => (() => void);
|
|
272
311
|
readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
|
|
273
312
|
}
|
|
@@ -281,6 +320,7 @@ export interface CreateRoleInput {
|
|
|
281
320
|
readonly isHumanSeat?: boolean;
|
|
282
321
|
readonly canBroadcast?: boolean;
|
|
283
322
|
readonly receivesAllDirect?: boolean;
|
|
323
|
+
readonly roleClass?: "queen" | "worker";
|
|
284
324
|
}
|
|
285
325
|
|
|
286
326
|
export interface UpdateRoleInput {
|
|
@@ -292,6 +332,12 @@ export interface UpdateRoleInput {
|
|
|
292
332
|
readonly isHumanSeat?: boolean;
|
|
293
333
|
readonly canBroadcast?: boolean;
|
|
294
334
|
readonly receivesAllDirect?: boolean;
|
|
335
|
+
readonly roleClass?: "queen" | "worker";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface UpdateCubeInput {
|
|
339
|
+
readonly directive?: string;
|
|
340
|
+
readonly messageTaxonomy?: MessageTaxonomy | null;
|
|
295
341
|
}
|
|
296
342
|
|
|
297
343
|
export interface CreateCubeInput {
|
|
@@ -310,7 +356,6 @@ export interface CreateCubeRecord {
|
|
|
310
356
|
export interface SeatAttachInput {
|
|
311
357
|
readonly cubeId: string;
|
|
312
358
|
readonly roleId: string;
|
|
313
|
-
readonly retryKey: string;
|
|
314
359
|
readonly priorDroneId?: string;
|
|
315
360
|
readonly droneId: string;
|
|
316
361
|
readonly sessionId: string;
|
|
@@ -336,10 +381,7 @@ export interface SeatAttachRecord {
|
|
|
336
381
|
};
|
|
337
382
|
readonly sessionId: string;
|
|
338
383
|
readonly expiresAt: string;
|
|
339
|
-
readonly
|
|
340
|
-
readonly posture: "observer" | "participant";
|
|
341
|
-
readonly reattached: boolean;
|
|
342
|
-
readonly revokedSessionIds: readonly string[];
|
|
384
|
+
readonly result: "created" | "reused";
|
|
343
385
|
}
|
|
344
386
|
|
|
345
387
|
export interface MaintenanceStore {
|
|
@@ -401,6 +443,24 @@ export interface MaintenanceStore {
|
|
|
401
443
|
}) => void;
|
|
402
444
|
readonly revokeClient: (clientId: string) => void;
|
|
403
445
|
readonly revokeDroneSession: (sessionId: string) => void;
|
|
446
|
+
readonly expireDroneSession: (sessionId: string) => void;
|
|
447
|
+
readonly inspectManagedDrone: (droneId: string) => {
|
|
448
|
+
readonly role_id: string;
|
|
449
|
+
readonly evicted: boolean;
|
|
450
|
+
readonly session_revoked: boolean;
|
|
451
|
+
};
|
|
452
|
+
readonly inspectCubeManagementState: (cubeId: string) => {
|
|
453
|
+
readonly directive: string;
|
|
454
|
+
readonly taxonomy_marker: string | null;
|
|
455
|
+
readonly role_ids: readonly string[];
|
|
456
|
+
readonly active_decision_ids: readonly string[];
|
|
457
|
+
readonly drones: ReadonlyArray<{
|
|
458
|
+
readonly id: string;
|
|
459
|
+
readonly role_id: string;
|
|
460
|
+
readonly evicted: boolean;
|
|
461
|
+
readonly session_revoked: boolean;
|
|
462
|
+
}>;
|
|
463
|
+
};
|
|
404
464
|
readonly expireActivityCursor: (cubeId: string, cursor: LogCursor) => void;
|
|
405
465
|
}
|
|
406
466
|
|
|
@@ -440,6 +500,15 @@ export class RoleSectionConflictError extends Error {
|
|
|
440
500
|
}
|
|
441
501
|
}
|
|
442
502
|
|
|
503
|
+
export class RoleInUseError extends Error {
|
|
504
|
+
readonly code = "ROLE_IN_USE";
|
|
505
|
+
|
|
506
|
+
constructor() {
|
|
507
|
+
super("The human-seat role is already occupied.");
|
|
508
|
+
this.name = "RoleInUseError";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
443
512
|
export class CursorExpiredError extends Error {
|
|
444
513
|
readonly code = "CURSOR_EXPIRED";
|
|
445
514
|
|
|
@@ -449,12 +518,12 @@ export class CursorExpiredError extends Error {
|
|
|
449
518
|
}
|
|
450
519
|
}
|
|
451
520
|
|
|
452
|
-
export class
|
|
453
|
-
readonly code = "
|
|
521
|
+
export class AttachSessionRejectedError extends Error {
|
|
522
|
+
readonly code = "SESSION_REJECTED";
|
|
454
523
|
|
|
455
524
|
constructor() {
|
|
456
|
-
super("The
|
|
457
|
-
this.name = "
|
|
525
|
+
super("The saved session is not accepted for this seat.");
|
|
526
|
+
this.name = "AttachSessionRejectedError";
|
|
458
527
|
}
|
|
459
528
|
}
|
|
460
529
|
|
|
@@ -482,6 +551,7 @@ interface CubeRow {
|
|
|
482
551
|
readonly owner_id: string;
|
|
483
552
|
readonly name: string;
|
|
484
553
|
readonly directive: string;
|
|
554
|
+
readonly message_taxonomy: string | null;
|
|
485
555
|
readonly created_at: string;
|
|
486
556
|
readonly updated_at: string;
|
|
487
557
|
}
|
|
@@ -573,9 +643,10 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
573
643
|
capacityProbe,
|
|
574
644
|
requiredInteger(pageRow, "page_size"),
|
|
575
645
|
);
|
|
646
|
+
const activityHub = new ActivityHub();
|
|
576
647
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
648
|
+
const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
|
|
577
649
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
578
|
-
const activityHub = new ActivityHub();
|
|
579
650
|
return Object.freeze({
|
|
580
651
|
forPrincipal: (principal: Principal) => {
|
|
581
652
|
assertServerDerivedPrincipal(principal);
|
|
@@ -592,6 +663,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
592
663
|
},
|
|
593
664
|
maintenance,
|
|
594
665
|
credentials,
|
|
666
|
+
liveness,
|
|
595
667
|
diagnostics: () => diagnostics(database),
|
|
596
668
|
close: () => database.close(),
|
|
597
669
|
});
|
|
@@ -712,7 +784,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
712
784
|
listCubes(): CubeRecord[] {
|
|
713
785
|
const scope = this.#scope("read");
|
|
714
786
|
const rows = this.#database.prepare(`
|
|
715
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
787
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
788
|
+
c.created_at, c.updated_at
|
|
716
789
|
FROM cubes AS c
|
|
717
790
|
WHERE ${scope.sql}
|
|
718
791
|
ORDER BY c.id
|
|
@@ -724,7 +797,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
724
797
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
725
798
|
const scope = this.#scope("read");
|
|
726
799
|
const row = this.#database.prepare(`
|
|
727
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
800
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
801
|
+
c.created_at, c.updated_at
|
|
728
802
|
FROM cubes AS c
|
|
729
803
|
WHERE c.id = ? AND ${scope.sql}
|
|
730
804
|
`).get(cubeId, ...scope.parameters);
|
|
@@ -732,17 +806,42 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
732
806
|
}
|
|
733
807
|
|
|
734
808
|
updateDirective(cubeId: string, directive: string): void {
|
|
809
|
+
this.updateCube(cubeId, { directive });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
updateCube(cubeId: string, input: UpdateCubeInput): CubeRecord {
|
|
735
813
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
736
|
-
|
|
814
|
+
if (input.directive === undefined && input.messageTaxonomy === undefined) {
|
|
815
|
+
throw new TypeError("At least one cube field is required.");
|
|
816
|
+
}
|
|
817
|
+
if (input.directive !== undefined) validateDirective(input.directive);
|
|
818
|
+
const taxonomy = input.messageTaxonomy === undefined
|
|
819
|
+
? undefined
|
|
820
|
+
: validateMessageTaxonomy(input.messageTaxonomy);
|
|
821
|
+
const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
|
|
737
822
|
const scope = this.#scope("manage");
|
|
738
823
|
this.#requireCube(cubeId, "manage");
|
|
739
|
-
this.#capacityGuard.assertCanGrow(
|
|
824
|
+
this.#capacityGuard.assertCanGrow(
|
|
825
|
+
Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""),
|
|
826
|
+
);
|
|
740
827
|
const result = this.#database.prepare(`
|
|
741
828
|
UPDATE cubes AS c
|
|
742
|
-
SET directive = ?,
|
|
829
|
+
SET directive = COALESCE(?, directive),
|
|
830
|
+
message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
|
|
831
|
+
updated_at = ?
|
|
743
832
|
WHERE c.id = ? AND ${scope.sql}
|
|
744
|
-
`).run(
|
|
833
|
+
`).run(
|
|
834
|
+
input.directive ?? null,
|
|
835
|
+
serializedTaxonomy === undefined ? 0 : 1,
|
|
836
|
+
serializedTaxonomy ?? null,
|
|
837
|
+
this.#now(),
|
|
838
|
+
cubeId,
|
|
839
|
+
...scope.parameters,
|
|
840
|
+
);
|
|
745
841
|
if (result.changes !== 1) throw new ScopedStoreError();
|
|
842
|
+
const cube = this.getCube(cubeId);
|
|
843
|
+
if (cube === null) throw new ScopedStoreError();
|
|
844
|
+
return cube;
|
|
746
845
|
}
|
|
747
846
|
|
|
748
847
|
appendActivity(cubeId: string, message: string): ActivityRecord {
|
|
@@ -803,6 +902,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
803
902
|
]) {
|
|
804
903
|
if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
|
|
805
904
|
}
|
|
905
|
+
validateRoleClass(input.roleClass);
|
|
806
906
|
const isDefault = input.isDefault ?? false;
|
|
807
907
|
const isMandatory = input.isMandatory ?? false;
|
|
808
908
|
const isHumanSeat = input.isHumanSeat ?? false;
|
|
@@ -832,11 +932,12 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
832
932
|
id, cube_id, name, short_description, detailed_description,
|
|
833
933
|
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
834
934
|
receives_all_direct, role_class, created_at
|
|
835
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
935
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
836
936
|
`).run(
|
|
837
937
|
id, cubeId, input.name, shortDescription, detailedDescription,
|
|
838
938
|
booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat),
|
|
839
|
-
booleanInteger(canBroadcast), booleanInteger(receivesAllDirect),
|
|
939
|
+
booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker",
|
|
940
|
+
this.#now(),
|
|
840
941
|
);
|
|
841
942
|
this.#mutationHook?.("role.insert");
|
|
842
943
|
const row = this.#database.prepare(`
|
|
@@ -875,6 +976,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
875
976
|
]) {
|
|
876
977
|
if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
|
|
877
978
|
}
|
|
979
|
+
validateRoleClass(input.roleClass);
|
|
878
980
|
this.#requireCube(cubeId, "manage");
|
|
879
981
|
this.#capacityGuard.assertCanGrow(
|
|
880
982
|
Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
|
|
@@ -911,7 +1013,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
911
1013
|
this.#database.prepare(`
|
|
912
1014
|
UPDATE roles SET
|
|
913
1015
|
name = ?, short_description = ?, detailed_description = ?, is_default = ?,
|
|
914
|
-
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct =
|
|
1016
|
+
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
|
|
1017
|
+
role_class = ?
|
|
915
1018
|
WHERE id = ? AND cube_id = ?
|
|
916
1019
|
`).run(
|
|
917
1020
|
input.name ?? existing.name,
|
|
@@ -922,6 +1025,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
922
1025
|
booleanInteger(input.isHumanSeat ?? existing.is_human_seat),
|
|
923
1026
|
booleanInteger(input.canBroadcast ?? existing.can_broadcast),
|
|
924
1027
|
booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct),
|
|
1028
|
+
input.roleClass ?? existing.role_class,
|
|
925
1029
|
roleId,
|
|
926
1030
|
cubeId,
|
|
927
1031
|
);
|
|
@@ -1006,6 +1110,129 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1006
1110
|
return rows.map(droneRecord);
|
|
1007
1111
|
}
|
|
1008
1112
|
|
|
1113
|
+
listDronesSince(cubeId: string, since: string): { drones: DroneRecord[]; since: string } {
|
|
1114
|
+
this.#requireCube(cubeId, "read");
|
|
1115
|
+
let createdAt: string;
|
|
1116
|
+
let anchorId: string | null = null;
|
|
1117
|
+
if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
|
|
1118
|
+
const anchor = this.#database.prepare(
|
|
1119
|
+
"SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?",
|
|
1120
|
+
).get(since, cubeId);
|
|
1121
|
+
if (anchor === undefined) throw new ScopedStoreError();
|
|
1122
|
+
anchorId = requiredText(anchor, "id");
|
|
1123
|
+
createdAt = requiredText(anchor, "created_at");
|
|
1124
|
+
} else {
|
|
1125
|
+
validateTimestamp(since);
|
|
1126
|
+
createdAt = since;
|
|
1127
|
+
}
|
|
1128
|
+
const rows = this.#database.prepare(`
|
|
1129
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
1130
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
1131
|
+
drone.hostname, drone.created_at,
|
|
1132
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1133
|
+
THEN 'participant' ELSE 'observer' END AS posture,
|
|
1134
|
+
(SELECT MAX(post.created_at) FROM activity_log AS post
|
|
1135
|
+
WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
|
|
1136
|
+
EXISTS (SELECT 1 FROM activity_log AS response
|
|
1137
|
+
WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
|
|
1138
|
+
AND (response.created_at > ? OR
|
|
1139
|
+
(? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
|
|
1140
|
+
FROM drones AS drone
|
|
1141
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
1142
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
1143
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1144
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1145
|
+
ORDER BY drone.label, drone.id
|
|
1146
|
+
`).all(createdAt, anchorId, createdAt, anchorId, cubeId);
|
|
1147
|
+
return {
|
|
1148
|
+
since: createdAt,
|
|
1149
|
+
drones: rows.map((row) => ({
|
|
1150
|
+
...droneRecord(row),
|
|
1151
|
+
last_log_post_at: nullableText(row, "last_log_post_at"),
|
|
1152
|
+
seen_since: requiredInteger(row, "seen_since") === 1,
|
|
1153
|
+
})),
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
reassignDrone(cubeId: string, droneId: string, roleId: string): DroneRecord {
|
|
1158
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1159
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
1160
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
1161
|
+
this.#requireCube(cubeId, "manage");
|
|
1162
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1163
|
+
try {
|
|
1164
|
+
this.#requireCube(cubeId, "manage");
|
|
1165
|
+
const droneRow = this.#database.prepare(`
|
|
1166
|
+
SELECT id, cube_id, role_id, label FROM drones
|
|
1167
|
+
WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1168
|
+
`).get(droneId, cubeId);
|
|
1169
|
+
const targetRoleRow = this.#database.prepare(`
|
|
1170
|
+
SELECT id, is_human_seat, role_class FROM roles WHERE id = ? AND cube_id = ?
|
|
1171
|
+
`).get(roleId, cubeId);
|
|
1172
|
+
if (droneRow === undefined || targetRoleRow === undefined) throw new ScopedStoreError();
|
|
1173
|
+
const sourceRoleRow = this.#database.prepare(`
|
|
1174
|
+
SELECT is_human_seat FROM roles WHERE id = ? AND cube_id = ?
|
|
1175
|
+
`).get(requiredText(droneRow, "role_id"), cubeId);
|
|
1176
|
+
if (sourceRoleRow === undefined) throw new ScopedStoreError();
|
|
1177
|
+
if (requiredText(targetRoleRow, "role_class") === "queen" &&
|
|
1178
|
+
requiredInteger(sourceRoleRow, "is_human_seat") !== 1) {
|
|
1179
|
+
throw new AccessDeniedError();
|
|
1180
|
+
}
|
|
1181
|
+
if (requiredInteger(targetRoleRow, "is_human_seat") === 1) {
|
|
1182
|
+
const occupied = this.#database.prepare(`
|
|
1183
|
+
SELECT 1 AS present FROM drones
|
|
1184
|
+
WHERE cube_id = ? AND role_id = ? AND id <> ? AND evicted_at IS NULL
|
|
1185
|
+
`).get(cubeId, roleId, droneId);
|
|
1186
|
+
if (occupied !== undefined) throw new RoleInUseError();
|
|
1187
|
+
}
|
|
1188
|
+
this.#database.prepare("UPDATE drones SET role_id = ? WHERE id = ? AND cube_id = ?")
|
|
1189
|
+
.run(roleId, droneId, cubeId);
|
|
1190
|
+
const row = this.#database.prepare(`
|
|
1191
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
1192
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
1193
|
+
drone.hostname, drone.created_at,
|
|
1194
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1195
|
+
THEN 'participant' ELSE 'observer' END AS posture
|
|
1196
|
+
FROM drones AS drone
|
|
1197
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
1198
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
1199
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1200
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1201
|
+
`).get(droneId, cubeId);
|
|
1202
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
1203
|
+
this.#database.exec("COMMIT");
|
|
1204
|
+
return droneRecord(row);
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1207
|
+
throw error;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
evictDrone(cubeId: string, droneId: string): void {
|
|
1212
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1213
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
1214
|
+
this.#requireCube(cubeId, "manage");
|
|
1215
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1216
|
+
try {
|
|
1217
|
+
this.#requireCube(cubeId, "manage");
|
|
1218
|
+
const active = this.#database.prepare(`
|
|
1219
|
+
SELECT 1 AS present FROM drones WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1220
|
+
`).get(droneId, cubeId);
|
|
1221
|
+
if (active === undefined) throw new ScopedStoreError();
|
|
1222
|
+
const now = this.#now();
|
|
1223
|
+
this.#database.prepare("UPDATE drones SET evicted_at = ? WHERE id = ? AND cube_id = ?")
|
|
1224
|
+
.run(now, droneId, cubeId);
|
|
1225
|
+
this.#database.prepare(`
|
|
1226
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
1227
|
+
WHERE drone_id = ? AND cube_id = ? AND revoked_at IS NULL
|
|
1228
|
+
`).run(now, droneId, cubeId);
|
|
1229
|
+
this.#database.exec("COMMIT");
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1232
|
+
throw error;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1009
1236
|
appendLog(cubeId: string, input: {
|
|
1010
1237
|
readonly message: string;
|
|
1011
1238
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -1052,6 +1279,12 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1052
1279
|
createdAt, visibility, cubeId, ...scope.parameters,
|
|
1053
1280
|
);
|
|
1054
1281
|
if (inserted.changes !== 1) throw new ScopedStoreError();
|
|
1282
|
+
if (droneId !== null) {
|
|
1283
|
+
this.#database.prepare(`
|
|
1284
|
+
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1285
|
+
`).run(createdAt, droneId, cubeId);
|
|
1286
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1287
|
+
}
|
|
1055
1288
|
if (recipients.length > 0) {
|
|
1056
1289
|
const valid = this.#database.prepare(`
|
|
1057
1290
|
SELECT COUNT(*) AS count
|
|
@@ -1125,19 +1358,56 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1125
1358
|
this.#requireCube(cubeId, "write");
|
|
1126
1359
|
assertCanonicalUuid(entryId, "Activity entry id");
|
|
1127
1360
|
if (kind !== "ack" && kind !== "claim") throw new Error("Unknown acknowledgement kind.");
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
).get(entryId, cubeId);
|
|
1131
|
-
if (
|
|
1361
|
+
const original = this.#database.prepare(`
|
|
1362
|
+
SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
|
|
1363
|
+
`).get(entryId, cubeId);
|
|
1364
|
+
if (original === undefined) throw new ScopedStoreError();
|
|
1132
1365
|
this.#capacityGuard.assertCanGrow(512);
|
|
1133
1366
|
const claimant = this.#principal.kind === "drone-session"
|
|
1134
1367
|
? this.#principal.droneId
|
|
1135
1368
|
: this.#principal.id;
|
|
1136
|
-
this.#
|
|
1369
|
+
const ackAt = this.#now();
|
|
1370
|
+
const inserted = this.#database.prepare(`
|
|
1137
1371
|
INSERT OR IGNORE INTO activity_acks (
|
|
1138
1372
|
entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
|
|
1139
1373
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1140
|
-
`).run(entryId, this.#principal.kind, this.#principal.id, kind,
|
|
1374
|
+
`).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
|
|
1375
|
+
if (inserted.changes !== 1 || this.#principal.kind !== "drone-session") return;
|
|
1376
|
+
const claimantDroneId = this.#principal.droneId;
|
|
1377
|
+
|
|
1378
|
+
const actor = this.#database.prepare(`
|
|
1379
|
+
SELECT drone.label, role.name AS role_name
|
|
1380
|
+
FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
|
|
1381
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1382
|
+
`).get(claimantDroneId, cubeId);
|
|
1383
|
+
const preview = activityPreview(requiredText(original, "message"));
|
|
1384
|
+
const originalAuthor = nullableText(original, "drone_id");
|
|
1385
|
+
const recipients = kind === "ack"
|
|
1386
|
+
? originalAuthor === null ? [] : [originalAuthor]
|
|
1387
|
+
: this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
|
|
1388
|
+
.filter((droneId) => droneId !== claimantDroneId);
|
|
1389
|
+
if (recipients.length === 0) return;
|
|
1390
|
+
const roleName = actor === undefined ? null : requiredText(actor, "role_name");
|
|
1391
|
+
const notification: ActivityNotificationRecord = {
|
|
1392
|
+
kind,
|
|
1393
|
+
id: randomUUID(),
|
|
1394
|
+
cube_id: cubeId,
|
|
1395
|
+
drone_id: claimantDroneId,
|
|
1396
|
+
drone_label: actor === undefined ? null : requiredText(actor, "label"),
|
|
1397
|
+
role_name: roleName,
|
|
1398
|
+
message: `[${kind.toUpperCase()}] ${preview}`,
|
|
1399
|
+
created_at: ackAt,
|
|
1400
|
+
visibility: "direct",
|
|
1401
|
+
recipient_drone_ids: recipients,
|
|
1402
|
+
log_entry_id: entryId,
|
|
1403
|
+
ack_kind: kind,
|
|
1404
|
+
ack_at: ackAt,
|
|
1405
|
+
entry_preview: preview,
|
|
1406
|
+
...(kind === "ack"
|
|
1407
|
+
? { author_drone_id: recipients[0]! }
|
|
1408
|
+
: { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
|
|
1409
|
+
};
|
|
1410
|
+
this.#activityHub.publish(cubeId, notification);
|
|
1141
1411
|
}
|
|
1142
1412
|
|
|
1143
1413
|
recordDecision(cubeId: string, input: {
|
|
@@ -1194,7 +1464,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1194
1464
|
if (this.#principal.kind !== "client") throw new ScopedStoreError();
|
|
1195
1465
|
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
1196
1466
|
assertCanonicalUuid(input.roleId, "Role id");
|
|
1197
|
-
assertCanonicalUuid(input.retryKey, "Retry key");
|
|
1198
1467
|
if (input.priorDroneId !== undefined) assertCanonicalUuid(input.priorDroneId, "Prior drone id");
|
|
1199
1468
|
assertCanonicalUuid(input.droneId, "Drone id");
|
|
1200
1469
|
assertCanonicalUuid(input.sessionId, "Drone session id");
|
|
@@ -1219,95 +1488,96 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1219
1488
|
WHERE c.id = ? AND role.id = ? AND ${scope.sql}
|
|
1220
1489
|
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
1221
1490
|
if (roleRow === undefined) throw new ScopedStoreError();
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
WHERE
|
|
1232
|
-
`).get(
|
|
1491
|
+
const bound = this.#database.prepare(`
|
|
1492
|
+
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
1493
|
+
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1494
|
+
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1495
|
+
drone.role_id, drone.label, drone.evicted_at
|
|
1496
|
+
FROM drone_session_credentials AS credential
|
|
1497
|
+
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1498
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
1499
|
+
AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
|
|
1500
|
+
WHERE credential.lookup_digest = ?
|
|
1501
|
+
`).get(input.credentialDigest.lookup);
|
|
1502
|
+
if (bound !== undefined) {
|
|
1503
|
+
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
1504
|
+
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
1505
|
+
optionalText(bound, "credential_revoked_at") !== null ||
|
|
1506
|
+
optionalText(bound, "session_revoked_at") !== null ||
|
|
1507
|
+
optionalText(bound, "evicted_at") !== null ||
|
|
1508
|
+
requiredText(bound, "expires_at") <= now ||
|
|
1509
|
+
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
1510
|
+
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
1511
|
+
requiredText(bound, "role_id") !== input.roleId ||
|
|
1512
|
+
(input.priorDroneId !== undefined &&
|
|
1513
|
+
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
1514
|
+
throw new AttachSessionRejectedError();
|
|
1515
|
+
}
|
|
1516
|
+
const droneId = requiredText(bound, "drone_id");
|
|
1517
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1518
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1519
|
+
const attachedRoleRow = this.#database.prepare(`
|
|
1520
|
+
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
1521
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
1522
|
+
`).get(requiredText(bound, "role_id"), input.cubeId);
|
|
1523
|
+
if (attachedRoleRow === undefined) throw new Error("Attached drone role is unavailable.");
|
|
1524
|
+
const roleClass = requiredText(attachedRoleRow, "role_class");
|
|
1525
|
+
if (roleClass !== "queen" && roleClass !== "worker") {
|
|
1526
|
+
throw new Error("Database contains invalid role class.");
|
|
1527
|
+
}
|
|
1528
|
+
this.#database.exec("COMMIT");
|
|
1529
|
+
return {
|
|
1530
|
+
cube: {
|
|
1531
|
+
id: requiredText(roleRow, "cube_id"),
|
|
1532
|
+
name: requiredText(roleRow, "cube_name"),
|
|
1533
|
+
},
|
|
1534
|
+
role: {
|
|
1535
|
+
id: requiredText(attachedRoleRow, "role_id"),
|
|
1536
|
+
name: requiredText(attachedRoleRow, "role_name"),
|
|
1537
|
+
role_class: roleClass,
|
|
1538
|
+
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
1539
|
+
},
|
|
1540
|
+
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
1541
|
+
sessionId: requiredText(bound, "session_id"),
|
|
1542
|
+
expiresAt: requiredText(bound, "expires_at"),
|
|
1543
|
+
result: "reused",
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
1548
|
+
SELECT id, role_id, label FROM drones
|
|
1549
|
+
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1550
|
+
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
1233
1551
|
let droneId: string;
|
|
1234
1552
|
let droneLabel: string;
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
|
|
1240
|
-
requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
|
|
1241
|
-
nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
|
|
1242
|
-
throw new AttachConflictError();
|
|
1553
|
+
if (priorSeat !== undefined) {
|
|
1554
|
+
const priorSeatId = requiredText(priorSeat, "id");
|
|
1555
|
+
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
1556
|
+
throw new AttachSessionRejectedError();
|
|
1243
1557
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1558
|
+
const occupied = this.#database.prepare(`
|
|
1559
|
+
SELECT 1 FROM drone_sessions AS session
|
|
1560
|
+
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
1561
|
+
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
1562
|
+
AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
|
|
1563
|
+
AND session.expires_at > ?
|
|
1564
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId, now);
|
|
1565
|
+
if (occupied !== undefined) throw new AttachSessionRejectedError();
|
|
1566
|
+
droneId = priorSeatId;
|
|
1567
|
+
droneLabel = requiredText(priorSeat, "label");
|
|
1568
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1251
1569
|
} else {
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
FROM drones
|
|
1255
|
-
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1256
|
-
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
1257
|
-
if (priorSeat !== undefined) {
|
|
1258
|
-
droneId = requiredText(priorSeat, "id");
|
|
1259
|
-
droneLabel = requiredText(priorSeat, "label");
|
|
1260
|
-
generation = requiredInteger(priorSeat, "attach_generation") + 1;
|
|
1261
|
-
this.#database.prepare(`
|
|
1262
|
-
UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
|
|
1263
|
-
`).run(now, generation, droneId);
|
|
1264
|
-
reattached = true;
|
|
1265
|
-
} else {
|
|
1266
|
-
droneId = input.droneId;
|
|
1267
|
-
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
1268
|
-
this.#database.prepare(`
|
|
1269
|
-
INSERT INTO drones (
|
|
1270
|
-
id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
|
|
1271
|
-
attach_generation
|
|
1272
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
1273
|
-
`).run(
|
|
1274
|
-
droneId,
|
|
1275
|
-
input.cubeId,
|
|
1276
|
-
input.roleId,
|
|
1277
|
-
this.#principal.id,
|
|
1278
|
-
droneLabel,
|
|
1279
|
-
now,
|
|
1280
|
-
now,
|
|
1281
|
-
input.retryKey,
|
|
1282
|
-
);
|
|
1283
|
-
reattached = false;
|
|
1284
|
-
generation = 1;
|
|
1285
|
-
}
|
|
1570
|
+
droneId = input.droneId;
|
|
1571
|
+
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
1286
1572
|
this.#database.prepare(`
|
|
1287
|
-
INSERT INTO
|
|
1288
|
-
|
|
1289
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1573
|
+
INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
|
|
1574
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1290
1575
|
`).run(
|
|
1291
|
-
|
|
1292
|
-
droneId, input.priorDroneId ?? null, now,
|
|
1576
|
+
droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
|
|
1293
1577
|
);
|
|
1294
1578
|
}
|
|
1579
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1295
1580
|
|
|
1296
|
-
const oldSessions = this.#database.prepare(
|
|
1297
|
-
"SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?",
|
|
1298
|
-
).all(droneId, this.#principal.id, input.cubeId)
|
|
1299
|
-
.map((row) => requiredText(row, "id"));
|
|
1300
|
-
if (oldSessions.length > 0) {
|
|
1301
|
-
const placeholders = oldSessions.map(() => "?").join(", ");
|
|
1302
|
-
this.#database.prepare(`
|
|
1303
|
-
UPDATE drone_session_credentials SET revoked_at = ?
|
|
1304
|
-
WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
|
|
1305
|
-
`).run(now, ...oldSessions);
|
|
1306
|
-
this.#database.prepare(`
|
|
1307
|
-
UPDATE drone_sessions SET revoked_at = ?
|
|
1308
|
-
WHERE id IN (${placeholders}) AND revoked_at IS NULL
|
|
1309
|
-
`).run(now, ...oldSessions);
|
|
1310
|
-
}
|
|
1311
1581
|
this.#database.prepare(`
|
|
1312
1582
|
INSERT INTO drone_sessions (
|
|
1313
1583
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -1355,10 +1625,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1355
1625
|
drone: { id: droneId, label: droneLabel },
|
|
1356
1626
|
sessionId: input.sessionId,
|
|
1357
1627
|
expiresAt: input.expiresAt,
|
|
1358
|
-
|
|
1359
|
-
posture,
|
|
1360
|
-
reattached,
|
|
1361
|
-
revokedSessionIds: oldSessions,
|
|
1628
|
+
result: "created",
|
|
1362
1629
|
};
|
|
1363
1630
|
} catch (error) {
|
|
1364
1631
|
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
@@ -1366,13 +1633,44 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1366
1633
|
}
|
|
1367
1634
|
}
|
|
1368
1635
|
|
|
1369
|
-
subscribeActivity(cubeId: string, listener: (entry:
|
|
1636
|
+
subscribeActivity(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
|
|
1370
1637
|
this.#requireCube(cubeId, "read");
|
|
1371
1638
|
return this.#activityHub.subscribe(cubeId, (entry) => {
|
|
1639
|
+
if ("kind" in entry) {
|
|
1640
|
+
if (this.#principal.kind === "drone-session" &&
|
|
1641
|
+
entry.recipient_drone_ids.includes(this.#principal.droneId)) listener(entry);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1372
1644
|
if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId)) listener(entry);
|
|
1373
1645
|
});
|
|
1374
1646
|
}
|
|
1375
1647
|
|
|
1648
|
+
#claimAudience(cubeId: string, entryId: string, visibility: string): string[] {
|
|
1649
|
+
if (visibility === "direct") {
|
|
1650
|
+
return this.#database.prepare(`
|
|
1651
|
+
SELECT recipient.drone_id
|
|
1652
|
+
FROM activity_log_recipients AS recipient
|
|
1653
|
+
JOIN drones AS drone ON drone.id = recipient.drone_id
|
|
1654
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1655
|
+
JOIN client_cube_grants AS grant_row
|
|
1656
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1657
|
+
WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1658
|
+
AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1659
|
+
ORDER BY recipient.drone_id
|
|
1660
|
+
`).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1661
|
+
}
|
|
1662
|
+
return this.#database.prepare(`
|
|
1663
|
+
SELECT drone.id AS drone_id
|
|
1664
|
+
FROM drones AS drone
|
|
1665
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1666
|
+
JOIN client_cube_grants AS grant_row
|
|
1667
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1668
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
|
|
1669
|
+
AND grant_row.access IN ('write', 'manage')
|
|
1670
|
+
ORDER BY drone.id
|
|
1671
|
+
`).all(cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1376
1674
|
#allowsDirectedWork(cubeId: string): boolean {
|
|
1377
1675
|
const scope = this.#scope("write");
|
|
1378
1676
|
return this.#database.prepare(`
|
|
@@ -1386,7 +1684,15 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1386
1684
|
const row = this.#database.prepare(`
|
|
1387
1685
|
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
1388
1686
|
`).get(cubeId, ...scope.parameters);
|
|
1389
|
-
if (row
|
|
1687
|
+
if (row !== undefined) return;
|
|
1688
|
+
if (access === "manage") {
|
|
1689
|
+
const visibleScope = this.#scope("read");
|
|
1690
|
+
const visible = this.#database.prepare(`
|
|
1691
|
+
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
|
|
1692
|
+
`).get(cubeId, ...visibleScope.parameters);
|
|
1693
|
+
if (visible !== undefined) throw new AccessDeniedError();
|
|
1694
|
+
}
|
|
1695
|
+
throw new ScopedStoreError();
|
|
1390
1696
|
}
|
|
1391
1697
|
|
|
1392
1698
|
#nextActivityTimestamp(cubeId: string): string {
|
|
@@ -1644,7 +1950,7 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1644
1950
|
for (const table of [
|
|
1645
1951
|
"cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
|
|
1646
1952
|
"decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
|
|
1647
|
-
"
|
|
1953
|
+
"drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
|
|
1648
1954
|
"enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
|
|
1649
1955
|
"enrollment_invitations",
|
|
1650
1956
|
]) this.#database.exec(`DELETE FROM ${table}`);
|
|
@@ -1792,6 +2098,59 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1792
2098
|
).run(this.#now(), sessionId);
|
|
1793
2099
|
}
|
|
1794
2100
|
|
|
2101
|
+
expireDroneSession(sessionId: string): void {
|
|
2102
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
2103
|
+
this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
|
|
2104
|
+
.run(new Date(0).toISOString(), sessionId);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
inspectManagedDrone(droneId: string) {
|
|
2108
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
2109
|
+
const row = this.#database.prepare(`
|
|
2110
|
+
SELECT drone.role_id, drone.evicted_at,
|
|
2111
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
2112
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
2113
|
+
FROM drones AS drone WHERE drone.id = ?
|
|
2114
|
+
`).get(droneId);
|
|
2115
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
2116
|
+
return {
|
|
2117
|
+
role_id: requiredText(row, "role_id"),
|
|
2118
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
2119
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
inspectCubeManagementState(cubeId: string) {
|
|
2124
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
2125
|
+
const cube = this.#database.prepare(
|
|
2126
|
+
"SELECT directive, message_taxonomy FROM cubes WHERE id = ?",
|
|
2127
|
+
).get(cubeId);
|
|
2128
|
+
if (cube === undefined) throw new ScopedStoreError();
|
|
2129
|
+
const taxonomy = nullableText(cube, "message_taxonomy");
|
|
2130
|
+
const parsed = taxonomy === null ? null : JSON.parse(taxonomy) as Array<{ class?: unknown }>;
|
|
2131
|
+
return {
|
|
2132
|
+
directive: requiredText(cube, "directive"),
|
|
2133
|
+
taxonomy_marker: typeof parsed?.[0]?.class === "string" ? parsed[0].class : null,
|
|
2134
|
+
role_ids: this.#database.prepare(
|
|
2135
|
+
"SELECT id FROM roles WHERE cube_id = ? ORDER BY id",
|
|
2136
|
+
).all(cubeId).map((row) => requiredText(row, "id")),
|
|
2137
|
+
active_decision_ids: this.#database.prepare(`
|
|
2138
|
+
SELECT id FROM decisions WHERE cube_id = ? AND status = 'active' ORDER BY id
|
|
2139
|
+
`).all(cubeId).map((row) => requiredText(row, "id")),
|
|
2140
|
+
drones: this.#database.prepare(`
|
|
2141
|
+
SELECT drone.id, drone.role_id, drone.evicted_at,
|
|
2142
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
2143
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
2144
|
+
FROM drones AS drone WHERE drone.cube_id = ? ORDER BY drone.id
|
|
2145
|
+
`).all(cubeId).map((row) => ({
|
|
2146
|
+
id: requiredText(row, "id"),
|
|
2147
|
+
role_id: requiredText(row, "role_id"),
|
|
2148
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
2149
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
2150
|
+
})),
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
|
|
1795
2154
|
expireActivityCursor(cubeId: string, cursor: LogCursor): void {
|
|
1796
2155
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
1797
2156
|
assertCanonicalUuid(cursor.id, "Activity cursor id");
|
|
@@ -1812,9 +2171,9 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1812
2171
|
}
|
|
1813
2172
|
|
|
1814
2173
|
class ActivityHub {
|
|
1815
|
-
readonly #listeners = new Map<string, Set<(entry:
|
|
2174
|
+
readonly #listeners = new Map<string, Set<(entry: ActivityStreamRecord) => void>>();
|
|
1816
2175
|
|
|
1817
|
-
subscribe(cubeId: string, listener: (entry:
|
|
2176
|
+
subscribe(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
|
|
1818
2177
|
const listeners = this.#listeners.get(cubeId) ?? new Set();
|
|
1819
2178
|
listeners.add(listener);
|
|
1820
2179
|
this.#listeners.set(cubeId, listeners);
|
|
@@ -1824,7 +2183,7 @@ class ActivityHub {
|
|
|
1824
2183
|
};
|
|
1825
2184
|
}
|
|
1826
2185
|
|
|
1827
|
-
publish(cubeId: string, entry:
|
|
2186
|
+
publish(cubeId: string, entry: ActivityStreamRecord): void {
|
|
1828
2187
|
for (const listener of this.#listeners.get(cubeId) ?? []) {
|
|
1829
2188
|
try {
|
|
1830
2189
|
listener(entry);
|
|
@@ -1835,6 +2194,64 @@ class ActivityHub {
|
|
|
1835
2194
|
}
|
|
1836
2195
|
}
|
|
1837
2196
|
|
|
2197
|
+
class SqliteLivenessStore implements LivenessStore {
|
|
2198
|
+
constructor(
|
|
2199
|
+
readonly database: DatabaseSync,
|
|
2200
|
+
readonly clock: () => Date,
|
|
2201
|
+
readonly hub: ActivityHub,
|
|
2202
|
+
readonly capacityGuard: StorageCapacityGuard,
|
|
2203
|
+
) {}
|
|
2204
|
+
|
|
2205
|
+
scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
|
|
2206
|
+
const now = this.clock();
|
|
2207
|
+
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
2208
|
+
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
2209
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
2210
|
+
const candidates = this.database.prepare(`
|
|
2211
|
+
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
2212
|
+
FROM drones AS drone
|
|
2213
|
+
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
2214
|
+
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
2215
|
+
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
2216
|
+
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
2217
|
+
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
2218
|
+
AND COALESCE(state.attempts, 0) < 3
|
|
2219
|
+
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
2220
|
+
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
2221
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
2222
|
+
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
2223
|
+
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
2224
|
+
const published: ActivityNotificationRecord[] = [];
|
|
2225
|
+
for (const candidate of candidates) {
|
|
2226
|
+
const droneId = requiredText(candidate, "drone_id");
|
|
2227
|
+
const cubeId = requiredText(candidate, "cube_id");
|
|
2228
|
+
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
2229
|
+
this.capacityGuard.assertCanGrow(256);
|
|
2230
|
+
const id = randomUUID();
|
|
2231
|
+
const createdAt = now.toISOString();
|
|
2232
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
2233
|
+
try {
|
|
2234
|
+
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
2235
|
+
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
2236
|
+
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
2237
|
+
`).run(droneId, now.toISOString());
|
|
2238
|
+
this.database.exec("COMMIT");
|
|
2239
|
+
} catch (error) {
|
|
2240
|
+
try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
|
|
2241
|
+
throw error;
|
|
2242
|
+
}
|
|
2243
|
+
const entry: ActivityNotificationRecord = {
|
|
2244
|
+
kind: "heartbeat_ping",
|
|
2245
|
+
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
2246
|
+
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
2247
|
+
};
|
|
2248
|
+
this.hub.publish(cubeId, entry);
|
|
2249
|
+
published.push(entry);
|
|
2250
|
+
}
|
|
2251
|
+
return published;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
1838
2255
|
class SqliteCredentialStore implements CredentialStore {
|
|
1839
2256
|
readonly #database: DatabaseSync;
|
|
1840
2257
|
readonly #clock: () => Date;
|
|
@@ -2159,9 +2576,8 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2159
2576
|
SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
|
|
2160
2577
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
2161
2578
|
session.expires_at,
|
|
2162
|
-
COALESCE(
|
|
2163
|
-
|
|
2164
|
-
) AS revoked_at
|
|
2579
|
+
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
2580
|
+
drone.evicted_at
|
|
2165
2581
|
FROM drone_session_credentials AS credential
|
|
2166
2582
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
2167
2583
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -2173,6 +2589,21 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2173
2589
|
return row === undefined ? null : storedDroneSessionDigest(row);
|
|
2174
2590
|
}
|
|
2175
2591
|
|
|
2592
|
+
findActiveDroneSessionExpiry(sessionId: string): string | null {
|
|
2593
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
2594
|
+
const row = this.#database.prepare(`
|
|
2595
|
+
SELECT session.expires_at
|
|
2596
|
+
FROM drone_sessions AS session
|
|
2597
|
+
JOIN clients AS client ON client.id = session.client_id
|
|
2598
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
2599
|
+
AND drone.client_id = session.client_id
|
|
2600
|
+
AND drone.cube_id = session.cube_id
|
|
2601
|
+
WHERE session.id = ? AND session.revoked_at IS NULL
|
|
2602
|
+
AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
|
|
2603
|
+
`).get(sessionId);
|
|
2604
|
+
return row === undefined ? null : requiredText(row, "expires_at");
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2176
2607
|
rotateClientCredential(input: {
|
|
2177
2608
|
readonly clientId: string;
|
|
2178
2609
|
readonly credentialId: string;
|
|
@@ -2195,6 +2626,16 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2195
2626
|
UPDATE client_credentials SET revoked_at = ?
|
|
2196
2627
|
WHERE client_id = ? AND revoked_at IS NULL
|
|
2197
2628
|
`).run(now, input.clientId);
|
|
2629
|
+
this.#database.prepare(`
|
|
2630
|
+
UPDATE drone_session_credentials SET revoked_at = ?
|
|
2631
|
+
WHERE revoked_at IS NULL AND session_id IN (
|
|
2632
|
+
SELECT id FROM drone_sessions WHERE client_id = ?
|
|
2633
|
+
)
|
|
2634
|
+
`).run(now, input.clientId);
|
|
2635
|
+
this.#database.prepare(`
|
|
2636
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
2637
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
2638
|
+
`).run(now, input.clientId);
|
|
2198
2639
|
this.#database.prepare(`
|
|
2199
2640
|
INSERT INTO client_credentials (
|
|
2200
2641
|
id, client_id, lookup_digest, verifier_digest, created_at
|
|
@@ -2361,6 +2802,7 @@ function cubeRecord(row: CubeRow): CubeRecord {
|
|
|
2361
2802
|
ownerId: row.owner_id,
|
|
2362
2803
|
name: row.name,
|
|
2363
2804
|
directive: row.directive,
|
|
2805
|
+
messageTaxonomy: parseTaxonomy(row.message_taxonomy),
|
|
2364
2806
|
createdAt: row.created_at,
|
|
2365
2807
|
updatedAt: row.updated_at,
|
|
2366
2808
|
};
|
|
@@ -2384,6 +2826,7 @@ function cubeRow(row: Record<string, unknown>): CubeRow {
|
|
|
2384
2826
|
owner_id: requiredText(row, "owner_id"),
|
|
2385
2827
|
name: requiredText(row, "name"),
|
|
2386
2828
|
directive: requiredText(row, "directive"),
|
|
2829
|
+
message_taxonomy: nullableText(row, "message_taxonomy"),
|
|
2387
2830
|
created_at: requiredText(row, "created_at"),
|
|
2388
2831
|
updated_at: requiredText(row, "updated_at"),
|
|
2389
2832
|
};
|
|
@@ -2611,6 +3054,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
|
|
|
2611
3054
|
cubeId: requiredText(row, "cube_id"),
|
|
2612
3055
|
droneId: requiredText(row, "drone_id"),
|
|
2613
3056
|
expiresAt: requiredText(row, "expires_at"),
|
|
3057
|
+
evictedAt: nullableText(row, "evicted_at"),
|
|
2614
3058
|
};
|
|
2615
3059
|
}
|
|
2616
3060
|
|
|
@@ -2696,6 +3140,34 @@ function validateDirective(value: string): void {
|
|
|
2696
3140
|
}
|
|
2697
3141
|
}
|
|
2698
3142
|
|
|
3143
|
+
function activityPreview(message: string): string {
|
|
3144
|
+
return message.replace(/\s+/gu, " ").trim().slice(0, 200);
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
function validateRoleClass(value: unknown): void {
|
|
3148
|
+
if (value !== undefined && value !== "queen" && value !== "worker") {
|
|
3149
|
+
throw new TypeError("Role class must be queen or worker.");
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
function serializeTaxonomy(value: MessageTaxonomy | null): string | null {
|
|
3154
|
+
if (value === null) return null;
|
|
3155
|
+
const serialized = JSON.stringify(value);
|
|
3156
|
+
if (Buffer.byteLength(serialized) > 100_000) {
|
|
3157
|
+
throw new RangeError("Message taxonomy exceeds 100000 bytes.");
|
|
3158
|
+
}
|
|
3159
|
+
return serialized;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
function parseTaxonomy(value: string | null): MessageTaxonomy | null {
|
|
3163
|
+
if (value === null) return null;
|
|
3164
|
+
try {
|
|
3165
|
+
return validateMessageTaxonomy(JSON.parse(value));
|
|
3166
|
+
} catch {
|
|
3167
|
+
throw new Error("Database contains invalid message taxonomy.");
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
|
|
2699
3171
|
function validateDigest(digest: DigestPair): void {
|
|
2700
3172
|
validateLookup(digest.lookup);
|
|
2701
3173
|
if (digest.verifier.length !== 32) throw new Error("Verifier digest must contain 32 bytes.");
|