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
package/src/service.ts CHANGED
@@ -9,8 +9,14 @@ import {
9
9
  loadTlsPrivateKey,
10
10
  type BootstrapResult,
11
11
  } from "./bootstrap.js";
12
- import { CredentialAuthority, CredentialDigester } from "./credentials.js";
12
+ import {
13
+ CredentialAuthority,
14
+ CredentialDigester,
15
+ type CubeInvitationResult,
16
+ } from "./credentials.js";
13
17
  import { CoordinationApi } from "./coordination-api.js";
18
+ import { createDebugLogger, disabledDebugLogger } from "./debug-log.js";
19
+ import { MigrationCompatibilityError } from "./migrations.js";
14
20
  import { createEnrollmentExchange } from "./enrollment.js";
15
21
  import {
16
22
  DEFAULT_SERVICE_LIMITS,
@@ -18,24 +24,43 @@ import {
18
24
  type HttpsServerOptions,
19
25
  type RunningServer,
20
26
  } from "./https-server.js";
21
- import { createPart2ProtocolInfo } from "./protocol-draft.js";
22
27
  import { resolveBindOptions } from "./network-policy.js";
23
- import { operatorErrors, type OperatorErrorCode } from "./operator-error.js";
28
+ import {
29
+ invitationCubeAmbiguousError,
30
+ operatorErrors,
31
+ type OperatorErrorCode,
32
+ } from "./operator-error.js";
24
33
  import { parseStartOptions } from "./start-options.js";
25
- import { DEFAULT_STORAGE_LIMITS, openStore, type StorageLimits } from "./store.js";
34
+ import {
35
+ DEFAULT_STORAGE_LIMITS,
36
+ openStore,
37
+ preparePrivateDataDirectory,
38
+ InvitationCubeAmbiguousError,
39
+ InvitationCubeNotFoundError,
40
+ type LivenessStore,
41
+ type StorageLimits,
42
+ } from "./store.js";
26
43
  import type { CubeAccess } from "./store.js";
27
44
 
28
45
  export interface ServerService {
29
46
  readonly start: (args: readonly string[]) => Promise<void>;
30
- readonly setup?: () => Promise<BootstrapResult>;
47
+ readonly setup?: (options: SetupOptions) => Promise<BootstrapResult>;
31
48
  readonly rotateClient?: (clientId: string) => Promise<string>;
32
49
  readonly revokeClient?: (clientId: string) => Promise<void>;
33
50
  readonly grantClient?: (clientId: string, cubeId: string, access: CubeAccess) => Promise<void>;
34
51
  readonly ungrantClient?: (clientId: string, cubeId: string) => Promise<void>;
35
- readonly createClientInvitation?: (recoveryCredential: string) => Promise<string>;
52
+ readonly createClientInvitation?: (
53
+ recoveryCredential: string,
54
+ cubeSelector?: string,
55
+ access?: CubeAccess,
56
+ ) => Promise<string | CubeInvitationResult>;
36
57
  readonly replaceOwnerInvitation?: (recoveryCredential: string) => Promise<string>;
37
58
  }
38
59
 
60
+ export interface SetupOptions {
61
+ readonly reinitialize: boolean;
62
+ }
63
+
39
64
  export interface ServerEnvironment {
40
65
  readonly BORG_SERVER_TLS_KEY_FILE?: string;
41
66
  readonly BORG_SERVER_TLS_CERT_FILE?: string;
@@ -54,8 +79,12 @@ interface ServiceDependencies {
54
79
  readonly startServer: (options: HttpsServerOptions) => Promise<RunningServer>;
55
80
  readonly onStarted: (origin: string) => void;
56
81
  readonly waitForShutdown: (server: RunningServer, signal?: AbortSignal) => Promise<void>;
82
+ readonly debugOutput?: (line: string) => void;
57
83
  readonly installShutdownHandlers?: () => { readonly signal: AbortSignal; readonly dispose: () => void };
58
84
  readonly openStore?: typeof openStore;
85
+ readonly startLivenessScheduler?: (
86
+ liveness: LivenessStore,
87
+ ) => { readonly stop: () => void };
59
88
  readonly onStartupPhase?: (
60
89
  phase: "pre-lock" | "post-lock" | "pre-listen",
61
90
  ) => Promise<void>;
@@ -66,6 +95,7 @@ interface RuntimeResources {
66
95
  readonly authRuntime: Awaited<ReturnType<typeof openStore>> | undefined;
67
96
  readonly digester: CredentialDigester | undefined;
68
97
  readonly runtimeLock: RuntimeLock | undefined;
98
+ readonly livenessScheduler: { readonly stop: () => void } | undefined;
69
99
  }
70
100
 
71
101
  const guardedRuntimeFailures = new Set<RuntimeResources>();
@@ -91,17 +121,27 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
91
121
  return {
92
122
  async start(args): Promise<void> {
93
123
  const shutdown = dependencies.installShutdownHandlers?.();
94
- let bind: ReturnType<typeof parseStartOptions>;
124
+ let bind: ReturnType<typeof parseStartOptions>["bind"];
125
+ let debugLogger = disabledDebugLogger;
95
126
  let dataDirectory: string | undefined;
96
127
  let storageLimits: StorageLimits;
97
128
  try {
98
129
  throwIfShutdown(shutdown?.signal);
99
130
  await dependencies.onStartupPhase?.("pre-lock");
100
131
  throwIfShutdown(shutdown?.signal);
101
- bind = parseStartOptions(args);
102
- const bindMode = resolveBindOptions(bind).mode;
132
+ const parsed = parseStartOptions(args);
133
+ bind = parsed.bind;
134
+ debugLogger = createDebugLogger(parsed.logLevel === "debug" ? dependencies.debugOutput : undefined);
135
+ const resolvedBind = resolveBindOptions(bind);
136
+ const bindMode = resolvedBind.mode;
103
137
  dataDirectory = dependencies.environment.BORG_SERVER_DATA_DIR;
104
138
  storageLimits = resolveStorageLimits(dependencies.environment);
139
+ debugLogger.emit({
140
+ event: "startup",
141
+ bindMode,
142
+ port: resolvedBind.port,
143
+ dataDirectory: dataDirectory === undefined ? "tls_only" : "configured",
144
+ });
105
145
  if (bindMode === "lan" && dataDirectory !== undefined) {
106
146
  await assertLanCaKeyOffline(dataDirectory);
107
147
  throwIfShutdown(shutdown?.signal);
@@ -124,7 +164,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
124
164
 
125
165
  let runtimeLock: Awaited<ReturnType<typeof acquireRuntimeLock>> | undefined;
126
166
  try {
127
- runtimeLock = dataDirectory === undefined ? undefined : await acquireRuntimeLock(dataDirectory);
167
+ runtimeLock = dataDirectory === undefined ? undefined : await acquireRuntimeLock(dataDirectory, "server");
128
168
  await dependencies.onStartupPhase?.("post-lock");
129
169
  throwIfShutdown(shutdown?.signal);
130
170
  } catch (error) {
@@ -134,6 +174,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
134
174
  throw error;
135
175
  }
136
176
  let running: RunningServer | undefined;
177
+ let livenessScheduler: { readonly stop: () => void } | undefined;
137
178
  let key: Buffer | undefined;
138
179
  try {
139
180
  throwIfShutdown(shutdown?.signal);
@@ -169,8 +210,14 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
169
210
  } finally {
170
211
  digestKey.fill(0);
171
212
  }
172
- authority = new CredentialAuthority(authRuntime.credentials, digester);
173
- coordinationApi = new CoordinationApi(authRuntime, authority);
213
+ authority = new CredentialAuthority(
214
+ authRuntime.credentials,
215
+ digester,
216
+ () => new Date(),
217
+ undefined,
218
+ debugLogger,
219
+ );
220
+ coordinationApi = new CoordinationApi(authRuntime, authority, debugLogger);
174
221
  }
175
222
  await dependencies.onStartupPhase?.("pre-listen");
176
223
  throwIfShutdown(shutdown?.signal);
@@ -178,12 +225,6 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
178
225
  bind,
179
226
  tls: { key, cert, ...(ca === undefined ? {} : { ca }) },
180
227
  limits: DEFAULT_SERVICE_LIMITS,
181
- protocolInfo: createPart2ProtocolInfo(DEFAULT_SERVICE_LIMITS),
182
- authorizeProtocol: async (authorization) => {
183
- if (authority === undefined) return false;
184
- const result = authority.authenticateStatus(authorization);
185
- return typeof result === "object" ? true : result;
186
- },
187
228
  ...(authority === undefined
188
229
  ? {}
189
230
  : { exchangeEnrollment: createEnrollmentExchange(authority) }),
@@ -197,11 +238,17 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
197
238
  ...(coordinationApi === undefined
198
239
  ? {}
199
240
  : { handleCoordination: (request) => coordinationApi.handle(request) }),
241
+ debugLogger,
200
242
  });
201
243
  throwIfShutdown(shutdown?.signal);
244
+ if (authRuntime !== undefined) {
245
+ livenessScheduler = (dependencies.startLivenessScheduler ?? startLivenessScheduler)(
246
+ authRuntime.liveness,
247
+ );
248
+ }
202
249
  } catch (error) {
203
250
  try {
204
- await teardownRuntime({ running, authRuntime, digester, runtimeLock });
251
+ await teardownRuntime({ running, authRuntime, digester, runtimeLock, livenessScheduler });
205
252
  } catch (cleanupError) {
206
253
  shutdown?.dispose();
207
254
  throw fatalTeardownError(error, cleanupError);
@@ -217,13 +264,15 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
217
264
  try {
218
265
  throwIfShutdown(shutdown?.signal);
219
266
  dependencies.onStarted(running.origin);
267
+ debugLogger.emit({ event: "lifecycle", action: "listening" });
220
268
  await dependencies.waitForShutdown(running, shutdown?.signal);
221
269
  } catch (error) {
222
270
  failed = true;
223
271
  failure = error;
224
272
  }
225
273
  try {
226
- await teardownRuntime({ running, authRuntime, digester, runtimeLock });
274
+ await teardownRuntime({ running, authRuntime, digester, runtimeLock, livenessScheduler });
275
+ debugLogger.emit({ event: "lifecycle", action: "stopped" });
227
276
  } catch (cleanupError) {
228
277
  shutdown?.dispose();
229
278
  throw fatalTeardownError(failed ? failure : undefined, cleanupError);
@@ -329,13 +378,69 @@ const startOnlyService = createNodeServerService({
329
378
  return handlers;
330
379
  },
331
380
  waitForShutdown,
381
+ debugOutput: (line) => console.error(line),
332
382
  });
333
383
  export const nodeServerService: ServerService = {
334
384
  start: startOnlyService.start,
335
- setup: () => bootstrapServer(dataDirectory, resolveSetupBindHost(serverEnvironment)),
385
+ setup: (options) => setupNodeServerInstallation(
386
+ dataDirectory,
387
+ resolveSetupBindHost(serverEnvironment),
388
+ options,
389
+ ),
336
390
  ...createOfflineCredentialService(dataDirectory),
337
391
  };
338
392
 
393
+ const managedInstallationFiles = Object.freeze([
394
+ "borg.db",
395
+ "borg.db-wal",
396
+ "borg.db-shm",
397
+ "borg.db-journal",
398
+ "credential-digest.key",
399
+ "ca.key",
400
+ "ca.crt",
401
+ "server.key",
402
+ "server.crt",
403
+ "server.json",
404
+ ]);
405
+
406
+ export async function setupNodeServerInstallation(
407
+ setupDataDirectory: string,
408
+ bindHost: string,
409
+ options: SetupOptions,
410
+ ): Promise<BootstrapResult> {
411
+ const directory = await preparePrivateDataDirectory(setupDataDirectory);
412
+ const runtimeLock = await acquireRuntimeLock(directory);
413
+ let invitationLock: RuntimeLock | undefined;
414
+ try {
415
+ invitationLock = await acquireInvitationMintLock(directory);
416
+ const existing = await inspectManagedInstallation(directory);
417
+ if (existing.length !== 0 && !options.reinitialize) throw operatorErrors.INSTALLATION_EXISTS;
418
+ if (options.reinitialize) {
419
+ for (const path of existing) await unlink(path);
420
+ }
421
+ return await bootstrapServer(directory, bindHost);
422
+ } finally {
423
+ if (invitationLock === undefined) await runtimeLock.release();
424
+ else await invitationLock.release().finally(() => runtimeLock.release());
425
+ }
426
+ }
427
+
428
+ async function inspectManagedInstallation(directory: string): Promise<string[]> {
429
+ const existing: string[] = [];
430
+ for (const name of managedInstallationFiles) {
431
+ const path = join(directory, name);
432
+ try {
433
+ const metadata = await lstat(path);
434
+ if (metadata.isSymbolicLink()) throw operatorErrors.DATA_PATH_SYMLINK;
435
+ if (!metadata.isFile()) throw new Error("Managed installation paths must be regular files.");
436
+ existing.push(path);
437
+ } catch (error) {
438
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
439
+ }
440
+ }
441
+ return existing;
442
+ }
443
+
339
444
  function resolveSetupBindHost(environment: ServerEnvironment): string {
340
445
  return resolveBindOptions({
341
446
  ...(environment.BORG_SERVER_BIND_HOST === undefined
@@ -356,9 +461,11 @@ export function createOfflineCredentialService(
356
461
  runtime: Awaited<ReturnType<typeof openStore>>,
357
462
  ) => T): Promise<T> => {
358
463
  const runtimeLock = await acquireRuntimeLock(offlineDataDirectory);
464
+ let invitationLock: RuntimeLock | undefined;
359
465
  let runtime: Awaited<ReturnType<typeof openStore>> | undefined;
360
466
  let digester: CredentialDigester | undefined;
361
467
  try {
468
+ invitationLock = await acquireInvitationMintLock(offlineDataDirectory);
362
469
  runtime = await openStore({ path: join(offlineDataDirectory, "borg.db") });
363
470
  const digestKey = await loadDigestKey(join(offlineDataDirectory, "credential-digest.key"));
364
471
  digester = new CredentialDigester(digestKey);
@@ -367,7 +474,35 @@ export function createOfflineCredentialService(
367
474
  } finally {
368
475
  digester?.destroy();
369
476
  runtime?.close();
370
- await runtimeLock.release();
477
+ if (invitationLock === undefined) await runtimeLock.release();
478
+ else await invitationLock.release().finally(() => runtimeLock.release());
479
+ }
480
+ };
481
+ const withInvitationAuthority = async <T>(operation: (authority: CredentialAuthority) => T): Promise<T> => {
482
+ const invitationLock = await acquireInvitationMintLock(offlineDataDirectory);
483
+ let offlineRuntimeLock: RuntimeLock | undefined;
484
+ let runtime: Awaited<ReturnType<typeof openStore>> | undefined;
485
+ let digester: CredentialDigester | undefined;
486
+ try {
487
+ const runtimeState = await invitationRuntimeState(offlineDataDirectory);
488
+ if (runtimeState === "offline") offlineRuntimeLock = await acquireRuntimeLock(offlineDataDirectory);
489
+ runtime = await openStore({
490
+ path: join(offlineDataDirectory, "borg.db"),
491
+ migrationMode: runtimeState === "live" ? "require-current" : "apply",
492
+ });
493
+ const digestKey = await loadDigestKey(join(offlineDataDirectory, "credential-digest.key"));
494
+ digester = new CredentialDigester(digestKey);
495
+ digestKey.fill(0);
496
+ return operation(new CredentialAuthority(runtime.credentials, digester));
497
+ } catch (error) {
498
+ if (error instanceof MigrationCompatibilityError) throw operatorErrors.INVITATION_SCHEMA_MISMATCH;
499
+ if (isSqliteContention(error)) throw operatorErrors.INVITATION_CONTENTION;
500
+ throw error;
501
+ } finally {
502
+ digester?.destroy();
503
+ runtime?.close();
504
+ if (offlineRuntimeLock === undefined) await invitationLock.release();
505
+ else await offlineRuntimeLock.release().finally(() => invitationLock.release());
371
506
  }
372
507
  };
373
508
  return {
@@ -385,12 +520,24 @@ export function createOfflineCredentialService(
385
520
  throw operatorErrors.GRANT_NOT_FOUND;
386
521
  }
387
522
  }),
388
- createClientInvitation: (recoveryCredential) => withAuthority((authority) => {
389
- const invitation = authority.createInvitation(recoveryCredential, 15 * 60_000);
523
+ createClientInvitation: (recoveryCredential, cubeSelector, access) => withInvitationAuthority((authority) => {
524
+ if (cubeSelector === undefined && access !== undefined) {
525
+ throw operatorErrors.INVITATION_CUBE_SELECTOR_INVALID;
526
+ }
527
+ const selector = cubeSelector === undefined ? undefined : parseInvitationCubeSelector(cubeSelector);
528
+ const invitation = selector === undefined
529
+ ? authority.createInvitation(recoveryCredential, 15 * 60_000)
530
+ : authority.createCubeInvitation(recoveryCredential, selector, access ?? "write", 15 * 60_000);
390
531
  if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
391
532
  return invitation;
533
+ }).catch((error: unknown) => {
534
+ if (error instanceof InvitationCubeNotFoundError) throw operatorErrors.INVITATION_CUBE_NOT_FOUND;
535
+ if (error instanceof InvitationCubeAmbiguousError) {
536
+ throw invitationCubeAmbiguousError(error.candidateIds);
537
+ }
538
+ throw error;
392
539
  }),
393
- replaceOwnerInvitation: (recoveryCredential) => withAuthority((authority) => {
540
+ replaceOwnerInvitation: (recoveryCredential) => withInvitationAuthority((authority) => {
394
541
  const invitation = authority.replaceOwnerInvitation(recoveryCredential, 15 * 60_000);
395
542
  if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
396
543
  return invitation;
@@ -398,11 +545,25 @@ export function createOfflineCredentialService(
398
545
  };
399
546
  }
400
547
 
548
+ const canonicalCubeUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
549
+ const uuidLikeCubeSelector = /^[0-9a-fA-F-]{32,36}$/u;
550
+
551
+ function parseInvitationCubeSelector(
552
+ value: string,
553
+ ): { readonly kind: "id" | "name"; readonly value: string } {
554
+ if (canonicalCubeUuid.test(value)) return { kind: "id", value };
555
+ if (value.length === 0 || value.length > 120 || uuidLikeCubeSelector.test(value)) {
556
+ throw operatorErrors.INVITATION_CUBE_SELECTOR_INVALID;
557
+ }
558
+ return { kind: "name", value };
559
+ }
560
+
401
561
  interface RuntimeLock {
402
562
  readonly release: () => Promise<void>;
403
563
  }
404
564
 
405
565
  async function teardownRuntime(resources: RuntimeResources): Promise<void> {
566
+ resources.livenessScheduler?.stop();
406
567
  try {
407
568
  await resources.running?.close();
408
569
  } catch (error) {
@@ -419,6 +580,21 @@ async function teardownRuntime(resources: RuntimeResources): Promise<void> {
419
580
  await resources.runtimeLock?.release();
420
581
  }
421
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
+
422
598
  const fatalTeardownErrors = new WeakSet<object>();
423
599
  const fatalTeardownCapability = Object.freeze({});
424
600
 
@@ -445,13 +621,16 @@ function fatalTeardownError(primary: unknown, cleanup: unknown): FatalTeardownEr
445
621
  return new FatalTeardownError(fatalTeardownCapability, primary, cleanup);
446
622
  }
447
623
 
448
- export async function acquireRuntimeLock(runtimeDataDirectory: string): Promise<RuntimeLock> {
624
+ export async function acquireRuntimeLock(
625
+ runtimeDataDirectory: string,
626
+ purpose: "server" | "exclusive-admin" = "exclusive-admin",
627
+ ): Promise<RuntimeLock> {
449
628
  const path = join(runtimeDataDirectory, "runtime.lock");
450
629
  const nonce = randomUUID();
451
630
  try {
452
631
  const handle = await open(path, "wx", 0o600);
453
632
  try {
454
- await handle.writeFile(JSON.stringify({ pid: process.pid, nonce }));
633
+ await handle.writeFile(JSON.stringify({ pid: process.pid, nonce, purpose }));
455
634
  } catch (error) {
456
635
  await handle.close();
457
636
  await unlink(path).catch(() => undefined);
@@ -487,6 +666,68 @@ export async function acquireRuntimeLock(runtimeDataDirectory: string): Promise<
487
666
  }
488
667
  }
489
668
 
669
+ export async function acquireInvitationMintLock(runtimeDataDirectory: string): Promise<RuntimeLock> {
670
+ const path = join(runtimeDataDirectory, "invitation-mint.lock");
671
+ const nonce = randomUUID();
672
+ try {
673
+ const handle = await open(path, "wx", 0o600);
674
+ try {
675
+ await handle.writeFile(JSON.stringify({ pid: process.pid, nonce }));
676
+ } catch (error) {
677
+ await handle.close();
678
+ await unlink(path).catch(() => undefined);
679
+ throw error;
680
+ }
681
+ return {
682
+ release: async () => {
683
+ await handle.close();
684
+ try {
685
+ const current = JSON.parse(await readFile(path, "utf8")) as { nonce?: unknown };
686
+ if (current.nonce === nonce) await unlink(path);
687
+ } catch (error) {
688
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
689
+ }
690
+ },
691
+ };
692
+ } catch (error) {
693
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
694
+ throw operatorErrors.INVITATION_BUSY;
695
+ }
696
+ }
697
+
698
+ async function invitationRuntimeState(runtimeDataDirectory: string): Promise<"live" | "offline"> {
699
+ const path = join(runtimeDataDirectory, "runtime.lock");
700
+ let metadata;
701
+ try {
702
+ metadata = await lstat(path);
703
+ } catch (error) {
704
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "offline";
705
+ throw error;
706
+ }
707
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
708
+ throw operatorErrors.RUNTIME_LOCK_UNSAFE;
709
+ }
710
+ let pid: number;
711
+ let purpose: unknown;
712
+ try {
713
+ const value = JSON.parse(await readFile(path, "utf8")) as { pid?: unknown; purpose?: unknown };
714
+ if (!Number.isSafeInteger(value.pid) || (value.pid as number) <= 0) throw new Error();
715
+ pid = value.pid as number;
716
+ purpose = value.purpose;
717
+ } catch {
718
+ throw operatorErrors.RUNTIME_LOCK_INVALID;
719
+ }
720
+ if (!processIsAlive(pid)) throw operatorErrors.RUNTIME_LOCK_STALE;
721
+ if (purpose !== "server") throw operatorErrors.RUNTIME_ACTIVE;
722
+ return "live";
723
+ }
724
+
725
+ function isSqliteContention(error: unknown): boolean {
726
+ if (typeof error !== "object" || error === null) return false;
727
+ const value = error as { code?: unknown; errcode?: unknown };
728
+ return value.code === "ERR_SQLITE_ERROR" && (value.errcode === 5 || value.errcode === 6);
729
+ }
730
+
490
731
  function processIsAlive(pid: number): boolean {
491
732
  try {
492
733
  process.kill(pid, 0);
@@ -1,10 +1,16 @@
1
1
  import type { BindOptionsInput } from "./network-policy.js";
2
2
  import { operatorErrors, type OperatorErrorCode } from "./operator-error.js";
3
3
 
4
- export function parseStartOptions(args: readonly string[]): BindOptionsInput {
4
+ export interface ParsedStartOptions {
5
+ readonly bind: BindOptionsInput;
6
+ readonly logLevel?: "debug";
7
+ }
8
+
9
+ export function parseStartOptions(args: readonly string[]): ParsedStartOptions {
5
10
  let host: string | undefined;
6
11
  let port: number | undefined;
7
12
  let lanConsent = false;
13
+ let logLevel: "debug" | undefined;
8
14
 
9
15
  for (let index = 0; index < args.length; index += 1) {
10
16
  const argument = args[index];
@@ -27,13 +33,24 @@ export function parseStartOptions(args: readonly string[]): BindOptionsInput {
27
33
  index += 1;
28
34
  continue;
29
35
  }
36
+ if (argument === "--log-level") {
37
+ if (logLevel !== undefined) throw operatorErrors.START_LOG_LEVEL_DUPLICATE;
38
+ const value = requiredValue(args, index, "START_LOG_LEVEL_MISSING");
39
+ if (value !== "debug") throw operatorErrors.START_LOG_LEVEL_INVALID;
40
+ logLevel = value;
41
+ index += 1;
42
+ continue;
43
+ }
30
44
  throw operatorErrors.START_OPTION_UNKNOWN;
31
45
  }
32
46
 
33
47
  return {
34
- ...(host === undefined ? {} : { host }),
35
- ...(port === undefined ? {} : { port }),
36
- ...(lanConsent ? { lanConsent: true } : {}),
48
+ bind: {
49
+ ...(host === undefined ? {} : { host }),
50
+ ...(port === undefined ? {} : { port }),
51
+ ...(lanConsent ? { lanConsent: true } : {}),
52
+ },
53
+ ...(logLevel === undefined ? {} : { logLevel }),
37
54
  };
38
55
  }
39
56