@davidvornholt/standards 0.19.0 → 0.21.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.
Files changed (56) hide show
  1. package/README.md +7 -5
  2. package/package.json +19 -1
  3. package/src/cli.ts +2 -2
  4. package/src/creds-add-cloudflare-commit.ts +2 -5
  5. package/src/creds-add-github.ts +82 -30
  6. package/src/creds-add-policy.ts +106 -26
  7. package/src/creds-add-preflight.ts +68 -0
  8. package/src/creds-add.ts +29 -20
  9. package/src/creds-args.ts +14 -0
  10. package/src/creds-cloudflare-account.ts +2 -2
  11. package/src/creds-cloudflare-id.ts +10 -0
  12. package/src/creds-cloudflare-zone.ts +40 -0
  13. package/src/creds-commands.ts +34 -11
  14. package/src/creds-github-app-api.ts +132 -0
  15. package/src/creds-github-apps.ts +107 -0
  16. package/src/creds-login-github-manifest.ts +38 -15
  17. package/src/creds-login-github.ts +78 -24
  18. package/src/creds-naming.ts +13 -0
  19. package/src/creds-plan-renew.ts +2 -5
  20. package/src/creds-plan-report.ts +87 -0
  21. package/src/creds-plan-run.ts +2 -11
  22. package/src/creds-plan-types.ts +19 -0
  23. package/src/creds-plan-unmanaged.ts +102 -0
  24. package/src/creds-plan.ts +25 -21
  25. package/src/creds-revoke.ts +162 -0
  26. package/src/creds-sops-value.ts +89 -0
  27. package/src/creds-sops.ts +0 -53
  28. package/src/creds-store-github.ts +121 -0
  29. package/src/creds-store-lock-holder.ts +105 -0
  30. package/src/creds-store-lock-inspection.ts +125 -0
  31. package/src/creds-store-lock-lease.ts +32 -0
  32. package/src/creds-store-lock.ts +28 -51
  33. package/src/creds-store.ts +14 -43
  34. package/src/dev-env-compose.ts +120 -0
  35. package/src/dev-env-destination-duplicates.ts +10 -10
  36. package/src/dev-env-destination-gitignore.ts +22 -0
  37. package/src/dev-env-destination.ts +52 -30
  38. package/src/dev-env-document.ts +19 -6
  39. package/src/dev-env-dotenv.ts +12 -3
  40. package/src/dev-env-reconciliation.ts +170 -0
  41. package/src/dev-env-secrets.ts +41 -0
  42. package/src/dev-env-transaction-recovery.ts +74 -11
  43. package/src/dev-env-transaction.ts +61 -15
  44. package/src/dev-env.ts +115 -63
  45. package/src/github-command-shared.ts +8 -1
  46. package/src/github-live-drift.ts +3 -3
  47. package/src/poller-closing-reference.ts +179 -0
  48. package/src/poller-fix-outcome.ts +9 -0
  49. package/src/poller-fix-publication.ts +5 -12
  50. package/src/poller-fix-run.ts +2 -3
  51. package/src/poller-fix-validation.ts +7 -8
  52. package/src/poller-outcome.ts +4 -1
  53. package/src/poller-protected-paths.ts +1 -25
  54. package/src/poller-protocol.ts +0 -42
  55. package/src/poller-review-publication.ts +2 -8
  56. package/src/poller-review-validation.ts +5 -12
