borgmcp-server 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +70 -22
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/cli.js +62 -11
  4. package/dist/cli.js.map +1 -1
  5. package/dist/coordination-api.d.ts +3 -1
  6. package/dist/coordination-api.js +476 -78
  7. package/dist/coordination-api.js.map +1 -1
  8. package/dist/credentials.d.ts +13 -7
  9. package/dist/credentials.js +77 -15
  10. package/dist/credentials.js.map +1 -1
  11. package/dist/debug-log.d.ts +76 -0
  12. package/dist/debug-log.js +124 -0
  13. package/dist/debug-log.js.map +1 -0
  14. package/dist/enrollment.d.ts +3 -11
  15. package/dist/enrollment.js +21 -60
  16. package/dist/enrollment.js.map +1 -1
  17. package/dist/https-server.d.ts +5 -20
  18. package/dist/https-server.js +108 -50
  19. package/dist/https-server.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/message-taxonomy.d.ts +38 -0
  22. package/dist/message-taxonomy.js +218 -0
  23. package/dist/message-taxonomy.js.map +1 -0
  24. package/dist/migrations.d.ts +4 -0
  25. package/dist/migrations.js +99 -0
  26. package/dist/migrations.js.map +1 -1
  27. package/dist/operator-error.d.ts +2 -1
  28. package/dist/operator-error.js +23 -5
  29. package/dist/operator-error.js.map +1 -1
  30. package/dist/role-section.d.ts +14 -0
  31. package/dist/role-section.js +87 -0
  32. package/dist/role-section.js.map +1 -0
  33. package/dist/service.d.ts +14 -4
  34. package/dist/service.js +243 -25
  35. package/dist/service.js.map +1 -1
  36. package/dist/start-options.d.ts +5 -1
  37. package/dist/start-options.js +17 -3
  38. package/dist/start-options.js.map +1 -1
  39. package/dist/store.d.ts +106 -8
  40. package/dist/store.js +772 -120
  41. package/dist/store.js.map +1 -1
  42. package/npm-shrinkwrap.json +6 -7
  43. package/package.json +2 -2
  44. package/src/cli.ts +61 -11
  45. package/src/coordination-api.ts +490 -72
  46. package/src/credentials.ts +103 -19
  47. package/src/debug-log.ts +165 -0
  48. package/src/enrollment.ts +32 -78
  49. package/src/https-server.ts +113 -78
  50. package/src/index.ts +1 -1
  51. package/src/message-taxonomy.ts +284 -0
  52. package/src/migrations.ts +102 -0
  53. package/src/operator-error.ts +40 -6
  54. package/src/role-section.ts +108 -0
  55. package/src/service.ts +268 -27
  56. package/src/start-options.ts +21 -4
  57. package/src/store.ts +887 -142
  58. package/dist/protocol-draft.d.ts +0 -2
  59. package/dist/protocol-draft.js +0 -31
  60. package/dist/protocol-draft.js.map +0 -1
  61. package/src/protocol-draft.ts +0 -32
@@ -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,10 +37,12 @@ export interface EnrollmentResponse {
35
37
  readonly serverCapabilities: readonly [] | readonly ["create_cube"];
36
38
  }
37
39
 
