borgmcp-server 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -22
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +232 -18
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +10 -2
- package/dist/credentials.js +44 -6
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +77 -0
- package/dist/debug-log.js +125 -0
- package/dist/debug-log.js.map +1 -0
- package/dist/https-server.d.ts +3 -0
- package/dist/https-server.js +80 -4
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +71 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +10 -3
- package/dist/service.js +218 -15
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +65 -1
- package/dist/store.js +398 -26
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +236 -14
- package/src/credentials.ts +71 -5
- package/src/debug-log.ts +165 -0
- package/src/https-server.ts +75 -2
- package/src/index.ts +1 -1
- package/src/migrations.ts +74 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +239 -18
- package/src/start-options.ts +21 -4
- package/src/store.ts +462 -28
package/src/coordination-api.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { Principal } from "./principal.js";
|
|
2
3
|
import { assertServerDerivedPrincipal } from "./principal.js";
|
|
4
|
+
import { disabledDebugLogger, type DebugLogger } from "./debug-log.js";
|
|
3
5
|
import type { CredentialAuthority } from "./credentials.js";
|
|
4
6
|
import {
|
|
5
7
|
CursorExpiredError,
|
|
6
8
|
AttachConflictError,
|
|
7
9
|
AccessDeniedError,
|
|
8
10
|
CreateCubeConflictError,
|
|
11
|
+
DefaultRoleRequiredError,
|
|
12
|
+
RoleConflictError,
|
|
13
|
+
RoleSectionConflictError,
|
|
9
14
|
ScopedStoreError,
|
|
10
15
|
StorageCapacityError,
|
|
11
16
|
type EnrichedActivityRecord,
|
|
@@ -42,11 +47,17 @@ interface ReplayBarrier {
|
|
|
42
47
|
export class CoordinationApi {
|
|
43
48
|
readonly #runtime: StoreRuntime;
|
|
44
49
|
readonly #authority: CredentialAuthority;
|
|
50
|
+
readonly #debugLogger: DebugLogger;
|
|
45
51
|
#replayBarrier: ReplayBarrier | undefined;
|
|
46
52
|
|
|
47
|
-
constructor(
|
|
53
|
+
constructor(
|
|
54
|
+
runtime: StoreRuntime,
|
|
55
|
+
authority: CredentialAuthority,
|
|
56
|
+
debugLogger: DebugLogger = disabledDebugLogger,
|
|
57
|
+
) {
|
|
48
58
|
this.#runtime = runtime;
|
|
49
59
|
this.#authority = authority;
|
|
60
|
+
this.#debugLogger = debugLogger;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
armReplayTransition(): { readonly reached: Promise<void>; readonly release: () => void } {
|
|
@@ -92,6 +103,7 @@ export class CoordinationApi {
|
|
|
92
103
|
token: attachment.credential,
|
|
93
104
|
expires_at: attachment.expiresAt,
|
|
94
105
|
generation: attachment.generation,
|
|
106
|
+
posture: attachment.posture,
|
|
95
107
|
},
|
|
96
108
|
reattached: attachment.reattached,
|
|
97
109
|
});
|
|
@@ -159,14 +171,20 @@ export class CoordinationApi {
|
|
|
159
171
|
}
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
|
|
175
|
+
.exec(request.path);
|
|
162
176
|
const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream))?$/u
|
|
163
177
|
.exec(request.path);
|
|
164
|
-
if (match === null
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
if (match === null && roleMatch === null) {
|
|
179
|
+
return failure(404, "NOT_FOUND", "The requested resource was not found.");
|
|
180
|
+
}
|
|
181
|
+
const cubeId = (roleMatch?.[1] ?? match?.[1])!;
|
|
182
|
+
const roleId = roleMatch?.[2];
|
|
183
|
+
if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId))) {
|
|
167
184
|
return failure(404, "NOT_FOUND", "The requested resource was not found.");
|
|
168
185
|
}
|
|
169
|
-
const resource = match[2];
|
|
186
|
+
const resource = roleMatch === null ? match?.[2] : "role";
|
|
187
|
+
const sectionPatch = roleMatch?.[3] !== undefined;
|
|
170
188
|
const store = this.#runtime.forPrincipal(authentication);
|
|
171
189
|
|
|
172
190
|
try {
|
|
@@ -187,6 +205,95 @@ export class CoordinationApi {
|
|
|
187
205
|
if (resource === "roles" && request.method === "GET") {
|
|
188
206
|
return success(200, "roles-read", { roles: store.listRoles(cubeId) });
|
|
189
207
|
}
|
|
208
|
+
if (resource === "roles" && request.method === "POST") {
|
|
209
|
+
const envelope = decodeEnvelope(request.body);
|
|
210
|
+
exactKeys(envelope.payload, ["name"], [
|
|
211
|
+
"short_description",
|
|
212
|
+
"detailed_description",
|
|
213
|
+
"is_default",
|
|
214
|
+
"is_mandatory",
|
|
215
|
+
"is_human_seat",
|
|
216
|
+
"can_broadcast",
|
|
217
|
+
"receives_all_direct",
|
|
218
|
+
]);
|
|
219
|
+
const role = store.createRole(cubeId, {
|
|
220
|
+
name: requiredRoleName(envelope.payload, "name"),
|
|
221
|
+
shortDescription: optionalText(envelope.payload, "short_description", 1_024) ?? "",
|
|
222
|
+
detailedDescription: optionalText(envelope.payload, "detailed_description", 51_200) ?? "",
|
|
223
|
+
isDefault: optionalBoolean(envelope.payload["is_default"]),
|
|
224
|
+
isMandatory: optionalBoolean(envelope.payload["is_mandatory"]),
|
|
225
|
+
isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
|
|
226
|
+
canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
|
|
227
|
+
receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
|
|
228
|
+
});
|
|
229
|
+
return success(201, envelope.requestId, { role });
|
|
230
|
+
}
|
|
231
|
+
if (resource === "role" && !sectionPatch && request.method === "PATCH") {
|
|
232
|
+
const envelope = decodeEnvelope(request.body);
|
|
233
|
+
exactKeys(envelope.payload, [], [
|
|
234
|
+
"name",
|
|
235
|
+
"short_description",
|
|
236
|
+
"detailed_description",
|
|
237
|
+
"is_default",
|
|
238
|
+
"is_mandatory",
|
|
239
|
+
"is_human_seat",
|
|
240
|
+
"can_broadcast",
|
|
241
|
+
"receives_all_direct",
|
|
242
|
+
]);
|
|
243
|
+
if (Object.keys(envelope.payload).length === 0) throw new InputError();
|
|
244
|
+
const name = envelope.payload["name"] === undefined
|
|
245
|
+
? undefined
|
|
246
|
+
: requiredRoleName(envelope.payload, "name");
|
|
247
|
+
const shortDescription = optionalText(envelope.payload, "short_description", 1_024);
|
|
248
|
+
const detailedDescription = optionalText(envelope.payload, "detailed_description", 51_200);
|
|
249
|
+
const isDefault = optionalBooleanValue(envelope.payload["is_default"]);
|
|
250
|
+
const isMandatory = optionalBooleanValue(envelope.payload["is_mandatory"]);
|
|
251
|
+
const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
|
|
252
|
+
const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
|
|
253
|
+
const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
|
|
254
|
+
const role = store.updateRole(cubeId, roleId!, {
|
|
255
|
+
...(name === undefined ? {} : { name }),
|
|
256
|
+
...(shortDescription === undefined ? {} : { shortDescription }),
|
|
257
|
+
...(detailedDescription === undefined ? {} : { detailedDescription }),
|
|
258
|
+
...(isDefault === undefined ? {} : { isDefault }),
|
|
259
|
+
...(isMandatory === undefined ? {} : { isMandatory }),
|
|
260
|
+
...(isHumanSeat === undefined ? {} : { isHumanSeat }),
|
|
261
|
+
...(canBroadcast === undefined ? {} : { canBroadcast }),
|
|
262
|
+
...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
|
|
263
|
+
});
|
|
264
|
+
return success(200, envelope.requestId, { role });
|
|
265
|
+
}
|
|
266
|
+
if (resource === "role" && sectionPatch && request.method === "POST") {
|
|
267
|
+
const envelope = decodeEnvelope(request.body);
|
|
268
|
+
const action = envelope.payload["action"];
|
|
269
|
+
if (action === "delete") {
|
|
270
|
+
exactKeys(envelope.payload, ["action", "heading"]);
|
|
271
|
+
const role = store.patchRoleSection(cubeId, roleId!, {
|
|
272
|
+
action,
|
|
273
|
+
heading: requiredSectionHeading(envelope.payload, "heading"),
|
|
274
|
+
});
|
|
275
|
+
return success(200, envelope.requestId, { role });
|
|
276
|
+
}
|
|
277
|
+
if (action !== "replace" && action !== "insert") throw new InputError();
|
|
278
|
+
exactKeys(envelope.payload, ["action", "heading", "body"], action === "insert" ? ["after"] : []);
|
|
279
|
+
const body = optionalText(envelope.payload, "body", 51_200);
|
|
280
|
+
if (body === undefined) throw new InputError();
|
|
281
|
+
const heading = requiredSectionHeading(envelope.payload, "heading");
|
|
282
|
+
if (action === "replace") {
|
|
283
|
+
return success(200, envelope.requestId, {
|
|
284
|
+
role: store.patchRoleSection(cubeId, roleId!, { action, heading, body }),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const after = optionalNullableSectionHeading(envelope.payload["after"]);
|
|
288
|
+
return success(200, envelope.requestId, {
|
|
289
|
+
role: store.patchRoleSection(cubeId, roleId!, {
|
|
290
|
+
action,
|
|
291
|
+
heading,
|
|
292
|
+
body,
|
|
293
|
+
...(after === undefined ? {} : { after }),
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
190
297
|
if (resource === "drones" && request.method === "GET") {
|
|
191
298
|
return success(200, "drones-read", { drones: store.listDrones(cubeId) });
|
|
192
299
|
}
|
|
@@ -205,6 +312,15 @@ export class CoordinationApi {
|
|
|
205
312
|
...(visibility === undefined ? {} : { visibility }),
|
|
206
313
|
...(recipientDroneIds === undefined ? {} : { recipientDroneIds }),
|
|
207
314
|
});
|
|
315
|
+
this.#debugLogger.emit({
|
|
316
|
+
event: "activity_append",
|
|
317
|
+
cubeId,
|
|
318
|
+
entryId: entry.id,
|
|
319
|
+
principal: authentication,
|
|
320
|
+
droneId: entry.drone_id,
|
|
321
|
+
visibility: entry.visibility,
|
|
322
|
+
recipientDroneIds: entry.recipient_drone_ids,
|
|
323
|
+
});
|
|
208
324
|
return success(201, envelope.requestId, { entry });
|
|
209
325
|
}
|
|
210
326
|
if (resource === "logs" && request.method === "PUT") {
|
|
@@ -212,7 +328,17 @@ export class CoordinationApi {
|
|
|
212
328
|
exactKeys(envelope.payload, ["cursor"], ["limit"]);
|
|
213
329
|
const cursor = decodeCursor(envelope.payload["cursor"]);
|
|
214
330
|
const limit = optionalLimit(envelope.payload["limit"]);
|
|
215
|
-
|
|
331
|
+
const page = store.readLog(cubeId, cursor, limit);
|
|
332
|
+
this.#debugLogger.emit({
|
|
333
|
+
event: "cursor_replay",
|
|
334
|
+
mode: "page",
|
|
335
|
+
cubeId,
|
|
336
|
+
cursorId: cursor?.id ?? null,
|
|
337
|
+
returnedCount: page.entries.length,
|
|
338
|
+
behindBy: page.behind_by,
|
|
339
|
+
truncated: page.has_more,
|
|
340
|
+
});
|
|
341
|
+
return success(200, envelope.requestId, page);
|
|
216
342
|
}
|
|
217
343
|
if (resource === "acks" && request.method === "POST") {
|
|
218
344
|
const envelope = decodeEnvelope(request.body);
|
|
@@ -221,6 +347,7 @@ export class CoordinationApi {
|
|
|
221
347
|
if (kind !== "ack" && kind !== "claim") throw new InputError();
|
|
222
348
|
exactKeys(envelope.payload, ["entry_id", "kind"]);
|
|
223
349
|
store.acknowledge(cubeId, entryId, kind);
|
|
350
|
+
this.#debugLogger.emit({ event: "ack_write", cubeId, entryId, kind, principal: authentication });
|
|
224
351
|
return { status: 204 };
|
|
225
352
|
}
|
|
226
353
|
if (resource === "decisions" && request.method === "POST") {
|
|
@@ -229,13 +356,18 @@ export class CoordinationApi {
|
|
|
229
356
|
const topic = requiredString(envelope.payload, "topic", 120);
|
|
230
357
|
const decision = requiredString(envelope.payload, "decision", 100_000);
|
|
231
358
|
const rationale = optionalString(envelope.payload, "rationale", 100_000);
|
|
232
|
-
|
|
233
|
-
decision: store.recordDecision(cubeId, {
|
|
359
|
+
const decisionRecord = store.recordDecision(cubeId, {
|
|
234
360
|
topic,
|
|
235
361
|
decision,
|
|
236
362
|
...(rationale === undefined ? {} : { rationale }),
|
|
237
|
-
})
|
|
363
|
+
});
|
|
364
|
+
this.#debugLogger.emit({
|
|
365
|
+
event: "decision_write",
|
|
366
|
+
cubeId,
|
|
367
|
+
decisionId: decisionRecord.id,
|
|
368
|
+
principal: authentication,
|
|
238
369
|
});
|
|
370
|
+
return success(201, envelope.requestId, { decision: decisionRecord });
|
|
239
371
|
}
|
|
240
372
|
if (resource === "decisions" && request.method === "PUT") {
|
|
241
373
|
const envelope = decodeEnvelope(request.body);
|
|
@@ -253,6 +385,15 @@ export class CoordinationApi {
|
|
|
253
385
|
if (error instanceof ScopedStoreError) {
|
|
254
386
|
return failure(404, "NOT_FOUND", error.message);
|
|
255
387
|
}
|
|
388
|
+
if (error instanceof RoleConflictError) {
|
|
389
|
+
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
390
|
+
}
|
|
391
|
+
if (error instanceof DefaultRoleRequiredError) {
|
|
392
|
+
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
393
|
+
}
|
|
394
|
+
if (error instanceof RoleSectionConflictError) {
|
|
395
|
+
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
396
|
+
}
|
|
256
397
|
if (error instanceof StorageCapacityError) {
|
|
257
398
|
return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
|
|
258
399
|
}
|
|
@@ -270,13 +411,25 @@ export class CoordinationApi {
|
|
|
270
411
|
requestSignal: AbortSignal,
|
|
271
412
|
): Promise<CoordinationResponse> {
|
|
272
413
|
const store = this.#runtime.forPrincipal(principal);
|
|
414
|
+
const connectionId = randomUUID();
|
|
273
415
|
const session = this.#authority.registerLiveSession(principal);
|
|
274
416
|
const signal = AbortSignal.any([requestSignal, session.signal]);
|
|
275
417
|
let unsubscribe = (): void => undefined;
|
|
418
|
+
let subscribed = false;
|
|
419
|
+
let deliveryCount = 0;
|
|
276
420
|
const queue = new AsyncStringQueue(() => {
|
|
277
421
|
unsubscribe();
|
|
278
422
|
session.release();
|
|
279
|
-
|
|
423
|
+
if (subscribed) {
|
|
424
|
+
this.#debugLogger.emit({
|
|
425
|
+
event: "sse_unsubscribe",
|
|
426
|
+
connectionId,
|
|
427
|
+
cubeId,
|
|
428
|
+
principal,
|
|
429
|
+
deliveryCount,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}, () => { deliveryCount += 1; });
|
|
280
433
|
const pending: EnrichedActivityRecord[] = [];
|
|
281
434
|
let live = false;
|
|
282
435
|
try {
|
|
@@ -290,7 +443,17 @@ export class CoordinationApi {
|
|
|
290
443
|
else pending.push(entry);
|
|
291
444
|
});
|
|
292
445
|
signal.addEventListener("abort", () => queue.close(), { once: true });
|
|
293
|
-
const
|
|
446
|
+
const replayPage = store.readLog(cubeId, cursor, 200);
|
|
447
|
+
const replay = replayPage.entries;
|
|
448
|
+
this.#debugLogger.emit({
|
|
449
|
+
event: "cursor_replay",
|
|
450
|
+
mode: "sse",
|
|
451
|
+
cubeId,
|
|
452
|
+
cursorId: cursor?.id ?? null,
|
|
453
|
+
returnedCount: replay.length,
|
|
454
|
+
behindBy: replayPage.behind_by,
|
|
455
|
+
truncated: replayPage.has_more,
|
|
456
|
+
});
|
|
294
457
|
const barrier = this.#replayBarrier;
|
|
295
458
|
this.#replayBarrier = undefined;
|
|
296
459
|
if (barrier !== undefined) {
|
|
@@ -311,6 +474,15 @@ export class CoordinationApi {
|
|
|
311
474
|
replay_complete: true,
|
|
312
475
|
})}\n\n`);
|
|
313
476
|
live = true;
|
|
477
|
+
subscribed = true;
|
|
478
|
+
this.#debugLogger.emit({
|
|
479
|
+
event: "sse_subscribe",
|
|
480
|
+
connectionId,
|
|
481
|
+
cubeId,
|
|
482
|
+
principal,
|
|
483
|
+
replayCount: replay.length,
|
|
484
|
+
truncated: replayPage.has_more,
|
|
485
|
+
});
|
|
314
486
|
return { status: 200, stream: queue };
|
|
315
487
|
} catch (error) {
|
|
316
488
|
queue.close();
|
|
@@ -327,10 +499,12 @@ class AsyncStringQueue implements AsyncIterable<string> {
|
|
|
327
499
|
readonly #values: string[] = [];
|
|
328
500
|
readonly #waiters: Array<(result: IteratorResult<string>) => void> = [];
|
|
329
501
|
readonly #cleanup: () => void;
|
|
502
|
+
readonly #onDelivered: () => void;
|
|
330
503
|
#closed = false;
|
|
331
504
|
|
|
332
|
-
constructor(cleanup: () => void) {
|
|
505
|
+
constructor(cleanup: () => void, onDelivered: () => void = () => undefined) {
|
|
333
506
|
this.#cleanup = cleanup;
|
|
507
|
+
this.#onDelivered = onDelivered;
|
|
334
508
|
}
|
|
335
509
|
|
|
336
510
|
push(value: string): void {
|
|
@@ -341,7 +515,10 @@ class AsyncStringQueue implements AsyncIterable<string> {
|
|
|
341
515
|
}
|
|
342
516
|
const waiter = this.#waiters.shift();
|
|
343
517
|
if (waiter === undefined) this.#values.push(value);
|
|
344
|
-
else
|
|
518
|
+
else {
|
|
519
|
+
this.#onDelivered();
|
|
520
|
+
waiter({ value, done: false });
|
|
521
|
+
}
|
|
345
522
|
}
|
|
346
523
|
|
|
347
524
|
close(): void {
|
|
@@ -355,7 +532,10 @@ class AsyncStringQueue implements AsyncIterable<string> {
|
|
|
355
532
|
return {
|
|
356
533
|
next: async () => {
|
|
357
534
|
const value = this.#values.shift();
|
|
358
|
-
if (value !== undefined)
|
|
535
|
+
if (value !== undefined) {
|
|
536
|
+
this.#onDelivered();
|
|
537
|
+
return { value, done: false };
|
|
538
|
+
}
|
|
359
539
|
if (this.#closed) return { value: undefined, done: true };
|
|
360
540
|
return new Promise<IteratorResult<string>>((resolve) => this.#waiters.push(resolve));
|
|
361
541
|
},
|
|
@@ -403,6 +583,12 @@ function requiredPresentationName(record: Record<string, unknown>, key: string):
|
|
|
403
583
|
return value;
|
|
404
584
|
}
|
|
405
585
|
|
|
586
|
+
function requiredRoleName(record: Record<string, unknown>, key: string): string {
|
|
587
|
+
const value = record[key];
|
|
588
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 64) throw new InputError();
|
|
589
|
+
return value;
|
|
590
|
+
}
|
|
591
|
+
|
|
406
592
|
function optionalString(
|
|
407
593
|
record: Record<string, unknown>,
|
|
408
594
|
key: string,
|
|
@@ -413,6 +599,42 @@ function optionalString(
|
|
|
413
599
|
return requiredString(record, key, maxBytes);
|
|
414
600
|
}
|
|
415
601
|
|
|
602
|
+
function optionalText(
|
|
603
|
+
record: Record<string, unknown>,
|
|
604
|
+
key: string,
|
|
605
|
+
maxCharacters: number,
|
|
606
|
+
): string | undefined {
|
|
607
|
+
const value = record[key];
|
|
608
|
+
if (value === undefined) return undefined;
|
|
609
|
+
if (typeof value !== "string" || value.length > maxCharacters) throw new InputError();
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function optionalBoolean(value: unknown): boolean {
|
|
614
|
+
if (value === undefined) return false;
|
|
615
|
+
if (typeof value !== "boolean") throw new InputError();
|
|
616
|
+
return value;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function optionalBooleanValue(value: unknown): boolean | undefined {
|
|
620
|
+
if (value === undefined) return undefined;
|
|
621
|
+
if (typeof value !== "boolean") throw new InputError();
|
|
622
|
+
return value;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function requiredSectionHeading(record: Record<string, unknown>, key: string): string {
|
|
626
|
+
const value = requiredString(record, key, 60);
|
|
627
|
+
const heading = value.trim();
|
|
628
|
+
if (heading.length === 0 || /^\s/u.test(value) || /[:\n\r]/u.test(heading) ||
|
|
629
|
+
/^[*\-#>`]/u.test(heading)) throw new InputError();
|
|
630
|
+
return heading;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function optionalNullableSectionHeading(value: unknown): string | null | undefined {
|
|
634
|
+
if (value === undefined || value === null) return value;
|
|
635
|
+
return requiredSectionHeading({ value }, "value");
|
|
636
|
+
}
|
|
637
|
+
|
|
416
638
|
function requiredUuid(record: Record<string, unknown>, key: string): string {
|
|
417
639
|
const value = record[key];
|
|
418
640
|
if (typeof value !== "string" || !uuidPattern.test(value)) throw new InputError();
|
package/src/credentials.ts
CHANGED
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
import type {
|
|
14
14
|
CredentialStore,
|
|
15
15
|
DigestPair,
|
|
16
|
+
InvitationCubeScope,
|
|
16
17
|
ScopedStore,
|
|
17
18
|
SeatAttachRecord,
|
|
18
19
|
} from "./store.js";
|
|
19
20
|
import { operatorErrors } from "./operator-error.js";
|
|
21
|
+
import { disabledDebugLogger, type DebugLogger } from "./debug-log.js";
|
|
20
22
|
|
|
21
23
|
const tokenPattern = /^[A-Za-z0-9_-]{43,1024}$/u;
|
|
22
24
|
const dummyVerifier = Buffer.alloc(32);
|
|
@@ -35,6 +37,10 @@ export interface EnrollmentResponse {
|
|
|
35
37
|
readonly serverCapabilities: readonly [] | readonly ["create_cube"];
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
export interface CubeInvitationResult extends InvitationCubeScope {
|
|
41
|
+
readonly invitation: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
export interface SeatAttachResponse extends SeatAttachRecord {
|
|
39
45
|
readonly credential: string;
|
|
40
46
|
}
|
|
@@ -131,17 +137,20 @@ export class CredentialAuthority {
|
|
|
131
137
|
readonly #digester: CredentialDigester;
|
|
132
138
|
readonly #clock: () => Date;
|
|
133
139
|
readonly #registry: LiveCredentialRegistry;
|
|
140
|
+
readonly #debugLogger: DebugLogger;
|
|
134
141
|
|
|
135
142
|
constructor(
|
|
136
143
|
store: CredentialStore,
|
|
137
144
|
digester: CredentialDigester,
|
|
138
145
|
clock: () => Date = () => new Date(),
|
|
139
146
|
registry = new LiveCredentialRegistry(),
|
|
147
|
+
debugLogger: DebugLogger = disabledDebugLogger,
|
|
140
148
|
) {
|
|
141
149
|
this.#store = store;
|
|
142
150
|
this.#digester = digester;
|
|
143
151
|
this.#clock = clock;
|
|
144
152
|
this.#registry = registry;
|
|
153
|
+
this.#debugLogger = debugLogger;
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
createRecoveryCredential(): string {
|
|
@@ -164,6 +173,18 @@ export class CredentialAuthority {
|
|
|
164
173
|
return this.#createInvitation("client", ttlMs);
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
createCubeInvitation(
|
|
177
|
+
recoveryCredential: string,
|
|
178
|
+
cubeSelector: { readonly kind: "id" | "name"; readonly value: string },
|
|
179
|
+
access: "read" | "write" | "manage",
|
|
180
|
+
ttlMs: number,
|
|
181
|
+
): CubeInvitationResult | null {
|
|
182
|
+
const digest = safeDigest(this.#digester, recoveryCredential, "recovery");
|
|
183
|
+
const stored = this.#store.findRecoveryCredential(digest.lookup);
|
|
184
|
+
if (!this.#digester.verify(recoveryCredential, "recovery", stored?.verifier)) return null;
|
|
185
|
+
return this.#createInvitation("client", ttlMs, { cubeSelector, access });
|
|
186
|
+
}
|
|
187
|
+
|
|
167
188
|
replaceOwnerInvitation(recoveryCredential: string, ttlMs: number): string | null {
|
|
168
189
|
const digest = safeDigest(this.#digester, recoveryCredential, "recovery");
|
|
169
190
|
const stored = this.#store.findRecoveryCredential(digest.lookup);
|
|
@@ -171,18 +192,41 @@ export class CredentialAuthority {
|
|
|
171
192
|
return this.#createInvitation("owner", ttlMs);
|
|
172
193
|
}
|
|
173
194
|
|
|
174
|
-
#createInvitation(purpose: "owner" | "client", ttlMs: number): string
|
|
195
|
+
#createInvitation(purpose: "owner" | "client", ttlMs: number): string;
|
|
196
|
+
#createInvitation(
|
|
197
|
+
purpose: "client",
|
|
198
|
+
ttlMs: number,
|
|
199
|
+
scoped: {
|
|
200
|
+
readonly cubeSelector: { readonly kind: "id" | "name"; readonly value: string };
|
|
201
|
+
readonly access: "read" | "write" | "manage";
|
|
202
|
+
},
|
|
203
|
+
): CubeInvitationResult;
|
|
204
|
+
#createInvitation(
|
|
205
|
+
purpose: "owner" | "client",
|
|
206
|
+
ttlMs: number,
|
|
207
|
+
scoped?: {
|
|
208
|
+
readonly cubeSelector: { readonly kind: "id" | "name"; readonly value: string };
|
|
209
|
+
readonly access: "read" | "write" | "manage";
|
|
210
|
+
},
|
|
211
|
+
): string | CubeInvitationResult {
|
|
175
212
|
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1_000 || ttlMs > 86_400_000) {
|
|
176
213
|
throw new Error("Invitation TTL must be an integer from 1000 to 86400000 milliseconds.");
|
|
177
214
|
}
|
|
178
215
|
const secret = generateSecret();
|
|
179
|
-
this.#store.createInvitation({
|
|
216
|
+
const scope = this.#store.createInvitation({
|
|
180
217
|
id: randomUUID(),
|
|
181
218
|
digest: this.#digester.digest(secret, "invitation"),
|
|
182
219
|
expiresAt: new Date(this.#clock().getTime() + ttlMs).toISOString(),
|
|
183
220
|
purpose,
|
|
221
|
+
...(scoped === undefined ? {} : scoped),
|
|
184
222
|
});
|
|
185
|
-
|
|
223
|
+
this.#debugLogger.emit({
|
|
224
|
+
event: "credential",
|
|
225
|
+
action: "invitation_created",
|
|
226
|
+
purpose,
|
|
227
|
+
...(scope === null ? {} : { cubeId: scope.cubeId }),
|
|
228
|
+
});
|
|
229
|
+
return scope === null ? secret : { invitation: secret, ...scope };
|
|
186
230
|
}
|
|
187
231
|
|
|
188
232
|
exchangeInvitation(request: EnrollmentRequest): EnrollmentResponse | null {
|
|
@@ -202,11 +246,20 @@ export class CredentialAuthority {
|
|
|
202
246
|
credentialId: randomUUID(),
|
|
203
247
|
credentialDigest: safeDigest(this.#digester, request.clientCredential, "client"),
|
|
204
248
|
});
|
|
205
|
-
|
|
249
|
+
const result = !verified || stored?.expiresAt === undefined || claimed === null ? null : {
|
|
206
250
|
purpose: claimed.purpose,
|
|
207
251
|
clientId: claimed.clientId,
|
|
208
252
|
serverCapabilities: claimed.serverCapabilities,
|
|
209
253
|
};
|
|
254
|
+
this.#debugLogger.emit(result === null
|
|
255
|
+
? { event: "credential", action: "enrollment_rejected" }
|
|
256
|
+
: {
|
|
257
|
+
event: "credential",
|
|
258
|
+
action: "enrollment_accepted",
|
|
259
|
+
purpose: result.purpose,
|
|
260
|
+
clientId: result.clientId,
|
|
261
|
+
});
|
|
262
|
+
return result;
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
authenticate(authorization: string | undefined): Principal | null {
|
|
@@ -263,7 +316,18 @@ export class CredentialAuthority {
|
|
|
263
316
|
credentialDigest: this.#digester.digest(credential, "drone-session"),
|
|
264
317
|
expiresAt: new Date(this.#clock().getTime() + 86_400_000).toISOString(),
|
|
265
318
|
});
|
|
266
|
-
for (const sessionId of record.revokedSessionIds)
|
|
319
|
+
for (const sessionId of record.revokedSessionIds) {
|
|
320
|
+
this.#registry.invalidate(sessionId);
|
|
321
|
+
this.#debugLogger.emit({ event: "credential", action: "session_revoked", sessionId });
|
|
322
|
+
}
|
|
323
|
+
this.#debugLogger.emit({
|
|
324
|
+
event: "credential",
|
|
325
|
+
action: "session_created",
|
|
326
|
+
sessionId: record.sessionId,
|
|
327
|
+
cubeId: record.cube.id,
|
|
328
|
+
droneId: record.drone.id,
|
|
329
|
+
generation: record.generation,
|
|
330
|
+
});
|
|
267
331
|
return { ...record, credential };
|
|
268
332
|
}
|
|
269
333
|
|
|
@@ -277,6 +341,7 @@ export class CredentialAuthority {
|
|
|
277
341
|
});
|
|
278
342
|
if (!rotated) throw operatorErrors.CLIENT_NOT_FOUND;
|
|
279
343
|
this.#registry.invalidate(clientId);
|
|
344
|
+
this.#debugLogger.emit({ event: "credential", action: "client_rotated", clientId });
|
|
280
345
|
return secret;
|
|
281
346
|
}
|
|
282
347
|
|
|
@@ -284,6 +349,7 @@ export class CredentialAuthority {
|
|
|
284
349
|
if (!this.#store.clientExists(clientId)) throw operatorErrors.CLIENT_NOT_FOUND;
|
|
285
350
|
this.#store.revokeClientCredentials(clientId);
|
|
286
351
|
this.#registry.invalidate(clientId);
|
|
352
|
+
this.#debugLogger.emit({ event: "credential", action: "client_revoked", clientId });
|
|
287
353
|
}
|
|
288
354
|
|
|
289
355
|
registerLiveSession(principal: string | Principal) {
|