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/https-server.ts
CHANGED
|
@@ -2,25 +2,21 @@ 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
|
-
|
|
10
|
-
export interface ProtocolInfoDocument {
|
|
11
|
-
readonly protocol_version: string;
|
|
12
|
-
readonly package: {
|
|
13
|
-
readonly name: "borgmcp-shared";
|
|
14
|
-
readonly version: string;
|
|
15
|
-
};
|
|
16
|
-
readonly capabilities: readonly string[];
|
|
17
|
-
readonly limits: {
|
|
18
|
-
readonly max_request_bytes: number;
|
|
19
|
-
readonly max_log_message_bytes: number;
|
|
20
|
-
readonly max_read_page_size: number;
|
|
21
|
-
readonly max_replay_page_size: number;
|
|
22
|
-
};
|
|
23
|
-
}
|
|
19
|
+
import { disabledDebugLogger, type DebugLogger, type DebugRoute } from "./debug-log.js";
|
|
24
20
|
|
|
25
21
|
export interface ServiceLimits {
|
|
26
22
|
readonly maxConnections: number;
|
|
@@ -61,19 +57,15 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
|
61
57
|
};
|
|
62
58
|
|
|
63
59
|
export interface RequestHandlerContext {
|
|
64
|
-
readonly protocolInfo: ProtocolInfoDocument;
|
|
65
|
-
readonly authorizeProtocol: (
|
|
66
|
-
authorization: string | undefined,
|
|
67
|
-
signal: AbortSignal,
|
|
68
|
-
) => Promise<boolean | "missing" | "invalid" | "revoked">;
|
|
69
60
|
readonly exchangeEnrollment?: (
|
|
70
61
|
body: unknown,
|
|
71
|
-
) => Promise<{ readonly status: 201 | 400 | 401 | 507; readonly body?: unknown }>;
|
|
62
|
+
) => Promise<{ readonly status: 201 | 400 | 401 | 426 | 507; readonly body?: unknown }>;
|
|
72
63
|
readonly authorizeCoordination?: (
|
|
73
64
|
authorization: string | undefined,
|
|
74
65
|
signal: AbortSignal,
|
|
75
|
-
) => Promise<Principal | "missing" | "invalid" | "revoked">;
|
|
66
|
+
) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
|
|
76
67
|
readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
|
|
68
|
+
readonly debugLogger: DebugLogger;
|
|
77
69
|
}
|
|
78
70
|
|
|
79
71
|
export interface HttpsServerOptions {
|
|
@@ -83,12 +75,11 @@ export interface HttpsServerOptions {
|
|
|
83
75
|
readonly cert: string | Buffer;
|
|
84
76
|
readonly ca?: string | Buffer;
|
|
85
77
|
};
|
|
86
|
-
readonly protocolInfo: ProtocolInfoDocument;
|
|
87
|
-
readonly authorizeProtocol: RequestHandlerContext["authorizeProtocol"];
|
|
88
78
|
readonly exchangeEnrollment?: RequestHandlerContext["exchangeEnrollment"];
|
|
89
79
|
readonly authorizeCoordination?: RequestHandlerContext["authorizeCoordination"];
|
|
90
80
|
readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
|
|
91
81
|
readonly limits?: ServiceLimits;
|
|
82
|
+
readonly debugLogger?: DebugLogger;
|
|
92
83
|
readonly testHooks?: {
|
|
93
84
|
readonly identifyRemoteAddress?: (socket: Socket) => string;
|
|
94
85
|
};
|
|
@@ -126,8 +117,14 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
|
|
|
126
117
|
|
|
127
118
|
const acceptedSockets = applyServerLimits(server, limits);
|
|
128
119
|
server.on("secureConnection", (socket) => socket.disableRenegotiation());
|
|
129
|
-
server.on("tlsClientError", (_error, socket) =>
|
|
130
|
-
|
|
120
|
+
server.on("tlsClientError", (_error, socket) => {
|
|
121
|
+
handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "tls_client_error" });
|
|
122
|
+
socket.destroy();
|
|
123
|
+
});
|
|
124
|
+
server.on("clientError", (_error, socket) => {
|
|
125
|
+
handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "http_parser_error" });
|
|
126
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
127
|
+
});
|
|
131
128
|
server.on("checkContinue", (_request, response) => sendEmpty(response, 417, true));
|
|
132
129
|
|
|
133
130
|
try {
|
|
@@ -159,8 +156,6 @@ export function createRequestHandlerContext(
|
|
|
159
156
|
options: HttpsServerOptions,
|
|
160
157
|
): RequestHandlerContext {
|
|
161
158
|
return Object.freeze({
|
|
162
|
-
protocolInfo: options.protocolInfo,
|
|
163
|
-
authorizeProtocol: options.authorizeProtocol,
|
|
164
159
|
...(options.exchangeEnrollment === undefined
|
|
165
160
|
? {}
|
|
166
161
|
: { exchangeEnrollment: options.exchangeEnrollment }),
|
|
@@ -170,6 +165,7 @@ export function createRequestHandlerContext(
|
|
|
170
165
|
...(options.handleCoordination === undefined
|
|
171
166
|
? {}
|
|
172
167
|
: { handleCoordination: options.handleCoordination }),
|
|
168
|
+
debugLogger: options.debugLogger ?? disabledDebugLogger,
|
|
173
169
|
});
|
|
174
170
|
}
|
|
175
171
|
|
|
@@ -182,6 +178,32 @@ function createRequestListener(
|
|
|
182
178
|
const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
|
|
183
179
|
const streamQuota = new ConcurrentQuota(limits.maxStreamsPerCredential);
|
|
184
180
|
return (request, response) => {
|
|
181
|
+
const startedAt = Date.now();
|
|
182
|
+
const trace: RequestTrace = {
|
|
183
|
+
route: debugRoute(request.url),
|
|
184
|
+
method: debugMethod(request.method),
|
|
185
|
+
authentication: "not_required",
|
|
186
|
+
};
|
|
187
|
+
let debugEmitted = false;
|
|
188
|
+
const emitDebug = (): void => {
|
|
189
|
+
if (debugEmitted) return;
|
|
190
|
+
debugEmitted = true;
|
|
191
|
+
const status = response.headersSent ? response.statusCode : 0;
|
|
192
|
+
context.debugLogger.emit({
|
|
193
|
+
event: "request",
|
|
194
|
+
route: trace.route,
|
|
195
|
+
method: trace.method,
|
|
196
|
+
authentication: trace.authentication,
|
|
197
|
+
authorization: trace.authentication === "accepted"
|
|
198
|
+
? status === 403 || status === 404 ? "denied_or_not_found" : "accepted"
|
|
199
|
+
: "not_checked",
|
|
200
|
+
...(trace.principal === undefined ? {} : { principal: trace.principal }),
|
|
201
|
+
status,
|
|
202
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
response.once("finish", emitDebug);
|
|
206
|
+
response.once("close", emitDebug);
|
|
185
207
|
const controller = new AbortController();
|
|
186
208
|
response.once("close", () => controller.abort());
|
|
187
209
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -202,6 +224,7 @@ function createRequestListener(
|
|
|
202
224
|
streamQuota,
|
|
203
225
|
identifyRemoteAddress,
|
|
204
226
|
controller.signal,
|
|
227
|
+
trace,
|
|
205
228
|
)
|
|
206
229
|
.then(() => "handled" as const);
|
|
207
230
|
|
|
@@ -226,6 +249,7 @@ async function handleRequest(
|
|
|
226
249
|
streamQuota: ConcurrentQuota,
|
|
227
250
|
identifyRemoteAddress: (socket: Socket) => string,
|
|
228
251
|
signal: AbortSignal,
|
|
252
|
+
trace: RequestTrace,
|
|
229
253
|
): Promise<void> {
|
|
230
254
|
const addressIdentity = `address:${identifyRemoteAddress(request.socket)}`;
|
|
231
255
|
const preAuthRetry = admissionLimiter.consume(addressIdentity);
|
|
@@ -252,43 +276,23 @@ async function handleRequest(
|
|
|
252
276
|
return;
|
|
253
277
|
}
|
|
254
278
|
|
|
255
|
-
if (path ===
|
|
279
|
+
if (path === HEALTH_PATH) {
|
|
256
280
|
if (requestBody.length !== 0) return sendEmpty(response, 400, true);
|
|
257
281
|
sendEmpty(response, request.method === "GET" ? 204 : 405);
|
|
258
282
|
return;
|
|
259
283
|
}
|
|
260
284
|
|
|
261
|
-
if (path ===
|
|
285
|
+
if (path === PROTOCOL_INFO_PATH) {
|
|
262
286
|
if (requestBody.length !== 0) return sendEmpty(response, 400, true);
|
|
263
|
-
const authorized = await context.authorizeProtocol(request.headers.authorization, signal);
|
|
264
|
-
if (signal.aborted) return;
|
|
265
|
-
if (authorized !== true) {
|
|
266
|
-
const code = authorized === "revoked"
|
|
267
|
-
? "SESSION_REVOKED"
|
|
268
|
-
: authorized === "missing" || request.headers.authorization === undefined
|
|
269
|
-
? "AUTH_MISSING"
|
|
270
|
-
: "AUTH_INVALID";
|
|
271
|
-
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
const credentialRetry = consumeAuthenticatedRateLimit(
|
|
275
|
-
request.headers.authorization,
|
|
276
|
-
credentialRateLimiter,
|
|
277
|
-
);
|
|
278
|
-
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
279
287
|
if (request.method !== "GET") {
|
|
280
288
|
sendEmpty(response, 405);
|
|
281
289
|
return;
|
|
282
290
|
}
|
|
283
|
-
sendJson(response, 200,
|
|
284
|
-
protocol_version: "1",
|
|
285
|
-
request_id: "protocol-info",
|
|
286
|
-
payload: context.protocolInfo,
|
|
287
|
-
});
|
|
291
|
+
sendJson(response, 200, createProtocolTagPreflight());
|
|
288
292
|
return;
|
|
289
293
|
}
|
|
290
294
|
|
|
291
|
-
if (path ===
|
|
295
|
+
if (path === ENROLLMENT_EXCHANGE_PATH) {
|
|
292
296
|
if (request.method !== "POST" || context.exchangeEnrollment === undefined) {
|
|
293
297
|
sendEmpty(response, 405);
|
|
294
298
|
return;
|
|
@@ -307,7 +311,9 @@ async function handleRequest(
|
|
|
307
311
|
if (signal.aborted) return;
|
|
308
312
|
if (result.body === undefined) sendEmpty(response, result.status, result.status === 400);
|
|
309
313
|
else if (result.status === 400) sendJson(response, 400, result.body, true);
|
|
310
|
-
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
|
+
}
|
|
311
317
|
else sendJson(response, 201, result.body);
|
|
312
318
|
return;
|
|
313
319
|
}
|
|
@@ -330,21 +336,27 @@ async function handleRequest(
|
|
|
330
336
|
return;
|
|
331
337
|
}
|
|
332
338
|
const authentication = await authorizeCoordination(authorization, signal);
|
|
339
|
+
trace.authentication = typeof authentication === "string" ? authentication : "accepted";
|
|
333
340
|
if (signal.aborted) return;
|
|
334
341
|
if (typeof authentication === "string") {
|
|
342
|
+
if (authentication === "evicted") {
|
|
343
|
+
sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
335
346
|
const code = authentication === "revoked"
|
|
336
347
|
? "SESSION_REVOKED"
|
|
337
348
|
: authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
|
|
338
349
|
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
339
350
|
return;
|
|
340
351
|
}
|
|
352
|
+
trace.principal = authentication;
|
|
341
353
|
const clientIdentity = `client:${authentication.kind === "drone-session"
|
|
342
354
|
? authentication.clientId
|
|
343
355
|
: authentication.id}`;
|
|
344
356
|
const credentialRetry = credentialRateLimiter.consume(clientIdentity);
|
|
345
357
|
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
346
|
-
const
|
|
347
|
-
if (
|
|
358
|
+
const query = parseCoordinationQuery(request.url, path);
|
|
359
|
+
if (query === INVALID_COORDINATION_QUERY) {
|
|
348
360
|
sendJson(response, 400, protocolError("INVALID_INPUT", "Invalid query parameters."), true);
|
|
349
361
|
return;
|
|
350
362
|
}
|
|
@@ -353,7 +365,7 @@ async function handleRequest(
|
|
|
353
365
|
path,
|
|
354
366
|
principal: authentication,
|
|
355
367
|
...(decoded === undefined ? {} : { body: decoded }),
|
|
356
|
-
...
|
|
368
|
+
...query,
|
|
357
369
|
signal,
|
|
358
370
|
});
|
|
359
371
|
if (signal.aborted) {
|
|
@@ -378,6 +390,39 @@ async function handleRequest(
|
|
|
378
390
|
sendEmpty(response, 404);
|
|
379
391
|
}
|
|
380
392
|
|
|
393
|
+
interface RequestTrace {
|
|
394
|
+
readonly route: DebugRoute;
|
|
395
|
+
readonly method: string;
|
|
396
|
+
authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
|
|
397
|
+
principal?: Principal;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function debugMethod(method: string | undefined): string {
|
|
401
|
+
return method === "GET" || method === "POST" || method === "PUT" ||
|
|
402
|
+
method === "PATCH" || method === "DELETE" ? method : "OTHER";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function debugRoute(rawUrl: string | undefined): DebugRoute {
|
|
406
|
+
const path = parseRequestPath(rawUrl);
|
|
407
|
+
if (path === null) return "unknown";
|
|
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";
|
|
412
|
+
if (path === "/api/cubes") return "cubes";
|
|
413
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}$/iu.test(path)) return "cube";
|
|
414
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles$/iu.test(path)) return "cube_roles";
|
|
415
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles\/[0-9a-f-]{36}$/iu.test(path)) return "cube_role";
|
|
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";
|
|
418
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/drones$/iu.test(path)) return "cube_drones";
|
|
419
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/logs$/iu.test(path)) return "cube_logs";
|
|
420
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/acks$/iu.test(path)) return "cube_acks";
|
|
421
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/decisions$/iu.test(path)) return "cube_decisions";
|
|
422
|
+
if (/^\/api\/cubes\/[0-9a-f-]{36}\/stream$/iu.test(path)) return "cube_stream";
|
|
423
|
+
return "unknown";
|
|
424
|
+
}
|
|
425
|
+
|
|
381
426
|
async function closeRejectedStream(stream: AsyncIterable<string>): Promise<void> {
|
|
382
427
|
const iterator = stream[Symbol.asyncIterator]();
|
|
383
428
|
try {
|
|
@@ -517,35 +562,33 @@ function waitForDrain(response: ServerResponse): Promise<void> {
|
|
|
517
562
|
}
|
|
518
563
|
|
|
519
564
|
function protocolError(code: string, message: string): object {
|
|
520
|
-
return { protocol_version:
|
|
565
|
+
return { protocol_version: PROTOCOL_VERSION, error: { code, message } };
|
|
521
566
|
}
|
|
522
567
|
|
|
523
568
|
const INVALID_COORDINATION_QUERY = Symbol("invalid-coordination-query");
|
|
524
569
|
|
|
525
|
-
function
|
|
570
|
+
function parseCoordinationQuery(
|
|
526
571
|
value: string | undefined,
|
|
527
572
|
path: string,
|
|
528
|
-
): string
|
|
529
|
-
if (value === undefined) return
|
|
573
|
+
): { readonly cursor?: string; readonly since?: string } | typeof INVALID_COORDINATION_QUERY {
|
|
574
|
+
if (value === undefined) return {};
|
|
530
575
|
try {
|
|
531
576
|
const parsed = new URL(value, "https://local.invalid");
|
|
532
577
|
const keys = [...parsed.searchParams.keys()];
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (values.length === 0) return
|
|
539
|
-
return
|
|
540
|
-
? values[0]
|
|
541
|
-
: 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]! };
|
|
542
585
|
} catch {
|
|
543
586
|
return INVALID_COORDINATION_QUERY;
|
|
544
587
|
}
|
|
545
588
|
}
|
|
546
589
|
|
|
547
590
|
function isCoordinationPath(path: string | null): path is string {
|
|
548
|
-
return path ===
|
|
591
|
+
return path === ATTACH_PATH || path === CUBES_PATH ||
|
|
549
592
|
path?.startsWith("/api/cubes/") === true;
|
|
550
593
|
}
|
|
551
594
|
|
|
@@ -921,14 +964,6 @@ function credentialIdentity(authorization: string | undefined): string | null {
|
|
|
921
964
|
return `credential:${createHash("sha256").update(authorization).digest("base64url")}`;
|
|
922
965
|
}
|
|
923
966
|
|
|
924
|
-
function consumeAuthenticatedRateLimit(
|
|
925
|
-
authorization: string | undefined,
|
|
926
|
-
rateLimiter: RequestRateLimiter,
|
|
927
|
-
): number | null {
|
|
928
|
-
const identity = credentialIdentity(authorization);
|
|
929
|
-
return identity === null ? null : rateLimiter.consume(identity);
|
|
930
|
-
}
|
|
931
|
-
|
|
932
967
|
function listen(server: HttpsServer, port: number, host: string): Promise<void> {
|
|
933
968
|
return new Promise((resolve, reject) => {
|
|
934
969
|
const onError = (error: Error): void => {
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type { MessageTaxonomy, MessageTaxonomyClass } from "borgmcp-shared/domain";
|
|
2
|
+
|
|
3
|
+
export interface RoutingRole {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly is_human_seat: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RoutingDrone {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly role_id: string;
|
|
13
|
+
readonly posture: "observer" | "participant";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MessageRouting {
|
|
17
|
+
readonly visibility: "broadcast" | "direct";
|
|
18
|
+
readonly recipientDroneIds: string[];
|
|
19
|
+
readonly routing: {
|
|
20
|
+
readonly class: string | null;
|
|
21
|
+
readonly recipients: string[];
|
|
22
|
+
readonly fellOpen: boolean;
|
|
23
|
+
readonly message: string | null;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TaxonomyPatch =
|
|
28
|
+
| { readonly action: "add" | "replace"; readonly classDef: MessageTaxonomyClass }
|
|
29
|
+
| { readonly action: "remove"; readonly className: string };
|
|
30
|
+
|
|
31
|
+
const MAX_TAXONOMY_CLASSES = 50;
|
|
32
|
+
const MAX_LIST_ITEMS = 100;
|
|
33
|
+
const MAX_NAME_LENGTH = 120;
|
|
34
|
+
|
|
35
|
+
export function validateMessageTaxonomy(value: unknown): MessageTaxonomy | null {
|
|
36
|
+
if (value === null) return null;
|
|
37
|
+
if (!Array.isArray(value) || value.length > MAX_TAXONOMY_CLASSES) {
|
|
38
|
+
throw new TypeError("Message taxonomy must be null or an array of at most 50 classes.");
|
|
39
|
+
}
|
|
40
|
+
const classes = new Set<string>();
|
|
41
|
+
const prefixes = new Set<string>();
|
|
42
|
+
return value.map((candidate) => {
|
|
43
|
+
const record = taxonomyObject(candidate);
|
|
44
|
+
exactTaxonomyKeys(record);
|
|
45
|
+
const className = boundedTaxonomyString(record["class"], "Taxonomy class", 64);
|
|
46
|
+
const classKey = normalize(className);
|
|
47
|
+
if (classes.has(classKey)) throw new TypeError("Message taxonomy class names must be unique.");
|
|
48
|
+
classes.add(classKey);
|
|
49
|
+
const routing = record["routing"];
|
|
50
|
+
if (routing !== "broadcast" && routing !== "directed") {
|
|
51
|
+
throw new TypeError("Message taxonomy routing must be broadcast or directed.");
|
|
52
|
+
}
|
|
53
|
+
const classPrefixes = taxonomyStringArray(record["prefixes"], "Taxonomy prefixes", 64);
|
|
54
|
+
for (const prefix of classPrefixes) {
|
|
55
|
+
const key = normalize(prefix);
|
|
56
|
+
if (prefixes.has(key)) throw new TypeError("Message taxonomy prefixes must be unique.");
|
|
57
|
+
prefixes.add(key);
|
|
58
|
+
}
|
|
59
|
+
const defaultTo = taxonomyStringArray(record["default_to"], "Taxonomy default recipients");
|
|
60
|
+
if (routing === "directed" && defaultTo.length === 0) {
|
|
61
|
+
throw new TypeError("Directed taxonomy classes require default recipients.");
|
|
62
|
+
}
|
|
63
|
+
const lifecycle = record["lifecycle"];
|
|
64
|
+
if (lifecycle !== undefined && lifecycle !== "dispatch" && lifecycle !== "completion") {
|
|
65
|
+
throw new TypeError("Message taxonomy lifecycle must be dispatch or completion.");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
class: className,
|
|
69
|
+
prefixes: classPrefixes,
|
|
70
|
+
routing,
|
|
71
|
+
...(defaultTo.length === 0 ? {} : { default_to: defaultTo }),
|
|
72
|
+
...(lifecycle === undefined ? {} : { lifecycle }),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function patchMessageTaxonomy(
|
|
78
|
+
taxonomy: MessageTaxonomy | null,
|
|
79
|
+
patch: TaxonomyPatch,
|
|
80
|
+
): MessageTaxonomy | null {
|
|
81
|
+
const current = taxonomy ?? [];
|
|
82
|
+
if (patch.action === "remove") {
|
|
83
|
+
const index = classIndex(current, patch.className);
|
|
84
|
+
if (index < 0) throw new TypeError("Message taxonomy class does not exist.");
|
|
85
|
+
const next = [...current.slice(0, index), ...current.slice(index + 1)];
|
|
86
|
+
return next.length === 0 ? null : validateMessageTaxonomy(next);
|
|
87
|
+
}
|
|
88
|
+
const validated = validateMessageTaxonomy([patch.classDef])![0]!;
|
|
89
|
+
const index = classIndex(current, validated.class);
|
|
90
|
+
if (patch.action === "add") {
|
|
91
|
+
if (index >= 0) throw new TypeError("Message taxonomy class already exists.");
|
|
92
|
+
return validateMessageTaxonomy([...current, validated]);
|
|
93
|
+
}
|
|
94
|
+
if (index < 0) throw new TypeError("Message taxonomy class does not exist.");
|
|
95
|
+
return validateMessageTaxonomy([
|
|
96
|
+
...current.slice(0, index),
|
|
97
|
+
validated,
|
|
98
|
+
...current.slice(index + 1),
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveMessageRouting(
|
|
103
|
+
input: {
|
|
104
|
+
readonly message: string;
|
|
105
|
+
readonly visibility?: "broadcast" | "direct";
|
|
106
|
+
readonly recipientDroneIds?: readonly string[];
|
|
107
|
+
readonly className?: string;
|
|
108
|
+
readonly to?: readonly string[];
|
|
109
|
+
},
|
|
110
|
+
taxonomy: MessageTaxonomy | null,
|
|
111
|
+
roles: readonly RoutingRole[],
|
|
112
|
+
drones: readonly RoutingDrone[],
|
|
113
|
+
): MessageRouting {
|
|
114
|
+
if (input.visibility === "broadcast" &&
|
|
115
|
+
((input.recipientDroneIds?.length ?? 0) > 0 || input.to !== undefined)) {
|
|
116
|
+
throw new TypeError("Broadcast activity cannot name direct recipients.");
|
|
117
|
+
}
|
|
118
|
+
if (input.visibility !== undefined || input.recipientDroneIds !== undefined) {
|
|
119
|
+
const visibility = input.visibility ?? "direct";
|
|
120
|
+
const recipients = visibility === "direct"
|
|
121
|
+
? input.recipientDroneIds?.length
|
|
122
|
+
? [...new Set(input.recipientDroneIds)]
|
|
123
|
+
: resolveSelectors(input.to ?? [], roles, drones, true)
|
|
124
|
+
: [];
|
|
125
|
+
if (visibility === "direct" && recipients.length === 0) {
|
|
126
|
+
throw new TypeError("Direct activity requires a recipient.");
|
|
127
|
+
}
|
|
128
|
+
return routingResult(visibility, recipients, null, false, null);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const explicitClass = input.className === undefined
|
|
132
|
+
? null
|
|
133
|
+
: taxonomyClass(taxonomy, input.className);
|
|
134
|
+
if (input.className !== undefined && explicitClass === null) {
|
|
135
|
+
throw new TypeError("Message taxonomy class is not declared by this cube.");
|
|
136
|
+
}
|
|
137
|
+
if (input.to !== undefined) {
|
|
138
|
+
const recipients = resolveSelectors(input.to, roles, drones, true);
|
|
139
|
+
return routingResult("direct", recipients, explicitClass?.class ?? null, false, null);
|
|
140
|
+
}
|
|
141
|
+
if (taxonomy === null) return routingResult("broadcast", [], null, false, null);
|
|
142
|
+
|
|
143
|
+
const matched = explicitClass ?? classifyMessage(taxonomy, input.message);
|
|
144
|
+
if (matched === null || matched.routing === "broadcast") {
|
|
145
|
+
return routingResult("broadcast", [], matched?.class ?? null, false, null);
|
|
146
|
+
}
|
|
147
|
+
const recipients = resolveSelectors(matched.default_to ?? [], roles, drones, false);
|
|
148
|
+
if (recipients.length === 0) {
|
|
149
|
+
return routingResult(
|
|
150
|
+
"broadcast",
|
|
151
|
+
[],
|
|
152
|
+
matched.class,
|
|
153
|
+
true,
|
|
154
|
+
"No active default recipient resolved; delivered as broadcast.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return routingResult("direct", recipients, matched.class, false, null);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function resolveSelectors(
|
|
161
|
+
selectors: readonly string[],
|
|
162
|
+
roles: readonly RoutingRole[],
|
|
163
|
+
drones: readonly RoutingDrone[],
|
|
164
|
+
strict: boolean,
|
|
165
|
+
): string[] {
|
|
166
|
+
if (strict && selectors.length === 0) throw new TypeError("Direct recipients cannot be empty.");
|
|
167
|
+
const recipients = new Set<string>();
|
|
168
|
+
for (const selector of selectors) {
|
|
169
|
+
try {
|
|
170
|
+
for (const drone of resolveSelector(selector, roles, drones)) recipients.add(drone.id);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (strict) throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return [...recipients];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveSelector(
|
|
179
|
+
selector: string,
|
|
180
|
+
roles: readonly RoutingRole[],
|
|
181
|
+
allDrones: readonly RoutingDrone[],
|
|
182
|
+
): RoutingDrone[] {
|
|
183
|
+
const drones = allDrones.filter((drone) => drone.posture === "participant");
|
|
184
|
+
if (selector === "@human-seat") {
|
|
185
|
+
const roleIds = new Set(roles.filter((role) => role.is_human_seat).map((role) => role.id));
|
|
186
|
+
const matches = drones.filter((drone) => roleIds.has(drone.role_id));
|
|
187
|
+
if (matches.length === 0) throw new TypeError("Recipient has no active drone.");
|
|
188
|
+
return matches;
|
|
189
|
+
}
|
|
190
|
+
const exact = drones.filter((drone) => drone.id === selector || drone.label === selector);
|
|
191
|
+
if (exact.length === 1) return exact;
|
|
192
|
+
if (exact.length > 1) throw new TypeError("Recipient is ambiguous.");
|
|
193
|
+
|
|
194
|
+
const shortId = selector.replace(/^`|`$/gu, "").replace(/^id:/iu, "");
|
|
195
|
+
if (/^[0-9a-f]{8,}$/iu.test(shortId)) {
|
|
196
|
+
const matches = drones.filter((drone) => drone.id.toLowerCase().startsWith(shortId.toLowerCase()));
|
|
197
|
+
if (matches.length === 1) return matches;
|
|
198
|
+
if (matches.length > 1) throw new TypeError("Recipient is ambiguous.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const matchingRoles = roles.filter((role) => roleSlug(role.name) === roleSlug(selector));
|
|
202
|
+
if (matchingRoles.length > 1) throw new TypeError("Recipient role is ambiguous.");
|
|
203
|
+
if (matchingRoles.length === 1) {
|
|
204
|
+
const matches = drones.filter((drone) => drone.role_id === matchingRoles[0]!.id);
|
|
205
|
+
if (matches.length === 0) throw new TypeError("Recipient role has no active drone.");
|
|
206
|
+
return matches;
|
|
207
|
+
}
|
|
208
|
+
throw new TypeError("Recipient does not exist.");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function classifyMessage(taxonomy: MessageTaxonomy, message: string): MessageTaxonomyClass | null {
|
|
212
|
+
const token = message.split(/[:\s]/u, 1)[0] ?? "";
|
|
213
|
+
const key = normalize(token);
|
|
214
|
+
return taxonomy.find((entry) => (entry.prefixes ?? []).some((prefix) => normalize(prefix) === key)) ?? null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function taxonomyClass(
|
|
218
|
+
taxonomy: MessageTaxonomy | null,
|
|
219
|
+
className: string,
|
|
220
|
+
): MessageTaxonomyClass | null {
|
|
221
|
+
if (taxonomy === null) return null;
|
|
222
|
+
const key = normalize(className);
|
|
223
|
+
return taxonomy.find((entry) => normalize(entry.class) === key) ?? null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function routingResult(
|
|
227
|
+
visibility: "broadcast" | "direct",
|
|
228
|
+
recipientDroneIds: string[],
|
|
229
|
+
className: string | null,
|
|
230
|
+
fellOpen: boolean,
|
|
231
|
+
message: string | null,
|
|
232
|
+
): MessageRouting {
|
|
233
|
+
return {
|
|
234
|
+
visibility,
|
|
235
|
+
recipientDroneIds,
|
|
236
|
+
routing: { class: className, recipients: recipientDroneIds, fellOpen, message },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function taxonomyObject(value: unknown): Record<string, unknown> {
|
|
241
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
242
|
+
throw new TypeError("Message taxonomy classes must be objects.");
|
|
243
|
+
}
|
|
244
|
+
return value as Record<string, unknown>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function exactTaxonomyKeys(record: Record<string, unknown>): void {
|
|
248
|
+
const allowed = new Set(["class", "prefixes", "routing", "default_to", "lifecycle"]);
|
|
249
|
+
if (Object.keys(record).some((key) => !allowed.has(key))) {
|
|
250
|
+
throw new TypeError("Message taxonomy class contains an unknown field.");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function boundedTaxonomyString(value: unknown, label: string, maxLength = MAX_NAME_LENGTH): string {
|
|
255
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > maxLength) {
|
|
256
|
+
throw new TypeError(`${label} must be non-empty bounded text.`);
|
|
257
|
+
}
|
|
258
|
+
return value.trim();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function taxonomyStringArray(value: unknown, label: string, maxLength = MAX_NAME_LENGTH): string[] {
|
|
262
|
+
if (value === undefined) return [];
|
|
263
|
+
if (!Array.isArray(value) || value.length > MAX_LIST_ITEMS) {
|
|
264
|
+
throw new TypeError(`${label} must be a bounded array.`);
|
|
265
|
+
}
|
|
266
|
+
const entries = value.map((entry) => boundedTaxonomyString(entry, label, maxLength));
|
|
267
|
+
if (new Set(entries.map(normalize)).size !== entries.length) {
|
|
268
|
+
throw new TypeError(`${label} must contain unique values.`);
|
|
269
|
+
}
|
|
270
|
+
return entries;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function classIndex(taxonomy: MessageTaxonomy, className: string): number {
|
|
274
|
+
const key = normalize(className);
|
|
275
|
+
return taxonomy.findIndex((entry) => normalize(entry.class) === key);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function normalize(value: string): string {
|
|
279
|
+
return value.trim().toLowerCase();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function roleSlug(value: string): string {
|
|
283
|
+
return value.toLowerCase().replace(/[\s_]+/gu, "-").replace(/[^a-z0-9-]/gu, "");
|
|
284
|
+
}
|