@@ -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,105 @@
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
+ // Both codes mean the lock is no longer this owner's to clean up. ENOENT: it is
14
+ // already gone. ENOTEMPTY: a contender published its own generation into the
15
+ // directory during the moment the unlink left it empty — renaming onto an empty
16
+ // directory succeeds, which is exactly how a waiter takes the lock. Releasing
17
+ // runs from a finally, so throwing here would replace the result of an
18
+ // operation that already succeeded.
19
+ const RELEASABLE_RMDIR_CODES = ['ENOENT', 'ENOTEMPTY'] as const;
20
+
21
+ export type HeldBrokerLock = {
22
+ readonly holderPath: string;
23
+ readonly lease: Worker;
24
+ readonly lockPath: string;
25
+ };
26
+
27
+ const isErrorCode = (error: unknown, code: string): boolean =>
28
+ isRecord(error) && error.code === code;
29
+
30
+ const cleanupCandidate = async (
31
+ candidatePath: string,
32
+ holderPath: string,
33
+ ): Promise<void> => {
34
+ await unlink(holderPath).catch(() => undefined);
35
+ await rmdir(candidatePath).catch(() => undefined);
36
+ };
37
+
38
+ export const tryAcquireBrokerLock = async (
39
+ lockPath: string,
40
+ staleMs: number,
41
+ ): Promise<HeldBrokerLock | null> => {
42
+ const generation = randomUUID();
43
+ const candidatePath = `${lockPath}.pending-${generation}`;
44
+ const candidateHolderPath = join(
45
+ candidatePath,
46
+ `${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
47
+ );
48
+ await mkdir(candidatePath, { mode: OWNER_ONLY_DIR_MODE });
49
+ try {
50
+ await writeFile(candidateHolderPath, JSON.stringify({ generation }), {
51
+ flag: 'wx',
52
+ mode: OWNER_ONLY_FILE_MODE,
53
+ });
54
+ try {
55
+ await rename(candidatePath, lockPath);
56
+ } catch (error) {
57
+ if (isErrorCode(error, 'EEXIST') || isErrorCode(error, 'ENOTEMPTY')) {
58
+ await cleanupCandidate(candidatePath, candidateHolderPath);
59
+ return null;
60
+ }
61
+ throw error;
62
+ }
63
+ } catch (error) {
64
+ await cleanupCandidate(candidatePath, candidateHolderPath);
65
+ throw error;
66
+ }
67
+ const holderPath = join(
68
+ lockPath,
69
+ `${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
70
+ );
71
+ try {
72
+ const lease = startBrokerLockLease(
73
+ holderPath,
74
+ Math.max(1, Math.floor(staleMs / LEASE_DIVISOR)),
75
+ );
76
+ return { holderPath, lease, lockPath };
77
+ } catch (error) {
78
+ await unlink(holderPath).catch(() => undefined);
79
+ await rmdir(lockPath).catch(() => undefined);
80
+ throw error;
81
+ }
82
+ };
83
+
84
+ export const releaseBrokerLock = async ({
85
+ holderPath,
86
+ lease,
87
+ lockPath,
88
+ }: HeldBrokerLock): Promise<void> => {
89
+ await lease.terminate();
90
+ try {
91
+ await unlink(holderPath);
92
+ } catch (error) {
93
+ if (isErrorCode(error, 'ENOENT')) {
94
+ return;
95
+ }
96
+ throw error;
97
+ }
98
+ try {
99
+ await rmdir(lockPath);
100
+ } catch (error) {
101
+ if (!RELEASABLE_RMDIR_CODES.some((code) => isErrorCode(error, code))) {
102
+ throw error;
103
+ }
104
+ }
105
+ };
@@ -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
+ };
@@ -1,12 +1,18 @@
1
- // Broker store lock: a mkdir-based mutex serializing read-modify-write
2
- // cycles on broker.yaml. Store writes are sub-second, so a lock directory
3
- // older than the staleness window can only belong to a dead process and is
4
- // broken automatically; the timeout error names the directory and the manual
5
- // remediation for the pathological rest.
1
+ // Broker store lock: a fully initialized generation is atomically renamed into
2
+ // place before its operation starts. A worker-thread lease remains live while
3
+ // synchronous SOPS/Nix blocks the main event loop. Cleanup unlinks the exact
4
+ // generation token before removing the directory, so an old owner cannot
5
+ // remove a replacement — and finding one there instead of an empty directory
6
+ // is a no-op, never a failure of the operation being unwound.
6
7
 
7
- import { mkdir, rmdir, stat } from 'node:fs/promises';
8
+ import { mkdir } from 'node:fs/promises';
8
9
  import { dirname } from 'node:path';
