borgmcp-server 0.1.1

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 (71) hide show
  1. package/LICENSE +105 -0
  2. package/NOTICE +8 -0
  3. package/README.md +119 -0
  4. package/SECURITY.md +38 -0
  5. package/THIRD_PARTY_NOTICES.md +35 -0
  6. package/dist/bootstrap.d.ts +18 -0
  7. package/dist/bootstrap.js +104 -0
  8. package/dist/bootstrap.js.map +1 -0
  9. package/dist/cli.d.ts +7 -0
  10. package/dist/cli.js +96 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/coordination-api.d.ts +25 -0
  13. package/dist/coordination-api.js +457 -0
  14. package/dist/coordination-api.js.map +1 -0
  15. package/dist/credentials.d.ts +58 -0
  16. package/dist/credentials.js +244 -0
  17. package/dist/credentials.js.map +1 -0
  18. package/dist/enrollment.d.ts +17 -0
  19. package/dist/enrollment.js +122 -0
  20. package/dist/enrollment.js.map +1 -0
  21. package/dist/https-server.d.ts +94 -0
  22. package/dist/https-server.js +814 -0
  23. package/dist/https-server.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/main.d.ts +3 -0
  28. package/dist/main.js +67 -0
  29. package/dist/main.js.map +1 -0
  30. package/dist/migrations.d.ts +8 -0
  31. package/dist/migrations.js +366 -0
  32. package/dist/migrations.js.map +1 -0
  33. package/dist/network-policy.d.ts +13 -0
  34. package/dist/network-policy.js +49 -0
  35. package/dist/network-policy.js.map +1 -0
  36. package/dist/operator-error.d.ts +3 -0
  37. package/dist/operator-error.js +63 -0
  38. package/dist/operator-error.js.map +1 -0
  39. package/dist/principal.d.ts +32 -0
  40. package/dist/principal.js +64 -0
  41. package/dist/principal.js.map +1 -0
  42. package/dist/protocol-draft.d.ts +2 -0
  43. package/dist/protocol-draft.js +31 -0
  44. package/dist/protocol-draft.js.map +1 -0
  45. package/dist/service.d.ts +61 -0
  46. package/dist/service.js +455 -0
  47. package/dist/service.js.map +1 -0
  48. package/dist/start-options.d.ts +2 -0
  49. package/dist/start-options.js +46 -0
  50. package/dist/start-options.js.map +1 -0
  51. package/dist/store.d.ts +327 -0
  52. package/dist/store.js +1729 -0
  53. package/dist/store.js.map +1 -0
  54. package/npm-shrinkwrap.json +1942 -0
  55. package/package.json +60 -0
  56. package/src/bootstrap.ts +127 -0
  57. package/src/cli.ts +102 -0
  58. package/src/coordination-api.ts +508 -0
  59. package/src/credentials.ts +319 -0
  60. package/src/enrollment.ts +156 -0
  61. package/src/https-server.ts +962 -0
  62. package/src/index.ts +3 -0
  63. package/src/main.ts +73 -0
  64. package/src/migrations.ts +394 -0
  65. package/src/network-policy.ts +65 -0
  66. package/src/operator-error.ts +97 -0
  67. package/src/principal.ts +106 -0
  68. package/src/protocol-draft.ts +32 -0
  69. package/src/service.ts +525 -0
  70. package/src/start-options.ts +46 -0
  71. package/src/store.ts +2316 -0
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { runCli } from "./cli.js";
2
+ export type { CliIo } from "./cli.js";
3
+ export type { ServerService } from "./service.js";
package/src/main.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli, type CliIo } from "./cli.js";
4
+ import { isFatalTeardownError, nodeServerService } from "./service.js";
5
+ import { operatorPublicMessage } from "./operator-error.js";
6
+ import { realpathSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const io = {
10
+ stdout: (message: string): void => console.log(message),
11
+ stderr: (message: string): void => console.error(message),
12
+ readSecret: readHiddenSecret,
13
+ };
14
+
15
+ async function readHiddenSecret(prompt: string): Promise<string> {
16
+ if (!process.stdin.isTTY || !process.stdout.isTTY || process.stdin.setRawMode === undefined) {
17
+ throw new Error("Private terminal input is unavailable.");
18
+ }
19
+ process.stdout.write(prompt);
20
+ process.stdin.setRawMode(true);
21
+ process.stdin.resume();
22
+ return new Promise<string>((resolve, reject) => {
23
+ let value = "";
24
+ const cleanup = (): void => {
25
+ process.stdin.off("data", onData);
26
+ process.stdin.setRawMode(false);
27
+ process.stdin.pause();
28
+ process.stdout.write("\n");
29
+ };
30
+ const onData = (chunk: Buffer | string): void => {
31
+ for (const byte of Buffer.from(chunk)) {
32
+ if (byte === 3) {
33
+ cleanup();
34
+ reject(new Error("Private terminal input was cancelled."));
35
+ return;
36
+ }
37
+ if (byte === 13 || byte === 10) {
38
+ cleanup();
39
+ resolve(value);
40
+ return;
41
+ }
42
+ if (byte === 8 || byte === 127) value = value.slice(0, -1);
43
+ else if (byte >= 32 && byte <= 126 && value.length < 1_024) value += String.fromCharCode(byte);
44
+ }
45
+ };
46
+ process.stdin.on("data", onData);
47
+ });
48
+ }
49
+
50
+ export async function runMain(
51
+ args: readonly string[] = process.argv.slice(2),
52
+ service = nodeServerService,
53
+ output: CliIo = io,
54
+ fatalExit: (code: number) => never = process.exit,
55
+ ): Promise<void> {
56
+ try {
57
+ process.exitCode = await runCli(args, service, output);
58
+ } catch (error) {
59
+ if (isFatalTeardownError(error)) {
60
+ output.stderr("Server command failed.");
61
+ fatalExit(1);
62
+ }
63
+ const operatorMessage = operatorPublicMessage(error);
64
+ output.stderr(operatorMessage === null
65
+ ? "Server command failed."
66
+ : `Server command failed: ${operatorMessage}`);
67
+ process.exitCode = 1;
68
+ }
69
+ }
70
+
71
+ if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
72
+ await runMain();
73
+ }
@@ -0,0 +1,394 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { DatabaseSync } from "node:sqlite";
3
+
4
+ export interface Migration {
5
+ readonly version: number;
6
+ readonly name: string;
7
+ readonly sql: string;
8
+ }
9
+
10
+ export const STORE_MIGRATIONS: readonly Migration[] = Object.freeze([
11
+ {
12
+ version: 1,
13
+ name: "initial_scoped_store",
14
+ sql: `
15
+ CREATE TABLE clients (
16
+ id TEXT PRIMARY KEY,
17
+ name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
18
+ created_at TEXT NOT NULL,
19
+ revoked_at TEXT
20
+ ) STRICT;
21
+
22
+ CREATE TABLE cubes (
23
+ id TEXT PRIMARY KEY,
24
+ name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
25
+ directive TEXT NOT NULL CHECK (length(directive) <= 100000),
26
+ created_at TEXT NOT NULL,
27
+ updated_at TEXT NOT NULL
28
+ ) STRICT;
29
+
30
+ CREATE TABLE client_cube_grants (
31
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
32
+ cube_id TEXT NOT NULL REFERENCES cubes(id) ON DELETE CASCADE,
33
+ access TEXT NOT NULL CHECK (access IN ('read', 'write', 'manage')),
34
+ created_at TEXT NOT NULL,
35
+ PRIMARY KEY (client_id, cube_id)
36
+ ) STRICT, WITHOUT ROWID;
37
+
38
+ CREATE TABLE roles (
39
+ id TEXT NOT NULL,
40
+ cube_id TEXT NOT NULL REFERENCES cubes(id) ON DELETE CASCADE,
41
+ name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
42
+ created_at TEXT NOT NULL,
43
+ PRIMARY KEY (id),
44
+ UNIQUE (id, cube_id),
45
+ UNIQUE (cube_id, name)
46
+ ) STRICT;
47
+
48
+ CREATE TABLE drones (
49
+ id TEXT NOT NULL,
50
+ cube_id TEXT NOT NULL REFERENCES cubes(id) ON DELETE CASCADE,
51
+ role_id TEXT NOT NULL,
52
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
53
+ label TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 120),
54
+ created_at TEXT NOT NULL,
55
+ evicted_at TEXT,
56
+ PRIMARY KEY (id),
57
+ UNIQUE (id, cube_id),
58
+ UNIQUE (id, client_id, cube_id),
59
+ UNIQUE (cube_id, label),
60
+ FOREIGN KEY (role_id, cube_id) REFERENCES roles(id, cube_id)
61
+ ) STRICT;
62
+
63
+ CREATE TABLE drone_sessions (
64
+ id TEXT PRIMARY KEY,
65
+ client_id TEXT NOT NULL,
66
+ cube_id TEXT NOT NULL,
67
+ drone_id TEXT NOT NULL,
68
+ created_at TEXT NOT NULL,
69
+ expires_at TEXT NOT NULL,
70
+ revoked_at TEXT,
71
+ FOREIGN KEY (drone_id, client_id, cube_id)
72
+ REFERENCES drones(id, client_id, cube_id) ON DELETE CASCADE
73
+ ) STRICT;
74
+
75
+ CREATE TABLE activity_log (
76
+ id TEXT PRIMARY KEY,
77
+ cube_id TEXT NOT NULL REFERENCES cubes(id) ON DELETE CASCADE,
78
+ drone_id TEXT,
79
+ actor_kind TEXT NOT NULL CHECK (actor_kind IN ('operator', 'client', 'drone-session')),
80
+ actor_id TEXT NOT NULL,
81
+ message TEXT NOT NULL CHECK (length(message) BETWEEN 1 AND 10240),
82
+ created_at TEXT NOT NULL,
83
+ FOREIGN KEY (drone_id, cube_id) REFERENCES drones(id, cube_id)
84
+ ) STRICT;
85
+
86
+ CREATE TABLE activity_acks (
87
+ entry_id TEXT NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE,
88
+ principal_kind TEXT NOT NULL CHECK (principal_kind IN ('operator', 'client', 'drone-session')),
89
+ principal_id TEXT NOT NULL,
90
+ kind TEXT NOT NULL CHECK (kind IN ('ack', 'claim')),
91
+ created_at TEXT NOT NULL,
92
+ PRIMARY KEY (entry_id, principal_kind, principal_id, kind)
93
+ ) STRICT, WITHOUT ROWID;
94
+
95
+ CREATE INDEX client_cube_grants_cube_idx
96
+ ON client_cube_grants (cube_id, access, client_id);
97
+ CREATE INDEX drone_sessions_scope_idx
98
+ ON drone_sessions (client_id, cube_id, drone_id, expires_at)
99
+ WHERE revoked_at IS NULL;
100
+ CREATE INDEX activity_log_cube_cursor_idx
101
+ ON activity_log (cube_id, created_at, id);
102
+ `,
103
+ },
104
+ {
105
+ version: 2,
106
+ name: "credential_authority",
107
+ sql: `
108
+ CREATE TABLE recovery_credentials (
109
+ id TEXT PRIMARY KEY,
110
+ lookup_digest BLOB NOT NULL UNIQUE,
111
+ verifier_digest BLOB NOT NULL,
112
+ created_at TEXT NOT NULL,
113
+ revoked_at TEXT
114
+ ) STRICT;
115
+
116
+ CREATE TABLE enrollment_invitations (
117
+ id TEXT PRIMARY KEY,
118
+ lookup_digest BLOB NOT NULL UNIQUE,
119
+ verifier_digest BLOB NOT NULL,
120
+ expires_at TEXT NOT NULL,
121
+ created_at TEXT NOT NULL,
122
+ consumed_at TEXT
123
+ ) STRICT;
124
+
125
+ CREATE TABLE client_credentials (
126
+ id TEXT PRIMARY KEY,
127
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
128
+ lookup_digest BLOB NOT NULL UNIQUE,
129
+ verifier_digest BLOB NOT NULL,
130
+ created_at TEXT NOT NULL,
131
+ revoked_at TEXT
132
+ ) STRICT;
133
+
134
+ CREATE INDEX enrollment_invitations_expiry_idx
135
+ ON enrollment_invitations (expires_at)
136
+ WHERE consumed_at IS NULL;
137
+ CREATE INDEX client_credentials_client_idx
138
+ ON client_credentials (client_id, revoked_at);
139
+ `,
140
+ },
141
+ {
142
+ version: 3,
143
+ name: "coordination_protocol",
144
+ sql: `
145
+ ALTER TABLE cubes ADD COLUMN owner_id TEXT NOT NULL
146
+ DEFAULT '00000000-0000-4000-8000-000000000000';
147
+ ALTER TABLE roles ADD COLUMN short_description TEXT NOT NULL DEFAULT '';
148
+ ALTER TABLE roles ADD COLUMN detailed_description TEXT NOT NULL DEFAULT '';
149
+ ALTER TABLE roles ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0
150
+ CHECK (is_default IN (0, 1));
151
+ ALTER TABLE roles ADD COLUMN is_human_seat INTEGER NOT NULL DEFAULT 0
152
+ CHECK (is_human_seat IN (0, 1));
153
+ ALTER TABLE drones ADD COLUMN last_seen TEXT;
154
+ ALTER TABLE drones ADD COLUMN hostname TEXT;
155
+ ALTER TABLE activity_log ADD COLUMN visibility TEXT NOT NULL DEFAULT 'broadcast'
156
+ CHECK (visibility IN ('broadcast', 'direct'));
157
+ ALTER TABLE activity_acks ADD COLUMN claimant_drone_id TEXT;
158
+
159
+ CREATE TABLE activity_log_recipients (
160
+ entry_id TEXT NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE,
161
+ drone_id TEXT NOT NULL REFERENCES drones(id) ON DELETE CASCADE,
162
+ PRIMARY KEY (entry_id, drone_id)
163
+ ) STRICT, WITHOUT ROWID;
164
+
165
+ CREATE TABLE expired_activity_cursors (
166
+ cube_id TEXT NOT NULL REFERENCES cubes(id) ON DELETE CASCADE,
167
+ entry_id TEXT NOT NULL,
168
+ created_at TEXT NOT NULL,
169
+ PRIMARY KEY (cube_id, entry_id, created_at)
170
+ ) STRICT, WITHOUT ROWID;
171
+
172
+ CREATE TABLE decisions (
173
+ id TEXT PRIMARY KEY,
174
+ cube_id TEXT NOT NULL REFERENCES cubes(id) ON DELETE CASCADE,
175
+ topic TEXT NOT NULL CHECK (length(topic) BETWEEN 1 AND 120),
176
+ decision TEXT NOT NULL CHECK (length(decision) BETWEEN 1 AND 100000),
177
+ rationale TEXT CHECK (rationale IS NULL OR length(rationale) <= 100000),
178
+ ratified_by TEXT,
179
+ status TEXT NOT NULL CHECK (status IN ('active', 'superseded', 'removed')),
180
+ supersedes TEXT REFERENCES decisions(id),
181
+ created_at TEXT NOT NULL
182
+ ) STRICT;
183
+
184
+ CREATE INDEX activity_log_recipients_drone_idx
185
+ ON activity_log_recipients (drone_id, entry_id);
186
+ CREATE INDEX activity_acks_claim_idx
187
+ ON activity_acks (kind, entry_id, created_at);
188
+ CREATE UNIQUE INDEX decisions_active_topic_idx
189
+ ON decisions (cube_id, topic) WHERE status = 'active';
190
+ CREATE INDEX decisions_cube_status_idx
191
+ ON decisions (cube_id, status, created_at, id);
192
+ `,
193
+ },
194
+ {
195
+ version: 4,
196
+ name: "seat_attach_credentials",
197
+ sql: `
198
+ ALTER TABLE drones ADD COLUMN retry_key TEXT;
199
+ ALTER TABLE drones ADD COLUMN attach_generation INTEGER NOT NULL DEFAULT 0
200
+ CHECK (attach_generation >= 0);
201
+ ALTER TABLE roles ADD COLUMN role_class TEXT NOT NULL DEFAULT 'worker'
202
+ CHECK (role_class IN ('queen', 'worker'));
203
+
204
+ CREATE UNIQUE INDEX drones_client_retry_key_idx
205
+ ON drones (client_id, retry_key) WHERE retry_key IS NOT NULL;
206
+
207
+ CREATE TABLE drone_session_credentials (
208
+ id TEXT PRIMARY KEY,
209
+ session_id TEXT NOT NULL REFERENCES drone_sessions(id) ON DELETE CASCADE,
210
+ lookup_digest BLOB NOT NULL UNIQUE,
211
+ verifier_digest BLOB NOT NULL,
212
+ created_at TEXT NOT NULL,
213
+ revoked_at TEXT
214
+ ) STRICT;
215
+
216
+ CREATE INDEX drone_session_credentials_session_idx
217
+ ON drone_session_credentials (session_id, revoked_at);
218
+ `,
219
+ },
220
+ {
221
+ version: 5,
222
+ name: "owner_enrollment_and_cube_creation",
223
+ sql: `
224
+ ALTER TABLE enrollment_invitations ADD COLUMN purpose TEXT NOT NULL DEFAULT 'client'
225
+ CHECK (purpose IN ('owner', 'client'));
226
+ ALTER TABLE enrollment_invitations ADD COLUMN owner_epoch INTEGER
227
+ CHECK (owner_epoch IS NULL OR owner_epoch > 0);
228
+ ALTER TABLE enrollment_invitations ADD COLUMN revoked_at TEXT;
229
+
230
+ CREATE TABLE owner_enrollment_state (
231
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
232
+ epoch INTEGER NOT NULL CHECK (epoch > 0),
233
+ claimed_client_id TEXT REFERENCES clients(id),
234
+ claimed_at TEXT,
235
+ CHECK ((claimed_client_id IS NULL) = (claimed_at IS NULL))
236
+ ) STRICT;
237
+
238
+ CREATE TABLE enrollment_claims (
239
+ invitation_id TEXT PRIMARY KEY REFERENCES enrollment_invitations(id) ON DELETE CASCADE,
240
+ retry_key TEXT NOT NULL,
241
+ client_id TEXT NOT NULL UNIQUE REFERENCES clients(id) ON DELETE CASCADE,
242
+ requested_client_name TEXT,
243
+ credential_lookup_digest BLOB NOT NULL,
244
+ credential_verifier_digest BLOB NOT NULL,
245
+ purpose TEXT NOT NULL CHECK (purpose IN ('owner', 'client')),
246
+ owner_epoch INTEGER,
247
+ created_at TEXT NOT NULL,
248
+ CHECK ((purpose = 'owner') = (owner_epoch IS NOT NULL))
249
+ ) STRICT;
250
+
251
+ CREATE TABLE client_server_capabilities (
252
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
253
+ capability TEXT NOT NULL CHECK (capability = 'create_cube'),
254
+ created_at TEXT NOT NULL,
255
+ PRIMARY KEY (client_id, capability)
256
+ ) STRICT, WITHOUT ROWID;
257
+
258
+ CREATE TABLE cube_create_bindings (
259
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
260
+ retry_key TEXT NOT NULL,
261
+ name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
262
+ template TEXT NOT NULL CHECK (template = 'default'),
263
+ cube_id TEXT NOT NULL UNIQUE REFERENCES cubes(id) ON DELETE CASCADE,
264
+ human_seat_role_id TEXT NOT NULL UNIQUE,
265
+ default_worker_role_id TEXT NOT NULL UNIQUE,
266
+ created_at TEXT NOT NULL,
267
+ PRIMARY KEY (client_id, retry_key),
268
+ FOREIGN KEY (human_seat_role_id, cube_id) REFERENCES roles(id, cube_id),
269
+ FOREIGN KEY (default_worker_role_id, cube_id) REFERENCES roles(id, cube_id)
270
+ ) STRICT, WITHOUT ROWID;
271
+
272
+ CREATE INDEX cube_create_bindings_cube_idx ON cube_create_bindings (cube_id);
273
+ CREATE INDEX enrollment_invitations_owner_idx
274
+ ON enrollment_invitations (purpose, owner_epoch, expires_at)
275
+ WHERE consumed_at IS NULL AND revoked_at IS NULL;
276
+ `,
277
+ },
278
+ {
279
+ version: 6,
280
+ name: "seat_reattach_bindings",
281
+ sql: `
282
+ CREATE TABLE seat_attach_bindings (
283
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
284
+ retry_key TEXT NOT NULL,
285
+ cube_id TEXT NOT NULL,
286
+ requested_role_id TEXT NOT NULL,
287
+ drone_id TEXT NOT NULL,
288
+ prior_drone_id TEXT,
289
+ created_at TEXT NOT NULL,
290
+ PRIMARY KEY (client_id, retry_key),
291
+ FOREIGN KEY (requested_role_id, cube_id) REFERENCES roles(id, cube_id),
292
+ FOREIGN KEY (drone_id, client_id, cube_id)
293
+ REFERENCES drones(id, client_id, cube_id) ON DELETE CASCADE
294
+ ) STRICT, WITHOUT ROWID;
295
+
296
+ INSERT INTO seat_attach_bindings (
297
+ client_id, retry_key, cube_id, requested_role_id, drone_id, prior_drone_id, created_at
298
+ )
299
+ SELECT client_id, retry_key, cube_id, role_id, id, NULL, created_at
300
+ FROM drones WHERE retry_key IS NOT NULL;
301
+
302
+ CREATE INDEX seat_attach_bindings_drone_idx
303
+ ON seat_attach_bindings (drone_id, client_id, cube_id);
304
+ `,
305
+ },
306
+ ]);
307
+
308
+ interface AppliedMigrationRow {
309
+ readonly version: number;
310
+ readonly name: string;
311
+ readonly checksum: string;
312
+ }
313
+
314
+ export function applyMigrations(
315
+ database: DatabaseSync,
316
+ migrations: readonly Migration[] = STORE_MIGRATIONS,
317
+ ): void {
318
+ validateMigrationOrder(migrations);
319
+ database.exec(`
320
+ CREATE TABLE IF NOT EXISTS schema_migrations (
321
+ version INTEGER PRIMARY KEY,
322
+ name TEXT NOT NULL,
323
+ checksum TEXT NOT NULL,
324
+ applied_at TEXT NOT NULL
325
+ ) STRICT;
326
+ `);
327
+
328
+ const applied = database.prepare(
329
+ "SELECT version, name, checksum FROM schema_migrations ORDER BY version",
330
+ ).all().map(appliedMigrationRow);
331
+ const knownVersions = new Set(migrations.map((migration) => migration.version));
332
+ for (const row of applied) {
333
+ const migration = migrations.find((candidate) => candidate.version === row.version);
334
+ if (migration === undefined || !knownVersions.has(row.version)) {
335
+ throw new Error(`Database contains unknown migration ${row.version}.`);
336
+ }
337
+ if (row.name !== migration.name || row.checksum !== checksum(migration)) {
338
+ throw new Error(`Migration ${row.version} does not match its recorded checksum.`);
339
+ }
340
+ }
341
+
342
+ const appliedVersions = new Set(applied.map((row) => row.version));
343
+ const record = database.prepare(
344
+ "INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)",
345
+ );
346
+ for (const migration of migrations) {
347
+ if (appliedVersions.has(migration.version)) continue;
348
+ database.exec("BEGIN IMMEDIATE");
349
+ try {
350
+ database.exec(migration.sql);
351
+ record.run(
352
+ migration.version,
353
+ migration.name,
354
+ checksum(migration),
355
+ new Date().toISOString(),
356
+ );
357
+ database.exec("COMMIT");
358
+ } catch (error) {
359
+ try {
360
+ database.exec("ROLLBACK");
361
+ } catch {
362
+ // The original migration error is the actionable failure.
363
+ }
364
+ throw error;
365
+ }
366
+ }
367
+ }
368
+
369
+ function validateMigrationOrder(migrations: readonly Migration[]): void {
370
+ migrations.forEach((migration, index) => {
371
+ if (migration.version !== index + 1 || migration.name.length === 0 || migration.sql.length === 0) {
372
+ throw new Error("Migrations must be non-empty and ordered contiguously from version 1.");
373
+ }
374
+ });
375
+ }
376
+
377
+ function checksum(migration: Migration): string {
378
+ return createHash("sha256")
379
+ .update(`${migration.version}\0${migration.name}\0${migration.sql}`)
380
+ .digest("hex");
381
+ }
382
+
383
+ function appliedMigrationRow(row: Record<string, unknown>): AppliedMigrationRow {
384
+ if (!Number.isSafeInteger(row["version"]) ||
385
+ typeof row["name"] !== "string" ||
386
+ typeof row["checksum"] !== "string") {
387
+ throw new Error("Database contains an invalid migration record.");
388
+ }
389
+ return {
390
+ version: row["version"] as number,
391
+ name: row["name"],
392
+ checksum: row["checksum"],
393
+ };
394
+ }
@@ -0,0 +1,65 @@
1
+ import { isIP } from "node:net";
2
+ import { operatorErrors } from "./operator-error.js";
3
+
4
+ export const DEFAULT_BIND_HOST = "127.0.0.1";
5
+ export const DEFAULT_PORT = 7_091;
6
+
7
+ export interface BindOptionsInput {
8
+ readonly host?: string;
9
+ readonly port?: number;
10
+ readonly lanConsent?: boolean;
11
+ }
12
+
13
+ export interface ResolvedBindOptions {
14
+ readonly host: string;
15
+ readonly port: number;
16
+ readonly mode: "loopback" | "lan";
17
+ }
18
+
19
+ export function resolveBindOptions(input: BindOptionsInput): ResolvedBindOptions {
20
+ const host = input.host ?? DEFAULT_BIND_HOST;
21
+ const port = input.port ?? DEFAULT_PORT;
22
+
23
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
24
+ throw operatorErrors.BIND_PORT_INVALID;
25
+ }
26
+ if (isIP(host) === 0) {
27
+ throw operatorErrors.BIND_HOST_INVALID;
28
+ }
29
+ if (host === "0.0.0.0" || host === "::") {
30
+ throw operatorErrors.BIND_WILDCARD;
31
+ }
32
+ if (isLoopback(host)) {
33
+ return { host, port, mode: "loopback" };
34
+ }
35
+ if (!isPrivateLan(host)) {
36
+ throw operatorErrors.BIND_PUBLIC;
37
+ }
38
+ if (input.lanConsent !== true) {
39
+ throw operatorErrors.BIND_LAN_CONSENT;
40
+ }
41
+
42
+ return { host, port, mode: "lan" };
43
+ }
44
+
45
+ function isLoopback(host: string): boolean {
46
+ if (host === "::1") return true;
47
+ if (isIP(host) !== 4) return false;
48
+ return Number(host.split(".")[0]) === 127;
49
+ }
50
+
51
+ function isPrivateLan(host: string): boolean {
52
+ const family = isIP(host);
53
+ if (family === 4) {
54
+ const octets = host.split(".").map(Number);
55
+ const first = octets[0];
56
+ const second = octets[1];
57
+ return first === 10 ||
58
+ (first === 172 && second !== undefined && second >= 16 && second <= 31) ||
59
+ (first === 192 && second === 168) ||
60
+ (first === 169 && second === 254);
61
+ }
62
+
63
+ const normalized = host.toLowerCase();
64
+ return normalized.startsWith("fc") || normalized.startsWith("fd") || /^fe[89ab]/u.test(normalized);
65
+ }
@@ -0,0 +1,97 @@
1
+ export type OperatorErrorCode =
2
+ | "START_LAN_DUPLICATE"
3
+ | "START_HOST_DUPLICATE"
4
+ | "START_PORT_DUPLICATE"
5
+ | "START_HOST_MISSING"
6
+ | "START_PORT_MISSING"
7
+ | "START_PORT_INVALID"
8
+ | "START_OPTION_UNKNOWN"
9
+ | "BIND_PORT_INVALID"
10
+ | "BIND_HOST_INVALID"
11
+ | "BIND_WILDCARD"
12
+ | "BIND_PUBLIC"
13
+ | "BIND_LAN_CONSENT"
14
+ | "SERVER_FILES_MISSING"
15
+ | "DATA_PATH_SYMLINK"
16
+ | "LAN_CA_KEY_ONLINE"
17
+ | "RUNTIME_ACTIVE"
18
+ | "RUNTIME_LOCK_UNSAFE"
19
+ | "RUNTIME_LOCK_INVALID"
20
+ | "RUNTIME_LOCK_STALE"
21
+ | "ACTIVITY_LIMIT_INVALID"
22
+ | "DATABASE_LIMIT_INVALID"
23
+ | "DISK_RESERVE_INVALID"
24
+ | "CLIENT_NOT_FOUND"
25
+ | "GRANT_NOT_FOUND"
26
+ | "RECOVERY_INVALID";
27
+
28
+ const publicMessages: Readonly<Record<OperatorErrorCode, string>> = Object.freeze({
29
+ START_LAN_DUPLICATE: "Provide --lan only once.",
30
+ START_HOST_DUPLICATE: "Provide --host only once.",
31
+ START_PORT_DUPLICATE: "Provide --port only once.",
32
+ START_HOST_MISSING: "Provide an IP address after --host.",
33
+ START_PORT_MISSING: "Provide a port number after --port.",
34
+ START_PORT_INVALID: "Provide --port as an integer from 0 to 65535.",
35
+ START_OPTION_UNKNOWN: "Use only documented start options; run borg-mcp-server help.",
36
+ BIND_PORT_INVALID: "Configure the listen port as an integer from 0 to 65535.",
37
+ BIND_HOST_INVALID: "Configure --host as an explicit IP address.",
38
+ BIND_WILDCARD: "Choose a specific loopback or private-LAN IP; wildcard binds are prohibited.",
39
+ BIND_PUBLIC: "Choose a loopback or private-LAN IP; public-routable binds are unsupported.",
40
+ BIND_LAN_CONSENT: "Add --lan to consent to this private-LAN start.",
41
+ SERVER_FILES_MISSING: "Configure BORG_SERVER_DATA_DIR or the required TLS file variables.",
42
+ DATA_PATH_SYMLINK: "Choose a BORG_SERVER_DATA_DIR path that contains no symbolic links.",
43
+ 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 client administration.",
45
+ RUNTIME_LOCK_UNSAFE: "Ensure runtime.lock is a private regular file before retrying.",
46
+ RUNTIME_LOCK_INVALID: "Confirm the server is stopped, then remove the invalid runtime.lock.",
47
+ RUNTIME_LOCK_STALE: "Confirm the recorded server process is stopped, then remove runtime.lock.",
48
+ ACTIVITY_LIMIT_INVALID: "Set BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE to a positive integer.",
49
+ DATABASE_LIMIT_INVALID: "Set BORG_SERVER_MAX_DATABASE_BYTES to a positive integer.",
50
+ DISK_RESERVE_INVALID: "Set BORG_SERVER_MIN_FREE_DISK_BYTES to a positive integer.",
51
+ CLIENT_NOT_FOUND: "Provide an existing active client ID.",
52
+ GRANT_NOT_FOUND: "Provide an existing client cube grant.",
53
+ RECOVERY_INVALID: "Provide the active recovery credential through the private prompt.",
54
+ });
55
+
56
+ const operatorErrorCodes = new WeakMap<object, OperatorErrorCode>();
57
+ const operatorErrorCapability = Object.freeze({});
58
+
59
+ class OperatorError extends Error {
60
+ readonly #operatorCode: OperatorErrorCode;
61
+
62
+ constructor(capability: object, code: OperatorErrorCode) {
63
+ super(Object.hasOwn(publicMessages, code) ? publicMessages[code] : "Operator error rejected.");
64
+ if (capability !== operatorErrorCapability || !Object.hasOwn(publicMessages, code)) {
65
+ throw new Error("Operator error construction is unavailable.");
66
+ }
67
+ this.name = "OperatorError";
68
+ this.#operatorCode = code;
69
+ operatorErrorCodes.set(this, code);
70
+ Object.freeze(this);
71
+ }
72
+
73
+ get code(): OperatorErrorCode {
74
+ return this.#operatorCode;
75
+ }
76
+
77
+ get publicMessage(): string {
78
+ return publicMessages[this.#operatorCode];
79
+ }
80
+ }
81
+
82
+ export const operatorErrors: Readonly<Record<OperatorErrorCode, Error>> = Object.freeze(
83
+ Object.fromEntries(
84
+ (Object.keys(publicMessages) as OperatorErrorCode[]).map((code) => [
85
+ code,
86
+ new OperatorError(operatorErrorCapability, code),
87
+ ]),
88
+ ) as unknown as Record<OperatorErrorCode, Error>,
89
+ );
90
+
91
+ export function operatorPublicMessage(error: unknown): string | null {
92
+ if (typeof error !== "object" || error === null) return null;
93
+ const code = operatorErrorCodes.get(error);
94
+ if (code === undefined) return null;
95
+ if (Object.getPrototypeOf(error) !== OperatorError.prototype || !Object.isFrozen(error)) return null;
96
+ return publicMessages[code];
97
+ }