borgmcp-server 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -22
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +232 -18
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +10 -2
- package/dist/credentials.js +44 -6
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +77 -0
- package/dist/debug-log.js +125 -0
- package/dist/debug-log.js.map +1 -0
- package/dist/https-server.d.ts +3 -0
- package/dist/https-server.js +80 -4
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +71 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +10 -3
- package/dist/service.js +218 -15
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +65 -1
- package/dist/store.js +398 -26
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +236 -14
- package/src/credentials.ts +71 -5
- package/src/debug-log.ts +165 -0
- package/src/https-server.ts +75 -2
- package/src/index.ts +1 -1
- package/src/migrations.ts +74 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +239 -18
- package/src/start-options.ts +21 -4
- package/src/store.ts +462 -28
package/src/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_drones"
|
|
14
|
+
| "cube_logs"
|
|
15
|
+
| "cube_acks"
|
|
16
|
+
| "cube_decisions"
|
|
17
|
+
| "cube_stream"
|
|
18
|
+
| "unknown";
|
|
19
|
+
|
|
20
|
+
export type DebugEvent =
|
|
21
|
+
| { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
|
|
22
|
+
| { 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: "activity_append"; readonly cubeId: string; readonly entryId: string; readonly principal: Principal; readonly droneId: string | null; readonly visibility: "broadcast" | "direct"; readonly recipientDroneIds: readonly string[] }
|
|
25
|
+
| { 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
|
+
| { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
|
|
27
|
+
| { readonly event: "decision_write"; readonly cubeId: string; readonly decisionId: string; readonly principal: Principal }
|
|
28
|
+
| { readonly event: "sse_subscribe"; readonly connectionId: string; readonly cubeId: string; readonly principal: Principal; readonly replayCount: number; readonly truncated: boolean }
|
|
29
|
+
| { 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; readonly generation?: number }
|
|
31
|
+
| { readonly event: "transport_rejection"; readonly reason: "tls_client_error" | "http_parser_error" };
|
|
32
|
+
|
|
33
|
+
export interface DebugLogger {
|
|
34
|
+
readonly emit: (event: DebugEvent) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const disabledDebugLogger: DebugLogger = Object.freeze({ emit: () => undefined });
|
|
38
|
+
|
|
39
|
+
export function createDebugLogger(write: ((line: string) => void) | undefined): DebugLogger {
|
|
40
|
+
if (write === undefined) return disabledDebugLogger;
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
emit(event: DebugEvent): void {
|
|
43
|
+
try {
|
|
44
|
+
const projected = projectEvent(event);
|
|
45
|
+
if (projected !== null) write(JSON.stringify({ level: "debug", ...projected }));
|
|
46
|
+
} catch {
|
|
47
|
+
// Operator diagnostics cannot alter request or server behavior.
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function projectEvent(event: DebugEvent): Record<string, unknown> | null {
|
|
54
|
+
const value = event as unknown as Record<string, unknown>;
|
|
55
|
+
switch (value["event"]) {
|
|
56
|
+
case "startup":
|
|
57
|
+
return {
|
|
58
|
+
event: "startup",
|
|
59
|
+
bind_mode: enumValue(value["bindMode"], ["loopback", "lan"], "loopback"),
|
|
60
|
+
port: boundedInteger(value["port"], 0, 65_535),
|
|
61
|
+
data_directory: enumValue(value["dataDirectory"], ["configured", "tls_only"], "tls_only"),
|
|
62
|
+
};
|
|
63
|
+
case "request": {
|
|
64
|
+
const principal = principalFields(value["principal"]);
|
|
65
|
+
return {
|
|
66
|
+
event: "request",
|
|
67
|
+
route: enumValue(value["route"], DEBUG_ROUTES, "unknown"),
|
|
68
|
+
method: enumValue(value["method"], HTTP_METHODS, "OTHER"),
|
|
69
|
+
authentication: enumValue(value["authentication"], AUTH_RESULTS, "invalid"),
|
|
70
|
+
authorization: enumValue(value["authorization"], AUTHZ_RESULTS, "not_checked"),
|
|
71
|
+
...principal,
|
|
72
|
+
status: boundedInteger(value["status"], 0, 599),
|
|
73
|
+
duration_ms: boundedInteger(value["durationMs"], 0, Number.MAX_SAFE_INTEGER),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
case "lifecycle":
|
|
77
|
+
return { event: "lifecycle", action: enumValue(value["action"], ["listening", "stopped"], "stopped") };
|
|
78
|
+
case "activity_append":
|
|
79
|
+
return {
|
|
80
|
+
event: "activity_append",
|
|
81
|
+
cube_id: uuid(value["cubeId"]),
|
|
82
|
+
entry_id: uuid(value["entryId"]),
|
|
83
|
+
...principalFields(value["principal"]),
|
|
84
|
+
drone_id: nullableUuid(value["droneId"]),
|
|
85
|
+
visibility: enumValue(value["visibility"], ["broadcast", "direct"], "broadcast"),
|
|
86
|
+
recipient_count: uuidArray(value["recipientDroneIds"]).length,
|
|
87
|
+
recipient_drone_ids: uuidArray(value["recipientDroneIds"]),
|
|
88
|
+
};
|
|
89
|
+
case "cursor_replay":
|
|
90
|
+
return {
|
|
91
|
+
event: "cursor_replay",
|
|
92
|
+
mode: enumValue(value["mode"], ["page", "sse"], "page"),
|
|
93
|
+
cube_id: uuid(value["cubeId"]),
|
|
94
|
+
cursor_id: nullableUuid(value["cursorId"]),
|
|
95
|
+
returned_count: boundedInteger(value["returnedCount"], 0, 500),
|
|
96
|
+
behind_by: boundedInteger(value["behindBy"], 0, Number.MAX_SAFE_INTEGER),
|
|
97
|
+
truncated: value["truncated"] === true,
|
|
98
|
+
};
|
|
99
|
+
case "ack_write":
|
|
100
|
+
return { event: "ack_write", cube_id: uuid(value["cubeId"]), entry_id: uuid(value["entryId"]), kind: enumValue(value["kind"], ["ack", "claim"], "ack"), ...principalFields(value["principal"]) };
|
|
101
|
+
case "decision_write":
|
|
102
|
+
return { event: "decision_write", cube_id: uuid(value["cubeId"]), decision_id: uuid(value["decisionId"]), ...principalFields(value["principal"]) };
|
|
103
|
+
case "sse_subscribe":
|
|
104
|
+
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 };
|
|
105
|
+
case "sse_unsubscribe":
|
|
106
|
+
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) };
|
|
107
|
+
case "credential":
|
|
108
|
+
return {
|
|
109
|
+
event: "credential",
|
|
110
|
+
action: enumValue(value["action"], CREDENTIAL_ACTIONS, "enrollment_rejected"),
|
|
111
|
+
...(value["purpose"] === "owner" || value["purpose"] === "client" ? { purpose: value["purpose"] } : {}),
|
|
112
|
+
...optionalUuid("client_id", value["clientId"]),
|
|
113
|
+
...optionalUuid("cube_id", value["cubeId"]),
|
|
114
|
+
...optionalUuid("drone_id", value["droneId"]),
|
|
115
|
+
...optionalUuid("session_id", value["sessionId"]),
|
|
116
|
+
...(Number.isSafeInteger(value["generation"]) ? { generation: value["generation"] } : {}),
|
|
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_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", "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/https-server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createHash, X509Certificate } from "node:crypto";
|
|
|
6
6
|
import { resolveBindOptions, type BindOptionsInput } from "./network-policy.js";
|
|
7
7
|
import type { CoordinationRequest, CoordinationResponse } from "./coordination-api.js";
|
|
8
8
|
import type { Principal } from "./principal.js";
|
|
9
|
+
import { disabledDebugLogger, type DebugLogger, type DebugRoute } from "./debug-log.js";
|
|
9
10
|
|
|
10
11
|
export interface ProtocolInfoDocument {
|
|
11
12
|
readonly protocol_version: string;
|
|
@@ -74,6 +75,7 @@ export interface RequestHandlerContext {
|
|
|
74
75
|
signal: AbortSignal,
|
|
75
76
|
) => Promise<Principal | "missing" | "invalid" | "revoked">;
|
|
76
77
|
readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
|
|
78
|
+
readonly debugLogger: DebugLogger;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export interface HttpsServerOptions {
|
|
@@ -89,6 +91,7 @@ export interface HttpsServerOptions {
|
|
|
89
91
|
readonly authorizeCoordination?: RequestHandlerContext["authorizeCoordination"];
|
|
90
92
|
readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
|
|
91
93
|
readonly limits?: ServiceLimits;
|
|
94
|
+
readonly debugLogger?: DebugLogger;
|
|
92
95
|
readonly testHooks?: {
|
|
93
96
|
readonly identifyRemoteAddress?: (socket: Socket) => string;
|
|
94
97
|
};
|
|
@@ -126,8 +129,14 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
|
|
|
126
129
|
|
|
127
130
|
const acceptedSockets = applyServerLimits(server, limits);
|
|
128
131
|
server.on("secureConnection", (socket) => socket.disableRenegotiation());
|
|
129
|
-
server.on("tlsClientError", (_error, socket) =>
|
|
130
|
-
|
|
132
|
+
server.on("tlsClientError", (_error, socket) => {
|
|
133
|
+
handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "tls_client_error" });
|
|
134
|
+
socket.destroy();
|
|
135
|
+
});
|
|
136
|
+
server.on("clientError", (_error, socket) => {
|
|
137
|
+
handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "http_parser_error" });
|
|
138
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
139
|
+
});
|
|
131
140
|
server.on("checkContinue", (_request, response) => sendEmpty(response, 417, true));
|
|
132
141
|
|
|
133
142
|
try {
|
|
@@ -170,6 +179,7 @@ export function createRequestHandlerContext(
|
|
|
170
179
|
...(options.handleCoordination === undefined
|
|
171
180
|
? {}
|
|
172
181
|
: { handleCoordination: options.handleCoordination }),
|
|
182
|
+
debugLogger: options.debugLogger ?? disabledDebugLogger,
|
|
173
183
|
});
|
|
174
184
|
}
|
|
175
185
|
|
|
@@ -182,6 +192,32 @@ function createRequestListener(
|
|
|
182
192
|
const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
|
|
183
193
|
const streamQuota = new ConcurrentQuota(limits.maxStreamsPerCredential);
|
|
184
194
|
return (request, response) => {
|
|
195
|
+
const startedAt = Date.now();
|
|
196
|
+
const trace: RequestTrace = {
|
|
197
|
+
route: debugRoute(request.url),
|
|
198
|
+
method: debugMethod(request.method),
|
|
199
|
+
authentication: "not_required",
|
|
200
|
+
};
|
|
201
|
+
let debugEmitted = false;
|
|
202
|
+
const emitDebug = (): void => {
|
|
203
|
+
if (debugEmitted) return;
|
|
204
|
+
debugEmitted = true;
|
|
205
|
+
const status = response.headersSent ? response.statusCode : 0;
|
|
206
|
+
context.debugLogger.emit({
|
|
207
|
+
event: "request",
|
|
208
|
+
route: trace.route,
|
|
209
|
+
method: trace.method,
|
|
210
|
+
authentication: trace.authentication,
|
|
211
|
+
authorization: trace.authentication === "accepted"
|
|
212
|
+
? status === 403 || status === 404 ? "denied_or_not_found" : "accepted"
|
|
213
|
+
: "not_checked",
|
|
214
|
+
...(trace.principal === undefined ? {} : { principal: trace.principal }),
|
|
215
|
+
status,
|
|
216
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
response.once("finish", emitDebug);
|
|
220
|
+
response.once("close", emitDebug);
|
|
185
221
|
const controller = new AbortController();
|
|
186
222
|
response.once("close", () => controller.abort());
|
|
187
223
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -202,6 +238,7 @@ function createRequestListener(
|
|
|
202
238
|
streamQuota,
|
|
203
239
|
identifyRemoteAddress,
|
|
204
240
|
controller.signal,
|
|
241
|
+
trace,
|
|
205
242
|
)
|
|
206
243
|
.then(() => "handled" as const);
|
|
207
244
|
|
|
@@ -226,6 +263,7 @@ async function handleRequest(
|
|
|
226
263
|
streamQuota: ConcurrentQuota,
|
|
227
264
|
identifyRemoteAddress: (socket: Socket) => string,
|
|
228
265
|
signal: AbortSignal,
|
|
266
|
+
trace: RequestTrace,
|
|
229
267
|
): Promise<void> {
|
|
230
268
|
const addressIdentity = `address:${identifyRemoteAddress(request.socket)}`;
|
|
231
269
|
const preAuthRetry = admissionLimiter.consume(addressIdentity);
|
|
@@ -261,6 +299,7 @@ async function handleRequest(
|
|
|
261
299
|
if (path === "/api/protocol") {
|
|
262
300
|
if (requestBody.length !== 0) return sendEmpty(response, 400, true);
|
|
263
301
|
const authorized = await context.authorizeProtocol(request.headers.authorization, signal);
|
|
302
|
+
trace.authentication = authorized === true ? "accepted" : authorized === false ? "invalid" : authorized;
|
|
264
303
|
if (signal.aborted) return;
|
|
265
304
|
if (authorized !== true) {
|
|
266
305
|
const code = authorized === "revoked"
|
|
@@ -330,6 +369,7 @@ async function handleRequest(
|
|
|
330
369
|
return;
|
|
331
370
|
}
|
|
332
371
|
const authentication = await authorizeCoordination(authorization, signal);
|
|
372
|
+
trace.authentication = typeof authentication === "string" ? authentication : "accepted";
|
|
333
373
|
if (signal.aborted) return;
|
|
334
374
|
if (typeof authentication === "string") {
|
|
335
375
|
const code = authentication === "revoked"
|
|
@@ -338,6 +378,7 @@ async function handleRequest(
|
|
|
338
378
|
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
339
379
|
return;
|
|
340
380
|
}
|
|
381
|
+
trace.principal = authentication;
|
|
341
382
|
const clientIdentity = `client:${authentication.kind === "drone-session"
|
|
342
383
|
? authentication.clientId
|
|
343
384
|
: authentication.id}`;
|
|
@@ -378,6 +419,38 @@ async function handleRequest(
|
|
|
378
419
|
sendEmpty(response, 404);
|
|
379
420
|
}
|
|
380
421
|
|
|
422
|
+
interface RequestTrace {
|
|
423
|
+
readonly route: DebugRoute;
|
|
424
|
+
readonly method: string;
|
|
425
|
+
authentication: "not_required" | "missing" | "invalid" | "revoked" | "accepted";
|
|
426
|
+
principal?: Principal;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function debugMethod(method: string | undefined): string {
|
|
430
|
+
return method === "GET" || method === "POST" || method === "PUT" ||
|
|
431
|
+
method === "PATCH" || method === "DELETE" ? method : "OTHER";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function debugRoute(rawUrl: string | undefined): DebugRoute {
|
|
435
|
+
const path = parseRequestPath(rawUrl);
|
|
436
|
+
if (path === null) return "unknown";
|
|
437
|
+
if (path === "/healthz") return "health";
|
|
438
|
+
if (path === "/api/protocol") return "protocol";
|
|
439
|
+
if (path === "/api/enrollment/exchange") return "enrollment_exchange";
|
|
440
|
+
if (path === "/api/client/attach") return "client_attach";
|
|
441
|
+
if (path === "/api/cubes") return "cubes";
|
|
442
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}$/iu.test(path)) return "cube";
|
|
443
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles$/iu.test(path)) return "cube_roles";
|
|
444
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles\/[0-9a-f-]{36}$/iu.test(path)) return "cube_role";
|
|
445
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles\/[0-9a-f-]{36}\/section-patch$/iu.test(path)) return "cube_role_section_patch";
|
|
446
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/drones$/iu.test(path)) return "cube_drones";
|
|
447
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/logs$/iu.test(path)) return "cube_logs";
|
|
448
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/acks$/iu.test(path)) return "cube_acks";
|
|
449
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/decisions$/iu.test(path)) return "cube_decisions";
|
|
450
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/stream$/iu.test(path)) return "cube_stream";
|
|
451
|
+
return "unknown";
|
|
452
|
+
}
|
|
453
|
+
|
|
381
454
|
async function closeRejectedStream(stream: AsyncIterable<string>): Promise<void> {
|
|
382
455
|
const iterator = stream[Symbol.asyncIterator]();
|
|
383
456
|
try {
|
package/src/index.ts
CHANGED
package/src/migrations.ts
CHANGED
|
@@ -7,6 +7,13 @@ export interface Migration {
|
|
|
7
7
|
readonly sql: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export class MigrationCompatibilityError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("Database migrations do not exactly match this server version.");
|
|
13
|
+
this.name = "MigrationCompatibilityError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
export const STORE_MIGRATIONS: readonly Migration[] = Object.freeze([
|
|
11
18
|
{
|
|
12
19
|
version: 1,
|
|
@@ -303,6 +310,46 @@ export const STORE_MIGRATIONS: readonly Migration[] = Object.freeze([
|
|
|
303
310
|
ON seat_attach_bindings (drone_id, client_id, cube_id);
|
|
304
311
|
`,
|
|
305
312
|
},
|
|
313
|
+
{
|
|
314
|
+
version: 7,
|
|
315
|
+
name: "role_management_foundation",
|
|
316
|
+
sql: `
|
|
317
|
+
ALTER TABLE roles ADD COLUMN is_mandatory INTEGER NOT NULL DEFAULT 0
|
|
318
|
+
CHECK (is_mandatory IN (0, 1));
|
|
319
|
+
ALTER TABLE roles ADD COLUMN can_broadcast INTEGER NOT NULL DEFAULT 0
|
|
320
|
+
CHECK (can_broadcast IN (0, 1));
|
|
321
|
+
ALTER TABLE roles ADD COLUMN receives_all_direct INTEGER NOT NULL DEFAULT 0
|
|
322
|
+
CHECK (receives_all_direct IN (0, 1));
|
|
323
|
+
|
|
324
|
+
CREATE UNIQUE INDEX roles_one_default_per_cube_idx
|
|
325
|
+
ON roles (cube_id) WHERE is_default = 1;
|
|
326
|
+
`,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
version: 8,
|
|
330
|
+
name: "cube_scoped_invitations",
|
|
331
|
+
sql: `
|
|
332
|
+
ALTER TABLE enrollment_invitations ADD COLUMN cube_id TEXT;
|
|
333
|
+
ALTER TABLE enrollment_invitations ADD COLUMN access TEXT
|
|
334
|
+
CHECK (access IS NULL OR access IN ('read', 'write', 'manage'));
|
|
335
|
+
|
|
336
|
+
CREATE TRIGGER enrollment_invitations_scope_insert
|
|
337
|
+
BEFORE INSERT ON enrollment_invitations
|
|
338
|
+
WHEN (NEW.cube_id IS NULL) <> (NEW.access IS NULL)
|
|
339
|
+
OR (NEW.cube_id IS NOT NULL AND NEW.purpose <> 'client')
|
|
340
|
+
BEGIN
|
|
341
|
+
SELECT RAISE(ABORT, 'invalid invitation cube scope');
|
|
342
|
+
END;
|
|
343
|
+
|
|
344
|
+
CREATE TRIGGER enrollment_invitations_scope_update
|
|
345
|
+
BEFORE UPDATE OF cube_id, access, purpose ON enrollment_invitations
|
|
346
|
+
WHEN (NEW.cube_id IS NULL) <> (NEW.access IS NULL)
|
|
347
|
+
OR (NEW.cube_id IS NOT NULL AND NEW.purpose <> 'client')
|
|
348
|
+
BEGIN
|
|
349
|
+
SELECT RAISE(ABORT, 'invalid invitation cube scope');
|
|
350
|
+
END;
|
|
351
|
+
`,
|
|
352
|
+
},
|
|
306
353
|
]);
|
|
307
354
|
|
|
308
355
|
interface AppliedMigrationRow {
|
|
@@ -366,6 +413,33 @@ export function applyMigrations(
|
|
|
366
413
|
}
|
|
367
414
|
}
|
|
368
415
|
|
|
416
|
+
export function assertMigrationsCurrent(
|
|
417
|
+
database: DatabaseSync,
|
|
418
|
+
migrations: readonly Migration[] = STORE_MIGRATIONS,
|
|
419
|
+
): void {
|
|
420
|
+
validateMigrationOrder(migrations);
|
|
421
|
+
let rows: Record<string, unknown>[];
|
|
422
|
+
try {
|
|
423
|
+
rows = database.prepare(
|
|
424
|
+
"SELECT version, name, checksum FROM schema_migrations ORDER BY version",
|
|
425
|
+
).all();
|
|
426
|
+
} catch {
|
|
427
|
+
throw new MigrationCompatibilityError();
|
|
428
|
+
}
|
|
429
|
+
if (rows.length !== migrations.length) throw new MigrationCompatibilityError();
|
|
430
|
+
for (let index = 0; index < migrations.length; index += 1) {
|
|
431
|
+
let applied: AppliedMigrationRow;
|
|
432
|
+
try {
|
|
433
|
+
applied = appliedMigrationRow(rows[index]!);
|
|
434
|
+
} catch {
|
|
435
|
+
throw new MigrationCompatibilityError();
|
|
436
|
+
}
|
|
437
|
+
const expected = migrations[index]!;
|
|
438
|
+
if (applied.version !== expected.version || applied.name !== expected.name ||
|
|
439
|
+
applied.checksum !== checksum(expected)) throw new MigrationCompatibilityError();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
369
443
|
function validateMigrationOrder(migrations: readonly Migration[]): void {
|
|
370
444
|
migrations.forEach((migration, index) => {
|
|
371
445
|
if (migration.version !== index + 1 || migration.name.length === 0 || migration.sql.length === 0) {
|
package/src/operator-error.ts
CHANGED
|
@@ -5,6 +5,9 @@ export type OperatorErrorCode =
|
|
|
5
5
|
| "START_HOST_MISSING"
|
|
6
6
|
| "START_PORT_MISSING"
|
|
7
7
|
| "START_PORT_INVALID"
|
|
8
|
+
| "START_LOG_LEVEL_DUPLICATE"
|
|
9
|
+
| "START_LOG_LEVEL_MISSING"
|
|
10
|
+
| "START_LOG_LEVEL_INVALID"
|
|
8
11
|
| "START_OPTION_UNKNOWN"
|
|
9
12
|
| "BIND_PORT_INVALID"
|
|
10
13
|
| "BIND_HOST_INVALID"
|
|
@@ -13,6 +16,7 @@ export type OperatorErrorCode =
|
|
|
13
16
|
| "BIND_LAN_CONSENT"
|
|
14
17
|
| "SERVER_FILES_MISSING"
|
|
15
18
|
| "DATA_PATH_SYMLINK"
|
|
19
|
+
| "INSTALLATION_EXISTS"
|
|
16
20
|
| "LAN_CA_KEY_ONLINE"
|
|
17
21
|
| "RUNTIME_ACTIVE"
|
|
18
22
|
| "RUNTIME_LOCK_UNSAFE"
|
|
@@ -23,7 +27,13 @@ export type OperatorErrorCode =
|
|
|
23
27
|
| "DISK_RESERVE_INVALID"
|
|
24
28
|
| "CLIENT_NOT_FOUND"
|
|
25
29
|
| "GRANT_NOT_FOUND"
|
|
26
|
-
| "RECOVERY_INVALID"
|
|
30
|
+
| "RECOVERY_INVALID"
|
|
31
|
+
| "INVITATION_BUSY"
|
|
32
|
+
| "INVITATION_CONTENTION"
|
|
33
|
+
| "INVITATION_SCHEMA_MISMATCH"
|
|
34
|
+
| "INVITATION_CUBE_NOT_FOUND"
|
|
35
|
+
| "INVITATION_CUBE_SELECTOR_INVALID"
|
|
36
|
+
| "INVITATION_CUBE_AMBIGUOUS";
|
|
27
37
|
|
|
28
38
|
const publicMessages: Readonly<Record<OperatorErrorCode, string>> = Object.freeze({
|
|
29
39
|
START_LAN_DUPLICATE: "Provide --lan only once.",
|
|
@@ -32,6 +42,9 @@ const publicMessages: Readonly<Record<OperatorErrorCode, string>> = Object.freez
|
|
|
32
42
|
START_HOST_MISSING: "Provide an IP address after --host.",
|
|
33
43
|
START_PORT_MISSING: "Provide a port number after --port.",
|
|
34
44
|
START_PORT_INVALID: "Provide --port as an integer from 0 to 65535.",
|
|
45
|
+
START_LOG_LEVEL_DUPLICATE: "Provide --log-level only once.",
|
|
46
|
+
START_LOG_LEVEL_MISSING: "Provide debug after --log-level.",
|
|
47
|
+
START_LOG_LEVEL_INVALID: "Use --log-level debug or omit the option.",
|
|
35
48
|
START_OPTION_UNKNOWN: "Use only documented start options; run borg-mcp-server help.",
|
|
36
49
|
BIND_PORT_INVALID: "Configure the listen port as an integer from 0 to 65535.",
|
|
37
50
|
BIND_HOST_INVALID: "Configure --host as an explicit IP address.",
|
|
@@ -40,8 +53,9 @@ const publicMessages: Readonly<Record<OperatorErrorCode, string>> = Object.freez
|
|
|
40
53
|
BIND_LAN_CONSENT: "Add --lan to consent to this private-LAN start.",
|
|
41
54
|
SERVER_FILES_MISSING: "Configure BORG_SERVER_DATA_DIR or the required TLS file variables.",
|
|
42
55
|
DATA_PATH_SYMLINK: "Choose a BORG_SERVER_DATA_DIR path that contains no symbolic links.",
|
|
56
|
+
INSTALLATION_EXISTS: "An installation already exists in BORG_SERVER_DATA_DIR. To destroy and recreate it, stop the server and run borg-mcp-server setup --reinitialize.",
|
|
43
57
|
LAN_CA_KEY_ONLINE: "Move ca.key out of the runtime data directory before private-LAN startup.",
|
|
44
|
-
RUNTIME_ACTIVE: "Stop the server before running offline
|
|
58
|
+
RUNTIME_ACTIVE: "Stop the server before running setup or offline administration.",
|
|
45
59
|
RUNTIME_LOCK_UNSAFE: "Ensure runtime.lock is a private regular file before retrying.",
|
|
46
60
|
RUNTIME_LOCK_INVALID: "Confirm the server is stopped, then remove the invalid runtime.lock.",
|
|
47
61
|
RUNTIME_LOCK_STALE: "Confirm the recorded server process is stopped, then remove runtime.lock.",
|
|
@@ -51,22 +65,30 @@ const publicMessages: Readonly<Record<OperatorErrorCode, string>> = Object.freez
|
|
|
51
65
|
CLIENT_NOT_FOUND: "Provide an existing active client ID.",
|
|
52
66
|
GRANT_NOT_FOUND: "Provide an existing client cube grant.",
|
|
53
67
|
RECOVERY_INVALID: "Provide the active recovery credential through the private prompt.",
|
|
68
|
+
INVITATION_BUSY: "Confirm no invitation or offline administration command is running, then remove invitation-mint.lock.",
|
|
69
|
+
INVITATION_CONTENTION: "Retry invitation minting after the current server database write completes.",
|
|
70
|
+
INVITATION_SCHEMA_MISMATCH: "Invitation minting is unavailable while a server with an incompatible schema is running. Stop the server and rerun this command, or use the CLI version that matches the running server.",
|
|
71
|
+
INVITATION_CUBE_NOT_FOUND: "Provide an existing cube name or full cube ID.",
|
|
72
|
+
INVITATION_CUBE_SELECTOR_INVALID: "Provide a full canonical cube UUID or an exact case-sensitive cube name.",
|
|
73
|
+
INVITATION_CUBE_AMBIGUOUS: "Cube name is ambiguous. Rerun with the full cube ID.",
|
|
54
74
|
});
|
|
55
75
|
|
|
56
76
|
const operatorErrorCodes = new WeakMap<object, OperatorErrorCode>();
|
|
77
|
+
const operatorErrorMessages = new WeakMap<object, string>();
|
|
57
78
|
const operatorErrorCapability = Object.freeze({});
|
|
58
79
|
|
|
59
80
|
class OperatorError extends Error {
|
|
60
81
|
readonly #operatorCode: OperatorErrorCode;
|
|
61
82
|
|
|
62
|
-
constructor(capability: object, code: OperatorErrorCode) {
|
|
63
|
-
super(Object.hasOwn(publicMessages, code) ? publicMessages[code] : "Operator error rejected.");
|
|
83
|
+
constructor(capability: object, code: OperatorErrorCode, publicMessage?: string) {
|
|
84
|
+
super(publicMessage ?? (Object.hasOwn(publicMessages, code) ? publicMessages[code] : "Operator error rejected."));
|
|
64
85
|
if (capability !== operatorErrorCapability || !Object.hasOwn(publicMessages, code)) {
|
|
65
86
|
throw new Error("Operator error construction is unavailable.");
|
|
66
87
|
}
|
|
67
88
|
this.name = "OperatorError";
|
|
68
89
|
this.#operatorCode = code;
|
|
69
90
|
operatorErrorCodes.set(this, code);
|
|
91
|
+
operatorErrorMessages.set(this, this.message);
|
|
70
92
|
Object.freeze(this);
|
|
71
93
|
}
|
|
72
94
|
|
|
@@ -75,7 +97,7 @@ class OperatorError extends Error {
|
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
get publicMessage(): string {
|
|
78
|
-
return publicMessages[this.#operatorCode];
|
|
100
|
+
return operatorErrorMessages.get(this) ?? publicMessages[this.#operatorCode];
|
|
79
101
|
}
|
|
80
102
|
}
|
|
81
103
|
|
|
@@ -93,5 +115,17 @@ export function operatorPublicMessage(error: unknown): string | null {
|
|
|
93
115
|
const code = operatorErrorCodes.get(error);
|
|
94
116
|
if (code === undefined) return null;
|
|
95
117
|
if (Object.getPrototypeOf(error) !== OperatorError.prototype || !Object.isFrozen(error)) return null;
|
|
96
|
-
return publicMessages[code];
|
|
118
|
+
return operatorErrorMessages.get(error) ?? publicMessages[code];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function invitationCubeAmbiguousError(candidateIds: readonly string[]): Error {
|
|
122
|
+
if (candidateIds.length < 2 || candidateIds.some((id) =>
|
|
123
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(id))) {
|
|
124
|
+
throw new Error("Ambiguous cube candidates must contain canonical cube IDs.");
|
|
125
|
+
}
|
|
126
|
+
return new OperatorError(
|
|
127
|
+
operatorErrorCapability,
|
|
128
|
+
"INVITATION_CUBE_AMBIGUOUS",
|
|
129
|
+
`Cube name is ambiguous. Rerun with the full cube ID. Candidate cube IDs: ${candidateIds.join(", ")}`,
|
|
130
|
+
);
|
|
97
131
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type RoleSectionPatchOp =
|
|
2
|
+
| { readonly action: "replace"; readonly heading: string; readonly body: string }
|
|
3
|
+
| {
|
|
4
|
+
readonly action: "insert";
|
|
5
|
+
readonly heading: string;
|
|
6
|
+
readonly body: string;
|
|
7
|
+
readonly after?: string | null;
|
|
8
|
+
}
|
|
9
|
+
| { readonly action: "delete"; readonly heading: string };
|
|
10
|
+
|
|
11
|
+
interface RoleSection {
|
|
12
|
+
readonly heading: string | null;
|
|
13
|
+
readonly body: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function patchRoleSectionText(text: string, operation: RoleSectionPatchOp): string {
|
|
17
|
+
validateHeading(operation.heading);
|
|
18
|
+
const sections = parseSections(text);
|
|
19
|
+
const target = normalizedHeading(operation.heading);
|
|
20
|
+
const index = sections.findIndex((section) =>
|
|
21
|
+
section.heading !== null && normalizedHeading(section.heading) === target
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (operation.action === "replace") {
|
|
25
|
+
if (index === -1) throw new Error("Role section not found.");
|
|
26
|
+
sections[index] = renderedSection(operation.heading, operation.body);
|
|
27
|
+
return serializeSections(sections);
|
|
28
|
+
}
|
|
29
|
+
if (operation.action === "delete") {
|
|
30
|
+
if (index === -1) throw new Error("Role section not found.");
|
|
31
|
+
sections.splice(index, 1);
|
|
32
|
+
return serializeSections(sections);
|
|
33
|
+
}
|
|
34
|
+
if (index !== -1) throw new Error("Role section already exists.");
|
|
35
|
+
|
|
36
|
+
const section = renderedSection(operation.heading, operation.body);
|
|
37
|
+
if (operation.after == null) {
|
|
38
|
+
ensureTrailingNewline(sections, sections.length - 1);
|
|
39
|
+
sections.push(section);
|
|
40
|
+
return serializeSections(sections);
|
|
41
|
+
}
|
|
42
|
+
validateHeading(operation.after);
|
|
43
|
+
const after = normalizedHeading(operation.after);
|
|
44
|
+
const afterIndex = sections.findIndex((candidate) =>
|
|
45
|
+
candidate.heading !== null && normalizedHeading(candidate.heading) === after
|
|
46
|
+
);
|
|
47
|
+
if (afterIndex === -1) throw new Error("Role section insertion point does not exist.");
|
|
48
|
+
ensureTrailingNewline(sections, afterIndex);
|
|
49
|
+
sections.splice(afterIndex + 1, 0, section);
|
|
50
|
+
return serializeSections(sections);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseSections(text: string): RoleSection[] {
|
|
54
|
+
const sections: RoleSection[] = [];
|
|
55
|
+
const lines = text.split("\n");
|
|
56
|
+
let heading: string | null = null;
|
|
57
|
+
let body = "";
|
|
58
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
59
|
+
const line = lines[index]!;
|
|
60
|
+
const sourceLine = index < lines.length - 1 ? `${line}\n` : line;
|
|
61
|
+
if (isLabelLine(line)) {
|
|
62
|
+
sections.push({ heading, body });
|
|
63
|
+
heading = line.slice(0, -1).trim();
|
|
64
|
+
body = sourceLine;
|
|
65
|
+
} else {
|
|
66
|
+
body += sourceLine;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
sections.push({ heading, body });
|
|
70
|
+
return sections;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isLabelLine(line: string): boolean {
|
|
74
|
+
if (/^\s/u.test(line) || !line.endsWith(":")) return false;
|
|
75
|
+
const label = line.slice(0, -1);
|
|
76
|
+
return label.length > 0 && label.length <= 60 && !label.includes(":") &&
|
|
77
|
+
!/^[*\-#>`]/u.test(label);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderedSection(heading: string, body: string): RoleSection {
|
|
81
|
+
const normalizedBody = body === "" || body.endsWith("\n") ? body : `${body}\n`;
|
|
82
|
+
return {
|
|
83
|
+
heading: heading.trim(),
|
|
84
|
+
body: `${heading.trim()}:\n${normalizedBody}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ensureTrailingNewline(sections: RoleSection[], index: number): void {
|
|
89
|
+
const section = sections[index];
|
|
90
|
+
if (section !== undefined && section.body !== "" && !section.body.endsWith("\n")) {
|
|
91
|
+
sections[index] = { ...section, body: `${section.body}\n` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function serializeSections(sections: readonly RoleSection[]): string {
|
|
96
|
+
return sections.map((section) => section.body).join("");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizedHeading(heading: string): string {
|
|
100
|
+
return heading.trim().toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateHeading(heading: string): void {
|
|
104
|
+
if (typeof heading !== "string" || /[\r\n]/u.test(heading) ||
|
|
105
|
+
!isLabelLine(`${heading.trim()}:`)) {
|
|
106
|
+
throw new TypeError("Role section heading is invalid.");
|
|
107
|
+
}
|
|
108
|
+
}
|