borgmcp-server 0.1.1 → 0.1.4
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 +69 -22
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +232 -18
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +10 -2
- package/dist/credentials.js +44 -6
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +77 -0
- package/dist/debug-log.js +125 -0
- package/dist/debug-log.js.map +1 -0
- package/dist/https-server.d.ts +3 -0
- package/dist/https-server.js +80 -4
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.js +71 -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 +10 -3
- package/dist/service.js +218 -15
- 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 +65 -1
- package/dist/store.js +398 -26
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +236 -14
- package/src/credentials.ts +71 -5
- package/src/debug-log.ts +165 -0
- package/src/https-server.ts +75 -2
- package/src/index.ts +1 -1
- package/src/migrations.ts +74 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +239 -18
- package/src/start-options.ts +21 -4
- package/src/store.ts +462 -28
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,
|
|
@@ -20,22 +26,41 @@ import {
|
|
|
20
26
|
} from "./https-server.js";
|
|
21
27
|
import { createPart2ProtocolInfo } from "./protocol-draft.js";
|
|
22
28
|
import { resolveBindOptions } from "./network-policy.js";
|
|
23
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
invitationCubeAmbiguousError,
|
|
31
|
+
operatorErrors,
|
|
32
|
+
type OperatorErrorCode,
|
|
33
|
+
} from "./operator-error.js";
|
|
24
34
|
import { parseStartOptions } from "./start-options.js";
|
|
25
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_STORAGE_LIMITS,
|
|
37
|
+
openStore,
|
|
38
|
+
preparePrivateDataDirectory,
|
|
39
|
+
InvitationCubeAmbiguousError,
|
|
40
|
+
InvitationCubeNotFoundError,
|
|
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,6 +79,7 @@ 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;
|
|
59
85
|
readonly onStartupPhase?: (
|
|
@@ -91,17 +117,27 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
91
117
|
return {
|
|
92
118
|
async start(args): Promise<void> {
|
|
93
119
|
const shutdown = dependencies.installShutdownHandlers?.();
|
|
94
|
-
let bind: ReturnType<typeof parseStartOptions
|
|
120
|
+
let bind: ReturnType<typeof parseStartOptions>["bind"];
|
|
121
|
+
let debugLogger = disabledDebugLogger;
|
|
95
122
|
let dataDirectory: string | undefined;
|
|
96
123
|
let storageLimits: StorageLimits;
|
|
97
124
|
try {
|
|
98
125
|
throwIfShutdown(shutdown?.signal);
|
|
99
126
|
await dependencies.onStartupPhase?.("pre-lock");
|
|
100
127
|
throwIfShutdown(shutdown?.signal);
|
|
101
|
-
|
|
102
|
-
|
|
128
|
+
const parsed = parseStartOptions(args);
|
|
129
|
+
bind = parsed.bind;
|
|
130
|
+
debugLogger = createDebugLogger(parsed.logLevel === "debug" ? dependencies.debugOutput : undefined);
|
|
131
|
+
const resolvedBind = resolveBindOptions(bind);
|
|
132
|
+
const bindMode = resolvedBind.mode;
|
|
103
133
|
dataDirectory = dependencies.environment.BORG_SERVER_DATA_DIR;
|
|
104
134
|
storageLimits = resolveStorageLimits(dependencies.environment);
|
|
135
|
+
debugLogger.emit({
|
|
136
|
+
event: "startup",
|
|
137
|
+
bindMode,
|
|
138
|
+
port: resolvedBind.port,
|
|
139
|
+
dataDirectory: dataDirectory === undefined ? "tls_only" : "configured",
|
|
140
|
+
});
|
|
105
141
|
if (bindMode === "lan" && dataDirectory !== undefined) {
|
|
106
142
|
await assertLanCaKeyOffline(dataDirectory);
|
|
107
143
|
throwIfShutdown(shutdown?.signal);
|
|
@@ -124,7 +160,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
124
160
|
|
|
125
161
|
let runtimeLock: Awaited<ReturnType<typeof acquireRuntimeLock>> | undefined;
|
|
126
162
|
try {
|
|
127
|
-
runtimeLock = dataDirectory === undefined ? undefined : await acquireRuntimeLock(dataDirectory);
|
|
163
|
+
runtimeLock = dataDirectory === undefined ? undefined : await acquireRuntimeLock(dataDirectory, "server");
|
|
128
164
|
await dependencies.onStartupPhase?.("post-lock");
|
|
129
165
|
throwIfShutdown(shutdown?.signal);
|
|
130
166
|
} catch (error) {
|
|
@@ -169,8 +205,14 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
169
205
|
} finally {
|
|
170
206
|
digestKey.fill(0);
|
|
171
207
|
}
|
|
172
|
-
authority = new CredentialAuthority(
|
|
173
|
-
|
|
208
|
+
authority = new CredentialAuthority(
|
|
209
|
+
authRuntime.credentials,
|
|
210
|
+
digester,
|
|
211
|
+
() => new Date(),
|
|
212
|
+
undefined,
|
|
213
|
+
debugLogger,
|
|
214
|
+
);
|
|
215
|
+
coordinationApi = new CoordinationApi(authRuntime, authority, debugLogger);
|
|
174
216
|
}
|
|
175
217
|
await dependencies.onStartupPhase?.("pre-listen");
|
|
176
218
|
throwIfShutdown(shutdown?.signal);
|
|
@@ -197,6 +239,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
197
239
|
...(coordinationApi === undefined
|
|
198
240
|
? {}
|
|
199
241
|
: { handleCoordination: (request) => coordinationApi.handle(request) }),
|
|
242
|
+
debugLogger,
|
|
200
243
|
});
|
|
201
244
|
throwIfShutdown(shutdown?.signal);
|
|
202
245
|
} catch (error) {
|
|
@@ -217,6 +260,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
217
260
|
try {
|
|
218
261
|
throwIfShutdown(shutdown?.signal);
|
|
219
262
|
dependencies.onStarted(running.origin);
|
|
263
|
+
debugLogger.emit({ event: "lifecycle", action: "listening" });
|
|
220
264
|
await dependencies.waitForShutdown(running, shutdown?.signal);
|
|
221
265
|
} catch (error) {
|
|
222
266
|
failed = true;
|
|
@@ -224,6 +268,7 @@ export function createNodeServerService(dependencies: ServiceDependencies): Serv
|
|
|
224
268
|
}
|
|
225
269
|
try {
|
|
226
270
|
await teardownRuntime({ running, authRuntime, digester, runtimeLock });
|
|
271
|
+
debugLogger.emit({ event: "lifecycle", action: "stopped" });
|
|
227
272
|
} catch (cleanupError) {
|
|
228
273
|
shutdown?.dispose();
|
|
229
274
|
throw fatalTeardownError(failed ? failure : undefined, cleanupError);
|
|
@@ -329,13 +374,69 @@ const startOnlyService = createNodeServerService({
|
|
|
329
374
|
return handlers;
|
|
330
375
|
},
|
|
331
376
|
waitForShutdown,
|
|
377
|
+
debugOutput: (line) => console.error(line),
|
|
332
378
|
});
|
|
333
379
|
export const nodeServerService: ServerService = {
|
|
334
380
|
start: startOnlyService.start,
|
|
335
|
-
setup: () =>
|
|
381
|
+
setup: (options) => setupNodeServerInstallation(
|
|
382
|
+
dataDirectory,
|
|
383
|
+
resolveSetupBindHost(serverEnvironment),
|
|
384
|
+
options,
|
|
385
|
+
),
|
|
336
386
|
...createOfflineCredentialService(dataDirectory),
|
|
337
387
|
};
|
|
338
388
|
|
|
389
|
+
const managedInstallationFiles = Object.freeze([
|
|
390
|
+
"borg.db",
|
|
391
|
+
"borg.db-wal",
|
|
392
|
+
"borg.db-shm",
|
|
393
|
+
"borg.db-journal",
|
|
394
|
+
"credential-digest.key",
|
|
395
|
+
"ca.key",
|
|
396
|
+
"ca.crt",
|
|
397
|
+
"server.key",
|
|
398
|
+
"server.crt",
|
|
399
|
+
"server.json",
|
|
400
|
+
]);
|
|
401
|
+
|
|
402
|
+
export async function setupNodeServerInstallation(
|
|
403
|
+
setupDataDirectory: string,
|
|
404
|
+
bindHost: string,
|
|
405
|
+
options: SetupOptions,
|
|
406
|
+
): Promise<BootstrapResult> {
|
|
407
|
+
const directory = await preparePrivateDataDirectory(setupDataDirectory);
|
|
408
|
+
const runtimeLock = await acquireRuntimeLock(directory);
|
|
409
|
+
let invitationLock: RuntimeLock | undefined;
|
|
410
|
+
try {
|
|
411
|
+
invitationLock = await acquireInvitationMintLock(directory);
|
|
412
|
+
const existing = await inspectManagedInstallation(directory);
|
|
413
|
+
if (existing.length !== 0 && !options.reinitialize) throw operatorErrors.INSTALLATION_EXISTS;
|
|
414
|
+
if (options.reinitialize) {
|
|
415
|
+
for (const path of existing) await unlink(path);
|
|
416
|
+
}
|
|
417
|
+
return await bootstrapServer(directory, bindHost);
|
|
418
|
+
} finally {
|
|
419
|
+
if (invitationLock === undefined) await runtimeLock.release();
|
|
420
|
+
else await invitationLock.release().finally(() => runtimeLock.release());
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function inspectManagedInstallation(directory: string): Promise<string[]> {
|
|
425
|
+
const existing: string[] = [];
|
|
426
|
+
for (const name of managedInstallationFiles) {
|
|
427
|
+
const path = join(directory, name);
|
|
428
|
+
try {
|
|
429
|
+
const metadata = await lstat(path);
|
|
430
|
+
if (metadata.isSymbolicLink()) throw operatorErrors.DATA_PATH_SYMLINK;
|
|
431
|
+
if (!metadata.isFile()) throw new Error("Managed installation paths must be regular files.");
|
|
432
|
+
existing.push(path);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return existing;
|
|
438
|
+
}
|
|
439
|
+
|
|
339
440
|
function resolveSetupBindHost(environment: ServerEnvironment): string {
|
|
340
441
|
return resolveBindOptions({
|
|
341
442
|
...(environment.BORG_SERVER_BIND_HOST === undefined
|
|
@@ -356,9 +457,11 @@ export function createOfflineCredentialService(
|
|
|
356
457
|
runtime: Awaited<ReturnType<typeof openStore>>,
|
|
357
458
|
) => T): Promise<T> => {
|
|
358
459
|
const runtimeLock = await acquireRuntimeLock(offlineDataDirectory);
|
|
460
|
+
let invitationLock: RuntimeLock | undefined;
|
|
359
461
|
let runtime: Awaited<ReturnType<typeof openStore>> | undefined;
|
|
360
462
|
let digester: CredentialDigester | undefined;
|
|
361
463
|
try {
|
|
464
|
+
invitationLock = await acquireInvitationMintLock(offlineDataDirectory);
|
|
362
465
|
runtime = await openStore({ path: join(offlineDataDirectory, "borg.db") });
|
|
363
466
|
const digestKey = await loadDigestKey(join(offlineDataDirectory, "credential-digest.key"));
|
|
364
467
|
digester = new CredentialDigester(digestKey);
|
|
@@ -367,7 +470,35 @@ export function createOfflineCredentialService(
|
|
|
367
470
|
} finally {
|
|
368
471
|
digester?.destroy();
|
|
369
472
|
runtime?.close();
|
|
370
|
-
await runtimeLock.release();
|
|
473
|
+
if (invitationLock === undefined) await runtimeLock.release();
|
|
474
|
+
else await invitationLock.release().finally(() => runtimeLock.release());
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const withInvitationAuthority = async <T>(operation: (authority: CredentialAuthority) => T): Promise<T> => {
|
|
478
|
+
const invitationLock = await acquireInvitationMintLock(offlineDataDirectory);
|
|
479
|
+
let offlineRuntimeLock: RuntimeLock | undefined;
|
|
480
|
+
let runtime: Awaited<ReturnType<typeof openStore>> | undefined;
|
|
481
|
+
let digester: CredentialDigester | undefined;
|
|
482
|
+
try {
|
|
483
|
+
const runtimeState = await invitationRuntimeState(offlineDataDirectory);
|
|
484
|
+
if (runtimeState === "offline") offlineRuntimeLock = await acquireRuntimeLock(offlineDataDirectory);
|
|
485
|
+
runtime = await openStore({
|
|
486
|
+
path: join(offlineDataDirectory, "borg.db"),
|
|
487
|
+
migrationMode: runtimeState === "live" ? "require-current" : "apply",
|
|
488
|
+
});
|
|
489
|
+
const digestKey = await loadDigestKey(join(offlineDataDirectory, "credential-digest.key"));
|
|
490
|
+
digester = new CredentialDigester(digestKey);
|
|
491
|
+
digestKey.fill(0);
|
|
492
|
+
return operation(new CredentialAuthority(runtime.credentials, digester));
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (error instanceof MigrationCompatibilityError) throw operatorErrors.INVITATION_SCHEMA_MISMATCH;
|
|
495
|
+
if (isSqliteContention(error)) throw operatorErrors.INVITATION_CONTENTION;
|
|
496
|
+
throw error;
|
|
497
|
+
} finally {
|
|
498
|
+
digester?.destroy();
|
|
499
|
+
runtime?.close();
|
|
500
|
+
if (offlineRuntimeLock === undefined) await invitationLock.release();
|
|
501
|
+
else await offlineRuntimeLock.release().finally(() => invitationLock.release());
|
|
371
502
|
}
|
|
372
503
|
};
|
|
373
504
|
return {
|
|
@@ -385,12 +516,24 @@ export function createOfflineCredentialService(
|
|
|
385
516
|
throw operatorErrors.GRANT_NOT_FOUND;
|
|
386
517
|
}
|
|
387
518
|
}),
|
|
388
|
-
createClientInvitation: (recoveryCredential) =>
|
|
389
|
-
|
|
519
|
+
createClientInvitation: (recoveryCredential, cubeSelector, access) => withInvitationAuthority((authority) => {
|
|
520
|
+
if (cubeSelector === undefined && access !== undefined) {
|
|
521
|
+
throw operatorErrors.INVITATION_CUBE_SELECTOR_INVALID;
|
|
522
|
+
}
|
|
523
|
+
const selector = cubeSelector === undefined ? undefined : parseInvitationCubeSelector(cubeSelector);
|
|
524
|
+
const invitation = selector === undefined
|
|
525
|
+
? authority.createInvitation(recoveryCredential, 15 * 60_000)
|
|
526
|
+
: authority.createCubeInvitation(recoveryCredential, selector, access ?? "write", 15 * 60_000);
|
|
390
527
|
if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
|
|
391
528
|
return invitation;
|
|
529
|
+
}).catch((error: unknown) => {
|
|
530
|
+
if (error instanceof InvitationCubeNotFoundError) throw operatorErrors.INVITATION_CUBE_NOT_FOUND;
|
|
531
|
+
if (error instanceof InvitationCubeAmbiguousError) {
|
|
532
|
+
throw invitationCubeAmbiguousError(error.candidateIds);
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
392
535
|
}),
|
|
393
|
-
replaceOwnerInvitation: (recoveryCredential) =>
|
|
536
|
+
replaceOwnerInvitation: (recoveryCredential) => withInvitationAuthority((authority) => {
|
|
394
537
|
const invitation = authority.replaceOwnerInvitation(recoveryCredential, 15 * 60_000);
|
|
395
538
|
if (invitation === null) throw operatorErrors.RECOVERY_INVALID;
|
|
396
539
|
return invitation;
|
|
@@ -398,6 +541,19 @@ export function createOfflineCredentialService(
|
|
|
398
541
|
};
|
|
399
542
|
}
|
|
400
543
|
|
|
544
|
+
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;
|
|
545
|
+
const uuidLikeCubeSelector = /^[0-9a-fA-F-]{32,36}$/u;
|
|
546
|
+
|
|
547
|
+
function parseInvitationCubeSelector(
|
|
548
|
+
value: string,
|
|
549
|
+
): { readonly kind: "id" | "name"; readonly value: string } {
|
|
550
|
+
if (canonicalCubeUuid.test(value)) return { kind: "id", value };
|
|
551
|
+
if (value.length === 0 || value.length > 120 || uuidLikeCubeSelector.test(value)) {
|
|
552
|
+
throw operatorErrors.INVITATION_CUBE_SELECTOR_INVALID;
|
|
553
|
+
}
|
|
554
|
+
return { kind: "name", value };
|
|
555
|
+
}
|
|
556
|
+
|
|
401
557
|
interface RuntimeLock {
|
|
402
558
|
readonly release: () => Promise<void>;
|
|
403
559
|
}
|
|
@@ -445,13 +601,16 @@ function fatalTeardownError(primary: unknown, cleanup: unknown): FatalTeardownEr
|
|
|
445
601
|
return new FatalTeardownError(fatalTeardownCapability, primary, cleanup);
|
|
446
602
|
}
|
|
447
603
|
|
|
448
|
-
export async function acquireRuntimeLock(
|
|
604
|
+
export async function acquireRuntimeLock(
|
|
605
|
+
runtimeDataDirectory: string,
|
|
606
|
+
purpose: "server" | "exclusive-admin" = "exclusive-admin",
|
|
607
|
+
): Promise<RuntimeLock> {
|
|
449
608
|
const path = join(runtimeDataDirectory, "runtime.lock");
|
|
450
609
|
const nonce = randomUUID();
|
|
451
610
|
try {
|
|
452
611
|
const handle = await open(path, "wx", 0o600);
|
|
453
612
|
try {
|
|
454
|
-
await handle.writeFile(JSON.stringify({ pid: process.pid, nonce }));
|
|
613
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, nonce, purpose }));
|
|
455
614
|
} catch (error) {
|
|
456
615
|
await handle.close();
|
|
457
616
|
await unlink(path).catch(() => undefined);
|
|
@@ -487,6 +646,68 @@ export async function acquireRuntimeLock(runtimeDataDirectory: string): Promise<
|
|
|
487
646
|
}
|
|
488
647
|
}
|
|
489
648
|
|
|
649
|
+
export async function acquireInvitationMintLock(runtimeDataDirectory: string): Promise<RuntimeLock> {
|
|
650
|
+
const path = join(runtimeDataDirectory, "invitation-mint.lock");
|
|
651
|
+
const nonce = randomUUID();
|
|
652
|
+
try {
|
|
653
|
+
const handle = await open(path, "wx", 0o600);
|
|
654
|
+
try {
|
|
655
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, nonce }));
|
|
656
|
+
} catch (error) {
|
|
657
|
+
await handle.close();
|
|
658
|
+
await unlink(path).catch(() => undefined);
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
release: async () => {
|
|
663
|
+
await handle.close();
|
|
664
|
+
try {
|
|
665
|
+
const current = JSON.parse(await readFile(path, "utf8")) as { nonce?: unknown };
|
|
666
|
+
if (current.nonce === nonce) await unlink(path);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
} catch (error) {
|
|
673
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
674
|
+
throw operatorErrors.INVITATION_BUSY;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function invitationRuntimeState(runtimeDataDirectory: string): Promise<"live" | "offline"> {
|
|
679
|
+
const path = join(runtimeDataDirectory, "runtime.lock");
|
|
680
|
+
let metadata;
|
|
681
|
+
try {
|
|
682
|
+
metadata = await lstat(path);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "offline";
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
|
688
|
+
throw operatorErrors.RUNTIME_LOCK_UNSAFE;
|
|
689
|
+
}
|
|
690
|
+
let pid: number;
|
|
691
|
+
let purpose: unknown;
|
|
692
|
+
try {
|
|
693
|
+
const value = JSON.parse(await readFile(path, "utf8")) as { pid?: unknown; purpose?: unknown };
|
|
694
|
+
if (!Number.isSafeInteger(value.pid) || (value.pid as number) <= 0) throw new Error();
|
|
695
|
+
pid = value.pid as number;
|
|
696
|
+
purpose = value.purpose;
|
|
697
|
+
} catch {
|
|
698
|
+
throw operatorErrors.RUNTIME_LOCK_INVALID;
|
|
699
|
+
}
|
|
700
|
+
if (!processIsAlive(pid)) throw operatorErrors.RUNTIME_LOCK_STALE;
|
|
701
|
+
if (purpose !== "server") throw operatorErrors.RUNTIME_ACTIVE;
|
|
702
|
+
return "live";
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function isSqliteContention(error: unknown): boolean {
|
|
706
|
+
if (typeof error !== "object" || error === null) return false;
|
|
707
|
+
const value = error as { code?: unknown; errcode?: unknown };
|
|
708
|
+
return value.code === "ERR_SQLITE_ERROR" && (value.errcode === 5 || value.errcode === 6);
|
|
709
|
+
}
|
|
710
|
+
|
|
490
711
|
function processIsAlive(pid: number): boolean {
|
|
491
712
|
try {
|
|
492
713
|
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
|
|