@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.
- package/README.md +5 -4
- package/package.json +11 -1
- package/src/cli.ts +1 -1
- package/src/creds-add-cloudflare-commit.ts +2 -5
- package/src/creds-add-github.ts +82 -30
- package/src/creds-add-policy.ts +106 -26
- package/src/creds-add-preflight.ts +68 -0
- package/src/creds-add.ts +29 -20
- package/src/creds-args.ts +5 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +21 -9
- package/src/creds-github-app-api.ts +132 -0
- package/src/creds-github-apps.ts +107 -0
- package/src/creds-login-github-manifest.ts +38 -15
- package/src/creds-login-github.ts +78 -24
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-sops-value.ts +89 -0
- package/src/creds-sops.ts +0 -53
- package/src/creds-store-github.ts +121 -0
- package/src/creds-store-lock-holder.ts +98 -0
- package/src/creds-store-lock-inspection.ts +125 -0
- package/src/creds-store-lock-lease.ts +32 -0
- package/src/creds-store-lock.ts +27 -51
- package/src/creds-store.ts +14 -43
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/poller-closing-reference.ts +179 -0
- package/src/poller-fix-outcome.ts +9 -0
- package/src/poller-fix-publication.ts +5 -12
- package/src/poller-fix-run.ts +2 -3
- package/src/poller-fix-validation.ts +7 -8
- package/src/poller-outcome.ts +4 -1
- package/src/poller-protected-paths.ts +1 -25
- package/src/poller-protocol.ts +0 -42
- package/src/poller-review-publication.ts +2 -8
- package/src/poller-review-validation.ts +5 -12
|
@@ -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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
readonly
|
|
90
|
-
|
|
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
|
|
93
|
-
if (
|
|
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
|
|
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(
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
};
|
package/src/creds-plan-renew.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|