@davidvornholt/standards 0.19.0 → 0.20.0

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.
@@ -1,15 +1,20 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { openInBrowser } from './creds-browser';
3
+ import {
4
+ loadOwnedGithubStore,
5
+ sameGithubApp,
6
+ upsertGithubApp,
7
+ } from './creds-github-apps';
3
8
  import {
4
9
  buildAppManifest,
5
10
  convertManifestCode,
6
11
  createManifestState,
7
12
  MANIFEST_STATE_BYTES,
8
13
  manifestFormHtml,
14
+ resolveGithubAppName,
9
15
  } from './creds-login-github-manifest';
10
- import { BROKER_IDENTITY_NAME } from './creds-naming';
11
16
  import {
12
- readBrokerStore,
17
+ type GithubBrokerApp,
13
18
  resolveBrokerPath,
14
19
  updateBrokerStore,
15
20
  } from './creds-store';
@@ -84,50 +89,99 @@ export const waitForCode = (
84
89
  };
85
90
  export const githubInstallMessage = (installUrl: string): string =>
86
91
  `Install it only on the selected repositories that need broker credentials: ${installUrl}`;
87
- export const runCredsLoginGithub = async (options: {
88
- readonly name: string | undefined;
89
- readonly org: string | undefined;
90
- }): Promise<boolean> => {
92
+
93
+ type GithubLoginDependencies = {
94
+ readonly waitForCode: typeof waitForCode;
95
+ readonly convertManifestCode: typeof convertManifestCode;
96
+ readonly openInBrowser: typeof openInBrowser;
97
+ };
98
+
99
+ const DEFAULT_LOGIN_DEPENDENCIES: GithubLoginDependencies = {
100
+ waitForCode,
101
+ convertManifestCode,
102
+ openInBrowser,
103
+ };
104
+
105
+ export const runCredsLoginGithub = async (
106
+ options: {
107
+ readonly name: string | undefined;
108
+ readonly org: string | undefined;
109
+ },
110
+ dependencies: GithubLoginDependencies = DEFAULT_LOGIN_DEPENDENCIES,
111
+ ): Promise<boolean> => {
112
+ const resolvedName = resolveGithubAppName(options.name, options.org);
113
+ if (!resolvedName.ok) {
114
+ console.error(`standards creds: ${resolvedName.problem}`);
115
+ return false;
116
+ }
91
117
  const storePath = resolveBrokerPath();
92
- const store = await readBrokerStore(storePath);
93
- if (store.github !== null) {
94
- console.error(
95
- `standards creds: a broker GitHub App is already configured (${store.github.htmlUrl}); remove the "github" section from ${storePath} to re-run login`,
96
- );
118
+ const loaded = await loadOwnedGithubStore(storePath);
119
+ if (!loaded.ok) {
120
+ console.error(`standards creds: ${loaded.problem}`);
97
121
  return false;
98
122
  }
99
123
  const action =
100
124
  options.org === undefined
101
125
  ? 'https://github.com/settings/apps/new'
102
126
  : `https://github.com/organizations/${options.org}/settings/apps/new`;
103
- const name = options.name ?? BROKER_IDENTITY_NAME;
104
- const code = await waitForCode((port, state) =>
127
+ const code = await dependencies.waitForCode((port, state) =>
105
128
  manifestFormHtml(
106
129
  action,
107
130
  JSON.stringify(
108
- buildAppManifest(name, `http://127.0.0.1:${port}/callback`),
131
+ buildAppManifest(
132
+ resolvedName.value,
133
+ `http://127.0.0.1:${port}/callback`,
134
+ ),
109
135
  ),
110
136
  state,
111
137
  ),
112
138
  );
113
- const conversion = await convertManifestCode(code);
139
+ const conversion = await dependencies.convertManifestCode(code);
114
140
  if (!conversion.ok) {
115
141
  console.error(`standards creds: ${conversion.problem}`);
116
142
  return false;
117
143
  }
118
144
  const { app } = conversion;
119
- await updateBrokerStore(storePath, (current) => {
120
- if (current.github !== null) {
121
- throw new Error(
122
- `a broker GitHub App was configured while login was in progress (${current.github.htmlUrl}); the newly created App ${app.htmlUrl} was not stored and should be deleted`,
145
+ const observed = loaded.value.github.find(
146
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
147
+ );
148
+ let replaced: GithubBrokerApp | undefined;
149
+ try {
150
+ await updateBrokerStore(storePath, (current) => {
151
+ const currentEntry = current.github.find(
152
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
123
153
  );
124
- }
125
- return { ...current, github: app };
126
- });
127
- console.log(`standards creds: created App ${app.slug} (${app.htmlUrl})`);
154
+ const unchanged =
155
+ observed === undefined
156
+ ? currentEntry === undefined
157
+ : currentEntry !== undefined && sameGithubApp(currentEntry, observed);
158
+ if (!unchanged) {
159
+ throw new Error(
160
+ `the broker App for ${app.owner} changed while this App was being created`,
161
+ );
162
+ }
163
+ const upserted = upsertGithubApp(current.github, app);
164
+ replaced = upserted.replaced ?? undefined;
165
+ return { ...current, github: upserted.apps };
166
+ });
167
+ } catch (error) {
168
+ const message = error instanceof Error ? error.message : String(error);
169
+ console.error(
170
+ `standards creds: the newly created App ${app.htmlUrl} was not stored (${message}); retry login, and delete the new App if it is still absent from \`creds status\``,
171
+ );
172
+ return false;
173
+ }
174
+ console.log(
175
+ `standards creds: created App ${app.slug} for ${app.owner} (${app.htmlUrl})`,
176
+ );
128
177
  console.log(` credentials stored in ${storePath}`);
178
+ if (replaced !== undefined) {
179
+ console.log(
180
+ ` replaced App ${replaced.slug} for ${app.owner}; update its SOPS destinations, verify token minting, then delete the old App`,
181
+ );
182
+ }
129
183
  const installUrl = `${app.htmlUrl}/installations/new`;
130
184
  console.log(githubInstallMessage(installUrl));
131
- openInBrowser(installUrl);
185
+ dependencies.openInBrowser(installUrl);
132
186
  return true;
133
187
  };
@@ -7,11 +7,8 @@ import { createAccountToken, deleteAccountToken } from './creds-cloudflare';
7
7
  import { resolveTargetRel } from './creds-dest';
8
8
  import type { PlannedAction } from './creds-plan-types';
9
9
  import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
10
- import {
11
- inspectSopsScalarDestination,
12
- setSopsValues,
13
- verifySopsStoredValue,
14
- } from './creds-sops';
10
+ import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
11
+ import { verifySopsStoredValue } from './creds-sops-value';
15
12
  import type { CloudflareBrokerAccount } from './creds-store';
16
13
 
17
14
  type RenewAction = Extract<PlannedAction, { readonly kind: 'renew' }>;
@@ -0,0 +1,89 @@
1
+ // Proving that a stored secret matches the value that was just minted, without
2
+ // that value reaching a return type, a log line, or an error message. Callers
3
+ // destroy the new credential on a proven mismatch and keep it on an
4
+ // unverifiable read, so the two negative answers must stay distinct: anything
5
+ // that is not a leaf we can read as a scalar string is unverifiable.
6
+
7
+ import { isContainedSopsPath, parseSopsKeyPath } from './creds-sops-structure';
8
+ import { runSops } from './sops-exec';
9
+
10
+ export type SopsStoredValueVerification =
11
+ | { readonly ok: true; readonly matches: boolean }
12
+ | { readonly ok: false; readonly problem: string };
13
+ type SopsStoredValueInput = {
14
+ readonly consumer: string;
15
+ readonly rel: string;
16
+ readonly dottedPath: string;
17
+ readonly expectedValue: string;
18
+ };
19
+
20
+ // Extracting a scalar leaf prints the stored value itself, with no quoting and
21
+ // no framing of any kind — probed against sops 3.13.0 and 3.13.3. Every byte of
22
+ // stdout is therefore value, which makes exact equality the only sound
23
+ // comparison: a trailing newline is a difference in the stored secret, not
24
+ // output framing.
25
+ //
26
+ // Once equality has been ruled out, output that parses as JSON is output we
27
+ // cannot read as this leaf's own value. It is a branch or list rather than a
28
+ // scalar; or a typed leaf, which a stored string could equally have produced;
29
+ // or a quoted rendering of exactly the value expected, which is either stray
30
+ // quoting in storage or framing from some sops we have not probed. None of
31
+ // those can be told apart from what was printed, and the wrong answer deletes a
32
+ // credential, so all of them stay unverifiable.
33
+ const isUnreadableOutput = (stdout: string, expectedValue: string): boolean => {
34
+ if (stdout === '') {
35
+ return true;
36
+ }
37
+ try {
38
+ const parsed: unknown = JSON.parse(stdout);
39
+ return typeof parsed !== 'string' || parsed === expectedValue;
40
+ } catch {
41
+ // Not JSON at all, which is the normal case: an opaque token value, or a
42
+ // hex access key ID that reads as a malformed number.
43
+ return false;
44
+ }
45
+ };
46
+
47
+ export const verifySopsStoredValueWith = (
48
+ runner: typeof runSops,
49
+ input: SopsStoredValueInput,
50
+ ): SopsStoredValueVerification => {
51
+ const { consumer, rel, dottedPath, expectedValue } = input;
52
+ const path = parseSopsKeyPath(dottedPath);
53
+ const problem = `could not verify stored SOPS value at ${dottedPath} in ${rel}`;
54
+ if (path === null || !isContainedSopsPath(consumer, rel, 'file')) {
55
+ return { ok: false, problem };
56
+ }
57
+ const extract = path
58
+ .map((segment) => `[${JSON.stringify(segment)}]`)
59
+ .join('');
60
+ const result = runner(
61
+ ['decrypt', '--extract', extract, '--output-type', 'json', rel],
62
+ consumer,
63
+ );
64
+ if (result.status !== 0) {
65
+ return { ok: false, problem };
66
+ }
67
+ // Equality is decided before anything is classified, so a secret whose own
68
+ // text happens to be valid JSON — an all-digit token — is proven by the bytes
69
+ // rather than second-guessed by their shape.
70
+ if (result.stdout === expectedValue) {
71
+ return { ok: true, matches: true };
72
+ }
73
+ return isUnreadableOutput(result.stdout, expectedValue)
74
+ ? { ok: false, problem }
75
+ : { ok: true, matches: false };
76
+ };
77
+
78
+ export const verifySopsStoredValue = (
79
+ consumer: string,
80
+ rel: string,
81
+ dottedPath: string,
82
+ expectedValue: string,
83
+ ): SopsStoredValueVerification =>
84
+ verifySopsStoredValueWith(runSops, {
85
+ consumer,
86
+ rel,
87
+ dottedPath,
88
+ expectedValue,
89
+ });
package/src/creds-sops.ts CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  import {
16
16
  inspectSopsStructure,
17
17
  isContainedSopsPath,
18
- parseSopsKeyPath,
19
18
  type SopsShapeFailure,
20
19
  } from './creds-sops-structure';
21
20
  import { runSops } from './sops-exec';
@@ -36,15 +35,6 @@ export type SopsScalarDestinationResult = SopsScalarStructureResult | ReadError;
36
35
  export type SopsWriteResult =
37
36
  | { readonly ok: true }
38
37
  | { readonly ok: false; readonly problem: string };
39
- export type SopsStoredValueVerification =
40
- | { readonly ok: true; readonly matches: boolean }
41
- | { readonly ok: false; readonly problem: string };
42
- type SopsStoredValueInput = {
43
- readonly consumer: string;
44
- readonly rel: string;
45
- readonly dottedPath: string;
46
- readonly expectedValue: string;
47
- };
48
38
 
49
39
  export const listEncryptedKeys = (text: string): EncryptedKeysReadResult => {
50
40
  const result = inspectSopsStructure(text, true);
@@ -123,49 +113,6 @@ const editorCommand = (): string => {
123
113
  return `"${process.execPath}" "${editor}"`;
124
114
  };
125
115
 
126
- export const verifySopsStoredValueWith = (
127
- runner: typeof runSops,
128
- input: SopsStoredValueInput,
129
- ): SopsStoredValueVerification => {
130
- const { consumer, rel, dottedPath, expectedValue } = input;
131
- const path = parseSopsKeyPath(dottedPath);
132
- const problem = `could not verify stored SOPS value at ${dottedPath} in ${rel}`;
133
- if (path === null || !isContainedSopsPath(consumer, rel, 'file')) {
134
- return { ok: false, problem };
135
- }
136
- const extract = path
137
- .map((segment) => `[${JSON.stringify(segment)}]`)
138
- .join('');
139
- const result = runner(
140
- ['decrypt', '--extract', extract, '--output-type', 'json', rel],
141
- consumer,
142
- );
143
- if (result.status !== 0) {
144
- return { ok: false, problem };
145
- }
146
- try {
147
- const stored: unknown = JSON.parse(result.stdout);
148
- return typeof stored === 'string'
149
- ? { ok: true, matches: stored === expectedValue }
150
- : { ok: false, problem };
151
- } catch {
152
- return { ok: false, problem };
153
- }
154
- };
155
-
156
- export const verifySopsStoredValue = (
157
- consumer: string,
158
- rel: string,
159
- dottedPath: string,
160
- expectedValue: string,
161
- ): SopsStoredValueVerification =>
162
- verifySopsStoredValueWith(runSops, {
163
- consumer,
164
- rel,
165
- dottedPath,
166
- expectedValue,
167
- });
168
-
169
116
  export const setSopsValues = (
170
117
  consumer: string,
171
118
  rel: string,
@@ -0,0 +1,121 @@
1
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
2
+
3
+ export type GithubBrokerApp = {
4
+ readonly owner: string | null;
5
+ readonly appId: number;
6
+ readonly slug: string;
7
+ readonly htmlUrl: string;
8
+ readonly clientId: string;
9
+ readonly privateKey: string;
10
+ };
11
+
12
+ export const isGithubBrokerApp = (value: unknown): value is GithubBrokerApp =>
13
+ isRecord(value) &&
14
+ (value.owner === null || isNonEmptyString(value.owner)) &&
15
+ typeof value.appId === 'number' &&
16
+ Number.isInteger(value.appId) &&
17
+ isNonEmptyString(value.slug) &&
18
+ isNonEmptyString(value.htmlUrl) &&
19
+ isNonEmptyString(value.clientId) &&
20
+ isNonEmptyString(value.privateKey);
21
+
22
+ const parseGithubApp = (
23
+ raw: unknown,
24
+ owner: string | null,
25
+ ): GithubBrokerApp => {
26
+ if (!isRecord(raw)) {
27
+ throw new Error('invalid github: run `standards creds login github`');
28
+ }
29
+ const app = {
30
+ owner,
31
+ appId: raw.app_id,
32
+ slug: raw.slug,
33
+ htmlUrl: raw.html_url,
34
+ clientId: raw.client_id,
35
+ privateKey: raw.private_key,
36
+ };
37
+ if (!isGithubBrokerApp(app)) {
38
+ throw new Error('invalid github: run `standards creds login github`');
39
+ }
40
+ return app;
41
+ };
42
+
43
+ export const validateGithubApps = (
44
+ apps: ReadonlyArray<GithubBrokerApp>,
45
+ path: string,
46
+ ): ReadonlyArray<GithubBrokerApp> => {
47
+ const owners = new Set<string>();
48
+ const appIds = new Set<number>();
49
+ for (const app of apps) {
50
+ if (!isGithubBrokerApp(app)) {
51
+ throw new Error(
52
+ `${path}: invalid GitHub App; run \`standards creds login github\``,
53
+ );
54
+ }
55
+ if (app.owner === null) {
56
+ if (apps.length !== 1) {
57
+ throw new Error(
58
+ `${path}: the ownerless legacy GitHub App must be migrated before another App can be stored`,
59
+ );
60
+ }
61
+ } else {
62
+ const owner = app.owner.toLowerCase();
63
+ if (owners.has(owner)) {
64
+ throw new Error(
65
+ `${path}: duplicate GitHub owner ${app.owner}; keep one broker App per account`,
66
+ );
67
+ }
68
+ owners.add(owner);
69
+ }
70
+ if (appIds.has(app.appId)) {
71
+ throw new Error(
72
+ `${path}: duplicate GitHub App id ${app.appId}; keep each App once`,
73
+ );
74
+ }
75
+ appIds.add(app.appId);
76
+ }
77
+ return apps;
78
+ };
79
+
80
+ export const parseGithubApps = (
81
+ raw: unknown,
82
+ path: string,
83
+ ): ReadonlyArray<GithubBrokerApp> => {
84
+ if (raw === undefined || raw === null) {
85
+ return [];
86
+ }
87
+ if (!Array.isArray(raw)) {
88
+ return [parseGithubApp(raw, null)];
89
+ }
90
+ const apps = raw.map((entry) => {
91
+ if (!(isRecord(entry) && isNonEmptyString(entry.owner))) {
92
+ throw new Error(
93
+ 'invalid github owner: run `standards creds login github`',
94
+ );
95
+ }
96
+ return parseGithubApp(entry, entry.owner);
97
+ });
98
+ return validateGithubApps(apps, path);
99
+ };
100
+
101
+ const appDocument = (
102
+ app: GithubBrokerApp,
103
+ ): Readonly<Record<string, unknown>> => ({
104
+ ...(app.owner === null ? {} : { owner: app.owner }),
105
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
106
+ app_id: app.appId,
107
+ slug: app.slug,
108
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
109
+ html_url: app.htmlUrl,
110
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
111
+ client_id: app.clientId,
112
+ // biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
113
+ private_key: app.privateKey,
114
+ });
115
+
116
+ export const githubStoreDocument = (
117
+ apps: ReadonlyArray<GithubBrokerApp>,
118
+ ): unknown =>
119
+ apps.length === 1 && apps[0]?.owner === null
120
+ ? appDocument(apps[0])
121
+ : apps.map(appDocument);
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { Worker } from 'node:worker_threads';
5
+ import { startBrokerLockLease } from './creds-store-lock-lease';
6
+ import { isRecord } from './github-settings-parse';
7
+
8
+ const OWNER_ONLY_DIR_MODE = 0o700;
9
+ const OWNER_ONLY_FILE_MODE = 0o600;
10
+ const HOLDER_PREFIX = 'holder-';
11
+ const HOLDER_SUFFIX = '.json';
12
+ const LEASE_DIVISOR = 3;
13
+
14
+ export type HeldBrokerLock = {
15
+ readonly holderPath: string;
16
+ readonly lease: Worker;
17
+ readonly lockPath: string;
18
+ };
19
+
20
+ const isErrorCode = (error: unknown, code: string): boolean =>
21
+ isRecord(error) && error.code === code;
22
+
23
+ const cleanupCandidate = async (
24
+ candidatePath: string,
25
+ holderPath: string,
26
+ ): Promise<void> => {
27
+ await unlink(holderPath).catch(() => undefined);
28
+ await rmdir(candidatePath).catch(() => undefined);
29
+ };
30
+
31
+ export const tryAcquireBrokerLock = async (
32
+ lockPath: string,
33
+ staleMs: number,
34
+ ): Promise<HeldBrokerLock | null> => {
35
+ const generation = randomUUID();
36
+ const candidatePath = `${lockPath}.pending-${generation}`;
37
+ const candidateHolderPath = join(
38
+ candidatePath,
39
+ `${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
40
+ );
41
+ await mkdir(candidatePath, { mode: OWNER_ONLY_DIR_MODE });
42
+ try {
43
+ await writeFile(candidateHolderPath, JSON.stringify({ generation }), {
44
+ flag: 'wx',
45
+ mode: OWNER_ONLY_FILE_MODE,
46
+ });
47
+ try {
48
+ await rename(candidatePath, lockPath);
49
+ } catch (error) {
50
+ if (isErrorCode(error, 'EEXIST') || isErrorCode(error, 'ENOTEMPTY')) {
51
+ await cleanupCandidate(candidatePath, candidateHolderPath);
52
+ return null;
53
+ }
54
+ throw error;
55
+ }
56
+ } catch (error) {
57
+ await cleanupCandidate(candidatePath, candidateHolderPath);
58
+ throw error;
59
+ }
60
+ const holderPath = join(
61
+ lockPath,
62
+ `${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
63
+ );
64
+ try {
65
+ const lease = startBrokerLockLease(
66
+ holderPath,
67
+ Math.max(1, Math.floor(staleMs / LEASE_DIVISOR)),
68
+ );
69
+ return { holderPath, lease, lockPath };
70
+ } catch (error) {
71
+ await unlink(holderPath).catch(() => undefined);
72
+ await rmdir(lockPath).catch(() => undefined);
73
+ throw error;
74
+ }
75
+ };
76
+
77
+ export const releaseBrokerLock = async ({
78
+ holderPath,
79
+ lease,
80
+ lockPath,
81
+ }: HeldBrokerLock): Promise<void> => {
82
+ await lease.terminate();
83
+ try {
84
+ await unlink(holderPath);
85
+ } catch (error) {
86
+ if (isErrorCode(error, 'ENOENT')) {
87
+ return;
88
+ }
89
+ throw error;
90
+ }
91
+ try {
92
+ await rmdir(lockPath);
93
+ } catch (error) {
94
+ if (!isErrorCode(error, 'ENOENT')) {
95
+ throw error;
96
+ }
97
+ }
98
+ };
@@ -0,0 +1,125 @@
1
+ import { readdir, readFile, rmdir, stat, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from './github-settings-parse';
4
+
5
+ const HOLDER_PREFIX = 'holder-';
6
+ const HOLDER_SUFFIX = '.json';
7
+
8
+ export type BrokerLockAvailability = 'blocked' | 'live' | 'retry';
9
+
10
+ const isErrorCode = (error: unknown, code: string): boolean =>
11
+ isRecord(error) && error.code === code;
12
+
13
+ type ExistingGeneration =
14
+ | { readonly kind: 'missing' }
15
+ | { readonly kind: 'unknown' }
16
+ | {
17
+ readonly entryPath: string | null;
18
+ readonly kind: 'incomplete';
19
+ readonly modifiedAt: number;
20
+ }
21
+ | {
22
+ readonly entryPath: string;
23
+ readonly kind: 'valid';
24
+ readonly modifiedAt: number;
25
+ };
26
+
27
+ const directoryModifiedAt = async (
28
+ lockPath: string,
29
+ ): Promise<number | null> => {
30
+ try {
31
+ return (await stat(lockPath)).mtimeMs;
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ const readExistingGeneration = async (
38
+ lockPath: string,
39
+ ): Promise<ExistingGeneration> => {
40
+ let names: ReadonlyArray<string>;
41
+ try {
42
+ names = await readdir(lockPath);
43
+ } catch (error) {
44
+ return isErrorCode(error, 'ENOENT')
45
+ ? { kind: 'missing' }
46
+ : { kind: 'unknown' };
47
+ }
48
+ if (names.length === 0) {
49
+ const modifiedAt = await directoryModifiedAt(lockPath);
50
+ return modifiedAt === null
51
+ ? { kind: 'missing' }
52
+ : { entryPath: null, kind: 'incomplete', modifiedAt };
53
+ }
54
+ const entryName = names.length === 1 ? names[0] : undefined;
55
+ if (entryName === undefined) {
56
+ return { kind: 'unknown' };
57
+ }
58
+ const entryPath = join(lockPath, entryName);
59
+ let modifiedAt: number;
60
+ try {
61
+ modifiedAt = (await stat(entryPath)).mtimeMs;
62
+ } catch {
63
+ return { kind: 'unknown' };
64
+ }
65
+ const incomplete = {
66
+ entryPath,
67
+ kind: 'incomplete' as const,
68
+ modifiedAt,
69
+ };
70
+ if (
71
+ !(entryName.startsWith(HOLDER_PREFIX) && entryName.endsWith(HOLDER_SUFFIX))
72
+ ) {
73
+ return incomplete;
74
+ }
75
+ try {
76
+ const decoded: unknown = JSON.parse(await readFile(entryPath, 'utf8'));
77
+ const generation = entryName.slice(
78
+ HOLDER_PREFIX.length,
79
+ -HOLDER_SUFFIX.length,
80
+ );
81
+ return isRecord(decoded) && decoded.generation === generation
82
+ ? { entryPath, kind: 'valid', modifiedAt }
83
+ : incomplete;
84
+ } catch {
85
+ return incomplete;
86
+ }
87
+ };
88
+
89
+ const removeObservedGeneration = async (
90
+ lockPath: string,
91
+ entryPath: string | null,
92
+ ): Promise<boolean> => {
93
+ if (entryPath !== null) {
94
+ try {
95
+ await unlink(entryPath);
96
+ } catch (error) {
97
+ return isErrorCode(error, 'ENOENT');
98
+ }
99
+ }
100
+ try {
101
+ await rmdir(lockPath);
102
+ return true;
103
+ } catch (error) {
104
+ return isErrorCode(error, 'ENOENT');
105
+ }
106
+ };
107
+
108
+ export const inspectBrokerLock = async (
109
+ lockPath: string,
110
+ staleMs: number,
111
+ ): Promise<BrokerLockAvailability> => {
112
+ const generation = await readExistingGeneration(lockPath);
113
+ if (generation.kind === 'missing') {
114
+ return 'retry';
115
+ }
116
+ if (generation.kind === 'unknown') {
117
+ return 'blocked';
118
+ }
119
+ if (Date.now() - generation.modifiedAt < staleMs) {
120
+ return generation.kind === 'valid' ? 'live' : 'blocked';
121
+ }
122
+ return (await removeObservedGeneration(lockPath, generation.entryPath))
123
+ ? 'retry'
124
+ : 'blocked';
125
+ };
@@ -0,0 +1,32 @@
1
+ import { utimes } from 'node:fs/promises';
2
+ import {
3
+ isMainThread,
4
+ parentPort,
5
+ Worker,
6
+ workerData,
7
+ } from 'node:worker_threads';
8
+
9
+ type LeaseWorkerData = {
10
+ readonly holderPath: string;
11
+ readonly intervalMs: number;
12
+ };
13
+
14
+ if (!isMainThread) {
15
+ const { holderPath, intervalMs } = workerData as LeaseWorkerData;
16
+ const timer = setInterval(
17
+ () => utimes(holderPath, new Date(), new Date()).catch(() => undefined),
18
+ intervalMs,
19
+ );
20
+ parentPort?.on('close', () => clearInterval(timer));
21
+ }
22
+
23
+ export const startBrokerLockLease = (
24
+ holderPath: string,
25
+ intervalMs: number,
26
+ ): Worker => {
27
+ const lease = new Worker(import.meta.url, {
28
+ workerData: { holderPath, intervalMs } satisfies LeaseWorkerData,
29
+ });
30
+ lease.unref();
31
+ return lease;
32
+ };