borgmcp-server 0.1.4 → 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 +7 -6
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +269 -85
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -6
- package/dist/credentials.js +43 -19
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +2 -3
- package/dist/debug-log.js +2 -3
- package/dist/debug-log.js.map +1 -1
- 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 +2 -20
- package/dist/https-server.js +33 -51
- package/dist/https-server.js.map +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.js +28 -0
- package/dist/migrations.js.map +1 -1
- package/dist/service.d.ts +4 -1
- package/dist/service.js +25 -10
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +42 -8
- package/dist/store.js +380 -100
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/coordination-api.ts +278 -82
- package/src/credentials.ts +44 -26
- package/src/debug-log.ts +5 -5
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +44 -82
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +28 -0
- package/src/service.ts +29 -9
- package/src/store.ts +432 -121
- 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
|
@@ -41,9 +41,7 @@ export interface CubeInvitationResult extends InvitationCubeScope {
|
|
|
41
41
|
readonly invitation: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export
|
|
45
|
-
readonly credential: string;
|
|
46
|
-
}
|
|
44
|
+
export type SeatAttachResponse = SeatAttachRecord;
|
|
47
45
|
|
|
48
46
|
export class CredentialDigester {
|
|
49
47
|
readonly #key: Buffer;
|
|
@@ -88,8 +86,9 @@ export class CredentialDigester {
|
|
|
88
86
|
export class LiveCredentialRegistry {
|
|
89
87
|
readonly #sessions = new Map<string, Set<AbortController>>();
|
|
90
88
|
readonly #keys = new Map<AbortController, readonly string[]>();
|
|
89
|
+
readonly #timers = new Map<AbortController, NodeJS.Timeout>();
|
|
91
90
|
|
|
92
|
-
register(identities: string | readonly string[]): {
|
|
91
|
+
register(identities: string | readonly string[], expiresInMs?: number): {
|
|
93
92
|
readonly signal: AbortSignal;
|
|
94
93
|
readonly release: () => void;
|
|
95
94
|
} {
|
|
@@ -101,6 +100,15 @@ export class LiveCredentialRegistry {
|
|
|
101
100
|
sessions.add(controller);
|
|
102
101
|
this.#sessions.set(key, sessions);
|
|
103
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
|
+
}
|
|
104
112
|
return {
|
|
105
113
|
signal: controller.signal,
|
|
106
114
|
release: () => this.#release(controller),
|
|
@@ -121,6 +129,11 @@ export class LiveCredentialRegistry {
|
|
|
121
129
|
}
|
|
122
130
|
|
|
123
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
|
+
}
|
|
124
137
|
const keys = this.#keys.get(controller);
|
|
125
138
|
if (keys === undefined) return;
|
|
126
139
|
this.#keys.delete(controller);
|
|
@@ -130,6 +143,11 @@ export class LiveCredentialRegistry {
|
|
|
130
143
|
if (sessions?.size === 0) this.#sessions.delete(key);
|
|
131
144
|
}
|
|
132
145
|
}
|
|
146
|
+
|
|
147
|
+
#expire(controller: AbortController): void {
|
|
148
|
+
this.#release(controller);
|
|
149
|
+
controller.abort();
|
|
150
|
+
}
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
export class CredentialAuthority {
|
|
@@ -269,7 +287,7 @@ export class CredentialAuthority {
|
|
|
269
287
|
|
|
270
288
|
authenticateStatus(
|
|
271
289
|
authorization: string | undefined,
|
|
272
|
-
): Principal | "missing" | "invalid" | "revoked" {
|
|
290
|
+
): Principal | "missing" | "invalid" | "revoked" | "evicted" {
|
|
273
291
|
if (authorization === undefined) return "missing";
|
|
274
292
|
const secret = bearerSecret(authorization);
|
|
275
293
|
const clientDigest = safeDigest(this.#digester, secret, "client");
|
|
@@ -285,6 +303,7 @@ export class CredentialAuthority {
|
|
|
285
303
|
return clientPrincipal(client!.clientId!);
|
|
286
304
|
}
|
|
287
305
|
if (droneValid) {
|
|
306
|
+
if (drone!.evictedAt !== null) return "evicted";
|
|
288
307
|
if (drone!.revokedAt != null || drone!.expiresAt <= this.#clock().toISOString()) {
|
|
289
308
|
return "revoked";
|
|
290
309
|
}
|
|
@@ -303,32 +322,30 @@ export class CredentialAuthority {
|
|
|
303
322
|
request: {
|
|
304
323
|
readonly cubeId: string;
|
|
305
324
|
readonly roleId: string;
|
|
306
|
-
readonly
|
|
325
|
+
readonly sessionCredential: string;
|
|
307
326
|
readonly priorDroneId?: string;
|
|
308
327
|
},
|
|
309
328
|
): SeatAttachResponse {
|
|
310
|
-
const credential = generateSecret();
|
|
311
329
|
const record = store.attachSeat({
|
|
312
|
-
|
|
330
|
+
cubeId: request.cubeId,
|
|
331
|
+
roleId: request.roleId,
|
|
332
|
+
...(request.priorDroneId === undefined ? {} : { priorDroneId: request.priorDroneId }),
|
|
313
333
|
droneId: randomUUID(),
|
|
314
334
|
sessionId: randomUUID(),
|
|
315
335
|
credentialId: randomUUID(),
|
|
316
|
-
credentialDigest: this.#digester.digest(
|
|
336
|
+
credentialDigest: this.#digester.digest(request.sessionCredential, "drone-session"),
|
|
317
337
|
expiresAt: new Date(this.#clock().getTime() + 86_400_000).toISOString(),
|
|
318
338
|
});
|
|
319
|
-
|
|
320
|
-
this.#
|
|
321
|
-
|
|
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
|
+
});
|
|
322
347
|
}
|
|
323
|
-
|
|
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
|
-
});
|
|
331
|
-
return { ...record, credential };
|
|
348
|
+
return record;
|
|
332
349
|
}
|
|
333
350
|
|
|
334
351
|
rotateClient(clientId: string): string {
|
|
@@ -354,11 +371,12 @@ export class CredentialAuthority {
|
|
|
354
371
|
|
|
355
372
|
registerLiveSession(principal: string | Principal) {
|
|
356
373
|
if (typeof principal === "string") return this.#registry.register(principal);
|
|
357
|
-
return this.#registry.register(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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);
|
|
362
380
|
}
|
|
363
381
|
}
|
|
364
382
|
|
package/src/debug-log.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type DebugRoute =
|
|
|
10
10
|
| "cube_roles"
|
|
11
11
|
| "cube_role"
|
|
12
12
|
| "cube_role_section_patch"
|
|
13
|
+
| "cube_taxonomy_patch"
|
|
13
14
|
| "cube_drones"
|
|
14
15
|
| "cube_logs"
|
|
15
16
|
| "cube_acks"
|
|
@@ -20,14 +21,14 @@ export type DebugRoute =
|
|
|
20
21
|
export type DebugEvent =
|
|
21
22
|
| { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
|
|
22
23
|
| { readonly event: "lifecycle"; readonly action: "listening" | "stopped" }
|
|
23
|
-
| { readonly event: "request"; readonly route: DebugRoute; readonly method: string; readonly authentication: "not_required" | "missing" | "invalid" | "revoked" | "accepted"; readonly authorization: "not_checked" | "accepted" | "denied_or_not_found"; readonly principal?: Principal; readonly status: number; readonly durationMs: number }
|
|
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 }
|
|
24
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[] }
|
|
25
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 }
|
|
26
27
|
| { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
|
|
27
28
|
| { readonly event: "decision_write"; readonly cubeId: string; readonly decisionId: string; readonly principal: Principal }
|
|
28
29
|
| { readonly event: "sse_subscribe"; readonly connectionId: string; readonly cubeId: string; readonly principal: Principal; readonly replayCount: number; readonly truncated: boolean }
|
|
29
30
|
| { readonly event: "sse_unsubscribe"; readonly connectionId: string; readonly cubeId: string; readonly principal: Principal; readonly deliveryCount: number }
|
|
30
|
-
| { 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
|
|
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 }
|
|
31
32
|
| { readonly event: "transport_rejection"; readonly reason: "tls_client_error" | "http_parser_error" };
|
|
32
33
|
|
|
33
34
|
export interface DebugLogger {
|
|
@@ -113,7 +114,6 @@ function projectEvent(event: DebugEvent): Record<string, unknown> | null {
|
|
|
113
114
|
...optionalUuid("cube_id", value["cubeId"]),
|
|
114
115
|
...optionalUuid("drone_id", value["droneId"]),
|
|
115
116
|
...optionalUuid("session_id", value["sessionId"]),
|
|
116
|
-
...(Number.isSafeInteger(value["generation"]) ? { generation: value["generation"] } : {}),
|
|
117
117
|
};
|
|
118
118
|
case "transport_rejection":
|
|
119
119
|
return { event: "transport_rejection", reason: enumValue(value["reason"], ["tls_client_error", "http_parser_error"], "tls_client_error") };
|
|
@@ -158,8 +158,8 @@ function enumValue<const T extends string>(value: unknown, allowed: readonly T[]
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
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_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
|
|
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
162
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OTHER"] as const;
|
|
163
|
-
const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "accepted"] as const;
|
|
163
|
+
const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "evicted", "accepted"] as const;
|
|
164
164
|
const AUTHZ_RESULTS = ["not_checked", "accepted", "denied_or_not_found"] as const;
|
|
165
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
|
}
|
package/src/https-server.ts
CHANGED
|
@@ -2,27 +2,22 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { createServer, type Server as HttpsServer } from "node:https";
|
|
3
3
|
import type { AddressInfo, Socket } from "node:net";
|
|
4
4
|
import { createHash, X509Certificate } from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
ATTACH_PATH,
|
|
7
|
+
CUBES_PATH,
|
|
8
|
+
ENROLLMENT_EXCHANGE_PATH,
|
|
9
|
+
ErrorCode,
|
|
10
|
+
HEALTH_PATH,
|
|
11
|
+
PROTOCOL_INFO_PATH,
|
|
12
|
+
PROTOCOL_VERSION,
|
|
13
|
+
createProtocolTagPreflight,
|
|
14
|
+
} from "borgmcp-shared/protocol";
|
|
5
15
|
|
|
6
16
|
import { resolveBindOptions, type BindOptionsInput } from "./network-policy.js";
|
|
7
17
|
import type { CoordinationRequest, CoordinationResponse } from "./coordination-api.js";
|
|
8
18
|
import type { Principal } from "./principal.js";
|
|
9
19
|
import { disabledDebugLogger, type DebugLogger, type DebugRoute } from "./debug-log.js";
|
|
10
20
|
|
|
11
|
-
export interface ProtocolInfoDocument {
|
|
12
|
-
readonly protocol_version: string;
|
|
13
|
-
readonly package: {
|
|
14
|
-
readonly name: "borgmcp-shared";
|
|
15
|
-
readonly version: string;
|
|
16
|
-
};
|
|
17
|
-
readonly capabilities: readonly string[];
|
|
18
|
-
readonly limits: {
|
|
19
|
-
readonly max_request_bytes: number;
|
|
20
|
-
readonly max_log_message_bytes: number;
|
|
21
|
-
readonly max_read_page_size: number;
|
|
22
|
-
readonly max_replay_page_size: number;
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
21
|
export interface ServiceLimits {
|
|
27
22
|
readonly maxConnections: number;
|
|
28
23
|
readonly maxConnectionsPerAddress: number;
|
|
@@ -62,18 +57,13 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
|
62
57
|
};
|
|
63
58
|
|
|
64
59
|
export interface RequestHandlerContext {
|
|
65
|
-
readonly protocolInfo: ProtocolInfoDocument;
|
|
66
|
-
readonly authorizeProtocol: (
|
|
67
|
-
authorization: string | undefined,
|
|
68
|
-
signal: AbortSignal,
|
|
69
|
-
) => Promise<boolean | "missing" | "invalid" | "revoked">;
|
|
70
60
|
readonly exchangeEnrollment?: (
|
|
71
61
|
body: unknown,
|
|
72
|
-
) => Promise<{ readonly status: 201 | 400 | 401 | 507; readonly body?: unknown }>;
|
|
62
|
+
) => Promise<{ readonly status: 201 | 400 | 401 | 426 | 507; readonly body?: unknown }>;
|
|
73
63
|
readonly authorizeCoordination?: (
|
|
74
64
|
authorization: string | undefined,
|
|
75
65
|
signal: AbortSignal,
|
|
76
|
-
) => Promise<Principal | "missing" | "invalid" | "revoked">;
|
|
66
|
+
) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
|
|
77
67
|
readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
|
|
78
68
|
readonly debugLogger: DebugLogger;
|
|
79
69
|
}
|
|
@@ -85,8 +75,6 @@ export interface HttpsServerOptions {
|
|
|
85
75
|
readonly cert: string | Buffer;
|
|
86
76
|
readonly ca?: string | Buffer;
|
|
87
77
|
};
|
|
88
|
-
readonly protocolInfo: ProtocolInfoDocument;
|
|
89
|
-
readonly authorizeProtocol: RequestHandlerContext["authorizeProtocol"];
|
|
90
78
|
readonly exchangeEnrollment?: RequestHandlerContext["exchangeEnrollment"];
|
|
91
79
|
readonly authorizeCoordination?: RequestHandlerContext["authorizeCoordination"];
|
|
92
80
|
readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
|
|
@@ -168,8 +156,6 @@ export function createRequestHandlerContext(
|
|
|
168
156
|
options: HttpsServerOptions,
|
|
169
157
|
): RequestHandlerContext {
|
|
170
158
|
return Object.freeze({
|
|
171
|
-
protocolInfo: options.protocolInfo,
|
|
172
|
-
authorizeProtocol: options.authorizeProtocol,
|
|
173
159
|
...(options.exchangeEnrollment === undefined
|
|
174
160
|
? {}
|
|
175
161
|
: { exchangeEnrollment: options.exchangeEnrollment }),
|
|
@@ -290,44 +276,23 @@ async function handleRequest(
|
|
|
290
276
|
return;
|
|
291
277
|
}
|
|
292
278
|
|
|
293
|
-
if (path ===
|
|
279
|
+
if (path === HEALTH_PATH) {
|
|
294
280
|
if (requestBody.length !== 0) return sendEmpty(response, 400, true);
|
|
295
281
|
sendEmpty(response, request.method === "GET" ? 204 : 405);
|
|
296
282
|
return;
|
|
297
283
|
}
|
|
298
284
|
|
|
299
|
-
if (path ===
|
|
285
|
+
if (path === PROTOCOL_INFO_PATH) {
|
|
300
286
|
if (requestBody.length !== 0) return sendEmpty(response, 400, true);
|
|
301
|
-
const authorized = await context.authorizeProtocol(request.headers.authorization, signal);
|
|
302
|
-
trace.authentication = authorized === true ? "accepted" : authorized === false ? "invalid" : authorized;
|
|
303
|
-
if (signal.aborted) return;
|
|
304
|
-
if (authorized !== true) {
|
|
305
|
-
const code = authorized === "revoked"
|
|
306
|
-
? "SESSION_REVOKED"
|
|
307
|
-
: authorized === "missing" || request.headers.authorization === undefined
|
|
308
|
-
? "AUTH_MISSING"
|
|
309
|
-
: "AUTH_INVALID";
|
|
310
|
-
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
const credentialRetry = consumeAuthenticatedRateLimit(
|
|
314
|
-
request.headers.authorization,
|
|
315
|
-
credentialRateLimiter,
|
|
316
|
-
);
|
|
317
|
-
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
318
287
|
if (request.method !== "GET") {
|
|
319
288
|
sendEmpty(response, 405);
|
|
320
289
|
return;
|
|
321
290
|
}
|
|
322
|
-
sendJson(response, 200,
|
|
323
|
-
protocol_version: "1",
|
|
324
|
-
request_id: "protocol-info",
|
|
325
|
-
payload: context.protocolInfo,
|
|
326
|
-
});
|
|
291
|
+
sendJson(response, 200, createProtocolTagPreflight());
|
|
327
292
|
return;
|
|
328
293
|
}
|
|
329
294
|
|
|
330
|
-
if (path ===
|
|
295
|
+
if (path === ENROLLMENT_EXCHANGE_PATH) {
|
|
331
296
|
if (request.method !== "POST" || context.exchangeEnrollment === undefined) {
|
|
332
297
|
sendEmpty(response, 405);
|
|
333
298
|
return;
|
|
@@ -346,7 +311,9 @@ async function handleRequest(
|
|
|
346
311
|
if (signal.aborted) return;
|
|
347
312
|
if (result.body === undefined) sendEmpty(response, result.status, result.status === 400);
|
|
348
313
|
else if (result.status === 400) sendJson(response, 400, result.body, true);
|
|
349
|
-
else if (result.status === 401 || result.status ===
|
|
314
|
+
else if (result.status === 401 || result.status === 426 || result.status === 507) {
|
|
315
|
+
sendJson(response, result.status, result.body);
|
|
316
|
+
}
|
|
350
317
|
else sendJson(response, 201, result.body);
|
|
351
318
|
return;
|
|
352
319
|
}
|
|
@@ -372,6 +339,10 @@ async function handleRequest(
|
|
|
372
339
|
trace.authentication = typeof authentication === "string" ? authentication : "accepted";
|
|
373
340
|
if (signal.aborted) return;
|
|
374
341
|
if (typeof authentication === "string") {
|
|
342
|
+
if (authentication === "evicted") {
|
|
343
|
+
sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
375
346
|
const code = authentication === "revoked"
|
|
376
347
|
? "SESSION_REVOKED"
|
|
377
348
|
: authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
|
|
@@ -384,8 +355,8 @@ async function handleRequest(
|
|
|
384
355
|
: authentication.id}`;
|
|
385
356
|
const credentialRetry = credentialRateLimiter.consume(clientIdentity);
|
|
386
357
|
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
358
|
+
const query = parseCoordinationQuery(request.url, path);
|
|
359
|
+
if (query === INVALID_COORDINATION_QUERY) {
|
|
389
360
|
sendJson(response, 400, protocolError("INVALID_INPUT", "Invalid query parameters."), true);
|
|
390
361
|
return;
|
|
391
362
|
}
|
|
@@ -394,7 +365,7 @@ async function handleRequest(
|
|
|
394
365
|
path,
|
|
395
366
|
principal: authentication,
|
|
396
367
|
...(decoded === undefined ? {} : { body: decoded }),
|
|
397
|
-
...
|
|
368
|
+
...query,
|
|
398
369
|
signal,
|
|
399
370
|
});
|
|
400
371
|
if (signal.aborted) {
|
|
@@ -422,7 +393,7 @@ async function handleRequest(
|
|
|
422
393
|
interface RequestTrace {
|
|
423
394
|
readonly route: DebugRoute;
|
|
424
395
|
readonly method: string;
|
|
425
|
-
authentication: "not_required" | "missing" | "invalid" | "revoked" | "accepted";
|
|
396
|
+
authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
|
|
426
397
|
principal?: Principal;
|
|
427
398
|
}
|
|
428
399
|
|
|
@@ -434,15 +405,16 @@ function debugMethod(method: string | undefined): string {
|
|
|
434
405
|
function debugRoute(rawUrl: string | undefined): DebugRoute {
|
|
435
406
|
const path = parseRequestPath(rawUrl);
|
|
436
407
|
if (path === null) return "unknown";
|
|
437
|
-
if (path ===
|
|
438
|
-
if (path ===
|
|
439
|
-
if (path ===
|
|
440
|
-
if (path ===
|
|
408
|
+
if (path === HEALTH_PATH) return "health";
|
|
409
|
+
if (path === PROTOCOL_INFO_PATH) return "protocol";
|
|
410
|
+
if (path === ENROLLMENT_EXCHANGE_PATH) return "enrollment_exchange";
|
|
411
|
+
if (path === ATTACH_PATH) return "client_attach";
|
|
441
412
|
if (path === "/api/cubes") return "cubes";
|
|
442
413
|
if (/^\/api\/cubes\/[0-9a-f-]{36}$/iu.test(path)) return "cube";
|
|
443
414
|
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles$/iu.test(path)) return "cube_roles";
|
|
444
415
|
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles\/[0-9a-f-]{36}$/iu.test(path)) return "cube_role";
|
|
445
416
|
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles\/[0-9a-f-]{36}\/section-patch$/iu.test(path)) return "cube_role_section_patch";
|
|
417
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/taxonomy-patch$/iu.test(path)) return "cube_taxonomy_patch";
|
|
446
418
|
if (/^\/api\/cubes\/[0-9a-f-]{36}\/drones$/iu.test(path)) return "cube_drones";
|
|
447
419
|
if (/^\/api\/cubes\/[0-9a-f-]{36}\/logs$/iu.test(path)) return "cube_logs";
|
|
448
420
|
if (/^\/api\/cubes\/[0-9a-f-]{36}\/acks$/iu.test(path)) return "cube_acks";
|
|
@@ -590,35 +562,33 @@ function waitForDrain(response: ServerResponse): Promise<void> {
|
|
|
590
562
|
}
|
|
591
563
|
|
|
592
564
|
function protocolError(code: string, message: string): object {
|
|
593
|
-
return { protocol_version:
|
|
565
|
+
return { protocol_version: PROTOCOL_VERSION, error: { code, message } };
|
|
594
566
|
}
|
|
595
567
|
|
|
596
568
|
const INVALID_COORDINATION_QUERY = Symbol("invalid-coordination-query");
|
|
597
569
|
|
|
598
|
-
function
|
|
570
|
+
function parseCoordinationQuery(
|
|
599
571
|
value: string | undefined,
|
|
600
572
|
path: string,
|
|
601
|
-
): string
|
|
602
|
-
if (value === undefined) return
|
|
573
|
+
): { readonly cursor?: string; readonly since?: string } | typeof INVALID_COORDINATION_QUERY {
|
|
574
|
+
if (value === undefined) return {};
|
|
603
575
|
try {
|
|
604
576
|
const parsed = new URL(value, "https://local.invalid");
|
|
605
577
|
const keys = [...parsed.searchParams.keys()];
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
if (values.length === 0) return
|
|
612
|
-
return
|
|
613
|
-
? values[0]
|
|
614
|
-
: INVALID_COORDINATION_QUERY;
|
|
578
|
+
const allowed = path.endsWith("/stream") ? "cursor" : path.endsWith("/drones") ? "since" : null;
|
|
579
|
+
if (allowed === null) return keys.length === 0 ? {} : INVALID_COORDINATION_QUERY;
|
|
580
|
+
if (keys.some((key) => key !== allowed)) return INVALID_COORDINATION_QUERY;
|
|
581
|
+
const values = parsed.searchParams.getAll(allowed);
|
|
582
|
+
if (values.length === 0) return {};
|
|
583
|
+
if (values.length !== 1 || values[0]!.length === 0) return INVALID_COORDINATION_QUERY;
|
|
584
|
+
return allowed === "cursor" ? { cursor: values[0]! } : { since: values[0]! };
|
|
615
585
|
} catch {
|
|
616
586
|
return INVALID_COORDINATION_QUERY;
|
|
617
587
|
}
|
|
618
588
|
}
|
|
619
589
|
|
|
620
590
|
function isCoordinationPath(path: string | null): path is string {
|
|
621
|
-
return path ===
|
|
591
|
+
return path === ATTACH_PATH || path === CUBES_PATH ||
|
|
622
592
|
path?.startsWith("/api/cubes/") === true;
|
|
623
593
|
}
|
|
624
594
|
|
|
@@ -994,14 +964,6 @@ function credentialIdentity(authorization: string | undefined): string | null {
|
|
|
994
964
|
return `credential:${createHash("sha256").update(authorization).digest("base64url")}`;
|
|
995
965
|
}
|
|
996
966
|
|
|
997
|
-
function consumeAuthenticatedRateLimit(
|
|
998
|
-
authorization: string | undefined,
|
|
999
|
-
rateLimiter: RequestRateLimiter,
|
|
1000
|
-
): number | null {
|
|
1001
|
-
const identity = credentialIdentity(authorization);
|
|
1002
|
-
return identity === null ? null : rateLimiter.consume(identity);
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
967
|
function listen(server: HttpsServer, port: number, host: string): Promise<void> {
|
|
1006
968
|
return new Promise((resolve, reject) => {
|
|
1007
969
|
const onError = (error: Error): void => {
|