borgmcp-server 0.1.8 → 0.1.9

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 (63) hide show
  1. package/README.md +57 -43
  2. package/SECURITY.md +12 -1
  3. package/dist/bootstrap.d.ts +8 -1
  4. package/dist/bootstrap.js +39 -6
  5. package/dist/bootstrap.js.map +1 -1
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +176 -1
  8. package/dist/cli.js.map +1 -1
  9. package/dist/credentials.d.ts +1 -0
  10. package/dist/credentials.js +7 -0
  11. package/dist/credentials.js.map +1 -1
  12. package/dist/debug-log.d.ts +1 -1
  13. package/dist/debug-log.js +1 -1
  14. package/dist/debug-log.js.map +1 -1
  15. package/dist/https-server.d.ts +3 -0
  16. package/dist/https-server.js +25 -7
  17. package/dist/https-server.js.map +1 -1
  18. package/dist/index.d.ts +14 -1
  19. package/dist/index.js +8 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/main.js +1 -0
  22. package/dist/main.js.map +1 -1
  23. package/dist/managed-service.d.ts +20 -0
  24. package/dist/managed-service.js +80 -0
  25. package/dist/managed-service.js.map +1 -0
  26. package/dist/portable-credential-store.d.ts +16 -0
  27. package/dist/portable-credential-store.js +305 -0
  28. package/dist/portable-credential-store.js.map +1 -0
  29. package/dist/registry-artifact.d.ts +11 -0
  30. package/dist/registry-artifact.js +99 -0
  31. package/dist/registry-artifact.js.map +1 -0
  32. package/dist/runtime-identity.d.ts +19 -0
  33. package/dist/runtime-identity.js +61 -0
  34. package/dist/runtime-identity.js.map +1 -0
  35. package/dist/runtime-lifecycle.d.ts +56 -0
  36. package/dist/runtime-lifecycle.js +486 -0
  37. package/dist/runtime-lifecycle.js.map +1 -0
  38. package/dist/runtime-operator.d.ts +24 -0
  39. package/dist/runtime-operator.js +95 -0
  40. package/dist/runtime-operator.js.map +1 -0
  41. package/dist/service.d.ts +53 -5
  42. package/dist/service.js +326 -15
  43. package/dist/service.js.map +1 -1
  44. package/dist/store.d.ts +1 -0
  45. package/dist/store.js +8 -0
  46. package/dist/store.js.map +1 -1
  47. package/npm-shrinkwrap.json +2 -2
  48. package/package.json +1 -1
  49. package/src/bootstrap.ts +46 -5
  50. package/src/cli.ts +171 -1
  51. package/src/credentials.ts +7 -0
  52. package/src/debug-log.ts +2 -1
  53. package/src/https-server.ts +27 -7
  54. package/src/index.ts +46 -1
  55. package/src/main.ts +1 -0
  56. package/src/managed-service.ts +107 -0
  57. package/src/portable-credential-store.ts +306 -0
  58. package/src/registry-artifact.ts +111 -0
  59. package/src/runtime-identity.ts +82 -0
  60. package/src/runtime-lifecycle.ts +567 -0
  61. package/src/runtime-operator.ts +124 -0
  62. package/src/service.ts +401 -20
  63. package/src/store.ts +10 -0
package/src/service.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
2
3
  import { lstat, open, readFile, unlink } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
- import { join } from "node:path";
5
+ import { basename, join } from "node:path";
6
+ import { promisify } from "node:util";
5
7
 
