borgmcp-server 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -22
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +232 -18
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +10 -2
- package/dist/credentials.js +44 -6
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +77 -0
- package/dist/debug-log.js +125 -0
- package/dist/debug-log.js.map +1 -0
- package/dist/https-server.d.ts +3 -0
- package/dist/https-server.js +80 -4
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +71 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +10 -3
- package/dist/service.js +218 -15
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +65 -1
- package/dist/store.js +398 -26
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +236 -14
- package/src/credentials.ts +71 -5
- package/src/debug-log.ts +165 -0
- package/src/https-server.ts +75 -2
- package/src/index.ts +1 -1
- package/src/migrations.ts +74 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +239 -18
- package/src/start-options.ts +21 -4
- package/src/store.ts +462 -28
package/dist/store.js
CHANGED
|
@@ -3,9 +3,10 @@ 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";
|
|
9
10
|
export const DEFAULT_CUBE_LIMITS = Object.freeze({
|
|
10
11
|
maxCubesPerClient: 100,
|
|
11
12
|
maxCubesTotal: 1_000,
|
|
@@ -15,6 +16,15 @@ export const DEFAULT_STORAGE_LIMITS = Object.freeze({
|
|
|
15
16
|
maxDatabaseBytes: 1_073_741_824,
|
|
16
17
|
minFreeDiskBytes: 67_108_864,
|
|
17
18
|
});
|
|
19
|
+
export class InvitationCubeNotFoundError extends Error {
|
|
20
|
+
}
|
|
21
|
+
export class InvitationCubeAmbiguousError extends Error {
|
|
22
|
+
candidateIds;
|
|
23
|
+
constructor(candidateIds) {
|
|
24
|
+
super("Cube name is ambiguous.");
|
|
25
|
+
this.candidateIds = Object.freeze([...candidateIds]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
18
28
|
export class ScopedStoreError extends Error {
|
|
19
29
|
code = "NOT_FOUND";
|
|
20
30
|
constructor() {
|
|
@@ -22,6 +32,27 @@ export class ScopedStoreError extends Error {
|
|
|
22
32
|
this.name = "ScopedStoreError";
|
|
23
33
|
}
|
|
24
34
|
}
|
|
35
|
+
export class RoleConflictError extends Error {
|
|
36
|
+
code = "ROLE_ALREADY_EXISTS";
|
|
37
|
+
constructor() {
|
|
38
|
+
super("A role with that name already exists.");
|
|
39
|
+
this.name = "RoleConflictError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class DefaultRoleRequiredError extends Error {
|
|
43
|
+
code = "DEFAULT_ROLE_REQUIRED";
|
|
44
|
+
constructor() {
|
|
45
|
+
super("A cube must retain one default role.");
|
|
46
|
+
this.name = "DefaultRoleRequiredError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class RoleSectionConflictError extends Error {
|
|
50
|
+
code = "ROLE_SECTION_CONFLICT";
|
|
51
|
+
constructor() {
|
|
52
|
+
super("The role section patch conflicts with the current role text.");
|
|
53
|
+
this.name = "RoleSectionConflictError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
25
56
|
export class CursorExpiredError extends Error {
|
|
26
57
|
code = "CURSOR_EXPIRED";
|
|
27
58
|
constructor() {
|
|
@@ -100,8 +131,14 @@ export async function openStore(options) {
|
|
|
100
131
|
});
|
|
101
132
|
const clock = options.clock ?? (() => new Date());
|
|
102
133
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
if (options.migrationMode === "require-current") {
|
|
135
|
+
configureExistingDatabase(database);
|
|
136
|
+
assertMigrationsCurrent(database);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
configureDatabase(database);
|
|
140
|
+
applyMigrations(database);
|
|
141
|
+
}
|
|
105
142
|
}
|
|
106
143
|
catch (error) {
|
|
107
144
|
database.close();
|
|
@@ -293,18 +330,220 @@ class SqliteScopedStore {
|
|
|
293
330
|
listRoles(cubeId) {
|
|
294
331
|
this.#requireCube(cubeId, "read");
|
|
295
332
|
const rows = this.#database.prepare(`
|
|
296
|
-
SELECT id, cube_id, name, short_description,
|
|
297
|
-
|
|
333
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
334
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
335
|
+
receives_all_direct, role_class, created_at
|
|
298
336
|
FROM roles WHERE cube_id = ? ORDER BY name, id
|
|
299
337
|
`).all(cubeId);
|
|
300
338
|
return rows.map(roleRecord);
|
|
301
339
|
}
|
|
340
|
+
createRole(cubeId, input) {
|
|
341
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
342
|
+
validateRoleName(input.name);
|
|
343
|
+
const shortDescription = input.shortDescription ?? "";
|
|
344
|
+
const detailedDescription = input.detailedDescription ?? "";
|
|
345
|
+
validateRoleShortDescription(shortDescription);
|
|
346
|
+
assertRoleTextWriteAllowed(detailedDescription);
|
|
347
|
+
for (const value of [
|
|
348
|
+
input.isDefault,
|
|
349
|
+
input.isMandatory,
|
|
350
|
+
input.isHumanSeat,
|
|
351
|
+
input.canBroadcast,
|
|
352
|
+
input.receivesAllDirect,
|
|
353
|
+
]) {
|
|
354
|
+
if (value !== undefined && typeof value !== "boolean")
|
|
355
|
+
throw new TypeError("Role flags must be boolean.");
|
|
356
|
+
}
|
|
357
|
+
const isDefault = input.isDefault ?? false;
|
|
358
|
+
const isMandatory = input.isMandatory ?? false;
|
|
359
|
+
const isHumanSeat = input.isHumanSeat ?? false;
|
|
360
|
+
const canBroadcast = input.canBroadcast ?? false;
|
|
361
|
+
const receivesAllDirect = input.receivesAllDirect ?? false;
|
|
362
|
+
this.#requireCube(cubeId, "manage");
|
|
363
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name) + Buffer.byteLength(shortDescription) +
|
|
364
|
+
Buffer.byteLength(detailedDescription) + 8_192);
|
|
365
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
366
|
+
try {
|
|
367
|
+
this.#requireCube(cubeId, "manage");
|
|
368
|
+
const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ?").get(cubeId, input.name);
|
|
369
|
+
if (duplicate !== undefined)
|
|
370
|
+
throw new RoleConflictError();
|
|
371
|
+
if (isDefault) {
|
|
372
|
+
this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
|
|
373
|
+
.run(cubeId);
|
|
374
|
+
this.#mutationHook?.("role.demote-default");
|
|
375
|
+
}
|
|
376
|
+
const id = randomUUID();
|
|
377
|
+
this.#database.prepare(`
|
|
378
|
+
INSERT INTO roles (
|
|
379
|
+
id, cube_id, name, short_description, detailed_description,
|
|
380
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
381
|
+
receives_all_direct, role_class, created_at
|
|
382
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'worker', ?)
|
|
383
|
+
`).run(id, cubeId, input.name, shortDescription, detailedDescription, booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat), booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), this.#now());
|
|
384
|
+
this.#mutationHook?.("role.insert");
|
|
385
|
+
const row = this.#database.prepare(`
|
|
386
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
387
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
388
|
+
receives_all_direct, role_class, created_at
|
|
389
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
390
|
+
`).get(id, cubeId);
|
|
391
|
+
if (row === undefined)
|
|
392
|
+
throw new ScopedStoreError();
|
|
393
|
+
this.#database.exec("COMMIT");
|
|
394
|
+
this.#mutationHook?.("role.after-commit");
|
|
395
|
+
return roleRecord(row);
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
try {
|
|
399
|
+
this.#database.exec("ROLLBACK");
|
|
400
|
+
}
|
|
401
|
+
catch { /* Preserve the original failure. */ }
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
updateRole(cubeId, roleId, input) {
|
|
406
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
407
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
408
|
+
if (Object.values(input).every((value) => value === undefined)) {
|
|
409
|
+
throw new TypeError("At least one role field is required.");
|
|
410
|
+
}
|
|
411
|
+
if (input.name !== undefined)
|
|
412
|
+
validateRoleName(input.name);
|
|
413
|
+
if (input.shortDescription !== undefined)
|
|
414
|
+
validateRoleShortDescription(input.shortDescription);
|
|
415
|
+
if (input.detailedDescription !== undefined && typeof input.detailedDescription !== "string") {
|
|
416
|
+
throw new TypeError("Role detailed description must be text.");
|
|
417
|
+
}
|
|
418
|
+
for (const value of [
|
|
419
|
+
input.isDefault,
|
|
420
|
+
input.isMandatory,
|
|
421
|
+
input.isHumanSeat,
|
|
422
|
+
input.canBroadcast,
|
|
423
|
+
input.receivesAllDirect,
|
|
424
|
+
]) {
|
|
425
|
+
if (value !== undefined && typeof value !== "boolean")
|
|
426
|
+
throw new TypeError("Role flags must be boolean.");
|
|
427
|
+
}
|
|
428
|
+
this.#requireCube(cubeId, "manage");
|
|
429
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
|
|
430
|
+
Buffer.byteLength(input.detailedDescription ?? "") + 8_192);
|
|
431
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
432
|
+
try {
|
|
433
|
+
this.#requireCube(cubeId, "manage");
|
|
434
|
+
const existingRow = this.#database.prepare(`
|
|
435
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
436
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
437
|
+
receives_all_direct, role_class, created_at
|
|
438
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
439
|
+
`).get(roleId, cubeId);
|
|
440
|
+
if (existingRow === undefined)
|
|
441
|
+
throw new ScopedStoreError();
|
|
442
|
+
const existing = roleRecord(existingRow);
|
|
443
|
+
if (input.isDefault === false && existing.is_default) {
|
|
444
|
+
throw new DefaultRoleRequiredError();
|
|
445
|
+
}
|
|
446
|
+
if (input.name !== undefined && input.name !== existing.name) {
|
|
447
|
+
const duplicate = this.#database.prepare("SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ? AND id <> ?").get(cubeId, input.name, roleId);
|
|
448
|
+
if (duplicate !== undefined)
|
|
449
|
+
throw new RoleConflictError();
|
|
450
|
+
}
|
|
451
|
+
if (input.isDefault === true && !existing.is_default) {
|
|
452
|
+
this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
|
|
453
|
+
.run(cubeId);
|
|
454
|
+
this.#mutationHook?.("role.demote-default");
|
|
455
|
+
}
|
|
456
|
+
const nextDetailedDescription = input.detailedDescription ?? existing.detailed_description;
|
|
457
|
+
assertRoleTextWriteAllowed(nextDetailedDescription, existing.detailed_description);
|
|
458
|
+
this.#database.prepare(`
|
|
459
|
+
UPDATE roles SET
|
|
460
|
+
name = ?, short_description = ?, detailed_description = ?, is_default = ?,
|
|
461
|
+
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?
|
|
462
|
+
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);
|
|
464
|
+
const row = this.#database.prepare(`
|
|
465
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
466
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
467
|
+
receives_all_direct, role_class, created_at
|
|
468
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
469
|
+
`).get(roleId, cubeId);
|
|
470
|
+
if (row === undefined)
|
|
471
|
+
throw new ScopedStoreError();
|
|
472
|
+
this.#database.exec("COMMIT");
|
|
473
|
+
this.#mutationHook?.("role.after-commit");
|
|
474
|
+
return roleRecord(row);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
try {
|
|
478
|
+
this.#database.exec("ROLLBACK");
|
|
479
|
+
}
|
|
480
|
+
catch { /* Preserve the original failure. */ }
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
patchRoleSection(cubeId, roleId, input) {
|
|
485
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
486
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
487
|
+
this.#requireCube(cubeId, "manage");
|
|
488
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.heading) +
|
|
489
|
+
("body" in input ? Buffer.byteLength(input.body) : 0) + 8_192);
|
|
490
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
491
|
+
try {
|
|
492
|
+
this.#requireCube(cubeId, "manage");
|
|
493
|
+
const existingRow = this.#database.prepare(`
|
|
494
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
495
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
496
|
+
receives_all_direct, role_class, created_at
|
|
497
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
498
|
+
`).get(roleId, cubeId);
|
|
499
|
+
if (existingRow === undefined)
|
|
500
|
+
throw new ScopedStoreError();
|
|
501
|
+
const existing = roleRecord(existingRow);
|
|
502
|
+
let detailedDescription;
|
|
503
|
+
try {
|
|
504
|
+
detailedDescription = patchRoleSectionText(existing.detailed_description, input);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
if (error instanceof TypeError)
|
|
508
|
+
throw error;
|
|
509
|
+
throw new RoleSectionConflictError();
|
|
510
|
+
}
|
|
511
|
+
assertRoleTextWriteAllowed(detailedDescription, existing.detailed_description);
|
|
512
|
+
this.#database.prepare("UPDATE roles SET detailed_description = ? WHERE id = ? AND cube_id = ?").run(detailedDescription, roleId, cubeId);
|
|
513
|
+
const row = this.#database.prepare(`
|
|
514
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
515
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
516
|
+
receives_all_direct, role_class, created_at
|
|
517
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
518
|
+
`).get(roleId, cubeId);
|
|
519
|
+
if (row === undefined)
|
|
520
|
+
throw new ScopedStoreError();
|
|
521
|
+
this.#database.exec("COMMIT");
|
|
522
|
+
this.#mutationHook?.("role.after-commit");
|
|
523
|
+
return roleRecord(row);
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
try {
|
|
527
|
+
this.#database.exec("ROLLBACK");
|
|
528
|
+
}
|
|
529
|
+
catch { /* Preserve the original failure. */ }
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
302
533
|
listDrones(cubeId) {
|
|
303
534
|
this.#requireCube(cubeId, "read");
|
|
304
535
|
const rows = this.#database.prepare(`
|
|
305
|
-
SELECT id, cube_id, role_id, label,
|
|
306
|
-
|
|
307
|
-
|
|
536
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
537
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
538
|
+
drone.hostname, drone.created_at,
|
|
539
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
540
|
+
THEN 'participant' ELSE 'observer' END AS posture
|
|
541
|
+
FROM drones AS drone
|
|
542
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
543
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
544
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
545
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
546
|
+
ORDER BY drone.label, drone.id
|
|
308
547
|
`).all(cubeId);
|
|
309
548
|
return rows.map(droneRecord);
|
|
310
549
|
}
|
|
@@ -355,9 +594,16 @@ class SqliteScopedStore {
|
|
|
355
594
|
throw new ScopedStoreError();
|
|
356
595
|
if (recipients.length > 0) {
|
|
357
596
|
const valid = this.#database.prepare(`
|
|
358
|
-
SELECT COUNT(*) AS count
|
|
359
|
-
|
|
360
|
-
|
|
597
|
+
SELECT COUNT(*) AS count
|
|
598
|
+
FROM drones AS recipient
|
|
599
|
+
JOIN clients AS recipient_client ON recipient_client.id = recipient.client_id
|
|
600
|
+
JOIN client_cube_grants AS recipient_grant
|
|
601
|
+
ON recipient_grant.client_id = recipient.client_id
|
|
602
|
+
AND recipient_grant.cube_id = recipient.cube_id
|
|
603
|
+
WHERE recipient.cube_id = ? AND recipient.evicted_at IS NULL
|
|
604
|
+
AND recipient_client.revoked_at IS NULL
|
|
605
|
+
AND recipient_grant.access IN ('write', 'manage')
|
|
606
|
+
AND recipient.id IN (${recipients.map(() => "?").join(", ")})
|
|
361
607
|
`).get(cubeId, ...recipients);
|
|
362
608
|
if (valid === undefined)
|
|
363
609
|
throw new Error("Recipient count query returned no row.");
|
|
@@ -387,6 +633,7 @@ class SqliteScopedStore {
|
|
|
387
633
|
throw new Error("Activity read limit must be an integer from 1 to 500.");
|
|
388
634
|
}
|
|
389
635
|
this.#validateCursor(cubeId, cursor);
|
|
636
|
+
const broadcastOnly = !this.#allowsDirectedWork(cubeId);
|
|
390
637
|
const cursorSql = cursor === null
|
|
391
638
|
? { sql: "1 = 1", parameters: [] }
|
|
392
639
|
: {
|
|
@@ -397,6 +644,7 @@ class SqliteScopedStore {
|
|
|
397
644
|
SELECT l.id
|
|
398
645
|
FROM activity_log AS l
|
|
399
646
|
WHERE l.cube_id = ? AND ${cursorSql.sql}
|
|
647
|
+
AND (${broadcastOnly ? "l.visibility = 'broadcast'" : "1 = 1"})
|
|
400
648
|
ORDER BY l.created_at, l.id
|
|
401
649
|
LIMIT ?
|
|
402
650
|
`).all(cubeId, ...cursorSql.parameters, limit + 1);
|
|
@@ -405,13 +653,15 @@ class SqliteScopedStore {
|
|
|
405
653
|
const nextCursor = entries.length === 0
|
|
406
654
|
? cursor
|
|
407
655
|
: { id: entries.at(-1).id, created_at: entries.at(-1).created_at };
|
|
408
|
-
const behind = nextCursor === null
|
|
656
|
+
const behind = nextCursor === null
|
|
657
|
+
? this.#countAfter(cubeId, null, broadcastOnly)
|
|
658
|
+
: this.#countAfter(cubeId, nextCursor, broadcastOnly);
|
|
409
659
|
return {
|
|
410
660
|
entries,
|
|
411
661
|
cursor: nextCursor,
|
|
412
662
|
behind_by: behind,
|
|
413
663
|
has_more: behind > 0,
|
|
414
|
-
claims: this.#claims(cubeId),
|
|
664
|
+
claims: this.#claims(cubeId, broadcastOnly),
|
|
415
665
|
};
|
|
416
666
|
}
|
|
417
667
|
acknowledge(cubeId, entryId, kind) {
|
|
@@ -508,6 +758,7 @@ class SqliteScopedStore {
|
|
|
508
758
|
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
509
759
|
if (roleRow === undefined)
|
|
510
760
|
throw new ScopedStoreError();
|
|
761
|
+
const posture = this.#allowsDirectedWork(input.cubeId) ? "participant" : "observer";
|
|
511
762
|
const retryBinding = this.#database.prepare(`
|
|
512
763
|
SELECT binding.cube_id AS binding_cube_id,
|
|
513
764
|
binding.requested_role_id AS binding_requested_role_id,
|
|
@@ -619,6 +870,7 @@ class SqliteScopedStore {
|
|
|
619
870
|
sessionId: input.sessionId,
|
|
620
871
|
expiresAt: input.expiresAt,
|
|
621
872
|
generation,
|
|
873
|
+
posture,
|
|
622
874
|
reattached,
|
|
623
875
|
revokedSessionIds: oldSessions,
|
|
624
876
|
};
|
|
@@ -633,7 +885,16 @@ class SqliteScopedStore {
|
|
|
633
885
|
}
|
|
634
886
|
subscribeActivity(cubeId, listener) {
|
|
635
887
|
this.#requireCube(cubeId, "read");
|
|
636
|
-
return this.#activityHub.subscribe(cubeId,
|
|
888
|
+
return this.#activityHub.subscribe(cubeId, (entry) => {
|
|
889
|
+
if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId))
|
|
890
|
+
listener(entry);
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
#allowsDirectedWork(cubeId) {
|
|
894
|
+
const scope = this.#scope("write");
|
|
895
|
+
return this.#database.prepare(`
|
|
896
|
+
SELECT 1 AS allowed FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
897
|
+
`).get(cubeId, ...scope.parameters) !== undefined;
|
|
637
898
|
}
|
|
638
899
|
#requireCube(cubeId, access) {
|
|
639
900
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
@@ -697,12 +958,14 @@ class SqliteScopedStore {
|
|
|
697
958
|
if (valid === undefined)
|
|
698
959
|
throw new ScopedStoreError();
|
|
699
960
|
}
|
|
700
|
-
#countAfter(cubeId, cursor) {
|
|
961
|
+
#countAfter(cubeId, cursor, broadcastOnly) {
|
|
962
|
+
const visibility = broadcastOnly ? "AND visibility = 'broadcast'" : "";
|
|
701
963
|
const row = cursor === null
|
|
702
|
-
? this.#database.prepare(
|
|
964
|
+
? this.#database.prepare(`SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ? ${visibility}`).get(cubeId)
|
|
703
965
|
: this.#database.prepare(`
|
|
704
966
|
SELECT COUNT(*) AS count FROM activity_log
|
|
705
967
|
WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
|
|
968
|
+
${visibility}
|
|
706
969
|
`).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
|
|
707
970
|
if (row === undefined)
|
|
708
971
|
throw new Error("Activity count query returned no row.");
|
|
@@ -724,7 +987,7 @@ class SqliteScopedStore {
|
|
|
724
987
|
`).all(entryId);
|
|
725
988
|
return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
|
|
726
989
|
}
|
|
727
|
-
#claims(cubeId) {
|
|
990
|
+
#claims(cubeId, broadcastOnly) {
|
|
728
991
|
return this.#database.prepare(`
|
|
729
992
|
SELECT acknowledgement.entry_id AS log_entry_id,
|
|
730
993
|
acknowledgement.claimant_drone_id,
|
|
@@ -738,6 +1001,7 @@ class SqliteScopedStore {
|
|
|
738
1001
|
LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
|
|
739
1002
|
WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
|
|
740
1003
|
AND acknowledgement.claimant_drone_id IS NOT NULL
|
|
1004
|
+
AND (${broadcastOnly ? "entry.visibility = 'broadcast'" : "1 = 1"})
|
|
741
1005
|
ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
|
|
742
1006
|
acknowledgement.claimant_drone_id
|
|
743
1007
|
`).all(cubeId).map(claimRecord);
|
|
@@ -1046,6 +1310,33 @@ class SqliteCredentialStore {
|
|
|
1046
1310
|
validateTimestamp(input.expiresAt);
|
|
1047
1311
|
this.#database.exec("BEGIN IMMEDIATE");
|
|
1048
1312
|
try {
|
|
1313
|
+
let scope = null;
|
|
1314
|
+
if (input.cubeSelector !== undefined) {
|
|
1315
|
+
if (input.purpose !== "client" || input.access === undefined) {
|
|
1316
|
+
throw new Error("Only client invitations may carry a complete cube scope.");
|
|
1317
|
+
}
|
|
1318
|
+
if (input.access !== "read" && input.access !== "write" && input.access !== "manage") {
|
|
1319
|
+
throw new Error("Unknown cube access grant.");
|
|
1320
|
+
}
|
|
1321
|
+
const rows = input.cubeSelector.kind === "id"
|
|
1322
|
+
? this.#database.prepare("SELECT id, name FROM cubes WHERE id = ?").all(input.cubeSelector.value)
|
|
1323
|
+
: this.#database.prepare("SELECT id, name FROM cubes WHERE name = ? ORDER BY id").all(input.cubeSelector.value);
|
|
1324
|
+
if (rows.length === 0)
|
|
1325
|
+
throw new InvitationCubeNotFoundError();
|
|
1326
|
+
if (rows.length > 1) {
|
|
1327
|
+
throw new InvitationCubeAmbiguousError(rows.map((row) => requiredText(row, "id")));
|
|
1328
|
+
}
|
|
1329
|
+
const cubeId = requiredText(rows[0], "id");
|
|
1330
|
+
assertCanonicalUuid(cubeId, "Resolved cube id");
|
|
1331
|
+
scope = {
|
|
1332
|
+
cubeId,
|
|
1333
|
+
cubeName: requiredText(rows[0], "name"),
|
|
1334
|
+
access: input.access,
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
else if (input.access !== undefined) {
|
|
1338
|
+
throw new Error("Invitation access requires a cube selector.");
|
|
1339
|
+
}
|
|
1049
1340
|
let ownerEpoch = null;
|
|
1050
1341
|
if (input.purpose === "owner") {
|
|
1051
1342
|
const state = this.#database.prepare("SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1").get();
|
|
@@ -1070,10 +1361,12 @@ class SqliteCredentialStore {
|
|
|
1070
1361
|
}
|
|
1071
1362
|
this.#database.prepare(`
|
|
1072
1363
|
INSERT INTO enrollment_invitations (
|
|
1073
|
-
id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch
|
|
1074
|
-
|
|
1075
|
-
|
|
1364
|
+
id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch,
|
|
1365
|
+
cube_id, access
|
|
1366
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1367
|
+
`).run(input.id, input.digest.lookup, input.digest.verifier, input.expiresAt, this.#now(), input.purpose, ownerEpoch, scope?.cubeId ?? null, scope?.access ?? null);
|
|
1076
1368
|
this.#database.exec("COMMIT");
|
|
1369
|
+
return scope;
|
|
1077
1370
|
}
|
|
1078
1371
|
catch (error) {
|
|
1079
1372
|
try {
|
|
@@ -1093,7 +1386,7 @@ class SqliteCredentialStore {
|
|
|
1093
1386
|
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1094
1387
|
invitation.consumed_at, invitation.revoked_at,
|
|
1095
1388
|
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1096
|
-
invitation.owner_epoch
|
|
1389
|
+
invitation.owner_epoch, invitation.cube_id, invitation.access
|
|
1097
1390
|
FROM (SELECT 1) AS seed
|
|
1098
1391
|
LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
|
|
1099
1392
|
`).get(lookup);
|
|
@@ -1118,7 +1411,8 @@ class SqliteCredentialStore {
|
|
|
1118
1411
|
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1119
1412
|
invitation.owner_epoch,
|
|
1120
1413
|
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1121
|
-
invitation.consumed_at, invitation.revoked_at
|
|
1414
|
+
invitation.consumed_at, invitation.revoked_at,
|
|
1415
|
+
invitation.cube_id, invitation.access
|
|
1122
1416
|
FROM (SELECT 1) AS seed
|
|
1123
1417
|
LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
|
|
1124
1418
|
`).get(input.invitationId);
|
|
@@ -1166,6 +1460,12 @@ class SqliteCredentialStore {
|
|
|
1166
1460
|
const ownerEpoch = invitation["owner_epoch"] === null
|
|
1167
1461
|
? null
|
|
1168
1462
|
: requiredInteger(invitation, "owner_epoch");
|
|
1463
|
+
const cubeId = nullableText(invitation, "cube_id");
|
|
1464
|
+
const accessValue = nullableText(invitation, "access");
|
|
1465
|
+
const access = accessValue === null ? null : cubeAccess(accessValue);
|
|
1466
|
+
if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
|
|
1467
|
+
throw new Error("Invalid invitation cube scope.");
|
|
1468
|
+
}
|
|
1169
1469
|
if (purpose === "owner") {
|
|
1170
1470
|
const state = this.#database.prepare(`
|
|
1171
1471
|
SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
|
|
@@ -1176,6 +1476,10 @@ class SqliteCredentialStore {
|
|
|
1176
1476
|
return null;
|
|
1177
1477
|
}
|
|
1178
1478
|
}
|
|
1479
|
+
if (cubeId !== null && this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(cubeId) === undefined) {
|
|
1480
|
+
this.#database.exec("ROLLBACK");
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1179
1483
|
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
|
|
1180
1484
|
this.#database.prepare("INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)").run(input.clientId, input.requestedClientName ?? "Local client", now);
|
|
1181
1485
|
this.#mutationHook?.("enrollment.insert-client");
|
|
@@ -1192,6 +1496,13 @@ class SqliteCredentialStore {
|
|
|
1192
1496
|
`).run(input.clientId, now);
|
|
1193
1497
|
this.#mutationHook?.("enrollment.insert-capability");
|
|
1194
1498
|
}
|
|
1499
|
+
if (cubeId !== null && access !== null) {
|
|
1500
|
+
this.#database.prepare(`
|
|
1501
|
+
INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
|
|
1502
|
+
VALUES (?, ?, ?, ?)
|
|
1503
|
+
`).run(input.clientId, cubeId, access, now);
|
|
1504
|
+
this.#mutationHook?.("enrollment.insert-grant");
|
|
1505
|
+
}
|
|
1195
1506
|
this.#database.prepare(`
|
|
1196
1507
|
INSERT INTO enrollment_claims (
|
|
1197
1508
|
invitation_id, retry_key, client_id, requested_client_name,
|
|
@@ -1332,9 +1643,7 @@ async function prepareDatabasePath(path) {
|
|
|
1332
1643
|
if (path === ":memory:")
|
|
1333
1644
|
throw new Error("The server store requires a file-backed database.");
|
|
1334
1645
|
const databasePath = resolve(path);
|
|
1335
|
-
const directory = dirname(databasePath);
|
|
1336
|
-
await ensureDirectoryTree(directory);
|
|
1337
|
-
await chmod(directory, 0o700);
|
|
1646
|
+
const directory = await preparePrivateDataDirectory(dirname(databasePath));
|
|
1338
1647
|
try {
|
|
1339
1648
|
const handle = await open(databasePath, "ax", 0o600);
|
|
1340
1649
|
await handle.close();
|
|
@@ -1352,6 +1661,12 @@ async function prepareDatabasePath(path) {
|
|
|
1352
1661
|
await chmod(databasePath, 0o600);
|
|
1353
1662
|
return databasePath;
|
|
1354
1663
|
}
|
|
1664
|
+
export async function preparePrivateDataDirectory(path) {
|
|
1665
|
+
const directory = resolve(path);
|
|
1666
|
+
await ensureDirectoryTree(directory);
|
|
1667
|
+
await chmod(directory, 0o700);
|
|
1668
|
+
return directory;
|
|
1669
|
+
}
|
|
1355
1670
|
async function ensureDirectoryTree(directory) {
|
|
1356
1671
|
const { root } = parse(directory);
|
|
1357
1672
|
let current = root;
|
|
@@ -1405,6 +1720,18 @@ function configureDatabase(database) {
|
|
|
1405
1720
|
throw new Error("SQLite WAL mode is required.");
|
|
1406
1721
|
}
|
|
1407
1722
|
}
|
|
1723
|
+
function configureExistingDatabase(database) {
|
|
1724
|
+
database.exec(`
|
|
1725
|
+
PRAGMA foreign_keys = ON;
|
|
1726
|
+
PRAGMA synchronous = FULL;
|
|
1727
|
+
PRAGMA trusted_schema = OFF;
|
|
1728
|
+
PRAGMA secure_delete = ON;
|
|
1729
|
+
PRAGMA busy_timeout = 5000;
|
|
1730
|
+
`);
|
|
1731
|
+
if (readPragma(database, "journal_mode") !== "wal") {
|
|
1732
|
+
throw new Error("SQLite WAL mode is required.");
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1408
1735
|
function diagnostics(database) {
|
|
1409
1736
|
const journalMode = readPragma(database, "journal_mode");
|
|
1410
1737
|
const foreignKeys = readPragma(database, "foreign_keys");
|
|
@@ -1523,8 +1850,12 @@ function roleRecord(row) {
|
|
|
1523
1850
|
cube_id: requiredText(row, "cube_id"),
|
|
1524
1851
|
name: requiredText(row, "name"),
|
|
1525
1852
|
short_description: requiredText(row, "short_description"),
|
|
1853
|
+
detailed_description: requiredText(row, "detailed_description"),
|
|
1526
1854
|
is_default: requiredInteger(row, "is_default") === 1,
|
|
1855
|
+
is_mandatory: requiredInteger(row, "is_mandatory") === 1,
|
|
1527
1856
|
is_human_seat: requiredInteger(row, "is_human_seat") === 1,
|
|
1857
|
+
can_broadcast: requiredInteger(row, "can_broadcast") === 1,
|
|
1858
|
+
receives_all_direct: requiredInteger(row, "receives_all_direct") === 1,
|
|
1528
1859
|
role_class: roleClass,
|
|
1529
1860
|
created_at: requiredText(row, "created_at"),
|
|
1530
1861
|
};
|
|
@@ -1538,6 +1869,10 @@ function createCubeRecord(row) {
|
|
|
1538
1869
|
};
|
|
1539
1870
|
}
|
|
1540
1871
|
function droneRecord(row) {
|
|
1872
|
+
const posture = requiredText(row, "posture");
|
|
1873
|
+
if (posture !== "observer" && posture !== "participant") {
|
|
1874
|
+
throw new Error("Database contains invalid drone posture.");
|
|
1875
|
+
}
|
|
1541
1876
|
return {
|
|
1542
1877
|
id: requiredText(row, "id"),
|
|
1543
1878
|
cube_id: requiredText(row, "cube_id"),
|
|
@@ -1545,6 +1880,7 @@ function droneRecord(row) {
|
|
|
1545
1880
|
label: requiredText(row, "label"),
|
|
1546
1881
|
last_seen: requiredText(row, "last_seen"),
|
|
1547
1882
|
hostname: nullableText(row, "hostname"),
|
|
1883
|
+
posture,
|
|
1548
1884
|
created_at: requiredText(row, "created_at"),
|
|
1549
1885
|
};
|
|
1550
1886
|
}
|
|
@@ -1591,7 +1927,19 @@ function storedInvitationDigest(row) {
|
|
|
1591
1927
|
}
|
|
1592
1928
|
const epochValue = row["owner_epoch"];
|
|
1593
1929
|
const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
|
|
1594
|
-
|
|
1930
|
+
const cubeId = nullableText(row, "cube_id");
|
|
1931
|
+
const accessValue = nullableText(row, "access");
|
|
1932
|
+
const access = accessValue === null ? null : cubeAccess(accessValue);
|
|
1933
|
+
if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
|
|
1934
|
+
throw new Error("Database contains invalid invitation cube scope.");
|
|
1935
|
+
}
|
|
1936
|
+
return { ...digest, purpose, ownerEpoch, cubeId, access };
|
|
1937
|
+
}
|
|
1938
|
+
function cubeAccess(value) {
|
|
1939
|
+
if (value !== "read" && value !== "write" && value !== "manage") {
|
|
1940
|
+
throw new Error("Database contains invalid cube access.");
|
|
1941
|
+
}
|
|
1942
|
+
return value;
|
|
1595
1943
|
}
|
|
1596
1944
|
function enrollmentClaimResult(purpose, clientId) {
|
|
1597
1945
|
return purpose === "owner"
|
|
@@ -1659,6 +2007,30 @@ function validatePresentationName(value) {
|
|
|
1659
2007
|
throw new Error("Presentation name is invalid.");
|
|
1660
2008
|
}
|
|
1661
2009
|
}
|
|
2010
|
+
function validateRoleName(value) {
|
|
2011
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 64) {
|
|
2012
|
+
throw new Error("Role name must contain 1 to 64 characters.");
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
function validateRoleShortDescription(value) {
|
|
2016
|
+
if (typeof value !== "string" || value.length > 1_024) {
|
|
2017
|
+
throw new Error("Role short description must contain at most 1024 characters.");
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
export const MAX_ROLE_DETAILED_DESCRIPTION_CHARS = 51_200;
|
|
2021
|
+
export function assertRoleTextWriteAllowed(value, previous) {
|
|
2022
|
+
if (typeof value !== "string")
|
|
2023
|
+
throw new TypeError("Role detailed description must be text.");
|
|
2024
|
+
if (value.length <= MAX_ROLE_DETAILED_DESCRIPTION_CHARS)
|
|
2025
|
+
return;
|
|
2026
|
+
if (previous !== undefined && previous.length > MAX_ROLE_DETAILED_DESCRIPTION_CHARS &&
|
|
2027
|
+
value.length < previous.length)
|
|
2028
|
+
return;
|
|
2029
|
+
throw new RangeError("Role detailed description is too large.");
|
|
2030
|
+
}
|
|
2031
|
+
function booleanInteger(value) {
|
|
2032
|
+
return value ? 1 : 0;
|
|
2033
|
+
}
|
|
1662
2034
|
function validateBoundedText(value, name, maxBytes) {
|
|
1663
2035
|
if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
|
|
1664
2036
|
throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);
|