borgmcp-server 0.1.1 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -22
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +3 -1
- package/dist/coordination-api.js +476 -78
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +13 -7
- package/dist/credentials.js +77 -15
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +76 -0
- package/dist/debug-log.js +124 -0
- package/dist/debug-log.js.map +1 -0
- package/dist/enrollment.d.ts +3 -11
- package/dist/enrollment.js +21 -60
- package/dist/enrollment.js.map +1 -1
- package/dist/https-server.d.ts +5 -20
- package/dist/https-server.js +108 -50
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/message-taxonomy.d.ts +38 -0
- package/dist/message-taxonomy.js +218 -0
- package/dist/message-taxonomy.js.map +1 -0
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +99 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +14 -4
- package/dist/service.js +243 -25
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +106 -8
- package/dist/store.js +772 -120
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +490 -72
- package/src/credentials.ts +103 -19
- package/src/debug-log.ts +165 -0
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +113 -78
- package/src/index.ts +1 -1
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +102 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +268 -27
- package/src/start-options.ts +21 -4
- package/src/store.ts +887 -142
- package/dist/protocol-draft.d.ts +0 -2
- package/dist/protocol-draft.js +0 -31
- package/dist/protocol-draft.js.map +0 -1
- package/src/protocol-draft.ts +0 -32
package/src/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,10 +37,12 @@ export interface EnrollmentResponse {
|
|
|
35
37
|
readonly serverCapabilities: readonly [] | readonly ["create_cube"];
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export interface
|
|
39
|
-
readonly
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
336
|
+
credentialDigest: this.#digester.digest(request.sessionCredential, "drone-session"),
|
|
264
337
|
expiresAt: new Date(this.#clock().getTime() + 86_400_000).toISOString(),
|
|
265
338
|
});
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
package/src/debug-log.ts
ADDED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
}
|