borgmcp-server 0.1.1 → 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 +70 -22
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +3 -1
- package/dist/coordination-api.js +476 -78
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +13 -7
- package/dist/credentials.js +77 -15
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +76 -0
- package/dist/debug-log.js +124 -0
- package/dist/debug-log.js.map +1 -0
- 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 +5 -20
- package/dist/https-server.js +108 -50
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +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.d.ts +4 -0
- package/dist/migrations.js +99 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +14 -4
- package/dist/service.js +243 -25
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +106 -8
- package/dist/store.js +772 -120
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +490 -72
- package/src/credentials.ts +103 -19
- package/src/debug-log.ts +165 -0
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +113 -78
- package/src/index.ts +1 -1
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +102 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +268 -27
- package/src/start-options.ts +21 -4
- package/src/store.ts +887 -142
- 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,11 +1,13 @@
|
|
|
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 { applyMigrations } from "./migrations.js";
|
|
6
|
+
import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
|
|
7
7
|
import { operatorErrors } from "./operator-error.js";
|
|
8
8
|
import { assertCanonicalUuid, assertServerDerivedPrincipal, } from "./principal.js";
|
|
9
|
+
import { patchRoleSectionText } from "./role-section.js";
|
|
10
|
+
import { validateMessageTaxonomy } from "./message-taxonomy.js";
|
|
9
11
|
export const DEFAULT_CUBE_LIMITS = Object.freeze({
|
|
10
12
|
maxCubesPerClient: 100,
|
|
11
13
|
maxCubesTotal: 1_000,
|
|
@@ -15,6 +17,15 @@ export const DEFAULT_STORAGE_LIMITS = Object.freeze({
|
|
|
15
17
|
maxDatabaseBytes: 1_073_741_824,
|
|
16
18
|
minFreeDiskBytes: 67_108_864,
|
|
17
19
|
});
|
|
20
|
+
export class InvitationCubeNotFoundError extends Error {
|
|
21
|
+
}
|
|
22
|
+
export class InvitationCubeAmbiguousError extends Error {
|
|
23
|
+
candidateIds;
|
|
24
|
+
constructor(candidateIds) {
|
|
25
|
+
super("Cube name is ambiguous.");
|
|
26
|
+
this.candidateIds = Object.freeze([...candidateIds]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
18
29
|
export class ScopedStoreError extends Error {
|
|
19
30
|
code = "NOT_FOUND";
|
|
20
31
|
constructor() {
|
|
@@ -22,6 +33,27 @@ export class ScopedStoreError extends Error {
|
|
|
22
33
|
this.name = "ScopedStoreError";
|
|
23
34
|
}
|
|
24
35
|
}
|
|
36
|
+
export class RoleConflictError extends Error {
|
|
37
|
+
code = "ROLE_ALREADY_EXISTS";
|
|
38
|
+
constructor() {
|
|
39
|
+
super("A role with that name already exists.");
|
|
40
|
+
this.name = "RoleConflictError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class DefaultRoleRequiredError extends Error {
|
|
44
|
+
code = "DEFAULT_ROLE_REQUIRED";
|
|
45
|
+
constructor() {
|
|
46
|
+
super("A cube must retain one default role.");
|
|
47
|
+
this.name = "DefaultRoleRequiredError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class RoleSectionConflictError extends Error {
|
|
51
|
+
code = "ROLE_SECTION_CONFLICT";
|
|
52
|
+
constructor() {
|
|
53
|
+
super("The role section patch conflicts with the current role text.");
|
|
54
|
+
this.name = "RoleSectionConflictError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
25
57
|
export class CursorExpiredError extends Error {
|
|
26
58
|
code = "CURSOR_EXPIRED";
|
|
27
59
|
constructor() {
|
|
@@ -29,11 +61,11 @@ export class CursorExpiredError extends Error {
|
|
|
29
61
|
this.name = "CursorExpiredError";
|
|
30
62
|
}
|
|
31
63
|
}
|
|
32
|
-
export class
|
|
33
|
-
code = "
|
|
64
|
+
export class AttachSessionRejectedError extends Error {
|
|
65
|
+
code = "SESSION_REJECTED";
|
|
34
66
|
constructor() {
|
|
35
|
-
super("The
|
|
36
|
-
this.name = "
|
|
67
|
+
super("The saved session is not accepted for this seat.");
|
|
68
|
+
this.name = "AttachSessionRejectedError";
|
|
37
69
|
}
|
|
38
70
|
}
|
|
39
71
|
export class StorageCapacityError extends Error {
|
|
@@ -100,8 +132,14 @@ export async function openStore(options) {
|
|
|
100
132
|
});
|
|
101
133
|
const clock = options.clock ?? (() => new Date());
|
|
102
134
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
if (options.migrationMode === "require-current") {
|
|
136
|
+
configureExistingDatabase(database);
|
|
137
|
+
assertMigrationsCurrent(database);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
configureDatabase(database);
|
|
141
|
+
applyMigrations(database);
|
|
142
|
+
}
|
|
105
143
|
}
|
|
106
144
|
catch (error) {
|
|
107
145
|
database.close();
|
|
@@ -113,9 +151,10 @@ export async function openStore(options) {
|
|
|
113
151
|
throw new Error("SQLite page size is unavailable.");
|
|
114
152
|
}
|
|
115
153
|
const capacityGuard = new StorageCapacityGuard(storageLimits, capacityProbe, requiredInteger(pageRow, "page_size"));
|
|
154
|
+
const activityHub = new ActivityHub();
|
|
116
155
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
156
|
+
const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
|
|
117
157
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
118
|
-
const activityHub = new ActivityHub();
|
|
119
158
|
return Object.freeze({
|
|
120
159
|
forPrincipal: (principal) => {
|
|
121
160
|
assertServerDerivedPrincipal(principal);
|
|
@@ -123,6 +162,7 @@ export async function openStore(options) {
|
|
|
123
162
|
},
|
|
124
163
|
maintenance,
|
|
125
164
|
credentials,
|
|
165
|
+
liveness,
|
|
126
166
|
diagnostics: () => diagnostics(database),
|
|
127
167
|
close: () => database.close(),
|
|
128
168
|
});
|
|
@@ -230,7 +270,8 @@ class SqliteScopedStore {
|
|
|
230
270
|
listCubes() {
|
|
231
271
|
const scope = this.#scope("read");
|
|
232
272
|
const rows = this.#database.prepare(`
|
|
233
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
273
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
274
|
+
c.created_at, c.updated_at
|
|
234
275
|
FROM cubes AS c
|
|
235
276
|
WHERE ${scope.sql}
|
|
236
277
|
ORDER BY c.id
|
|
@@ -241,25 +282,43 @@ class SqliteScopedStore {
|
|
|
241
282
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
242
283
|
const scope = this.#scope("read");
|
|
243
284
|
const row = this.#database.prepare(`
|
|
244
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
285
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
286
|
+
c.created_at, c.updated_at
|
|
245
287
|
FROM cubes AS c
|
|
246
288
|
WHERE c.id = ? AND ${scope.sql}
|
|
247
289
|
`).get(cubeId, ...scope.parameters);
|
|
248
290
|
return row === undefined ? null : cubeRecord(cubeRow(row));
|
|
249
291
|
}
|
|
250
292
|
updateDirective(cubeId, directive) {
|
|
293
|
+
this.updateCube(cubeId, { directive });
|
|
294
|
+
}
|
|
295
|
+
updateCube(cubeId, input) {
|
|
251
296
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
252
|
-
|
|
297
|
+
if (input.directive === undefined && input.messageTaxonomy === undefined) {
|
|
298
|
+
throw new TypeError("At least one cube field is required.");
|
|
299
|
+
}
|
|
300
|
+
if (input.directive !== undefined)
|
|
301
|
+
validateDirective(input.directive);
|
|
302
|
+
const taxonomy = input.messageTaxonomy === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: validateMessageTaxonomy(input.messageTaxonomy);
|
|
305
|
+
const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
|
|
253
306
|
const scope = this.#scope("manage");
|
|
254
307
|
this.#requireCube(cubeId, "manage");
|
|
255
|
-
this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
|
|
308
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""));
|
|
256
309
|
const result = this.#database.prepare(`
|
|
257
310
|
UPDATE cubes AS c
|
|
258
|
-
SET directive = ?,
|
|
311
|
+
SET directive = COALESCE(?, directive),
|
|
312
|
+
message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
|
|
313
|
+
updated_at = ?
|
|
259
314
|
WHERE c.id = ? AND ${scope.sql}
|
|
260
|
-
`).run(directive, this.#now(), cubeId, ...scope.parameters);
|
|
315
|
+
`).run(input.directive ?? null, serializedTaxonomy === undefined ? 0 : 1, serializedTaxonomy ?? null, this.#now(), cubeId, ...scope.parameters);
|
|
261
316
|
if (result.changes !== 1)
|
|
262
317
|
throw new ScopedStoreError();
|
|
318
|
+
const cube = this.getCube(cubeId);
|
|
319
|
+
if (cube === null)
|
|
320
|
+
throw new ScopedStoreError();
|
|
321
|
+
return cube;
|
|
263
322
|
}
|
|
264
323
|
appendActivity(cubeId, message) {
|
|
265
324
|
const entry = this.appendLog(cubeId, { message });
|
|
@@ -293,21 +352,269 @@ class SqliteScopedStore {
|
|
|
293
352
|
listRoles(cubeId) {
|
|
294
353
|
this.#requireCube(cubeId, "read");
|
|
295
354
|
const rows = this.#database.prepare(`
|
|
296
|
-
SELECT id, cube_id, name, short_description,
|
|
297
|
-
|
|
355
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
356
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
357
|
+
receives_all_direct, role_class, created_at
|
|
298
358
|
FROM roles WHERE cube_id = ? ORDER BY name, id
|
|
299
359
|
`).all(cubeId);
|
|
300
360
|
return rows.map(roleRecord);
|
|
301
361
|
}
|
|
362
|
+
createRole(cubeId, input) {
|
|
363
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
364
|
+
validateRoleName(input.name);
|
|
365
|
+
const shortDescription = input.shortDescription ?? "";
|
|
366
|
+
const detailedDescription = input.detailedDescription ?? "";
|
|
367
|
+
validateRoleShortDescription(shortDescription);
|
|
368
|
+
assertRoleTextWriteAllowed(detailedDescription);
|
|
369
|
+
for (const value of [
|
|
370
|
+
input.isDefault,
|
|
371
|
+
input.isMandatory,
|
|
372
|
+
input.isHumanSeat,
|
|
373
|
+
input.canBroadcast,
|
|
374
|
+
input.receivesAllDirect,
|
|
375
|
+
]) {
|
|
376
|
+
if (value !== undefined && typeof value !== "boolean")
|
|
377
|
+
throw new TypeError("Role flags must be boolean.");
|
|
378
|
+
}
|
|
379
|
+
validateRoleClass(input.roleClass);
|
|
380
|
+
const isDefault = input.isDefault ?? false;
|
|
381
|
+
const isMandatory = input.isMandatory ?? false;
|
|
382
|
+
const isHumanSeat = input.isHumanSeat ?? false;
|
|
383
|
+
const canBroadcast = input.canBroadcast ?? false;
|
|
384
|
+
const receivesAllDirect = input.receivesAllDirect ?? false;
|
|
385
|
+
this.#requireCube(cubeId, "manage");
|
|
386
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name) + Buffer.byteLength(shortDescription) +
|
|
387
|
+
Buffer.byteLength(detailedDescription) + 8_192);
|
|
388
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
389
|
+
try {
|
|
390
|
+
this.#requireCube(cubeId, "manage");
|
|
391
|
+
const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ?").get(cubeId, input.name);
|
|
392
|
+
if (duplicate !== undefined)
|
|
393
|
+
throw new RoleConflictError();
|
|
394
|
+
if (isDefault) {
|
|
395
|
+
this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
|
|
396
|
+
.run(cubeId);
|
|
397
|
+
this.#mutationHook?.("role.demote-default");
|
|
398
|
+
}
|
|
399
|
+
const id = randomUUID();
|
|
400
|
+
this.#database.prepare(`
|
|
401
|
+
INSERT INTO roles (
|
|
402
|
+
id, cube_id, name, short_description, detailed_description,
|
|
403
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
404
|
+
receives_all_direct, role_class, created_at
|
|
405
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
406
|
+
`).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker", this.#now());
|
|
407
|
+
this.#mutationHook?.("role.insert");
|
|
408
|
+
const row = this.#database.prepare(`
|
|
409
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
410
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
411
|
+
receives_all_direct, role_class, created_at
|
|
412
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
413
|
+
`).get(id, cubeId);
|
|
414
|
+
if (row === undefined)
|
|
415
|
+
throw new ScopedStoreError();
|
|
416
|
+
this.#database.exec("COMMIT");
|
|
417
|
+
this.#mutationHook?.("role.after-commit");
|
|
418
|
+
return roleRecord(row);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
try {
|
|
422
|
+
this.#database.exec("ROLLBACK");
|
|
423
|
+
}
|
|
424
|
+
catch { /* Preserve the original failure. */ }
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
updateRole(cubeId, roleId, input) {
|
|
429
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
430
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
431
|
+
if (Object.values(input).every((value) => value === undefined)) {
|
|
432
|
+
throw new TypeError("At least one role field is required.");
|
|
433
|
+
}
|
|
434
|
+
if (input.name !== undefined)
|
|
435
|
+
validateRoleName(input.name);
|
|
436
|
+
if (input.shortDescription !== undefined)
|
|
437
|
+
validateRoleShortDescription(input.shortDescription);
|
|
438
|
+
if (input.detailedDescription !== undefined && typeof input.detailedDescription !== "string") {
|
|
439
|
+
throw new TypeError("Role detailed description must be text.");
|
|
440
|
+
}
|
|
441
|
+
for (const value of [
|
|
442
|
+
input.isDefault,
|
|
443
|
+
input.isMandatory,
|
|
444
|
+
input.isHumanSeat,
|
|
445
|
+
input.canBroadcast,
|
|
446
|
+
input.receivesAllDirect,
|
|
447
|
+
]) {
|
|
448
|
+
if (value !== undefined && typeof value !== "boolean")
|
|
449
|
+
throw new TypeError("Role flags must be boolean.");
|
|
450
|
+
}
|
|
451
|
+
validateRoleClass(input.roleClass);
|
|
452
|
+
this.#requireCube(cubeId, "manage");
|
|
453
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
|
|
454
|
+
Buffer.byteLength(input.detailedDescription ?? "") + 8_192);
|
|
455
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
456
|
+
try {
|
|
457
|
+
this.#requireCube(cubeId, "manage");
|
|
458
|
+
const existingRow = this.#database.prepare(`
|
|
459
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
460
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
461
|
+
receives_all_direct, role_class, created_at
|
|
462
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
463
|
+
`).get(roleId, cubeId);
|
|
464
|
+
if (existingRow === undefined)
|
|
465
|
+
throw new ScopedStoreError();
|
|
466
|
+
const existing = roleRecord(existingRow);
|
|
467
|
+
if (input.isDefault === false && existing.is_default) {
|
|
468
|
+
throw new DefaultRoleRequiredError();
|
|
469
|
+
}
|
|
470
|
+
if (input.name !== undefined && input.name !== existing.name) {
|
|
471
|
+
const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ? AND id <> ?").get(cubeId, input.name, roleId);
|
|
472
|
+
if (duplicate !== undefined)
|
|
473
|
+
throw new RoleConflictError();
|
|
474
|
+
}
|
|
475
|
+
if (input.isDefault === true && !existing.is_default) {
|
|
476
|
+
this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
|
|
477
|
+
.run(cubeId);
|
|
478
|
+
this.#mutationHook?.("role.demote-default");
|
|
479
|
+
}
|
|
480
|
+
const nextDetailedDescription = input.detailedDescription ?? existing.detailed_description;
|
|
481
|
+
assertRoleTextWriteAllowed(nextDetailedDescription, existing.detailed_description);
|
|
482
|
+
this.#database.prepare(`
|
|
483
|
+
UPDATE roles SET
|
|
484
|
+
name = ?, short_description = ?, detailed_description = ?, is_default = ?,
|
|
485
|
+
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
|
|
486
|
+
role_class = ?
|
|
487
|
+
WHERE id = ? AND cube_id = ?
|
|
488
|
+
`).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);
|
|
489
|
+
const row = this.#database.prepare(`
|
|
490
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
491
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
492
|
+
receives_all_direct, role_class, created_at
|
|
493
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
494
|
+
`).get(roleId, cubeId);
|
|
495
|
+
if (row === undefined)
|
|
496
|
+
throw new ScopedStoreError();
|
|
497
|
+
this.#database.exec("COMMIT");
|
|
498
|
+
this.#mutationHook?.("role.after-commit");
|
|
499
|
+
return roleRecord(row);
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
try {
|
|
503
|
+
this.#database.exec("ROLLBACK");
|
|
504
|
+
}
|
|
505
|
+
catch { /* Preserve the original failure. */ }
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
patchRoleSection(cubeId, roleId, input) {
|
|
510
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
511
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
512
|
+
this.#requireCube(cubeId, "manage");
|
|
513
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.heading) +
|
|
514
|
+
("body" in input ? Buffer.byteLength(input.body) : 0) + 8_192);
|
|
515
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
516
|
+
try {
|
|
517
|
+
this.#requireCube(cubeId, "manage");
|
|
518
|
+
const existingRow = this.#database.prepare(`
|
|
519
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
520
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
521
|
+
receives_all_direct, role_class, created_at
|
|
522
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
523
|
+
`).get(roleId, cubeId);
|
|
524
|
+
if (existingRow === undefined)
|
|
525
|
+
throw new ScopedStoreError();
|
|
526
|
+
const existing = roleRecord(existingRow);
|
|
527
|
+
let detailedDescription;
|
|
528
|
+
try {
|
|
529
|
+
detailedDescription = patchRoleSectionText(existing.detailed_description, input);
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (error instanceof TypeError)
|
|
533
|
+
throw error;
|
|
534
|
+
throw new RoleSectionConflictError();
|
|
535
|
+
}
|
|
536
|
+
assertRoleTextWriteAllowed(detailedDescription, existing.detailed_description);
|
|
537
|
+
this.#database.prepare("UPDATE roles SET detailed_description = ? WHERE id = ? AND cube_id = ?").run(detailedDescription, roleId, cubeId);
|
|
538
|
+
const row = this.#database.prepare(`
|
|
539
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
540
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
541
|
+
receives_all_direct, role_class, created_at
|
|
542
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
543
|
+
`).get(roleId, cubeId);
|
|
544
|
+
if (row === undefined)
|
|
545
|
+
throw new ScopedStoreError();
|
|
546
|
+
this.#database.exec("COMMIT");
|
|
547
|
+
this.#mutationHook?.("role.after-commit");
|
|
548
|
+
return roleRecord(row);
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
try {
|
|
552
|
+
this.#database.exec("ROLLBACK");
|
|
553
|
+
}
|
|
554
|
+
catch { /* Preserve the original failure. */ }
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
302
558
|
listDrones(cubeId) {
|
|
303
559
|
this.#requireCube(cubeId, "read");
|
|
304
560
|
const rows = this.#database.prepare(`
|
|
305
|
-
SELECT id, cube_id, role_id, label,
|
|
306
|
-
|
|
307
|
-
|
|
561
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
562
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
563
|
+
drone.hostname, drone.created_at,
|
|
564
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
565
|
+
THEN 'participant' ELSE 'observer' END AS posture
|
|
566
|
+
FROM drones AS drone
|
|
567
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
568
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
569
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
570
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
571
|
+
ORDER BY drone.label, drone.id
|
|
308
572
|
`).all(cubeId);
|
|
309
573
|
return rows.map(droneRecord);
|
|
310
574
|
}
|
|
575
|
+
listDronesSince(cubeId, since) {
|
|
576
|
+
this.#requireCube(cubeId, "read");
|
|
577
|
+
let createdAt;
|
|
578
|
+
let anchorId = null;
|
|
579
|
+
if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
|
|
580
|
+
const anchor = this.#database.prepare("SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?").get(since, cubeId);
|
|
581
|
+
if (anchor === undefined)
|
|
582
|
+
throw new ScopedStoreError();
|
|
583
|
+
anchorId = requiredText(anchor, "id");
|
|
584
|
+
createdAt = requiredText(anchor, "created_at");
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
validateTimestamp(since);
|
|
588
|
+
createdAt = since;
|
|
589
|
+
}
|
|
590
|
+
const rows = this.#database.prepare(`
|
|
591
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
592
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
593
|
+
drone.hostname, drone.created_at,
|
|
594
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
595
|
+
THEN 'participant' ELSE 'observer' END AS posture,
|
|
596
|
+
(SELECT MAX(post.created_at) FROM activity_log AS post
|
|
597
|
+
WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
|
|
598
|
+
EXISTS (SELECT 1 FROM activity_log AS response
|
|
599
|
+
WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
|
|
600
|
+
AND (response.created_at > ? OR
|
|
601
|
+
(? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
|
|
602
|
+
FROM drones AS drone
|
|
603
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
604
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
605
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
606
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
607
|
+
ORDER BY drone.label, drone.id
|
|
608
|
+
`).all(createdAt, anchorId, createdAt, anchorId, cubeId);
|
|
609
|
+
return {
|
|
610
|
+
since: createdAt,
|
|
611
|
+
drones: rows.map((row) => ({
|
|
612
|
+
...droneRecord(row),
|
|
613
|
+
last_log_post_at: nullableText(row, "last_log_post_at"),
|
|
614
|
+
seen_since: requiredInteger(row, "seen_since") === 1,
|
|
615
|
+
})),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
311
618
|
appendLog(cubeId, input) {
|
|
312
619
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
313
620
|
if (input.message.length === 0 || Buffer.byteLength(input.message) > 10_240) {
|
|
@@ -353,11 +660,24 @@ class SqliteScopedStore {
|
|
|
353
660
|
`).run(id, droneId, this.#principal.kind, this.#principal.id, input.message, createdAt, visibility, cubeId, ...scope.parameters);
|
|
354
661
|
if (inserted.changes !== 1)
|
|
355
662
|
throw new ScopedStoreError();
|
|
663
|
+
if (droneId !== null) {
|
|
664
|
+
this.#database.prepare(`
|
|
665
|
+
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
666
|
+
`).run(createdAt, droneId, cubeId);
|
|
667
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
668
|
+
}
|
|
356
669
|
if (recipients.length > 0) {
|
|
357
670
|
const valid = this.#database.prepare(`
|
|
358
|
-
SELECT COUNT(*) AS count
|
|
359
|
-
|
|
360
|
-
|
|
671
|
+
SELECT COUNT(*) AS count
|
|
672
|
+
FROM drones AS recipient
|
|
673
|
+
JOIN clients AS recipient_client ON recipient_client.id = recipient.client_id
|
|
674
|
+
JOIN client_cube_grants AS recipient_grant
|
|
675
|
+
ON recipient_grant.client_id = recipient.client_id
|
|
676
|
+
AND recipient_grant.cube_id = recipient.cube_id
|
|
677
|
+
WHERE recipient.cube_id = ? AND recipient.evicted_at IS NULL
|
|
678
|
+
AND recipient_client.revoked_at IS NULL
|
|
679
|
+
AND recipient_grant.access IN ('write', 'manage')
|
|
680
|
+
AND recipient.id IN (${recipients.map(() => "?").join(", ")})
|
|
361
681
|
`).get(cubeId, ...recipients);
|
|
362
682
|
if (valid === undefined)
|
|
363
683
|
throw new Error("Recipient count query returned no row.");
|
|
@@ -387,6 +707,7 @@ class SqliteScopedStore {
|
|
|
387
707
|
throw new Error("Activity read limit must be an integer from 1 to 500.");
|
|
388
708
|
}
|
|
389
709
|
this.#validateCursor(cubeId, cursor);
|
|
710
|
+
const broadcastOnly = !this.#allowsDirectedWork(cubeId);
|
|
390
711
|
const cursorSql = cursor === null
|
|
391
712
|
? { sql: "1 = 1", parameters: [] }
|
|
392
713
|
: {
|
|
@@ -397,6 +718,7 @@ class SqliteScopedStore {
|
|
|
397
718
|
SELECT l.id
|
|
398
719
|
FROM activity_log AS l
|
|
399
720
|
WHERE l.cube_id = ? AND ${cursorSql.sql}
|
|
721
|
+
AND (${broadcastOnly ? "l.visibility = 'broadcast'" : "1 = 1"})
|
|
400
722
|
ORDER BY l.created_at, l.id
|
|
401
723
|
LIMIT ?
|
|
402
724
|
`).all(cubeId, ...cursorSql.parameters, limit + 1);
|
|
@@ -405,13 +727,15 @@ class SqliteScopedStore {
|
|
|
405
727
|
const nextCursor = entries.length === 0
|
|
406
728
|
? cursor
|
|
407
729
|
: { id: entries.at(-1).id, created_at: entries.at(-1).created_at };
|
|
408
|
-
const behind = nextCursor === null
|
|
730
|
+
const behind = nextCursor === null
|
|
731
|
+
? this.#countAfter(cubeId, null, broadcastOnly)
|
|
732
|
+
: this.#countAfter(cubeId, nextCursor, broadcastOnly);
|
|
409
733
|
return {
|
|
410
734
|
entries,
|
|
411
735
|
cursor: nextCursor,
|
|
412
736
|
behind_by: behind,
|
|
413
737
|
has_more: behind > 0,
|
|
414
|
-
claims: this.#claims(cubeId),
|
|
738
|
+
claims: this.#claims(cubeId, broadcastOnly),
|
|
415
739
|
};
|
|
416
740
|
}
|
|
417
741
|
acknowledge(cubeId, entryId, kind) {
|
|
@@ -419,18 +743,58 @@ class SqliteScopedStore {
|
|
|
419
743
|
assertCanonicalUuid(entryId, "Activity entry id");
|
|
420
744
|
if (kind !== "ack" && kind !== "claim")
|
|
421
745
|
throw new Error("Unknown acknowledgement kind.");
|
|
422
|
-
const
|
|
423
|
-
|
|
746
|
+
const original = this.#database.prepare(`
|
|
747
|
+
SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
|
|
748
|
+
`).get(entryId, cubeId);
|
|
749
|
+
if (original === undefined)
|
|
424
750
|
throw new ScopedStoreError();
|
|
425
751
|
this.#capacityGuard.assertCanGrow(512);
|
|
426
752
|
const claimant = this.#principal.kind === "drone-session"
|
|
427
753
|
? this.#principal.droneId
|
|
428
754
|
: this.#principal.id;
|
|
429
|
-
this.#
|
|
755
|
+
const ackAt = this.#now();
|
|
756
|
+
const inserted = this.#database.prepare(`
|
|
430
757
|
INSERT OR IGNORE INTO activity_acks (
|
|
431
758
|
entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
|
|
432
759
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
433
|
-
`).run(entryId, this.#principal.kind, this.#principal.id, kind,
|
|
760
|
+
`).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
|
|
761
|
+
if (inserted.changes !== 1 || this.#principal.kind !== "drone-session")
|
|
762
|
+
return;
|
|
763
|
+
const claimantDroneId = this.#principal.droneId;
|
|
764
|
+
const actor = this.#database.prepare(`
|
|
765
|
+
SELECT drone.label, role.name AS role_name
|
|
766
|
+
FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
|
|
767
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
768
|
+
`).get(claimantDroneId, cubeId);
|
|
769
|
+
const preview = activityPreview(requiredText(original, "message"));
|
|
770
|
+
const originalAuthor = nullableText(original, "drone_id");
|
|
771
|
+
const recipients = kind === "ack"
|
|
772
|
+
? originalAuthor === null ? [] : [originalAuthor]
|
|
773
|
+
: this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
|
|
774
|
+
.filter((droneId) => droneId !== claimantDroneId);
|
|
775
|
+
if (recipients.length === 0)
|
|
776
|
+
return;
|
|
777
|
+
const roleName = actor === undefined ? null : requiredText(actor, "role_name");
|
|
778
|
+
const notification = {
|
|
779
|
+
kind,
|
|
780
|
+
id: randomUUID(),
|
|
781
|
+
cube_id: cubeId,
|
|
782
|
+
drone_id: claimantDroneId,
|
|
783
|
+
drone_label: actor === undefined ? null : requiredText(actor, "label"),
|
|
784
|
+
role_name: roleName,
|
|
785
|
+
message: `[${kind.toUpperCase()}] ${preview}`,
|
|
786
|
+
created_at: ackAt,
|
|
787
|
+
visibility: "direct",
|
|
788
|
+
recipient_drone_ids: recipients,
|
|
789
|
+
log_entry_id: entryId,
|
|
790
|
+
ack_kind: kind,
|
|
791
|
+
ack_at: ackAt,
|
|
792
|
+
entry_preview: preview,
|
|
793
|
+
...(kind === "ack"
|
|
794
|
+
? { author_drone_id: recipients[0] }
|
|
795
|
+
: { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
|
|
796
|
+
};
|
|
797
|
+
this.#activityHub.publish(cubeId, notification);
|
|
434
798
|
}
|
|
435
799
|
recordDecision(cubeId, input) {
|
|
436
800
|
this.#requireCube(cubeId, "manage");
|
|
@@ -480,7 +844,6 @@ class SqliteScopedStore {
|
|
|
480
844
|
throw new ScopedStoreError();
|
|
481
845
|
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
482
846
|
assertCanonicalUuid(input.roleId, "Role id");
|
|
483
|
-
assertCanonicalUuid(input.retryKey, "Retry key");
|
|
484
847
|
if (input.priorDroneId !== undefined)
|
|
485
848
|
assertCanonicalUuid(input.priorDroneId, "Prior drone id");
|
|
486
849
|
assertCanonicalUuid(input.droneId, "Drone id");
|
|
@@ -508,81 +871,95 @@ class SqliteScopedStore {
|
|
|
508
871
|
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
509
872
|
if (roleRow === undefined)
|
|
510
873
|
throw new ScopedStoreError();
|
|
511
|
-
const
|
|
512
|
-
SELECT
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
drone.
|
|
516
|
-
FROM
|
|
517
|
-
JOIN
|
|
518
|
-
|
|
519
|
-
|
|
874
|
+
const bound = this.#database.prepare(`
|
|
875
|
+
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
876
|
+
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
877
|
+
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
878
|
+
drone.role_id, drone.label, drone.evicted_at
|
|
879
|
+
FROM drone_session_credentials AS credential
|
|
880
|
+
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
881
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
882
|
+
AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
|
|
883
|
+
WHERE credential.lookup_digest = ?
|
|
884
|
+
`).get(input.credentialDigest.lookup);
|
|
885
|
+
if (bound !== undefined) {
|
|
886
|
+
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
887
|
+
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
888
|
+
optionalText(bound, "credential_revoked_at") !== null ||
|
|
889
|
+
optionalText(bound, "session_revoked_at") !== null ||
|
|
890
|
+
optionalText(bound, "evicted_at") !== null ||
|
|
891
|
+
requiredText(bound, "expires_at") <= now ||
|
|
892
|
+
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
893
|
+
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
894
|
+
requiredText(bound, "role_id") !== input.roleId ||
|
|
895
|
+
(input.priorDroneId !== undefined &&
|
|
896
|
+
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
897
|
+
throw new AttachSessionRejectedError();
|
|
898
|
+
}
|
|
899
|
+
const droneId = requiredText(bound, "drone_id");
|
|
900
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
901
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
902
|
+
const attachedRoleRow = this.#database.prepare(`
|
|
903
|
+
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
904
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
905
|
+
`).get(requiredText(bound, "role_id"), input.cubeId);
|
|
906
|
+
if (attachedRoleRow === undefined)
|
|
907
|
+
throw new Error("Attached drone role is unavailable.");
|
|
908
|
+
const roleClass = requiredText(attachedRoleRow, "role_class");
|
|
909
|
+
if (roleClass !== "queen" && roleClass !== "worker") {
|
|
910
|
+
throw new Error("Database contains invalid role class.");
|
|
911
|
+
}
|
|
912
|
+
this.#database.exec("COMMIT");
|
|
913
|
+
return {
|
|
914
|
+
cube: {
|
|
915
|
+
id: requiredText(roleRow, "cube_id"),
|
|
916
|
+
name: requiredText(roleRow, "cube_name"),
|
|
917
|
+
},
|
|
918
|
+
role: {
|
|
919
|
+
id: requiredText(attachedRoleRow, "role_id"),
|
|
920
|
+
name: requiredText(attachedRoleRow, "role_name"),
|
|
921
|
+
role_class: roleClass,
|
|
922
|
+
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
923
|
+
},
|
|
924
|
+
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
925
|
+
sessionId: requiredText(bound, "session_id"),
|
|
926
|
+
expiresAt: requiredText(bound, "expires_at"),
|
|
927
|
+
result: "reused",
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
931
|
+
SELECT id, role_id, label FROM drones
|
|
932
|
+
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
933
|
+
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
520
934
|
let droneId;
|
|
521
935
|
let droneLabel;
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
throw new ScopedStoreError();
|
|
527
|
-
if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
|
|
528
|
-
requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
|
|
529
|
-
nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
|
|
530
|
-
throw new AttachConflictError();
|
|
936
|
+
if (priorSeat !== undefined) {
|
|
937
|
+
const priorSeatId = requiredText(priorSeat, "id");
|
|
938
|
+
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
939
|
+
throw new AttachSessionRejectedError();
|
|
531
940
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
941
|
+
const occupied = this.#database.prepare(`
|
|
942
|
+
SELECT 1 FROM drone_sessions AS session
|
|
943
|
+
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
944
|
+
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
945
|
+
AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
|
|
946
|
+
AND session.expires_at > ?
|
|
947
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId, now);
|
|
948
|
+
if (occupied !== undefined)
|
|
949
|
+
throw new AttachSessionRejectedError();
|
|
950
|
+
droneId = priorSeatId;
|
|
951
|
+
droneLabel = requiredText(priorSeat, "label");
|
|
952
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
539
953
|
}
|
|
540
954
|
else {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
FROM drones
|
|
544
|
-
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
545
|
-
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
546
|
-
if (priorSeat !== undefined) {
|
|
547
|
-
droneId = requiredText(priorSeat, "id");
|
|
548
|
-
droneLabel = requiredText(priorSeat, "label");
|
|
549
|
-
generation = requiredInteger(priorSeat, "attach_generation") + 1;
|
|
550
|
-
this.#database.prepare(`
|
|
551
|
-
UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
|
|
552
|
-
`).run(now, generation, droneId);
|
|
553
|
-
reattached = true;
|
|
554
|
-
}
|
|
555
|
-
else {
|
|
556
|
-
droneId = input.droneId;
|
|
557
|
-
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
558
|
-
this.#database.prepare(`
|
|
559
|
-
INSERT INTO drones (
|
|
560
|
-
id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
|
|
561
|
-
attach_generation
|
|
562
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
563
|
-
`).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now, input.retryKey);
|
|
564
|
-
reattached = false;
|
|
565
|
-
generation = 1;
|
|
566
|
-
}
|
|
567
|
-
this.#database.prepare(`
|
|
568
|
-
INSERT INTO seat_attach_bindings (
|
|
569
|
-
client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
|
|
570
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
571
|
-
`).run(this.#principal.id, input.retryKey, input.cubeId, input.roleId, droneId, input.priorDroneId ?? null, now);
|
|
572
|
-
}
|
|
573
|
-
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)
|
|
574
|
-
.map((row) => requiredText(row, "id"));
|
|
575
|
-
if (oldSessions.length > 0) {
|
|
576
|
-
const placeholders = oldSessions.map(() => "?").join(", ");
|
|
577
|
-
this.#database.prepare(`
|
|
578
|
-
UPDATE drone_session_credentials SET revoked_at = ?
|
|
579
|
-
WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
|
|
580
|
-
`).run(now, ...oldSessions);
|
|
955
|
+
droneId = input.droneId;
|
|
956
|
+
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
581
957
|
this.#database.prepare(`
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
`).run(now,
|
|
958
|
+
INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
|
|
959
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
960
|
+
`).run(droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now);
|
|
585
961
|
}
|
|
962
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
586
963
|
this.#database.prepare(`
|
|
587
964
|
INSERT INTO drone_sessions (
|
|
588
965
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -618,9 +995,7 @@ class SqliteScopedStore {
|
|
|
618
995
|
drone: { id: droneId, label: droneLabel },
|
|
619
996
|
sessionId: input.sessionId,
|
|
620
997
|
expiresAt: input.expiresAt,
|
|
621
|
-
|
|
622
|
-
reattached,
|
|
623
|
-
revokedSessionIds: oldSessions,
|
|
998
|
+
result: "created",
|
|
624
999
|
};
|
|
625
1000
|
}
|
|
626
1001
|
catch (error) {
|
|
@@ -633,7 +1008,47 @@ class SqliteScopedStore {
|
|
|
633
1008
|
}
|
|
634
1009
|
subscribeActivity(cubeId, listener) {
|
|
635
1010
|
this.#requireCube(cubeId, "read");
|
|
636
|
-
return this.#activityHub.subscribe(cubeId,
|
|
1011
|
+
return this.#activityHub.subscribe(cubeId, (entry) => {
|
|
1012
|
+
if ("kind" in entry) {
|
|
1013
|
+
if (this.#principal.kind === "drone-session" &&
|
|
1014
|
+
entry.recipient_drone_ids.includes(this.#principal.droneId))
|
|
1015
|
+
listener(entry);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId))
|
|
1019
|
+
listener(entry);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
#claimAudience(cubeId, entryId, visibility) {
|
|
1023
|
+
if (visibility === "direct") {
|
|
1024
|
+
return this.#database.prepare(`
|
|
1025
|
+
SELECT recipient.drone_id
|
|
1026
|
+
FROM activity_log_recipients AS recipient
|
|
1027
|
+
JOIN drones AS drone ON drone.id = recipient.drone_id
|
|
1028
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1029
|
+
JOIN client_cube_grants AS grant_row
|
|
1030
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1031
|
+
WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1032
|
+
AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1033
|
+
ORDER BY recipient.drone_id
|
|
1034
|
+
`).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1035
|
+
}
|
|
1036
|
+
return this.#database.prepare(`
|
|
1037
|
+
SELECT drone.id AS drone_id
|
|
1038
|
+
FROM drones AS drone
|
|
1039
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1040
|
+
JOIN client_cube_grants AS grant_row
|
|
1041
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1042
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
|
|
1043
|
+
AND grant_row.access IN ('write', 'manage')
|
|
1044
|
+
ORDER BY drone.id
|
|
1045
|
+
`).all(cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1046
|
+
}
|
|
1047
|
+
#allowsDirectedWork(cubeId) {
|
|
1048
|
+
const scope = this.#scope("write");
|
|
1049
|
+
return this.#database.prepare(`
|
|
1050
|
+
SELECT 1 AS allowed FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
1051
|
+
`).get(cubeId, ...scope.parameters) !== undefined;
|
|
637
1052
|
}
|
|
638
1053
|
#requireCube(cubeId, access) {
|
|
639
1054
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
@@ -641,8 +1056,17 @@ class SqliteScopedStore {
|
|
|
641
1056
|
const row = this.#database.prepare(`
|
|
642
1057
|
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
643
1058
|
`).get(cubeId, ...scope.parameters);
|
|
644
|
-
if (row
|
|
645
|
-
|
|
1059
|
+
if (row !== undefined)
|
|
1060
|
+
return;
|
|
1061
|
+
if (access === "manage") {
|
|
1062
|
+
const visibleScope = this.#scope("read");
|
|
1063
|
+
const visible = this.#database.prepare(`
|
|
1064
|
+
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
|
|
1065
|
+
`).get(cubeId, ...visibleScope.parameters);
|
|
1066
|
+
if (visible !== undefined)
|
|
1067
|
+
throw new AccessDeniedError();
|
|
1068
|
+
}
|
|
1069
|
+
throw new ScopedStoreError();
|
|
646
1070
|
}
|
|
647
1071
|
#nextActivityTimestamp(cubeId) {
|
|
648
1072
|
const now = this.#clock().getTime();
|
|
@@ -697,12 +1121,14 @@ class SqliteScopedStore {
|
|
|
697
1121
|
if (valid === undefined)
|
|
698
1122
|
throw new ScopedStoreError();
|
|
699
1123
|
}
|
|
700
|
-
#countAfter(cubeId, cursor) {
|
|
1124
|
+
#countAfter(cubeId, cursor, broadcastOnly) {
|
|
1125
|
+
const visibility = broadcastOnly ? "AND visibility = 'broadcast'" : "";
|
|
701
1126
|
const row = cursor === null
|
|
702
|
-
? this.#database.prepare(
|
|
1127
|
+
? this.#database.prepare(`SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ? ${visibility}`).get(cubeId)
|
|
703
1128
|
: this.#database.prepare(`
|
|
704
1129
|
SELECT COUNT(*) AS count FROM activity_log
|
|
705
1130
|
WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
|
|
1131
|
+
${visibility}
|
|
706
1132
|
`).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
|
|
707
1133
|
if (row === undefined)
|
|
708
1134
|
throw new Error("Activity count query returned no row.");
|
|
@@ -724,7 +1150,7 @@ class SqliteScopedStore {
|
|
|
724
1150
|
`).all(entryId);
|
|
725
1151
|
return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
|
|
726
1152
|
}
|
|
727
|
-
#claims(cubeId) {
|
|
1153
|
+
#claims(cubeId, broadcastOnly) {
|
|
728
1154
|
return this.#database.prepare(`
|
|
729
1155
|
SELECT acknowledgement.entry_id AS log_entry_id,
|
|
730
1156
|
acknowledgement.claimant_drone_id,
|
|
@@ -738,6 +1164,7 @@ class SqliteScopedStore {
|
|
|
738
1164
|
LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
|
|
739
1165
|
WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
|
|
740
1166
|
AND acknowledgement.claimant_drone_id IS NOT NULL
|
|
1167
|
+
AND (${broadcastOnly ? "entry.visibility = 'broadcast'" : "1 = 1"})
|
|
741
1168
|
ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
|
|
742
1169
|
acknowledgement.claimant_drone_id
|
|
743
1170
|
`).all(cubeId).map(claimRecord);
|
|
@@ -868,7 +1295,7 @@ class SqliteMaintenanceStore {
|
|
|
868
1295
|
for (const table of [
|
|
869
1296
|
"cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
|
|
870
1297
|
"decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
|
|
871
|
-
"
|
|
1298
|
+
"drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
|
|
872
1299
|
"enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
|
|
873
1300
|
"enrollment_invitations",
|
|
874
1301
|
])
|
|
@@ -1011,6 +1438,70 @@ class ActivityHub {
|
|
|
1011
1438
|
}
|
|
1012
1439
|
}
|
|
1013
1440
|
}
|
|
1441
|
+
class SqliteLivenessStore {
|
|
1442
|
+
database;
|
|
1443
|
+
clock;
|
|
1444
|
+
hub;
|
|
1445
|
+
capacityGuard;
|
|
1446
|
+
constructor(database, clock, hub, capacityGuard) {
|
|
1447
|
+
this.database = database;
|
|
1448
|
+
this.clock = clock;
|
|
1449
|
+
this.hub = hub;
|
|
1450
|
+
this.capacityGuard = capacityGuard;
|
|
1451
|
+
}
|
|
1452
|
+
scan(options = {}) {
|
|
1453
|
+
const now = this.clock();
|
|
1454
|
+
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
1455
|
+
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
1456
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
1457
|
+
const candidates = this.database.prepare(`
|
|
1458
|
+
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
1459
|
+
FROM drones AS drone
|
|
1460
|
+
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
1461
|
+
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
1462
|
+
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
1463
|
+
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
1464
|
+
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
1465
|
+
AND COALESCE(state.attempts, 0) < 3
|
|
1466
|
+
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
1467
|
+
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
1468
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
1469
|
+
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
1470
|
+
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
1471
|
+
const published = [];
|
|
1472
|
+
for (const candidate of candidates) {
|
|
1473
|
+
const droneId = requiredText(candidate, "drone_id");
|
|
1474
|
+
const cubeId = requiredText(candidate, "cube_id");
|
|
1475
|
+
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
1476
|
+
this.capacityGuard.assertCanGrow(256);
|
|
1477
|
+
const id = randomUUID();
|
|
1478
|
+
const createdAt = now.toISOString();
|
|
1479
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
1480
|
+
try {
|
|
1481
|
+
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
1482
|
+
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
1483
|
+
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
1484
|
+
`).run(droneId, now.toISOString());
|
|
1485
|
+
this.database.exec("COMMIT");
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
try {
|
|
1489
|
+
this.database.exec("ROLLBACK");
|
|
1490
|
+
}
|
|
1491
|
+
catch { /* Preserve original failure. */ }
|
|
1492
|
+
throw error;
|
|
1493
|
+
}
|
|
1494
|
+
const entry = {
|
|
1495
|
+
kind: "heartbeat_ping",
|
|
1496
|
+
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
1497
|
+
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
1498
|
+
};
|
|
1499
|
+
this.hub.publish(cubeId, entry);
|
|
1500
|
+
published.push(entry);
|
|
1501
|
+
}
|
|
1502
|
+
return published;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1014
1505
|
class SqliteCredentialStore {
|
|
1015
1506
|
#database;
|
|
1016
1507
|
#clock;
|
|
@@ -1046,6 +1537,33 @@ class SqliteCredentialStore {
|
|
|
1046
1537
|
validateTimestamp(input.expiresAt);
|
|
1047
1538
|
this.#database.exec("BEGIN IMMEDIATE");
|
|
1048
1539
|
try {
|
|
1540
|
+
let scope = null;
|
|
1541
|
+
if (input.cubeSelector !== undefined) {
|
|
1542
|
+
if (input.purpose !== "client" || input.access === undefined) {
|
|
1543
|
+
throw new Error("Only client invitations may carry a complete cube scope.");
|
|
1544
|
+
}
|
|
1545
|
+
if (input.access !== "read" && input.access !== "write" && input.access !== "manage") {
|
|
1546
|
+
throw new Error("Unknown cube access grant.");
|
|
1547
|
+
}
|
|
1548
|
+
const rows = input.cubeSelector.kind === "id"
|
|
1549
|
+
? this.#database.prepare("SELECT id, name FROM cubes WHERE id = ?").all(input.cubeSelector.value)
|
|
1550
|
+
: this.#database.prepare("SELECT id, name FROM cubes WHERE name = ? ORDER BY id").all(input.cubeSelector.value);
|
|
1551
|
+
if (rows.length === 0)
|
|
1552
|
+
throw new InvitationCubeNotFoundError();
|
|
1553
|
+
if (rows.length > 1) {
|
|
1554
|
+
throw new InvitationCubeAmbiguousError(rows.map((row) => requiredText(row, "id")));
|
|
1555
|
+
}
|
|
1556
|
+
const cubeId = requiredText(rows[0], "id");
|
|
1557
|
+
assertCanonicalUuid(cubeId, "Resolved cube id");
|
|
1558
|
+
scope = {
|
|
1559
|
+
cubeId,
|
|
1560
|
+
cubeName: requiredText(rows[0], "name"),
|
|
1561
|
+
access: input.access,
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
else if (input.access !== undefined) {
|
|
1565
|
+
throw new Error("Invitation access requires a cube selector.");
|
|
1566
|
+
}
|
|
1049
1567
|
let ownerEpoch = null;
|
|
1050
1568
|
if (input.purpose === "owner") {
|
|
1051
1569
|
const state = this.#database.prepare("SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1").get();
|
|
@@ -1070,10 +1588,12 @@ class SqliteCredentialStore {
|
|
|
1070
1588
|
}
|
|
1071
1589
|
this.#database.prepare(`
|
|
1072
1590
|
INSERT INTO enrollment_invitations (
|
|
1073
|
-
id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch
|
|
1074
|
-
|
|
1075
|
-
|
|
1591
|
+
id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch,
|
|
1592
|
+
cube_id, access
|
|
1593
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1594
|
+
`).run(input.id, input.digest.lookup, input.digest.verifier, input.expiresAt, this.#now(), input.purpose, ownerEpoch, scope?.cubeId ?? null, scope?.access ?? null);
|
|
1076
1595
|
this.#database.exec("COMMIT");
|
|
1596
|
+
return scope;
|
|
1077
1597
|
}
|
|
1078
1598
|
catch (error) {
|
|
1079
1599
|
try {
|
|
@@ -1093,7 +1613,7 @@ class SqliteCredentialStore {
|
|
|
1093
1613
|
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1094
1614
|
invitation.consumed_at, invitation.revoked_at,
|
|
1095
1615
|
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1096
|
-
invitation.owner_epoch
|
|
1616
|
+
invitation.owner_epoch, invitation.cube_id, invitation.access
|
|
1097
1617
|
FROM (SELECT 1) AS seed
|
|
1098
1618
|
LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
|
|
1099
1619
|
`).get(lookup);
|
|
@@ -1118,7 +1638,8 @@ class SqliteCredentialStore {
|
|
|
1118
1638
|
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1119
1639
|
invitation.owner_epoch,
|
|
1120
1640
|
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1121
|
-
invitation.consumed_at, invitation.revoked_at
|
|
1641
|
+
invitation.consumed_at, invitation.revoked_at,
|
|
1642
|
+
invitation.cube_id, invitation.access
|
|
1122
1643
|
FROM (SELECT 1) AS seed
|
|
1123
1644
|
LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
|
|
1124
1645
|
`).get(input.invitationId);
|
|
@@ -1166,6 +1687,12 @@ class SqliteCredentialStore {
|
|
|
1166
1687
|
const ownerEpoch = invitation["owner_epoch"] === null
|
|
1167
1688
|
? null
|
|
1168
1689
|
: requiredInteger(invitation, "owner_epoch");
|
|
1690
|
+
const cubeId = nullableText(invitation, "cube_id");
|
|
1691
|
+
const accessValue = nullableText(invitation, "access");
|
|
1692
|
+
const access = accessValue === null ? null : cubeAccess(accessValue);
|
|
1693
|
+
if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
|
|
1694
|
+
throw new Error("Invalid invitation cube scope.");
|
|
1695
|
+
}
|
|
1169
1696
|
if (purpose === "owner") {
|
|
1170
1697
|
const state = this.#database.prepare(`
|
|
1171
1698
|
SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
|
|
@@ -1176,6 +1703,10 @@ class SqliteCredentialStore {
|
|
|
1176
1703
|
return null;
|
|
1177
1704
|
}
|
|
1178
1705
|
}
|
|
1706
|
+
if (cubeId !== null && this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(cubeId) === undefined) {
|
|
1707
|
+
this.#database.exec("ROLLBACK");
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1179
1710
|
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
|
|
1180
1711
|
this.#database.prepare("INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)").run(input.clientId, input.requestedClientName ?? "Local client", now);
|
|
1181
1712
|
this.#mutationHook?.("enrollment.insert-client");
|
|
@@ -1192,6 +1723,13 @@ class SqliteCredentialStore {
|
|
|
1192
1723
|
`).run(input.clientId, now);
|
|
1193
1724
|
this.#mutationHook?.("enrollment.insert-capability");
|
|
1194
1725
|
}
|
|
1726
|
+
if (cubeId !== null && access !== null) {
|
|
1727
|
+
this.#database.prepare(`
|
|
1728
|
+
INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
|
|
1729
|
+
VALUES (?, ?, ?, ?)
|
|
1730
|
+
`).run(input.clientId, cubeId, access, now);
|
|
1731
|
+
this.#mutationHook?.("enrollment.insert-grant");
|
|
1732
|
+
}
|
|
1195
1733
|
this.#database.prepare(`
|
|
1196
1734
|
INSERT INTO enrollment_claims (
|
|
1197
1735
|
invitation_id, retry_key, client_id, requested_client_name,
|
|
@@ -1255,9 +1793,8 @@ class SqliteCredentialStore {
|
|
|
1255
1793
|
SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
|
|
1256
1794
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1257
1795
|
session.expires_at,
|
|
1258
|
-
COALESCE(
|
|
1259
|
-
|
|
1260
|
-
) AS revoked_at
|
|
1796
|
+
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
1797
|
+
drone.evicted_at
|
|
1261
1798
|
FROM drone_session_credentials AS credential
|
|
1262
1799
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1263
1800
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -1268,6 +1805,20 @@ class SqliteCredentialStore {
|
|
|
1268
1805
|
`).get(lookup);
|
|
1269
1806
|
return row === undefined ? null : storedDroneSessionDigest(row);
|
|
1270
1807
|
}
|
|
1808
|
+
findActiveDroneSessionExpiry(sessionId) {
|
|
1809
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
1810
|
+
const row = this.#database.prepare(`
|
|
1811
|
+
SELECT session.expires_at
|
|
1812
|
+
FROM drone_sessions AS session
|
|
1813
|
+
JOIN clients AS client ON client.id = session.client_id
|
|
1814
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
1815
|
+
AND drone.client_id = session.client_id
|
|
1816
|
+
AND drone.cube_id = session.cube_id
|
|
1817
|
+
WHERE session.id = ? AND session.revoked_at IS NULL
|
|
1818
|
+
AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
|
|
1819
|
+
`).get(sessionId);
|
|
1820
|
+
return row === undefined ? null : requiredText(row, "expires_at");
|
|
1821
|
+
}
|
|
1271
1822
|
rotateClientCredential(input) {
|
|
1272
1823
|
assertCanonicalUuid(input.clientId, "Client id");
|
|
1273
1824
|
assertCanonicalUuid(input.credentialId, "Credential id");
|
|
@@ -1283,6 +1834,16 @@ class SqliteCredentialStore {
|
|
|
1283
1834
|
this.#database.prepare(`
|
|
1284
1835
|
UPDATE client_credentials SET revoked_at = ?
|
|
1285
1836
|
WHERE client_id = ? AND revoked_at IS NULL
|
|
1837
|
+
`).run(now, input.clientId);
|
|
1838
|
+
this.#database.prepare(`
|
|
1839
|
+
UPDATE drone_session_credentials SET revoked_at = ?
|
|
1840
|
+
WHERE revoked_at IS NULL AND session_id IN (
|
|
1841
|
+
SELECT id FROM drone_sessions WHERE client_id = ?
|
|
1842
|
+
)
|
|
1843
|
+
`).run(now, input.clientId);
|
|
1844
|
+
this.#database.prepare(`
|
|
1845
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
1846
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
1286
1847
|
`).run(now, input.clientId);
|
|
1287
1848
|
this.#database.prepare(`
|
|
1288
1849
|
INSERT INTO client_credentials (
|
|
@@ -1332,9 +1893,7 @@ async function prepareDatabasePath(path) {
|
|
|
1332
1893
|
if (path === ":memory:")
|
|
1333
1894
|
throw new Error("The server store requires a file-backed database.");
|
|
1334
1895
|
const databasePath = resolve(path);
|
|
1335
|
-
const directory = dirname(databasePath);
|
|
1336
|
-
await ensureDirectoryTree(directory);
|
|
1337
|
-
await chmod(directory, 0o700);
|
|
1896
|
+
const directory = await preparePrivateDataDirectory(dirname(databasePath));
|
|
1338
1897
|
try {
|
|
1339
1898
|
const handle = await open(databasePath, "ax", 0o600);
|
|
1340
1899
|
await handle.close();
|
|
@@ -1352,6 +1911,12 @@ async function prepareDatabasePath(path) {
|
|
|
1352
1911
|
await chmod(databasePath, 0o600);
|
|
1353
1912
|
return databasePath;
|
|
1354
1913
|
}
|
|
1914
|
+
export async function preparePrivateDataDirectory(path) {
|
|
1915
|
+
const directory = resolve(path);
|
|
1916
|
+
await ensureDirectoryTree(directory);
|
|
1917
|
+
await chmod(directory, 0o700);
|
|
1918
|
+
return directory;
|
|
1919
|
+
}
|
|
1355
1920
|
async function ensureDirectoryTree(directory) {
|
|
1356
1921
|
const { root } = parse(directory);
|
|
1357
1922
|
let current = root;
|
|
@@ -1405,6 +1970,18 @@ function configureDatabase(database) {
|
|
|
1405
1970
|
throw new Error("SQLite WAL mode is required.");
|
|
1406
1971
|
}
|
|
1407
1972
|
}
|
|
1973
|
+
function configureExistingDatabase(database) {
|
|
1974
|
+
database.exec(`
|
|
1975
|
+
PRAGMA foreign_keys = ON;
|
|
1976
|
+
PRAGMA synchronous = FULL;
|
|
1977
|
+
PRAGMA trusted_schema = OFF;
|
|
1978
|
+
PRAGMA secure_delete = ON;
|
|
1979
|
+
PRAGMA busy_timeout = 5000;
|
|
1980
|
+
`);
|
|
1981
|
+
if (readPragma(database, "journal_mode") !== "wal") {
|
|
1982
|
+
throw new Error("SQLite WAL mode is required.");
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1408
1985
|
function diagnostics(database) {
|
|
1409
1986
|
const journalMode = readPragma(database, "journal_mode");
|
|
1410
1987
|
const foreignKeys = readPragma(database, "foreign_keys");
|
|
@@ -1425,6 +2002,7 @@ function cubeRecord(row) {
|
|
|
1425
2002
|
ownerId: row.owner_id,
|
|
1426
2003
|
name: row.name,
|
|
1427
2004
|
directive: row.directive,
|
|
2005
|
+
messageTaxonomy: parseTaxonomy(row.message_taxonomy),
|
|
1428
2006
|
createdAt: row.created_at,
|
|
1429
2007
|
updatedAt: row.updated_at,
|
|
1430
2008
|
};
|
|
@@ -1446,6 +2024,7 @@ function cubeRow(row) {
|
|
|
1446
2024
|
owner_id: requiredText(row, "owner_id"),
|
|
1447
2025
|
name: requiredText(row, "name"),
|
|
1448
2026
|
directive: requiredText(row, "directive"),
|
|
2027
|
+
message_taxonomy: nullableText(row, "message_taxonomy"),
|
|
1449
2028
|
created_at: requiredText(row, "created_at"),
|
|
1450
2029
|
updated_at: requiredText(row, "updated_at"),
|
|
1451
2030
|
};
|
|
@@ -1523,8 +2102,12 @@ function roleRecord(row) {
|
|
|
1523
2102
|
cube_id: requiredText(row, "cube_id"),
|
|
1524
2103
|
name: requiredText(row, "name"),
|
|
1525
2104
|
short_description: requiredText(row, "short_description"),
|
|
2105
|
+
detailed_description: requiredText(row, "detailed_description"),
|
|
1526
2106
|
is_default: requiredInteger(row, "is_default") === 1,
|
|
2107
|
+
is_mandatory: requiredInteger(row, "is_mandatory") === 1,
|
|
1527
2108
|
is_human_seat: requiredInteger(row, "is_human_seat") === 1,
|
|
2109
|
+
can_broadcast: requiredInteger(row, "can_broadcast") === 1,
|
|
2110
|
+
receives_all_direct: requiredInteger(row, "receives_all_direct") === 1,
|
|
1528
2111
|
role_class: roleClass,
|
|
1529
2112
|
created_at: requiredText(row, "created_at"),
|
|
1530
2113
|
};
|
|
@@ -1538,6 +2121,10 @@ function createCubeRecord(row) {
|
|
|
1538
2121
|
};
|
|
1539
2122
|
}
|
|
1540
2123
|
function droneRecord(row) {
|
|
2124
|
+
const posture = requiredText(row, "posture");
|
|
2125
|
+
if (posture !== "observer" && posture !== "participant") {
|
|
2126
|
+
throw new Error("Database contains invalid drone posture.");
|
|
2127
|
+
}
|
|
1541
2128
|
return {
|
|
1542
2129
|
id: requiredText(row, "id"),
|
|
1543
2130
|
cube_id: requiredText(row, "cube_id"),
|
|
@@ -1545,6 +2132,7 @@ function droneRecord(row) {
|
|
|
1545
2132
|
label: requiredText(row, "label"),
|
|
1546
2133
|
last_seen: requiredText(row, "last_seen"),
|
|
1547
2134
|
hostname: nullableText(row, "hostname"),
|
|
2135
|
+
posture,
|
|
1548
2136
|
created_at: requiredText(row, "created_at"),
|
|
1549
2137
|
};
|
|
1550
2138
|
}
|
|
@@ -1591,7 +2179,19 @@ function storedInvitationDigest(row) {
|
|
|
1591
2179
|
}
|
|
1592
2180
|
const epochValue = row["owner_epoch"];
|
|
1593
2181
|
const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
|
|
1594
|
-
|
|
2182
|
+
const cubeId = nullableText(row, "cube_id");
|
|
2183
|
+
const accessValue = nullableText(row, "access");
|
|
2184
|
+
const access = accessValue === null ? null : cubeAccess(accessValue);
|
|
2185
|
+
if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
|
|
2186
|
+
throw new Error("Database contains invalid invitation cube scope.");
|
|
2187
|
+
}
|
|
2188
|
+
return { ...digest, purpose, ownerEpoch, cubeId, access };
|
|
2189
|
+
}
|
|
2190
|
+
function cubeAccess(value) {
|
|
2191
|
+
if (value !== "read" && value !== "write" && value !== "manage") {
|
|
2192
|
+
throw new Error("Database contains invalid cube access.");
|
|
2193
|
+
}
|
|
2194
|
+
return value;
|
|
1595
2195
|
}
|
|
1596
2196
|
function enrollmentClaimResult(purpose, clientId) {
|
|
1597
2197
|
return purpose === "owner"
|
|
@@ -1624,6 +2224,7 @@ function storedDroneSessionDigest(row) {
|
|
|
1624
2224
|
cubeId: requiredText(row, "cube_id"),
|
|
1625
2225
|
droneId: requiredText(row, "drone_id"),
|
|
1626
2226
|
expiresAt: requiredText(row, "expires_at"),
|
|
2227
|
+
evictedAt: nullableText(row, "evicted_at"),
|
|
1627
2228
|
};
|
|
1628
2229
|
}
|
|
1629
2230
|
function requiredBuffer(row, key) {
|
|
@@ -1659,6 +2260,30 @@ function validatePresentationName(value) {
|
|
|
1659
2260
|
throw new Error("Presentation name is invalid.");
|
|
1660
2261
|
}
|
|
1661
2262
|
}
|
|
2263
|
+
function validateRoleName(value) {
|
|
2264
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 64) {
|
|
2265
|
+
throw new Error("Role name must contain 1 to 64 characters.");
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
function validateRoleShortDescription(value) {
|
|
2269
|
+
if (typeof value !== "string" || value.length > 1_024) {
|
|
2270
|
+
throw new Error("Role short description must contain at most 1024 characters.");
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
export const MAX_ROLE_DETAILED_DESCRIPTION_CHARS = 51_200;
|
|
2274
|
+
export function assertRoleTextWriteAllowed(value, previous) {
|
|
2275
|
+
if (typeof value !== "string")
|
|
2276
|
+
throw new TypeError("Role detailed description must be text.");
|
|
2277
|
+
if (value.length <= MAX_ROLE_DETAILED_DESCRIPTION_CHARS)
|
|
2278
|
+
return;
|
|
2279
|
+
if (previous !== undefined && previous.length > MAX_ROLE_DETAILED_DESCRIPTION_CHARS &&
|
|
2280
|
+
value.length < previous.length)
|
|
2281
|
+
return;
|
|
2282
|
+
throw new RangeError("Role detailed description is too large.");
|
|
2283
|
+
}
|
|
2284
|
+
function booleanInteger(value) {
|
|
2285
|
+
return value ? 1 : 0;
|
|
2286
|
+
}
|
|
1662
2287
|
function validateBoundedText(value, name, maxBytes) {
|
|
1663
2288
|
if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
|
|
1664
2289
|
throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);
|
|
@@ -1678,6 +2303,33 @@ function validateDirective(value) {
|
|
|
1678
2303
|
throw new Error("Cube directive exceeds 100000 bytes.");
|
|
1679
2304
|
}
|
|
1680
2305
|
}
|
|
2306
|
+
function activityPreview(message) {
|
|
2307
|
+
return message.replace(/\s+/gu, " ").trim().slice(0, 200);
|
|
2308
|
+
}
|
|
2309
|
+
function validateRoleClass(value) {
|
|
2310
|
+
if (value !== undefined && value !== "queen" && value !== "worker") {
|
|
2311
|
+
throw new TypeError("Role class must be queen or worker.");
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
function serializeTaxonomy(value) {
|
|
2315
|
+
if (value === null)
|
|
2316
|
+
return null;
|
|
2317
|
+
const serialized = JSON.stringify(value);
|
|
2318
|
+
if (Buffer.byteLength(serialized) > 100_000) {
|
|
2319
|
+
throw new RangeError("Message taxonomy exceeds 100000 bytes.");
|
|
2320
|
+
}
|
|
2321
|
+
return serialized;
|
|
2322
|
+
}
|
|
2323
|
+
function parseTaxonomy(value) {
|
|
2324
|
+
if (value === null)
|
|
2325
|
+
return null;
|
|
2326
|
+
try {
|
|
2327
|
+
return validateMessageTaxonomy(JSON.parse(value));
|
|
2328
|
+
}
|
|
2329
|
+
catch {
|
|
2330
|
+
throw new Error("Database contains invalid message taxonomy.");
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
1681
2333
|
function validateDigest(digest) {
|
|
1682
2334
|
validateLookup(digest.lookup);
|
|
1683
2335
|
if (digest.verifier.length !== 32)
|