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/src/store.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { statfsSync, statSync } from "node:fs";
|
|
3
3
|
import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, parse, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import type { MessageTaxonomy } from "borgmcp-shared/domain";
|
|
6
7
|
|
|
7
|
-
import { applyMigrations } from "./migrations.js";
|
|
8
|
+
import { applyMigrations, assertMigrationsCurrent } from "./migrations.js";
|
|
8
9
|
import { operatorErrors } from "./operator-error.js";
|
|
9
10
|
import {
|
|
10
11
|
assertCanonicalUuid,
|
|
11
12
|
assertServerDerivedPrincipal,
|
|
12
13
|
type Principal,
|
|
13
14
|
} from "./principal.js";
|
|
15
|
+
import { patchRoleSectionText, type RoleSectionPatchOp } from "./role-section.js";
|
|
16
|
+
import { validateMessageTaxonomy } from "./message-taxonomy.js";
|
|
14
17
|
|
|
15
18
|
export type CubeAccess = "read" | "write" | "manage";
|
|
16
19
|
|
|
@@ -19,6 +22,7 @@ export interface CubeRecord {
|
|
|
19
22
|
readonly ownerId: string;
|
|
20
23
|
readonly name: string;
|
|
21
24
|
readonly directive: string;
|
|
25
|
+
readonly messageTaxonomy: MessageTaxonomy | null;
|
|
22
26
|
readonly createdAt: string;
|
|
23
27
|
readonly updatedAt: string;
|
|
24
28
|
}
|
|
@@ -50,6 +54,22 @@ export interface EnrichedActivityRecord {
|
|
|
50
54
|
readonly recipient_drone_ids: string[];
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
export type ActivityNotificationRecord = EnrichedActivityRecord & (
|
|
58
|
+
| {
|
|
59
|
+
readonly kind: "ack" | "claim";
|
|
60
|
+
readonly log_entry_id: string;
|
|
61
|
+
readonly ack_kind: "ack" | "claim";
|
|
62
|
+
readonly ack_at: string;
|
|
63
|
+
readonly entry_preview: string;
|
|
64
|
+
readonly author_drone_id?: string;
|
|
65
|
+
readonly claimant_drone_id?: string;
|
|
66
|
+
readonly claimant_role?: string | null;
|
|
67
|
+
}
|
|
68
|
+
| { readonly kind: "heartbeat_ping" }
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
export type ActivityStreamRecord = EnrichedActivityRecord | ActivityNotificationRecord;
|
|
72
|
+
|
|
53
73
|
export interface ClaimRecord {
|
|
54
74
|
readonly log_entry_id: string;
|
|
55
75
|
readonly claimant_drone_id: string;
|
|
@@ -84,8 +104,12 @@ export interface RoleRecord {
|
|
|
84
104
|
readonly cube_id: string;
|
|
85
105
|
readonly name: string;
|
|
86
106
|
readonly short_description: string;
|
|
107
|
+
readonly detailed_description: string;
|
|
87
108
|
readonly is_default: boolean;
|
|
109
|
+
readonly is_mandatory: boolean;
|
|
88
110
|
readonly is_human_seat: boolean;
|
|
111
|
+
readonly can_broadcast: boolean;
|
|
112
|
+
readonly receives_all_direct: boolean;
|
|
89
113
|
readonly role_class: "queen" | "worker";
|
|
90
114
|
readonly created_at: string;
|
|
91
115
|
}
|
|
@@ -97,7 +121,10 @@ export interface DroneRecord {
|
|
|
97
121
|
readonly label: string;
|
|
98
122
|
readonly last_seen: string;
|
|
99
123
|
readonly hostname: string | null;
|
|
124
|
+
readonly posture: "observer" | "participant";
|
|
100
125
|
readonly created_at: string;
|
|
126
|
+
readonly last_log_post_at?: string | null;
|
|
127
|
+
readonly seen_since?: boolean;
|
|
101
128
|
}
|
|
102
129
|
|
|
103
130
|
export interface StoreDiagnostics {
|
|
@@ -113,6 +140,7 @@ export interface OpenStoreOptions {
|
|
|
113
140
|
readonly capacityProbe?: () => StorageCapacity;
|
|
114
141
|
readonly cubeLimits?: CubeLimits;
|
|
115
142
|
readonly mutationHook?: (phase: string) => void;
|
|
143
|
+
readonly migrationMode?: "apply" | "require-current";
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
export interface CubeLimits {
|
|
@@ -146,10 +174,19 @@ export interface StoreRuntime {
|
|
|
146
174
|
readonly forPrincipal: (principal: Principal) => ScopedStore;
|
|
147
175
|
readonly maintenance: MaintenanceStore;
|
|
148
176
|
readonly credentials: CredentialStore;
|
|
177
|
+
readonly liveness: LivenessStore;
|
|
149
178
|
readonly diagnostics: () => StoreDiagnostics;
|
|
150
179
|
readonly close: () => void;
|
|
151
180
|
}
|
|
152
181
|
|
|
182
|
+
export interface LivenessStore {
|
|
183
|
+
readonly scan: (options?: {
|
|
184
|
+
readonly silentMs?: number;
|
|
185
|
+
readonly cooldownMs?: number;
|
|
186
|
+
readonly limit?: number;
|
|
187
|
+
}) => ActivityNotificationRecord[];
|
|
188
|
+
}
|
|
189
|
+
|
|
153
190
|
export interface DigestPair {
|
|
154
191
|
readonly lookup: Buffer;
|
|
155
192
|
readonly verifier: Buffer;
|
|
@@ -166,6 +203,25 @@ export interface StoredSecretDigest extends DigestPair {
|
|
|
166
203
|
export interface StoredInvitationDigest extends StoredSecretDigest {
|
|
167
204
|
readonly purpose: "owner" | "client";
|
|
168
205
|
readonly ownerEpoch: number | null;
|
|
206
|
+
readonly cubeId: string | null;
|
|
207
|
+
readonly access: CubeAccess | null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface InvitationCubeScope {
|
|
211
|
+
readonly cubeId: string;
|
|
212
|
+
readonly cubeName: string;
|
|
213
|
+
readonly access: CubeAccess;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export class InvitationCubeNotFoundError extends Error {}
|
|
217
|
+
|
|
218
|
+
export class InvitationCubeAmbiguousError extends Error {
|
|
219
|
+
readonly candidateIds: readonly string[];
|
|
220
|
+
|
|
221
|
+
constructor(candidateIds: readonly string[]) {
|
|
222
|
+
super("Cube name is ambiguous.");
|
|
223
|
+
this.candidateIds = Object.freeze([...candidateIds]);
|
|
224
|
+
}
|
|
169
225
|
}
|
|
170
226
|
|
|
171
227
|
export interface EnrollmentClaimResult {
|
|
@@ -180,6 +236,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
|
|
|
180
236
|
readonly cubeId: string;
|
|
181
237
|
readonly droneId: string;
|
|
182
238
|
readonly expiresAt: string;
|
|
239
|
+
readonly evictedAt: string | null;
|
|
183
240
|
}
|
|
184
241
|
|
|
185
242
|
export interface CredentialStore {
|
|
@@ -190,7 +247,9 @@ export interface CredentialStore {
|
|
|
190
247
|
readonly digest: DigestPair;
|
|
191
248
|
readonly expiresAt: string;
|
|
192
249
|
readonly purpose: "owner" | "client";
|
|
193
|
-
|
|
250
|
+
readonly cubeSelector?: { readonly kind: "id" | "name"; readonly value: string };
|
|
251
|
+
readonly access?: CubeAccess;
|
|
252
|
+
}) => InvitationCubeScope | null;
|
|
194
253
|
readonly findInvitation: (lookup: Buffer) => StoredInvitationDigest | null;
|
|
195
254
|
readonly claimInvitation: (input: {
|
|
196
255
|
readonly invitationId: string;
|
|
@@ -204,6 +263,7 @@ export interface CredentialStore {
|
|
|
204
263
|
readonly clientExists: (clientId: string) => boolean;
|
|
205
264
|
readonly clientIsActive: (clientId: string) => boolean;
|
|
206
265
|
readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
|
|
266
|
+
readonly findActiveDroneSessionExpiry: (sessionId: string) => string | null;
|
|
207
267
|
readonly rotateClientCredential: (input: {
|
|
208
268
|
readonly clientId: string;
|
|
209
269
|
readonly credentialId: string;
|
|
@@ -216,11 +276,19 @@ export interface ScopedStore {
|
|
|
216
276
|
readonly createCube: (input: CreateCubeInput) => CreateCubeRecord;
|
|
217
277
|
readonly listCubes: () => CubeRecord[];
|
|
218
278
|
readonly getCube: (cubeId: string) => CubeRecord | null;
|
|
279
|
+
readonly updateCube: (cubeId: string, input: UpdateCubeInput) => CubeRecord;
|
|
219
280
|
readonly updateDirective: (cubeId: string, directive: string) => void;
|
|
220
281
|
readonly appendActivity: (cubeId: string, message: string) => ActivityRecord;
|
|
221
282
|
readonly readActivity: (cubeId: string, limit: number) => ActivityRecord[];
|
|
222
283
|
readonly listRoles: (cubeId: string) => RoleRecord[];
|
|
284
|
+
readonly createRole: (cubeId: string, input: CreateRoleInput) => RoleRecord;
|
|
285
|
+
readonly updateRole: (cubeId: string, roleId: string, input: UpdateRoleInput) => RoleRecord;
|
|
286
|
+
readonly patchRoleSection: (cubeId: string, roleId: string, input: RoleSectionPatchOp) => RoleRecord;
|
|
223
287
|
readonly listDrones: (cubeId: string) => DroneRecord[];
|
|
288
|
+
readonly listDronesSince: (cubeId: string, since: string) => {
|
|
289
|
+
readonly drones: DroneRecord[];
|
|
290
|
+
readonly since: string;
|
|
291
|
+
};
|
|
224
292
|
readonly appendLog: (cubeId: string, input: {
|
|
225
293
|
readonly message: string;
|
|
226
294
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -236,11 +304,40 @@ export interface ScopedStore {
|
|
|
236
304
|
readonly listDecisions: (cubeId: string) => DecisionRecord[];
|
|
237
305
|
readonly subscribeActivity: (
|
|
238
306
|
cubeId: string,
|
|
239
|
-
listener: (entry:
|
|
307
|
+
listener: (entry: ActivityStreamRecord) => void,
|
|
240
308
|
) => (() => void);
|
|
241
309
|
readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
|
|
242
310
|
}
|
|
243
311
|
|
|
312
|
+
export interface CreateRoleInput {
|
|
313
|
+
readonly name: string;
|
|
314
|
+
readonly shortDescription?: string;
|
|
315
|
+
readonly detailedDescription?: string;
|
|
316
|
+
readonly isDefault?: boolean;
|
|
317
|
+
readonly isMandatory?: boolean;
|
|
318
|
+
readonly isHumanSeat?: boolean;
|
|
319
|
+
readonly canBroadcast?: boolean;
|
|
320
|
+
readonly receivesAllDirect?: boolean;
|
|
321
|
+
readonly roleClass?: "queen" | "worker";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface UpdateRoleInput {
|
|
325
|
+
readonly name?: string;
|
|
326
|
+
readonly shortDescription?: string;
|
|
327
|
+
readonly detailedDescription?: string;
|
|
328
|
+
readonly isDefault?: boolean;
|
|
329
|
+
readonly isMandatory?: boolean;
|
|
330
|
+
readonly isHumanSeat?: boolean;
|
|
331
|
+
readonly canBroadcast?: boolean;
|
|
332
|
+
readonly receivesAllDirect?: boolean;
|
|
333
|
+
readonly roleClass?: "queen" | "worker";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface UpdateCubeInput {
|
|
337
|
+
readonly directive?: string;
|
|
338
|
+
readonly messageTaxonomy?: MessageTaxonomy | null;
|
|
339
|
+
}
|
|
340
|
+
|
|
244
341
|
export interface CreateCubeInput {
|
|
245
342
|
readonly retryKey: string;
|
|
246
343
|
readonly name: string;
|
|
@@ -257,7 +354,6 @@ export interface CreateCubeRecord {
|
|
|
257
354
|
export interface SeatAttachInput {
|
|
258
355
|
readonly cubeId: string;
|
|
259
356
|
readonly roleId: string;
|
|
260
|
-
readonly retryKey: string;
|
|
261
357
|
readonly priorDroneId?: string;
|
|
262
358
|
readonly droneId: string;
|
|
263
359
|
readonly sessionId: string;
|
|
@@ -283,9 +379,7 @@ export interface SeatAttachRecord {
|
|
|
283
379
|
};
|
|
284
380
|
readonly sessionId: string;
|
|
285
381
|
readonly expiresAt: string;
|
|
286
|
-
readonly
|
|
287
|
-
readonly reattached: boolean;
|
|
288
|
-
readonly revokedSessionIds: readonly string[];
|
|
382
|
+
readonly result: "created" | "reused";
|
|
289
383
|
}
|
|
290
384
|
|
|
291
385
|
export interface MaintenanceStore {
|
|
@@ -359,6 +453,33 @@ export class ScopedStoreError extends Error {
|
|
|
359
453
|
}
|
|
360
454
|
}
|
|
361
455
|
|
|
456
|
+
export class RoleConflictError extends Error {
|
|
457
|
+
readonly code = "ROLE_ALREADY_EXISTS";
|
|
458
|
+
|
|
459
|
+
constructor() {
|
|
460
|
+
super("A role with that name already exists.");
|
|
461
|
+
this.name = "RoleConflictError";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export class DefaultRoleRequiredError extends Error {
|
|
466
|
+
readonly code = "DEFAULT_ROLE_REQUIRED";
|
|
467
|
+
|
|
468
|
+
constructor() {
|
|
469
|
+
super("A cube must retain one default role.");
|
|
470
|
+
this.name = "DefaultRoleRequiredError";
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export class RoleSectionConflictError extends Error {
|
|
475
|
+
readonly code = "ROLE_SECTION_CONFLICT";
|
|
476
|
+
|
|
477
|
+
constructor() {
|
|
478
|
+
super("The role section patch conflicts with the current role text.");
|
|
479
|
+
this.name = "RoleSectionConflictError";
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
362
483
|
export class CursorExpiredError extends Error {
|
|
363
484
|
readonly code = "CURSOR_EXPIRED";
|
|
364
485
|
|
|
@@ -368,12 +489,12 @@ export class CursorExpiredError extends Error {
|
|
|
368
489
|
}
|
|
369
490
|
}
|
|
370
491
|
|
|
371
|
-
export class
|
|
372
|
-
readonly code = "
|
|
492
|
+
export class AttachSessionRejectedError extends Error {
|
|
493
|
+
readonly code = "SESSION_REJECTED";
|
|
373
494
|
|
|
374
495
|
constructor() {
|
|
375
|
-
super("The
|
|
376
|
-
this.name = "
|
|
496
|
+
super("The saved session is not accepted for this seat.");
|
|
497
|
+
this.name = "AttachSessionRejectedError";
|
|
377
498
|
}
|
|
378
499
|
}
|
|
379
500
|
|
|
@@ -401,6 +522,7 @@ interface CubeRow {
|
|
|
401
522
|
readonly owner_id: string;
|
|
402
523
|
readonly name: string;
|
|
403
524
|
readonly directive: string;
|
|
525
|
+
readonly message_taxonomy: string | null;
|
|
404
526
|
readonly created_at: string;
|
|
405
527
|
readonly updated_at: string;
|
|
406
528
|
}
|
|
@@ -470,8 +592,13 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
470
592
|
});
|
|
471
593
|
const clock = options.clock ?? (() => new Date());
|
|
472
594
|
try {
|
|
473
|
-
|
|
474
|
-
|
|
595
|
+
if (options.migrationMode === "require-current") {
|
|
596
|
+
configureExistingDatabase(database);
|
|
597
|
+
assertMigrationsCurrent(database);
|
|
598
|
+
} else {
|
|
599
|
+
configureDatabase(database);
|
|
600
|
+
applyMigrations(database);
|
|
601
|
+
}
|
|
475
602
|
} catch (error) {
|
|
476
603
|
database.close();
|
|
477
604
|
throw error;
|
|
@@ -487,9 +614,10 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
487
614
|
capacityProbe,
|
|
488
615
|
requiredInteger(pageRow, "page_size"),
|
|
489
616
|
);
|
|
617
|
+
const activityHub = new ActivityHub();
|
|
490
618
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
619
|
+
const liveness = new SqliteLivenessStore(database, clock, activityHub, capacityGuard);
|
|
491
620
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
492
|
-
const activityHub = new ActivityHub();
|
|
493
621
|
return Object.freeze({
|
|
494
622
|
forPrincipal: (principal: Principal) => {
|
|
495
623
|
assertServerDerivedPrincipal(principal);
|
|
@@ -506,6 +634,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
506
634
|
},
|
|
507
635
|
maintenance,
|
|
508
636
|
credentials,
|
|
637
|
+
liveness,
|
|
509
638
|
diagnostics: () => diagnostics(database),
|
|
510
639
|
close: () => database.close(),
|
|
511
640
|
});
|
|
@@ -626,7 +755,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
626
755
|
listCubes(): CubeRecord[] {
|
|
627
756
|
const scope = this.#scope("read");
|
|
628
757
|
const rows = this.#database.prepare(`
|
|
629
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
758
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
759
|
+
c.created_at, c.updated_at
|
|
630
760
|
FROM cubes AS c
|
|
631
761
|
WHERE ${scope.sql}
|
|
632
762
|
ORDER BY c.id
|
|
@@ -638,7 +768,8 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
638
768
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
639
769
|
const scope = this.#scope("read");
|
|
640
770
|
const row = this.#database.prepare(`
|
|
641
|
-
SELECT c.id, c.owner_id, c.name, c.directive, c.
|
|
771
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.message_taxonomy,
|
|
772
|
+
c.created_at, c.updated_at
|
|
642
773
|
FROM cubes AS c
|
|
643
774
|
WHERE c.id = ? AND ${scope.sql}
|
|
644
775
|
`).get(cubeId, ...scope.parameters);
|
|
@@ -646,17 +777,42 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
646
777
|
}
|
|
647
778
|
|
|
648
779
|
updateDirective(cubeId: string, directive: string): void {
|
|
780
|
+
this.updateCube(cubeId, { directive });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
updateCube(cubeId: string, input: UpdateCubeInput): CubeRecord {
|
|
649
784
|
assertCanonicalUuid(cubeId, "Cube id");
|
|
650
|
-
|
|
785
|
+
if (input.directive === undefined && input.messageTaxonomy === undefined) {
|
|
786
|
+
throw new TypeError("At least one cube field is required.");
|
|
787
|
+
}
|
|
788
|
+
if (input.directive !== undefined) validateDirective(input.directive);
|
|
789
|
+
const taxonomy = input.messageTaxonomy === undefined
|
|
790
|
+
? undefined
|
|
791
|
+
: validateMessageTaxonomy(input.messageTaxonomy);
|
|
792
|
+
const serializedTaxonomy = taxonomy === undefined ? undefined : serializeTaxonomy(taxonomy);
|
|
651
793
|
const scope = this.#scope("manage");
|
|
652
794
|
this.#requireCube(cubeId, "manage");
|
|
653
|
-
this.#capacityGuard.assertCanGrow(
|
|
795
|
+
this.#capacityGuard.assertCanGrow(
|
|
796
|
+
Buffer.byteLength(input.directive ?? "") + Buffer.byteLength(serializedTaxonomy ?? ""),
|
|
797
|
+
);
|
|
654
798
|
const result = this.#database.prepare(`
|
|
655
799
|
UPDATE cubes AS c
|
|
656
|
-
SET directive = ?,
|
|
800
|
+
SET directive = COALESCE(?, directive),
|
|
801
|
+
message_taxonomy = CASE WHEN ? = 1 THEN ? ELSE message_taxonomy END,
|
|
802
|
+
updated_at = ?
|
|
657
803
|
WHERE c.id = ? AND ${scope.sql}
|
|
658
|
-
`).run(
|
|
804
|
+
`).run(
|
|
805
|
+
input.directive ?? null,
|
|
806
|
+
serializedTaxonomy === undefined ? 0 : 1,
|
|
807
|
+
serializedTaxonomy ?? null,
|
|
808
|
+
this.#now(),
|
|
809
|
+
cubeId,
|
|
810
|
+
...scope.parameters,
|
|
811
|
+
);
|
|
659
812
|
if (result.changes !== 1) throw new ScopedStoreError();
|
|
813
|
+
const cube = this.getCube(cubeId);
|
|
814
|
+
if (cube === null) throw new ScopedStoreError();
|
|
815
|
+
return cube;
|
|
660
816
|
}
|
|
661
817
|
|
|
662
818
|
appendActivity(cubeId: string, message: string): ActivityRecord {
|
|
@@ -693,23 +849,282 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
693
849
|
listRoles(cubeId: string): RoleRecord[] {
|
|
694
850
|
this.#requireCube(cubeId, "read");
|
|
695
851
|
const rows = this.#database.prepare(`
|
|
696
|
-
SELECT id, cube_id, name, short_description,
|
|
697
|
-
|
|
852
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
853
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
854
|
+
receives_all_direct, role_class, created_at
|
|
698
855
|
FROM roles WHERE cube_id = ? ORDER BY name, id
|
|
699
856
|
`).all(cubeId);
|
|
700
857
|
return rows.map(roleRecord);
|
|
701
858
|
}
|
|
702
859
|
|
|
860
|
+
createRole(cubeId: string, input: CreateRoleInput): RoleRecord {
|
|
861
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
862
|
+
validateRoleName(input.name);
|
|
863
|
+
const shortDescription = input.shortDescription ?? "";
|
|
864
|
+
const detailedDescription = input.detailedDescription ?? "";
|
|
865
|
+
validateRoleShortDescription(shortDescription);
|
|
866
|
+
assertRoleTextWriteAllowed(detailedDescription);
|
|
867
|
+
for (const value of [
|
|
868
|
+
input.isDefault,
|
|
869
|
+
input.isMandatory,
|
|
870
|
+
input.isHumanSeat,
|
|
871
|
+
input.canBroadcast,
|
|
872
|
+
input.receivesAllDirect,
|
|
873
|
+
]) {
|
|
874
|
+
if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
|
|
875
|
+
}
|
|
876
|
+
validateRoleClass(input.roleClass);
|
|
877
|
+
const isDefault = input.isDefault ?? false;
|
|
878
|
+
const isMandatory = input.isMandatory ?? false;
|
|
879
|
+
const isHumanSeat = input.isHumanSeat ?? false;
|
|
880
|
+
const canBroadcast = input.canBroadcast ?? false;
|
|
881
|
+
const receivesAllDirect = input.receivesAllDirect ?? false;
|
|
882
|
+
this.#requireCube(cubeId, "manage");
|
|
883
|
+
this.#capacityGuard.assertCanGrow(
|
|
884
|
+
Buffer.byteLength(input.name) + Buffer.byteLength(shortDescription) +
|
|
885
|
+
Buffer.byteLength(detailedDescription) + 8_192,
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
889
|
+
try {
|
|
890
|
+
this.#requireCube(cubeId, "manage");
|
|
891
|
+
const duplicate = this.#database.prepare(
|
|
892
|
+
"SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ?",
|
|
893
|
+
).get(cubeId, input.name);
|
|
894
|
+
if (duplicate !== undefined) throw new RoleConflictError();
|
|
895
|
+
if (isDefault) {
|
|
896
|
+
this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
|
|
897
|
+
.run(cubeId);
|
|
898
|
+
this.#mutationHook?.("role.demote-default");
|
|
899
|
+
}
|
|
900
|
+
const id = randomUUID();
|
|
901
|
+
this.#database.prepare(`
|
|
902
|
+
INSERT INTO roles (
|
|
903
|
+
id, cube_id, name, short_description, detailed_description,
|
|
904
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
905
|
+
receives_all_direct, role_class, created_at
|
|
906
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
907
|
+
`).run(
|
|
908
|
+
id, cubeId, input.name, shortDescription, detailedDescription,
|
|
909
|
+
booleanInteger(isDefault), booleanInteger(isMandatory), booleanInteger(isHumanSeat),
|
|
910
|
+
booleanInteger(canBroadcast), booleanInteger(receivesAllDirect), input.roleClass ?? "worker",
|
|
911
|
+
this.#now(),
|
|
912
|
+
);
|
|
913
|
+
this.#mutationHook?.("role.insert");
|
|
914
|
+
const row = this.#database.prepare(`
|
|
915
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
916
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
917
|
+
receives_all_direct, role_class, created_at
|
|
918
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
919
|
+
`).get(id, cubeId);
|
|
920
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
921
|
+
this.#database.exec("COMMIT");
|
|
922
|
+
this.#mutationHook?.("role.after-commit");
|
|
923
|
+
return roleRecord(row);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
updateRole(cubeId: string, roleId: string, input: UpdateRoleInput): RoleRecord {
|
|
931
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
932
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
933
|
+
if (Object.values(input).every((value) => value === undefined)) {
|
|
934
|
+
throw new TypeError("At least one role field is required.");
|
|
935
|
+
}
|
|
936
|
+
if (input.name !== undefined) validateRoleName(input.name);
|
|
937
|
+
if (input.shortDescription !== undefined) validateRoleShortDescription(input.shortDescription);
|
|
938
|
+
if (input.detailedDescription !== undefined && typeof input.detailedDescription !== "string") {
|
|
939
|
+
throw new TypeError("Role detailed description must be text.");
|
|
940
|
+
}
|
|
941
|
+
for (const value of [
|
|
942
|
+
input.isDefault,
|
|
943
|
+
input.isMandatory,
|
|
944
|
+
input.isHumanSeat,
|
|
945
|
+
input.canBroadcast,
|
|
946
|
+
input.receivesAllDirect,
|
|
947
|
+
]) {
|
|
948
|
+
if (value !== undefined && typeof value !== "boolean") throw new TypeError("Role flags must be boolean.");
|
|
949
|
+
}
|
|
950
|
+
validateRoleClass(input.roleClass);
|
|
951
|
+
this.#requireCube(cubeId, "manage");
|
|
952
|
+
this.#capacityGuard.assertCanGrow(
|
|
953
|
+
Buffer.byteLength(input.name ?? "") + Buffer.byteLength(input.shortDescription ?? "") +
|
|
954
|
+
Buffer.byteLength(input.detailedDescription ?? "") + 8_192,
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
958
|
+
try {
|
|
959
|
+
this.#requireCube(cubeId, "manage");
|
|
960
|
+
const existingRow = this.#database.prepare(`
|
|
961
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
962
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
963
|
+
receives_all_direct, role_class, created_at
|
|
964
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
965
|
+
`).get(roleId, cubeId);
|
|
966
|
+
if (existingRow === undefined) throw new ScopedStoreError();
|
|
967
|
+
const existing = roleRecord(existingRow);
|
|
968
|
+
if (input.isDefault === false && existing.is_default) {
|
|
969
|
+
throw new DefaultRoleRequiredError();
|
|
970
|
+
}
|
|
971
|
+
if (input.name !== undefined && input.name !== existing.name) {
|
|
972
|
+
const duplicate = this.#database.prepare(
|
|
973
|
+
"SELECT 1 AS present FROM roles WHERE cube_id = ? AND name = ? AND id <> ?",
|
|
974
|
+
).get(cubeId, input.name, roleId);
|
|
975
|
+
if (duplicate !== undefined) throw new RoleConflictError();
|
|
976
|
+
}
|
|
977
|
+
if (input.isDefault === true && !existing.is_default) {
|
|
978
|
+
this.#database.prepare("UPDATE roles SET is_default = 0 WHERE cube_id = ? AND is_default = 1")
|
|
979
|
+
.run(cubeId);
|
|
980
|
+
this.#mutationHook?.("role.demote-default");
|
|
981
|
+
}
|
|
982
|
+
const nextDetailedDescription = input.detailedDescription ?? existing.detailed_description;
|
|
983
|
+
assertRoleTextWriteAllowed(nextDetailedDescription, existing.detailed_description);
|
|
984
|
+
this.#database.prepare(`
|
|
985
|
+
UPDATE roles SET
|
|
986
|
+
name = ?, short_description = ?, detailed_description = ?, is_default = ?,
|
|
987
|
+
is_mandatory = ?, is_human_seat = ?, can_broadcast = ?, receives_all_direct = ?,
|
|
988
|
+
role_class = ?
|
|
989
|
+
WHERE id = ? AND cube_id = ?
|
|
990
|
+
`).run(
|
|
991
|
+
input.name ?? existing.name,
|
|
992
|
+
input.shortDescription ?? existing.short_description,
|
|
993
|
+
nextDetailedDescription,
|
|
994
|
+
booleanInteger(input.isDefault ?? existing.is_default),
|
|
995
|
+
booleanInteger(input.isMandatory ?? existing.is_mandatory),
|
|
996
|
+
booleanInteger(input.isHumanSeat ?? existing.is_human_seat),
|
|
997
|
+
booleanInteger(input.canBroadcast ?? existing.can_broadcast),
|
|
998
|
+
booleanInteger(input.receivesAllDirect ?? existing.receives_all_direct),
|
|
999
|
+
input.roleClass ?? existing.role_class,
|
|
1000
|
+
roleId,
|
|
1001
|
+
cubeId,
|
|
1002
|
+
);
|
|
1003
|
+
const row = this.#database.prepare(`
|
|
1004
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
1005
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
1006
|
+
receives_all_direct, role_class, created_at
|
|
1007
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
1008
|
+
`).get(roleId, cubeId);
|
|
1009
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
1010
|
+
this.#database.exec("COMMIT");
|
|
1011
|
+
this.#mutationHook?.("role.after-commit");
|
|
1012
|
+
return roleRecord(row);
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1015
|
+
throw error;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
patchRoleSection(cubeId: string, roleId: string, input: RoleSectionPatchOp): RoleRecord {
|
|
1020
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1021
|
+
assertCanonicalUuid(roleId, "Role id");
|
|
1022
|
+
this.#requireCube(cubeId, "manage");
|
|
1023
|
+
this.#capacityGuard.assertCanGrow(
|
|
1024
|
+
Buffer.byteLength(input.heading) +
|
|
1025
|
+
("body" in input ? Buffer.byteLength(input.body) : 0) + 8_192,
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1029
|
+
try {
|
|
1030
|
+
this.#requireCube(cubeId, "manage");
|
|
1031
|
+
const existingRow = this.#database.prepare(`
|
|
1032
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
1033
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
1034
|
+
receives_all_direct, role_class, created_at
|
|
1035
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
1036
|
+
`).get(roleId, cubeId);
|
|
1037
|
+
if (existingRow === undefined) throw new ScopedStoreError();
|
|
1038
|
+
const existing = roleRecord(existingRow);
|
|
1039
|
+
let detailedDescription: string;
|
|
1040
|
+
try {
|
|
1041
|
+
detailedDescription = patchRoleSectionText(existing.detailed_description, input);
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
if (error instanceof TypeError) throw error;
|
|
1044
|
+
throw new RoleSectionConflictError();
|
|
1045
|
+
}
|
|
1046
|
+
assertRoleTextWriteAllowed(detailedDescription, existing.detailed_description);
|
|
1047
|
+
this.#database.prepare(
|
|
1048
|
+
"UPDATE roles SET detailed_description = ? WHERE id = ? AND cube_id = ?",
|
|
1049
|
+
).run(detailedDescription, roleId, cubeId);
|
|
1050
|
+
const row = this.#database.prepare(`
|
|
1051
|
+
SELECT id, cube_id, name, short_description, detailed_description,
|
|
1052
|
+
is_default, is_mandatory, is_human_seat, can_broadcast,
|
|
1053
|
+
receives_all_direct, role_class, created_at
|
|
1054
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
1055
|
+
`).get(roleId, cubeId);
|
|
1056
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
1057
|
+
this.#database.exec("COMMIT");
|
|
1058
|
+
this.#mutationHook?.("role.after-commit");
|
|
1059
|
+
return roleRecord(row);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1062
|
+
throw error;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
703
1066
|
listDrones(cubeId: string): DroneRecord[] {
|
|
704
1067
|
this.#requireCube(cubeId, "read");
|
|
705
1068
|
const rows = this.#database.prepare(`
|
|
706
|
-
SELECT id, cube_id, role_id, label,
|
|
707
|
-
|
|
708
|
-
|
|
1069
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
1070
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
1071
|
+
drone.hostname, drone.created_at,
|
|
1072
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1073
|
+
THEN 'participant' ELSE 'observer' END AS posture
|
|
1074
|
+
FROM drones AS drone
|
|
1075
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
1076
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
1077
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1078
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1079
|
+
ORDER BY drone.label, drone.id
|
|
709
1080
|
`).all(cubeId);
|
|
710
1081
|
return rows.map(droneRecord);
|
|
711
1082
|
}
|
|
712
1083
|
|
|
1084
|
+
listDronesSince(cubeId: string, since: string): { drones: DroneRecord[]; since: string } {
|
|
1085
|
+
this.#requireCube(cubeId, "read");
|
|
1086
|
+
let createdAt: string;
|
|
1087
|
+
let anchorId: string | null = null;
|
|
1088
|
+
if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(since)) {
|
|
1089
|
+
const anchor = this.#database.prepare(
|
|
1090
|
+
"SELECT id, created_at FROM activity_log WHERE id = ? AND cube_id = ?",
|
|
1091
|
+
).get(since, cubeId);
|
|
1092
|
+
if (anchor === undefined) throw new ScopedStoreError();
|
|
1093
|
+
anchorId = requiredText(anchor, "id");
|
|
1094
|
+
createdAt = requiredText(anchor, "created_at");
|
|
1095
|
+
} else {
|
|
1096
|
+
validateTimestamp(since);
|
|
1097
|
+
createdAt = since;
|
|
1098
|
+
}
|
|
1099
|
+
const rows = this.#database.prepare(`
|
|
1100
|
+
SELECT drone.id, drone.cube_id, drone.role_id, drone.label,
|
|
1101
|
+
COALESCE(drone.last_seen, drone.created_at) AS last_seen,
|
|
1102
|
+
drone.hostname, drone.created_at,
|
|
1103
|
+
CASE WHEN client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1104
|
+
THEN 'participant' ELSE 'observer' END AS posture,
|
|
1105
|
+
(SELECT MAX(post.created_at) FROM activity_log AS post
|
|
1106
|
+
WHERE post.cube_id = drone.cube_id AND post.drone_id = drone.id) AS last_log_post_at,
|
|
1107
|
+
EXISTS (SELECT 1 FROM activity_log AS response
|
|
1108
|
+
WHERE response.cube_id = drone.cube_id AND response.drone_id = drone.id
|
|
1109
|
+
AND (response.created_at > ? OR
|
|
1110
|
+
(? IS NOT NULL AND response.created_at = ? AND response.id > ?))) AS seen_since
|
|
1111
|
+
FROM drones AS drone
|
|
1112
|
+
LEFT JOIN clients AS client ON client.id = drone.client_id
|
|
1113
|
+
LEFT JOIN client_cube_grants AS grant_row
|
|
1114
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1115
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1116
|
+
ORDER BY drone.label, drone.id
|
|
1117
|
+
`).all(createdAt, anchorId, createdAt, anchorId, cubeId);
|
|
1118
|
+
return {
|
|
1119
|
+
since: createdAt,
|
|
1120
|
+
drones: rows.map((row) => ({
|
|
1121
|
+
...droneRecord(row),
|
|
1122
|
+
last_log_post_at: nullableText(row, "last_log_post_at"),
|
|
1123
|
+
seen_since: requiredInteger(row, "seen_since") === 1,
|
|
1124
|
+
})),
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
713
1128
|
appendLog(cubeId: string, input: {
|
|
714
1129
|
readonly message: string;
|
|
715
1130
|
readonly visibility?: "broadcast" | "direct";
|
|
@@ -756,11 +1171,24 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
756
1171
|
createdAt, visibility, cubeId, ...scope.parameters,
|
|
757
1172
|
);
|
|
758
1173
|
if (inserted.changes !== 1) throw new ScopedStoreError();
|
|
1174
|
+
if (droneId !== null) {
|
|
1175
|
+
this.#database.prepare(`
|
|
1176
|
+
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1177
|
+
`).run(createdAt, droneId, cubeId);
|
|
1178
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1179
|
+
}
|
|
759
1180
|
if (recipients.length > 0) {
|
|
760
1181
|
const valid = this.#database.prepare(`
|
|
761
|
-
SELECT COUNT(*) AS count
|
|
762
|
-
|
|
763
|
-
|
|
1182
|
+
SELECT COUNT(*) AS count
|
|
1183
|
+
FROM drones AS recipient
|
|
1184
|
+
JOIN clients AS recipient_client ON recipient_client.id = recipient.client_id
|
|
1185
|
+
JOIN client_cube_grants AS recipient_grant
|
|
1186
|
+
ON recipient_grant.client_id = recipient.client_id
|
|
1187
|
+
AND recipient_grant.cube_id = recipient.cube_id
|
|
1188
|
+
WHERE recipient.cube_id = ? AND recipient.evicted_at IS NULL
|
|
1189
|
+
AND recipient_client.revoked_at IS NULL
|
|
1190
|
+
AND recipient_grant.access IN ('write', 'manage')
|
|
1191
|
+
AND recipient.id IN (${recipients.map(() => "?").join(", ")})
|
|
764
1192
|
`).get(cubeId, ...recipients);
|
|
765
1193
|
if (valid === undefined) throw new Error("Recipient count query returned no row.");
|
|
766
1194
|
if (requiredInteger(valid, "count") !== recipients.length) throw new ScopedStoreError();
|
|
@@ -786,6 +1214,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
786
1214
|
throw new Error("Activity read limit must be an integer from 1 to 500.");
|
|
787
1215
|
}
|
|
788
1216
|
this.#validateCursor(cubeId, cursor);
|
|
1217
|
+
const broadcastOnly = !this.#allowsDirectedWork(cubeId);
|
|
789
1218
|
const cursorSql = cursor === null
|
|
790
1219
|
? { sql: "1 = 1", parameters: [] as string[] }
|
|
791
1220
|
: {
|
|
@@ -796,6 +1225,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
796
1225
|
SELECT l.id
|
|
797
1226
|
FROM activity_log AS l
|
|
798
1227
|
WHERE l.cube_id = ? AND ${cursorSql.sql}
|
|
1228
|
+
AND (${broadcastOnly ? "l.visibility = 'broadcast'" : "1 = 1"})
|
|
799
1229
|
ORDER BY l.created_at, l.id
|
|
800
1230
|
LIMIT ?
|
|
801
1231
|
`).all(cubeId, ...cursorSql.parameters, limit + 1);
|
|
@@ -804,13 +1234,15 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
804
1234
|
const nextCursor = entries.length === 0
|
|
805
1235
|
? cursor
|
|
806
1236
|
: { id: entries.at(-1)!.id, created_at: entries.at(-1)!.created_at };
|
|
807
|
-
const behind = nextCursor === null
|
|
1237
|
+
const behind = nextCursor === null
|
|
1238
|
+
? this.#countAfter(cubeId, null, broadcastOnly)
|
|
1239
|
+
: this.#countAfter(cubeId, nextCursor, broadcastOnly);
|
|
808
1240
|
return {
|
|
809
1241
|
entries,
|
|
810
1242
|
cursor: nextCursor,
|
|
811
1243
|
behind_by: behind,
|
|
812
1244
|
has_more: behind > 0,
|
|
813
|
-
claims: this.#claims(cubeId),
|
|
1245
|
+
claims: this.#claims(cubeId, broadcastOnly),
|
|
814
1246
|
};
|
|
815
1247
|
}
|
|
816
1248
|
|
|
@@ -818,19 +1250,56 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
818
1250
|
this.#requireCube(cubeId, "write");
|
|
819
1251
|
assertCanonicalUuid(entryId, "Activity entry id");
|
|
820
1252
|
if (kind !== "ack" && kind !== "claim") throw new Error("Unknown acknowledgement kind.");
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
).get(entryId, cubeId);
|
|
824
|
-
if (
|
|
1253
|
+
const original = this.#database.prepare(`
|
|
1254
|
+
SELECT drone_id, message, visibility FROM activity_log WHERE id = ? AND cube_id = ?
|
|
1255
|
+
`).get(entryId, cubeId);
|
|
1256
|
+
if (original === undefined) throw new ScopedStoreError();
|
|
825
1257
|
this.#capacityGuard.assertCanGrow(512);
|
|
826
1258
|
const claimant = this.#principal.kind === "drone-session"
|
|
827
1259
|
? this.#principal.droneId
|
|
828
1260
|
: this.#principal.id;
|
|
829
|
-
this.#
|
|
1261
|
+
const ackAt = this.#now();
|
|
1262
|
+
const inserted = this.#database.prepare(`
|
|
830
1263
|
INSERT OR IGNORE INTO activity_acks (
|
|
831
1264
|
entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
|
|
832
1265
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
833
|
-
`).run(entryId, this.#principal.kind, this.#principal.id, kind,
|
|
1266
|
+
`).run(entryId, this.#principal.kind, this.#principal.id, kind, ackAt, claimant);
|
|
1267
|
+
if (inserted.changes !== 1 || this.#principal.kind !== "drone-session") return;
|
|
1268
|
+
const claimantDroneId = this.#principal.droneId;
|
|
1269
|
+
|
|
1270
|
+
const actor = this.#database.prepare(`
|
|
1271
|
+
SELECT drone.label, role.name AS role_name
|
|
1272
|
+
FROM drones AS drone JOIN roles AS role ON role.id = drone.role_id
|
|
1273
|
+
WHERE drone.id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1274
|
+
`).get(claimantDroneId, cubeId);
|
|
1275
|
+
const preview = activityPreview(requiredText(original, "message"));
|
|
1276
|
+
const originalAuthor = nullableText(original, "drone_id");
|
|
1277
|
+
const recipients = kind === "ack"
|
|
1278
|
+
? originalAuthor === null ? [] : [originalAuthor]
|
|
1279
|
+
: this.#claimAudience(cubeId, entryId, requiredText(original, "visibility"))
|
|
1280
|
+
.filter((droneId) => droneId !== claimantDroneId);
|
|
1281
|
+
if (recipients.length === 0) return;
|
|
1282
|
+
const roleName = actor === undefined ? null : requiredText(actor, "role_name");
|
|
1283
|
+
const notification: ActivityNotificationRecord = {
|
|
1284
|
+
kind,
|
|
1285
|
+
id: randomUUID(),
|
|
1286
|
+
cube_id: cubeId,
|
|
1287
|
+
drone_id: claimantDroneId,
|
|
1288
|
+
drone_label: actor === undefined ? null : requiredText(actor, "label"),
|
|
1289
|
+
role_name: roleName,
|
|
1290
|
+
message: `[${kind.toUpperCase()}] ${preview}`,
|
|
1291
|
+
created_at: ackAt,
|
|
1292
|
+
visibility: "direct",
|
|
1293
|
+
recipient_drone_ids: recipients,
|
|
1294
|
+
log_entry_id: entryId,
|
|
1295
|
+
ack_kind: kind,
|
|
1296
|
+
ack_at: ackAt,
|
|
1297
|
+
entry_preview: preview,
|
|
1298
|
+
...(kind === "ack"
|
|
1299
|
+
? { author_drone_id: recipients[0]! }
|
|
1300
|
+
: { claimant_drone_id: claimantDroneId, claimant_role: roleName }),
|
|
1301
|
+
};
|
|
1302
|
+
this.#activityHub.publish(cubeId, notification);
|
|
834
1303
|
}
|
|
835
1304
|
|
|
836
1305
|
recordDecision(cubeId: string, input: {
|
|
@@ -887,7 +1356,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
887
1356
|
if (this.#principal.kind !== "client") throw new ScopedStoreError();
|
|
888
1357
|
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
889
1358
|
assertCanonicalUuid(input.roleId, "Role id");
|
|
890
|
-
assertCanonicalUuid(input.retryKey, "Retry key");
|
|
891
1359
|
if (input.priorDroneId !== undefined) assertCanonicalUuid(input.priorDroneId, "Prior drone id");
|
|
892
1360
|
assertCanonicalUuid(input.droneId, "Drone id");
|
|
893
1361
|
assertCanonicalUuid(input.sessionId, "Drone session id");
|
|
@@ -912,94 +1380,96 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
912
1380
|
WHERE c.id = ? AND role.id = ? AND ${scope.sql}
|
|
913
1381
|
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
914
1382
|
if (roleRow === undefined) throw new ScopedStoreError();
|
|
1383
|
+
const bound = this.#database.prepare(`
|
|
1384
|
+
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
1385
|
+
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1386
|
+
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1387
|
+
drone.role_id, drone.label, drone.evicted_at
|
|
1388
|
+
FROM drone_session_credentials AS credential
|
|
1389
|
+
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1390
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
1391
|
+
AND drone.client_id = session.client_id AND drone.cube_id = session.cube_id
|
|
1392
|
+
WHERE credential.lookup_digest = ?
|
|
1393
|
+
`).get(input.credentialDigest.lookup);
|
|
1394
|
+
if (bound !== undefined) {
|
|
1395
|
+
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
1396
|
+
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
1397
|
+
optionalText(bound, "credential_revoked_at") !== null ||
|
|
1398
|
+
optionalText(bound, "session_revoked_at") !== null ||
|
|
1399
|
+
optionalText(bound, "evicted_at") !== null ||
|
|
1400
|
+
requiredText(bound, "expires_at") <= now ||
|
|
1401
|
+
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
1402
|
+
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
1403
|
+
requiredText(bound, "role_id") !== input.roleId ||
|
|
1404
|
+
(input.priorDroneId !== undefined &&
|
|
1405
|
+
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
1406
|
+
throw new AttachSessionRejectedError();
|
|
1407
|
+
}
|
|
1408
|
+
const droneId = requiredText(bound, "drone_id");
|
|
1409
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1410
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1411
|
+
const attachedRoleRow = this.#database.prepare(`
|
|
1412
|
+
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
1413
|
+
FROM roles WHERE id = ? AND cube_id = ?
|
|
1414
|
+
`).get(requiredText(bound, "role_id"), input.cubeId);
|
|
1415
|
+
if (attachedRoleRow === undefined) throw new Error("Attached drone role is unavailable.");
|
|
1416
|
+
const roleClass = requiredText(attachedRoleRow, "role_class");
|
|
1417
|
+
if (roleClass !== "queen" && roleClass !== "worker") {
|
|
1418
|
+
throw new Error("Database contains invalid role class.");
|
|
1419
|
+
}
|
|
1420
|
+
this.#database.exec("COMMIT");
|
|
1421
|
+
return {
|
|
1422
|
+
cube: {
|
|
1423
|
+
id: requiredText(roleRow, "cube_id"),
|
|
1424
|
+
name: requiredText(roleRow, "cube_name"),
|
|
1425
|
+
},
|
|
1426
|
+
role: {
|
|
1427
|
+
id: requiredText(attachedRoleRow, "role_id"),
|
|
1428
|
+
name: requiredText(attachedRoleRow, "role_name"),
|
|
1429
|
+
role_class: roleClass,
|
|
1430
|
+
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
1431
|
+
},
|
|
1432
|
+
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
1433
|
+
sessionId: requiredText(bound, "session_id"),
|
|
1434
|
+
expiresAt: requiredText(bound, "expires_at"),
|
|
1435
|
+
result: "reused",
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
915
1438
|
|
|
916
|
-
const
|
|
917
|
-
SELECT
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
drone.attach_generation, drone.evicted_at
|
|
921
|
-
FROM seat_attach_bindings AS binding
|
|
922
|
-
JOIN drones AS drone ON drone.id = binding.drone_id
|
|
923
|
-
WHERE binding.client_id = ? AND binding.retry_key = ?
|
|
924
|
-
`).get(this.#principal.id, input.retryKey);
|
|
1439
|
+
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
1440
|
+
SELECT id, role_id, label FROM drones
|
|
1441
|
+
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1442
|
+
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
925
1443
|
let droneId: string;
|
|
926
1444
|
let droneLabel: string;
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
if (requiredText(retryBinding, "binding_cube_id") !== input.cubeId ||
|
|
932
|
-
requiredText(retryBinding, "binding_requested_role_id") !== input.roleId ||
|
|
933
|
-
nullableText(retryBinding, "prior_drone_id") !== (input.priorDroneId ?? null)) {
|
|
934
|
-
throw new AttachConflictError();
|
|
1445
|
+
if (priorSeat !== undefined) {
|
|
1446
|
+
const priorSeatId = requiredText(priorSeat, "id");
|
|
1447
|
+
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
1448
|
+
throw new AttachSessionRejectedError();
|
|
935
1449
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1450
|
+
const occupied = this.#database.prepare(`
|
|
1451
|
+
SELECT 1 FROM drone_sessions AS session
|
|
1452
|
+
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
1453
|
+
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
1454
|
+
AND session.revoked_at IS NULL AND credential.revoked_at IS NULL
|
|
1455
|
+
AND session.expires_at > ?
|
|
1456
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId, now);
|
|
1457
|
+
if (occupied !== undefined) throw new AttachSessionRejectedError();
|
|
1458
|
+
droneId = priorSeatId;
|
|
1459
|
+
droneLabel = requiredText(priorSeat, "label");
|
|
1460
|
+
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
943
1461
|
} else {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
FROM drones
|
|
947
|
-
WHERE id = ? AND client_id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
948
|
-
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
949
|
-
if (priorSeat !== undefined) {
|
|
950
|
-
droneId = requiredText(priorSeat, "id");
|
|
951
|
-
droneLabel = requiredText(priorSeat, "label");
|
|
952
|
-
generation = requiredInteger(priorSeat, "attach_generation") + 1;
|
|
953
|
-
this.#database.prepare(`
|
|
954
|
-
UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
|
|
955
|
-
`).run(now, generation, droneId);
|
|
956
|
-
reattached = true;
|
|
957
|
-
} else {
|
|
958
|
-
droneId = input.droneId;
|
|
959
|
-
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
960
|
-
this.#database.prepare(`
|
|
961
|
-
INSERT INTO drones (
|
|
962
|
-
id, cube_id, role_id, client_id, label, created_at, last_seen, retry_key,
|
|
963
|
-
attach_generation
|
|
964
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
965
|
-
`).run(
|
|
966
|
-
droneId,
|
|
967
|
-
input.cubeId,
|
|
968
|
-
input.roleId,
|
|
969
|
-
this.#principal.id,
|
|
970
|
-
droneLabel,
|
|
971
|
-
now,
|
|
972
|
-
now,
|
|
973
|
-
input.retryKey,
|
|
974
|
-
);
|
|
975
|
-
reattached = false;
|
|
976
|
-
generation = 1;
|
|
977
|
-
}
|
|
1462
|
+
droneId = input.droneId;
|
|
1463
|
+
droneLabel = seatLabel(requiredText(roleRow, "role_name"), droneId);
|
|
978
1464
|
this.#database.prepare(`
|
|
979
|
-
INSERT INTO
|
|
980
|
-
|
|
981
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1465
|
+
INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at, last_seen)
|
|
1466
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
982
1467
|
`).run(
|
|
983
|
-
|
|
984
|
-
droneId, input.priorDroneId ?? null, now,
|
|
1468
|
+
droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
|
|
985
1469
|
);
|
|
986
1470
|
}
|
|
1471
|
+
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
987
1472
|
|
|
988
|
-
const oldSessions = this.#database.prepare(
|
|
989
|
-
"SELECT id FROM drone_sessions WHERE drone_id = ? AND client_id = ? AND cube_id = ?",
|
|
990
|
-
).all(droneId, this.#principal.id, input.cubeId)
|
|
991
|
-
.map((row) => requiredText(row, "id"));
|
|
992
|
-
if (oldSessions.length > 0) {
|
|
993
|
-
const placeholders = oldSessions.map(() => "?").join(", ");
|
|
994
|
-
this.#database.prepare(`
|
|
995
|
-
UPDATE drone_session_credentials SET revoked_at = ?
|
|
996
|
-
WHERE session_id IN (${placeholders}) AND revoked_at IS NULL
|
|
997
|
-
`).run(now, ...oldSessions);
|
|
998
|
-
this.#database.prepare(`
|
|
999
|
-
UPDATE drone_sessions SET revoked_at = ?
|
|
1000
|
-
WHERE id IN (${placeholders}) AND revoked_at IS NULL
|
|
1001
|
-
`).run(now, ...oldSessions);
|
|
1002
|
-
}
|
|
1003
1473
|
this.#database.prepare(`
|
|
1004
1474
|
INSERT INTO drone_sessions (
|
|
1005
1475
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -1047,9 +1517,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1047
1517
|
drone: { id: droneId, label: droneLabel },
|
|
1048
1518
|
sessionId: input.sessionId,
|
|
1049
1519
|
expiresAt: input.expiresAt,
|
|
1050
|
-
|
|
1051
|
-
reattached,
|
|
1052
|
-
revokedSessionIds: oldSessions,
|
|
1520
|
+
result: "created",
|
|
1053
1521
|
};
|
|
1054
1522
|
} catch (error) {
|
|
1055
1523
|
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
@@ -1057,9 +1525,49 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1057
1525
|
}
|
|
1058
1526
|
}
|
|
1059
1527
|
|
|
1060
|
-
subscribeActivity(cubeId: string, listener: (entry:
|
|
1528
|
+
subscribeActivity(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
|
|
1061
1529
|
this.#requireCube(cubeId, "read");
|
|
1062
|
-
return this.#activityHub.subscribe(cubeId,
|
|
1530
|
+
return this.#activityHub.subscribe(cubeId, (entry) => {
|
|
1531
|
+
if ("kind" in entry) {
|
|
1532
|
+
if (this.#principal.kind === "drone-session" &&
|
|
1533
|
+
entry.recipient_drone_ids.includes(this.#principal.droneId)) listener(entry);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (entry.visibility === "broadcast" || this.#allowsDirectedWork(cubeId)) listener(entry);
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
#claimAudience(cubeId: string, entryId: string, visibility: string): string[] {
|
|
1541
|
+
if (visibility === "direct") {
|
|
1542
|
+
return this.#database.prepare(`
|
|
1543
|
+
SELECT recipient.drone_id
|
|
1544
|
+
FROM activity_log_recipients AS recipient
|
|
1545
|
+
JOIN drones AS drone ON drone.id = recipient.drone_id
|
|
1546
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1547
|
+
JOIN client_cube_grants AS grant_row
|
|
1548
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1549
|
+
WHERE recipient.entry_id = ? AND drone.cube_id = ? AND drone.evicted_at IS NULL
|
|
1550
|
+
AND client.revoked_at IS NULL AND grant_row.access IN ('write', 'manage')
|
|
1551
|
+
ORDER BY recipient.drone_id
|
|
1552
|
+
`).all(entryId, cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1553
|
+
}
|
|
1554
|
+
return this.#database.prepare(`
|
|
1555
|
+
SELECT drone.id AS drone_id
|
|
1556
|
+
FROM drones AS drone
|
|
1557
|
+
JOIN clients AS client ON client.id = drone.client_id
|
|
1558
|
+
JOIN client_cube_grants AS grant_row
|
|
1559
|
+
ON grant_row.client_id = drone.client_id AND grant_row.cube_id = drone.cube_id
|
|
1560
|
+
WHERE drone.cube_id = ? AND drone.evicted_at IS NULL AND client.revoked_at IS NULL
|
|
1561
|
+
AND grant_row.access IN ('write', 'manage')
|
|
1562
|
+
ORDER BY drone.id
|
|
1563
|
+
`).all(cubeId).map((row) => requiredText(row, "drone_id"));
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
#allowsDirectedWork(cubeId: string): boolean {
|
|
1567
|
+
const scope = this.#scope("write");
|
|
1568
|
+
return this.#database.prepare(`
|
|
1569
|
+
SELECT 1 AS allowed FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
1570
|
+
`).get(cubeId, ...scope.parameters) !== undefined;
|
|
1063
1571
|
}
|
|
1064
1572
|
|
|
1065
1573
|
#requireCube(cubeId: string, access: CubeAccess): void {
|
|
@@ -1068,7 +1576,15 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1068
1576
|
const row = this.#database.prepare(`
|
|
1069
1577
|
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
1070
1578
|
`).get(cubeId, ...scope.parameters);
|
|
1071
|
-
if (row
|
|
1579
|
+
if (row !== undefined) return;
|
|
1580
|
+
if (access === "manage") {
|
|
1581
|
+
const visibleScope = this.#scope("read");
|
|
1582
|
+
const visible = this.#database.prepare(`
|
|
1583
|
+
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${visibleScope.sql}
|
|
1584
|
+
`).get(cubeId, ...visibleScope.parameters);
|
|
1585
|
+
if (visible !== undefined) throw new AccessDeniedError();
|
|
1586
|
+
}
|
|
1587
|
+
throw new ScopedStoreError();
|
|
1072
1588
|
}
|
|
1073
1589
|
|
|
1074
1590
|
#nextActivityTimestamp(cubeId: string): string {
|
|
@@ -1123,14 +1639,16 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1123
1639
|
if (valid === undefined) throw new ScopedStoreError();
|
|
1124
1640
|
}
|
|
1125
1641
|
|
|
1126
|
-
#countAfter(cubeId: string, cursor: LogCursor | null): number {
|
|
1642
|
+
#countAfter(cubeId: string, cursor: LogCursor | null, broadcastOnly: boolean): number {
|
|
1643
|
+
const visibility = broadcastOnly ? "AND visibility = 'broadcast'" : "";
|
|
1127
1644
|
const row = cursor === null
|
|
1128
1645
|
? this.#database.prepare(
|
|
1129
|
-
|
|
1646
|
+
`SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ? ${visibility}`,
|
|
1130
1647
|
).get(cubeId)
|
|
1131
1648
|
: this.#database.prepare(`
|
|
1132
1649
|
SELECT COUNT(*) AS count FROM activity_log
|
|
1133
1650
|
WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
|
|
1651
|
+
${visibility}
|
|
1134
1652
|
`).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
|
|
1135
1653
|
if (row === undefined) throw new Error("Activity count query returned no row.");
|
|
1136
1654
|
return requiredInteger(row, "count");
|
|
@@ -1152,7 +1670,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1152
1670
|
return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
|
|
1153
1671
|
}
|
|
1154
1672
|
|
|
1155
|
-
#claims(cubeId: string): ClaimRecord[] {
|
|
1673
|
+
#claims(cubeId: string, broadcastOnly: boolean): ClaimRecord[] {
|
|
1156
1674
|
return this.#database.prepare(`
|
|
1157
1675
|
SELECT acknowledgement.entry_id AS log_entry_id,
|
|
1158
1676
|
acknowledgement.claimant_drone_id,
|
|
@@ -1166,6 +1684,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1166
1684
|
LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
|
|
1167
1685
|
WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
|
|
1168
1686
|
AND acknowledgement.claimant_drone_id IS NOT NULL
|
|
1687
|
+
AND (${broadcastOnly ? "entry.visibility = 'broadcast'" : "1 = 1"})
|
|
1169
1688
|
ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
|
|
1170
1689
|
acknowledgement.claimant_drone_id
|
|
1171
1690
|
`).all(cubeId).map(claimRecord);
|
|
@@ -1323,7 +1842,7 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1323
1842
|
for (const table of [
|
|
1324
1843
|
"cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
|
|
1325
1844
|
"decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
|
|
1326
|
-
"
|
|
1845
|
+
"drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
|
|
1327
1846
|
"enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
|
|
1328
1847
|
"enrollment_invitations",
|
|
1329
1848
|
]) this.#database.exec(`DELETE FROM ${table}`);
|
|
@@ -1491,9 +2010,9 @@ class SqliteMaintenanceStore implements MaintenanceStore {
|
|
|
1491
2010
|
}
|
|
1492
2011
|
|
|
1493
2012
|
class ActivityHub {
|
|
1494
|
-
readonly #listeners = new Map<string, Set<(entry:
|
|
2013
|
+
readonly #listeners = new Map<string, Set<(entry: ActivityStreamRecord) => void>>();
|
|
1495
2014
|
|
|
1496
|
-
subscribe(cubeId: string, listener: (entry:
|
|
2015
|
+
subscribe(cubeId: string, listener: (entry: ActivityStreamRecord) => void): () => void {
|
|
1497
2016
|
const listeners = this.#listeners.get(cubeId) ?? new Set();
|
|
1498
2017
|
listeners.add(listener);
|
|
1499
2018
|
this.#listeners.set(cubeId, listeners);
|
|
@@ -1503,7 +2022,7 @@ class ActivityHub {
|
|
|
1503
2022
|
};
|
|
1504
2023
|
}
|
|
1505
2024
|
|
|
1506
|
-
publish(cubeId: string, entry:
|
|
2025
|
+
publish(cubeId: string, entry: ActivityStreamRecord): void {
|
|
1507
2026
|
for (const listener of this.#listeners.get(cubeId) ?? []) {
|
|
1508
2027
|
try {
|
|
1509
2028
|
listener(entry);
|
|
@@ -1514,6 +2033,64 @@ class ActivityHub {
|
|
|
1514
2033
|
}
|
|
1515
2034
|
}
|
|
1516
2035
|
|
|
2036
|
+
class SqliteLivenessStore implements LivenessStore {
|
|
2037
|
+
constructor(
|
|
2038
|
+
readonly database: DatabaseSync,
|
|
2039
|
+
readonly clock: () => Date,
|
|
2040
|
+
readonly hub: ActivityHub,
|
|
2041
|
+
readonly capacityGuard: StorageCapacityGuard,
|
|
2042
|
+
) {}
|
|
2043
|
+
|
|
2044
|
+
scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
|
|
2045
|
+
const now = this.clock();
|
|
2046
|
+
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
2047
|
+
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
2048
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
2049
|
+
const candidates = this.database.prepare(`
|
|
2050
|
+
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
2051
|
+
FROM drones AS drone
|
|
2052
|
+
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
2053
|
+
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
2054
|
+
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
2055
|
+
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
2056
|
+
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
2057
|
+
AND COALESCE(state.attempts, 0) < 3
|
|
2058
|
+
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
2059
|
+
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
2060
|
+
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
2061
|
+
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
2062
|
+
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
2063
|
+
const published: ActivityNotificationRecord[] = [];
|
|
2064
|
+
for (const candidate of candidates) {
|
|
2065
|
+
const droneId = requiredText(candidate, "drone_id");
|
|
2066
|
+
const cubeId = requiredText(candidate, "cube_id");
|
|
2067
|
+
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
2068
|
+
this.capacityGuard.assertCanGrow(256);
|
|
2069
|
+
const id = randomUUID();
|
|
2070
|
+
const createdAt = now.toISOString();
|
|
2071
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
2072
|
+
try {
|
|
2073
|
+
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
2074
|
+
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
2075
|
+
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
2076
|
+
`).run(droneId, now.toISOString());
|
|
2077
|
+
this.database.exec("COMMIT");
|
|
2078
|
+
} catch (error) {
|
|
2079
|
+
try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
|
|
2080
|
+
throw error;
|
|
2081
|
+
}
|
|
2082
|
+
const entry: ActivityNotificationRecord = {
|
|
2083
|
+
kind: "heartbeat_ping",
|
|
2084
|
+
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
2085
|
+
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
2086
|
+
};
|
|
2087
|
+
this.hub.publish(cubeId, entry);
|
|
2088
|
+
published.push(entry);
|
|
2089
|
+
}
|
|
2090
|
+
return published;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
1517
2094
|
class SqliteCredentialStore implements CredentialStore {
|
|
1518
2095
|
readonly #database: DatabaseSync;
|
|
1519
2096
|
readonly #clock: () => Date;
|
|
@@ -1557,12 +2134,39 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1557
2134
|
readonly digest: DigestPair;
|
|
1558
2135
|
readonly expiresAt: string;
|
|
1559
2136
|
readonly purpose: "owner" | "client";
|
|
1560
|
-
|
|
2137
|
+
readonly cubeSelector?: { readonly kind: "id" | "name"; readonly value: string };
|
|
2138
|
+
readonly access?: CubeAccess;
|
|
2139
|
+
}): InvitationCubeScope | null {
|
|
1561
2140
|
assertCanonicalUuid(input.id, "Invitation id");
|
|
1562
2141
|
validateDigest(input.digest);
|
|
1563
2142
|
validateTimestamp(input.expiresAt);
|
|
1564
2143
|
this.#database.exec("BEGIN IMMEDIATE");
|
|
1565
2144
|
try {
|
|
2145
|
+
let scope: InvitationCubeScope | null = null;
|
|
2146
|
+
if (input.cubeSelector !== undefined) {
|
|
2147
|
+
if (input.purpose !== "client" || input.access === undefined) {
|
|
2148
|
+
throw new Error("Only client invitations may carry a complete cube scope.");
|
|
2149
|
+
}
|
|
2150
|
+
if (input.access !== "read" && input.access !== "write" && input.access !== "manage") {
|
|
2151
|
+
throw new Error("Unknown cube access grant.");
|
|
2152
|
+
}
|
|
2153
|
+
const rows = input.cubeSelector.kind === "id"
|
|
2154
|
+
? this.#database.prepare("SELECT id, name FROM cubes WHERE id = ?").all(input.cubeSelector.value)
|
|
2155
|
+
: this.#database.prepare("SELECT id, name FROM cubes WHERE name = ? ORDER BY id").all(input.cubeSelector.value);
|
|
2156
|
+
if (rows.length === 0) throw new InvitationCubeNotFoundError();
|
|
2157
|
+
if (rows.length > 1) {
|
|
2158
|
+
throw new InvitationCubeAmbiguousError(rows.map((row) => requiredText(row, "id")));
|
|
2159
|
+
}
|
|
2160
|
+
const cubeId = requiredText(rows[0]!, "id");
|
|
2161
|
+
assertCanonicalUuid(cubeId, "Resolved cube id");
|
|
2162
|
+
scope = {
|
|
2163
|
+
cubeId,
|
|
2164
|
+
cubeName: requiredText(rows[0]!, "name"),
|
|
2165
|
+
access: input.access,
|
|
2166
|
+
};
|
|
2167
|
+
} else if (input.access !== undefined) {
|
|
2168
|
+
throw new Error("Invitation access requires a cube selector.");
|
|
2169
|
+
}
|
|
1566
2170
|
let ownerEpoch: number | null = null;
|
|
1567
2171
|
if (input.purpose === "owner") {
|
|
1568
2172
|
const state = this.#database.prepare(
|
|
@@ -1587,13 +2191,15 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1587
2191
|
}
|
|
1588
2192
|
this.#database.prepare(`
|
|
1589
2193
|
INSERT INTO enrollment_invitations (
|
|
1590
|
-
id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch
|
|
1591
|
-
|
|
2194
|
+
id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch,
|
|
2195
|
+
cube_id, access
|
|
2196
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1592
2197
|
`).run(
|
|
1593
2198
|
input.id, input.digest.lookup, input.digest.verifier, input.expiresAt,
|
|
1594
|
-
this.#now(), input.purpose, ownerEpoch,
|
|
2199
|
+
this.#now(), input.purpose, ownerEpoch, scope?.cubeId ?? null, scope?.access ?? null,
|
|
1595
2200
|
);
|
|
1596
2201
|
this.#database.exec("COMMIT");
|
|
2202
|
+
return scope;
|
|
1597
2203
|
} catch (error) {
|
|
1598
2204
|
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1599
2205
|
throw error;
|
|
@@ -1610,7 +2216,7 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1610
2216
|
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1611
2217
|
invitation.consumed_at, invitation.revoked_at,
|
|
1612
2218
|
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1613
|
-
invitation.owner_epoch
|
|
2219
|
+
invitation.owner_epoch, invitation.cube_id, invitation.access
|
|
1614
2220
|
FROM (SELECT 1) AS seed
|
|
1615
2221
|
LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
|
|
1616
2222
|
`).get(lookup);
|
|
@@ -1641,7 +2247,8 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1641
2247
|
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1642
2248
|
invitation.owner_epoch,
|
|
1643
2249
|
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1644
|
-
invitation.consumed_at, invitation.revoked_at
|
|
2250
|
+
invitation.consumed_at, invitation.revoked_at,
|
|
2251
|
+
invitation.cube_id, invitation.access
|
|
1645
2252
|
FROM (SELECT 1) AS seed
|
|
1646
2253
|
LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
|
|
1647
2254
|
`).get(input.invitationId);
|
|
@@ -1689,6 +2296,12 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1689
2296
|
const ownerEpoch = invitation["owner_epoch"] === null
|
|
1690
2297
|
? null
|
|
1691
2298
|
: requiredInteger(invitation, "owner_epoch");
|
|
2299
|
+
const cubeId = nullableText(invitation, "cube_id");
|
|
2300
|
+
const accessValue = nullableText(invitation, "access");
|
|
2301
|
+
const access = accessValue === null ? null : cubeAccess(accessValue);
|
|
2302
|
+
if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
|
|
2303
|
+
throw new Error("Invalid invitation cube scope.");
|
|
2304
|
+
}
|
|
1692
2305
|
if (purpose === "owner") {
|
|
1693
2306
|
const state = this.#database.prepare(`
|
|
1694
2307
|
SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
|
|
@@ -1699,6 +2312,10 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1699
2312
|
return null;
|
|
1700
2313
|
}
|
|
1701
2314
|
}
|
|
2315
|
+
if (cubeId !== null && this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(cubeId) === undefined) {
|
|
2316
|
+
this.#database.exec("ROLLBACK");
|
|
2317
|
+
return null;
|
|
2318
|
+
}
|
|
1702
2319
|
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
|
|
1703
2320
|
this.#database.prepare(
|
|
1704
2321
|
"INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)",
|
|
@@ -1723,6 +2340,13 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1723
2340
|
`).run(input.clientId, now);
|
|
1724
2341
|
this.#mutationHook?.("enrollment.insert-capability");
|
|
1725
2342
|
}
|
|
2343
|
+
if (cubeId !== null && access !== null) {
|
|
2344
|
+
this.#database.prepare(`
|
|
2345
|
+
INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
|
|
2346
|
+
VALUES (?, ?, ?, ?)
|
|
2347
|
+
`).run(input.clientId, cubeId, access, now);
|
|
2348
|
+
this.#mutationHook?.("enrollment.insert-grant");
|
|
2349
|
+
}
|
|
1726
2350
|
this.#database.prepare(`
|
|
1727
2351
|
INSERT INTO enrollment_claims (
|
|
1728
2352
|
invitation_id, retry_key, client_id, requested_client_name,
|
|
@@ -1791,9 +2415,8 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1791
2415
|
SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
|
|
1792
2416
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1793
2417
|
session.expires_at,
|
|
1794
|
-
COALESCE(
|
|
1795
|
-
|
|
1796
|
-
) AS revoked_at
|
|
2418
|
+
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
2419
|
+
drone.evicted_at
|
|
1797
2420
|
FROM drone_session_credentials AS credential
|
|
1798
2421
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1799
2422
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -1805,6 +2428,21 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1805
2428
|
return row === undefined ? null : storedDroneSessionDigest(row);
|
|
1806
2429
|
}
|
|
1807
2430
|
|
|
2431
|
+
findActiveDroneSessionExpiry(sessionId: string): string | null {
|
|
2432
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
2433
|
+
const row = this.#database.prepare(`
|
|
2434
|
+
SELECT session.expires_at
|
|
2435
|
+
FROM drone_sessions AS session
|
|
2436
|
+
JOIN clients AS client ON client.id = session.client_id
|
|
2437
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
2438
|
+
AND drone.client_id = session.client_id
|
|
2439
|
+
AND drone.cube_id = session.cube_id
|
|
2440
|
+
WHERE session.id = ? AND session.revoked_at IS NULL
|
|
2441
|
+
AND client.revoked_at IS NULL AND drone.evicted_at IS NULL
|
|
2442
|
+
`).get(sessionId);
|
|
2443
|
+
return row === undefined ? null : requiredText(row, "expires_at");
|
|
2444
|
+
}
|
|
2445
|
+
|
|
1808
2446
|
rotateClientCredential(input: {
|
|
1809
2447
|
readonly clientId: string;
|
|
1810
2448
|
readonly credentialId: string;
|
|
@@ -1827,6 +2465,16 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1827
2465
|
UPDATE client_credentials SET revoked_at = ?
|
|
1828
2466
|
WHERE client_id = ? AND revoked_at IS NULL
|
|
1829
2467
|
`).run(now, input.clientId);
|
|
2468
|
+
this.#database.prepare(`
|
|
2469
|
+
UPDATE drone_session_credentials SET revoked_at = ?
|
|
2470
|
+
WHERE revoked_at IS NULL AND session_id IN (
|
|
2471
|
+
SELECT id FROM drone_sessions WHERE client_id = ?
|
|
2472
|
+
)
|
|
2473
|
+
`).run(now, input.clientId);
|
|
2474
|
+
this.#database.prepare(`
|
|
2475
|
+
UPDATE drone_sessions SET revoked_at = ?
|
|
2476
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
2477
|
+
`).run(now, input.clientId);
|
|
1830
2478
|
this.#database.prepare(`
|
|
1831
2479
|
INSERT INTO client_credentials (
|
|
1832
2480
|
id, client_id, lookup_digest, verifier_digest, created_at
|
|
@@ -1881,9 +2529,7 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
1881
2529
|
async function prepareDatabasePath(path: string): Promise<string> {
|
|
1882
2530
|
if (path === ":memory:") throw new Error("The server store requires a file-backed database.");
|
|
1883
2531
|
const databasePath = resolve(path);
|
|
1884
|
-
const directory = dirname(databasePath);
|
|
1885
|
-
await ensureDirectoryTree(directory);
|
|
1886
|
-
await chmod(directory, 0o700);
|
|
2532
|
+
const directory = await preparePrivateDataDirectory(dirname(databasePath));
|
|
1887
2533
|
try {
|
|
1888
2534
|
const handle = await open(databasePath, "ax", 0o600);
|
|
1889
2535
|
await handle.close();
|
|
@@ -1898,6 +2544,13 @@ async function prepareDatabasePath(path: string): Promise<string> {
|
|
|
1898
2544
|
return databasePath;
|
|
1899
2545
|
}
|
|
1900
2546
|
|
|
2547
|
+
export async function preparePrivateDataDirectory(path: string): Promise<string> {
|
|
2548
|
+
const directory = resolve(path);
|
|
2549
|
+
await ensureDirectoryTree(directory);
|
|
2550
|
+
await chmod(directory, 0o700);
|
|
2551
|
+
return directory;
|
|
2552
|
+
}
|
|
2553
|
+
|
|
1901
2554
|
async function ensureDirectoryTree(directory: string): Promise<void> {
|
|
1902
2555
|
const { root } = parse(directory);
|
|
1903
2556
|
let current = root;
|
|
@@ -1951,6 +2604,19 @@ function configureDatabase(database: DatabaseSync): void {
|
|
|
1951
2604
|
}
|
|
1952
2605
|
}
|
|
1953
2606
|
|
|
2607
|
+
function configureExistingDatabase(database: DatabaseSync): void {
|
|
2608
|
+
database.exec(`
|
|
2609
|
+
PRAGMA foreign_keys = ON;
|
|
2610
|
+
PRAGMA synchronous = FULL;
|
|
2611
|
+
PRAGMA trusted_schema = OFF;
|
|
2612
|
+
PRAGMA secure_delete = ON;
|
|
2613
|
+
PRAGMA busy_timeout = 5000;
|
|
2614
|
+
`);
|
|
2615
|
+
if (readPragma(database, "journal_mode") !== "wal") {
|
|
2616
|
+
throw new Error("SQLite WAL mode is required.");
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
|
|
1954
2620
|
function diagnostics(database: DatabaseSync): StoreDiagnostics {
|
|
1955
2621
|
const journalMode = readPragma(database, "journal_mode");
|
|
1956
2622
|
const foreignKeys = readPragma(database, "foreign_keys");
|
|
@@ -1975,6 +2641,7 @@ function cubeRecord(row: CubeRow): CubeRecord {
|
|
|
1975
2641
|
ownerId: row.owner_id,
|
|
1976
2642
|
name: row.name,
|
|
1977
2643
|
directive: row.directive,
|
|
2644
|
+
messageTaxonomy: parseTaxonomy(row.message_taxonomy),
|
|
1978
2645
|
createdAt: row.created_at,
|
|
1979
2646
|
updatedAt: row.updated_at,
|
|
1980
2647
|
};
|
|
@@ -1998,6 +2665,7 @@ function cubeRow(row: Record<string, unknown>): CubeRow {
|
|
|
1998
2665
|
owner_id: requiredText(row, "owner_id"),
|
|
1999
2666
|
name: requiredText(row, "name"),
|
|
2000
2667
|
directive: requiredText(row, "directive"),
|
|
2668
|
+
message_taxonomy: nullableText(row, "message_taxonomy"),
|
|
2001
2669
|
created_at: requiredText(row, "created_at"),
|
|
2002
2670
|
updated_at: requiredText(row, "updated_at"),
|
|
2003
2671
|
};
|
|
@@ -2083,8 +2751,12 @@ function roleRecord(row: Record<string, unknown>): RoleRecord {
|
|
|
2083
2751
|
cube_id: requiredText(row, "cube_id"),
|
|
2084
2752
|
name: requiredText(row, "name"),
|
|
2085
2753
|
short_description: requiredText(row, "short_description"),
|
|
2754
|
+
detailed_description: requiredText(row, "detailed_description"),
|
|
2086
2755
|
is_default: requiredInteger(row, "is_default") === 1,
|
|
2756
|
+
is_mandatory: requiredInteger(row, "is_mandatory") === 1,
|
|
2087
2757
|
is_human_seat: requiredInteger(row, "is_human_seat") === 1,
|
|
2758
|
+
can_broadcast: requiredInteger(row, "can_broadcast") === 1,
|
|
2759
|
+
receives_all_direct: requiredInteger(row, "receives_all_direct") === 1,
|
|
2088
2760
|
role_class: roleClass,
|
|
2089
2761
|
created_at: requiredText(row, "created_at"),
|
|
2090
2762
|
};
|
|
@@ -2100,6 +2772,10 @@ function createCubeRecord(row: Record<string, unknown>): CreateCubeRecord {
|
|
|
2100
2772
|
}
|
|
2101
2773
|
|
|
2102
2774
|
function droneRecord(row: Record<string, unknown>): DroneRecord {
|
|
2775
|
+
const posture = requiredText(row, "posture");
|
|
2776
|
+
if (posture !== "observer" && posture !== "participant") {
|
|
2777
|
+
throw new Error("Database contains invalid drone posture.");
|
|
2778
|
+
}
|
|
2103
2779
|
return {
|
|
2104
2780
|
id: requiredText(row, "id"),
|
|
2105
2781
|
cube_id: requiredText(row, "cube_id"),
|
|
@@ -2107,6 +2783,7 @@ function droneRecord(row: Record<string, unknown>): DroneRecord {
|
|
|
2107
2783
|
label: requiredText(row, "label"),
|
|
2108
2784
|
last_seen: requiredText(row, "last_seen"),
|
|
2109
2785
|
hostname: nullableText(row, "hostname"),
|
|
2786
|
+
posture,
|
|
2110
2787
|
created_at: requiredText(row, "created_at"),
|
|
2111
2788
|
};
|
|
2112
2789
|
}
|
|
@@ -2155,7 +2832,20 @@ function storedInvitationDigest(row: Record<string, unknown>): StoredInvitationD
|
|
|
2155
2832
|
}
|
|
2156
2833
|
const epochValue = row["owner_epoch"];
|
|
2157
2834
|
const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
|
|
2158
|
-
|
|
2835
|
+
const cubeId = nullableText(row, "cube_id");
|
|
2836
|
+
const accessValue = nullableText(row, "access");
|
|
2837
|
+
const access = accessValue === null ? null : cubeAccess(accessValue);
|
|
2838
|
+
if ((cubeId === null) !== (access === null) || (cubeId !== null && purpose !== "client")) {
|
|
2839
|
+
throw new Error("Database contains invalid invitation cube scope.");
|
|
2840
|
+
}
|
|
2841
|
+
return { ...digest, purpose, ownerEpoch, cubeId, access };
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
function cubeAccess(value: string): CubeAccess {
|
|
2845
|
+
if (value !== "read" && value !== "write" && value !== "manage") {
|
|
2846
|
+
throw new Error("Database contains invalid cube access.");
|
|
2847
|
+
}
|
|
2848
|
+
return value;
|
|
2159
2849
|
}
|
|
2160
2850
|
|
|
2161
2851
|
function enrollmentClaimResult(
|
|
@@ -2203,6 +2893,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
|
|
|
2203
2893
|
cubeId: requiredText(row, "cube_id"),
|
|
2204
2894
|
droneId: requiredText(row, "drone_id"),
|
|
2205
2895
|
expiresAt: requiredText(row, "expires_at"),
|
|
2896
|
+
evictedAt: nullableText(row, "evicted_at"),
|
|
2206
2897
|
};
|
|
2207
2898
|
}
|
|
2208
2899
|
|
|
@@ -2239,6 +2930,32 @@ function validatePresentationName(value: string): void {
|
|
|
2239
2930
|
}
|
|
2240
2931
|
}
|
|
2241
2932
|
|
|
2933
|
+
function validateRoleName(value: string): void {
|
|
2934
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 64) {
|
|
2935
|
+
throw new Error("Role name must contain 1 to 64 characters.");
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
function validateRoleShortDescription(value: string): void {
|
|
2940
|
+
if (typeof value !== "string" || value.length > 1_024) {
|
|
2941
|
+
throw new Error("Role short description must contain at most 1024 characters.");
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
export const MAX_ROLE_DETAILED_DESCRIPTION_CHARS = 51_200;
|
|
2946
|
+
|
|
2947
|
+
export function assertRoleTextWriteAllowed(value: string, previous?: string): void {
|
|
2948
|
+
if (typeof value !== "string") throw new TypeError("Role detailed description must be text.");
|
|
2949
|
+
if (value.length <= MAX_ROLE_DETAILED_DESCRIPTION_CHARS) return;
|
|
2950
|
+
if (previous !== undefined && previous.length > MAX_ROLE_DETAILED_DESCRIPTION_CHARS &&
|
|
2951
|
+
value.length < previous.length) return;
|
|
2952
|
+
throw new RangeError("Role detailed description is too large.");
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
function booleanInteger(value: boolean): 0 | 1 {
|
|
2956
|
+
return value ? 1 : 0;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2242
2959
|
function validateBoundedText(value: string, name: string, maxBytes: number): void {
|
|
2243
2960
|
if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
|
|
2244
2961
|
throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);
|
|
@@ -2262,6 +2979,34 @@ function validateDirective(value: string): void {
|
|
|
2262
2979
|
}
|
|
2263
2980
|
}
|
|
2264
2981
|
|
|
2982
|
+
function activityPreview(message: string): string {
|
|
2983
|
+
return message.replace(/\s+/gu, " ").trim().slice(0, 200);
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
function validateRoleClass(value: unknown): void {
|
|
2987
|
+
if (value !== undefined && value !== "queen" && value !== "worker") {
|
|
2988
|
+
throw new TypeError("Role class must be queen or worker.");
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
function serializeTaxonomy(value: MessageTaxonomy | null): string | null {
|
|
2993
|
+
if (value === null) return null;
|
|
2994
|
+
const serialized = JSON.stringify(value);
|
|
2995
|
+
if (Buffer.byteLength(serialized) > 100_000) {
|
|
2996
|
+
throw new RangeError("Message taxonomy exceeds 100000 bytes.");
|
|
2997
|
+
}
|
|
2998
|
+
return serialized;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function parseTaxonomy(value: string | null): MessageTaxonomy | null {
|
|
3002
|
+
if (value === null) return null;
|
|
3003
|
+
try {
|
|
3004
|
+
return validateMessageTaxonomy(JSON.parse(value));
|
|
3005
|
+
} catch {
|
|
3006
|
+
throw new Error("Database contains invalid message taxonomy.");
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
|
|
2265
3010
|
function validateDigest(digest: DigestPair): void {
|
|
2266
3011
|
validateLookup(digest.lookup);
|
|
2267
3012
|
if (digest.verifier.length !== 32) throw new Error("Verifier digest must contain 32 bytes.");
|