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.
Files changed (48) hide show
  1. package/README.md +69 -22
  2. package/dist/cli.js +62 -11
  3. package/dist/cli.js.map +1 -1
  4. package/dist/coordination-api.d.ts +2 -1
  5. package/dist/coordination-api.js +232 -18
  6. package/dist/coordination-api.js.map +1 -1
  7. package/dist/credentials.d.ts +10 -2
  8. package/dist/credentials.js +44 -6
  9. package/dist/credentials.js.map +1 -1
  10. package/dist/debug-log.d.ts +77 -0
  11. package/dist/debug-log.js +125 -0
  12. package/dist/debug-log.js.map +1 -0
  13. package/dist/https-server.d.ts +3 -0
  14. package/dist/https-server.js +80 -4
  15. package/dist/https-server.js.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/migrations.d.ts +4 -0
  18. package/dist/migrations.js +71 -0
  19. package/dist/migrations.js.map +1 -1
  20. package/dist/operator-error.d.ts +2 -1
  21. package/dist/operator-error.js +23 -5
  22. package/dist/operator-error.js.map +1 -1
  23. package/dist/role-section.d.ts +14 -0
  24. package/dist/role-section.js +87 -0
  25. package/dist/role-section.js.map +1 -0
  26. package/dist/service.d.ts +10 -3
  27. package/dist/service.js +218 -15
  28. package/dist/service.js.map +1 -1
  29. package/dist/start-options.d.ts +5 -1
  30. package/dist/start-options.js +17 -3
  31. package/dist/start-options.js.map +1 -1
  32. package/dist/store.d.ts +65 -1
  33. package/dist/store.js +398 -26
  34. package/dist/store.js.map +1 -1
  35. package/npm-shrinkwrap.json +2 -2
  36. package/package.json +1 -1
  37. package/src/cli.ts +61 -11
  38. package/src/coordination-api.ts +236 -14
  39. package/src/credentials.ts +71 -5
  40. package/src/debug-log.ts +165 -0
  41. package/src/https-server.ts +75 -2
  42. package/src/index.ts +1 -1
  43. package/src/migrations.ts +74 -0
  44. package/src/operator-error.ts +40 -6
  45. package/src/role-section.ts +108 -0
  46. package/src/service.ts +239 -18
  47. package/src/start-options.ts +21 -4
  48. package/src/store.ts +462 -28
@@ -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(runtime: StoreRuntime, authority: CredentialAuthority) {
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) return failure(404, "NOT_FOUND", "The requested resource was not found.");
165
- const cubeId = match[1]!;
166
- if (!uuidPattern.test(cubeId)) {
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
- return success(200, envelope.requestId, store.readLog(cubeId, cursor, limit));
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
- return success(201, envelope.requestId, {
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 replay = store.readLog(cubeId, cursor, 200).entries;
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 waiter({ value, done: false });
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) return { value, done: false };
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();
@@ -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
- return secret;
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
- return !verified || stored?.expiresAt === undefined || claimed === null ? null : {
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) this.#registry.invalidate(sessionId);
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) {