borgmcp-server 0.1.4 → 0.1.7

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 (43) hide show
  1. package/README.md +32 -11
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/coordination-api.d.ts +2 -1
  4. package/dist/coordination-api.js +298 -89
  5. package/dist/coordination-api.js.map +1 -1
  6. package/dist/credentials.d.ts +4 -6
  7. package/dist/credentials.js +43 -19
  8. package/dist/credentials.js.map +1 -1
  9. package/dist/debug-log.d.ts +2 -3
  10. package/dist/debug-log.js +2 -3
  11. package/dist/debug-log.js.map +1 -1
  12. package/dist/enrollment.d.ts +3 -11
  13. package/dist/enrollment.js +21 -60
  14. package/dist/enrollment.js.map +1 -1
  15. package/dist/https-server.d.ts +2 -20
  16. package/dist/https-server.js +33 -51
  17. package/dist/https-server.js.map +1 -1
  18. package/dist/message-taxonomy.d.ts +38 -0
  19. package/dist/message-taxonomy.js +218 -0
  20. package/dist/message-taxonomy.js.map +1 -0
  21. package/dist/migrations.js +28 -0
  22. package/dist/migrations.js.map +1 -1
  23. package/dist/service.d.ts +4 -1
  24. package/dist/service.js +25 -10
  25. package/dist/service.js.map +1 -1
  26. package/dist/store.d.ts +66 -8
  27. package/dist/store.js +525 -100
  28. package/dist/store.js.map +1 -1
  29. package/npm-shrinkwrap.json +6 -7
  30. package/package.json +2 -2
  31. package/src/coordination-api.ts +312 -86
  32. package/src/credentials.ts +44 -26
  33. package/src/debug-log.ts +5 -5
  34. package/src/enrollment.ts +32 -78
  35. package/src/https-server.ts +44 -82
  36. package/src/message-taxonomy.ts +284 -0
  37. package/src/migrations.ts +28 -0
  38. package/src/service.ts +29 -9
  39. package/src/store.ts +593 -121
  40. package/dist/protocol-draft.d.ts +0 -2
  41. package/dist/protocol-draft.js +0 -31
  42. package/dist/protocol-draft.js.map +0 -1
  43. package/src/protocol-draft.ts +0 -32
