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/dist/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
@@ -7,6 +7,7 @@ import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
|
|
|
7
7
|
import { operatorErrors } from "./operator-error.js";
|
|
8
8
|
import { assertCanonicalUuid, assertServerDerivedPrincipal, } from "./principal.js";
|
|
9
9
|
import { patchRoleSectionText } from "./role-section.js";
|
|
10
|
+
import { validateMessageTaxonomy } from "./message-taxonomy.js";
|
|
10
11
|
export const DEFAULT_CUBE_LIMITS = Object.freeze({
|
|
11
12
|
maxCubesPerClient: 100,
|
|
12
13
|
maxCubesTotal: 1_000,
|
|
@@ -53,6 +54,13 @@ export class RoleSectionConflictError extends Error {
|
|
|
53
54
|
this.name = "RoleSectionConflictError";
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
export class RoleInUseError extends Error {
|
|
58
|
+
code = "ROLE_IN_USE";
|
|
59
|
+
constructor() {
|
|
60
|
+
super("The human-seat role is already occupied.");
|
|
61
|
+
this.name = "RoleInUseError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
56
64
|
export class CursorExpiredError extends Error {
|
|
57
65
|
code = "CURSOR_EXPIRED";
|
|
58
66
|
constructor() {
|
|
@@ -60,11 +68,11 @@ export class CursorExpiredError extends Error {
|
|
|
60
68
|
this.name = "CursorExpiredError";
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
|
-
export class
|
|
64
|
-
code = "
|
|
71
|
+
export class AttachSessionRejectedError extends Error {
|
|
72
|
+
code = "SESSION_REJECTED";
|
|
65
73
|
constructor() {
|
|
66
|
-
super("The
|
|
67
|
-
this.name = "
|
|
74
|
+
super("The saved session is not accepted for this seat.");
|
|
75
|
+
this.name = "AttachSessionRejectedError";
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
export class StorageCapacityError extends Error {
|
|
@@ -150,9 +158,10 @@ export async function openStore(options) {
|
|
|
150
158
|
throw new Error("SQLite page size is unavailable.");
|
|
151
159
|
}
|
|
152
160
|
const capacityGuard = new StorageCapacityGuard(storageLimits, capacityProbe, requiredInteger(pageRow, "page_size"));
|
|
161
|
+
const activityHub = new ActivityHub();
|
|
153
162
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
163
|
+
const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
|
|
154
164
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
155
|
-
const activityHub = new ActivityHub();
|
|
156
165
|
return Object.freeze({
|
|
157
166
|
forPrincipal: (principal) => {
|
|
158
167
|
assertServerDerivedPrincipal(principal);
|
|
@@ -160,6 +169,7 @@ export async function openStore(options) {
|
|
|
160
169
|
},
|
|
161
170
|
maintenance,
|
|
162
171
|
credentials,
|
|
172
|
+
liveness,
|
|
163
173
|
diagnostics: () => diagnostics(database),
|
|
164
174
|
close: () => database.close(),
|
|
165
175
|
});
|
|
@@ -267,7 +277,8 @@ class SqliteScopedStore {
|
|
|
267
277
|
listCubes() {
|
|
268
278
|
const scope = this.#scope("read");
|
|
269
279
|
const rows = this.#database.prepare(`
|
|
270
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
280
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
281
|
+
c.created_at, c.updated_at
|
|
271
282
|
FROM cubes AS c
|
|
272
283
|
WHERE ${scope.sql}
|
|
273
284
|
ORDER BY c.id
|
|
@@ -278,25 +289,43 @@ class SqliteScopedStore {
|
|
|
278
289
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
279
290
|
const scope = this.#scope("read");
|
|
280
291
|
const row = this.#database.prepare(`
|
|
281
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
292
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
293
|
+
c.created_at, c.updated_at
|
|
282
294
|
FROM cubes AS c
|
|
283
295
|
WHERE c.id = ? AND ${scope.sql}
|
|
284
296
|
`).get(cubeId, ...scope.parameters);
|
|
285
297
|
return row === undefined ? null : cubeRecord(cubeRow(row));
|
|
286
298
|
}
|
|
287
299
|
updateDirective(cubeId, directive) {
|
|
300
|
+
this.updateCube(cubeId, { directive });
|
|
301
|
+
}
|
|
302
|
+
updateCube(cubeId, input) {
|
|
288
303
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
289
|
-
|
|
304
|
+
if (input.directive === undefined && input.messageTaxonomy === undefined) {
|
|
305
|
+
throw new TypeError("At least one cube field is required.");
|
|
306
|
+
}
|
|
307
|
+
if (input.directive !== undefined)
|
|
308
|
+
validateDirective(input.directive);
|
|
309
|
+
const taxonomy = input.messageTaxonomy === undefined
|
|
310
|
+
? undefined
|
|
311
|
+
: validateMessageTaxonomy(input.messageTaxonomy);
|
|
312
|
+
const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
|
|
290
313
|
const scope = this.#scope("manage");
|
|
291
314
|
this.#requireCube(cubeId, "manage");
|
|
292
|
-
this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
|
|
315
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""));
|
|
293
316
|
const result = this.#database.prepare(`
|
|
294
317
|
UPDATE cubes AS c
|
|
295
|
-
SET directive = ?,
|
|
318
|
+
SET directive = COALESCE(?, directive),
|
|
319
|
+
message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
|
|
320
|
+
updated_at = ?
|
|
296
321
|
WHERE c.id = ? AND ${scope.sql}
|
|
297
|
-
`).run(directive, this.#now(), cubeId, ...scope.parameters);
|
|
322
|
+
`).run(input.directive ?? null, serializedTaxonomy === undefined ? 0 : 1, serializedTaxonomy ?? null, this.#now(), cubeId, ...scope.parameters);
|
|
298
323
|
if (result.changes !== 1)
|
|
299
324
|
throw new ScopedStoreError();
|
|
325
|
+
const cube = this.getCube(cubeId);
|
|
326
|
+
if (cube === null)
|
|
327
|
+
throw new ScopedStoreError();
|
|
328
|
+
return cube;
|
|
300
329
|
}
|
|
301
330
|
appendActivity(cubeId, message) {
|
|
302
331
|
const entry = this.appendLog(cubeId, { message });
|
|
@@ -354,6 +383,7 @@ class SqliteScopedStore {
|
|
|
354
383
|
if (value !== undefined && typeof value !== "boolean")
|
|
355
384
|
throw new TypeError("Role flags must be boolean.");
|
|
356
385
|
}
|
|
386
|
+
validateRoleClass(input.roleClass);
|
|
357
387
|
const isDefault = input.isDefault ?? false;
|
|
358
388
|
const isMandatory = input.isMandatory ?? false;
|
|
359
389
|
const isHumanSeat = input.isHumanSeat ?? false;
|
|
@@ -379,8 +409,8 @@ class SqliteScopedStore {
|
|
|
379
409
|
id, cube_id, name, short_description, detailed_description,
|
|
380
410
|
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
381
411
|
receives_all_direct, role_class, created_at
|
|
382
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
383
|
-
`).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), this.#now());
|
|
412
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
413
|
+
`).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker", this.#now());
|
|
384
414
|
this.#mutationHook?.("role.insert");
|
|
385
415
|
const row = this.#database.prepare(`
|
|
386
416
|
SELECT id, cube_id, name, short_description, detailed_description,
|
|
@@ -425,6 +455,7 @@ class SqliteScopedStore {
|
|
|
425
455
|
if (value !== undefined && typeof value !== "boolean")
|
|
426
456
|
throw new TypeError("Role flags must be boolean.");
|
|
427
457
|
}
|
|
458
|
+
validateRoleClass(input.roleClass);
|
|
428
459
|
this.#requireCube(cubeId, "manage");
|
|
429
460
|
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
|
|
430
461
|
Buffer.byteLength(input.detailedDescription ?? "") + 8_192);
|
|
@@ -458,9 +489,10 @@ class SqliteScopedStore {
|
|
|
458
489
|
this.#database.prepare(`
|
|
459
490
|
UPDATE roles SET
|
|
460
491
|
name = ?, short_description = ?, detailed_description = ?, is_default = ?,
|
|
461
|
-
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct =
|
|
492
|
+
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
|
|
493
|
+
role_class = ?
|
|
462
494
|
WHERE id = ? AND cube_id = ?
|
|
463
|
-
`).run(input.name ?? existing.name, input.shortDescription ?? existing.short_description, nextDetailedDescription, booleanInteger(input.isDefault ?? existing.is_default), booleanInteger(input.isMandatory ?? existing.is_mandatory), booleanInteger(input.isHumanSeat ?? existing.is_human_seat), booleanInteger(input.canBroadcast ?? existing.can_broadcast), booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct), roleId, cubeId);
|
|
495
|
+
`).run(input.name ?? existing.name, input.shortDescription ?? existing.short_description, nextDetailedDescription, booleanInteger(input.isDefault ?? existing.is_default), booleanInteger(input.isMandatory ?? existing.is_mandatory), booleanInteger(input.isHumanSeat ?? existing.is_human_seat), booleanInteger(input.canBroadcast ?? existing.can_broadcast), booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct), input.roleClass ?? existing.role_class, roleId, cubeId);
|
|
464
496
|
const row = this.#database.prepare(`
|
|
465
497
|
SELECT id, cube_id, name, short_description, detailed_description,
|
|
466
498
|
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
@@ -547,6 +579,139 @@ class SqliteScopedStore {
|
|
|
547
579
|
`).all(cubeId);
|
|
548
580
|
return rows.map(droneRecord);
|
|
549
581
|
}
|
|
582
|
+
listDronesSince(cubeId, since) {
|
|
583
|
+
this.#requireCube(cubeId, "read");
|
|
584
|
+
let createdAt;
|
|
585
|
+
let anchorId = null;
|
|
586
|
+
if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
|
|
587
|
+
const anchor = this.#database.prepare("SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?").get(since, cubeId);
|
|
588
|
+
if (anchor === undefined)
|
|
589
|
+
throw new ScopedStoreError();
|
|
590
|
+
anchorId = requiredText(anchor, "id");
|
|
591
|
+
createdAt = requiredText(anchor, "created_at");
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
validateTimestamp(since);
|
|
595
|
+
createdAt = since;
|
|
596
|
+
}
|
|
597
|
+
const rows = this.#database.prepare(`
|
|
598
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
599
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
600
|
+
drone.hostname, drone.created_at,
|
|
601
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
602
|
+
THEN 'participant' ELSE 'observer' END AS posture,
|
|
603
|
+
(SELECT MAX(post.created_at) FROM activity_log AS post
|
|
604
|
+
WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
|
|
605
|
+
EXISTS (SELECT 1 FROM activity_log AS response
|
|
606
|
+
WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
|
|
607
|
+
AND (response.created_at > ? OR
|
|
608
|
+
(? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
|
|
609
|
+
FROM drones AS drone
|
|
610
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
611
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
612
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
613
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
614
|
+
ORDER BY drone.label, drone.id
|
|
615
|
+
`).all(createdAt, anchorId, createdAt, anchorId, cubeId);
|
|
616
|
+
return {
|
|
617
|
+
since: createdAt,
|
|
618
|
+
drones: rows.map((row) => ({
|
|
619
|
+
...droneRecord(row),
|
|
620
|
+
last_log_post_at: nullableText(row, "last_log_post_at"),
|
|
621
|
+
seen_since: requiredInteger(row, "seen_since") === 1,
|
|
622
|
+
})),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
reassignDrone(cubeId, droneId, roleId) {
|
|
626
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
627
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
628
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
629
|
+
this.#requireCube(cubeId, "manage");
|
|
630
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
631
|
+
try {
|
|
632
|
+
this.#requireCube(cubeId, "manage");
|
|
633
|
+
const droneRow = this.#database.prepare(`
|
|
634
|
+
SELECT id, cube_id, role_id, label FROM drones
|
|
635
|
+
WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
636
|
+
`).get(droneId, cubeId);
|
|
637
|
+
const targetRoleRow = this.#database.prepare(`
|
|
638
|
+
SELECT id, is_human_seat, role_class FROM roles WHERE id = ? AND cube_id = ?
|
|
639
|
+
`).get(roleId, cubeId);
|
|
640
|
+
if (droneRow === undefined || targetRoleRow === undefined)
|
|
641
|
+
throw new ScopedStoreError();
|
|
642
|
+
const sourceRoleRow = this.#database.prepare(`
|
|
643
|
+
SELECT is_human_seat FROM roles WHERE id = ? AND cube_id = ?
|
|
644
|
+
`).get(requiredText(droneRow, "role_id"), cubeId);
|
|
645
|
+
if (sourceRoleRow === undefined)
|
|
646
|
+
throw new ScopedStoreError();
|
|
647
|
+
if (requiredText(targetRoleRow, "role_class") === "queen" &&
|
|
648
|
+
requiredInteger(sourceRoleRow, "is_human_seat") !== 1) {
|
|
649
|
+
throw new AccessDeniedError();
|
|
650
|
+
}
|
|
651
|
+
if (requiredInteger(targetRoleRow, "is_human_seat") === 1) {
|
|
652
|
+
const occupied = this.#database.prepare(`
|
|
653
|
+
SELECT 1 AS present FROM drones
|
|
654
|
+
WHERE cube_id = ? AND role_id = ? AND id <> ? AND evicted_at IS NULL
|
|
655
|
+
`).get(cubeId, roleId, droneId);
|
|
656
|
+
if (occupied !== undefined)
|
|
657
|
+
throw new RoleInUseError();
|
|
658
|
+
}
|
|
659
|
+
this.#database.prepare("UPDATE drones SET role_id = ? WHERE id = ? AND cube_id = ?")
|
|
660
|
+
.run(roleId, droneId, cubeId);
|
|
661
|
+
const row = this.#database.prepare(`
|
|
662
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
663
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
664
|
+
drone.hostname, drone.created_at,
|
|
665
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
666
|
+
THEN 'participant' ELSE 'observer' END AS posture
|
|
667
|
+
FROM drones AS drone
|
|
668
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
669
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
670
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
671
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
672
|
+
`).get(droneId, cubeId);
|
|
673
|
+
if (row === undefined)
|
|
674
|
+
throw new ScopedStoreError();
|
|
675
|
+
this.#database.exec("COMMIT");
|
|
676
|
+
return droneRecord(row);
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
try {
|
|
680
|
+
this.#database.exec("ROLLBACK");
|
|
681
|
+
}
|
|
682
|
+
catch { /* Preserve the original failure. */ }
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
evictDrone(cubeId, droneId) {
|
|
687
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
688
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
689
|
+
this.#requireCube(cubeId, "manage");
|
|
690
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
691
|
+
try {
|
|
692
|
+
this.#requireCube(cubeId, "manage");
|
|
693
|
+
const active = this.#database.prepare(`
|
|
694
|
+
SELECT 1 AS present FROM drones WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
695
|
+
`).get(droneId, cubeId);
|
|
696
|
+
if (active === undefined)
|
|
697
|
+
throw new ScopedStoreError();
|
|
698
|
+
const now = this.#now();
|
|
699
|
+
this.#database.prepare("UPDATE drones SET evicted_at = ? WHERE id = ? AND cube_id = ?")
|
|
700
|
+
.run(now, droneId, cubeId);
|
|
701
|
+
this.#database.prepare(`
|
|
702
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
703
|
+
WHERE drone_id = ? AND cube_id = ? AND revoked_at IS NULL
|
|
704
|
+
`).run(now, droneId, cubeId);
|
|
705
|
+
this.#database.exec("COMMIT");
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
try {
|
|
709
|
+
this.#database.exec("ROLLBACK");
|
|
710
|
+
}
|
|
711
|
+
catch { /* Preserve the original failure. */ }
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
550
715
|
appendLog(cubeId, input) {
|
|
551
716
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
552
717
|
if (input.message.length === 0 || Buffer.byteLength(input.message) > 10_240) {
|
|
@@ -592,6 +757,12 @@ class SqliteScopedStore {
|
|
|
592
757
|
`).run(id, droneId, this.#principal.kind, this.#principal.id, input.message, createdAt, visibility, cubeId, ...scope.parameters);
|
|
593
758
|
if (inserted.changes !== 1)
|
|
594
759
|
throw new ScopedStoreError();
|
|
760
|
+
if (droneId !== null) {
|
|
761
|
+
this.#database.prepare(`
|
|
762
|
+
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
763
|
+
`).run(createdAt, droneId, cubeId);
|
|
764
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
765
|
+
}
|
|
595
766
|
if (recipients.length > 0) {
|
|
596
767
|
const valid = this.#database.prepare(`
|
|
597
768
|
SELECT COUNT(*) AS count
|
|
@@ -669,18 +840,58 @@ class SqliteScopedStore {
|
|
|
669
840
|
assertCanonicalUuid(entryId, "Activity entry id");
|
|
670
841
|
if (kind !== "ack" && kind !== "claim")
|
|
671
842
|
throw new Error("Unknown acknowledgement kind.");
|
|
672
|
-
const
|
|
673
|
-
|
|
843
|
+
const original = this.#database.prepare(`
|
|
844
|
+
SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
|
|
845
|
+
`).get(entryId, cubeId);
|
|
846
|
+
if (original === undefined)
|
|
674
847
|
throw new ScopedStoreError();
|
|
675
848
|
this.#capacityGuard.assertCanGrow(512);
|
|
676
849
|
const claimant = this.#principal.kind === "drone-session"
|
|
677
850
|
? this.#principal.droneId
|
|
678
851
|
: this.#principal.id;
|
|
679
|
-
this.#
|
|
852
|
+
const ackAt = this.#now();
|
|
853
|
+
const inserted = this.#database.prepare(`
|
|
680
854
|
INSERT OR IGNORE INTO activity_acks (
|
|
681
855
|
entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
|
|
682
856
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
683
|
-
`).run(entryId, this.#principal.kind, this.#principal.id, kind,
|
|
857
|
+
`).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
|
|
858
|
+
if (inserted.changes !== 1 || this.#principal.kind !== "drone-session")
|
|
859
|
+
return;
|
|
860
|
+
const claimantDroneId = this.#principal.droneId;
|
|
861
|
+
const actor = this.#database.prepare(`
|
|
862
|
+
SELECT drone.label, role.name AS role_name
|
|
863
|
+
FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
|
|
864
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
865
|
+
`).get(claimantDroneId, cubeId);
|
|
866
|
+
const preview = activityPreview(requiredText(original, "message"));
|
|
867
|
+
const originalAuthor = nullableText(original, "drone_id");
|
|
868
|
+
const recipients = kind === "ack"
|
|
869
|
+
? originalAuthor === null ? [] : [originalAuthor]
|
|
870
|
+
: this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
|
|
871
|
+
.filter((droneId) => droneId !== claimantDroneId);
|
|
872
|
+
if (recipients.length === 0)
|
|
873
|
+
return;
|
|
874
|
+
const roleName = actor === undefined ? null : requiredText(actor, "role_name");
|
|
875
|
+
const notification = {
|
|
876
|
+
kind,
|
|
877
|
+
id: randomUUID(),
|
|
878
|
+
cube_id: cubeId,
|
|
879
|
+
drone_id: claimantDroneId,
|
|
880
|
+
drone_label: actor === undefined ? null : requiredText(actor, "label"),
|
|
881
|
+
role_name: roleName,
|
|
882
|
+
message: `[${kind.toUpperCase()}] ${preview}`,
|
|
883
|
+
created_at: ackAt,
|
|
884
|
+
visibility: "direct",
|
|
885
|
+
recipient_drone_ids: recipients,
|
|
886
|
+
log_entry_id: entryId,
|
|
887
|
+
ack_kind: kind,
|
|
888
|
+
ack_at: ackAt,
|
|
889
|
+
entry_preview: preview,
|
|
890
|
+
...(kind === "ack"
|
|
891
|
+
? { author_drone_id: recipients[0] }
|
|
892
|
+
: { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
|
|
893
|
+
};
|
|
894
|
+
this.#activityHub.publish(cubeId, notification);
|
|
684
895
|
}
|
|
685
896
|
recordDecision(cubeId, input) {
|
|
686
897
|
this.#requireCube(cubeId, "manage");
|
|
@@ -730,7 +941,6 @@ class SqliteScopedStore {
|
|
|
730
941
|
throw new ScopedStoreError();
|
|
731
942
|
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
732
943
|
assertCanonicalUuid(input.roleId, "Role id");
|
|
733
|
-
assertCanonicalUuid(input.retryKey, "Retry key");
|
|
734
944
|
if (input.priorDroneId !== undefined)
|
|
735
945
|
assertCanonicalUuid(input.priorDroneId, "Prior drone id");
|
|
736
946
|
assertCanonicalUuid(input.droneId, "Drone id");
|
|
@@ -758,82 +968,95 @@ class SqliteScopedStore {
|
|
|
758
968
|
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
759
969
|
if (roleRow === undefined)
|
|
760
970
|
throw new ScopedStoreError();
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
JOIN drones AS drone ON drone.id =
|
|
769
|
-
|
|
770
|
-
|
|
971
|
+
const bound = this.#database.prepare(`
|
|
972
|
+
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
973
|
+
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
974
|
+
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
975
|
+
drone.role_id, drone.label, drone.evicted_at
|
|
976
|
+
FROM drone_session_credentials AS credential
|
|
977
|
+
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
978
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
979
|
+
AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
|
|
980
|
+
WHERE credential.lookup_digest = ?
|
|
981
|
+
`).get(input.credentialDigest.lookup);
|
|
982
|
+
if (bound !== undefined) {
|
|
983
|
+
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
984
|
+
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
985
|
+
optionalText(bound, "credential_revoked_at") !== null ||
|
|
986
|
+
optionalText(bound, "session_revoked_at") !== null ||
|
|
987
|
+
optionalText(bound, "evicted_at") !== null ||
|
|
988
|
+
requiredText(bound, "expires_at") <= now ||
|
|
989
|
+
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
990
|
+
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
991
|
+
requiredText(bound, "role_id") !== input.roleId ||
|
|
992
|
+
(input.priorDroneId !== undefined &&
|
|
993
|
+
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
994
|
+
throw new AttachSessionRejectedError();
|
|
995
|
+
}
|
|
996
|
+
const droneId = requiredText(bound, "drone_id");
|
|
997
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
998
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
999
|
+
const attachedRoleRow = this.#database.prepare(`
|
|
1000
|
+
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
1001
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
1002
|
+
`).get(requiredText(bound, "role_id"), input.cubeId);
|
|
1003
|
+
if (attachedRoleRow === undefined)
|
|
1004
|
+
throw new Error("Attached drone role is unavailable.");
|
|
1005
|
+
const roleClass = requiredText(attachedRoleRow, "role_class");
|
|
1006
|
+
if (roleClass !== "queen" && roleClass !== "worker") {
|
|
1007
|
+
throw new Error("Database contains invalid role class.");
|
|
1008
|
+
}
|
|
1009
|
+
this.#database.exec("COMMIT");
|
|
1010
|
+
return {
|
|
1011
|
+
cube: {
|
|
1012
|
+
id: requiredText(roleRow, "cube_id"),
|
|
1013
|
+
name: requiredText(roleRow, "cube_name"),
|
|
1014
|
+
},
|
|
1015
|
+
role: {
|
|
1016
|
+
id: requiredText(attachedRoleRow, "role_id"),
|
|
1017
|
+
name: requiredText(attachedRoleRow, "role_name"),
|
|
1018
|
+
role_class: roleClass,
|
|
1019
|
+
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
1020
|
+
},
|
|
1021
|
+
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
1022
|
+
sessionId: requiredText(bound, "session_id"),
|
|
1023
|
+
expiresAt: requiredText(bound, "expires_at"),
|
|
1024
|
+
result: "reused",
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
1028
|
+
SELECT id, role_id, label FROM drones
|
|
1029
|
+
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1030
|
+
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
771
1031
|
let droneId;
|
|
772
1032
|
let droneLabel;
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
throw new ScopedStoreError();
|
|
778
|
-
if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
|
|
779
|
-
requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
|
|
780
|
-
nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
|
|
781
|
-
throw new AttachConflictError();
|
|
1033
|
+
if (priorSeat !== undefined) {
|
|
1034
|
+
const priorSeatId = requiredText(priorSeat, "id");
|
|
1035
|
+
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
1036
|
+
throw new AttachSessionRejectedError();
|
|
782
1037
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1038
|
+
const occupied = this.#database.prepare(`
|
|
1039
|
+
SELECT 1 FROM drone_sessions AS session
|
|
1040
|
+
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
1041
|
+
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
1042
|
+
AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
|
|
1043
|
+
AND session.expires_at > ?
|
|
1044
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId, now);
|
|
1045
|
+
if (occupied !== undefined)
|
|
1046
|
+
throw new AttachSessionRejectedError();
|
|
1047
|
+
droneId = priorSeatId;
|
|
1048
|
+
droneLabel = requiredText(priorSeat, "label");
|
|
1049
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
790
1050
|
}
|
|
791
1051
|
else {
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
FROM drones
|
|
795
|
-
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
796
|
-
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
797
|
-
if (priorSeat !== undefined) {
|
|
798
|
-
droneId = requiredText(priorSeat, "id");
|
|
799
|
-
droneLabel = requiredText(priorSeat, "label");
|
|
800
|
-
generation = requiredInteger(priorSeat, "attach_generation") + 1;
|
|
801
|
-
this.#database.prepare(`
|
|
802
|
-
UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
|
|
803
|
-
`).run(now, generation, droneId);
|
|
804
|
-
reattached = true;
|
|
805
|
-
}
|
|
806
|
-
else {
|
|
807
|
-
droneId = input.droneId;
|
|
808
|
-
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
809
|
-
this.#database.prepare(`
|
|
810
|
-
INSERT INTO drones (
|
|
811
|
-
id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
|
|
812
|
-
attach_generation
|
|
813
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
814
|
-
`).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now, input.retryKey);
|
|
815
|
-
reattached = false;
|
|
816
|
-
generation = 1;
|
|
817
|
-
}
|
|
1052
|
+
droneId = input.droneId;
|
|
1053
|
+
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
818
1054
|
this.#database.prepare(`
|
|
819
|
-
INSERT INTO
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
`).run(this.#principal.id, input.retryKey, input.cubeId, input.roleId, droneId, input.priorDroneId ?? null, now);
|
|
823
|
-
}
|
|
824
|
-
const oldSessions = this.#database.prepare("SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?").all(droneId, this.#principal.id, input.cubeId)
|
|
825
|
-
.map((row) => requiredText(row, "id"));
|
|
826
|
-
if (oldSessions.length > 0) {
|
|
827
|
-
const placeholders = oldSessions.map(() => "?").join(", ");
|
|
828
|
-
this.#database.prepare(`
|
|
829
|
-
UPDATE drone_session_credentials SET revoked_at = ?
|
|
830
|
-
WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
|
|
831
|
-
`).run(now, ...oldSessions);
|
|
832
|
-
this.#database.prepare(`
|
|
833
|
-
UPDATE drone_sessions SET revoked_at = ?
|
|
834
|
-
WHERE id IN (${placeholders}) AND revoked_at IS NULL
|
|
835
|
-
`).run(now, ...oldSessions);
|
|
1055
|
+
INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
|
|
1056
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1057
|
+
`).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now);
|
|
836
1058
|
}
|
|
1059
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
837
1060
|
this.#database.prepare(`
|
|
838
1061
|
INSERT INTO drone_sessions (
|
|
839
1062
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -869,10 +1092,7 @@ class SqliteScopedStore {
|
|
|
869
1092
|
drone: { id: droneId, label: droneLabel },
|
|
870
1093
|
sessionId: input.sessionId,
|
|
871
1094
|
expiresAt: input.expiresAt,
|
|
872
|
-
|
|
873
|
-
posture,
|
|
874
|
-
reattached,
|
|
875
|
-
revokedSessionIds: oldSessions,
|
|
1095
|
+
result: "created",
|
|
876
1096
|
};
|
|
877
1097
|
}
|
|
878
1098
|
catch (error) {
|
|
@@ -886,10 +1106,41 @@ class SqliteScopedStore {
|
|
|
886
1106
|
subscribeActivity(cubeId, listener) {
|
|
887
1107
|
this.#requireCube(cubeId, "read");
|
|
888
1108
|
return this.#activityHub.subscribe(cubeId, (entry) => {
|
|
1109
|
+
if ("kind" in entry) {
|
|
1110
|
+
if (this.#principal.kind === "drone-session" &&
|
|
1111
|
+
entry.recipient_drone_ids.includes(this.#principal.droneId))
|
|
1112
|
+
listener(entry);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
889
1115
|
if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId))
|
|
890
1116
|
listener(entry);
|
|
891
1117
|
});
|
|
892
1118
|
}
|
|
1119
|
+
#claimAudience(cubeId, entryId, visibility) {
|
|
1120
|
+
if (visibility === "direct") {
|
|
1121
|
+
return this.#database.prepare(`
|
|
1122
|
+
SELECT recipient.drone_id
|
|
1123
|
+
FROM activity_log_recipients AS recipient
|
|
1124
|
+
JOIN drones AS drone ON drone.id = recipient.drone_id
|
|
1125
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1126
|
+
JOIN client_cube_grants AS grant_row
|
|
1127
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1128
|
+
WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1129
|
+
AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1130
|
+
ORDER BY recipient.drone_id
|
|
1131
|
+
`).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1132
|
+
}
|
|
1133
|
+
return this.#database.prepare(`
|
|
1134
|
+
SELECT drone.id AS drone_id
|
|
1135
|
+
FROM drones AS drone
|
|
1136
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1137
|
+
JOIN client_cube_grants AS grant_row
|
|
1138
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1139
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
|
|
1140
|
+
AND grant_row.access IN ('write', 'manage')
|
|
1141
|
+
ORDER BY drone.id
|
|
1142
|
+
`).all(cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1143
|
+
}
|
|
893
1144
|
#allowsDirectedWork(cubeId) {
|
|
894
1145
|
const scope = this.#scope("write");
|
|
895
1146
|
return this.#database.prepare(`
|
|
@@ -902,8 +1153,17 @@ class SqliteScopedStore {
|
|
|
902
1153
|
const row = this.#database.prepare(`
|
|
903
1154
|
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
904
1155
|
`).get(cubeId, ...scope.parameters);
|
|
905
|
-
if (row
|
|
906
|
-
|
|
1156
|
+
if (row !== undefined)
|
|
1157
|
+
return;
|
|
1158
|
+
if (access === "manage") {
|
|
1159
|
+
const visibleScope = this.#scope("read");
|
|
1160
|
+
const visible = this.#database.prepare(`
|
|
1161
|
+
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
|
|
1162
|
+
`).get(cubeId, ...visibleScope.parameters);
|
|
1163
|
+
if (visible !== undefined)
|
|
1164
|
+
throw new AccessDeniedError();
|
|
1165
|
+
}
|
|
1166
|
+
throw new ScopedStoreError();
|
|
907
1167
|
}
|
|
908
1168
|
#nextActivityTimestamp(cubeId) {
|
|
909
1169
|
const now = this.#clock().getTime();
|
|
@@ -1132,7 +1392,7 @@ class SqliteMaintenanceStore {
|
|
|
1132
1392
|
for (const table of [
|
|
1133
1393
|
"cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
|
|
1134
1394
|
"decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
|
|
1135
|
-
"
|
|
1395
|
+
"drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
|
|
1136
1396
|
"enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
|
|
1137
1397
|
"enrollment_invitations",
|
|
1138
1398
|
])
|
|
@@ -1234,6 +1494,54 @@ class SqliteMaintenanceStore {
|
|
|
1234
1494
|
assertCanonicalUuid(sessionId, "Drone session id");
|
|
1235
1495
|
this.#database.prepare("UPDATE drone_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(this.#now(), sessionId);
|
|
1236
1496
|
}
|
|
1497
|
+
expireDroneSession(sessionId) {
|
|
1498
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
1499
|
+
this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
|
|
1500
|
+
.run(new Date(0).toISOString(), sessionId);
|
|
1501
|
+
}
|
|
1502
|
+
inspectManagedDrone(droneId) {
|
|
1503
|
+
assertCanonicalUuid(droneId, "Drone id");
|
|
1504
|
+
const row = this.#database.prepare(`
|
|
1505
|
+
SELECT drone.role_id, drone.evicted_at,
|
|
1506
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
1507
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
1508
|
+
FROM drones AS drone WHERE drone.id = ?
|
|
1509
|
+
`).get(droneId);
|
|
1510
|
+
if (row === undefined)
|
|
1511
|
+
throw new ScopedStoreError();
|
|
1512
|
+
return {
|
|
1513
|
+
role_id: requiredText(row, "role_id"),
|
|
1514
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
1515
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
inspectCubeManagementState(cubeId) {
|
|
1519
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1520
|
+
const cube = this.#database.prepare("SELECT directive, message_taxonomy FROM cubes WHERE id = ?").get(cubeId);
|
|
1521
|
+
if (cube === undefined)
|
|
1522
|
+
throw new ScopedStoreError();
|
|
1523
|
+
const taxonomy = nullableText(cube, "message_taxonomy");
|
|
1524
|
+
const parsed = taxonomy === null ? null : JSON.parse(taxonomy);
|
|
1525
|
+
return {
|
|
1526
|
+
directive: requiredText(cube, "directive"),
|
|
1527
|
+
taxonomy_marker: typeof parsed?.[0]?.class === "string" ? parsed[0].class : null,
|
|
1528
|
+
role_ids: this.#database.prepare("SELECT id FROM roles WHERE cube_id = ? ORDER BY id").all(cubeId).map((row) => requiredText(row, "id")),
|
|
1529
|
+
active_decision_ids: this.#database.prepare(`
|
|
1530
|
+
SELECT id FROM decisions WHERE cube_id = ? AND status = 'active' ORDER BY id
|
|
1531
|
+
`).all(cubeId).map((row) => requiredText(row, "id")),
|
|
1532
|
+
drones: this.#database.prepare(`
|
|
1533
|
+
SELECT drone.id, drone.role_id, drone.evicted_at,
|
|
1534
|
+
EXISTS(SELECT 1 FROM drone_sessions AS session
|
|
1535
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NOT NULL) AS session_revoked
|
|
1536
|
+
FROM drones AS drone WHERE drone.cube_id = ? ORDER BY drone.id
|
|
1537
|
+
`).all(cubeId).map((row) => ({
|
|
1538
|
+
id: requiredText(row, "id"),
|
|
1539
|
+
role_id: requiredText(row, "role_id"),
|
|
1540
|
+
evicted: nullableText(row, "evicted_at") !== null,
|
|
1541
|
+
session_revoked: requiredInteger(row, "session_revoked") === 1,
|
|
1542
|
+
})),
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1237
1545
|
expireActivityCursor(cubeId, cursor) {
|
|
1238
1546
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
1239
1547
|
assertCanonicalUuid(cursor.id, "Activity cursor id");
|
|
@@ -1275,6 +1583,70 @@ class ActivityHub {
|
|
|
1275
1583
|
}
|
|
1276
1584
|
}
|
|
1277
1585
|
}
|
|
1586
|
+
class SqliteLivenessStore {
|
|
1587
|
+
database;
|
|
1588
|
+
clock;
|
|
1589
|
+
hub;
|
|
1590
|
+
capacityGuard;
|
|
1591
|
+
constructor(database, clock, hub, capacityGuard) {
|
|
1592
|
+
this.database = database;
|
|
1593
|
+
this.clock = clock;
|
|
1594
|
+
this.hub = hub;
|
|
1595
|
+
this.capacityGuard = capacityGuard;
|
|
1596
|
+
}
|
|
1597
|
+
scan(options = {}) {
|
|
1598
|
+
const now = this.clock();
|
|
1599
|
+
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
1600
|
+
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
1601
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
1602
|
+
const candidates = this.database.prepare(`
|
|
1603
|
+
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
1604
|
+
FROM drones AS drone
|
|
1605
|
+
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
1606
|
+
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
1607
|
+
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
1608
|
+
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
1609
|
+
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
1610
|
+
AND COALESCE(state.attempts, 0) < 3
|
|
1611
|
+
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
1612
|
+
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
1613
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
1614
|
+
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
1615
|
+
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
1616
|
+
const published = [];
|
|
1617
|
+
for (const candidate of candidates) {
|
|
1618
|
+
const droneId = requiredText(candidate, "drone_id");
|
|
1619
|
+
const cubeId = requiredText(candidate, "cube_id");
|
|
1620
|
+
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
1621
|
+
this.capacityGuard.assertCanGrow(256);
|
|
1622
|
+
const id = randomUUID();
|
|
1623
|
+
const createdAt = now.toISOString();
|
|
1624
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
1625
|
+
try {
|
|
1626
|
+
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
1627
|
+
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
1628
|
+
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
1629
|
+
`).run(droneId, now.toISOString());
|
|
1630
|
+
this.database.exec("COMMIT");
|
|
1631
|
+
}
|
|
1632
|
+
catch (error) {
|
|
1633
|
+
try {
|
|
1634
|
+
this.database.exec("ROLLBACK");
|
|
1635
|
+
}
|
|
1636
|
+
catch { /* Preserve original failure. */ }
|
|
1637
|
+
throw error;
|
|
1638
|
+
}
|
|
1639
|
+
const entry = {
|
|
1640
|
+
kind: "heartbeat_ping",
|
|
1641
|
+
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
1642
|
+
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
1643
|
+
};
|
|
1644
|
+
this.hub.publish(cubeId, entry);
|
|
1645
|
+
published.push(entry);
|
|
1646
|
+
}
|
|
1647
|
+
return published;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1278
1650
|
class SqliteCredentialStore {
|
|
1279
1651
|
#database;
|
|
1280
1652
|
#clock;
|
|
@@ -1566,9 +1938,8 @@ class SqliteCredentialStore {
|
|
|
1566
1938
|
SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
|
|
1567
1939
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1568
1940
|
session.expires_at,
|
|
1569
|
-
COALESCE(
|
|
1570
|
-
|
|
1571
|
-
) AS revoked_at
|
|
1941
|
+
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
1942
|
+
drone.evicted_at
|
|
1572
1943
|
FROM drone_session_credentials AS credential
|
|
1573
1944
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1574
1945
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -1579,6 +1950,20 @@ class SqliteCredentialStore {
|
|
|
1579
1950
|
`).get(lookup);
|
|
1580
1951
|
return row === undefined ? null : storedDroneSessionDigest(row);
|
|
1581
1952
|
}
|
|
1953
|
+
findActiveDroneSessionExpiry(sessionId) {
|
|
1954
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
1955
|
+
const row = this.#database.prepare(`
|
|
1956
|
+
SELECT session.expires_at
|
|
1957
|
+
FROM drone_sessions AS session
|
|
1958
|
+
JOIN clients AS client ON client.id = session.client_id
|
|
1959
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
1960
|
+
AND drone.client_id = session.client_id
|
|
1961
|
+
AND drone.cube_id = session.cube_id
|
|
1962
|
+
WHERE session.id = ? AND session.revoked_at IS NULL
|
|
1963
|
+
AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
|
|
1964
|
+
`).get(sessionId);
|
|
1965
|
+
return row === undefined ? null : requiredText(row, "expires_at");
|
|
1966
|
+
}
|
|
1582
1967
|
rotateClientCredential(input) {
|
|
1583
1968
|
assertCanonicalUuid(input.clientId, "Client id");
|
|
1584
1969
|
assertCanonicalUuid(input.credentialId, "Credential id");
|
|
@@ -1594,6 +1979,16 @@ class SqliteCredentialStore {
|
|
|
1594
1979
|
this.#database.prepare(`
|
|
1595
1980
|
UPDATE client_credentials SET revoked_at = ?
|
|
1596
1981
|
WHERE client_id = ? AND revoked_at IS NULL
|
|
1982
|
+
`).run(now, input.clientId);
|
|
1983
|
+
this.#database.prepare(`
|
|
1984
|
+
UPDATE drone_session_credentials SET revoked_at = ?
|
|
1985
|
+
WHERE revoked_at IS NULL AND session_id IN (
|
|
1986
|
+
SELECT id FROM drone_sessions WHERE client_id = ?
|
|
1987
|
+
)
|
|
1988
|
+
`).run(now, input.clientId);
|
|
1989
|
+
this.#database.prepare(`
|
|
1990
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
1991
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
1597
1992
|
`).run(now, input.clientId);
|
|
1598
1993
|
this.#database.prepare(`
|
|
1599
1994
|
INSERT INTO client_credentials (
|
|
@@ -1752,6 +2147,7 @@ function cubeRecord(row) {
|
|
|
1752
2147
|
ownerId: row.owner_id,
|
|
1753
2148
|
name: row.name,
|
|
1754
2149
|
directive: row.directive,
|
|
2150
|
+
messageTaxonomy: parseTaxonomy(row.message_taxonomy),
|
|
1755
2151
|
createdAt: row.created_at,
|
|
1756
2152
|
updatedAt: row.updated_at,
|
|
1757
2153
|
};
|
|
@@ -1773,6 +2169,7 @@ function cubeRow(row) {
|
|
|
1773
2169
|
owner_id: requiredText(row, "owner_id"),
|
|
1774
2170
|
name: requiredText(row, "name"),
|
|
1775
2171
|
directive: requiredText(row, "directive"),
|
|
2172
|
+
message_taxonomy: nullableText(row, "message_taxonomy"),
|
|
1776
2173
|
created_at: requiredText(row, "created_at"),
|
|
1777
2174
|
updated_at: requiredText(row, "updated_at"),
|
|
1778
2175
|
};
|
|
@@ -1972,6 +2369,7 @@ function storedDroneSessionDigest(row) {
|
|
|
1972
2369
|
cubeId: requiredText(row, "cube_id"),
|
|
1973
2370
|
droneId: requiredText(row, "drone_id"),
|
|
1974
2371
|
expiresAt: requiredText(row, "expires_at"),
|
|
2372
|
+
evictedAt: nullableText(row, "evicted_at"),
|
|
1975
2373
|
};
|
|
1976
2374
|
}
|
|
1977
2375
|
function requiredBuffer(row, key) {
|
|
@@ -2050,6 +2448,33 @@ function validateDirective(value) {
|
|
|
2050
2448
|
throw new Error("Cube directive exceeds 100000 bytes.");
|
|
2051
2449
|
}
|
|
2052
2450
|
}
|
|
2451
|
+
function activityPreview(message) {
|
|
2452
|
+
return message.replace(/\s+/gu, " ").trim().slice(0, 200);
|
|
2453
|
+
}
|
|
2454
|
+
function validateRoleClass(value) {
|
|
2455
|
+
if (value !== undefined && value !== "queen" && value !== "worker") {
|
|
2456
|
+
throw new TypeError("Role class must be queen or worker.");
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
function serializeTaxonomy(value) {
|
|
2460
|
+
if (value === null)
|
|
2461
|
+
return null;
|
|
2462
|
+
const serialized = JSON.stringify(value);
|
|
2463
|
+
if (Buffer.byteLength(serialized) > 100_000) {
|
|
2464
|
+
throw new RangeError("Message taxonomy exceeds 100000 bytes.");
|
|
2465
|
+
}
|
|
2466
|
+
return serialized;
|
|
2467
|
+
}
|
|
2468
|
+
function parseTaxonomy(value) {
|
|
2469
|
+
if (value === null)
|
|
2470
|
+
return null;
|
|
2471
|
+
try {
|
|
2472
|
+
return validateMessageTaxonomy(JSON.parse(value));
|
|
2473
|
+
}
|
|
2474
|
+
catch {
|
|
2475
|
+
throw new Error("Database contains invalid message taxonomy.");
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2053
2478
|
function validateDigest(digest) {
|
|
2054
2479
|
validateLookup(digest.lookup);
|
|
2055
2480
|
if (digest.verifier.length !== 32)
|