9
- import { isRecord } from './github-settings-parse';
10
+ import {
11
+ type HeldBrokerLock,
12
+ releaseBrokerLock,
13
+ tryAcquireBrokerLock,
14
+ } from './creds-store-lock-holder';
15
+ import { inspectBrokerLock } from './creds-store-lock-inspection';
10
16
 
11
17
  const OWNER_ONLY_DIR_MODE = 0o700;
12
18
 
@@ -23,59 +29,30 @@ const DEFAULT_STALE_MS = 30_000;
23
29
  const sleep = (ms: number): Promise<void> =>
24
30
  new Promise((resolve) => setTimeout(resolve, ms));
25
31
 
26
- const tryAcquire = async (lockPath: string): Promise<boolean> => {
27
- try {
28
- await mkdir(lockPath, { mode: OWNER_ONLY_DIR_MODE });
29
- return true;
30
- } catch (error) {
31
- if (isRecord(error) && error.code === 'EEXIST') {
32
- return false;
33
- }
34
- throw error;
35
- }
36
- };
37
-
38
- // True when the contender may immediately retry: the lock vanished, or it
39
- // was stale and this process removed it (losing the removal race to another
40
- // contender also lands here — the retry's mkdir arbitrates).
41
- const breakIfStale = async (
42
- lockPath: string,
43
- staleMs: number,
44
- ): Promise<boolean> => {
45
- try {
46
- const info = await stat(lockPath);
47
- if (Date.now() - info.mtimeMs < staleMs) {
48
- return false;
49
- }
50
- await rmdir(lockPath);
51
- return true;
52
- } catch {
53
- return true;
54
- }
55
- };
56
-
57
32
  // One acquisition attempt: acquired, or cleared a stale/vanished lock (retry
58
- // immediately), or waited one retry slot; a live lock past the deadline
59
- // throws with the remediation hint.
33
+ // immediately), or waited one retry slot. Proven-live generations do not
34
+ // consume the recovery deadline; incomplete or ambiguous generations do.
60
35
  const attemptAcquire = async (
61
36
  lockPath: string,
62
37
  staleMs: number,
63
38
  retryMs: number,
64
39
  deadline: number,
65
- ): Promise<boolean> => {
66
- if (await tryAcquire(lockPath)) {
67
- return true;
40
+ ): Promise<HeldBrokerLock | null> => {
41
+ const held = await tryAcquireBrokerLock(lockPath, staleMs);
42
+ if (held !== null) {
43
+ return held;
68
44
  }
69
- if (await breakIfStale(lockPath, staleMs)) {
70
- return false;
45
+ const availability = await inspectBrokerLock(lockPath, staleMs);
46
+ if (availability === 'retry') {
47
+ return null;
71
48
  }
72
- if (Date.now() >= deadline) {
49
+ if (availability === 'blocked' && Date.now() >= deadline) {
73
50
  throw new Error(
74
51
  `lock timeout: another creds process holds ${lockPath}; if none is running, remove that directory and retry`,
75
52
  );
76
53
  }
77
54
  await sleep(retryMs);
78
- return false;
55
+ return null;
79
56
  };
80
57
 
81
58
  export const withBrokerLock = async <T>(
@@ -89,14 +66,14 @@ export const withBrokerLock = async <T>(
89
66
  const lockPath = `${path}.lock`;
90
67
  await mkdir(dirname(path), { recursive: true, mode: OWNER_ONLY_DIR_MODE });
91
68
  const deadline = Date.now() + timeoutMs;
92
- let acquired = false;
93
- while (!acquired) {
69
+ let held: HeldBrokerLock | null = null;
70
+ while (held === null) {
94
71
  // biome-ignore lint/performance/noAwaitInLoops: lock acquisition retries are inherently sequential.
95
- acquired = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
72
+ held = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
96
73
  }
97
74
  try {
98
75
  return await operation();
99
76
  } finally {
100
- await rmdir(lockPath);
77
+ await releaseBrokerLock(held);
101
78
  }
102
79
  };