@@ -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
+ }
package/src/migrations.ts CHANGED
@@ -350,6 +350,34 @@ export const STORE_MIGRATIONS: readonly Migration[] = Object.freeze([
350
350
  END;
351
351
  `,
352
352
  },
353
+ {
354
+ version: 9,
355
+ name: "digest_correlated_seat_attach",
356
+ sql: `
357
+ DROP TABLE seat_attach_bindings;
358
+ DROP INDEX drones_client_retry_key_idx;
359
+ ALTER TABLE drones DROP COLUMN retry_key;
360
+ ALTER TABLE drones DROP COLUMN attach_generation;
361
+ `,
362
+ },
363
+ {
364
+ version: 10,
365
+ name: "cube_message_taxonomy",
366
+ sql: "ALTER TABLE cubes ADD COLUMN message_taxonomy TEXT;",
367
+ },
368
+ {
369
+ version: 11,
370
+ name: "fleet_liveness",
371
+ sql: `
372
+ CREATE INDEX activity_log_drone_post_idx
373
+ ON activity_log (cube_id, drone_id, created_at, id) WHERE drone_id IS NOT NULL;
374
+ CREATE TABLE silent_seat_ping_state (
375
+ drone_id TEXT PRIMARY KEY REFERENCES drones(id) ON DELETE CASCADE,
376
+ attempts INTEGER NOT NULL CHECK (attempts BETWEEN 1 AND 3),
377
+ last_ping_at TEXT NOT NULL
378
+ ) STRICT;
379
+ `,
380
+ },
353
381
  ]);
354
382
 
355
383
  interface AppliedMigrationRow {
package/src/service.ts CHANGED
@@ -24,7 +24,6 @@ import {
24
24
  type HttpsServerOptions,
25
25
  type RunningServer,
26
26
  } from "./https-server.js";
27
- import { createPart2ProtocolInfo } from "./protocol-draft.js";
28
27
  import { resolveBindOptions } from "./network-policy.js";
29
28
  import {
30
29
  invitationCubeAmbiguousError,
@@ -38,6 +37,7 @@ import {
38
37
  preparePrivateDataDirectory,
39
38
  InvitationCubeAmbiguousError,
40
39
  InvitationCubeNotFoundError,
40
+ type LivenessStore,
41
41
  type StorageLimits,
42
42
  } from "./store.js";
43
43
  import type { CubeAccess } from "./store.js";
@@ -82,6 +82,9 @@ interface ServiceDependencies {
82
82
  readonly debugOutput?: (line: string) => void;
83
83
  readonly installShutdownHandlers?: () => { readonly signal: AbortSignal; readonly dispose: () => void };
84
84
  readonly openStore?: typeof openStore;
85
+ readonly startLivenessScheduler?: (
86
+ liveness: LivenessStore,
87
+ ) => { readonly stop: () => void };
85
88
  readonly onStartupPhase?: (
86
89
  phase: "pre-lock" | "post-lock" | "pre-listen",
87
90
  ) => Promise<void>;
@@ -92,6 +95,7 @@ interface RuntimeResources {
92
95
  readonly authRuntime: Awaited<ReturnType<typeof openStore>> | undefined;
93
96
  readonly digester: CredentialDigester | undefined;
94
97
  readonly runtimeLock: RuntimeLock | undefined;
98
+ readonly livenessScheduler: { readonly stop: () => void } | undefined;
95
99
  }
96
100
 
97
101
  const guardedRuntimeFailures = new Set<RuntimeResources>();
@@ -170,6 +174,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
170
174
  throw error;
171
175
  }
172
176
  let running: RunningServer | undefined;
177
+ let livenessScheduler: { readonly stop: () => void } | undefined;
173
178
  let key: Buffer | undefined;
174
179
  try {
175
180
  throwIfShutdown(shutdown?.signal);
@@ -220,12 +225,6 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
220
225
  bind,
221
226
  tls: { key, cert, ...(ca === undefined ? {} : { ca }) },
222
227
  limits: DEFAULT_SERVICE_LIMITS,
223
- protocolInfo: createPart2ProtocolInfo(DEFAULT_SERVICE_LIMITS),
224
- authorizeProtocol: async (authorization) => {
225
- if (authority === undefined) return false;
226
- const result = authority.authenticateStatus(authorization);
227
- return typeof result === "object" ? true : result;
228
- },
229
228
  ...(authority === undefined
230
229
  ? {}
231
230
  : { exchangeEnrollment: createEnrollmentExchange(authority) }),
@@ -242,9 +241,14 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
242
241
  debugLogger,
243
242
  });
244
243
  throwIfShutdown(shutdown?.signal);
244
+ if (authRuntime !== undefined) {
245
+ livenessScheduler = (dependencies.startLivenessScheduler ?? startLivenessScheduler)(
246
+ authRuntime.liveness,
247
+ );
248
+ }
245
249
  } catch (error) {
246
250
  try {
247
- await teardownRuntime({ running, authRuntime, digester, runtimeLock });
251
+ await teardownRuntime({ running, authRuntime, digester, runtimeLock, livenessScheduler });
248
252
  } catch (cleanupError) {
249
253
  shutdown?.dispose();
250
254
  throw fatalTeardownError(error, cleanupError);
@@ -267,7 +271,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
267
271
  failure = error;
268
272
  }
269
273
  try {
270
- await teardownRuntime({ running, authRuntime, digester, runtimeLock });
274
+ await teardownRuntime({ running, authRuntime, digester, runtimeLock, livenessScheduler });
271
275
  debugLogger.emit({ event: "lifecycle", action: "stopped" });
272
276
  } catch (cleanupError) {
273
277
  shutdown?.dispose();
@@ -559,6 +563,7 @@ interface RuntimeLock {
559
563
  }
560
564
 
561
565
  async function teardownRuntime(resources: RuntimeResources): Promise<void> {
566
+ resources.livenessScheduler?.stop();
562
567
  try {
563
568
  await resources.running?.close();
564
569
  } catch (error) {
@@ -575,6 +580,21 @@ async function teardownRuntime(resources: RuntimeResources): Promise<void> {
575
580
  await resources.runtimeLock?.release();
576
581
  }
577
582
 
583
+ function startLivenessScheduler(liveness: { readonly scan: () => unknown }): { readonly stop: () => void } {
584
+ let stopped = false;
585
+ let timer: NodeJS.Timeout;
586
+ const schedule = (): void => {
587
+ timer = setTimeout(() => {
588
+ if (stopped) return;
589
+ try { liveness.scan(); } catch { /* Retry on the next bounded tick. */ }
590
+ schedule();
591
+ }, 60_000);
592
+ timer.unref();
593
+ };
594
+ schedule();
595
+ return { stop: () => { stopped = true; clearTimeout(timer); } };
596
+ }
597
+
578
598
  const fatalTeardownErrors = new WeakSet<object>();
579
599
  const fatalTeardownCapability = Object.freeze({});
580
600