borgmcp-server 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +70 -22
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/cli.js +62 -11
  4. package/dist/cli.js.map +1 -1
  5. package/dist/coordination-api.d.ts +3 -1
  6. package/dist/coordination-api.js +476 -78
  7. package/dist/coordination-api.js.map +1 -1
  8. package/dist/credentials.d.ts +13 -7
  9. package/dist/credentials.js +77 -15
  10. package/dist/credentials.js.map +1 -1
  11. package/dist/debug-log.d.ts +76 -0
  12. package/dist/debug-log.js +124 -0
  13. package/dist/debug-log.js.map +1 -0
  14. package/dist/enrollment.d.ts +3 -11
  15. package/dist/enrollment.js +21 -60
  16. package/dist/enrollment.js.map +1 -1
  17. package/dist/https-server.d.ts +5 -20
  18. package/dist/https-server.js +108 -50
  19. package/dist/https-server.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/message-taxonomy.d.ts +38 -0
  22. package/dist/message-taxonomy.js +218 -0
  23. package/dist/message-taxonomy.js.map +1 -0
  24. package/dist/migrations.d.ts +4 -0
  25. package/dist/migrations.js +99 -0
  26. package/dist/migrations.js.map +1 -1
  27. package/dist/operator-error.d.ts +2 -1
  28. package/dist/operator-error.js +23 -5
  29. package/dist/operator-error.js.map +1 -1
  30. package/dist/role-section.d.ts +14 -0
  31. package/dist/role-section.js +87 -0
  32. package/dist/role-section.js.map +1 -0
  33. package/dist/service.d.ts +14 -4
  34. package/dist/service.js +243 -25
  35. package/dist/service.js.map +1 -1
  36. package/dist/start-options.d.ts +5 -1
  37. package/dist/start-options.js +17 -3
  38. package/dist/start-options.js.map +1 -1
  39. package/dist/store.d.ts +106 -8
  40. package/dist/store.js +772 -120
  41. package/dist/store.js.map +1 -1
  42. package/npm-shrinkwrap.json +6 -7
  43. package/package.json +2 -2
  44. package/src/cli.ts +61 -11
  45. package/src/coordination-api.ts +490 -72
  46. package/src/credentials.ts +103 -19
  47. package/src/debug-log.ts +165 -0
  48. package/src/enrollment.ts +32 -78
  49. package/src/https-server.ts +113 -78
  50. package/src/index.ts +1 -1
  51. package/src/message-taxonomy.ts +284 -0
  52. package/src/migrations.ts +102 -0
  53. package/src/operator-error.ts +40 -6
  54. package/src/role-section.ts +108 -0
  55. package/src/service.ts +268 -27
  56. package/src/start-options.ts +21 -4
  57. package/src/store.ts +887 -142
  58. package/dist/protocol-draft.d.ts +0 -2
  59. package/dist/protocol-draft.js +0 -31
  60. package/dist/protocol-draft.js.map +0 -1
  61. package/src/protocol-draft.ts +0 -32
@@ -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) => socket.destroy());
130
- server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"));
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 === "/healthz") {
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 === "/api/protocol") {
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 === "/api/enrollment/exchange") {
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 === 507) sendJson(response, result.status, result.body);
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 cursor = parseCursorParameter(request.url, path);
347
- if (cursor === INVALID_COORDINATION_QUERY) {
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
- ...(cursor === undefined ? {} : { cursor }),
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: "1", error: { code, message } };
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 parseCursorParameter(
570
+ function parseCoordinationQuery(
526
571
  value: string | undefined,
527
572
  path: string,
528
- ): string | undefined | typeof INVALID_COORDINATION_QUERY {
529
- if (value === undefined) return undefined;
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
- if (!path.endsWith("/stream")) {
534
- return keys.length === 0 ? undefined : INVALID_COORDINATION_QUERY;
535
- }
536
- if (keys.some((key) => key !== "cursor")) return INVALID_COORDINATION_QUERY;
537
- const values = parsed.searchParams.getAll("cursor");
538
- if (values.length === 0) return undefined;
539
- return values.length === 1 && values[0]!.length > 0
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 === "/api/client/attach" || path === "/api/cubes" ||
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
@@ -1,3 +1,3 @@
1
1
  export { runCli } from "./cli.js";
2
2
  export type { CliIo } from "./cli.js";
3
- export type { ServerService } from "./service.js";
3
+ export type { ServerService, SetupOptions } from "./service.js";
@@ -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
+ }