38
- export interface SeatAttachResponse extends SeatAttachRecord {
39
- readonly credential: string;
40
+ export interface CubeInvitationResult extends InvitationCubeScope {
41
+ readonly invitation: string;
40
42
  }
41
43
 
44
+ export type SeatAttachResponse = SeatAttachRecord;
45
+
42
46
  export class CredentialDigester {
43
47
  readonly #key: Buffer;
44
48
 
@@ -82,8 +86,9 @@ export class CredentialDigester {
82
86
  export class LiveCredentialRegistry {
83
87
  readonly #sessions = new Map<string, Set<AbortController>>();
84
88
  readonly #keys = new Map<AbortController, readonly string[]>();
89
+ readonly #timers = new Map<AbortController, NodeJS.Timeout>();
85
90
 
86
- register(identities: string | readonly string[]): {
91
+ register(identities: string | readonly string[], expiresInMs?: number): {
87
92
  readonly signal: AbortSignal;
88
93
  readonly release: () => void;
89
94
  } {
@@ -95,6 +100,15 @@ export class LiveCredentialRegistry {
95
100
  sessions.add(controller);
96
101
  this.#sessions.set(key, sessions);
97
102
  }
103
+ if (expiresInMs !== undefined) {
104
+ if (expiresInMs <= 0) {
105
+ this.#expire(controller);
106
+ } else {
107
+ const timer = setTimeout(() => this.#expire(controller), expiresInMs);
108
+ timer.unref();
109
+ this.#timers.set(controller, timer);
110
+ }
111
+ }
98
112
  return {
99
113
  signal: controller.signal,
100
114
  release: () => this.#release(controller),
@@ -115,6 +129,11 @@ export class LiveCredentialRegistry {
115
129
  }
116
130
 
117
131
  #release(controller: AbortController): void {
132
+ const timer = this.#timers.get(controller);
133
+ if (timer !== undefined) {
134
+ clearTimeout(timer);
135
+ this.#timers.delete(controller);
136
+ }
118
137
  const keys = this.#keys.get(controller);
119
138
  if (keys === undefined) return;
120
139
  this.#keys.delete(controller);
@@ -124,6 +143,11 @@ export class LiveCredentialRegistry {
124
143
  if (sessions?.size === 0) this.#sessions.delete(key);
125
144
  }
126
145
  }
146
+
147
+ #expire(controller: AbortController): void {
148
+ this.#release(controller);
149
+ controller.abort();
150
+ }
127
151
  }
128
152
 
129
153
  export class CredentialAuthority {
@@ -131,17 +155,20 @@ export class CredentialAuthority {
131
155
  readonly #digester: CredentialDigester;
132
156
  readonly #clock: () => Date;
133
157
  readonly #registry: LiveCredentialRegistry;
158
+ readonly #debugLogger: DebugLogger;
134
159
 
135
160
  constructor(
136
161
  store: CredentialStore,
137
162
  digester: CredentialDigester,
138
163
  clock: () => Date = () => new Date(),
139
164
  registry = new LiveCredentialRegistry(),
165
+ debugLogger: DebugLogger = disabledDebugLogger,
140
166
  ) {
141
167
  this.#store = store;
142
168
  this.#digester = digester;
143
169
  this.#clock = clock;
144
170
  this.#registry = registry;
171
+ this.#debugLogger = debugLogger;
145
172
  }
146
173
 
147
174
  createRecoveryCredential(): string {
@@ -164,6 +191,18 @@ export class CredentialAuthority {
164
191
  return this.#createInvitation("client", ttlMs);
165
192
  }
166
193
 
194
+ createCubeInvitation(
195
+ recoveryCredential: string,
196
+ cubeSelector: { readonly kind: "id" | "name"; readonly value: string },
197
+ access: "read" | "write" | "manage",
198
+ ttlMs: number,
199
+ ): CubeInvitationResult | null {
200
+ const digest = safeDigest(this.#digester, recoveryCredential, "recovery");
201
+ const stored = this.#store.findRecoveryCredential(digest.lookup);
202
+ if (!this.#digester.verify(recoveryCredential, "recovery", stored?.verifier)) return null;
203
+ return this.#createInvitation("client", ttlMs, { cubeSelector, access });
204
+ }
205
+
167
206
  replaceOwnerInvitation(recoveryCredential: string, ttlMs: number): string | null {
168
207
  const digest = safeDigest(this.#digester, recoveryCredential, "recovery");
169
208
  const stored = this.#store.findRecoveryCredential(digest.lookup);
@@ -171,18 +210,41 @@ export class CredentialAuthority {
171
210
  return this.#createInvitation("owner", ttlMs);
172
211
  }
173
212
 
174
- #createInvitation(purpose: "owner" | "client", ttlMs: number): string {
213
+ #createInvitation(purpose: "owner" | "client", ttlMs: number): string;
214
+ #createInvitation(
215
+ purpose: "client",
216
+ ttlMs: number,
217
+ scoped: {
218
+ readonly cubeSelector: { readonly kind: "id" | "name"; readonly value: string };
219
+ readonly access: "read" | "write" | "manage";
220
+ },
221
+ ): CubeInvitationResult;
222
+ #createInvitation(
223
+ purpose: "owner" | "client",
224
+ ttlMs: number,
225
+ scoped?: {
226
+ readonly cubeSelector: { readonly kind: "id" | "name"; readonly value: string };
227
+ readonly access: "read" | "write" | "manage";
228
+ },
229
+ ): string | CubeInvitationResult {
175
230
  if (!Number.isSafeInteger(ttlMs) || ttlMs < 1_000 || ttlMs > 86_400_000) {
176
231
  throw new Error("Invitation TTL must be an integer from 1000 to 86400000 milliseconds.");
177
232
  }
178
233
  const secret = generateSecret();
179
- this.#store.createInvitation({
234
+ const scope = this.#store.createInvitation({
180
235
  id: randomUUID(),
181
236
  digest: this.#digester.digest(secret, "invitation"),
182
237
  expiresAt: new Date(this.#clock().getTime() + ttlMs).toISOString(),
183
238
  purpose,
239
+ ...(scoped === undefined ? {} : scoped),
184
240
  });
185
- return secret;
241
+ this.#debugLogger.emit({
242
+ event: "credential",
243
+ action: "invitation_created",
244
+ purpose,
245
+ ...(scope === null ? {} : { cubeId: scope.cubeId }),
246
+ });
247
+ return scope === null ? secret : { invitation: secret, ...scope };
186
248
  }
187
249
 
188
250
  exchangeInvitation(request: EnrollmentRequest): EnrollmentResponse | null {
@@ -202,11 +264,20 @@ export class CredentialAuthority {
202
264
  credentialId: randomUUID(),
203
265
  credentialDigest: safeDigest(this.#digester, request.clientCredential, "client"),
204
266
  });
205
- return !verified || stored?.expiresAt === undefined || claimed === null ? null : {
267
+ const result = !verified || stored?.expiresAt === undefined || claimed === null ? null : {
206
268
  purpose: claimed.purpose,
207
269
  clientId: claimed.clientId,
208
270
  serverCapabilities: claimed.serverCapabilities,
209
271
  };
272
+ this.#debugLogger.emit(result === null
273
+ ? { event: "credential", action: "enrollment_rejected" }
274
+ : {
275
+ event: "credential",
276
+ action: "enrollment_accepted",
277
+ purpose: result.purpose,
278
+ clientId: result.clientId,
279
+ });
280
+ return result;
210
281
  }
211
282
 
212
283
  authenticate(authorization: string | undefined): Principal | null {
@@ -216,7 +287,7 @@ export class CredentialAuthority {
216
287
 
217
288
  authenticateStatus(
218
289
  authorization: string | undefined,
219
- ): Principal | "missing" | "invalid" | "revoked" {
290
+ ): Principal | "missing" | "invalid" | "revoked" | "evicted" {
220
291
  if (authorization === undefined) return "missing";
221
292
  const secret = bearerSecret(authorization);
222
293
  const clientDigest = safeDigest(this.#digester, secret, "client");
@@ -232,6 +303,7 @@ export class CredentialAuthority {
232
303
  return clientPrincipal(client!.clientId!);
233
304
  }
234
305
  if (droneValid) {
306
+ if (drone!.evictedAt !== null) return "evicted";
235
307
  if (drone!.revokedAt != null || drone!.expiresAt <= this.#clock().toISOString()) {
236
308
  return "revoked";
237
309
  }
@@ -250,21 +322,30 @@ export class CredentialAuthority {
250
322
  request: {
251
323
  readonly cubeId: string;
252
324
  readonly roleId: string;
253
- readonly retryKey: string;
325
+ readonly sessionCredential: string;
254
326
  readonly priorDroneId?: string;
255
327
  },
256
328
  ): SeatAttachResponse {
257
- const credential = generateSecret();
258
329
  const record = store.attachSeat({
259
- ...request,
330
+ cubeId: request.cubeId,
331
+ roleId: request.roleId,
332
+ ...(request.priorDroneId === undefined ? {} : { priorDroneId: request.priorDroneId }),
260
333
  droneId: randomUUID(),
261
334
  sessionId: randomUUID(),
262
335
  credentialId: randomUUID(),
263
- credentialDigest: this.#digester.digest(credential, "drone-session"),
336
+ credentialDigest: this.#digester.digest(request.sessionCredential, "drone-session"),
264
337
  expiresAt: new Date(this.#clock().getTime() + 86_400_000).toISOString(),
265
338
  });
266
- for (const sessionId of record.revokedSessionIds) this.#registry.invalidate(sessionId);
267
- return { ...record, credential };
339
+ if (record.result === "created") {
340
+ this.#debugLogger.emit({
341
+ event: "credential",
342
+ action: "session_created",
343
+ sessionId: record.sessionId,
344
+ cubeId: record.cube.id,
345
+ droneId: record.drone.id,
346
+ });
347
+ }
348
+ return record;
268
349
  }
269
350
 
270
351
  rotateClient(clientId: string): string {
@@ -277,6 +358,7 @@ export class CredentialAuthority {
277
358
  });
278
359
  if (!rotated) throw operatorErrors.CLIENT_NOT_FOUND;
279
360
  this.#registry.invalidate(clientId);
361
+ this.#debugLogger.emit({ event: "credential", action: "client_rotated", clientId });
280
362
  return secret;
281
363
  }
282
364
 
@@ -284,15 +366,17 @@ export class CredentialAuthority {
284
366
  if (!this.#store.clientExists(clientId)) throw operatorErrors.CLIENT_NOT_FOUND;
285
367
  this.#store.revokeClientCredentials(clientId);
286
368
  this.#registry.invalidate(clientId);
369
+ this.#debugLogger.emit({ event: "credential", action: "client_revoked", clientId });
287
370
  }
288
371
 
289
372
  registerLiveSession(principal: string | Principal) {
290
373
  if (typeof principal === "string") return this.#registry.register(principal);
291
- return this.#registry.register(
292
- principal.kind === "drone-session"
293
- ? [principal.id, principal.clientId]
294
- : principal.id,
295
- );
374
+ if (principal.kind !== "drone-session") return this.#registry.register(principal.id);
375
+ const expiresAt = this.#store.findActiveDroneSessionExpiry(principal.id);
376
+ const expiresInMs = expiresAt === null
377
+ ? 0
378
+ : new Date(expiresAt).getTime() - this.#clock().getTime();
379
+ return this.#registry.register([principal.id, principal.clientId], expiresInMs);
296
380
  }
297
381
  }
298
382
 
@@ -0,0 +1,165 @@
1
+ import type { Principal } from "./principal.js";
2
+
3
+ export type DebugRoute =
4
+ | "health"
5
+ | "protocol"
6
+ | "enrollment_exchange"
7
+ | "client_attach"
8
+ | "cubes"
9
+ | "cube"
10
+ | "cube_roles"
11
+ | "cube_role"
12
+ | "cube_role_section_patch"
13
+ | "cube_taxonomy_patch"
14
+ | "cube_drones"
15
+ | "cube_logs"
16
+ | "cube_acks"
17
+ | "cube_decisions"
18
+ | "cube_stream"
19
+ | "unknown";
20
+
21
+ export type DebugEvent =
22
+ | { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
23
+ | { readonly event: "lifecycle"; readonly action: "listening" | "stopped" }
24
+ | { readonly event: "request"; readonly route: DebugRoute; readonly method: string; readonly authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted"; readonly authorization: "not_checked" | "accepted" | "denied_or_not_found"; readonly principal?: Principal; readonly status: number; readonly durationMs: number }
25
+ | { readonly event: "activity_append"; readonly cubeId: string; readonly entryId: string; readonly principal: Principal; readonly droneId: string | null; readonly visibility: "broadcast" | "direct"; readonly recipientDroneIds: readonly string[] }
26
+ | { readonly event: "cursor_replay"; readonly mode: "page" | "sse"; readonly cubeId: string; readonly cursorId: string | null; readonly returnedCount: number; readonly behindBy: number; readonly truncated: boolean }
27
+ | { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
28
+ | { readonly event: "decision_write"; readonly cubeId: string; readonly decisionId: string; readonly principal: Principal }
29
+ | { readonly event: "sse_subscribe"; readonly connectionId: string; readonly cubeId: string; readonly principal: Principal; readonly replayCount: number; readonly truncated: boolean }
30
+ | { readonly event: "sse_unsubscribe"; readonly connectionId: string; readonly cubeId: string; readonly principal: Principal; readonly deliveryCount: number }
31
+ | { readonly event: "credential"; readonly action: "invitation_created" | "enrollment_accepted" | "enrollment_rejected" | "session_created" | "session_revoked" | "client_rotated" | "client_revoked"; readonly purpose?: "owner" | "client"; readonly clientId?: string; readonly cubeId?: string; readonly droneId?: string; readonly sessionId?: string }
32
+ | { readonly event: "transport_rejection"; readonly reason: "tls_client_error" | "http_parser_error" };
33
+
34
+ export interface DebugLogger {
35
+ readonly emit: (event: DebugEvent) => void;
36
+ }
37
+
38
+ export const disabledDebugLogger: DebugLogger = Object.freeze({ emit: () => undefined });
39
+
40
+ export function createDebugLogger(write: ((line: string) => void) | undefined): DebugLogger {
41
+ if (write === undefined) return disabledDebugLogger;
42
+ return Object.freeze({
43
+ emit(event: DebugEvent): void {
44
+ try {
45
+ const projected = projectEvent(event);
46
+ if (projected !== null) write(JSON.stringify({ level: "debug", ...projected }));
47
+ } catch {
48
+ // Operator diagnostics cannot alter request or server behavior.
49
+ }
50
+ },
51
+ });
52
+ }
53
+
54
+ function projectEvent(event: DebugEvent): Record<string, unknown> | null {
55
+ const value = event as unknown as Record<string, unknown>;
56
+ switch (value["event"]) {
57
+ case "startup":
58
+ return {
59
+ event: "startup",
60
+ bind_mode: enumValue(value["bindMode"], ["loopback", "lan"], "loopback"),
61
+ port: boundedInteger(value["port"], 0, 65_535),
62
+ data_directory: enumValue(value["dataDirectory"], ["configured", "tls_only"], "tls_only"),
63
+ };
64
+ case "request": {
65
+ const principal = principalFields(value["principal"]);
66
+ return {
67
+ event: "request",
68
+ route: enumValue(value["route"], DEBUG_ROUTES, "unknown"),
69
+ method: enumValue(value["method"], HTTP_METHODS, "OTHER"),
70
+ authentication: enumValue(value["authentication"], AUTH_RESULTS, "invalid"),
71
+ authorization: enumValue(value["authorization"], AUTHZ_RESULTS, "not_checked"),
72
+ ...principal,
73
+ status: boundedInteger(value["status"], 0, 599),
74
+ duration_ms: boundedInteger(value["durationMs"], 0, Number.MAX_SAFE_INTEGER),
75
+ };
76
+ }
77
+ case "lifecycle":
78
+ return { event: "lifecycle", action: enumValue(value["action"], ["listening", "stopped"], "stopped") };
79
+ case "activity_append":
80
+ return {
81
+ event: "activity_append",
82
+ cube_id: uuid(value["cubeId"]),
83
+ entry_id: uuid(value["entryId"]),
84
+ ...principalFields(value["principal"]),
85
+ drone_id: nullableUuid(value["droneId"]),
86
+ visibility: enumValue(value["visibility"], ["broadcast", "direct"], "broadcast"),
87
+ recipient_count: uuidArray(value["recipientDroneIds"]).length,
88
+ recipient_drone_ids: uuidArray(value["recipientDroneIds"]),
89
+ };
90
+ case "cursor_replay":
91
+ return {
92
+ event: "cursor_replay",
93
+ mode: enumValue(value["mode"], ["page", "sse"], "page"),
94
+ cube_id: uuid(value["cubeId"]),
95
+ cursor_id: nullableUuid(value["cursorId"]),
96
+ returned_count: boundedInteger(value["returnedCount"], 0, 500),
97
+ behind_by: boundedInteger(value["behindBy"], 0, Number.MAX_SAFE_INTEGER),
98
+ truncated: value["truncated"] === true,
99
+ };
100
+ case "ack_write":
101
+ return { event: "ack_write", cube_id: uuid(value["cubeId"]), entry_id: uuid(value["entryId"]), kind: enumValue(value["kind"], ["ack", "claim"], "ack"), ...principalFields(value["principal"]) };
102
+ case "decision_write":
103
+ return { event: "decision_write", cube_id: uuid(value["cubeId"]), decision_id: uuid(value["decisionId"]), ...principalFields(value["principal"]) };
104
+ case "sse_subscribe":
105
+ return { event: "sse_subscribe", connection_id: uuid(value["connectionId"]), cube_id: uuid(value["cubeId"]), ...principalFields(value["principal"]), replay_count: boundedInteger(value["replayCount"], 0, 200), truncated: value["truncated"] === true };
106
+ case "sse_unsubscribe":
107
+ return { event: "sse_unsubscribe", connection_id: uuid(value["connectionId"]), cube_id: uuid(value["cubeId"]), ...principalFields(value["principal"]), delivery_count: boundedInteger(value["deliveryCount"], 0, Number.MAX_SAFE_INTEGER) };
108
+ case "credential":
109
+ return {
110
+ event: "credential",
111
+ action: enumValue(value["action"], CREDENTIAL_ACTIONS, "enrollment_rejected"),
112
+ ...(value["purpose"] === "owner" || value["purpose"] === "client" ? { purpose: value["purpose"] } : {}),
113
+ ...optionalUuid("client_id", value["clientId"]),
114
+ ...optionalUuid("cube_id", value["cubeId"]),
115
+ ...optionalUuid("drone_id", value["droneId"]),
116
+ ...optionalUuid("session_id", value["sessionId"]),
117
+ };
118
+ case "transport_rejection":
119
+ return { event: "transport_rejection", reason: enumValue(value["reason"], ["tls_client_error", "http_parser_error"], "tls_client_error") };
120
+ default:
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function principalFields(value: unknown): Record<string, unknown> {
126
+ if (typeof value !== "object" || value === null) return {};
127
+ const principal = value as Record<string, unknown>;
128
+ const kind = enumValue(principal["kind"], ["operator", "client", "drone-session"], "client");
129
+ return { principal_kind: kind, ...optionalUuid("principal_id", principal["id"]) };
130
+ }
131
+
132
+ function uuid(value: unknown): string | null {
133
+ return typeof value === "string" && UUID_PATTERN.test(value) ? value.toLowerCase() : null;
134
+ }
135
+
136
+ function nullableUuid(value: unknown): string | null {
137
+ return value === null ? null : uuid(value);
138
+ }
139
+
140
+ function optionalUuid(key: string, value: unknown): Record<string, string> {
141
+ const safe = uuid(value);
142
+ return safe === null ? {} : { [key]: safe };
143
+ }
144
+
145
+ function uuidArray(value: unknown): string[] {
146
+ if (!Array.isArray(value)) return [];
147
+ return value.slice(0, 100).map(uuid).filter((item): item is string => item !== null);
148
+ }
149
+
150
+ function boundedInteger(value: unknown, minimum: number, maximum: number): number {
151
+ return Number.isSafeInteger(value) && (value as number) >= minimum && (value as number) <= maximum
152
+ ? value as number
153
+ : minimum;
154
+ }
155
+
156
+ function enumValue<const T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
157
+ return typeof value === "string" && allowed.includes(value as T) ? value as T : fallback;
158
+ }
159
+
160
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
161
+ const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "enrollment_exchange", "client_attach", "cubes", "cube", "cube_roles", "cube_role", "cube_role_section_patch", "cube_taxonomy_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
162
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OTHER"] as const;
163
+ const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "evicted", "accepted"] as const;
164
+ const AUTHZ_RESULTS = ["not_checked", "accepted", "denied_or_not_found"] as const;
165
+ const CREDENTIAL_ACTIONS = ["invitation_created", "enrollment_accepted", "enrollment_rejected", "session_created", "session_revoked", "client_rotated", "client_revoked"] as const;
package/src/enrollment.ts CHANGED
@@ -1,26 +1,37 @@
1
+ import {
2
+ ErrorCode,
3
+ PROTOCOL_VERSION,
4
+ ProtocolContractError,
5
+ createProtocolEnvelope,
6
+ decodeEnrollmentExchangeRequestEnvelope,
7
+ type EnrollmentExchangeRequest,
8
+ type ProtocolEnvelope,
9
+ } from "borgmcp-shared/protocol";
1
10
  import type { CredentialAuthority } from "./credentials.js";
2
11
  import { StorageCapacityError } from "./store.js";
3
12
 
4
- interface EnrollmentEnvelope {
5
- readonly protocol_version: "1";
6
- readonly request_id: string;
7
- readonly payload: {
8
- readonly invitation: string;
9
- readonly retry_key: string;
10
- readonly client_credential: string;
11
- readonly client_name?: string;
12
- };
13
- }
13
+ type EnrollmentEnvelope = ProtocolEnvelope<EnrollmentExchangeRequest>;
14
14
 
15
15
  export function createEnrollmentExchange(authority: CredentialAuthority) {
16
16
  return async (body: unknown): Promise<{
17
- readonly status: 201 | 400 | 401 | 507;
17
+ readonly status: 201 | 400 | 401 | 426 | 507;
18
18
  readonly body?: unknown;
19
19
  }> => {
20
20
  let envelope: EnrollmentEnvelope;
21
21
  try {
22
22
  envelope = decodeEnrollmentEnvelope(body);
23
- } catch {
23
+ } catch (error) {
24
+ if (error instanceof ProtocolContractError &&
25
+ error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION) {
26
+ return {
27
+ status: 426,
28
+ body: errorEnvelope(
29
+ ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
30
+ "Unsupported protocol version.",
31
+ safeRequestId(body),
32
+ ),
33
+ };
34
+ }
24
35
  return {
25
36
  status: 400,
26
37
  body: errorEnvelope(
@@ -59,26 +70,22 @@ export function createEnrollmentExchange(authority: CredentialAuthority) {
59
70
  }
60
71
  return {
61
72
  status: 201,
62
- body: {
63
- protocol_version: "1",
64
- request_id: envelope.request_id,
65
- payload: {
73
+ body: createProtocolEnvelope(envelope.request_id, {
66
74
  purpose: response.purpose,
67
75
  client_id: response.clientId,
68
76
  server_capabilities: response.serverCapabilities,
69
- },
70
- },
77
+ }),
71
78
  };
72
79
  };
73
80
  }
74
81
 
75
82
  function errorEnvelope(
76
- code: "INVALID_INPUT" | "AUTH_INVALID" | "CAPACITY_EXCEEDED",
83
+ code: "INVALID_INPUT" | "AUTH_INVALID" | "CAPACITY_EXCEEDED" | "UNSUPPORTED_PROTOCOL_VERSION",
77
84
  message: string,
78
85
  requestId?: string,
79
86
  ) {
80
87
  return {
81
- protocol_version: "1" as const,
88
+ protocol_version: PROTOCOL_VERSION,
82
89
  ...(requestId === undefined ? {} : { request_id: requestId }),
83
90
  error: { code, message },
84
91
  };
@@ -93,64 +100,11 @@ function safeRequestId(value: unknown): string | undefined {
93
100
  }
94
101
 
95
102
  export function decodeEnrollmentEnvelope(value: unknown): EnrollmentEnvelope {
96
- const envelope = exactRecord(value, ["protocol_version", "request_id", "payload"]);
97
- if (envelope["protocol_version"] !== "1") throw new Error("Invalid enrollment request.");
98
- const requestId = envelope["request_id"];
99
- if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
100
- throw new Error("Invalid enrollment request.");
101
- }
102
- const payload = exactRecord(
103
- envelope["payload"],
104
- ["invitation", "retry_key", "client_credential"],
105
- ["client_name"],
106
- );
107
- const invitation = payload["invitation"];
108
- if (typeof invitation !== "string" || !/^[A-Za-z0-9_-]{43,1024}$/u.test(invitation)) {
109
- throw new Error("Invalid enrollment request.");
110
- }
111
- const retryKey = payload["retry_key"];
112
- if (typeof retryKey !== "string" ||
113
- !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(retryKey)) {
114
- throw new Error("Invalid enrollment request.");
115
- }
116
- const clientCredential = payload["client_credential"];
117
- if (typeof clientCredential !== "string" ||
118
- !/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/u.test(clientCredential)) {
119
- throw new Error("Invalid enrollment request.");
120
- }
121
- const clientName = payload["client_name"];
122
- if (clientName !== undefined &&
123
- (typeof clientName !== "string" || Buffer.byteLength(clientName) > 120 ||
124
- !/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/u.test(clientName))) {
125
- throw new Error("Invalid enrollment request.");
126
- }
127
- return {
128
- protocol_version: "1",
129
- request_id: requestId,
130
- payload: clientName === undefined
131
- ? { invitation, retry_key: retryKey.toLowerCase(), client_credential: clientCredential }
132
- : {
133
- invitation,
134
- retry_key: retryKey.toLowerCase(),
135
- client_credential: clientCredential,
136
- client_name: clientName,
137
- },
138
- };
139
- }
140
-
141
- function exactRecord(
142
- value: unknown,
143
- required: readonly string[],
144
- optional: readonly string[] = [],
145
- ): Record<string, unknown> {
146
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
147
- throw new Error("Invalid enrollment request.");
148
- }
149
- const record = value as Record<string, unknown>;
150
- const allowed = new Set([...required, ...optional]);
151
- if (Object.keys(record).some((key) => !allowed.has(key)) ||
152
- required.some((key) => !Object.hasOwn(record, key))) {
103
+ try {
104
+ return decodeEnrollmentExchangeRequestEnvelope(value);
105
+ } catch (error) {
106
+ if (error instanceof ProtocolContractError &&
107
+ error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION) throw error;
153
108
  throw new Error("Invalid enrollment request.");
154
109
  }
155
- return record;
156
110
  }