borgmcp-server 0.1.1
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/LICENSE +105 -0
- package/NOTICE +8 -0
- package/README.md +119 -0
- package/SECURITY.md +38 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/dist/bootstrap.d.ts +18 -0
- package/dist/bootstrap.js +104 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +96 -0
- package/dist/cli.js.map +1 -0
- package/dist/coordination-api.d.ts +25 -0
- package/dist/coordination-api.js +457 -0
- package/dist/coordination-api.js.map +1 -0
- package/dist/credentials.d.ts +58 -0
- package/dist/credentials.js +244 -0
- package/dist/credentials.js.map +1 -0
- package/dist/enrollment.d.ts +17 -0
- package/dist/enrollment.js +122 -0
- package/dist/enrollment.js.map +1 -0
- package/dist/https-server.d.ts +94 -0
- package/dist/https-server.js +814 -0
- package/dist/https-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +67 -0
- package/dist/main.js.map +1 -0
- package/dist/migrations.d.ts +8 -0
- package/dist/migrations.js +366 -0
- package/dist/migrations.js.map +1 -0
- package/dist/network-policy.d.ts +13 -0
- package/dist/network-policy.js +49 -0
- package/dist/network-policy.js.map +1 -0
- package/dist/operator-error.d.ts +3 -0
- package/dist/operator-error.js +63 -0
- package/dist/operator-error.js.map +1 -0
- package/dist/principal.d.ts +32 -0
- package/dist/principal.js +64 -0
- package/dist/principal.js.map +1 -0
- package/dist/protocol-draft.d.ts +2 -0
- package/dist/protocol-draft.js +31 -0
- package/dist/protocol-draft.js.map +1 -0
- package/dist/service.d.ts +61 -0
- package/dist/service.js +455 -0
- package/dist/service.js.map +1 -0
- package/dist/start-options.d.ts +2 -0
- package/dist/start-options.js +46 -0
- package/dist/start-options.js.map +1 -0
- package/dist/store.d.ts +327 -0
- package/dist/store.js +1729 -0
- package/dist/store.js.map +1 -0
- package/npm-shrinkwrap.json +1942 -0
- package/package.json +60 -0
- package/src/bootstrap.ts +127 -0
- package/src/cli.ts +102 -0
- package/src/coordination-api.ts +508 -0
- package/src/credentials.ts +319 -0
- package/src/enrollment.ts +156 -0
- package/src/https-server.ts +962 -0
- package/src/index.ts +3 -0
- package/src/main.ts +73 -0
- package/src/migrations.ts +394 -0
- package/src/network-policy.ts +65 -0
- package/src/operator-error.ts +97 -0
- package/src/principal.ts +106 -0
- package/src/protocol-draft.ts +32 -0
- package/src/service.ts +525 -0
- package/src/start-options.ts +46 -0
- package/src/store.ts +2316 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,2316 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { statfsSync, statSync } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, open } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, parse, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
|
|
7
|
+
import { applyMigrations } from "./migrations.js";
|
|
8
|
+
import { operatorErrors } from "./operator-error.js";
|
|
9
|
+
import {
|
|
10
|
+
assertCanonicalUuid,
|
|
11
|
+
assertServerDerivedPrincipal,
|
|
12
|
+
type Principal,
|
|
13
|
+
} from "./principal.js";
|
|
14
|
+
|
|
15
|
+
export type CubeAccess = "read" | "write" | "manage";
|
|
16
|
+
|
|
17
|
+
export interface CubeRecord {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly ownerId: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly directive: string;
|
|
22
|
+
readonly createdAt: string;
|
|
23
|
+
readonly updatedAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ActivityRecord {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly cubeId: string;
|
|
29
|
+
readonly droneId: string | null;
|
|
30
|
+
readonly actorKind: Principal["kind"];
|
|
31
|
+
readonly actorId: string;
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly createdAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LogCursor {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly created_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EnrichedActivityRecord {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly cube_id: string;
|
|
44
|
+
readonly drone_id: string | null;
|
|
45
|
+
readonly message: string;
|
|
46
|
+
readonly visibility: "broadcast" | "direct";
|
|
47
|
+
readonly created_at: string;
|
|
48
|
+
readonly drone_label: string | null;
|
|
49
|
+
readonly role_name: string | null;
|
|
50
|
+
readonly recipient_drone_ids: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ClaimRecord {
|
|
54
|
+
readonly log_entry_id: string;
|
|
55
|
+
readonly claimant_drone_id: string;
|
|
56
|
+
readonly claimant_label: string | null;
|
|
57
|
+
readonly claimant_role: string | null;
|
|
58
|
+
readonly claimed_at: string;
|
|
59
|
+
readonly stale: false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ActivityPage {
|
|
63
|
+
readonly entries: EnrichedActivityRecord[];
|
|
64
|
+
readonly cursor: LogCursor | null;
|
|
65
|
+
readonly behind_by: number;
|
|
66
|
+
readonly has_more: boolean;
|
|
67
|
+
readonly claims: ClaimRecord[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DecisionRecord {
|
|
71
|
+
readonly id: string;
|
|
72
|
+
readonly cube_id: string;
|
|
73
|
+
readonly topic: string;
|
|
74
|
+
readonly decision: string;
|
|
75
|
+
readonly rationale: string | null;
|
|
76
|
+
readonly ratified_by: string | null;
|
|
77
|
+
readonly status: "active" | "superseded" | "removed";
|
|
78
|
+
readonly supersedes: string | null;
|
|
79
|
+
readonly created_at: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RoleRecord {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly cube_id: string;
|
|
85
|
+
readonly name: string;
|
|
86
|
+
readonly short_description: string;
|
|
87
|
+
readonly is_default: boolean;
|
|
88
|
+
readonly is_human_seat: boolean;
|
|
89
|
+
readonly role_class: "queen" | "worker";
|
|
90
|
+
readonly created_at: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DroneRecord {
|
|
94
|
+
readonly id: string;
|
|
95
|
+
readonly cube_id: string;
|
|
96
|
+
readonly role_id: string;
|
|
97
|
+
readonly label: string;
|
|
98
|
+
readonly last_seen: string;
|
|
99
|
+
readonly hostname: string | null;
|
|
100
|
+
readonly created_at: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface StoreDiagnostics {
|
|
104
|
+
readonly journalMode: string;
|
|
105
|
+
readonly foreignKeys: boolean;
|
|
106
|
+
readonly schemaVersions: number[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface OpenStoreOptions {
|
|
110
|
+
readonly path: string;
|
|
111
|
+
readonly clock?: () => Date;
|
|
112
|
+
readonly storageLimits?: StorageLimits;
|
|
113
|
+
readonly capacityProbe?: () => StorageCapacity;
|
|
114
|
+
readonly cubeLimits?: CubeLimits;
|
|
115
|
+
readonly mutationHook?: (phase: string) => void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CubeLimits {
|
|
119
|
+
readonly maxCubesPerClient: number;
|
|
120
|
+
readonly maxCubesTotal: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const DEFAULT_CUBE_LIMITS: CubeLimits = Object.freeze({
|
|
124
|
+
maxCubesPerClient: 100,
|
|
125
|
+
maxCubesTotal: 1_000,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export interface StorageLimits {
|
|
129
|
+
readonly maxActivityEntriesPerCube: number;
|
|
130
|
+
readonly maxDatabaseBytes: number;
|
|
131
|
+
readonly minFreeDiskBytes: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface StorageCapacity {
|
|
135
|
+
readonly databaseBytes: number;
|
|
136
|
+
readonly freeDiskBytes: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const DEFAULT_STORAGE_LIMITS: StorageLimits = Object.freeze({
|
|
140
|
+
maxActivityEntriesPerCube: 10_000,
|
|
141
|
+
maxDatabaseBytes: 1_073_741_824,
|
|
142
|
+
minFreeDiskBytes: 67_108_864,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export interface StoreRuntime {
|
|
146
|
+
readonly forPrincipal: (principal: Principal) => ScopedStore;
|
|
147
|
+
readonly maintenance: MaintenanceStore;
|
|
148
|
+
readonly credentials: CredentialStore;
|
|
149
|
+
readonly diagnostics: () => StoreDiagnostics;
|
|
150
|
+
readonly close: () => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface DigestPair {
|
|
154
|
+
readonly lookup: Buffer;
|
|
155
|
+
readonly verifier: Buffer;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface StoredSecretDigest extends DigestPair {
|
|
159
|
+
readonly id: string;
|
|
160
|
+
readonly expiresAt?: string;
|
|
161
|
+
readonly consumedAt?: string | null;
|
|
162
|
+
readonly clientId?: string;
|
|
163
|
+
readonly revokedAt?: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface StoredInvitationDigest extends StoredSecretDigest {
|
|
167
|
+
readonly purpose: "owner" | "client";
|
|
168
|
+
readonly ownerEpoch: number | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface EnrollmentClaimResult {
|
|
172
|
+
readonly purpose: "owner" | "client";
|
|
173
|
+
readonly clientId: string;
|
|
174
|
+
readonly serverCapabilities: readonly [] | readonly ["create_cube"];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface StoredDroneSessionDigest extends StoredSecretDigest {
|
|
178
|
+
readonly sessionId: string;
|
|
179
|
+
readonly clientId: string;
|
|
180
|
+
readonly cubeId: string;
|
|
181
|
+
readonly droneId: string;
|
|
182
|
+
readonly expiresAt: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface CredentialStore {
|
|
186
|
+
readonly createRecoveryCredential: (id: string, digest: DigestPair) => void;
|
|
187
|
+
readonly findRecoveryCredential: (lookup: Buffer) => StoredSecretDigest | null;
|
|
188
|
+
readonly createInvitation: (input: {
|
|
189
|
+
readonly id: string;
|
|
190
|
+
readonly digest: DigestPair;
|
|
191
|
+
readonly expiresAt: string;
|
|
192
|
+
readonly purpose: "owner" | "client";
|
|
193
|
+
}) => void;
|
|
194
|
+
readonly findInvitation: (lookup: Buffer) => StoredInvitationDigest | null;
|
|
195
|
+
readonly claimInvitation: (input: {
|
|
196
|
+
readonly invitationId: string;
|
|
197
|
+
readonly clientId: string;
|
|
198
|
+
readonly requestedClientName: string | null;
|
|
199
|
+
readonly retryKey: string;
|
|
200
|
+
readonly credentialId: string;
|
|
201
|
+
readonly credentialDigest: DigestPair;
|
|
202
|
+
}) => EnrollmentClaimResult | null;
|
|
203
|
+
readonly findClientCredential: (lookup: Buffer) => StoredSecretDigest | null;
|
|
204
|
+
readonly clientExists: (clientId: string) => boolean;
|
|
205
|
+
readonly clientIsActive: (clientId: string) => boolean;
|
|
206
|
+
readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
|
|
207
|
+
readonly rotateClientCredential: (input: {
|
|
208
|
+
readonly clientId: string;
|
|
209
|
+
readonly credentialId: string;
|
|
210
|
+
readonly credentialDigest: DigestPair;
|
|
211
|
+
}) => boolean;
|
|
212
|
+
readonly revokeClientCredentials: (clientId: string) => void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface ScopedStore {
|
|
216
|
+
readonly createCube: (input: CreateCubeInput) => CreateCubeRecord;
|
|
217
|
+
readonly listCubes: () => CubeRecord[];
|
|
218
|
+
readonly getCube: (cubeId: string) => CubeRecord | null;
|
|
219
|
+
readonly updateDirective: (cubeId: string, directive: string) => void;
|
|
220
|
+
readonly appendActivity: (cubeId: string, message: string) => ActivityRecord;
|
|
221
|
+
readonly readActivity: (cubeId: string, limit: number) => ActivityRecord[];
|
|
222
|
+
readonly listRoles: (cubeId: string) => RoleRecord[];
|
|
223
|
+
readonly listDrones: (cubeId: string) => DroneRecord[];
|
|
224
|
+
readonly appendLog: (cubeId: string, input: {
|
|
225
|
+
readonly message: string;
|
|
226
|
+
readonly visibility?: "broadcast" | "direct";
|
|
227
|
+
readonly recipientDroneIds?: readonly string[];
|
|
228
|
+
}) => EnrichedActivityRecord;
|
|
229
|
+
readonly readLog: (cubeId: string, cursor: LogCursor | null, limit: number) => ActivityPage;
|
|
230
|
+
readonly acknowledge: (cubeId: string, entryId: string, kind: "ack" | "claim") => void;
|
|
231
|
+
readonly recordDecision: (cubeId: string, input: {
|
|
232
|
+
readonly topic: string;
|
|
233
|
+
readonly decision: string;
|
|
234
|
+
readonly rationale?: string;
|
|
235
|
+
}) => DecisionRecord;
|
|
236
|
+
readonly listDecisions: (cubeId: string) => DecisionRecord[];
|
|
237
|
+
readonly subscribeActivity: (
|
|
238
|
+
cubeId: string,
|
|
239
|
+
listener: (entry: EnrichedActivityRecord) => void,
|
|
240
|
+
) => (() => void);
|
|
241
|
+
readonly attachSeat: (input: SeatAttachInput) => SeatAttachRecord;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface CreateCubeInput {
|
|
245
|
+
readonly retryKey: string;
|
|
246
|
+
readonly name: string;
|
|
247
|
+
readonly template: "default";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface CreateCubeRecord {
|
|
251
|
+
readonly cubeId: string;
|
|
252
|
+
readonly humanSeatRoleId: string;
|
|
253
|
+
readonly defaultWorkerRoleId: string;
|
|
254
|
+
readonly access: "manage";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface SeatAttachInput {
|
|
258
|
+
readonly cubeId: string;
|
|
259
|
+
readonly roleId: string;
|
|
260
|
+
readonly retryKey: string;
|
|
261
|
+
readonly priorDroneId?: string;
|
|
262
|
+
readonly droneId: string;
|
|
263
|
+
readonly sessionId: string;
|
|
264
|
+
readonly credentialId: string;
|
|
265
|
+
readonly credentialDigest: DigestPair;
|
|
266
|
+
readonly expiresAt: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface SeatAttachRecord {
|
|
270
|
+
readonly cube: {
|
|
271
|
+
readonly id: string;
|
|
272
|
+
readonly name: string;
|
|
273
|
+
};
|
|
274
|
+
readonly role: {
|
|
275
|
+
readonly id: string;
|
|
276
|
+
readonly name: string;
|
|
277
|
+
readonly role_class: "queen" | "worker";
|
|
278
|
+
readonly is_human_seat: boolean;
|
|
279
|
+
};
|
|
280
|
+
readonly drone: {
|
|
281
|
+
readonly id: string;
|
|
282
|
+
readonly label: string;
|
|
283
|
+
};
|
|
284
|
+
readonly sessionId: string;
|
|
285
|
+
readonly expiresAt: string;
|
|
286
|
+
readonly generation: number;
|
|
287
|
+
readonly reattached: boolean;
|
|
288
|
+
readonly revokedSessionIds: readonly string[];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface MaintenanceStore {
|
|
292
|
+
readonly createClient: (input: { readonly id: string; readonly name: string }) => void;
|
|
293
|
+
readonly createCube: (input: {
|
|
294
|
+
readonly id: string;
|
|
295
|
+
readonly ownerId?: string;
|
|
296
|
+
readonly name: string;
|
|
297
|
+
readonly directive: string;
|
|
298
|
+
}) => void;
|
|
299
|
+
readonly grantClientCube: (input: {
|
|
300
|
+
readonly clientId: string;
|
|
301
|
+
readonly cubeId: string;
|
|
302
|
+
readonly access: CubeAccess;
|
|
303
|
+
}) => void;
|
|
304
|
+
readonly removeClientCubeGrant: (clientId: string, cubeId: string) => boolean;
|
|
305
|
+
readonly grantCreateCubeCapability: (clientId: string) => void;
|
|
306
|
+
readonly resetAuthorityState: () => void;
|
|
307
|
+
readonly observeAuthorityState: () => {
|
|
308
|
+
readonly enrolled_clients: number;
|
|
309
|
+
readonly enrollment_claims: number;
|
|
310
|
+
readonly cubes: number;
|
|
311
|
+
readonly roles: number;
|
|
312
|
+
readonly grants: number;
|
|
313
|
+
readonly server_capabilities: number;
|
|
314
|
+
readonly cube_create_bindings: number;
|
|
315
|
+
};
|
|
316
|
+
readonly inspectCreatedCube: (clientId: string, record: CreateCubeRecord) => {
|
|
317
|
+
readonly cube_exists: boolean;
|
|
318
|
+
readonly creator_has_grant: boolean;
|
|
319
|
+
readonly grant_count: number;
|
|
320
|
+
readonly role_count: number;
|
|
321
|
+
readonly human_seat_role_matches: boolean;
|
|
322
|
+
readonly default_worker_role_matches: boolean;
|
|
323
|
+
};
|
|
324
|
+
readonly inspectEnrollmentPrincipal: (clientId: string) => {
|
|
325
|
+
readonly active_credential_bindings: number;
|
|
326
|
+
};
|
|
327
|
+
readonly createRole: (input: {
|
|
328
|
+
readonly id: string;
|
|
329
|
+
readonly cubeId: string;
|
|
330
|
+
readonly name: string;
|
|
331
|
+
readonly roleClass?: "queen" | "worker";
|
|
332
|
+
readonly isHumanSeat?: boolean;
|
|
333
|
+
}) => void;
|
|
334
|
+
readonly createDrone: (input: {
|
|
335
|
+
readonly id: string;
|
|
336
|
+
readonly cubeId: string;
|
|
337
|
+
readonly roleId: string;
|
|
338
|
+
readonly clientId: string;
|
|
339
|
+
readonly label: string;
|
|
340
|
+
}) => void;
|
|
341
|
+
readonly createDroneSession: (input: {
|
|
342
|
+
readonly id: string;
|
|
343
|
+
readonly clientId: string;
|
|
344
|
+
readonly cubeId: string;
|
|
345
|
+
readonly droneId: string;
|
|
346
|
+
readonly expiresAt: string;
|
|
347
|
+
}) => void;
|
|
348
|
+
readonly revokeClient: (clientId: string) => void;
|
|
349
|
+
readonly revokeDroneSession: (sessionId: string) => void;
|
|
350
|
+
readonly expireActivityCursor: (cubeId: string, cursor: LogCursor) => void;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export class ScopedStoreError extends Error {
|
|
354
|
+
readonly code = "NOT_FOUND";
|
|
355
|
+
|
|
356
|
+
constructor() {
|
|
357
|
+
super("The requested resource was not found.");
|
|
358
|
+
this.name = "ScopedStoreError";
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export class CursorExpiredError extends Error {
|
|
363
|
+
readonly code = "CURSOR_EXPIRED";
|
|
364
|
+
|
|
365
|
+
constructor() {
|
|
366
|
+
super("The activity cursor has expired.");
|
|
367
|
+
this.name = "CursorExpiredError";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export class AttachConflictError extends Error {
|
|
372
|
+
readonly code = "ATTACH_CONFLICT";
|
|
373
|
+
|
|
374
|
+
constructor() {
|
|
375
|
+
super("The attach request conflicts with an existing attachment.");
|
|
376
|
+
this.name = "AttachConflictError";
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export class StorageCapacityError extends Error {
|
|
381
|
+
readonly code = "CAPACITY_EXCEEDED";
|
|
382
|
+
|
|
383
|
+
constructor() {
|
|
384
|
+
super("Storage capacity is unavailable.");
|
|
385
|
+
this.name = "StorageCapacityError";
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export class AccessDeniedError extends Error {
|
|
390
|
+
readonly code = "ACCESS_DENIED";
|
|
391
|
+
constructor() { super("Access denied."); this.name = "AccessDeniedError"; }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export class CreateCubeConflictError extends Error {
|
|
395
|
+
readonly code = "INVALID_INPUT";
|
|
396
|
+
constructor() { super("The cube creation request conflicts with an existing retry."); this.name = "CreateCubeConflictError"; }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface CubeRow {
|
|
400
|
+
readonly id: string;
|
|
401
|
+
readonly owner_id: string;
|
|
402
|
+
readonly name: string;
|
|
403
|
+
readonly directive: string;
|
|
404
|
+
readonly created_at: string;
|
|
405
|
+
readonly updated_at: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
interface ActivityRow {
|
|
409
|
+
readonly id: string;
|
|
410
|
+
readonly cube_id: string;
|
|
411
|
+
readonly drone_id: string | null;
|
|
412
|
+
readonly actor_kind: Principal["kind"];
|
|
413
|
+
readonly actor_id: string;
|
|
414
|
+
readonly message: string;
|
|
415
|
+
readonly created_at: string;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
interface ScopePredicate {
|
|
419
|
+
readonly sql: string;
|
|
420
|
+
readonly parameters: readonly (string | number)[];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
class StorageCapacityGuard {
|
|
424
|
+
readonly #limits: StorageLimits;
|
|
425
|
+
readonly #probe: () => StorageCapacity;
|
|
426
|
+
readonly #pageSize: number;
|
|
427
|
+
|
|
428
|
+
constructor(limits: StorageLimits, probe: () => StorageCapacity, pageSize: number) {
|
|
429
|
+
this.#limits = limits;
|
|
430
|
+
this.#probe = probe;
|
|
431
|
+
this.#pageSize = pageSize;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
assertCanGrow(payloadBytes: number): void {
|
|
435
|
+
try {
|
|
436
|
+
if (!Number.isSafeInteger(payloadBytes) || payloadBytes < 0) throw new StorageCapacityError();
|
|
437
|
+
const capacity = this.#probe();
|
|
438
|
+
if (capacity === null || typeof capacity !== "object" ||
|
|
439
|
+
!Number.isSafeInteger(capacity.databaseBytes) || capacity.databaseBytes < 0 ||
|
|
440
|
+
!Number.isSafeInteger(capacity.freeDiskBytes) || capacity.freeDiskBytes < 0) {
|
|
441
|
+
throw new StorageCapacityError();
|
|
442
|
+
}
|
|
443
|
+
const page = BigInt(this.#pageSize);
|
|
444
|
+
const content = BigInt(payloadBytes) + (page * 64n);
|
|
445
|
+
const contentPages = (content + page - 1n) / page;
|
|
446
|
+
const newContentBytes = contentPages * page;
|
|
447
|
+
// A transaction may dirty every existing page and write each new page to both DB and WAL.
|
|
448
|
+
const worstCaseGrowth = BigInt(capacity.databaseBytes) + (newContentBytes * 2n);
|
|
449
|
+
const projectedFootprint = BigInt(capacity.databaseBytes) + worstCaseGrowth;
|
|
450
|
+
if (projectedFootprint > BigInt(this.#limits.maxDatabaseBytes) ||
|
|
451
|
+
BigInt(capacity.freeDiskBytes) - worstCaseGrowth < BigInt(this.#limits.minFreeDiskBytes)) {
|
|
452
|
+
throw new StorageCapacityError();
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
throw new StorageCapacityError();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime> {
|
|
461
|
+
const databasePath = await prepareDatabasePath(options.path);
|
|
462
|
+
const storageLimits = options.storageLimits ?? DEFAULT_STORAGE_LIMITS;
|
|
463
|
+
const cubeLimits = options.cubeLimits ?? DEFAULT_CUBE_LIMITS;
|
|
464
|
+
validateStorageLimits(storageLimits);
|
|
465
|
+
validateStorageLimits(cubeLimits);
|
|
466
|
+
const capacityProbe = options.capacityProbe ?? (() => storageCapacity(databasePath));
|
|
467
|
+
const database = new DatabaseSync(databasePath, {
|
|
468
|
+
enableForeignKeyConstraints: true,
|
|
469
|
+
enableDoubleQuotedStringLiterals: false,
|
|
470
|
+
});
|
|
471
|
+
const clock = options.clock ?? (() => new Date());
|
|
472
|
+
try {
|
|
473
|
+
configureDatabase(database);
|
|
474
|
+
applyMigrations(database);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
database.close();
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const pageRow = database.prepare("PRAGMA page_size").get();
|
|
481
|
+
if (pageRow === undefined) {
|
|
482
|
+
database.close();
|
|
483
|
+
throw new Error("SQLite page size is unavailable.");
|
|
484
|
+
}
|
|
485
|
+
const capacityGuard = new StorageCapacityGuard(
|
|
486
|
+
storageLimits,
|
|
487
|
+
capacityProbe,
|
|
488
|
+
requiredInteger(pageRow, "page_size"),
|
|
489
|
+
);
|
|
490
|
+
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
491
|
+
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
492
|
+
const activityHub = new ActivityHub();
|
|
493
|
+
return Object.freeze({
|
|
494
|
+
forPrincipal: (principal: Principal) => {
|
|
495
|
+
assertServerDerivedPrincipal(principal);
|
|
496
|
+
return new SqliteScopedStore(
|
|
497
|
+
database,
|
|
498
|
+
principal,
|
|
499
|
+
clock,
|
|
500
|
+
activityHub,
|
|
501
|
+
storageLimits,
|
|
502
|
+
capacityGuard,
|
|
503
|
+
cubeLimits,
|
|
504
|
+
options.mutationHook,
|
|
505
|
+
);
|
|
506
|
+
},
|
|
507
|
+
maintenance,
|
|
508
|
+
credentials,
|
|
509
|
+
diagnostics: () => diagnostics(database),
|
|
510
|
+
close: () => database.close(),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
class SqliteScopedStore implements ScopedStore {
|
|
515
|
+
readonly #database: DatabaseSync;
|
|
516
|
+
readonly #principal: Principal;
|
|
517
|
+
readonly #clock: () => Date;
|
|
518
|
+
readonly #activityHub: ActivityHub;
|
|
519
|
+
readonly #storageLimits: StorageLimits;
|
|
520
|
+
readonly #capacityGuard: StorageCapacityGuard;
|
|
521
|
+
readonly #cubeLimits: CubeLimits;
|
|
522
|
+
readonly #mutationHook: ((phase: string) => void) | undefined;
|
|
523
|
+
|
|
524
|
+
constructor(
|
|
525
|
+
database: DatabaseSync,
|
|
526
|
+
principal: Principal,
|
|
527
|
+
clock: () => Date,
|
|
528
|
+
activityHub: ActivityHub,
|
|
529
|
+
storageLimits: StorageLimits,
|
|
530
|
+
capacityGuard: StorageCapacityGuard,
|
|
531
|
+
cubeLimits: CubeLimits,
|
|
532
|
+
mutationHook: ((phase: string) => void) | undefined,
|
|
533
|
+
) {
|
|
534
|
+
this.#database = database;
|
|
535
|
+
this.#principal = principal;
|
|
536
|
+
this.#clock = clock;
|
|
537
|
+
this.#activityHub = activityHub;
|
|
538
|
+
this.#storageLimits = storageLimits;
|
|
539
|
+
this.#capacityGuard = capacityGuard;
|
|
540
|
+
this.#cubeLimits = cubeLimits;
|
|
541
|
+
this.#mutationHook = mutationHook;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
createCube(input: CreateCubeInput): CreateCubeRecord {
|
|
545
|
+
if (this.#principal.kind !== "client") throw new AccessDeniedError();
|
|
546
|
+
assertCanonicalUuid(input.retryKey, "Cube creation retry key");
|
|
547
|
+
validatePresentationName(input.name);
|
|
548
|
+
if (input.template !== "default") throw new Error("Unsupported cube template.");
|
|
549
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
550
|
+
try {
|
|
551
|
+
const authorized = this.#database.prepare(`
|
|
552
|
+
SELECT 1 FROM clients AS client
|
|
553
|
+
JOIN client_server_capabilities AS capability ON capability.client_id = client.id
|
|
554
|
+
WHERE client.id = ? AND client.revoked_at IS NULL AND capability.capability = 'create_cube'
|
|
555
|
+
`).get(this.#principal.id);
|
|
556
|
+
if (authorized === undefined) throw new AccessDeniedError();
|
|
557
|
+
const existing = this.#database.prepare(`
|
|
558
|
+
SELECT name, template, cube_id, human_seat_role_id, default_worker_role_id
|
|
559
|
+
FROM cube_create_bindings WHERE client_id = ? AND retry_key = ?
|
|
560
|
+
`).get(this.#principal.id, input.retryKey);
|
|
561
|
+
if (existing !== undefined) {
|
|
562
|
+
if (requiredText(existing, "name") !== input.name || requiredText(existing, "template") !== input.template) {
|
|
563
|
+
throw new CreateCubeConflictError();
|
|
564
|
+
}
|
|
565
|
+
const result = createCubeRecord(existing);
|
|
566
|
+
this.#database.exec("COMMIT");
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
const clientCount = requiredInteger(this.#database.prepare(
|
|
570
|
+
"SELECT COUNT(*) AS count FROM cube_create_bindings WHERE client_id = ?",
|
|
571
|
+
).get(this.#principal.id)!, "count");
|
|
572
|
+
const totalCount = requiredInteger(this.#database.prepare(
|
|
573
|
+
"SELECT COUNT(*) AS count FROM cubes",
|
|
574
|
+
).get()!, "count");
|
|
575
|
+
if (clientCount >= this.#cubeLimits.maxCubesPerClient || totalCount >= this.#cubeLimits.maxCubesTotal) {
|
|
576
|
+
throw new StorageCapacityError();
|
|
577
|
+
}
|
|
578
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.name) + 16_384);
|
|
579
|
+
const now = this.#now();
|
|
580
|
+
const cubeId = randomUUID();
|
|
581
|
+
const humanSeatRoleId = randomUUID();
|
|
582
|
+
const defaultWorkerRoleId = randomUUID();
|
|
583
|
+
this.#database.prepare(`
|
|
584
|
+
INSERT INTO cubes (id, owner_id, name, directive, created_at, updated_at)
|
|
585
|
+
VALUES (?, ?, ?, '', ?, ?)
|
|
586
|
+
`).run(cubeId, this.#principal.id, input.name, now, now);
|
|
587
|
+
this.#mutationHook?.("cube.insert-cube");
|
|
588
|
+
this.#database.prepare(`
|
|
589
|
+
INSERT INTO roles (
|
|
590
|
+
id, cube_id, name, short_description, detailed_description,
|
|
591
|
+
is_default, is_human_seat, role_class, created_at
|
|
592
|
+
) VALUES (?, ?, 'Coordinator', 'Human coordination seat', '', 0, 1, 'queen', ?)
|
|
593
|
+
`).run(humanSeatRoleId, cubeId, now);
|
|
594
|
+
this.#mutationHook?.("cube.insert-human-role");
|
|
595
|
+
this.#database.prepare(`
|
|
596
|
+
INSERT INTO roles (
|
|
597
|
+
id, cube_id, name, short_description, detailed_description,
|
|
598
|
+
is_default, is_human_seat, role_class, created_at
|
|
599
|
+
) VALUES (?, ?, 'Builder', 'Default implementation worker', '', 1, 0, 'worker', ?)
|
|
600
|
+
`).run(defaultWorkerRoleId, cubeId, now);
|
|
601
|
+
this.#mutationHook?.("cube.insert-worker-role");
|
|
602
|
+
this.#database.prepare(`
|
|
603
|
+
INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
|
|
604
|
+
VALUES (?, ?, 'manage', ?)
|
|
605
|
+
`).run(this.#principal.id, cubeId, now);
|
|
606
|
+
this.#mutationHook?.("cube.insert-grant");
|
|
607
|
+
this.#database.prepare(`
|
|
608
|
+
INSERT INTO cube_create_bindings (
|
|
609
|
+
client_id, retry_key, name, template, cube_id,
|
|
610
|
+
human_seat_role_id, default_worker_role_id, created_at
|
|
611
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
612
|
+
`).run(
|
|
613
|
+
this.#principal.id, input.retryKey, input.name, input.template, cubeId,
|
|
614
|
+
humanSeatRoleId, defaultWorkerRoleId, now,
|
|
615
|
+
);
|
|
616
|
+
this.#mutationHook?.("cube.insert-binding");
|
|
617
|
+
this.#database.exec("COMMIT");
|
|
618
|
+
this.#mutationHook?.("cube.after-commit");
|
|
619
|
+
return { cubeId, humanSeatRoleId, defaultWorkerRoleId, access: "manage" };
|
|
620
|
+
} catch (error) {
|
|
621
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
listCubes(): CubeRecord[] {
|
|
627
|
+
const scope = this.#scope("read");
|
|
628
|
+
const rows = this.#database.prepare(`
|
|
629
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
|
|
630
|
+
FROM cubes AS c
|
|
631
|
+
WHERE ${scope.sql}
|
|
632
|
+
ORDER BY c.id
|
|
633
|
+
`).all(...scope.parameters);
|
|
634
|
+
return rows.map((row) => cubeRecord(cubeRow(row)));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
getCube(cubeId: string): CubeRecord | null {
|
|
638
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
639
|
+
const scope = this.#scope("read");
|
|
640
|
+
const row = this.#database.prepare(`
|
|
641
|
+
SELECT c.id, c.owner_id, c.name, c.directive, c.created_at, c.updated_at
|
|
642
|
+
FROM cubes AS c
|
|
643
|
+
WHERE c.id = ? AND ${scope.sql}
|
|
644
|
+
`).get(cubeId, ...scope.parameters);
|
|
645
|
+
return row === undefined ? null : cubeRecord(cubeRow(row));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
updateDirective(cubeId: string, directive: string): void {
|
|
649
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
650
|
+
validateDirective(directive);
|
|
651
|
+
const scope = this.#scope("manage");
|
|
652
|
+
this.#requireCube(cubeId, "manage");
|
|
653
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(directive));
|
|
654
|
+
const result = this.#database.prepare(`
|
|
655
|
+
UPDATE cubes AS c
|
|
656
|
+
SET directive = ?, updated_at = ?
|
|
657
|
+
WHERE c.id = ? AND ${scope.sql}
|
|
658
|
+
`).run(directive, this.#now(), cubeId, ...scope.parameters);
|
|
659
|
+
if (result.changes !== 1) throw new ScopedStoreError();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
appendActivity(cubeId: string, message: string): ActivityRecord {
|
|
663
|
+
const entry = this.appendLog(cubeId, { message });
|
|
664
|
+
const droneId = this.#principal.kind === "drone-session" ? this.#principal.droneId : null;
|
|
665
|
+
return {
|
|
666
|
+
id: entry.id,
|
|
667
|
+
cubeId: entry.cube_id,
|
|
668
|
+
droneId,
|
|
669
|
+
actorKind: this.#principal.kind,
|
|
670
|
+
actorId: this.#principal.id,
|
|
671
|
+
message: entry.message,
|
|
672
|
+
createdAt: entry.created_at,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
readActivity(cubeId: string, limit: number): ActivityRecord[] {
|
|
677
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
678
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
679
|
+
throw new Error("Activity read limit must be an integer from 1 to 500.");
|
|
680
|
+
}
|
|
681
|
+
const scope = this.#scope("read");
|
|
682
|
+
const rows = this.#database.prepare(`
|
|
683
|
+
SELECT l.id, l.cube_id, l.drone_id, l.actor_kind, l.actor_id, l.message, l.created_at
|
|
684
|
+
FROM activity_log AS l
|
|
685
|
+
JOIN cubes AS c ON c.id = l.cube_id
|
|
686
|
+
WHERE c.id = ? AND ${scope.sql}
|
|
687
|
+
ORDER BY l.created_at, l.id
|
|
688
|
+
LIMIT ?
|
|
689
|
+
`).all(cubeId, ...scope.parameters, limit);
|
|
690
|
+
return rows.map((row) => activityRecord(activityRow(row)));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
listRoles(cubeId: string): RoleRecord[] {
|
|
694
|
+
this.#requireCube(cubeId, "read");
|
|
695
|
+
const rows = this.#database.prepare(`
|
|
696
|
+
SELECT id, cube_id, name, short_description, is_default, is_human_seat,
|
|
697
|
+
role_class, created_at
|
|
698
|
+
FROM roles WHERE cube_id = ? ORDER BY name, id
|
|
699
|
+
`).all(cubeId);
|
|
700
|
+
return rows.map(roleRecord);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
listDrones(cubeId: string): DroneRecord[] {
|
|
704
|
+
this.#requireCube(cubeId, "read");
|
|
705
|
+
const rows = this.#database.prepare(`
|
|
706
|
+
SELECT id, cube_id, role_id, label, COALESCE(last_seen, created_at) AS last_seen,
|
|
707
|
+
hostname, created_at
|
|
708
|
+
FROM drones WHERE cube_id = ? AND evicted_at IS NULL ORDER BY label, id
|
|
709
|
+
`).all(cubeId);
|
|
710
|
+
return rows.map(droneRecord);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
appendLog(cubeId: string, input: {
|
|
714
|
+
readonly message: string;
|
|
715
|
+
readonly visibility?: "broadcast" | "direct";
|
|
716
|
+
readonly recipientDroneIds?: readonly string[];
|
|
717
|
+
}): EnrichedActivityRecord {
|
|
718
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
719
|
+
if (input.message.length === 0 || Buffer.byteLength(input.message) > 10_240) {
|
|
720
|
+
throw new Error("Activity message must contain 1 to 10240 bytes.");
|
|
721
|
+
}
|
|
722
|
+
const visibility = input.visibility ?? "broadcast";
|
|
723
|
+
if (visibility !== "broadcast" && visibility !== "direct") {
|
|
724
|
+
throw new Error("Unknown activity visibility.");
|
|
725
|
+
}
|
|
726
|
+
const recipients = [...new Set(input.recipientDroneIds ?? [])];
|
|
727
|
+
if (recipients.length > 100 || recipients.some((id) => {
|
|
728
|
+
try { assertCanonicalUuid(id, "Recipient drone id"); return false; } catch { return true; }
|
|
729
|
+
})) {
|
|
730
|
+
throw new Error("Activity recipients must contain at most 100 valid drone ids.");
|
|
731
|
+
}
|
|
732
|
+
if (visibility === "broadcast" && recipients.length !== 0) {
|
|
733
|
+
throw new Error("Broadcast activity cannot name direct recipients.");
|
|
734
|
+
}
|
|
735
|
+
if (visibility === "direct" && recipients.length === 0) {
|
|
736
|
+
throw new Error("Direct activity requires at least one recipient.");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const scope = this.#scope("write");
|
|
740
|
+
this.#requireCube(cubeId, "write");
|
|
741
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.message) + (recipients.length * 128));
|
|
742
|
+
const id = randomUUID();
|
|
743
|
+
const createdAt = this.#nextActivityTimestamp(cubeId);
|
|
744
|
+
const droneId = this.#principal.kind === "drone-session" ? this.#principal.droneId : null;
|
|
745
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
746
|
+
try {
|
|
747
|
+
const inserted = this.#database.prepare(`
|
|
748
|
+
INSERT INTO activity_log (
|
|
749
|
+
id, cube_id, drone_id, actor_kind, actor_id, message, created_at, visibility
|
|
750
|
+
)
|
|
751
|
+
SELECT ?, c.id, ?, ?, ?, ?, ?, ?
|
|
752
|
+
FROM cubes AS c
|
|
753
|
+
WHERE c.id = ? AND ${scope.sql}
|
|
754
|
+
`).run(
|
|
755
|
+
id, droneId, this.#principal.kind, this.#principal.id, input.message,
|
|
756
|
+
createdAt, visibility, cubeId, ...scope.parameters,
|
|
757
|
+
);
|
|
758
|
+
if (inserted.changes !== 1) throw new ScopedStoreError();
|
|
759
|
+
if (recipients.length > 0) {
|
|
760
|
+
const valid = this.#database.prepare(`
|
|
761
|
+
SELECT COUNT(*) AS count FROM drones
|
|
762
|
+
WHERE cube_id = ? AND evicted_at IS NULL
|
|
763
|
+
AND id IN (${recipients.map(() => "?").join(", ")})
|
|
764
|
+
`).get(cubeId, ...recipients);
|
|
765
|
+
if (valid === undefined) throw new Error("Recipient count query returned no row.");
|
|
766
|
+
if (requiredInteger(valid, "count") !== recipients.length) throw new ScopedStoreError();
|
|
767
|
+
const addRecipient = this.#database.prepare(
|
|
768
|
+
"INSERT INTO activity_log_recipients (entry_id, drone_id) VALUES (?, ?)",
|
|
769
|
+
);
|
|
770
|
+
for (const recipient of recipients) addRecipient.run(id, recipient);
|
|
771
|
+
}
|
|
772
|
+
this.#pruneActivity(cubeId);
|
|
773
|
+
this.#database.exec("COMMIT");
|
|
774
|
+
} catch (error) {
|
|
775
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
776
|
+
throw error;
|
|
777
|
+
}
|
|
778
|
+
const entry = this.#enrichedEntry(cubeId, id);
|
|
779
|
+
this.#activityHub.publish(cubeId, entry);
|
|
780
|
+
return entry;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
readLog(cubeId: string, cursor: LogCursor | null, limit: number): ActivityPage {
|
|
784
|
+
this.#requireCube(cubeId, "read");
|
|
785
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
786
|
+
throw new Error("Activity read limit must be an integer from 1 to 500.");
|
|
787
|
+
}
|
|
788
|
+
this.#validateCursor(cubeId, cursor);
|
|
789
|
+
const cursorSql = cursor === null
|
|
790
|
+
? { sql: "1 = 1", parameters: [] as string[] }
|
|
791
|
+
: {
|
|
792
|
+
sql: "(l.created_at > ? OR (l.created_at = ? AND l.id > ?))",
|
|
793
|
+
parameters: [cursor.created_at, cursor.created_at, cursor.id],
|
|
794
|
+
};
|
|
795
|
+
const rows = this.#database.prepare(`
|
|
796
|
+
SELECT l.id
|
|
797
|
+
FROM activity_log AS l
|
|
798
|
+
WHERE l.cube_id = ? AND ${cursorSql.sql}
|
|
799
|
+
ORDER BY l.created_at, l.id
|
|
800
|
+
LIMIT ?
|
|
801
|
+
`).all(cubeId, ...cursorSql.parameters, limit + 1);
|
|
802
|
+
const selected = rows.slice(0, limit);
|
|
803
|
+
const entries = selected.map((row) => this.#enrichedEntry(cubeId, requiredText(row, "id")));
|
|
804
|
+
const nextCursor = entries.length === 0
|
|
805
|
+
? cursor
|
|
806
|
+
: { id: entries.at(-1)!.id, created_at: entries.at(-1)!.created_at };
|
|
807
|
+
const behind = nextCursor === null ? this.#countAfter(cubeId, null) : this.#countAfter(cubeId, nextCursor);
|
|
808
|
+
return {
|
|
809
|
+
entries,
|
|
810
|
+
cursor: nextCursor,
|
|
811
|
+
behind_by: behind,
|
|
812
|
+
has_more: behind > 0,
|
|
813
|
+
claims: this.#claims(cubeId),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
acknowledge(cubeId: string, entryId: string, kind: "ack" | "claim"): void {
|
|
818
|
+
this.#requireCube(cubeId, "write");
|
|
819
|
+
assertCanonicalUuid(entryId, "Activity entry id");
|
|
820
|
+
if (kind !== "ack" && kind !== "claim") throw new Error("Unknown acknowledgement kind.");
|
|
821
|
+
const exists = this.#database.prepare(
|
|
822
|
+
"SELECT 1 AS present FROM activity_log WHERE id = ? AND cube_id = ?",
|
|
823
|
+
).get(entryId, cubeId);
|
|
824
|
+
if (exists === undefined) throw new ScopedStoreError();
|
|
825
|
+
this.#capacityGuard.assertCanGrow(512);
|
|
826
|
+
const claimant = this.#principal.kind === "drone-session"
|
|
827
|
+
? this.#principal.droneId
|
|
828
|
+
: this.#principal.id;
|
|
829
|
+
this.#database.prepare(`
|
|
830
|
+
INSERT OR IGNORE INTO activity_acks (
|
|
831
|
+
entry_id, principal_kind, principal_id, kind, created_at, claimant_drone_id
|
|
832
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
833
|
+
`).run(entryId, this.#principal.kind, this.#principal.id, kind, this.#now(), claimant);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
recordDecision(cubeId: string, input: {
|
|
837
|
+
readonly topic: string;
|
|
838
|
+
readonly decision: string;
|
|
839
|
+
readonly rationale?: string;
|
|
840
|
+
}): DecisionRecord {
|
|
841
|
+
this.#requireCube(cubeId, "manage");
|
|
842
|
+
validateBoundedText(input.topic, "Decision topic", 120);
|
|
843
|
+
validateBoundedText(input.decision, "Decision", 100_000);
|
|
844
|
+
if (input.rationale !== undefined) validateBoundedText(input.rationale, "Decision rationale", 100_000);
|
|
845
|
+
this.#capacityGuard.assertCanGrow(
|
|
846
|
+
Buffer.byteLength(input.topic) + Buffer.byteLength(input.decision) +
|
|
847
|
+
(input.rationale === undefined ? 0 : Buffer.byteLength(input.rationale)),
|
|
848
|
+
);
|
|
849
|
+
const id = randomUUID();
|
|
850
|
+
const now = this.#now();
|
|
851
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
852
|
+
try {
|
|
853
|
+
const previous = this.#database.prepare(`
|
|
854
|
+
SELECT id FROM decisions WHERE cube_id = ? AND topic = ? AND status = 'active'
|
|
855
|
+
`).get(cubeId, input.topic);
|
|
856
|
+
const supersedes = previous === undefined ? null : requiredText(previous, "id");
|
|
857
|
+
if (supersedes !== null) {
|
|
858
|
+
this.#database.prepare("UPDATE decisions SET status = 'superseded' WHERE id = ?")
|
|
859
|
+
.run(supersedes);
|
|
860
|
+
}
|
|
861
|
+
this.#database.prepare(`
|
|
862
|
+
INSERT INTO decisions (
|
|
863
|
+
id, cube_id, topic, decision, rationale, ratified_by, status, supersedes, created_at
|
|
864
|
+
) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
|
865
|
+
`).run(
|
|
866
|
+
id, cubeId, input.topic, input.decision, input.rationale ?? null,
|
|
867
|
+
this.#principal.kind === "drone-session" ? this.#principal.droneId : null,
|
|
868
|
+
supersedes, now,
|
|
869
|
+
);
|
|
870
|
+
this.#database.exec("COMMIT");
|
|
871
|
+
} catch (error) {
|
|
872
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
return this.#decision(id);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
listDecisions(cubeId: string): DecisionRecord[] {
|
|
879
|
+
this.#requireCube(cubeId, "read");
|
|
880
|
+
return this.#database.prepare(`
|
|
881
|
+
SELECT id, cube_id, topic, decision, rationale, ratified_by, status, supersedes, created_at
|
|
882
|
+
FROM decisions WHERE cube_id = ? AND status = 'active' ORDER BY topic, created_at, id
|
|
883
|
+
`).all(cubeId).map(decisionRecord);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
attachSeat(input: SeatAttachInput): SeatAttachRecord {
|
|
887
|
+
if (this.#principal.kind !== "client") throw new ScopedStoreError();
|
|
888
|
+
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
889
|
+
assertCanonicalUuid(input.roleId, "Role id");
|
|
890
|
+
assertCanonicalUuid(input.retryKey, "Retry key");
|
|
891
|
+
if (input.priorDroneId !== undefined) assertCanonicalUuid(input.priorDroneId, "Prior drone id");
|
|
892
|
+
assertCanonicalUuid(input.droneId, "Drone id");
|
|
893
|
+
assertCanonicalUuid(input.sessionId, "Drone session id");
|
|
894
|
+
assertCanonicalUuid(input.credentialId, "Drone session credential id");
|
|
895
|
+
validateDigest(input.credentialDigest);
|
|
896
|
+
validateTimestamp(input.expiresAt);
|
|
897
|
+
const scope = this.#scope("read");
|
|
898
|
+
const authorizedRole = this.#database.prepare(`
|
|
899
|
+
SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
|
|
900
|
+
WHERE c.id = ? AND role.id = ? AND ${scope.sql}
|
|
901
|
+
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
902
|
+
if (authorizedRole === undefined) throw new ScopedStoreError();
|
|
903
|
+
this.#capacityGuard.assertCanGrow(4_096);
|
|
904
|
+
const now = this.#now();
|
|
905
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
906
|
+
try {
|
|
907
|
+
const roleRow = this.#database.prepare(`
|
|
908
|
+
SELECT c.id AS cube_id, c.name AS cube_name, role.id AS role_id,
|
|
909
|
+
role.name AS role_name, role.role_class, role.is_human_seat
|
|
910
|
+
FROM cubes AS c
|
|
911
|
+
JOIN roles AS role ON role.cube_id = c.id
|
|
912
|
+
WHERE c.id = ? AND role.id = ? AND ${scope.sql}
|
|
913
|
+
`).get(input.cubeId, input.roleId, ...scope.parameters);
|
|
914
|
+
if (roleRow === undefined) throw new ScopedStoreError();
|
|
915
|
+
|
|
916
|
+
const retryBinding = this.#database.prepare(`
|
|
917
|
+
SELECT binding.cube_id AS binding_cube_id,
|
|
918
|
+
binding.requested_role_id AS binding_requested_role_id,
|
|
919
|
+
binding.prior_drone_id, drone.id, drone.label,
|
|
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);
|
|
925
|
+
let droneId: string;
|
|
926
|
+
let droneLabel: string;
|
|
927
|
+
let reattached: boolean;
|
|
928
|
+
let generation: number;
|
|
929
|
+
if (retryBinding !== undefined) {
|
|
930
|
+
if (optionalText(retryBinding, "evicted_at") != null) throw new ScopedStoreError();
|
|
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();
|
|
935
|
+
}
|
|
936
|
+
droneId = requiredText(retryBinding, "id");
|
|
937
|
+
droneLabel = requiredText(retryBinding, "label");
|
|
938
|
+
generation = requiredInteger(retryBinding, "attach_generation") + 1;
|
|
939
|
+
this.#database.prepare(`
|
|
940
|
+
UPDATE drones SET last_seen = ?, attach_generation = ? WHERE id = ?
|
|
941
|
+
`).run(now, generation, droneId);
|
|
942
|
+
reattached = true;
|
|
943
|
+
} else {
|
|
944
|
+
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
945
|
+
SELECT id, label, attach_generation
|
|
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
|
+
}
|
|
978
|
+
this.#database.prepare(`
|
|
979
|
+
INSERT INTO seat_attach_bindings (
|
|
980
|
+
client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
|
|
981
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
982
|
+
`).run(
|
|
983
|
+
this.#principal.id, input.retryKey, input.cubeId, input.roleId,
|
|
984
|
+
droneId, input.priorDroneId ?? null, now,
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
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
|
+
this.#database.prepare(`
|
|
1004
|
+
INSERT INTO drone_sessions (
|
|
1005
|
+
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
1006
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1007
|
+
`).run(
|
|
1008
|
+
input.sessionId,
|
|
1009
|
+
this.#principal.id,
|
|
1010
|
+
input.cubeId,
|
|
1011
|
+
droneId,
|
|
1012
|
+
now,
|
|
1013
|
+
input.expiresAt,
|
|
1014
|
+
);
|
|
1015
|
+
this.#database.prepare(`
|
|
1016
|
+
INSERT INTO drone_session_credentials (
|
|
1017
|
+
id, session_id, lookup_digest, verifier_digest, created_at
|
|
1018
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1019
|
+
`).run(
|
|
1020
|
+
input.credentialId,
|
|
1021
|
+
input.sessionId,
|
|
1022
|
+
input.credentialDigest.lookup,
|
|
1023
|
+
input.credentialDigest.verifier,
|
|
1024
|
+
now,
|
|
1025
|
+
);
|
|
1026
|
+
const attachedRoleRow = this.#database.prepare(`
|
|
1027
|
+
SELECT role.id AS role_id, role.name AS role_name, role.role_class, role.is_human_seat
|
|
1028
|
+
FROM roles AS role WHERE role.id = (SELECT role_id FROM drones WHERE id = ?)
|
|
1029
|
+
`).get(droneId);
|
|
1030
|
+
if (attachedRoleRow === undefined) throw new Error("Attached drone role is unavailable.");
|
|
1031
|
+
const roleClass = requiredText(attachedRoleRow, "role_class");
|
|
1032
|
+
if (roleClass !== "queen" && roleClass !== "worker") {
|
|
1033
|
+
throw new Error("Database contains invalid role class.");
|
|
1034
|
+
}
|
|
1035
|
+
this.#database.exec("COMMIT");
|
|
1036
|
+
return {
|
|
1037
|
+
cube: {
|
|
1038
|
+
id: requiredText(roleRow, "cube_id"),
|
|
1039
|
+
name: requiredText(roleRow, "cube_name"),
|
|
1040
|
+
},
|
|
1041
|
+
role: {
|
|
1042
|
+
id: requiredText(attachedRoleRow, "role_id"),
|
|
1043
|
+
name: requiredText(attachedRoleRow, "role_name"),
|
|
1044
|
+
role_class: roleClass,
|
|
1045
|
+
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
1046
|
+
},
|
|
1047
|
+
drone: { id: droneId, label: droneLabel },
|
|
1048
|
+
sessionId: input.sessionId,
|
|
1049
|
+
expiresAt: input.expiresAt,
|
|
1050
|
+
generation,
|
|
1051
|
+
reattached,
|
|
1052
|
+
revokedSessionIds: oldSessions,
|
|
1053
|
+
};
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
subscribeActivity(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
|
|
1061
|
+
this.#requireCube(cubeId, "read");
|
|
1062
|
+
return this.#activityHub.subscribe(cubeId, listener);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
#requireCube(cubeId: string, access: CubeAccess): void {
|
|
1066
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1067
|
+
const scope = this.#scope(access);
|
|
1068
|
+
const row = this.#database.prepare(`
|
|
1069
|
+
SELECT 1 AS present FROM cubes AS c WHERE c.id = ? AND ${scope.sql}
|
|
1070
|
+
`).get(cubeId, ...scope.parameters);
|
|
1071
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
#nextActivityTimestamp(cubeId: string): string {
|
|
1075
|
+
const now = this.#clock().getTime();
|
|
1076
|
+
const latest = this.#database.prepare(`
|
|
1077
|
+
SELECT created_at FROM activity_log WHERE cube_id = ? ORDER BY created_at DESC, id DESC LIMIT 1
|
|
1078
|
+
`).get(cubeId);
|
|
1079
|
+
if (latest === undefined) return new Date(now).toISOString();
|
|
1080
|
+
return new Date(Math.max(now, Date.parse(requiredText(latest, "created_at")) + 1)).toISOString();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
#pruneActivity(cubeId: string): void {
|
|
1084
|
+
const excess = this.#database.prepare(`
|
|
1085
|
+
SELECT id, created_at FROM activity_log WHERE cube_id = ?
|
|
1086
|
+
ORDER BY created_at, id
|
|
1087
|
+
LIMIT MAX(0, (SELECT COUNT(*) FROM activity_log WHERE cube_id = ?) - ?)
|
|
1088
|
+
`).all(cubeId, cubeId, this.#storageLimits.maxActivityEntriesPerCube);
|
|
1089
|
+
const expire = this.#database.prepare(`
|
|
1090
|
+
INSERT OR IGNORE INTO expired_activity_cursors (cube_id, entry_id, created_at)
|
|
1091
|
+
VALUES (?, ?, ?)
|
|
1092
|
+
`);
|
|
1093
|
+
const remove = this.#database.prepare("DELETE FROM activity_log WHERE cube_id = ? AND id = ?");
|
|
1094
|
+
for (const row of excess) {
|
|
1095
|
+
const entryId = requiredText(row, "id");
|
|
1096
|
+
expire.run(cubeId, entryId, requiredText(row, "created_at"));
|
|
1097
|
+
remove.run(cubeId, entryId);
|
|
1098
|
+
}
|
|
1099
|
+
const staleCursors = this.#database.prepare(`
|
|
1100
|
+
SELECT entry_id, created_at FROM expired_activity_cursors WHERE cube_id = ?
|
|
1101
|
+
ORDER BY created_at DESC, entry_id DESC LIMIT -1 OFFSET ?
|
|
1102
|
+
`).all(cubeId, this.#storageLimits.maxActivityEntriesPerCube);
|
|
1103
|
+
const removeCursor = this.#database.prepare(`
|
|
1104
|
+
DELETE FROM expired_activity_cursors WHERE cube_id = ? AND entry_id = ? AND created_at = ?
|
|
1105
|
+
`);
|
|
1106
|
+
for (const row of staleCursors) {
|
|
1107
|
+
removeCursor.run(cubeId, requiredText(row, "entry_id"), requiredText(row, "created_at"));
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
#validateCursor(cubeId: string, cursor: LogCursor | null): void {
|
|
1112
|
+
if (cursor === null) return;
|
|
1113
|
+
assertCanonicalUuid(cursor.id, "Activity cursor id");
|
|
1114
|
+
validateTimestamp(cursor.created_at);
|
|
1115
|
+
const expired = this.#database.prepare(`
|
|
1116
|
+
SELECT 1 AS present FROM expired_activity_cursors
|
|
1117
|
+
WHERE cube_id = ? AND entry_id = ? AND created_at = ?
|
|
1118
|
+
`).get(cubeId, cursor.id, cursor.created_at);
|
|
1119
|
+
if (expired !== undefined) throw new CursorExpiredError();
|
|
1120
|
+
const valid = this.#database.prepare(`
|
|
1121
|
+
SELECT 1 AS present FROM activity_log WHERE cube_id = ? AND id = ? AND created_at = ?
|
|
1122
|
+
`).get(cubeId, cursor.id, cursor.created_at);
|
|
1123
|
+
if (valid === undefined) throw new ScopedStoreError();
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
#countAfter(cubeId: string, cursor: LogCursor | null): number {
|
|
1127
|
+
const row = cursor === null
|
|
1128
|
+
? this.#database.prepare(
|
|
1129
|
+
"SELECT COUNT(*) AS count FROM activity_log WHERE cube_id = ?",
|
|
1130
|
+
).get(cubeId)
|
|
1131
|
+
: this.#database.prepare(`
|
|
1132
|
+
SELECT COUNT(*) AS count FROM activity_log
|
|
1133
|
+
WHERE cube_id = ? AND (created_at > ? OR (created_at = ? AND id > ?))
|
|
1134
|
+
`).get(cubeId, cursor.created_at, cursor.created_at, cursor.id);
|
|
1135
|
+
if (row === undefined) throw new Error("Activity count query returned no row.");
|
|
1136
|
+
return requiredInteger(row, "count");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
#enrichedEntry(cubeId: string, entryId: string): EnrichedActivityRecord {
|
|
1140
|
+
const row = this.#database.prepare(`
|
|
1141
|
+
SELECT l.id, l.cube_id, l.drone_id, l.message, l.visibility, l.created_at,
|
|
1142
|
+
d.label AS drone_label, r.name AS role_name
|
|
1143
|
+
FROM activity_log AS l
|
|
1144
|
+
LEFT JOIN drones AS d ON d.id = l.drone_id AND d.cube_id = l.cube_id
|
|
1145
|
+
LEFT JOIN roles AS r ON r.id = d.role_id AND r.cube_id = d.cube_id
|
|
1146
|
+
WHERE l.cube_id = ? AND l.id = ?
|
|
1147
|
+
`).get(cubeId, entryId);
|
|
1148
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
1149
|
+
const recipientRows = this.#database.prepare(`
|
|
1150
|
+
SELECT drone_id FROM activity_log_recipients WHERE entry_id = ? ORDER BY drone_id
|
|
1151
|
+
`).all(entryId);
|
|
1152
|
+
return enrichedActivityRecord(row, recipientRows.map((recipient) => requiredText(recipient, "drone_id")));
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
#claims(cubeId: string): ClaimRecord[] {
|
|
1156
|
+
return this.#database.prepare(`
|
|
1157
|
+
SELECT acknowledgement.entry_id AS log_entry_id,
|
|
1158
|
+
acknowledgement.claimant_drone_id,
|
|
1159
|
+
drone.label AS claimant_label,
|
|
1160
|
+
role.name AS claimant_role,
|
|
1161
|
+
acknowledgement.created_at AS claimed_at
|
|
1162
|
+
FROM activity_acks AS acknowledgement
|
|
1163
|
+
JOIN activity_log AS entry ON entry.id = acknowledgement.entry_id
|
|
1164
|
+
LEFT JOIN drones AS drone ON drone.id = acknowledgement.claimant_drone_id
|
|
1165
|
+
AND drone.cube_id = entry.cube_id
|
|
1166
|
+
LEFT JOIN roles AS role ON role.id = drone.role_id AND role.cube_id = drone.cube_id
|
|
1167
|
+
WHERE entry.cube_id = ? AND acknowledgement.kind = 'claim'
|
|
1168
|
+
AND acknowledgement.claimant_drone_id IS NOT NULL
|
|
1169
|
+
ORDER BY acknowledgement.created_at, acknowledgement.entry_id,
|
|
1170
|
+
acknowledgement.claimant_drone_id
|
|
1171
|
+
`).all(cubeId).map(claimRecord);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
#decision(id: string): DecisionRecord {
|
|
1175
|
+
const row = this.#database.prepare(`
|
|
1176
|
+
SELECT id, cube_id, topic, decision, rationale, ratified_by, status, supersedes, created_at
|
|
1177
|
+
FROM decisions WHERE id = ?
|
|
1178
|
+
`).get(id);
|
|
1179
|
+
if (row === undefined) throw new ScopedStoreError();
|
|
1180
|
+
return decisionRecord(row);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
#scope(access: CubeAccess): ScopePredicate {
|
|
1184
|
+
if (this.#principal.kind === "operator") return { sql: "1 = 1", parameters: [] };
|
|
1185
|
+
if (this.#principal.kind === "client") {
|
|
1186
|
+
const allowed = allowedGrantAccess(access);
|
|
1187
|
+
const placeholders = allowed.map(() => "?").join(", ");
|
|
1188
|
+
return {
|
|
1189
|
+
sql: `EXISTS (
|
|
1190
|
+
SELECT 1
|
|
1191
|
+
FROM clients AS authorized_client
|
|
1192
|
+
JOIN client_cube_grants AS grant_row
|
|
1193
|
+
ON grant_row.client_id = authorized_client.id
|
|
1194
|
+
WHERE authorized_client.id = ?
|
|
1195
|
+
AND authorized_client.revoked_at IS NULL
|
|
1196
|
+
AND grant_row.cube_id = c.id
|
|
1197
|
+
AND grant_row.access IN (${placeholders})
|
|
1198
|
+
)`,
|
|
1199
|
+
parameters: [this.#principal.id, ...allowed],
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
if (access === "manage") return { sql: "0 = 1", parameters: [] };
|
|
1203
|
+
const allowed = allowedGrantAccess(access);
|
|
1204
|
+
const placeholders = allowed.map(() => "?").join(", ");
|
|
1205
|
+
return {
|
|
1206
|
+
sql: `EXISTS (
|
|
1207
|
+
SELECT 1
|
|
1208
|
+
FROM drone_sessions AS authorized_session
|
|
1209
|
+
JOIN clients AS authorized_client
|
|
1210
|
+
ON authorized_client.id = authorized_session.client_id
|
|
1211
|
+
JOIN client_cube_grants AS parent_grant
|
|
1212
|
+
ON parent_grant.client_id = authorized_session.client_id
|
|
1213
|
+
AND parent_grant.cube_id = authorized_session.cube_id
|
|
1214
|
+
JOIN drones AS authorized_drone
|
|
1215
|
+
ON authorized_drone.id = authorized_session.drone_id
|
|
1216
|
+
AND authorized_drone.client_id = authorized_session.client_id
|
|
1217
|
+
AND authorized_drone.cube_id = authorized_session.cube_id
|
|
1218
|
+
WHERE authorized_session.id = ?
|
|
1219
|
+
AND authorized_session.client_id = ?
|
|
1220
|
+
AND authorized_session.cube_id = ?
|
|
1221
|
+
AND authorized_session.drone_id = ?
|
|
1222
|
+
AND authorized_session.cube_id = c.id
|
|
1223
|
+
AND authorized_session.revoked_at IS NULL
|
|
1224
|
+
AND authorized_session.expires_at > ?
|
|
1225
|
+
AND authorized_client.revoked_at IS NULL
|
|
1226
|
+
AND authorized_drone.evicted_at IS NULL
|
|
1227
|
+
AND parent_grant.access IN (${placeholders})
|
|
1228
|
+
)`,
|
|
1229
|
+
parameters: [
|
|
1230
|
+
this.#principal.id,
|
|
1231
|
+
this.#principal.clientId,
|
|
1232
|
+
this.#principal.cubeId,
|
|
1233
|
+
this.#principal.droneId,
|
|
1234
|
+
this.#now(),
|
|
1235
|
+
...allowed,
|
|
1236
|
+
],
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
#now(): string {
|
|
1241
|
+
return this.#clock().toISOString();
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
class SqliteMaintenanceStore implements MaintenanceStore {
|
|
1246
|
+
readonly #database: DatabaseSync;
|
|
1247
|
+
readonly #clock: () => Date;
|
|
1248
|
+
|
|
1249
|
+
constructor(database: DatabaseSync, clock: () => Date) {
|
|
1250
|
+
this.#database = database;
|
|
1251
|
+
this.#clock = clock;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
createClient(input: { readonly id: string; readonly name: string }): void {
|
|
1255
|
+
assertCanonicalUuid(input.id, "Client id");
|
|
1256
|
+
validateName(input.name);
|
|
1257
|
+
this.#database.prepare(
|
|
1258
|
+
"INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)",
|
|
1259
|
+
).run(input.id, input.name, this.#now());
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
createCube(input: {
|
|
1263
|
+
readonly id: string;
|
|
1264
|
+
readonly ownerId?: string;
|
|
1265
|
+
readonly name: string;
|
|
1266
|
+
readonly directive: string;
|
|
1267
|
+
}): void {
|
|
1268
|
+
assertCanonicalUuid(input.id, "Cube id");
|
|
1269
|
+
if (input.ownerId !== undefined) assertCanonicalUuid(input.ownerId, "Cube owner id");
|
|
1270
|
+
validateName(input.name);
|
|
1271
|
+
validateDirective(input.directive);
|
|
1272
|
+
const now = this.#now();
|
|
1273
|
+
this.#database.prepare(`
|
|
1274
|
+
INSERT INTO cubes (id, owner_id, name, directive, created_at, updated_at)
|
|
1275
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1276
|
+
`).run(
|
|
1277
|
+
input.id,
|
|
1278
|
+
input.ownerId ?? "00000000-0000-4000-8000-000000000000",
|
|
1279
|
+
input.name,
|
|
1280
|
+
input.directive,
|
|
1281
|
+
now,
|
|
1282
|
+
now,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
grantClientCube(input: {
|
|
1287
|
+
readonly clientId: string;
|
|
1288
|
+
readonly cubeId: string;
|
|
1289
|
+
readonly access: CubeAccess;
|
|
1290
|
+
}): void {
|
|
1291
|
+
assertCanonicalUuid(input.clientId, "Client id");
|
|
1292
|
+
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
1293
|
+
if (!(["read", "write", "manage"] as const).includes(input.access)) {
|
|
1294
|
+
throw new Error("Unknown cube access grant.");
|
|
1295
|
+
}
|
|
1296
|
+
this.#database.prepare(`
|
|
1297
|
+
INSERT INTO client_cube_grants (client_id, cube_id, access, created_at)
|
|
1298
|
+
VALUES (?, ?, ?, ?)
|
|
1299
|
+
ON CONFLICT (client_id, cube_id) DO UPDATE SET access = excluded.access
|
|
1300
|
+
`).run(input.clientId, input.cubeId, input.access, this.#now());
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
removeClientCubeGrant(clientId: string, cubeId: string): boolean {
|
|
1304
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1305
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1306
|
+
const result = this.#database.prepare(
|
|
1307
|
+
"DELETE FROM client_cube_grants WHERE client_id = ? AND cube_id = ?",
|
|
1308
|
+
).run(clientId, cubeId);
|
|
1309
|
+
return result.changes === 1;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
grantCreateCubeCapability(clientId: string): void {
|
|
1313
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1314
|
+
this.#database.prepare(`
|
|
1315
|
+
INSERT OR IGNORE INTO client_server_capabilities (client_id, capability, created_at)
|
|
1316
|
+
VALUES (?, 'create_cube', ?)
|
|
1317
|
+
`).run(clientId, this.#now());
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
resetAuthorityState(): void {
|
|
1321
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1322
|
+
try {
|
|
1323
|
+
for (const table of [
|
|
1324
|
+
"cube_create_bindings", "activity_acks", "activity_log_recipients", "activity_log",
|
|
1325
|
+
"decisions", "expired_activity_cursors", "drone_session_credentials", "drone_sessions",
|
|
1326
|
+
"seat_attach_bindings", "drones", "roles", "client_cube_grants", "cubes", "client_server_capabilities",
|
|
1327
|
+
"enrollment_claims", "client_credentials", "owner_enrollment_state", "clients",
|
|
1328
|
+
"enrollment_invitations",
|
|
1329
|
+
]) this.#database.exec(`DELETE FROM ${table}`);
|
|
1330
|
+
this.#database.exec("COMMIT");
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1333
|
+
throw error;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
observeAuthorityState() {
|
|
1338
|
+
const count = (table: string): number => requiredInteger(
|
|
1339
|
+
this.#database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get()!,
|
|
1340
|
+
"count",
|
|
1341
|
+
);
|
|
1342
|
+
return {
|
|
1343
|
+
enrolled_clients: count("clients"),
|
|
1344
|
+
enrollment_claims: count("enrollment_claims"),
|
|
1345
|
+
cubes: count("cubes"),
|
|
1346
|
+
roles: count("roles"),
|
|
1347
|
+
grants: count("client_cube_grants"),
|
|
1348
|
+
server_capabilities: count("client_server_capabilities"),
|
|
1349
|
+
cube_create_bindings: count("cube_create_bindings"),
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
inspectCreatedCube(clientId: string, record: CreateCubeRecord) {
|
|
1354
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1355
|
+
const cube = this.#database.prepare("SELECT 1 FROM cubes WHERE id = ?").get(record.cubeId);
|
|
1356
|
+
const grant = this.#database.prepare(`
|
|
1357
|
+
SELECT access FROM client_cube_grants WHERE client_id = ? AND cube_id = ?
|
|
1358
|
+
`).get(clientId, record.cubeId);
|
|
1359
|
+
const roles = requiredInteger(this.#database.prepare(
|
|
1360
|
+
"SELECT COUNT(*) AS count FROM roles WHERE cube_id = ?",
|
|
1361
|
+
).get(record.cubeId)!, "count");
|
|
1362
|
+
const human = this.#database.prepare(`
|
|
1363
|
+
SELECT 1 FROM roles WHERE id = ? AND cube_id = ? AND is_human_seat = 1 AND role_class = 'queen'
|
|
1364
|
+
`).get(record.humanSeatRoleId, record.cubeId);
|
|
1365
|
+
const worker = this.#database.prepare(`
|
|
1366
|
+
SELECT 1 FROM roles WHERE id = ? AND cube_id = ? AND is_default = 1 AND role_class = 'worker'
|
|
1367
|
+
`).get(record.defaultWorkerRoleId, record.cubeId);
|
|
1368
|
+
const grants = requiredInteger(this.#database.prepare(
|
|
1369
|
+
"SELECT COUNT(*) AS count FROM client_cube_grants WHERE cube_id = ?",
|
|
1370
|
+
).get(record.cubeId)!, "count");
|
|
1371
|
+
return {
|
|
1372
|
+
cube_exists: cube !== undefined,
|
|
1373
|
+
creator_has_grant: grant !== undefined && requiredText(grant, "access") === "manage",
|
|
1374
|
+
grant_count: grants,
|
|
1375
|
+
role_count: roles,
|
|
1376
|
+
human_seat_role_matches: human !== undefined,
|
|
1377
|
+
default_worker_role_matches: worker !== undefined,
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
inspectEnrollmentPrincipal(clientId: string) {
|
|
1382
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1383
|
+
const count = requiredInteger(this.#database.prepare(`
|
|
1384
|
+
SELECT COUNT(*) AS count FROM client_credentials
|
|
1385
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
1386
|
+
`).get(clientId)!, "count");
|
|
1387
|
+
return { active_credential_bindings: count };
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
createRole(input: {
|
|
1391
|
+
readonly id: string;
|
|
1392
|
+
readonly cubeId: string;
|
|
1393
|
+
readonly name: string;
|
|
1394
|
+
readonly roleClass?: "queen" | "worker";
|
|
1395
|
+
readonly isHumanSeat?: boolean;
|
|
1396
|
+
}): void {
|
|
1397
|
+
assertCanonicalUuid(input.id, "Role id");
|
|
1398
|
+
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
1399
|
+
validateName(input.name);
|
|
1400
|
+
const roleClass = input.roleClass ?? "worker";
|
|
1401
|
+
if (roleClass !== "queen" && roleClass !== "worker") throw new Error("Unknown role class.");
|
|
1402
|
+
this.#database.prepare(`
|
|
1403
|
+
INSERT INTO roles (
|
|
1404
|
+
id, cube_id, name, is_human_seat, role_class, created_at
|
|
1405
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1406
|
+
`).run(
|
|
1407
|
+
input.id,
|
|
1408
|
+
input.cubeId,
|
|
1409
|
+
input.name,
|
|
1410
|
+
input.isHumanSeat === true ? 1 : 0,
|
|
1411
|
+
roleClass,
|
|
1412
|
+
this.#now(),
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
createDrone(input: {
|
|
1417
|
+
readonly id: string;
|
|
1418
|
+
readonly cubeId: string;
|
|
1419
|
+
readonly roleId: string;
|
|
1420
|
+
readonly clientId: string;
|
|
1421
|
+
readonly label: string;
|
|
1422
|
+
}): void {
|
|
1423
|
+
assertCanonicalUuid(input.id, "Drone id");
|
|
1424
|
+
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
1425
|
+
assertCanonicalUuid(input.roleId, "Role id");
|
|
1426
|
+
assertCanonicalUuid(input.clientId, "Client id");
|
|
1427
|
+
validateName(input.label);
|
|
1428
|
+
this.#database.prepare(`
|
|
1429
|
+
INSERT INTO drones (id, cube_id, role_id, client_id, label, created_at)
|
|
1430
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1431
|
+
`).run(input.id, input.cubeId, input.roleId, input.clientId, input.label, this.#now());
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
createDroneSession(input: {
|
|
1435
|
+
readonly id: string;
|
|
1436
|
+
readonly clientId: string;
|
|
1437
|
+
readonly cubeId: string;
|
|
1438
|
+
readonly droneId: string;
|
|
1439
|
+
readonly expiresAt: string;
|
|
1440
|
+
}): void {
|
|
1441
|
+
assertCanonicalUuid(input.id, "Drone session id");
|
|
1442
|
+
assertCanonicalUuid(input.clientId, "Client id");
|
|
1443
|
+
assertCanonicalUuid(input.cubeId, "Cube id");
|
|
1444
|
+
assertCanonicalUuid(input.droneId, "Drone id");
|
|
1445
|
+
validateTimestamp(input.expiresAt);
|
|
1446
|
+
this.#database.prepare(`
|
|
1447
|
+
INSERT INTO drone_sessions (
|
|
1448
|
+
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
1449
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1450
|
+
`).run(
|
|
1451
|
+
input.id,
|
|
1452
|
+
input.clientId,
|
|
1453
|
+
input.cubeId,
|
|
1454
|
+
input.droneId,
|
|
1455
|
+
this.#now(),
|
|
1456
|
+
input.expiresAt,
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
revokeClient(clientId: string): void {
|
|
1461
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1462
|
+
this.#database.prepare(
|
|
1463
|
+
"UPDATE clients SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
|
|
1464
|
+
).run(this.#now(), clientId);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
revokeDroneSession(sessionId: string): void {
|
|
1468
|
+
assertCanonicalUuid(sessionId, "Drone session id");
|
|
1469
|
+
this.#database.prepare(
|
|
1470
|
+
"UPDATE drone_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
|
|
1471
|
+
).run(this.#now(), sessionId);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
expireActivityCursor(cubeId: string, cursor: LogCursor): void {
|
|
1475
|
+
assertCanonicalUuid(cubeId, "Cube id");
|
|
1476
|
+
assertCanonicalUuid(cursor.id, "Activity cursor id");
|
|
1477
|
+
validateTimestamp(cursor.created_at);
|
|
1478
|
+
const entry = this.#database.prepare(`
|
|
1479
|
+
SELECT 1 AS present FROM activity_log WHERE cube_id = ? AND id = ? AND created_at = ?
|
|
1480
|
+
`).get(cubeId, cursor.id, cursor.created_at);
|
|
1481
|
+
if (entry === undefined) throw new ScopedStoreError();
|
|
1482
|
+
this.#database.prepare(`
|
|
1483
|
+
INSERT OR IGNORE INTO expired_activity_cursors (cube_id, entry_id, created_at)
|
|
1484
|
+
VALUES (?, ?, ?)
|
|
1485
|
+
`).run(cubeId, cursor.id, cursor.created_at);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
#now(): string {
|
|
1489
|
+
return this.#clock().toISOString();
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
class ActivityHub {
|
|
1494
|
+
readonly #listeners = new Map<string, Set<(entry: EnrichedActivityRecord) => void>>();
|
|
1495
|
+
|
|
1496
|
+
subscribe(cubeId: string, listener: (entry: EnrichedActivityRecord) => void): () => void {
|
|
1497
|
+
const listeners = this.#listeners.get(cubeId) ?? new Set();
|
|
1498
|
+
listeners.add(listener);
|
|
1499
|
+
this.#listeners.set(cubeId, listeners);
|
|
1500
|
+
return () => {
|
|
1501
|
+
listeners.delete(listener);
|
|
1502
|
+
if (listeners.size === 0) this.#listeners.delete(cubeId);
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
publish(cubeId: string, entry: EnrichedActivityRecord): void {
|
|
1507
|
+
for (const listener of this.#listeners.get(cubeId) ?? []) {
|
|
1508
|
+
try {
|
|
1509
|
+
listener(entry);
|
|
1510
|
+
} catch {
|
|
1511
|
+
// A live subscriber cannot roll back or alter a committed append.
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
class SqliteCredentialStore implements CredentialStore {
|
|
1518
|
+
readonly #database: DatabaseSync;
|
|
1519
|
+
readonly #clock: () => Date;
|
|
1520
|
+
readonly #capacityGuard: StorageCapacityGuard;
|
|
1521
|
+
readonly #mutationHook: ((phase: string) => void) | undefined;
|
|
1522
|
+
|
|
1523
|
+
constructor(
|
|
1524
|
+
database: DatabaseSync,
|
|
1525
|
+
clock: () => Date,
|
|
1526
|
+
capacityGuard: StorageCapacityGuard,
|
|
1527
|
+
mutationHook?: (phase: string) => void,
|
|
1528
|
+
) {
|
|
1529
|
+
this.#database = database;
|
|
1530
|
+
this.#clock = clock;
|
|
1531
|
+
this.#capacityGuard = capacityGuard;
|
|
1532
|
+
this.#mutationHook = mutationHook;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
createRecoveryCredential(id: string, digest: DigestPair): void {
|
|
1536
|
+
assertCanonicalUuid(id, "Recovery credential id");
|
|
1537
|
+
validateDigest(digest);
|
|
1538
|
+
this.#database.prepare(`
|
|
1539
|
+
INSERT INTO recovery_credentials (
|
|
1540
|
+
id, lookup_digest, verifier_digest, created_at
|
|
1541
|
+
) VALUES (?, ?, ?, ?)
|
|
1542
|
+
`).run(id, digest.lookup, digest.verifier, this.#now());
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
findRecoveryCredential(lookup: Buffer): StoredSecretDigest | null {
|
|
1546
|
+
validateLookup(lookup);
|
|
1547
|
+
const row = this.#database.prepare(`
|
|
1548
|
+
SELECT id, lookup_digest, verifier_digest, revoked_at
|
|
1549
|
+
FROM recovery_credentials
|
|
1550
|
+
WHERE lookup_digest = ? AND revoked_at IS NULL
|
|
1551
|
+
`).get(lookup);
|
|
1552
|
+
return row === undefined ? null : storedDigest(row);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
createInvitation(input: {
|
|
1556
|
+
readonly id: string;
|
|
1557
|
+
readonly digest: DigestPair;
|
|
1558
|
+
readonly expiresAt: string;
|
|
1559
|
+
readonly purpose: "owner" | "client";
|
|
1560
|
+
}): void {
|
|
1561
|
+
assertCanonicalUuid(input.id, "Invitation id");
|
|
1562
|
+
validateDigest(input.digest);
|
|
1563
|
+
validateTimestamp(input.expiresAt);
|
|
1564
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1565
|
+
try {
|
|
1566
|
+
let ownerEpoch: number | null = null;
|
|
1567
|
+
if (input.purpose === "owner") {
|
|
1568
|
+
const state = this.#database.prepare(
|
|
1569
|
+
"SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1",
|
|
1570
|
+
).get();
|
|
1571
|
+
if (state === undefined) {
|
|
1572
|
+
ownerEpoch = 1;
|
|
1573
|
+
this.#database.prepare(`
|
|
1574
|
+
INSERT INTO owner_enrollment_state (singleton, epoch) VALUES (1, 1)
|
|
1575
|
+
`).run();
|
|
1576
|
+
} else {
|
|
1577
|
+
if (nullableText(state, "claimed_client_id") !== null) throw new AccessDeniedError();
|
|
1578
|
+
ownerEpoch = requiredInteger(state, "epoch") + 1;
|
|
1579
|
+
this.#database.prepare(`
|
|
1580
|
+
UPDATE enrollment_invitations SET revoked_at = ?
|
|
1581
|
+
WHERE purpose = 'owner' AND consumed_at IS NULL AND revoked_at IS NULL
|
|
1582
|
+
`).run(this.#now());
|
|
1583
|
+
this.#database.prepare(`
|
|
1584
|
+
UPDATE owner_enrollment_state SET epoch = ? WHERE singleton = 1
|
|
1585
|
+
`).run(ownerEpoch);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
this.#database.prepare(`
|
|
1589
|
+
INSERT INTO enrollment_invitations (
|
|
1590
|
+
id, lookup_digest, verifier_digest, expires_at, created_at, purpose, owner_epoch
|
|
1591
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1592
|
+
`).run(
|
|
1593
|
+
input.id, input.digest.lookup, input.digest.verifier, input.expiresAt,
|
|
1594
|
+
this.#now(), input.purpose, ownerEpoch,
|
|
1595
|
+
);
|
|
1596
|
+
this.#database.exec("COMMIT");
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
try { this.#database.exec("ROLLBACK"); } catch { /* Preserve the original failure. */ }
|
|
1599
|
+
throw error;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
findInvitation(lookup: Buffer): StoredInvitationDigest | null {
|
|
1604
|
+
validateLookup(lookup);
|
|
1605
|
+
const row = this.#database.prepare(`
|
|
1606
|
+
SELECT invitation.id IS NOT NULL AS found,
|
|
1607
|
+
COALESCE(invitation.id, '00000000-0000-4000-8000-000000000000') AS id,
|
|
1608
|
+
COALESCE(invitation.lookup_digest, zeroblob(32)) AS lookup_digest,
|
|
1609
|
+
COALESCE(invitation.verifier_digest, zeroblob(32)) AS verifier_digest,
|
|
1610
|
+
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1611
|
+
invitation.consumed_at, invitation.revoked_at,
|
|
1612
|
+
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1613
|
+
invitation.owner_epoch
|
|
1614
|
+
FROM (SELECT 1) AS seed
|
|
1615
|
+
LEFT JOIN enrollment_invitations AS invitation ON invitation.lookup_digest = ?
|
|
1616
|
+
`).get(lookup);
|
|
1617
|
+
if (row === undefined) throw new Error("Invitation lookup did not return a sentinel row.");
|
|
1618
|
+
const stored = storedInvitationDigest(row);
|
|
1619
|
+
return requiredInteger(row, "found") === 1 ? stored : null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
claimInvitation(input: {
|
|
1623
|
+
readonly invitationId: string;
|
|
1624
|
+
readonly clientId: string;
|
|
1625
|
+
readonly requestedClientName: string | null;
|
|
1626
|
+
readonly retryKey: string;
|
|
1627
|
+
readonly credentialId: string;
|
|
1628
|
+
readonly credentialDigest: DigestPair;
|
|
1629
|
+
}): EnrollmentClaimResult | null {
|
|
1630
|
+
assertCanonicalUuid(input.invitationId, "Invitation id");
|
|
1631
|
+
assertCanonicalUuid(input.clientId, "Client id");
|
|
1632
|
+
assertCanonicalUuid(input.credentialId, "Credential id");
|
|
1633
|
+
assertCanonicalUuid(input.retryKey, "Enrollment retry key");
|
|
1634
|
+
if (input.requestedClientName !== null) validatePresentationName(input.requestedClientName);
|
|
1635
|
+
validateDigest(input.credentialDigest);
|
|
1636
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1637
|
+
try {
|
|
1638
|
+
const now = this.#now();
|
|
1639
|
+
const invitation = this.#database.prepare(`
|
|
1640
|
+
SELECT invitation.id IS NOT NULL AS found,
|
|
1641
|
+
COALESCE(invitation.purpose, 'client') AS purpose,
|
|
1642
|
+
invitation.owner_epoch,
|
|
1643
|
+
COALESCE(invitation.expires_at, '') AS expires_at,
|
|
1644
|
+
invitation.consumed_at, invitation.revoked_at
|
|
1645
|
+
FROM (SELECT 1) AS seed
|
|
1646
|
+
LEFT JOIN enrollment_invitations AS invitation ON invitation.id = ?
|
|
1647
|
+
`).get(input.invitationId);
|
|
1648
|
+
const existing = this.#database.prepare(`
|
|
1649
|
+
SELECT claim.invitation_id IS NOT NULL AS found,
|
|
1650
|
+
COALESCE(claim.retry_key, '') AS retry_key,
|
|
1651
|
+
COALESCE(claim.client_id, '') AS client_id,
|
|
1652
|
+
claim.requested_client_name,
|
|
1653
|
+
COALESCE(claim.credential_lookup_digest, zeroblob(32)) AS credential_lookup_digest,
|
|
1654
|
+
COALESCE(claim.credential_verifier_digest, zeroblob(32)) AS credential_verifier_digest,
|
|
1655
|
+
COALESCE(claim.purpose, 'client') AS purpose,
|
|
1656
|
+
claim.owner_epoch
|
|
1657
|
+
FROM (SELECT 1) AS seed
|
|
1658
|
+
LEFT JOIN enrollment_claims AS claim ON claim.invitation_id = ?
|
|
1659
|
+
`).get(input.invitationId);
|
|
1660
|
+
if (invitation === undefined || existing === undefined) {
|
|
1661
|
+
throw new Error("Enrollment lookup did not return a sentinel row.");
|
|
1662
|
+
}
|
|
1663
|
+
const invitationFound = requiredInteger(invitation, "found") === 1;
|
|
1664
|
+
const existingFound = requiredInteger(existing, "found") === 1;
|
|
1665
|
+
const purposeValue = requiredText(invitation, "purpose");
|
|
1666
|
+
if (purposeValue !== "owner" && purposeValue !== "client") {
|
|
1667
|
+
throw new Error("Invalid invitation purpose.");
|
|
1668
|
+
}
|
|
1669
|
+
const purpose = purposeValue;
|
|
1670
|
+
const claimMatch = matchEnrollmentClaim(existing, input, purpose);
|
|
1671
|
+
if (!invitationFound) {
|
|
1672
|
+
this.#database.exec("ROLLBACK");
|
|
1673
|
+
return null;
|
|
1674
|
+
}
|
|
1675
|
+
if (existingFound) {
|
|
1676
|
+
if (!claimMatch.exact) {
|
|
1677
|
+
this.#database.exec("ROLLBACK");
|
|
1678
|
+
return null;
|
|
1679
|
+
}
|
|
1680
|
+
const result = enrollmentClaimResult(purpose, claimMatch.clientId);
|
|
1681
|
+
this.#database.exec("COMMIT");
|
|
1682
|
+
return result;
|
|
1683
|
+
}
|
|
1684
|
+
if (nullableText(invitation, "consumed_at") !== null || nullableText(invitation, "revoked_at") !== null ||
|
|
1685
|
+
requiredText(invitation, "expires_at") <= now) {
|
|
1686
|
+
this.#database.exec("ROLLBACK");
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
const ownerEpoch = invitation["owner_epoch"] === null
|
|
1690
|
+
? null
|
|
1691
|
+
: requiredInteger(invitation, "owner_epoch");
|
|
1692
|
+
if (purpose === "owner") {
|
|
1693
|
+
const state = this.#database.prepare(`
|
|
1694
|
+
SELECT epoch, claimed_client_id FROM owner_enrollment_state WHERE singleton = 1
|
|
1695
|
+
`).get();
|
|
1696
|
+
if (state === undefined || requiredInteger(state, "epoch") !== ownerEpoch ||
|
|
1697
|
+
nullableText(state, "claimed_client_id") !== null) {
|
|
1698
|
+
this.#database.exec("ROLLBACK");
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
this.#capacityGuard.assertCanGrow(Buffer.byteLength(input.requestedClientName ?? "") + 8_192);
|
|
1703
|
+
this.#database.prepare(
|
|
1704
|
+
"INSERT INTO clients (id, name, created_at) VALUES (?, ?, ?)",
|
|
1705
|
+
).run(input.clientId, input.requestedClientName ?? "Local client", now);
|
|
1706
|
+
this.#mutationHook?.("enrollment.insert-client");
|
|
1707
|
+
this.#database.prepare(`
|
|
1708
|
+
INSERT INTO client_credentials (
|
|
1709
|
+
id, client_id, lookup_digest, verifier_digest, created_at
|
|
1710
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1711
|
+
`).run(
|
|
1712
|
+
input.credentialId,
|
|
1713
|
+
input.clientId,
|
|
1714
|
+
input.credentialDigest.lookup,
|
|
1715
|
+
input.credentialDigest.verifier,
|
|
1716
|
+
now,
|
|
1717
|
+
);
|
|
1718
|
+
this.#mutationHook?.("enrollment.insert-credential");
|
|
1719
|
+
if (purpose === "owner") {
|
|
1720
|
+
this.#database.prepare(`
|
|
1721
|
+
INSERT INTO client_server_capabilities (client_id, capability, created_at)
|
|
1722
|
+
VALUES (?, 'create_cube', ?)
|
|
1723
|
+
`).run(input.clientId, now);
|
|
1724
|
+
this.#mutationHook?.("enrollment.insert-capability");
|
|
1725
|
+
}
|
|
1726
|
+
this.#database.prepare(`
|
|
1727
|
+
INSERT INTO enrollment_claims (
|
|
1728
|
+
invitation_id, retry_key, client_id, requested_client_name,
|
|
1729
|
+
credential_lookup_digest, credential_verifier_digest, purpose, owner_epoch, created_at
|
|
1730
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1731
|
+
`).run(
|
|
1732
|
+
input.invitationId, input.retryKey, input.clientId, input.requestedClientName,
|
|
1733
|
+
input.credentialDigest.lookup, input.credentialDigest.verifier, purpose, ownerEpoch, now,
|
|
1734
|
+
);
|
|
1735
|
+
this.#mutationHook?.("enrollment.insert-claim");
|
|
1736
|
+
const consumed = this.#database.prepare(`
|
|
1737
|
+
UPDATE enrollment_invitations SET consumed_at = ?
|
|
1738
|
+
WHERE id = ? AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at > ?
|
|
1739
|
+
`).run(now, input.invitationId, now);
|
|
1740
|
+
if (consumed.changes !== 1) throw new Error("Invitation claim raced.");
|
|
1741
|
+
this.#mutationHook?.("enrollment.consume-invitation");
|
|
1742
|
+
if (purpose === "owner") {
|
|
1743
|
+
const claimed = this.#database.prepare(`
|
|
1744
|
+
UPDATE owner_enrollment_state SET claimed_client_id = ?, claimed_at = ?
|
|
1745
|
+
WHERE singleton = 1 AND epoch = ? AND claimed_client_id IS NULL
|
|
1746
|
+
`).run(input.clientId, now, ownerEpoch);
|
|
1747
|
+
if (claimed.changes !== 1) throw new Error("Owner claim raced.");
|
|
1748
|
+
this.#mutationHook?.("enrollment.claim-owner");
|
|
1749
|
+
}
|
|
1750
|
+
this.#database.exec("COMMIT");
|
|
1751
|
+
this.#mutationHook?.("enrollment.after-commit");
|
|
1752
|
+
return enrollmentClaimResult(purpose, input.clientId);
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
try {
|
|
1755
|
+
this.#database.exec("ROLLBACK");
|
|
1756
|
+
} catch {
|
|
1757
|
+
// Preserve the originating storage failure.
|
|
1758
|
+
}
|
|
1759
|
+
throw error;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
findClientCredential(lookup: Buffer): StoredSecretDigest | null {
|
|
1764
|
+
validateLookup(lookup);
|
|
1765
|
+
const row = this.#database.prepare(`
|
|
1766
|
+
SELECT credential.id, credential.client_id, credential.lookup_digest,
|
|
1767
|
+
credential.verifier_digest,
|
|
1768
|
+
COALESCE(credential.revoked_at, client.revoked_at) AS revoked_at
|
|
1769
|
+
FROM client_credentials AS credential
|
|
1770
|
+
JOIN clients AS client ON client.id = credential.client_id
|
|
1771
|
+
WHERE credential.lookup_digest = ?
|
|
1772
|
+
`).get(lookup);
|
|
1773
|
+
return row === undefined ? null : storedDigest(row);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
clientExists(clientId: string): boolean {
|
|
1777
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1778
|
+
return this.#database.prepare("SELECT 1 FROM clients WHERE id = ?").get(clientId) !== undefined;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
clientIsActive(clientId: string): boolean {
|
|
1782
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1783
|
+
return this.#database.prepare(
|
|
1784
|
+
"SELECT 1 FROM clients WHERE id = ? AND revoked_at IS NULL",
|
|
1785
|
+
).get(clientId) !== undefined;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
findDroneSessionCredential(lookup: Buffer): StoredDroneSessionDigest | null {
|
|
1789
|
+
validateLookup(lookup);
|
|
1790
|
+
const row = this.#database.prepare(`
|
|
1791
|
+
SELECT credential.id, credential.lookup_digest, credential.verifier_digest,
|
|
1792
|
+
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1793
|
+
session.expires_at,
|
|
1794
|
+
COALESCE(
|
|
1795
|
+
credential.revoked_at, session.revoked_at, client.revoked_at, drone.evicted_at
|
|
1796
|
+
) AS revoked_at
|
|
1797
|
+
FROM drone_session_credentials AS credential
|
|
1798
|
+
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1799
|
+
JOIN clients AS client ON client.id = session.client_id
|
|
1800
|
+
JOIN drones AS drone ON drone.id = session.drone_id
|
|
1801
|
+
AND drone.client_id = session.client_id
|
|
1802
|
+
AND drone.cube_id = session.cube_id
|
|
1803
|
+
WHERE credential.lookup_digest = ?
|
|
1804
|
+
`).get(lookup);
|
|
1805
|
+
return row === undefined ? null : storedDroneSessionDigest(row);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
rotateClientCredential(input: {
|
|
1809
|
+
readonly clientId: string;
|
|
1810
|
+
readonly credentialId: string;
|
|
1811
|
+
readonly credentialDigest: DigestPair;
|
|
1812
|
+
}): boolean {
|
|
1813
|
+
assertCanonicalUuid(input.clientId, "Client id");
|
|
1814
|
+
assertCanonicalUuid(input.credentialId, "Credential id");
|
|
1815
|
+
validateDigest(input.credentialDigest);
|
|
1816
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1817
|
+
try {
|
|
1818
|
+
const active = this.#database.prepare(
|
|
1819
|
+
"SELECT 1 FROM clients WHERE id = ? AND revoked_at IS NULL",
|
|
1820
|
+
).get(input.clientId);
|
|
1821
|
+
if (active === undefined) {
|
|
1822
|
+
this.#database.exec("ROLLBACK");
|
|
1823
|
+
return false;
|
|
1824
|
+
}
|
|
1825
|
+
const now = this.#now();
|
|
1826
|
+
this.#database.prepare(`
|
|
1827
|
+
UPDATE client_credentials SET revoked_at = ?
|
|
1828
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
1829
|
+
`).run(now, input.clientId);
|
|
1830
|
+
this.#database.prepare(`
|
|
1831
|
+
INSERT INTO client_credentials (
|
|
1832
|
+
id, client_id, lookup_digest, verifier_digest, created_at
|
|
1833
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1834
|
+
`).run(
|
|
1835
|
+
input.credentialId,
|
|
1836
|
+
input.clientId,
|
|
1837
|
+
input.credentialDigest.lookup,
|
|
1838
|
+
input.credentialDigest.verifier,
|
|
1839
|
+
now,
|
|
1840
|
+
);
|
|
1841
|
+
this.#database.exec("COMMIT");
|
|
1842
|
+
return true;
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
try {
|
|
1845
|
+
this.#database.exec("ROLLBACK");
|
|
1846
|
+
} catch {
|
|
1847
|
+
// Preserve the originating storage failure.
|
|
1848
|
+
}
|
|
1849
|
+
throw error;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
revokeClientCredentials(clientId: string): void {
|
|
1854
|
+
assertCanonicalUuid(clientId, "Client id");
|
|
1855
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1856
|
+
try {
|
|
1857
|
+
const now = this.#now();
|
|
1858
|
+
this.#database.prepare(
|
|
1859
|
+
"UPDATE clients SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
|
|
1860
|
+
).run(now, clientId);
|
|
1861
|
+
this.#database.prepare(`
|
|
1862
|
+
UPDATE client_credentials SET revoked_at = ?
|
|
1863
|
+
WHERE client_id = ? AND revoked_at IS NULL
|
|
1864
|
+
`).run(now, clientId);
|
|
1865
|
+
this.#database.exec("COMMIT");
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
try {
|
|
1868
|
+
this.#database.exec("ROLLBACK");
|
|
1869
|
+
} catch {
|
|
1870
|
+
// Preserve the originating storage failure.
|
|
1871
|
+
}
|
|
1872
|
+
throw error;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
#now(): string {
|
|
1877
|
+
return this.#clock().toISOString();
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
async function prepareDatabasePath(path: string): Promise<string> {
|
|
1882
|
+
if (path === ":memory:") throw new Error("The server store requires a file-backed database.");
|
|
1883
|
+
const databasePath = resolve(path);
|
|
1884
|
+
const directory = dirname(databasePath);
|
|
1885
|
+
await ensureDirectoryTree(directory);
|
|
1886
|
+
await chmod(directory, 0o700);
|
|
1887
|
+
try {
|
|
1888
|
+
const handle = await open(databasePath, "ax", 0o600);
|
|
1889
|
+
await handle.close();
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
if (!isAlreadyExists(error)) throw error;
|
|
1892
|
+
const metadata = await lstat(databasePath);
|
|
1893
|
+
if (metadata.isSymbolicLink()) throw operatorErrors.DATA_PATH_SYMLINK;
|
|
1894
|
+
if (!metadata.isFile()) throw new Error("Database path must be a regular file.");
|
|
1895
|
+
}
|
|
1896
|
+
await assertDirectoryTreeHasNoSymlinks(directory);
|
|
1897
|
+
await chmod(databasePath, 0o600);
|
|
1898
|
+
return databasePath;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
async function ensureDirectoryTree(directory: string): Promise<void> {
|
|
1902
|
+
const { root } = parse(directory);
|
|
1903
|
+
let current = root;
|
|
1904
|
+
for (const component of relative(root, directory).split(sep).filter(Boolean)) {
|
|
1905
|
+
current = join(current, component);
|
|
1906
|
+
try {
|
|
1907
|
+
await assertDirectoryComponent(current);
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
if (!isMissing(error)) throw error;
|
|
1910
|
+
try {
|
|
1911
|
+
await mkdir(current, { mode: 0o700 });
|
|
1912
|
+
} catch (mkdirError) {
|
|
1913
|
+
if (!isAlreadyExists(mkdirError)) throw mkdirError;
|
|
1914
|
+
}
|
|
1915
|
+
await assertDirectoryComponent(current);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
await assertDirectoryTreeHasNoSymlinks(directory);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
async function assertDirectoryTreeHasNoSymlinks(directory: string): Promise<void> {
|
|
1922
|
+
const { root } = parse(directory);
|
|
1923
|
+
let current = root;
|
|
1924
|
+
for (const component of relative(root, directory).split(sep).filter(Boolean)) {
|
|
1925
|
+
current = join(current, component);
|
|
1926
|
+
await assertDirectoryComponent(current);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
async function assertDirectoryComponent(path: string): Promise<void> {
|
|
1931
|
+
const metadata = await lstat(path);
|
|
1932
|
+
if (metadata.isSymbolicLink()) {
|
|
1933
|
+
throw operatorErrors.DATA_PATH_SYMLINK;
|
|
1934
|
+
}
|
|
1935
|
+
if (!metadata.isDirectory()) {
|
|
1936
|
+
throw new Error("Database parent path must contain only directories.");
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function configureDatabase(database: DatabaseSync): void {
|
|
1941
|
+
database.exec(`
|
|
1942
|
+
PRAGMA foreign_keys = ON;
|
|
1943
|
+
PRAGMA journal_mode = WAL;
|
|
1944
|
+
PRAGMA synchronous = FULL;
|
|
1945
|
+
PRAGMA trusted_schema = OFF;
|
|
1946
|
+
PRAGMA secure_delete = ON;
|
|
1947
|
+
PRAGMA busy_timeout = 5000;
|
|
1948
|
+
`);
|
|
1949
|
+
if (readPragma(database, "journal_mode") !== "wal") {
|
|
1950
|
+
throw new Error("SQLite WAL mode is required.");
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function diagnostics(database: DatabaseSync): StoreDiagnostics {
|
|
1955
|
+
const journalMode = readPragma(database, "journal_mode");
|
|
1956
|
+
const foreignKeys = readPragma(database, "foreign_keys");
|
|
1957
|
+
const rows = database.prepare(
|
|
1958
|
+
"SELECT version FROM schema_migrations ORDER BY version",
|
|
1959
|
+
).all();
|
|
1960
|
+
return {
|
|
1961
|
+
journalMode: String(journalMode).toLowerCase(),
|
|
1962
|
+
foreignKeys: foreignKeys === 1,
|
|
1963
|
+
schemaVersions: rows.map((row) => requiredInteger(row, "version")),
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function readPragma(database: DatabaseSync, name: "journal_mode" | "foreign_keys"): unknown {
|
|
1968
|
+
const row = database.prepare(`PRAGMA ${name}`).get();
|
|
1969
|
+
return row?.[name];
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function cubeRecord(row: CubeRow): CubeRecord {
|
|
1973
|
+
return {
|
|
1974
|
+
id: row.id,
|
|
1975
|
+
ownerId: row.owner_id,
|
|
1976
|
+
name: row.name,
|
|
1977
|
+
directive: row.directive,
|
|
1978
|
+
createdAt: row.created_at,
|
|
1979
|
+
updatedAt: row.updated_at,
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function activityRecord(row: ActivityRow): ActivityRecord {
|
|
1984
|
+
return {
|
|
1985
|
+
id: row.id,
|
|
1986
|
+
cubeId: row.cube_id,
|
|
1987
|
+
droneId: row.drone_id,
|
|
1988
|
+
actorKind: row.actor_kind,
|
|
1989
|
+
actorId: row.actor_id,
|
|
1990
|
+
message: row.message,
|
|
1991
|
+
createdAt: row.created_at,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function cubeRow(row: Record<string, unknown>): CubeRow {
|
|
1996
|
+
return {
|
|
1997
|
+
id: requiredText(row, "id"),
|
|
1998
|
+
owner_id: requiredText(row, "owner_id"),
|
|
1999
|
+
name: requiredText(row, "name"),
|
|
2000
|
+
directive: requiredText(row, "directive"),
|
|
2001
|
+
created_at: requiredText(row, "created_at"),
|
|
2002
|
+
updated_at: requiredText(row, "updated_at"),
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function activityRow(row: Record<string, unknown>): ActivityRow {
|
|
2007
|
+
const actorKind = requiredText(row, "actor_kind");
|
|
2008
|
+
if (actorKind !== "operator" && actorKind !== "client" && actorKind !== "drone-session") {
|
|
2009
|
+
throw new Error("Database contains an invalid activity actor kind.");
|
|
2010
|
+
}
|
|
2011
|
+
const droneId = row["drone_id"];
|
|
2012
|
+
if (droneId !== null && typeof droneId !== "string") {
|
|
2013
|
+
throw new Error("Database contains an invalid activity drone id.");
|
|
2014
|
+
}
|
|
2015
|
+
return {
|
|
2016
|
+
id: requiredText(row, "id"),
|
|
2017
|
+
cube_id: requiredText(row, "cube_id"),
|
|
2018
|
+
drone_id: droneId,
|
|
2019
|
+
actor_kind: actorKind,
|
|
2020
|
+
actor_id: requiredText(row, "actor_id"),
|
|
2021
|
+
message: requiredText(row, "message"),
|
|
2022
|
+
created_at: requiredText(row, "created_at"),
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function enrichedActivityRecord(
|
|
2027
|
+
row: Record<string, unknown>,
|
|
2028
|
+
recipientDroneIds: string[],
|
|
2029
|
+
): EnrichedActivityRecord {
|
|
2030
|
+
const visibility = requiredText(row, "visibility");
|
|
2031
|
+
if (visibility !== "broadcast" && visibility !== "direct") {
|
|
2032
|
+
throw new Error("Database contains invalid activity visibility.");
|
|
2033
|
+
}
|
|
2034
|
+
return {
|
|
2035
|
+
id: requiredText(row, "id"),
|
|
2036
|
+
cube_id: requiredText(row, "cube_id"),
|
|
2037
|
+
drone_id: nullableText(row, "drone_id"),
|
|
2038
|
+
message: requiredText(row, "message"),
|
|
2039
|
+
visibility,
|
|
2040
|
+
created_at: requiredText(row, "created_at"),
|
|
2041
|
+
drone_label: nullableText(row, "drone_label"),
|
|
2042
|
+
role_name: nullableText(row, "role_name"),
|
|
2043
|
+
recipient_drone_ids: recipientDroneIds,
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function claimRecord(row: Record<string, unknown>): ClaimRecord {
|
|
2048
|
+
return {
|
|
2049
|
+
log_entry_id: requiredText(row, "log_entry_id"),
|
|
2050
|
+
claimant_drone_id: requiredText(row, "claimant_drone_id"),
|
|
2051
|
+
claimant_label: nullableText(row, "claimant_label"),
|
|
2052
|
+
claimant_role: nullableText(row, "claimant_role"),
|
|
2053
|
+
claimed_at: requiredText(row, "claimed_at"),
|
|
2054
|
+
stale: false,
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function decisionRecord(row: Record<string, unknown>): DecisionRecord {
|
|
2059
|
+
const status = requiredText(row, "status");
|
|
2060
|
+
if (status !== "active" && status !== "superseded" && status !== "removed") {
|
|
2061
|
+
throw new Error("Database contains invalid decision status.");
|
|
2062
|
+
}
|
|
2063
|
+
return {
|
|
2064
|
+
id: requiredText(row, "id"),
|
|
2065
|
+
cube_id: requiredText(row, "cube_id"),
|
|
2066
|
+
topic: requiredText(row, "topic"),
|
|
2067
|
+
decision: requiredText(row, "decision"),
|
|
2068
|
+
rationale: nullableText(row, "rationale"),
|
|
2069
|
+
ratified_by: nullableText(row, "ratified_by"),
|
|
2070
|
+
status,
|
|
2071
|
+
supersedes: nullableText(row, "supersedes"),
|
|
2072
|
+
created_at: requiredText(row, "created_at"),
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function roleRecord(row: Record<string, unknown>): RoleRecord {
|
|
2077
|
+
const roleClass = requiredText(row, "role_class");
|
|
2078
|
+
if (roleClass !== "queen" && roleClass !== "worker") {
|
|
2079
|
+
throw new Error("Database contains invalid role class.");
|
|
2080
|
+
}
|
|
2081
|
+
return {
|
|
2082
|
+
id: requiredText(row, "id"),
|
|
2083
|
+
cube_id: requiredText(row, "cube_id"),
|
|
2084
|
+
name: requiredText(row, "name"),
|
|
2085
|
+
short_description: requiredText(row, "short_description"),
|
|
2086
|
+
is_default: requiredInteger(row, "is_default") === 1,
|
|
2087
|
+
is_human_seat: requiredInteger(row, "is_human_seat") === 1,
|
|
2088
|
+
role_class: roleClass,
|
|
2089
|
+
created_at: requiredText(row, "created_at"),
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function createCubeRecord(row: Record<string, unknown>): CreateCubeRecord {
|
|
2094
|
+
return {
|
|
2095
|
+
cubeId: requiredText(row, "cube_id"),
|
|
2096
|
+
humanSeatRoleId: requiredText(row, "human_seat_role_id"),
|
|
2097
|
+
defaultWorkerRoleId: requiredText(row, "default_worker_role_id"),
|
|
2098
|
+
access: "manage",
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function droneRecord(row: Record<string, unknown>): DroneRecord {
|
|
2103
|
+
return {
|
|
2104
|
+
id: requiredText(row, "id"),
|
|
2105
|
+
cube_id: requiredText(row, "cube_id"),
|
|
2106
|
+
role_id: requiredText(row, "role_id"),
|
|
2107
|
+
label: requiredText(row, "label"),
|
|
2108
|
+
last_seen: requiredText(row, "last_seen"),
|
|
2109
|
+
hostname: nullableText(row, "hostname"),
|
|
2110
|
+
created_at: requiredText(row, "created_at"),
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function nullableText(row: Record<string, unknown>, key: string): string | null {
|
|
2115
|
+
const value = row[key];
|
|
2116
|
+
if (value === null || typeof value === "string") return value;
|
|
2117
|
+
throw new Error(`Database contains invalid ${key}.`);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function requiredText(row: Record<string, unknown>, key: string): string {
|
|
2121
|
+
const value = row[key];
|
|
2122
|
+
if (typeof value !== "string") throw new Error(`Database contains invalid ${key}.`);
|
|
2123
|
+
return value;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function requiredInteger(row: Record<string, unknown>, key: string): number {
|
|
2127
|
+
const value = row[key];
|
|
2128
|
+
if (!Number.isSafeInteger(value)) throw new Error(`Database contains invalid ${key}.`);
|
|
2129
|
+
return value as number;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function storedDigest(row: Record<string, unknown>): StoredSecretDigest {
|
|
2133
|
+
const lookup = requiredBuffer(row, "lookup_digest");
|
|
2134
|
+
const verifier = requiredBuffer(row, "verifier_digest");
|
|
2135
|
+
const expiresAt = optionalNonNullText(row, "expires_at");
|
|
2136
|
+
const consumedAt = optionalText(row, "consumed_at");
|
|
2137
|
+
const clientId = optionalNonNullText(row, "client_id");
|
|
2138
|
+
const revokedAt = optionalText(row, "revoked_at");
|
|
2139
|
+
return {
|
|
2140
|
+
id: requiredText(row, "id"),
|
|
2141
|
+
lookup,
|
|
2142
|
+
verifier,
|
|
2143
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
2144
|
+
...(consumedAt === undefined ? {} : { consumedAt }),
|
|
2145
|
+
...(clientId === undefined ? {} : { clientId }),
|
|
2146
|
+
...(revokedAt === undefined ? {} : { revokedAt }),
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function storedInvitationDigest(row: Record<string, unknown>): StoredInvitationDigest {
|
|
2151
|
+
const digest = storedDigest(row);
|
|
2152
|
+
const purpose = requiredText(row, "purpose");
|
|
2153
|
+
if (purpose !== "owner" && purpose !== "client") {
|
|
2154
|
+
throw new Error("Database contains invalid invitation purpose.");
|
|
2155
|
+
}
|
|
2156
|
+
const epochValue = row["owner_epoch"];
|
|
2157
|
+
const ownerEpoch = epochValue === null ? null : requiredInteger(row, "owner_epoch");
|
|
2158
|
+
return { ...digest, purpose, ownerEpoch };
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function enrollmentClaimResult(
|
|
2162
|
+
purpose: "owner" | "client",
|
|
2163
|
+
clientId: string,
|
|
2164
|
+
): EnrollmentClaimResult {
|
|
2165
|
+
return purpose === "owner"
|
|
2166
|
+
? { purpose, clientId, serverCapabilities: ["create_cube"] }
|
|
2167
|
+
: { purpose, clientId, serverCapabilities: [] };
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function matchEnrollmentClaim(
|
|
2171
|
+
row: Record<string, unknown>,
|
|
2172
|
+
input: {
|
|
2173
|
+
readonly retryKey: string;
|
|
2174
|
+
readonly requestedClientName: string | null;
|
|
2175
|
+
readonly credentialDigest: DigestPair;
|
|
2176
|
+
},
|
|
2177
|
+
purpose: "owner" | "client",
|
|
2178
|
+
): { exact: boolean; clientId: string } {
|
|
2179
|
+
const retryKey = requiredText(row, "retry_key");
|
|
2180
|
+
const clientId = requiredText(row, "client_id");
|
|
2181
|
+
const requestedClientName = nullableText(row, "requested_client_name");
|
|
2182
|
+
const storedPurpose = requiredText(row, "purpose");
|
|
2183
|
+
const lookup = requiredBuffer(row, "credential_lookup_digest");
|
|
2184
|
+
const verifier = requiredBuffer(row, "credential_verifier_digest");
|
|
2185
|
+
|
|
2186
|
+
const retryMatches = retryKey === input.retryKey;
|
|
2187
|
+
const nameMatches = requestedClientName === input.requestedClientName;
|
|
2188
|
+
const purposeMatches = storedPurpose === purpose;
|
|
2189
|
+
const lookupMatches = lookup.equals(input.credentialDigest.lookup);
|
|
2190
|
+
const verifierMatches = verifier.equals(input.credentialDigest.verifier);
|
|
2191
|
+
return {
|
|
2192
|
+
exact: retryMatches && nameMatches && purposeMatches && lookupMatches && verifierMatches,
|
|
2193
|
+
clientId,
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSessionDigest {
|
|
2198
|
+
const digest = storedDigest(row);
|
|
2199
|
+
return {
|
|
2200
|
+
...digest,
|
|
2201
|
+
sessionId: requiredText(row, "session_id"),
|
|
2202
|
+
clientId: requiredText(row, "client_id"),
|
|
2203
|
+
cubeId: requiredText(row, "cube_id"),
|
|
2204
|
+
droneId: requiredText(row, "drone_id"),
|
|
2205
|
+
expiresAt: requiredText(row, "expires_at"),
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function requiredBuffer(row: Record<string, unknown>, key: string): Buffer {
|
|
2210
|
+
const value = row[key];
|
|
2211
|
+
if (!(value instanceof Uint8Array)) throw new Error(`Database contains invalid ${key}.`);
|
|
2212
|
+
return Buffer.from(value);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function optionalText(row: Record<string, unknown>, key: string): string | null | undefined {
|
|
2216
|
+
const value = row[key];
|
|
2217
|
+
if (value === undefined) return undefined;
|
|
2218
|
+
if (value === null || typeof value === "string") return value;
|
|
2219
|
+
throw new Error(`Database contains invalid ${key}.`);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function optionalNonNullText(row: Record<string, unknown>, key: string): string | undefined {
|
|
2223
|
+
const value = row[key];
|
|
2224
|
+
if (value === undefined) return undefined;
|
|
2225
|
+
if (typeof value === "string") return value;
|
|
2226
|
+
throw new Error(`Database contains invalid ${key}.`);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function validateName(value: string): void {
|
|
2230
|
+
if (value.length < 1 || value.length > 120) {
|
|
2231
|
+
throw new Error("Name must contain 1 to 120 characters.");
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function validatePresentationName(value: string): void {
|
|
2236
|
+
if (Buffer.byteLength(value) < 1 || Buffer.byteLength(value) > 120 ||
|
|
2237
|
+
!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/u.test(value)) {
|
|
2238
|
+
throw new Error("Presentation name is invalid.");
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function validateBoundedText(value: string, name: string, maxBytes: number): void {
|
|
2243
|
+
if (value.length === 0 || Buffer.byteLength(value) > maxBytes) {
|
|
2244
|
+
throw new Error(`${name} must contain 1 to ${maxBytes} bytes.`);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function seatLabel(roleName: string, droneId: string): string {
|
|
2249
|
+
const role = roleName.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "") || "seat";
|
|
2250
|
+
return `${role.slice(0, 80)}-${droneId.slice(0, 8)}`;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function allowedGrantAccess(access: CubeAccess): readonly CubeAccess[] {
|
|
2254
|
+
return access === "read"
|
|
2255
|
+
? ["read", "write", "manage"]
|
|
2256
|
+
: access === "write" ? ["write", "manage"] : ["manage"];
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function validateDirective(value: string): void {
|
|
2260
|
+
if (Buffer.byteLength(value) > 100_000) {
|
|
2261
|
+
throw new Error("Cube directive exceeds 100000 bytes.");
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
function validateDigest(digest: DigestPair): void {
|
|
2266
|
+
validateLookup(digest.lookup);
|
|
2267
|
+
if (digest.verifier.length !== 32) throw new Error("Verifier digest must contain 32 bytes.");
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function validateLookup(lookup: Buffer): void {
|
|
2271
|
+
if (lookup.length !== 16) throw new Error("Lookup digest must contain 16 bytes.");
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
function validateTimestamp(value: string): void {
|
|
2275
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value) ||
|
|
2276
|
+
new Date(value).toISOString() !== value) {
|
|
2277
|
+
throw new Error("Timestamp must be canonical UTC with millisecond precision.");
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function validateStorageLimits(limits: StorageLimits | CubeLimits): void {
|
|
2282
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
2283
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
2284
|
+
throw new Error(`${name} must be a positive safe integer.`);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function storageCapacity(databasePath: string): StorageCapacity {
|
|
2290
|
+
const databaseBytes = [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]
|
|
2291
|
+
.reduce((total, path) => total + fileSize(path), 0);
|
|
2292
|
+
const filesystem = statfsSync(dirname(databasePath), { bigint: true });
|
|
2293
|
+
const freeDiskBytes = toSafeInteger(filesystem.bavail * filesystem.bsize);
|
|
2294
|
+
return { databaseBytes, freeDiskBytes };
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function fileSize(path: string): number {
|
|
2298
|
+
try {
|
|
2299
|
+
return toSafeInteger(statSync(path, { bigint: true }).size);
|
|
2300
|
+
} catch (error) {
|
|
2301
|
+
if (isMissing(error)) return 0;
|
|
2302
|
+
throw error;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function toSafeInteger(value: bigint): number {
|
|
2307
|
+
return Number(value > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : value);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function isAlreadyExists(error: unknown): boolean {
|
|
2311
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function isMissing(error: unknown): boolean {
|
|
2315
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
2316
|
+
}
|