6
8
  import {
7
9
  bootstrapServer,
@@ -25,6 +27,11 @@ import {
25
27
  type RunningServer,
26
28
  } from "./https-server.js";
27
29
  import { resolveBindOptions } from "./network-policy.js";
30
+ import { DEFAULT_PORT } from "./network-policy.js";
31
+ import {
32
+ readPortableServerCredential,
33
+ writePortableServerCredential,
34
+ } from "./portable-credential-store.js";
28
35
  import {
29
36
  invitationCubeAmbiguousError,
30
37
  operatorErrors,
@@ -41,10 +48,22 @@ import {
41
48
  type StorageLimits,
42
49
  } from "./store.js";
43
50
  import type { CubeAccess } from "./store.js";
51
+ import { fileURLToPath } from "node:url";
52
+ import { loadRuntimeBuildIdentity, type RuntimeBuildIdentity } from "./runtime-identity.js";
53
+ import {
54
+ createRuntimeLifecycle,
55
+ createUnixNpmArtifactUnpacker,
56
+ inspectActiveRuntimeArtifact,
57
+ } from "./runtime-lifecycle.js";
58
+ import { createRegistryArtifactSource } from "./registry-artifact.js";
59
+ import { createRuntimeOperator, type RuntimeUpdateResult } from "./runtime-operator.js";
60
+ import { createManagedServiceDefinition } from "./managed-service.js";
44
61
 
45
62
  export interface ServerService {
46
63
  readonly start: (args: readonly string[]) => Promise<void>;
47
- readonly setup?: (options: SetupOptions) => Promise<BootstrapResult>;
64
+ readonly setup?: (options: SetupOptions) => Promise<ServerSetupResult>;
65
+ readonly status?: () => Promise<ServerRuntimeStatus>;
66
+ readonly update?: () => Promise<RuntimeUpdateResult>;
48
67
  readonly rotateClient?: (clientId: string) => Promise<string>;
49
68
  readonly revokeClient?: (clientId: string) => Promise<void>;
50
69
  readonly grantClient?: (clientId: string, cubeId: string, access: CubeAccess) => Promise<void>;
@@ -55,12 +74,31 @@ export interface ServerService {
55
74
  access?: CubeAccess,
56
75
  ) => Promise<string | CubeInvitationResult>;
57
76
  readonly replaceOwnerInvitation?: (recoveryCredential: string) => Promise<string>;
77
+ readonly invite?: () => Promise<string>;
58
78
  }
59
79
 
60
80
  export interface SetupOptions {
61
81
  readonly reinitialize: boolean;
62
82
  }
63
83
 
84
+ export type ServerSetupResult =
85
+ | (Omit<BootstrapResult, "recoveryCredential" | "initialInvitation"> & {
86
+ readonly artifact?: { readonly version: string; readonly integrity: string; readonly sourceSha: string | null };
87
+ })
88
+ | {
89
+ readonly existing: true;
90
+ readonly artifact?: { readonly version: string; readonly integrity: string; readonly sourceSha: string | null };
91
+ };
92
+
93
+ export interface ServerRuntimeStatus {
94
+ readonly status: "running" | "stopped";
95
+ readonly artifact: { readonly version: string; readonly integrity: string } | null;
96
+ readonly buildIdentity: string | null;
97
+ readonly endpoint: string | null;
98
+ readonly mode: "foreground" | "managed" | "stopped";
99
+ readonly dataIdentity: "available" | "unavailable";
100
+ }
101
+
64
102
  export interface ServerEnvironment {
65
103
  readonly BORG_SERVER_TLS_KEY_FILE?: string;
66
104
  readonly BORG_SERVER_TLS_CERT_FILE?: string;
@@ -70,6 +108,10 @@ export interface ServerEnvironment {
70
108
  readonly BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE?: string;
71
109
  readonly BORG_SERVER_MAX_DATABASE_BYTES?: string;
72
110
  readonly BORG_SERVER_MIN_FREE_DISK_BYTES?: string;
111
+ readonly BORG_SERVER_SOURCE_SHA?: string;
112
+ readonly BORG_SERVER_ARTIFACT_INTEGRITY?: string;
113
+ readonly BORG_SERVER_PROCESS_MODE?: "foreground" | "managed";
114
+ readonly BORG_SERVER_RUNTIME_DIR?: string;
73
115
  }
74
116
 
75
117
  interface ServiceDependencies {
@@ -77,7 +119,7 @@ interface ServiceDependencies {
77
119
  readonly readFile: (path: string) => Promise<Buffer>;
78
120
  readonly readPrivateKey: (path: string) => Promise<Buffer>;
79
121
  readonly startServer: (options: HttpsServerOptions) => Promise<RunningServer>;
80
- readonly onStarted: (origin: string) => void;
122
+ readonly onStarted: (origin: string, identity: RuntimeBuildIdentity) => void;
81
123
  readonly waitForShutdown: (server: RunningServer, signal?: AbortSignal) => Promise<void>;
82
124
  readonly debugOutput?: (line: string) => void;
83
125
  readonly installShutdownHandlers?: () => { readonly signal: AbortSignal; readonly dispose: () => void };
@@ -98,6 +140,16 @@ interface RuntimeResources {
98
140
  readonly livenessScheduler: { readonly stop: () => void } | undefined;
99
141
  }
100
142
 
143
+ export type RuntimeLockStatus =
144
+ | { readonly running: false }
145
+ | {
146
+ readonly running: true;
147
+ readonly pid: number;
148
+ readonly identity: RuntimeBuildIdentity | null;
149
+ readonly endpoint: string | null;
150
+ readonly mode: "foreground" | "managed";
151
+ };
152
+
101
153
  const guardedRuntimeFailures = new Set<RuntimeResources>();
102
154
 
103
155
  export interface NodeServerTestHooks {
@@ -163,8 +215,25 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
163
215
  }
164
216
 
165
217
  let runtimeLock: Awaited<ReturnType<typeof acquireRuntimeLock>> | undefined;
218
+ let runtimeIdentity: RuntimeBuildIdentity;
166
219
  try {
167
- runtimeLock = dataDirectory === undefined ? undefined : await acquireRuntimeLock(dataDirectory, "server");
220
+ runtimeIdentity = await loadRuntimeBuildIdentity({
221
+ ...(dependencies.environment.BORG_SERVER_SOURCE_SHA === undefined
222
+ ? {}
223
+ : { sourceSha: dependencies.environment.BORG_SERVER_SOURCE_SHA }),
224
+ ...(dependencies.environment.BORG_SERVER_ARTIFACT_INTEGRITY === undefined
225
+ ? {}
226
+ : { artifactIntegrity: dependencies.environment.BORG_SERVER_ARTIFACT_INTEGRITY }),
227
+ artifactDescriptorPath: fileURLToPath(new URL("../../artifact.json", import.meta.url)),
228
+ });
229
+ runtimeLock = dataDirectory === undefined
230
+ ? undefined
231
+ : await acquireRuntimeLock(
232
+ dataDirectory,
233
+ "server",
234
+ runtimeIdentity,
235
+ dependencies.environment.BORG_SERVER_PROCESS_MODE ?? "foreground",
236
+ );
168
237
  await dependencies.onStartupPhase?.("post-lock");
169
238
  throwIfShutdown(shutdown?.signal);
170
239
  } catch (error) {
@@ -239,6 +308,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
239
308
  ? {}
240
309
  : { handleCoordination: (request) => coordinationApi.handle(request) }),
241
310
  debugLogger,
311
+ runtimeIdentity,
242
312
  });
243
313
  throwIfShutdown(shutdown?.signal);
244
314
  if (authRuntime !== undefined) {
@@ -263,7 +333,8 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
263
333
  let failure: unknown;
264
334
  try {
265
335
  throwIfShutdown(shutdown?.signal);
266
- dependencies.onStarted(running.origin);
336
+ await runtimeLock?.updateOrigin?.(running.origin);
337
+ dependencies.onStarted(running.origin, runtimeIdentity);
267
338
  debugLogger.emit({ event: "lifecycle", action: "listening" });
268
339
  await dependencies.waitForShutdown(running, shutdown?.signal);
269
340
  } catch (error) {
@@ -303,6 +374,13 @@ export function selectServerEnvironment(environment: NodeJS.ProcessEnv): ServerE
303
374
  const maxActivityEntries = environment["BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE"];
304
375
  const maxDatabaseBytes = environment["BORG_SERVER_MAX_DATABASE_BYTES"];
305
376
  const minFreeDiskBytes = environment["BORG_SERVER_MIN_FREE_DISK_BYTES"];
377
+ const sourceSha = environment["BORG_SERVER_SOURCE_SHA"];
378
+ const artifactIntegrity = environment["BORG_SERVER_ARTIFACT_INTEGRITY"];
379
+ const processMode = environment["BORG_SERVER_PROCESS_MODE"];
380
+ const runtimeDirectory = environment["BORG_SERVER_RUNTIME_DIR"];
381
+ if (processMode !== undefined && processMode !== "foreground" && processMode !== "managed") {
382
+ throw new Error("BORG_SERVER_PROCESS_MODE is invalid.");
383
+ }
306
384
  return {
307
385
  ...(keyFile === undefined ? {} : { BORG_SERVER_TLS_KEY_FILE: keyFile }),
308
386
  ...(certificateFile === undefined
@@ -316,6 +394,10 @@ export function selectServerEnvironment(environment: NodeJS.ProcessEnv): ServerE
316
394
  : { BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE: maxActivityEntries }),
317
395
  ...(maxDatabaseBytes === undefined ? {} : { BORG_SERVER_MAX_DATABASE_BYTES: maxDatabaseBytes }),
318
396
  ...(minFreeDiskBytes === undefined ? {} : { BORG_SERVER_MIN_FREE_DISK_BYTES: minFreeDiskBytes }),
397
+ ...(sourceSha === undefined ? {} : { BORG_SERVER_SOURCE_SHA: sourceSha }),
398
+ ...(artifactIntegrity === undefined ? {} : { BORG_SERVER_ARTIFACT_INTEGRITY: artifactIntegrity }),
399
+ ...(processMode === undefined ? {} : { BORG_SERVER_PROCESS_MODE: processMode }),
400
+ ...(runtimeDirectory === undefined ? {} : { BORG_SERVER_RUNTIME_DIR: runtimeDirectory }),
319
401
  };
320
402
  }
321
403
 
@@ -357,6 +439,9 @@ function storageOperatorErrorCode(name: string): OperatorErrorCode {
357
439
 
358
440
  const serverEnvironment = selectServerEnvironment(process.env);
359
441
  const dataDirectory = serverEnvironment.BORG_SERVER_DATA_DIR ?? join(homedir(), ".borg", "server");
442
+ const runtimeDirectory = serverEnvironment.BORG_SERVER_RUNTIME_DIR ?? join(homedir(), ".borg", "server-runtime");
443
+ const credentialFile = join(homedir(), ".borg", "credentials");
444
+ const nodeRuntimeOperator = createNodeRuntimeOperator(runtimeDirectory, dataDirectory);
360
445
  const startOnlyService = createNodeServerService({
361
446
  environment: { ...serverEnvironment, BORG_SERVER_DATA_DIR: dataDirectory },
362
447
  readFile,
@@ -365,8 +450,28 @@ const startOnlyService = createNodeServerService({
365
450
  const running = await startHttpsServer(options);
366
451
  return nodeServerTestHooks?.wrapRunningServer?.(running) ?? running;
367
452
  },
368
- onStarted: (origin) => {
369
- console.error(`Borg server listening on ${origin}`);
453
+ onStarted: (origin, identity) => {
454
+ if (process.stderr.isTTY !== true || serverEnvironment.BORG_SERVER_PROCESS_MODE === "managed") {
455
+ console.error(JSON.stringify({
456
+ status: "running",
457
+ artifact: `borgmcp-server@${identity.package_version}`,
458
+ artifact_integrity: identity.artifact_integrity,
459
+ build_identity: identity.source_sha,
460
+ endpoint: origin,
461
+ mode: serverEnvironment.BORG_SERVER_PROCESS_MODE ?? "foreground",
462
+ data_identity: "available",
463
+ }));
464
+ } else {
465
+ console.error([
466
+ "Starting verified local server in the foreground.",
467
+ `Artifact: borgmcp-server@${identity.package_version} (${identity.artifact_integrity ?? "unavailable"})`,
468
+ `Build identity: ${identity.source_sha ?? "unavailable"}`,
469
+ `Endpoint: ${origin}`,
470
+ "Data and identity: preserved",
471
+ "Ctrl-C stops the foreground process.",
472
+ "Foreground mode does not manage persistence.",
473
+ ].join("\n"));
474
+ }
370
475
  nodeServerTestHooks?.onListening?.(origin);
371
476
  },
372
477
  onStartupPhase: (phase) => nodeServerTestHooks?.onStartupPhase?.(phase) ?? Promise.resolve(),
@@ -382,14 +487,137 @@ const startOnlyService = createNodeServerService({
382
487
  });
383
488
  export const nodeServerService: ServerService = {
384
489
  start: startOnlyService.start,
385
- setup: (options) => setupNodeServerInstallation(
386
- dataDirectory,
387
- resolveSetupBindHost(serverEnvironment),
388
- options,
389
- ),
390
- ...createOfflineCredentialService(dataDirectory),
490
+ setup: async (options) => {
491
+ const bindHost = resolveSetupBindHost(serverEnvironment);
492
+ if ((await inspectRuntimeLock(dataDirectory)).running) throw operatorErrors.RUNTIME_ACTIVE;
493
+ const artifact = await nodeRuntimeOperator.prepareLatest(30_000);
494
+ const result = await setupNodeServerInstallation(
495
+ dataDirectory,
496
+ bindHost,
497
+ options,
498
+ credentialFile,
499
+ );
500
+ if (!("existing" in result)) {
501
+ const { recoveryCredential: _recovery, initialInvitation: _invitation, ...publicResult } = result;
502
+ return {
503
+ ...publicResult,
504
+ artifact: {
505
+ version: artifact.version,
506
+ integrity: artifact.integrity,
507
+ sourceSha: artifact.sourceSha,
508
+ },
509
+ };
510
+ }
511
+ return {
512
+ ...result,
513
+ artifact: {
514
+ version: artifact.version,
515
+ integrity: artifact.integrity,
516
+ sourceSha: artifact.sourceSha,
517
+ },
518
+ };
519
+ },
520
+ status: () => inspectNodeRuntime(dataDirectory, runtimeDirectory),
521
+ update: () => nodeRuntimeOperator.updateLatest(30_000),
522
+ ...createOfflineCredentialService(dataDirectory, credentialFile),
391
523
  };
392
524
 
525
+ function createNodeRuntimeOperator(managedRuntimeDirectory: string, runtimeDataDirectory: string) {
526
+ const platform = process.platform === "darwin" ? "launchd" : "systemd";
527
+ const definition = createManagedServiceDefinition({
528
+ platform,
529
+ nodeExecutable: process.execPath,
530
+ runtimeRoot: managedRuntimeDirectory,
531
+ dataDirectory: runtimeDataDirectory,
532
+ definitionPath: platform === "launchd"
533
+ ? join(homedir(), "Library", "LaunchAgents", "ai.borgmcp.server.plist")
534
+ : join(homedir(), ".config", "systemd", "user", "ai.borgmcp.server.service"),
535
+ ...(platform === "launchd" ? { launchdDomain: `gui/${process.getuid?.() ?? 0}` } : {}),
536
+ });
537
+ const run = async (command: readonly [string, ...string[]], signal: AbortSignal): Promise<void> => {
538
+ const [executable, ...args] = command;
539
+ await promisify(execFile)(executable, args, {
540
+ signal,
541
+ timeout: 20_000,
542
+ maxBuffer: 64 * 1024,
543
+ encoding: "utf8",
544
+ });
545
+ };
546
+ const lifecycle = createRuntimeLifecycle({
547
+ unpack: createUnixNpmArtifactUnpacker(),
548
+ restart: (signal) => run(definition.restart, signal),
549
+ stop: (signal) => run(definition.stop, signal),
550
+ probe: (signal) => waitForRuntimeIdentity(runtimeDataDirectory, signal),
551
+ });
552
+ return createRuntimeOperator({
553
+ runtimeRoot: managedRuntimeDirectory,
554
+ artifacts: createRegistryArtifactSource(),
555
+ lifecycle,
556
+ isRunning: async () => {
557
+ const status = await inspectRuntimeLock(runtimeDataDirectory);
558
+ if (status.running && status.mode !== "managed") {
559
+ throw new Error("Foreground runtime must be stopped before artifact activation.");
560
+ }
561
+ return status.running;
562
+ },
563
+ });
564
+ }
565
+
566
+ async function waitForRuntimeIdentity(
567
+ runtimeDataDirectory: string,
568
+ signal: AbortSignal,
569
+ ): Promise<RuntimeBuildIdentity> {
570
+ while (!signal.aborted) {
571
+ try {
572
+ const status = await inspectRuntimeLock(runtimeDataDirectory);
573
+ if (status.running && status.identity !== null) return status.identity;
574
+ } catch (error) {
575
+ if (error !== operatorErrors.RUNTIME_LOCK_STALE) throw error;
576
+ }
577
+ await new Promise<void>((resolve) => {
578
+ const timer = setTimeout(resolve, 50);
579
+ signal.addEventListener("abort", () => {
580
+ clearTimeout(timer);
581
+ resolve();
582
+ }, { once: true });
583
+ });
584
+ }
585
+ throw new Error("Managed runtime identity probe was cancelled.");
586
+ }
587
+
588
+ export async function inspectNodeRuntime(
589
+ runtimeDataDirectory: string,
590
+ managedRuntimeDirectory: string,
591
+ ): Promise<ServerRuntimeStatus> {
592
+ const [lock, activeArtifact, dataIdentity] = await Promise.all([
593
+ inspectRuntimeLock(runtimeDataDirectory),
594
+ inspectActiveRuntimeArtifact(managedRuntimeDirectory),
595
+ hasDataIdentity(runtimeDataDirectory),
596
+ ]);
597
+ const identity = lock.running ? lock.identity : null;
598
+ const artifact = identity?.artifact_integrity === null || identity === null
599
+ ? activeArtifact === null ? null : { version: activeArtifact.version, integrity: activeArtifact.integrity }
600
+ : { version: identity.package_version, integrity: identity.artifact_integrity };
601
+ return Object.freeze({
602
+ status: lock.running ? "running" : "stopped",
603
+ artifact,
604
+ buildIdentity: identity?.source_sha ?? null,
605
+ endpoint: lock.running ? lock.endpoint : null,
606
+ mode: lock.running ? lock.mode : "stopped",
607
+ dataIdentity,
608
+ });
609
+ }
610
+
611
+ async function hasDataIdentity(directory: string): Promise<"available" | "unavailable"> {
612
+ try {
613
+ const metadata = await lstat(join(directory, "server.json"));
614
+ return metadata.isFile() && !metadata.isSymbolicLink() ? "available" : "unavailable";
615
+ } catch (error) {
616
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "unavailable";
617
+ throw error;
618
+ }
619
+ }
620
+
393
621
  const managedInstallationFiles = Object.freeze([
394
622
  "borg.db",
395
623
  "borg.db-wal",
@@ -407,18 +635,38 @@ export async function setupNodeServerInstallation(
407
635
  setupDataDirectory: string,
408
636
  bindHost: string,
409
637
  options: SetupOptions,
410
- ): Promise<BootstrapResult> {
638
+ credentialRoot?: string,
639
+ ): Promise<BootstrapResult | { readonly existing: true }> {
411
640
  const directory = await preparePrivateDataDirectory(setupDataDirectory);
412
641
  const runtimeLock = await acquireRuntimeLock(directory);
413
642
  let invitationLock: RuntimeLock | undefined;
414
643
  try {
415
644
  invitationLock = await acquireInvitationMintLock(directory);
416
645
  const existing = await inspectManagedInstallation(directory);
417
- if (existing.length !== 0 && !options.reinitialize) throw operatorErrors.INSTALLATION_EXISTS;
646
+ if (existing.length !== 0 && !options.reinitialize) {
647
+ const names = new Set(existing.map((path) => basename(path)));
648
+ const complete = [
649
+ "borg.db",
650
+ "credential-digest.key",
651
+ "ca.crt",
652
+ "server.key",
653
+ "server.crt",
654
+ "server.json",
655
+ ].every((name) => names.has(name));
656
+ if (!complete) throw operatorErrors.INSTALLATION_EXISTS;
657
+ return Object.freeze({ existing: true });
658
+ }
418
659
  if (options.reinitialize) {
419
660
  for (const path of existing) await unlink(path);
420
661
  }
421
- return await bootstrapServer(directory, bindHost);
662
+ return await bootstrapServer(
663
+ directory,
664
+ bindHost,
665
+ () => new Date(),
666
+ credentialRoot === undefined
667
+ ? async () => undefined
668
+ : (record) => writePortableServerCredential(credentialRoot, record),
669
+ );
422
670
  } finally {
423
671
  if (invitationLock === undefined) await runtimeLock.release();
424
672
  else await invitationLock.release().finally(() => runtimeLock.release());
@@ -452,9 +700,10 @@ function resolveSetupBindHost(environment: ServerEnvironment): string {
452
700
 
453
701
  export function createOfflineCredentialService(
454
702
  offlineDataDirectory: string,
703
+ credentialRoot?: string,
455
704
  ): Pick<Required<ServerService>,
456
705
  "rotateClient" | "revokeClient" | "grantClient" | "ungrantClient" |
457
- "createClientInvitation" | "replaceOwnerInvitation"
706
+ "createClientInvitation" | "replaceOwnerInvitation" | "invite"
458
707
  > {
459
708
  const withAuthority = async <T>(operation: (
460
709
  authority: CredentialAuthority,
@@ -478,7 +727,9 @@ export function createOfflineCredentialService(
478
727
  else await invitationLock.release().finally(() => runtimeLock.release());
479
728
  }
480
729
  };
481
- const withInvitationAuthority = async <T>(operation: (authority: CredentialAuthority) => T): Promise<T> => {
730
+ const withInvitationAuthority = async <T>(
731
+ operation: (authority: CredentialAuthority) => T | Promise<T>,
732
+ ): Promise<T> => {
482
733
  const invitationLock = await acquireInvitationMintLock(offlineDataDirectory);
483
734
  let offlineRuntimeLock: RuntimeLock | undefined;
484
735
  let runtime: Awaited<ReturnType<typeof openStore>> | undefined;
@@ -493,7 +744,7 @@ export function createOfflineCredentialService(
493
744
  const digestKey = await loadDigestKey(join(offlineDataDirectory, "credential-digest.key"));
494
745
  digester = new CredentialDigester(digestKey);
495
746
  digestKey.fill(0);
496
- return operation(new CredentialAuthority(runtime.credentials, digester));
747
+ return await operation(new CredentialAuthority(runtime.credentials, digester));
497
748
  } catch (error) {
498
749
  if (error instanceof MigrationCompatibilityError) throw operatorErrors.INVITATION_SCHEMA_MISMATCH;
499
750
  if (isSqliteContention(error)) throw operatorErrors.INVITATION_CONTENTION;
@@ -542,6 +793,22 @@ export function createOfflineCredentialService(
542
793
  if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
543
794
  return invitation;
544
795
  }),
796
+ invite: () => withInvitationAuthority(async (authority) => {
797
+ if (credentialRoot === undefined) throw new Error("Local owner credential store is unavailable.");
798
+ const config = JSON.parse((await readFile(join(offlineDataDirectory, "server.json"))).toString("utf8")) as {
799
+ bind_host?: unknown;
800
+ ca_spki_sha256?: unknown;
801
+ };
802
+ if (typeof config.bind_host !== "string" || typeof config.ca_spki_sha256 !== "string") {
803
+ throw new Error("Server identity is invalid.");
804
+ }
805
+ const origin = `https://${config.bind_host === "::1" ? "[::1]" : config.bind_host}:${DEFAULT_PORT}`;
806
+ const trustIdentity = `spki-sha256:${config.ca_spki_sha256}`;
807
+ const record = await readPortableServerCredential(credentialRoot, origin, trustIdentity);
808
+ const invitation = authority.createInvitationForOwnerCredential(record.credential, 15 * 60_000);
809
+ if (invitation === null) throw new Error("Local owner credential is invalid.");
810
+ return invitation;
811
+ }),
545
812
  };
546
813
  }
547
814
 
@@ -560,6 +827,7 @@ function parseInvitationCubeSelector(
560
827
 
561
828
  interface RuntimeLock {
562
829
  readonly release: () => Promise<void>;
830
+ readonly updateOrigin?: (origin: string) => Promise<void>;
563
831
  }
564
832
 
565
833
  async function teardownRuntime(resources: RuntimeResources): Promise<void> {
@@ -624,19 +892,44 @@ function fatalTeardownError(primary: unknown, cleanup: unknown): FatalTeardownEr
624
892
  export async function acquireRuntimeLock(
625
893
  runtimeDataDirectory: string,
626
894
  purpose: "server" | "exclusive-admin" = "exclusive-admin",
895
+ identity?: RuntimeBuildIdentity,
896
+ mode: "foreground" | "managed" = "foreground",
627
897
  ): Promise<RuntimeLock> {
628
898
  const path = join(runtimeDataDirectory, "runtime.lock");
629
899
  const nonce = randomUUID();
630
900
  try {
631
901
  const handle = await open(path, "wx", 0o600);
902
+ const record: {
903
+ readonly pid: number;
904
+ readonly nonce: string;
905
+ readonly purpose: "server" | "exclusive-admin";
906
+ readonly mode: "foreground" | "managed";
907
+ readonly runtime_identity?: RuntimeBuildIdentity;
908
+ endpoint?: string;
909
+ } = {
910
+ pid: process.pid,
911
+ nonce,
912
+ purpose,
913
+ mode,
914
+ ...(identity === undefined ? {} : { runtime_identity: identity }),
915
+ };
632
916
  try {
633
- await handle.writeFile(JSON.stringify({ pid: process.pid, nonce, purpose }));
917
+ await handle.writeFile(JSON.stringify(record));
634
918
  } catch (error) {
635
919
  await handle.close();
636
920
  await unlink(path).catch(() => undefined);
637
921
  throw error;
638
922
  }
639
923
  return {
924
+ updateOrigin: async (origin) => {
925
+ if (!/^https:\/\/(?:\[[0-9a-f:]+\]|[0-9.]+):[0-9]{1,5}$/u.test(origin)) {
926
+ throw new Error("Runtime endpoint is invalid.");
927
+ }
928
+ record.endpoint = origin;
929
+ await handle.truncate(0);
930
+ await handle.write(JSON.stringify(record), 0, "utf8");
931
+ await handle.sync();
932
+ },
640
933
  release: async () => {
641
934
  await handle.close();
642
935
  try {
@@ -666,6 +959,94 @@ export async function acquireRuntimeLock(
666
959
  }
667
960
  }
668
961
 
962
+ export async function inspectRuntimeLock(runtimeDataDirectory: string): Promise<RuntimeLockStatus> {
963
+ const path = join(runtimeDataDirectory, "runtime.lock");
964
+ let metadata;
965
+ try {
966
+ metadata = await lstat(path);
967
+ } catch (error) {
968
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return Object.freeze({ running: false });
969
+ throw error;
970
+ }
971
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0 ||
972
+ metadata.size > 8 * 1024) {
973
+ throw operatorErrors.RUNTIME_LOCK_UNSAFE;
974
+ }
975
+ try {
976
+ const value = JSON.parse(await readFile(path, "utf8")) as {
977
+ pid?: unknown;
978
+ purpose?: unknown;
979
+ runtime_identity?: unknown;
980
+ endpoint?: unknown;
981
+ mode?: unknown;
982
+ };
983
+ if (!Number.isSafeInteger(value.pid) || (value.pid as number) <= 0 || value.purpose !== "server" ||
984
+ (value.endpoint !== undefined &&
985
+ (typeof value.endpoint !== "string" || !isRuntimeEndpoint(value.endpoint))) ||
986
+ (value.mode !== "foreground" && value.mode !== "managed")) {
987
+ throw new Error();
988
+ }
989
+ const pid = value.pid as number;
990
+ if (!processIsAlive(pid)) throw operatorErrors.RUNTIME_LOCK_STALE;
991
+ return Object.freeze({
992
+ running: true,
993
+ pid,
994
+ identity: decodeRuntimeLockIdentity(value.runtime_identity),
995
+ endpoint: value.endpoint ?? null,
996
+ mode: value.mode,
997
+ });
998
+ } catch (error) {
999
+ if (error === operatorErrors.RUNTIME_LOCK_STALE) throw error;
1000
+ throw operatorErrors.RUNTIME_LOCK_INVALID;
1001
+ }
1002
+ }
1003
+
1004
+ function isRuntimeEndpoint(value: string): boolean {
1005
+ if (value.length > 512) return false;
1006
+ try {
1007
+ const endpoint = new URL(value);
1008
+ if (endpoint.protocol !== "https:" || endpoint.username !== "" || endpoint.password !== "" ||
1009
+ endpoint.pathname !== "/" || endpoint.search !== "" || endpoint.hash !== "") return false;
1010
+ const host = endpoint.hostname.startsWith("[") && endpoint.hostname.endsWith("]")
1011
+ ? endpoint.hostname.slice(1, -1)
1012
+ : endpoint.hostname;
1013
+ resolveBindOptions({ host, port: Number(endpoint.port || "443"), lanConsent: true });
1014
+ return true;
1015
+ } catch {
1016
+ return false;
1017
+ }
1018
+ }
1019
+
1020
+ function decodeRuntimeLockIdentity(value: unknown): RuntimeBuildIdentity | null {
1021
+ if (value === undefined) return null;
1022
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error();
1023
+ const identity = value as Record<string, unknown>;
1024
+ const packageVersion = identity["package_version"];
1025
+ const sourceSha = identity["source_sha"];
1026
+ const artifactIntegrity = identity["artifact_integrity"];
1027
+ const protocolVersion = identity["protocol_version"];
1028
+ const startedAt = identity["started_at"];
1029
+ if (typeof packageVersion !== "string" ||
1030
+ !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(packageVersion) ||
1031
+ (sourceSha !== null &&
1032
+ (typeof sourceSha !== "string" || !/^[0-9a-f]{40}$/u.test(sourceSha))) ||
1033
+ (artifactIntegrity !== null &&
1034
+ (typeof artifactIntegrity !== "string" ||
1035
+ !/^sha512-[A-Za-z0-9+/]{86}==$/u.test(artifactIntegrity))) ||
1036
+ typeof protocolVersion !== "string" || protocolVersion.length < 1 || protocolVersion.length > 32 ||
1037
+ typeof startedAt !== "string" || startedAt.length > 64 ||
1038
+ !Number.isFinite(Date.parse(startedAt)) || new Date(startedAt).toISOString() !== startedAt) {
1039
+ throw new Error();
1040
+ }
1041
+ return Object.freeze({
1042
+ package_version: packageVersion,
1043
+ source_sha: sourceSha as string | null,
1044
+ artifact_integrity: artifactIntegrity as string | null,
1045
+ protocol_version: protocolVersion,
1046
+ started_at: startedAt,
1047
+ });
1048
+ }
1049
+
669
1050
  export async function acquireInvitationMintLock(runtimeDataDirectory: string): Promise<RuntimeLock> {
670
1051
  const path = join(runtimeDataDirectory, "invitation-mint.lock");
671
1052
  const nonce = randomUUID();
package/src/store.ts CHANGED
@@ -258,6 +258,7 @@ export interface CredentialStore {
258
258
  readonly findClientCredential: (lookup: Buffer) => StoredSecretDigest | null;
259
259
  readonly clientExists: (clientId: string) => boolean;
260
260
  readonly clientIsActive: (clientId: string) => boolean;
261
+ readonly clientHasServerCapability: (clientId: string, capability: "create_cube") => boolean;
261
262
  readonly findDroneSessionCredential: (lookup: Buffer) => StoredDroneSessionDigest | null;
262
263
  readonly findActiveDroneSessionExpiry: (sessionId: string) => string | null;
263
264
  readonly rotateClientCredential: (input: {
@@ -2576,6 +2577,15 @@ class SqliteCredentialStore implements CredentialStore {
2576
2577
  ).get(clientId) !== undefined;
2577
2578
  }
2578
2579
 
2580
+ clientHasServerCapability(clientId: string, capability: "create_cube"): boolean {
2581
+ assertCanonicalUuid(clientId, "Client id");
2582
+ return this.#database.prepare(`
2583
+ SELECT 1 FROM client_server_capabilities AS capability
2584
+ JOIN clients AS client ON client.id = capability.client_id
2585
+ WHERE capability.client_id = ? AND capability.capability = ? AND client.revoked_at IS NULL
2586
+ `).get(clientId, capability) !== undefined;
2587
+ }
2588
+
2579
2589
  findDroneSessionCredential(lookup: Buffer): StoredDroneSessionDigest | null {
2580
2590
  validateLookup(lookup);
2581
2591
  const row = this.#database.prepare(`