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.
- package/README.md +70 -22
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +3 -1
- package/dist/coordination-api.js +476 -78
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +13 -7
- package/dist/credentials.js +77 -15
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +76 -0
- package/dist/debug-log.js +124 -0
- package/dist/debug-log.js.map +1 -0
- package/dist/enrollment.d.ts +3 -11
- package/dist/enrollment.js +21 -60
- package/dist/enrollment.js.map +1 -1
- package/dist/https-server.d.ts +5 -20
- package/dist/https-server.js +108 -50
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/message-taxonomy.d.ts +38 -0
- package/dist/message-taxonomy.js +218 -0
- package/dist/message-taxonomy.js.map +1 -0
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +99 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +14 -4
- package/dist/service.js +243 -25
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +106 -8
- package/dist/store.js +772 -120
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +490 -72
- package/src/credentials.ts +103 -19
- package/src/debug-log.ts +165 -0
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +113 -78
- package/src/index.ts +1 -1
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +102 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +268 -27
- package/src/start-options.ts +21 -4
- package/src/store.ts +887 -142
- package/dist/protocol-draft.d.ts +0 -2
- package/dist/protocol-draft.js +0 -31
- package/dist/protocol-draft.js.map +0 -1
- 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 {
|
|
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 {
|
|
28
|
+
import {
|
|
29
|
+
invitationCubeAmbiguousError,
|
|
30
|
+
operatorErrors,
|
|
31
|
+
type OperatorErrorCode,
|
|
32
|
+
} from "./operator-error.js";
|
|
24
33
|
import { parseStartOptions } from "./start-options.js";
|
|
25
|
-
import {
|
|
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?: (
|
|
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
|
-
|
|
102
|
-
|
|
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(
|
|
173
|
-
|
|
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: () =>
|
|
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) =>
|
|
389
|
-
|
|
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) =>
|
|
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(
|
|
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);
|
package/src/start-options.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|