borgmcp-server 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +269 -85
- 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 +42 -8
- package/dist/store.js +380 -100
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/coordination-api.ts +278 -82
- 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 +432 -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,10 @@ 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
|
+
};
|
|
255
292
|
readonly appendLog: (cubeId: string, input: {
|
|
256
293
|
readonly message: string;
|
|
257
294
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -267,7 +304,7 @@ export interface ScopedStore {
|
|
|
267
304
|
readonly listDecisions: (cubeId: string) => DecisionRecord[];
|
|
268
305
|
readonly subscribeActivity: (
|
|
269
306
|
cubeId: string,
|
|
270
|
-
listener: (entry:
|
|
307
|
+
listener: (entry: ActivityStreamRecord) => void,
|
|
271
308
|
) => (() => void);
|
|
272
309
|
readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
|
|
273
310
|
}
|
|
@@ -281,6 +318,7 @@ export interface CreateRoleInput {
|
|
|
281
318
|
readonly isHumanSeat?: boolean;
|
|
282
319
|
readonly canBroadcast?: boolean;
|
|
283
320
|
readonly receivesAllDirect?: boolean;
|
|
321
|
+
readonly roleClass?: "queen" | "worker";
|
|
284
322
|
}
|
|
285
323
|
|
|
286
324
|
export interface UpdateRoleInput {
|
|
@@ -292,6 +330,12 @@ export interface UpdateRoleInput {
|
|
|
292
330
|
readonly isHumanSeat?: boolean;
|
|
293
331
|
readonly canBroadcast?: boolean;
|
|
294
332
|
readonly receivesAllDirect?: boolean;
|
|
333
|
+
readonly roleClass?: "queen" | "worker";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface UpdateCubeInput {
|
|
337
|
+
readonly directive?: string;
|
|
338
|
+
readonly messageTaxonomy?: MessageTaxonomy | null;
|
|
295
339
|
}
|
|
296
340
|
|
|
297
341
|
export interface CreateCubeInput {
|
|
@@ -310,7 +354,6 @@ export interface CreateCubeRecord {
|
|
|
310
354
|
export interface SeatAttachInput {
|
|
311
355
|
readonly cubeId: string;
|
|
312
356
|
readonly roleId: string;
|
|
313
|
-
readonly retryKey: string;
|
|
314
357
|
readonly priorDroneId?: string;
|
|
315
358
|
readonly droneId: string;
|
|
316
359
|
readonly sessionId: string;
|
|
@@ -336,10 +379,7 @@ export interface SeatAttachRecord {
|
|
|
336
379
|
};
|
|
337
380
|
readonly sessionId: string;
|
|
338
381
|
readonly expiresAt: string;
|
|
339
|
-
readonly
|
|
340
|
-
readonly posture: "observer" | "participant";
|
|
341
|
-
readonly reattached: boolean;
|
|
342
|
-
readonly revokedSessionIds: readonly string[];
|
|
382
|
+
readonly result: "created" | "reused";
|
|
343
383
|
}
|
|
344
384
|
|
|
345
385
|
export interface MaintenanceStore {
|
|
@@ -449,12 +489,12 @@ export class CursorExpiredError extends Error {
|
|
|
449
489
|
}
|
|
450
490
|
}
|
|
451
491
|
|
|
452
|
-
export class
|
|
453
|
-
readonly code = "
|
|
492
|
+
export class AttachSessionRejectedError extends Error {
|
|
493
|
+
readonly code = "SESSION_REJECTED";
|
|
454
494
|
|
|
455
495
|
constructor() {
|
|
456
|
-
super("The
|
|
457
|
-
this.name = "
|
|
496
|
+
super("The saved session is not accepted for this seat.");
|
|
497
|
+
this.name = "AttachSessionRejectedError";
|
|
458
498
|
}
|
|
459
499
|
}
|
|
460
500
|
|
|
@@ -482,6 +522,7 @@ interface CubeRow {
|
|
|
482
522
|
readonly owner_id: string;
|
|
483
523
|
readonly name: string;
|
|
484
524
|
readonly directive: string;
|
|
525
|
+
readonly message_taxonomy: string | null;
|
|
485
526
|
readonly created_at: string;
|
|
486
527
|
readonly updated_at: string;
|
|
487
528
|
}
|
|
@@ -573,9 +614,10 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
573
614
|
capacityProbe,
|
|
574
615
|
requiredInteger(pageRow, "page_size"),
|
|
575
616
|
);
|
|
617
|
+
const activityHub = new ActivityHub();
|
|
576
618
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
619
|
+
const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
|
|
577
620
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
578
|
-
const activityHub = new ActivityHub();
|
|
579
621
|
return Object.freeze({
|
|
580
622
|
forPrincipal: (principal: Principal) => {
|
|
581
623
|
assertServerDerivedPrincipal(principal);
|
|
@@ -592,6 +634,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
592
634
|
},
|
|
593
635
|
maintenance,
|
|
594
636
|
credentials,
|
|
637
|
+
liveness,
|
|
595
638
|
diagnostics: () => diagnostics(database),
|
|
596
639
|
close: () => database.close(),
|
|
597
640
|
});
|
|
@@ -712,7 +755,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
712
755
|
listCubes(): CubeRecord[] {
|
|
713
756
|
const scope = this.#scope("read");
|
|
714
757
|
const rows = this.#database.prepare(`
|
|
715
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
758
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
759
|
+
c.created_at, c.updated_at
|
|
716
760
|
FROM cubes AS c
|
|
717
761
|
WHERE ${scope.sql}
|
|
718
762
|
ORDER BY c.id
|
|
@@ -724,7 +768,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
724
768
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
725
769
|
const scope = this.#scope("read");
|
|
726
770
|
const row = this.#database.prepare(`
|
|
727
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
771
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
772
|
+
c.created_at, c.updated_at
|
|
728
773
|
FROM cubes AS c
|
|
729
774
|
WHERE c.id = ? AND ${scope.sql}
|
|
730
775
|
`).get(cubeId, ...scope.parameters);
|
|
@@ -732,17 +777,42 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
732
777
|
}
|
|
733
778
|
|
|
734
779
|
updateDirective(cubeId: string, directive: string): void {
|
|
780
|
+
this.updateCube(cubeId, { directive });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
updateCube(cubeId: string, input: UpdateCubeInput): CubeRecord {
|
|
735
784
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
736
|
-
|
|
785
|
+
if (input.directive === undefined && input.messageTaxonomy === undefined) {
|
|
786
|
+
throw new TypeError("At least one cube field is required.");
|
|
787
|
+
}
|
|
788
|
+
if (input.directive !== undefined) validateDirective(input.directive);
|
|
789
|
+
const taxonomy = input.messageTaxonomy === undefined
|
|
790
|
+
? undefined
|
|
791
|
+
: validateMessageTaxonomy(input.messageTaxonomy);
|
|
792
|
+
const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
|
|
737
793
|
const scope = this.#scope("manage");
|
|
738
794
|
this.#requireCube(cubeId, "manage");
|
|
739
|
-
this.#capacityGuard.assertCanGrow(
|
|
795
|
+
this.#capacityGuard.assertCanGrow(
|
|
796
|
+
Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""),
|
|
797
|
+
);
|
|
740
798
|
const result = this.#database.prepare(`
|
|
741
799
|
UPDATE cubes AS c
|
|
742
|
-
SET directive = ?,
|
|
800
|
+
SET directive = COALESCE(?, directive),
|
|
801
|
+
message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
|
|
802
|
+
updated_at = ?
|
|
743
803
|
WHERE c.id = ? AND ${scope.sql}
|
|
744
|
-
`).run(
|
|
804
|
+
`).run(
|
|
805
|
+
input.directive ?? null,
|
|
806
|
+
serializedTaxonomy === undefined ? 0 : 1,
|
|
807
|
+
serializedTaxonomy ?? null,
|
|
808
|
+
this.#now(),
|
|
809
|
+
cubeId,
|
|
810
|
+
...scope.parameters,
|
|
811
|
+
);
|
|
745
812
|
if (result.changes !== 1) throw new ScopedStoreError();
|
|
813
|
+
const cube = this.getCube(cubeId);
|
|
814
|
+
if (cube === null) throw new ScopedStoreError();
|
|
815
|
+
return cube;
|
|
746
816
|
}
|
|
747
817
|
|
|
748
818
|
appendActivity(cubeId: string, message: string): ActivityRecord {
|
|
@@ -803,6 +873,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
803
873
|
]) {
|
|
804
874
|
if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
|
|
805
875
|
}
|
|
876
|
+
validateRoleClass(input.roleClass);
|
|
806
877
|
const isDefault = input.isDefault ?? false;
|
|
807
878
|
const isMandatory = input.isMandatory ?? false;
|
|
808
879
|
const isHumanSeat = input.isHumanSeat ?? false;
|
|
@@ -832,11 +903,12 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
832
903
|
id, cube_id, name, short_description, detailed_description,
|
|
833
904
|
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
834
905
|
receives_all_direct, role_class, created_at
|
|
835
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
906
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
836
907
|
`).run(
|
|
837
908
|
id, cubeId, input.name, shortDescription, detailedDescription,
|
|
838
909
|
booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat),
|
|
839
|
-
booleanInteger(canBroadcast), booleanInteger(receivesAllDirect),
|
|
910
|
+
booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker",
|
|
911
|
+
this.#now(),
|
|
840
912
|
);
|
|
841
913
|
this.#mutationHook?.("role.insert");
|
|
842
914
|
const row = this.#database.prepare(`
|
|
@@ -875,6 +947,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
875
947
|
]) {
|
|
876
948
|
if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
|
|
877
949
|
}
|
|
950
|
+
validateRoleClass(input.roleClass);
|
|
878
951
|
this.#requireCube(cubeId, "manage");
|
|
879
952
|
this.#capacityGuard.assertCanGrow(
|
|
880
953
|
Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
|
|
@@ -911,7 +984,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
911
984
|
this.#database.prepare(`
|
|
912
985
|
UPDATE roles SET
|
|
913
986
|
name = ?, short_description = ?, detailed_description = ?, is_default = ?,
|
|
914
|
-
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct =
|
|
987
|
+
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
|
|
988
|
+
role_class = ?
|
|
915
989
|
WHERE id = ? AND cube_id = ?
|
|
916
990
|
`).run(
|
|
917
991
|
input.name ?? existing.name,
|
|
@@ -922,6 +996,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
922
996
|
booleanInteger(input.isHumanSeat ?? existing.is_human_seat),
|
|
923
997
|
booleanInteger(input.canBroadcast ?? existing.can_broadcast),
|
|
924
998
|
booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct),
|
|
999
|
+
input.roleClass ?? existing.role_class,
|
|
925
1000
|
roleId,
|
|
926
1001
|
cubeId,
|
|
927
1002
|
);
|
|
@@ -1006,6 +1081,50 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1006
1081
|
return rows.map(droneRecord);
|
|
1007
1082
|
}
|
|
1008
1083
|
|
|
1084
|
+
listDronesSince(cubeId: string, since: string): { drones: DroneRecord[]; since: string } {
|
|
1085
|
+
this.#requireCube(cubeId, "read");
|
|
1086
|
+
let createdAt: string;
|
|
1087
|
+
let anchorId: string | null = null;
|
|
1088
|
+
if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
|
|
1089
|
+
const anchor = this.#database.prepare(
|
|
1090
|
+
"SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?",
|
|
1091
|
+
).get(since, cubeId);
|
|
1092
|
+
if (anchor === undefined) throw new ScopedStoreError();
|
|
1093
|
+
anchorId = requiredText(anchor, "id");
|
|
1094
|
+
createdAt = requiredText(anchor, "created_at");
|
|
1095
|
+
} else {
|
|
1096
|
+
validateTimestamp(since);
|
|
1097
|
+
createdAt = since;
|
|
1098
|
+
}
|
|
1099
|
+
const rows = this.#database.prepare(`
|
|
1100
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
1101
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
1102
|
+
drone.hostname, drone.created_at,
|
|
1103
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1104
|
+
THEN 'participant' ELSE 'observer' END AS posture,
|
|
1105
|
+
(SELECT MAX(post.created_at) FROM activity_log AS post
|
|
1106
|
+
WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
|
|
1107
|
+
EXISTS (SELECT 1 FROM activity_log AS response
|
|
1108
|
+
WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
|
|
1109
|
+
AND (response.created_at > ? OR
|
|
1110
|
+
(? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
|
|
1111
|
+
FROM drones AS drone
|
|
1112
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
1113
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
1114
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1115
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1116
|
+
ORDER BY drone.label, drone.id
|
|
1117
|
+
`).all(createdAt, anchorId, createdAt, anchorId, cubeId);
|
|
1118
|
+
return {
|
|
1119
|
+
since: createdAt,
|
|
1120
|
+
drones: rows.map((row) => ({
|
|
1121
|
+
...droneRecord(row),
|
|
1122
|
+
last_log_post_at: nullableText(row, "last_log_post_at"),
|
|
1123
|
+
seen_since: requiredInteger(row, "seen_since") === 1,
|
|
1124
|
+
})),
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1009
1128
|
appendLog(cubeId: string, input: {
|
|
1010
1129
|
readonly message: string;
|
|
1011
1130
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -1052,6 +1171,12 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1052
1171
|
createdAt, visibility, cubeId, ...scope.parameters,
|
|
1053
1172
|
);
|
|
1054
1173
|
if (inserted.changes !== 1) throw new ScopedStoreError();
|
|
1174
|
+
if (droneId !== null) {
|
|
1175
|
+
this.#database.prepare(`
|
|
1176
|
+
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1177
|
+
`).run(createdAt, droneId, cubeId);
|
|
1178
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1179
|
+
}
|
|
1055
1180
|
if (recipients.length > 0) {
|
|
1056
1181
|
const valid = this.#database.prepare(`
|
|
1057
1182
|
SELECT COUNT(*) AS count
|
|
@@ -1125,19 +1250,56 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1125
1250
|
this.#requireCube(cubeId, "write");
|
|
1126
1251
|
assertCanonicalUuid(entryId, "Activity entry id");
|
|
1127
1252
|
if (kind !== "ack" && kind !== "claim") throw new Error("Unknown acknowledgement kind.");
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1130
|
-
).get(entryId, cubeId);
|
|
1131
|
-
if (
|
|
1253
|
+
const original = this.#database.prepare(`
|
|
1254
|
+
SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
|
|
1255
|
+
`).get(entryId, cubeId);
|
|
1256
|
+
if (original === undefined) throw new ScopedStoreError();
|
|
1132
1257
|
this.#capacityGuard.assertCanGrow(512);
|
|
1133
1258
|
const claimant = this.#principal.kind === "drone-session"
|
|
1134
1259
|
? this.#principal.droneId
|
|
1135
1260
|
: this.#principal.id;
|
|
1136
|
-
this.#
|
|
1261
|
+
const ackAt = this.#now();
|
|
1262
|
+
const inserted = this.#database.prepare(`
|
|
1137
1263
|
INSERT OR IGNORE INTO activity_acks (
|
|
1138
1264
|
entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
|
|
1139
1265
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1140
|
-
`).run(entryId, this.#principal.kind, this.#principal.id, kind,
|
|
1266
|
+
`).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
|
|
1267
|
+
if (inserted.changes !== 1 || this.#principal.kind !== "drone-session") return;
|
|
1268
|
+
const claimantDroneId = this.#principal.droneId;
|
|
1269
|
+
|
|
1270
|
+
const actor = this.#database.prepare(`
|
|
1271
|
+
SELECT drone.label, role.name AS role_name
|
|
1272
|
+
FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
|
|
1273
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1274
|
+
`).get(claimantDroneId, cubeId);
|
|
1275
|
+
const preview = activityPreview(requiredText(original, "message"));
|
|
1276
|
+
const originalAuthor = nullableText(original, "drone_id");
|
|
1277
|
+
const recipients = kind === "ack"
|
|
1278
|
+
? originalAuthor === null ? [] : [originalAuthor]
|
|
1279
|
+
: this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
|
|
1280
|
+
.filter((droneId) => droneId !== claimantDroneId);
|
|
1281
|
+
if (recipients.length === 0) return;
|
|
1282
|
+
const roleName = actor === undefined ? null : requiredText(actor, "role_name");
|
|
1283
|
+
const notification: ActivityNotificationRecord = {
|
|
1284
|
+
kind,
|
|
1285
|
+
id: randomUUID(),
|
|
1286
|
+
cube_id: cubeId,
|
|
1287
|
+
drone_id: claimantDroneId,
|
|
1288
|
+
drone_label: actor === undefined ? null : requiredText(actor, "label"),
|
|
1289
|
+
role_name: roleName,
|
|
1290
|
+
message: `[${kind.toUpperCase()}] ${preview}`,
|
|
1291
|
+
created_at: ackAt,
|
|
1292
|
+
visibility: "direct",
|
|
1293
|
+
recipient_drone_ids: recipients,
|
|
1294
|
+
log_entry_id: entryId,
|
|
1295
|
+
ack_kind: kind,
|
|
1296
|
+
ack_at: ackAt,
|
|
1297
|
+
entry_preview: preview,
|
|
1298
|
+
...(kind === "ack"
|
|
1299
|
+
? { author_drone_id: recipients[0]! }
|
|
1300
|
+
: { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
|
|
1301
|
+
};
|
|
1302
|
+
this.#activityHub.publish(cubeId, notification);
|
|
1141
1303
|
}
|
|
1142
1304
|
|
|
1143
1305
|
recordDecision(cubeId: string, input: {
|
|
@@ -1194,7 +1356,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1194
1356
|
if (this.#principal.kind !== "client") throw new ScopedStoreError();
|
|
1195
1357
|
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
1196
1358
|
assertCanonicalUuid(input.roleId, "Role id");
|
|
1197
|
-
assertCanonicalUuid(input.retryKey, "Retry key");
|
|
1198
1359
|
if (input.priorDroneId !== undefined) assertCanonicalUuid(input.priorDroneId, "Prior drone id");
|
|
1199
1360
|
assertCanonicalUuid(input.droneId, "Drone id");
|
|
1200
1361
|
assertCanonicalUuid(input.sessionId, "Drone session id");
|
|
@@ -1219,95 +1380,96 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1219
1380
|
WHERE c.id = ? AND role.id = ? AND ${scope.sql}
|
|
1220
1381
|
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
1221
1382
|
if (roleRow === undefined) throw new ScopedStoreError();
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
WHERE
|
|
1232
|
-
`).get(
|
|
1383
|
+
const bound = this.#database.prepare(`
|
|
1384
|
+
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
1385
|
+
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1386
|
+
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1387
|
+
drone.role_id, drone.label, drone.evicted_at
|
|
1388
|
+
FROM drone_session_credentials AS credential
|
|
1389
|
+
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1390
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
1391
|
+
AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
|
|
1392
|
+
WHERE credential.lookup_digest = ?
|
|
1393
|
+
`).get(input.credentialDigest.lookup);
|
|
1394
|
+
if (bound !== undefined) {
|
|
1395
|
+
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
1396
|
+
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
1397
|
+
optionalText(bound, "credential_revoked_at") !== null ||
|
|
1398
|
+
optionalText(bound, "session_revoked_at") !== null ||
|
|
1399
|
+
optionalText(bound, "evicted_at") !== null ||
|
|
1400
|
+
requiredText(bound, "expires_at") <= now ||
|
|
1401
|
+
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
1402
|
+
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
1403
|
+
requiredText(bound, "role_id") !== input.roleId ||
|
|
1404
|
+
(input.priorDroneId !== undefined &&
|
|
1405
|
+
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
1406
|
+
throw new AttachSessionRejectedError();
|
|
1407
|
+
}
|
|
1408
|
+
const droneId = requiredText(bound, "drone_id");
|
|
1409
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1410
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1411
|
+
const attachedRoleRow = this.#database.prepare(`
|
|
1412
|
+
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
1413
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
1414
|
+
`).get(requiredText(bound, "role_id"), input.cubeId);
|
|
1415
|
+
if (attachedRoleRow === undefined) throw new Error("Attached drone role is unavailable.");
|
|
1416
|
+
const roleClass = requiredText(attachedRoleRow, "role_class");
|
|
1417
|
+
if (roleClass !== "queen" && roleClass !== "worker") {
|
|
1418
|
+
throw new Error("Database contains invalid role class.");
|
|
1419
|
+
}
|
|
1420
|
+
this.#database.exec("COMMIT");
|
|
1421
|
+
return {
|
|
1422
|
+
cube: {
|
|
1423
|
+
id: requiredText(roleRow, "cube_id"),
|
|
1424
|
+
name: requiredText(roleRow, "cube_name"),
|
|
1425
|
+
},
|
|
1426
|
+
role: {
|
|
1427
|
+
id: requiredText(attachedRoleRow, "role_id"),
|
|
1428
|
+
name: requiredText(attachedRoleRow, "role_name"),
|
|
1429
|
+
role_class: roleClass,
|
|
1430
|
+
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
1431
|
+
},
|
|
1432
|
+
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
1433
|
+
sessionId: requiredText(bound, "session_id"),
|
|
1434
|
+
expiresAt: requiredText(bound, "expires_at"),
|
|
1435
|
+
result: "reused",
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
1440
|
+
SELECT id, role_id, label FROM drones
|
|
1441
|
+
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1442
|
+
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
1233
1443
|
let droneId: string;
|
|
1234
1444
|
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();
|
|
1445
|
+
if (priorSeat !== undefined) {
|
|
1446
|
+
const priorSeatId = requiredText(priorSeat, "id");
|
|
1447
|
+
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
1448
|
+
throw new AttachSessionRejectedError();
|
|
1243
1449
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1450
|
+
const occupied = this.#database.prepare(`
|
|
1451
|
+
SELECT 1 FROM drone_sessions AS session
|
|
1452
|
+
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
1453
|
+
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
1454
|
+
AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
|
|
1455
|
+
AND session.expires_at > ?
|
|
1456
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId, now);
|
|
1457
|
+
if (occupied !== undefined) throw new AttachSessionRejectedError();
|
|
1458
|
+
droneId = priorSeatId;
|
|
1459
|
+
droneLabel = requiredText(priorSeat, "label");
|
|
1460
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1251
1461
|
} 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
|
-
}
|
|
1462
|
+
droneId = input.droneId;
|
|
1463
|
+
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
1286
1464
|
this.#database.prepare(`
|
|
1287
|
-
INSERT INTO
|
|
1288
|
-
|
|
1289
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1465
|
+
INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
|
|
1466
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1290
1467
|
`).run(
|
|
1291
|
-
|
|
1292
|
-
droneId, input.priorDroneId ?? null, now,
|
|
1468
|
+
droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
|
|
1293
1469
|
);
|
|
1294
1470
|
}
|
|
1471
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1295
1472
|
|
|
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
1473
|
this.#database.prepare(`
|
|
1312
1474
|
INSERT INTO drone_sessions (
|
|
1313
1475
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -1355,10 +1517,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1355
1517
|
drone: { id: droneId, label: droneLabel },
|
|
1356
1518
|
sessionId: input.sessionId,
|
|
1357
1519
|
expiresAt: input.expiresAt,
|
|
1358
|
-
|
|
1359
|
-
posture,
|
|
1360
|
-
reattached,
|
|
1361
|
-
revokedSessionIds: oldSessions,
|
|
1520
|
+
result: "created",
|
|
1362
1521
|
};
|
|
1363
1522
|
} catch (error) {
|
|
1364
1523
|
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
@@ -1366,13 +1525,44 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1366
1525
|
}
|
|
1367
1526
|
}
|
|
1368
1527
|
|
|
1369
|
-
subscribeActivity(cubeId: string, listener: (entry:
|
|
1528
|
+
subscribeActivity(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
|
|
1370
1529
|
this.#requireCube(cubeId, "read");
|
|
1371
1530
|
return this.#activityHub.subscribe(cubeId, (entry) => {
|
|
1531
|
+
if ("kind" in entry) {
|
|
1532
|
+
if (this.#principal.kind === "drone-session" &&
|
|
1533
|
+
entry.recipient_drone_ids.includes(this.#principal.droneId)) listener(entry);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1372
1536
|
if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId)) listener(entry);
|
|
1373
1537
|
});
|
|
1374
1538
|
}
|
|
1375
1539
|
|
|
1540
|
+
#claimAudience(cubeId: string, entryId: string, visibility: string): string[] {
|
|
1541
|
+
if (visibility === "direct") {
|
|
1542
|
+
return this.#database.prepare(`
|
|
1543
|
+
SELECT recipient.drone_id
|
|
1544
|
+
FROM activity_log_recipients AS recipient
|
|
1545
|
+
JOIN drones AS drone ON drone.id = recipient.drone_id
|
|
1546
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1547
|
+
JOIN client_cube_grants AS grant_row
|
|
1548
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1549
|
+
WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1550
|
+
AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1551
|
+
ORDER BY recipient.drone_id
|
|
1552
|
+
`).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1553
|
+
}
|
|
1554
|
+
return this.#database.prepare(`
|
|
1555
|
+
SELECT drone.id AS drone_id
|
|
1556
|
+
FROM drones AS drone
|
|
1557
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1558
|
+
JOIN client_cube_grants AS grant_row
|
|
1559
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1560
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
|
|
1561
|
+
AND grant_row.access IN ('write', 'manage')
|
|
1562
|
+
ORDER BY drone.id
|
|
1563
|
+
`).all(cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1376
1566
|
#allowsDirectedWork(cubeId: string): boolean {
|
|
1377
1567
|
const scope = this.#scope("write");
|
|
1378
1568
|
return this.#database.prepare(`
|
|
@@ -1386,7 +1576,15 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1386
1576
|
const row = this.#database.prepare(`
|
|
1387
1577
|
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
1388
1578
|
`).get(cubeId, ...scope.parameters);
|
|
1389
|
-
if (row
|
|
1579
|
+
if (row !== undefined) return;
|
|
1580
|
+
if (access === "manage") {
|
|
1581
|
+
const visibleScope = this.#scope("read");
|
|
1582
|
+
const visible = this.#database.prepare(`
|
|
1583
|
+
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
|
|
1584
|
+
`).get(cubeId, ...visibleScope.parameters);
|
|
1585
|
+
if (visible !== undefined) throw new AccessDeniedError();
|
|
1586
|
+
}
|
|
1587
|
+
throw new ScopedStoreError();
|
|
1390
1588
|
}
|
|
1391
1589
|
|
|
1392
1590
|
#nextActivityTimestamp(cubeId: string): string {
|
|
@@ -1644,7 +1842,7 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1644
1842
|
for (const table of [
|
|
1645
1843
|
"cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
|
|
1646
1844
|
"decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
|
|
1647
|
-
"
|
|
1845
|
+
"drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
|
|
1648
1846
|
"enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
|
|
1649
1847
|
"enrollment_invitations",
|
|
1650
1848
|
]) this.#database.exec(`DELETE FROM ${table}`);
|
|
@@ -1812,9 +2010,9 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1812
2010
|
}
|
|
1813
2011
|
|
|
1814
2012
|
class ActivityHub {
|
|
1815
|
-
readonly #listeners = new Map<string, Set<(entry:
|
|
2013
|
+
readonly #listeners = new Map<string, Set<(entry: ActivityStreamRecord) => void>>();
|
|
1816
2014
|
|
|
1817
|
-
subscribe(cubeId: string, listener: (entry:
|
|
2015
|
+
subscribe(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
|
|
1818
2016
|
const listeners = this.#listeners.get(cubeId) ?? new Set();
|
|
1819
2017
|
listeners.add(listener);
|
|
1820
2018
|
this.#listeners.set(cubeId, listeners);
|
|
@@ -1824,7 +2022,7 @@ class ActivityHub {
|
|
|
1824
2022
|
};
|
|
1825
2023
|
}
|
|
1826
2024
|
|
|
1827
|
-
publish(cubeId: string, entry:
|
|
2025
|
+
publish(cubeId: string, entry: ActivityStreamRecord): void {
|
|
1828
2026
|
for (const listener of this.#listeners.get(cubeId) ?? []) {
|
|
1829
2027
|
try {
|
|
1830
2028
|
listener(entry);
|
|
@@ -1835,6 +2033,64 @@ class ActivityHub {
|
|
|
1835
2033
|
}
|
|
1836
2034
|
}
|
|
1837
2035
|
|
|
2036
|
+
class SqliteLivenessStore implements LivenessStore {
|
|
2037
|
+
constructor(
|
|
2038
|
+
readonly database: DatabaseSync,
|
|
2039
|
+
readonly clock: () => Date,
|
|
2040
|
+
readonly hub: ActivityHub,
|
|
2041
|
+
readonly capacityGuard: StorageCapacityGuard,
|
|
2042
|
+
) {}
|
|
2043
|
+
|
|
2044
|
+
scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
|
|
2045
|
+
const now = this.clock();
|
|
2046
|
+
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
2047
|
+
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
2048
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
2049
|
+
const candidates = this.database.prepare(`
|
|
2050
|
+
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
2051
|
+
FROM drones AS drone
|
|
2052
|
+
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
2053
|
+
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
2054
|
+
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
2055
|
+
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
2056
|
+
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
2057
|
+
AND COALESCE(state.attempts, 0) < 3
|
|
2058
|
+
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
2059
|
+
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
2060
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
2061
|
+
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
2062
|
+
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
2063
|
+
const published: ActivityNotificationRecord[] = [];
|
|
2064
|
+
for (const candidate of candidates) {
|
|
2065
|
+
const droneId = requiredText(candidate, "drone_id");
|
|
2066
|
+
const cubeId = requiredText(candidate, "cube_id");
|
|
2067
|
+
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
2068
|
+
this.capacityGuard.assertCanGrow(256);
|
|
2069
|
+
const id = randomUUID();
|
|
2070
|
+
const createdAt = now.toISOString();
|
|
2071
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
2072
|
+
try {
|
|
2073
|
+
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
2074
|
+
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
2075
|
+
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
2076
|
+
`).run(droneId, now.toISOString());
|
|
2077
|
+
this.database.exec("COMMIT");
|
|
2078
|
+
} catch (error) {
|
|
2079
|
+
try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
|
|
2080
|
+
throw error;
|
|
2081
|
+
}
|
|
2082
|
+
const entry: ActivityNotificationRecord = {
|
|
2083
|
+
kind: "heartbeat_ping",
|
|
2084
|
+
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
2085
|
+
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
2086
|
+
};
|
|
2087
|
+
this.hub.publish(cubeId, entry);
|
|
2088
|
+
published.push(entry);
|
|
2089
|
+
}
|
|
2090
|
+
return published;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
1838
2094
|
class SqliteCredentialStore implements CredentialStore {
|
|
1839
2095
|
readonly #database: DatabaseSync;
|
|
1840
2096
|
readonly #clock: () => Date;
|
|
@@ -2159,9 +2415,8 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2159
2415
|
SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
|
|
2160
2416
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
2161
2417
|
session.expires_at,
|
|
2162
|
-
COALESCE(
|
|
2163
|
-
|
|
2164
|
-
) AS revoked_at
|
|
2418
|
+
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
2419
|
+
drone.evicted_at
|
|
2165
2420
|
FROM drone_session_credentials AS credential
|
|
2166
2421
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
2167
2422
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -2173,6 +2428,21 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2173
2428
|
return row === undefined ? null : storedDroneSessionDigest(row);
|
|
2174
2429
|
}
|
|
2175
2430
|
|
|
2431
|
+
findActiveDroneSessionExpiry(sessionId: string): string | null {
|
|
2432
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
2433
|
+
const row = this.#database.prepare(`
|
|
2434
|
+
SELECT session.expires_at
|
|
2435
|
+
FROM drone_sessions AS session
|
|
2436
|
+
JOIN clients AS client ON client.id = session.client_id
|
|
2437
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
2438
|
+
AND drone.client_id = session.client_id
|
|
2439
|
+
AND drone.cube_id = session.cube_id
|
|
2440
|
+
WHERE session.id = ? AND session.revoked_at IS NULL
|
|
2441
|
+
AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
|
|
2442
|
+
`).get(sessionId);
|
|
2443
|
+
return row === undefined ? null : requiredText(row, "expires_at");
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2176
2446
|
rotateClientCredential(input: {
|
|
2177
2447
|
readonly clientId: string;
|
|
2178
2448
|
readonly credentialId: string;
|
|
@@ -2195,6 +2465,16 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2195
2465
|
UPDATE client_credentials SET revoked_at = ?
|
|
2196
2466
|
WHERE client_id = ? AND revoked_at IS NULL
|
|
2197
2467
|
`).run(now, input.clientId);
|
|
2468
|
+
this.#database.prepare(`
|
|
2469
|
+
UPDATE drone_session_credentials SET revoked_at = ?
|
|
2470
|
+
WHERE revoked_at IS NULL AND session_id IN (
|
|
2471
|
+
SELECT id FROM drone_sessions WHERE client_id = ?
|
|
2472
|
+
)
|
|
2473
|
+
`).run(now, input.clientId);
|
|
2474
|
+
this.#database.prepare(`
|
|
2475
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
2476
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
2477
|
+
`).run(now, input.clientId);
|
|
2198
2478
|
this.#database.prepare(`
|
|
2199
2479
|
INSERT INTO client_credentials (
|
|
2200
2480
|
id, client_id, lookup_digest, verifier_digest, created_at
|
|
@@ -2361,6 +2641,7 @@ function cubeRecord(row: CubeRow): CubeRecord {
|
|
|
2361
2641
|
ownerId: row.owner_id,
|
|
2362
2642
|
name: row.name,
|
|
2363
2643
|
directive: row.directive,
|
|
2644
|
+
messageTaxonomy: parseTaxonomy(row.message_taxonomy),
|
|
2364
2645
|
createdAt: row.created_at,
|
|
2365
2646
|
updatedAt: row.updated_at,
|
|
2366
2647
|
};
|
|
@@ -2384,6 +2665,7 @@ function cubeRow(row: Record<string, unknown>): CubeRow {
|
|
|
2384
2665
|
owner_id: requiredText(row, "owner_id"),
|
|
2385
2666
|
name: requiredText(row, "name"),
|
|
2386
2667
|
directive: requiredText(row, "directive"),
|
|
2668
|
+
message_taxonomy: nullableText(row, "message_taxonomy"),
|
|
2387
2669
|
created_at: requiredText(row, "created_at"),
|
|
2388
2670
|
updated_at: requiredText(row, "updated_at"),
|
|
2389
2671
|
};
|
|
@@ -2611,6 +2893,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
|
|
|
2611
2893
|
cubeId: requiredText(row, "cube_id"),
|
|
2612
2894
|
droneId: requiredText(row, "drone_id"),
|
|
2613
2895
|
expiresAt: requiredText(row, "expires_at"),
|
|
2896
|
+
evictedAt: nullableText(row, "evicted_at"),
|
|
2614
2897
|
};
|
|
2615
2898
|
}
|
|
2616
2899
|
|
|
@@ -2696,6 +2979,34 @@ function validateDirective(value: string): void {
|
|
|
2696
2979
|
}
|
|
2697
2980
|
}
|
|
2698
2981
|
|
|
2982
|
+
function activityPreview(message: string): string {
|
|
2983
|
+
return message.replace(/\s+/gu, " ").trim().slice(0, 200);
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
function validateRoleClass(value: unknown): void {
|
|
2987
|
+
if (value !== undefined && value !== "queen" && value !== "worker") {
|
|
2988
|
+
throw new TypeError("Role class must be queen or worker.");
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
function serializeTaxonomy(value: MessageTaxonomy | null): string | null {
|
|
2993
|
+
if (value === null) return null;
|
|
2994
|
+
const serialized = JSON.stringify(value);
|
|
2995
|
+
if (Buffer.byteLength(serialized) > 100_000) {
|
|
2996
|
+
throw new RangeError("Message taxonomy exceeds 100000 bytes.");
|
|
2997
|
+
}
|
|
2998
|
+
return serialized;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function parseTaxonomy(value: string | null): MessageTaxonomy | null {
|
|
3002
|
+
if (value === null) return null;
|
|
3003
|
+
try {
|
|
3004
|
+
return validateMessageTaxonomy(JSON.parse(value));
|
|
3005
|
+
} catch {
|
|
3006
|
+
throw new Error("Database contains invalid message taxonomy.");
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
|
|
2699
3010
|
function validateDigest(digest: DigestPair): void {
|
|
2700
3011
|
validateLookup(digest.lookup);
|
|
2701
3012
|
if (digest.verifier.length !== 32) throw new Error("Verifier digest must contain 32 bytes.");
|