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
@@ -0,0 +1,106 @@
1
+ const canonicalUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
2
+ const principalBrand: unique symbol = Symbol("server-derived-principal");
3
+ const derivedPrincipals = new WeakSet<object>();
4
+
5
+ interface ServerDerivedPrincipal {
6
+ readonly [principalBrand]: true;
7
+ }
8
+
9
+ export interface OperatorPrincipal extends ServerDerivedPrincipal {
10
+ readonly kind: "operator";
11
+ readonly id: string;
12
+ }
13
+
14
+ export interface ClientPrincipal extends ServerDerivedPrincipal {
15
+ readonly kind: "client";
16
+ readonly id: string;
17
+ }
18
+
19
+ export interface DroneSessionPrincipal extends ServerDerivedPrincipal {
20
+ readonly kind: "drone-session";
21
+ readonly id: string;
22
+ readonly clientId: string;
23
+ readonly cubeId: string;
24
+ readonly droneId: string;
25
+ }
26
+
27
+ export interface DroneSessionPrincipalInput {
28
+ readonly id: string;
29
+ readonly clientId: string;
30
+ readonly cubeId: string;
31
+ readonly droneId: string;
32
+ }
33
+
34
+ export type Principal = OperatorPrincipal | ClientPrincipal | DroneSessionPrincipal;
35
+
36
+ export function operatorPrincipal(id: string): OperatorPrincipal {
37
+ assertCanonicalUuid(id, "Principal id");
38
+ return branded({ kind: "operator", id });
39
+ }
40
+
41
+ export function clientPrincipal(id: string): ClientPrincipal {
42
+ assertCanonicalUuid(id, "Principal id");
43
+ return branded({ kind: "client", id });
44
+ }
45
+
46
+ export function droneSessionPrincipal(
47
+ input: DroneSessionPrincipalInput,
48
+ ): DroneSessionPrincipal {
49
+ assertCanonicalUuid(input.id, "Principal id");
50
+ assertCanonicalUuid(input.clientId, "Client id");
51
+ assertCanonicalUuid(input.cubeId, "Cube id");
52
+ assertCanonicalUuid(input.droneId, "Drone id");
53
+ return branded({ kind: "drone-session", ...input });
54
+ }
55
+
56
+ export function assertServerDerivedPrincipal(value: unknown): asserts value is Principal {
57
+ if (typeof value !== "object" || value === null || !derivedPrincipals.has(value)) {
58
+ throw new Error("Principal must be created by the server authentication boundary.");
59
+ }
60
+ const descriptors = Object.getOwnPropertyDescriptors(value);
61
+ const kind = frozenDataValue(descriptors, "kind");
62
+ const expectedKeys = kind === "drone-session"
63
+ ? ["kind", "id", "clientId", "cubeId", "droneId"]
64
+ : kind === "operator" || kind === "client" ? ["kind", "id"] : [];
65
+ const ownNames = Object.getOwnPropertyNames(value);
66
+ const brand = Object.getOwnPropertyDescriptor(value, principalBrand);
67
+ if (Object.getPrototypeOf(value) !== Object.prototype || !Object.isFrozen(value) ||
68
+ !Object.hasOwn(value, principalBrand) || brand?.value !== true || brand.get !== undefined ||
69
+ brand.set !== undefined || brand.enumerable || brand.configurable || brand.writable ||
70
+ Object.getOwnPropertySymbols(value).length !== 1 || ownNames.length !== expectedKeys.length ||
71
+ expectedKeys.some((key) => !Object.hasOwn(value, key)) ||
72
+ ownNames.some((key) => !expectedKeys.includes(key)) ||
73
+ expectedKeys.some((key) => frozenDataValue(descriptors, key) === undefined)) {
74
+ throw new Error("Principal must be created by the server authentication boundary.");
75
+ }
76
+ for (const key of expectedKeys.filter((key) => key !== "kind")) {
77
+ if (!canonicalUuid.test(frozenDataValue(descriptors, key)!)) {
78
+ throw new Error("Principal must be created by the server authentication boundary.");
79
+ }
80
+ }
81
+ }
82
+
83
+ export function assertCanonicalUuid(value: string, label: string): void {
84
+ if (!canonicalUuid.test(value)) {
85
+ throw new Error(`${label} must be a canonical UUID.`);
86
+ }
87
+ }
88
+
89
+ function branded<T extends object>(value: T): T & ServerDerivedPrincipal {
90
+ Object.defineProperty(value, principalBrand, { value: true });
91
+ const principal = Object.freeze(value) as T & ServerDerivedPrincipal;
92
+ derivedPrincipals.add(principal);
93
+ return principal;
94
+ }
95
+
96
+ function frozenDataValue(
97
+ descriptors: PropertyDescriptorMap,
98
+ key: string,
99
+ ): string | undefined {
100
+ const descriptor = descriptors[key];
101
+ return descriptor !== undefined && typeof descriptor.value === "string" &&
102
+ descriptor.get === undefined && descriptor.set === undefined && descriptor.enumerable === true &&
103
+ descriptor.configurable === false && descriptor.writable === false
104
+ ? descriptor.value
105
+ : undefined;
106
+ }
@@ -0,0 +1,32 @@
1
+ import type { ProtocolInfoDocument, ServiceLimits } from "./https-server.js";
2
+
3
+ export function createPart2ProtocolInfo(limits: ServiceLimits): ProtocolInfoDocument {
4
+ return {
5
+ protocol_version: "1",
6
+ package: {
7
+ name: "borgmcp-shared",
8
+ version: "0.3.0",
9
+ },
10
+ capabilities: [
11
+ "coordination.core",
12
+ "auth.bearer",
13
+ "auth.revocation",
14
+ "auth.retry-safe-enrollment",
15
+ "scope.cube-isolation",
16
+ "transport.tls",
17
+ "authority.no-cloud-fallback",
18
+ "log.cursor",
19
+ "stream.sse",
20
+ "stream.replay",
21
+ "acks",
22
+ "claims",
23
+ "decisions",
24
+ ],
25
+ limits: {
26
+ max_request_bytes: limits.maxRequestBodyBytes,
27
+ max_log_message_bytes: 10_240,
28
+ max_read_page_size: 500,
29
+ max_replay_page_size: 200,
30
+ },
31
+ };
32
+ }
package/src/service.ts ADDED
@@ -0,0 +1,525 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, open, readFile, unlink } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ bootstrapServer,
8
+ loadDigestKey,
9
+ loadTlsPrivateKey,
10
+ type BootstrapResult,
11
+ } from "./bootstrap.js";
12
+ import { CredentialAuthority, CredentialDigester } from "./credentials.js";
13
+ import { CoordinationApi } from "./coordination-api.js";
14
+ import { createEnrollmentExchange } from "./enrollment.js";
15
+ import {
16
+ DEFAULT_SERVICE_LIMITS,
17
+ startHttpsServer,
18
+ type HttpsServerOptions,
19
+ type RunningServer,
20
+ } from "./https-server.js";
21
+ import { createPart2ProtocolInfo } from "./protocol-draft.js";
22
+ import { resolveBindOptions } from "./network-policy.js";
23
+ import { operatorErrors, type OperatorErrorCode } from "./operator-error.js";
24
+ import { parseStartOptions } from "./start-options.js";
25
+ import { DEFAULT_STORAGE_LIMITS, openStore, type StorageLimits } from "./store.js";
26
+ import type { CubeAccess } from "./store.js";
27
+
28
+ export interface ServerService {
29
+ readonly start: (args: readonly string[]) => Promise<void>;
30
+ readonly setup?: () => Promise<BootstrapResult>;
31
+ readonly rotateClient?: (clientId: string) => Promise<string>;
32
+ readonly revokeClient?: (clientId: string) => Promise<void>;
33
+ readonly grantClient?: (clientId: string, cubeId: string, access: CubeAccess) => Promise<void>;
34
+ readonly ungrantClient?: (clientId: string, cubeId: string) => Promise<void>;
35
+ readonly createClientInvitation?: (recoveryCredential: string) => Promise<string>;
36
+ readonly replaceOwnerInvitation?: (recoveryCredential: string) => Promise<string>;
37
+ }
38
+
39
+ export interface ServerEnvironment {
40
+ readonly BORG_SERVER_TLS_KEY_FILE?: string;
41
+ readonly BORG_SERVER_TLS_CERT_FILE?: string;
42
+ readonly BORG_SERVER_TLS_CA_FILE?: string;
43
+ readonly BORG_SERVER_DATA_DIR?: string;
44
+ readonly BORG_SERVER_BIND_HOST?: string;
45
+ readonly BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE?: string;
46
+ readonly BORG_SERVER_MAX_DATABASE_BYTES?: string;
47
+ readonly BORG_SERVER_MIN_FREE_DISK_BYTES?: string;
48
+ }
49
+
50
+ interface ServiceDependencies {
51
+ readonly environment: ServerEnvironment;
52
+ readonly readFile: (path: string) => Promise<Buffer>;
53
+ readonly readPrivateKey: (path: string) => Promise<Buffer>;
54
+ readonly startServer: (options: HttpsServerOptions) => Promise<RunningServer>;
55
+ readonly onStarted: (origin: string) => void;
56
+ readonly waitForShutdown: (server: RunningServer, signal?: AbortSignal) => Promise<void>;
57
+ readonly installShutdownHandlers?: () => { readonly signal: AbortSignal; readonly dispose: () => void };
58
+ readonly openStore?: typeof openStore;
59
+ readonly onStartupPhase?: (
60
+ phase: "pre-lock" | "post-lock" | "pre-listen",
61
+ ) => Promise<void>;
62
+ }
63
+
64
+ interface RuntimeResources {
65
+ readonly running: RunningServer | undefined;
66
+ readonly authRuntime: Awaited<ReturnType<typeof openStore>> | undefined;
67
+ readonly digester: CredentialDigester | undefined;
68
+ readonly runtimeLock: RuntimeLock | undefined;
69
+ }
70
+
71
+ const guardedRuntimeFailures = new Set<RuntimeResources>();
72
+
73
+ export interface NodeServerTestHooks {
74
+ readonly onStartupPhase?: (phase: "pre-lock" | "post-lock" | "pre-listen") => Promise<void>;
75
+ readonly onSignalObserved?: () => void;
76
+ readonly onListening?: (origin: string) => void;
77
+ readonly wrapRunningServer?: (running: RunningServer) => RunningServer;
78
+ }
79
+
80
+ let nodeServerTestHooks: NodeServerTestHooks | undefined;
81
+
82
+ export function installNodeServerTestHooks(hooks: NodeServerTestHooks): () => void {
83
+ if (nodeServerTestHooks !== undefined) throw new Error("Node server test hooks are already installed.");
84
+ nodeServerTestHooks = Object.freeze({ ...hooks });
85
+ return () => {
86
+ nodeServerTestHooks = undefined;
87
+ };
88
+ }
89
+
90
+ export function createNodeServerService(dependencies: ServiceDependencies): ServerService {
91
+ return {
92
+ async start(args): Promise<void> {
93
+ const shutdown = dependencies.installShutdownHandlers?.();
94
+ let bind: ReturnType<typeof parseStartOptions>;
95
+ let dataDirectory: string | undefined;
96
+ let storageLimits: StorageLimits;
97
+ try {
98
+ throwIfShutdown(shutdown?.signal);
99
+ await dependencies.onStartupPhase?.("pre-lock");
100
+ throwIfShutdown(shutdown?.signal);
101
+ bind = parseStartOptions(args);
102
+ const bindMode = resolveBindOptions(bind).mode;
103
+ dataDirectory = dependencies.environment.BORG_SERVER_DATA_DIR;
104
+ storageLimits = resolveStorageLimits(dependencies.environment);
105
+ if (bindMode === "lan" && dataDirectory !== undefined) {
106
+ await assertLanCaKeyOffline(dataDirectory);
107
+ throwIfShutdown(shutdown?.signal);
108
+ }
109
+ } catch (error) {
110
+ shutdown?.dispose();
111
+ if (error instanceof ShutdownRequestedError) return;
112
+ throw error;
113
+ }
114
+ const keyPath = dependencies.environment.BORG_SERVER_TLS_KEY_FILE ??
115
+ (dataDirectory === undefined ? undefined : join(dataDirectory, "server.key"));
116
+ const certificatePath = dependencies.environment.BORG_SERVER_TLS_CERT_FILE ??
117
+ (dataDirectory === undefined ? undefined : join(dataDirectory, "server.crt"));
118
+ const caPath = dependencies.environment.BORG_SERVER_TLS_CA_FILE ??
119
+ (dataDirectory === undefined ? undefined : join(dataDirectory, "ca.crt"));
120
+ if (keyPath === undefined || certificatePath === undefined) {
121
+ shutdown?.dispose();
122
+ throw operatorErrors.SERVER_FILES_MISSING;
123
+ }
124
+
125
+ let runtimeLock: Awaited<ReturnType<typeof acquireRuntimeLock>> | undefined;
126
+ try {
127
+ runtimeLock = dataDirectory === undefined ? undefined : await acquireRuntimeLock(dataDirectory);
128
+ await dependencies.onStartupPhase?.("post-lock");
129
+ throwIfShutdown(shutdown?.signal);
130
+ } catch (error) {
131
+ await runtimeLock?.release().catch(() => undefined);
132
+ shutdown?.dispose();
133
+ if (error instanceof ShutdownRequestedError) return;
134
+ throw error;
135
+ }
136
+ let running: RunningServer | undefined;
137
+ let key: Buffer | undefined;
138
+ try {
139
+ throwIfShutdown(shutdown?.signal);
140
+ key = await dependencies.readPrivateKey(keyPath);
141
+ throwIfShutdown(shutdown?.signal);
142
+ } catch (error) {
143
+ key?.fill(0);
144
+ await runtimeLock?.release().catch(() => undefined);
145
+ shutdown?.dispose();
146
+ if (error instanceof ShutdownRequestedError) return;
147
+ throw error;
148
+ }
149
+ if (key === undefined) throw new Error("TLS private key is unavailable.");
150
+ let authRuntime: Awaited<ReturnType<typeof openStore>> | undefined;
151
+ let digester: CredentialDigester | undefined;
152
+ try {
153
+ const cert = await dependencies.readFile(certificatePath);
154
+ throwIfShutdown(shutdown?.signal);
155
+ const ca = caPath === undefined ? undefined : await dependencies.readFile(caPath);
156
+ throwIfShutdown(shutdown?.signal);
157
+ let authority: CredentialAuthority | undefined;
158
+ let coordinationApi: CoordinationApi | undefined;
159
+ if (dataDirectory !== undefined) {
160
+ authRuntime = await (dependencies.openStore ?? openStore)({
161
+ path: join(dataDirectory, "borg.db"),
162
+ storageLimits,
163
+ });
164
+ throwIfShutdown(shutdown?.signal);
165
+ const digestKey = await loadDigestKey(join(dataDirectory, "credential-digest.key"));
166
+ try {
167
+ throwIfShutdown(shutdown?.signal);
168
+ digester = new CredentialDigester(digestKey);
169
+ } finally {
170
+ digestKey.fill(0);
171
+ }
172
+ authority = new CredentialAuthority(authRuntime.credentials, digester);
173
+ coordinationApi = new CoordinationApi(authRuntime, authority);
174
+ }
175
+ await dependencies.onStartupPhase?.("pre-listen");
176
+ throwIfShutdown(shutdown?.signal);
177
+ running = await dependencies.startServer({
178
+ bind,
179
+ tls: { key, cert, ...(ca === undefined ? {} : { ca }) },
180
+ 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
+ ...(authority === undefined
188
+ ? {}
189
+ : { exchangeEnrollment: createEnrollmentExchange(authority) }),
190
+ ...(authority === undefined
191
+ ? {}
192
+ : {
193
+ authorizeCoordination: async (authorization: string | undefined) => {
194
+ return authority.authenticateStatus(authorization);
195
+ },
196
+ }),
197
+ ...(coordinationApi === undefined
198
+ ? {}
199
+ : { handleCoordination: (request) => coordinationApi.handle(request) }),
200
+ });
201
+ throwIfShutdown(shutdown?.signal);
202
+ } catch (error) {
203
+ try {
204
+ await teardownRuntime({ running, authRuntime, digester, runtimeLock });
205
+ } catch (cleanupError) {
206
+ shutdown?.dispose();
207
+ throw fatalTeardownError(error, cleanupError);
208
+ }
209
+ shutdown?.dispose();
210
+ if (error instanceof ShutdownRequestedError) return;
211
+ throw error;
212
+ } finally {
213
+ key.fill(0);
214
+ }
215
+ let failed = false;
216
+ let failure: unknown;
217
+ try {
218
+ throwIfShutdown(shutdown?.signal);
219
+ dependencies.onStarted(running.origin);
220
+ await dependencies.waitForShutdown(running, shutdown?.signal);
221
+ } catch (error) {
222
+ failed = true;
223
+ failure = error;
224
+ }
225
+ try {
226
+ await teardownRuntime({ running, authRuntime, digester, runtimeLock });
227
+ } catch (cleanupError) {
228
+ shutdown?.dispose();
229
+ throw fatalTeardownError(failed ? failure : undefined, cleanupError);
230
+ }
231
+ shutdown?.dispose();
232
+ if (failure instanceof ShutdownRequestedError) return;
233
+ if (failed) throw failure;
234
+ },
235
+ };
236
+ }
237
+
238
+ export async function assertLanCaKeyOffline(runtimeDataDirectory: string): Promise<void> {
239
+ try {
240
+ await lstat(join(runtimeDataDirectory, "ca.key"));
241
+ } catch (error) {
242
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
243
+ throw error;
244
+ }
245
+ throw operatorErrors.LAN_CA_KEY_ONLINE;
246
+ }
247
+
248
+ export function selectServerEnvironment(environment: NodeJS.ProcessEnv): ServerEnvironment {
249
+ const keyFile = environment["BORG_SERVER_TLS_KEY_FILE"];
250
+ const certificateFile = environment["BORG_SERVER_TLS_CERT_FILE"];
251
+ const caFile = environment["BORG_SERVER_TLS_CA_FILE"];
252
+ const dataDirectory = environment["BORG_SERVER_DATA_DIR"];
253
+ const bindHost = environment["BORG_SERVER_BIND_HOST"];
254
+ const maxActivityEntries = environment["BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE"];
255
+ const maxDatabaseBytes = environment["BORG_SERVER_MAX_DATABASE_BYTES"];
256
+ const minFreeDiskBytes = environment["BORG_SERVER_MIN_FREE_DISK_BYTES"];
257
+ return {
258
+ ...(keyFile === undefined ? {} : { BORG_SERVER_TLS_KEY_FILE: keyFile }),
259
+ ...(certificateFile === undefined
260
+ ? {}
261
+ : { BORG_SERVER_TLS_CERT_FILE: certificateFile }),
262
+ ...(caFile === undefined ? {} : { BORG_SERVER_TLS_CA_FILE: caFile }),
263
+ ...(dataDirectory === undefined ? {} : { BORG_SERVER_DATA_DIR: dataDirectory }),
264
+ ...(bindHost === undefined ? {} : { BORG_SERVER_BIND_HOST: bindHost }),
265
+ ...(maxActivityEntries === undefined
266
+ ? {}
267
+ : { BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE: maxActivityEntries }),
268
+ ...(maxDatabaseBytes === undefined ? {} : { BORG_SERVER_MAX_DATABASE_BYTES: maxDatabaseBytes }),
269
+ ...(minFreeDiskBytes === undefined ? {} : { BORG_SERVER_MIN_FREE_DISK_BYTES: minFreeDiskBytes }),
270
+ };
271
+ }
272
+
273
+ export function resolveStorageLimits(environment: ServerEnvironment): StorageLimits {
274
+ return {
275
+ maxActivityEntriesPerCube: positiveEnvironmentInteger(
276
+ environment.BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE,
277
+ DEFAULT_STORAGE_LIMITS.maxActivityEntriesPerCube,
278
+ "BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE",
279
+ ),
280
+ maxDatabaseBytes: positiveEnvironmentInteger(
281
+ environment.BORG_SERVER_MAX_DATABASE_BYTES,
282
+ DEFAULT_STORAGE_LIMITS.maxDatabaseBytes,
283
+ "BORG_SERVER_MAX_DATABASE_BYTES",
284
+ ),
285
+ minFreeDiskBytes: positiveEnvironmentInteger(
286
+ environment.BORG_SERVER_MIN_FREE_DISK_BYTES,
287
+ DEFAULT_STORAGE_LIMITS.minFreeDiskBytes,
288
+ "BORG_SERVER_MIN_FREE_DISK_BYTES",
289
+ ),
290
+ };
291
+ }
292
+
293
+ function positiveEnvironmentInteger(value: string | undefined, fallback: number, name: string): number {
294
+ if (value === undefined) return fallback;
295
+ const code = storageOperatorErrorCode(name);
296
+ if (!/^[1-9][0-9]*$/u.test(value)) throw operatorErrors[code];
297
+ const parsed = Number(value);
298
+ if (!Number.isSafeInteger(parsed)) throw operatorErrors[code];
299
+ return parsed;
300
+ }
301
+
302
+ function storageOperatorErrorCode(name: string): OperatorErrorCode {
303
+ if (name === "BORG_SERVER_MAX_ACTIVITY_ENTRIES_PER_CUBE") return "ACTIVITY_LIMIT_INVALID";
304
+ if (name === "BORG_SERVER_MAX_DATABASE_BYTES") return "DATABASE_LIMIT_INVALID";
305
+ if (name === "BORG_SERVER_MIN_FREE_DISK_BYTES") return "DISK_RESERVE_INVALID";
306
+ throw new Error("Unknown storage environment setting.");
307
+ }
308
+
309
+ const serverEnvironment = selectServerEnvironment(process.env);
310
+ const dataDirectory = serverEnvironment.BORG_SERVER_DATA_DIR ?? join(homedir(), ".borg", "server");
311
+ const startOnlyService = createNodeServerService({
312
+ environment: { ...serverEnvironment, BORG_SERVER_DATA_DIR: dataDirectory },
313
+ readFile,
314
+ readPrivateKey: loadTlsPrivateKey,
315
+ startServer: async (options) => {
316
+ const running = await startHttpsServer(options);
317
+ return nodeServerTestHooks?.wrapRunningServer?.(running) ?? running;
318
+ },
319
+ onStarted: (origin) => {
320
+ console.error(`Borg server listening on ${origin}`);
321
+ nodeServerTestHooks?.onListening?.(origin);
322
+ },
323
+ onStartupPhase: (phase) => nodeServerTestHooks?.onStartupPhase?.(phase) ?? Promise.resolve(),
324
+ installShutdownHandlers: () => {
325
+ const handlers = installProcessShutdownHandlers();
326
+ handlers.signal.addEventListener("abort", () => nodeServerTestHooks?.onSignalObserved?.(), {
327
+ once: true,
328
+ });
329
+ return handlers;
330
+ },
331
+ waitForShutdown,
332
+ });
333
+ export const nodeServerService: ServerService = {
334
+ start: startOnlyService.start,
335
+ setup: () => bootstrapServer(dataDirectory, resolveSetupBindHost(serverEnvironment)),
336
+ ...createOfflineCredentialService(dataDirectory),
337
+ };
338
+
339
+ function resolveSetupBindHost(environment: ServerEnvironment): string {
340
+ return resolveBindOptions({
341
+ ...(environment.BORG_SERVER_BIND_HOST === undefined
342
+ ? {}
343
+ : { host: environment.BORG_SERVER_BIND_HOST }),
344
+ lanConsent: true,
345
+ }).host;
346
+ }
347
+
348
+ export function createOfflineCredentialService(
349
+ offlineDataDirectory: string,
350
+ ): Pick<Required<ServerService>,
351
+ "rotateClient" | "revokeClient" | "grantClient" | "ungrantClient" |
352
+ "createClientInvitation" | "replaceOwnerInvitation"
353
+ > {
354
+ const withAuthority = async <T>(operation: (
355
+ authority: CredentialAuthority,
356
+ runtime: Awaited<ReturnType<typeof openStore>>,
357
+ ) => T): Promise<T> => {
358
+ const runtimeLock = await acquireRuntimeLock(offlineDataDirectory);
359
+ let runtime: Awaited<ReturnType<typeof openStore>> | undefined;
360
+ let digester: CredentialDigester | undefined;
361
+ try {
362
+ runtime = await openStore({ path: join(offlineDataDirectory, "borg.db") });
363
+ const digestKey = await loadDigestKey(join(offlineDataDirectory, "credential-digest.key"));
364
+ digester = new CredentialDigester(digestKey);
365
+ digestKey.fill(0);
366
+ return operation(new CredentialAuthority(runtime.credentials, digester), runtime);
367
+ } finally {
368
+ digester?.destroy();
369
+ runtime?.close();
370
+ await runtimeLock.release();
371
+ }
372
+ };
373
+ return {
374
+ rotateClient: (clientId) => withAuthority((authority) => authority.rotateClient(clientId)),
375
+ revokeClient: (clientId) => withAuthority((authority) => authority.revokeClient(clientId)),
376
+ grantClient: (clientId, cubeId, access) => withAuthority((_authority, runtime) => {
377
+ if (!runtime.credentials.clientIsActive(clientId)) {
378
+ throw operatorErrors.CLIENT_NOT_FOUND;
379
+ }
380
+ runtime.maintenance.grantClientCube({ clientId, cubeId, access });
381
+ }),
382
+ ungrantClient: (clientId, cubeId) => withAuthority((_authority, runtime) => {
383
+ if (!runtime.credentials.clientIsActive(clientId)) throw operatorErrors.CLIENT_NOT_FOUND;
384
+ if (!runtime.maintenance.removeClientCubeGrant(clientId, cubeId)) {
385
+ throw operatorErrors.GRANT_NOT_FOUND;
386
+ }
387
+ }),
388
+ createClientInvitation: (recoveryCredential) => withAuthority((authority) => {
389
+ const invitation = authority.createInvitation(recoveryCredential, 15 * 60_000);
390
+ if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
391
+ return invitation;
392
+ }),
393
+ replaceOwnerInvitation: (recoveryCredential) => withAuthority((authority) => {
394
+ const invitation = authority.replaceOwnerInvitation(recoveryCredential, 15 * 60_000);
395
+ if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
396
+ return invitation;
397
+ }),
398
+ };
399
+ }
400
+
401
+ interface RuntimeLock {
402
+ readonly release: () => Promise<void>;
403
+ }
404
+
405
+ async function teardownRuntime(resources: RuntimeResources): Promise<void> {
406
+ try {
407
+ await resources.running?.close();
408
+ } catch (error) {
409
+ guardedRuntimeFailures.add(Object.freeze({ ...resources }));
410
+ throw error;
411
+ }
412
+ try {
413
+ resources.authRuntime?.close();
414
+ resources.digester?.destroy();
415
+ } catch (error) {
416
+ guardedRuntimeFailures.add(Object.freeze({ ...resources, running: undefined }));
417
+ throw error;
418
+ }
419
+ await resources.runtimeLock?.release();
420
+ }
421
+
422
+ const fatalTeardownErrors = new WeakSet<object>();
423
+ const fatalTeardownCapability = Object.freeze({});
424
+
425
+ class FatalTeardownError extends AggregateError {
426
+ constructor(capability: object, primary: unknown, cleanup: unknown) {
427
+ super(
428
+ primary === undefined ? [cleanup] : [primary, cleanup],
429
+ "Server teardown could not be confirmed; the runtime remains locked.",
430
+ );
431
+ if (capability !== fatalTeardownCapability) {
432
+ throw new Error("Fatal teardown error construction is unavailable.");
433
+ }
434
+ this.name = "FatalTeardownError";
435
+ fatalTeardownErrors.add(this);
436
+ Object.freeze(this);
437
+ }
438
+ }
439
+
440
+ export function isFatalTeardownError(error: unknown): boolean {
441
+ return typeof error === "object" && error !== null && fatalTeardownErrors.has(error);
442
+ }
443
+
444
+ function fatalTeardownError(primary: unknown, cleanup: unknown): FatalTeardownError {
445
+ return new FatalTeardownError(fatalTeardownCapability, primary, cleanup);
446
+ }
447
+
448
+ export async function acquireRuntimeLock(runtimeDataDirectory: string): Promise<RuntimeLock> {
449
+ const path = join(runtimeDataDirectory, "runtime.lock");
450
+ const nonce = randomUUID();
451
+ try {
452
+ const handle = await open(path, "wx", 0o600);
453
+ try {
454
+ await handle.writeFile(JSON.stringify({ pid: process.pid, nonce }));
455
+ } catch (error) {
456
+ await handle.close();
457
+ await unlink(path).catch(() => undefined);
458
+ throw error;
459
+ }
460
+ return {
461
+ release: async () => {
462
+ await handle.close();
463
+ try {
464
+ const current = JSON.parse(await readFile(path, "utf8")) as { nonce?: unknown };
465
+ if (current.nonce === nonce) await unlink(path);
466
+ } catch (error) {
467
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
468
+ }
469
+ },
470
+ };
471
+ } catch (error) {
472
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
473
+ const metadata = await lstat(path);
474
+ if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
475
+ throw operatorErrors.RUNTIME_LOCK_UNSAFE;
476
+ }
477
+ let pid: number;
478
+ try {
479
+ const value = JSON.parse(await readFile(path, "utf8")) as { pid?: unknown };
480
+ if (!Number.isSafeInteger(value.pid) || (value.pid as number) <= 0) throw new Error();
481
+ pid = value.pid as number;
482
+ } catch {
483
+ throw operatorErrors.RUNTIME_LOCK_INVALID;
484
+ }
485
+ if (processIsAlive(pid)) throw operatorErrors.RUNTIME_ACTIVE;
486
+ throw operatorErrors.RUNTIME_LOCK_STALE;
487
+ }
488
+ }
489
+
490
+ function processIsAlive(pid: number): boolean {
491
+ try {
492
+ process.kill(pid, 0);
493
+ return true;
494
+ } catch (error) {
495
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
496
+ }
497
+ }
498
+
499
+ class ShutdownRequestedError extends Error {}
500
+
501
+ function throwIfShutdown(signal: AbortSignal | undefined): void {
502
+ if (signal?.aborted === true) throw new ShutdownRequestedError("Server shutdown requested.");
503
+ }
504
+
505
+ export function installProcessShutdownHandlers(): {
506
+ readonly signal: AbortSignal;
507
+ readonly dispose: () => void;
508
+ } {
509
+ const controller = new AbortController();
510
+ const stop = (): void => controller.abort();
511
+ process.once("SIGINT", stop);
512
+ process.once("SIGTERM", stop);
513
+ return {
514
+ signal: controller.signal,
515
+ dispose: () => {
516
+ process.off("SIGINT", stop);
517
+ process.off("SIGTERM", stop);
518
+ },
519
+ };
520
+ }
521
+
522
+ function waitForShutdown(_server: RunningServer, signal?: AbortSignal): Promise<void> {
523
+ if (signal?.aborted === true) return Promise.resolve();
524
+ return new Promise((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true }));
525
+ }