@davidvornholt/standards 0.26.4 → 0.27.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 +32 -1
- package/package.json +6 -2
- package/src/cli.ts +127 -16
- package/src/creds-add-github.ts +12 -4
- package/src/creds-add.ts +14 -1
- package/src/creds-commands.ts +7 -1
- package/src/creds-github-apps.ts +71 -2
- package/src/creds-login-cloudflare.ts +6 -6
- package/src/creds-login-github.ts +6 -2
- package/src/creds-naming.ts +4 -1
- package/src/creds-plan-renew.ts +14 -2
- package/src/creds-plan-report.ts +1 -1
- package/src/creds-plan-unmanaged.ts +4 -5
- package/src/creds-plan.ts +1 -1
- package/src/creds-revoke-duplicate.ts +127 -0
- package/src/creds-revoke.ts +36 -15
- package/src/creds-store-lock-inspection.ts +26 -21
- package/src/creds-store-lock.ts +14 -1
- package/src/dependabot-directory-overlap.ts +100 -8
- package/src/dependabot-update.ts +11 -0
- package/src/dev-env-brokered-allowlist.ts +2 -2
- package/src/dev-env-brokered-preflight.ts +69 -0
- package/src/dev-env-brokered-preserve.ts +2 -1
- package/src/dev-env-brokered-refresh.ts +1 -1
- package/src/dev-env-brokered-resolve.ts +33 -22
- package/src/dev-env-brokered-source.ts +128 -0
- package/src/dev-env-brokered.ts +15 -2
- package/src/dev-env.ts +22 -0
- package/src/github-api.ts +5 -2
- package/src/github-apply.ts +26 -2
- package/src/github-bypass-actors.ts +2 -2
- package/src/sops-action-callers.ts +133 -0
- package/src/structure-check.ts +5 -1
package/README.md
CHANGED
|
@@ -148,6 +148,31 @@ brokeredReferences:
|
|
|
148
148
|
|
|
149
149
|
The referenced target must contain both `access_key_id` and `secret_access_key`. Every generated `.env.local`, transaction artifact, and `config/dev.local.yaml` must be ignored by Git.
|
|
150
150
|
|
|
151
|
+
A pair owned by another repository uses an explicit source descriptor. The checkout path is resolved relative to the consumer root; put machine-specific paths in ignored `config/dev.local.yaml`.
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
apps:
|
|
155
|
+
web:
|
|
156
|
+
S3_ACCESS_KEY_ID:
|
|
157
|
+
brokeredS3: garage
|
|
158
|
+
key: garage.development
|
|
159
|
+
part: access_key_id
|
|
160
|
+
source:
|
|
161
|
+
repository: owner/source
|
|
162
|
+
checkout: ../source
|
|
163
|
+
S3_SECRET_ACCESS_KEY:
|
|
164
|
+
brokeredS3: garage
|
|
165
|
+
key: garage.development
|
|
166
|
+
part: secret_access_key
|
|
167
|
+
source:
|
|
168
|
+
repository: owner/source
|
|
169
|
+
checkout: ../source
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The consumer's encrypted allowlist must authorize the exact repository, target, and key as `owner/source@garage:garage.development`. Local target authorization does not authorize an external source. The source must be a Git checkout root whose `origin` is that GitHub repository, with no symlinked checkout or target path. Targets remain contained regular SOPS files under the source repository. One source repository must resolve to one checkout per generation. This verifies a maintainer-managed checkout's identity; it does not attest untrusted local Git configuration.
|
|
173
|
+
|
|
174
|
+
Run `bun standards dev-env` in the consumer after updating the source checkout or rotating its pair. Both values are read from the one encrypted owner and only written to ignored generated environments. Rotation in a separate source repository cannot discover or update other workstations automatically. All selected source references and configuration are validated before any source target is decrypted; a failed generation leaves previous environments intact.
|
|
175
|
+
|
|
151
176
|
## github
|
|
152
177
|
|
|
153
178
|
```sh
|
|
@@ -205,7 +230,13 @@ To delete one Cloudflare token outside normal reconciliation:
|
|
|
205
230
|
bun standards creds revoke --account <account-id> --token-id <token-id>
|
|
206
231
|
```
|
|
207
232
|
|
|
208
|
-
|
|
233
|
+
For same-name brokered duplicates left by interrupted renewal, keep the stored key intact and select the unused token ID. `revoke` proves which credential is stored, refuses that token, and rechecks the destination before deleting the selected duplicate. Unreadable or unidentifiable stored credentials leave every token intact. A destination already missing its key can also be recovered after successful decryption.
|
|
234
|
+
|
|
235
|
+
Bootstrap credentials and singular tokens still owned by an active repository are refused.
|
|
236
|
+
|
|
237
|
+
### Prepare existing consumers for GitHub App client IDs
|
|
238
|
+
|
|
239
|
+
`standards creds add github --dest ci:ci.broker_app` writes and verifies `app_id`, `client_id`, and `private_key` together. Run it in each existing consumer, mirror the new `client_id` key with a placeholder in its `secrets/ci.example.yaml`, and commit both files before adopting a workflow that resolves `broker_app.client_id` and passes `client-id` to `actions/create-github-app-token@v3`. The numeric `app_id` remains available while existing workflows are migrated. Do not remove it until every local and canonical caller has switched. Canonical workflows continue using `app-id` until consumer provisioning is complete.
|
|
209
240
|
|
|
210
241
|
## screenshots
|
|
211
242
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"src/creds-prompt.ts",
|
|
57
57
|
"src/creds-r2.ts",
|
|
58
58
|
"src/creds-revoke.ts",
|
|
59
|
+
"src/creds-revoke-duplicate.ts",
|
|
59
60
|
"src/creds-sops.ts",
|
|
60
61
|
"src/creds-sops-editor.ts",
|
|
61
62
|
"src/creds-sops-structure.ts",
|
|
@@ -74,8 +75,10 @@
|
|
|
74
75
|
"src/dependabot-directory-overlap.ts",
|
|
75
76
|
"src/dependabot-inspect.ts",
|
|
76
77
|
"src/dependabot-update.ts",
|
|
78
|
+
"src/dev-env-brokered-source.ts",
|
|
77
79
|
"src/dev-env-brokered.ts",
|
|
78
80
|
"src/dev-env-brokered-allowlist.ts",
|
|
81
|
+
"src/dev-env-brokered-preflight.ts",
|
|
79
82
|
"src/dev-env-brokered-preserve.ts",
|
|
80
83
|
"src/dev-env-brokered-resolve.ts",
|
|
81
84
|
"src/dev-env-brokered-refresh.ts",
|
|
@@ -173,7 +176,8 @@
|
|
|
173
176
|
"src/structure-workspace.ts",
|
|
174
177
|
"src/sync-policy.ts",
|
|
175
178
|
"src/yaml-emit.ts",
|
|
176
|
-
"src/yaml-parse.ts"
|
|
179
|
+
"src/yaml-parse.ts",
|
|
180
|
+
"src/sops-action-callers.ts"
|
|
177
181
|
],
|
|
178
182
|
"scripts": {
|
|
179
183
|
"lint": "biome check --error-on-warnings .",
|
package/src/cli.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from './managed-path-ownership';
|
|
45
45
|
import { runPollerCommand } from './poller-commands';
|
|
46
46
|
import { runScreenshotsCommand } from './screenshots-commands';
|
|
47
|
+
import { collectSopsActionCallerProblems } from './sops-action-callers';
|
|
47
48
|
import { collectSourceTextProblems } from './source-text';
|
|
48
49
|
import { collectStructureProblems } from './structure-check';
|
|
49
50
|
import type { StructureProfile } from './structure-profile';
|
|
@@ -155,13 +156,90 @@ const symlinkTargetProblem = (rel: string, target: string): string | null => {
|
|
|
155
156
|
return null;
|
|
156
157
|
};
|
|
157
158
|
|
|
159
|
+
// Resolve components in filesystem order: missing/../real is dangling, and a
|
|
160
|
+
// symlink component must be followed before a subsequent .. is interpreted.
|
|
161
|
+
const MAX_SYMLINK_TRAVERSALS = 40;
|
|
162
|
+
type ManagedTargetTraversal = {
|
|
163
|
+
readonly pending: Array<string>;
|
|
164
|
+
readonly resolved: Array<string>;
|
|
165
|
+
traversals: number;
|
|
166
|
+
};
|
|
167
|
+
const resolveManagedComponent = (
|
|
168
|
+
component: string,
|
|
169
|
+
state: ManagedTargetTraversal,
|
|
170
|
+
entries: ReadonlyMap<string, ManagedEntry>,
|
|
171
|
+
): boolean => {
|
|
172
|
+
if (component === '' || component === '.') {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
if (component === '..') {
|
|
176
|
+
return state.resolved.pop() !== undefined;
|
|
177
|
+
}
|
|
178
|
+
const path = [...state.resolved, component].join('/');
|
|
179
|
+
const entry = entries.get(path);
|
|
180
|
+
if (entry?.kind === 'symlink') {
|
|
181
|
+
state.traversals += 1;
|
|
182
|
+
if (
|
|
183
|
+
state.traversals > MAX_SYMLINK_TRAVERSALS ||
|
|
184
|
+
symlinkTargetProblem(path, entry.target) !== null
|
|
185
|
+
) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
state.pending.unshift(...entry.target.split('/'));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
const directory = [...entries.keys()].some((key) =>
|
|
192
|
+
key.startsWith(`${path}/`),
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
!(directory || entry?.kind === 'file') ||
|
|
196
|
+
(state.pending.length > 0 && !directory)
|
|
197
|
+
) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
state.resolved.push(component);
|
|
201
|
+
return true;
|
|
202
|
+
};
|
|
203
|
+
const managedTargetExists = (
|
|
204
|
+
target: string,
|
|
205
|
+
entries: ReadonlyMap<string, ManagedEntry>,
|
|
206
|
+
): boolean => {
|
|
207
|
+
const state: ManagedTargetTraversal = {
|
|
208
|
+
pending: target.split('/'),
|
|
209
|
+
resolved: [],
|
|
210
|
+
// The original managed symlink also consumes one kernel traversal.
|
|
211
|
+
traversals: 1,
|
|
212
|
+
};
|
|
213
|
+
while (state.pending.length > 0) {
|
|
214
|
+
const component = state.pending.shift();
|
|
215
|
+
if (
|
|
216
|
+
component !== undefined &&
|
|
217
|
+
!resolveManagedComponent(component, state, entries)
|
|
218
|
+
) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
};
|
|
224
|
+
|
|
158
225
|
const symlinkTargetProblems = (
|
|
159
226
|
entries: ReadonlyMap<string, ManagedEntry>,
|
|
227
|
+
requireManagedTarget = false,
|
|
160
228
|
): ReadonlyArray<string> =>
|
|
161
229
|
[...entries]
|
|
162
|
-
.map(([rel, entry]) =>
|
|
163
|
-
entry.kind
|
|
164
|
-
|
|
230
|
+
.map(([rel, entry]) => {
|
|
231
|
+
if (entry.kind !== 'symlink') {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const problem = symlinkTargetProblem(rel, entry.target);
|
|
235
|
+
if (problem !== null) {
|
|
236
|
+
return problem;
|
|
237
|
+
}
|
|
238
|
+
const target = `${posix.dirname(rel)}/${entry.target}`;
|
|
239
|
+
return requireManagedTarget && !managedTargetExists(target, entries)
|
|
240
|
+
? `canonical symlink ${rel} targets ${target}, which is missing from the managed payload or forms a symlink cycle`
|
|
241
|
+
: null;
|
|
242
|
+
})
|
|
165
243
|
.filter((problem): problem is string => problem !== null);
|
|
166
244
|
|
|
167
245
|
const parseManifest = (raw: unknown): Manifest => {
|
|
@@ -334,6 +412,10 @@ const mirrorPreconditionProblems = async (
|
|
|
334
412
|
const locked = new Set(Object.keys(previous));
|
|
335
413
|
const collisions = await Promise.all(
|
|
336
414
|
[...upstream.keys()].map(async (rel) => {
|
|
415
|
+
const ancestor = await interposedAncestor(consumer, rel, upstream);
|
|
416
|
+
if (ancestor !== null) {
|
|
417
|
+
return `${rel} is below the ${ancestor.kind} ${ancestor.rel}; refusing to write through an existing file or symlink ancestor; move it aside before syncing this payload`;
|
|
418
|
+
}
|
|
337
419
|
const current = await inspectManagedPath(join(consumer, rel));
|
|
338
420
|
if (current?.kind !== 'directory') {
|
|
339
421
|
return null;
|
|
@@ -345,7 +427,8 @@ const mirrorPreconditionProblems = async (
|
|
|
345
427
|
}),
|
|
346
428
|
);
|
|
347
429
|
return [
|
|
348
|
-
...symlinkTargetProblems(upstream),
|
|
430
|
+
...symlinkTargetProblems(upstream, true),
|
|
431
|
+
...(await collectSopsActionCallerProblems(consumer, upstream)),
|
|
349
432
|
...collisions.filter((problem): problem is string => problem !== null),
|
|
350
433
|
];
|
|
351
434
|
};
|
|
@@ -364,16 +447,16 @@ const assertNoProblems = (
|
|
|
364
447
|
};
|
|
365
448
|
|
|
366
449
|
type PruneOutcome =
|
|
367
|
-
| { readonly kind: '
|
|
450
|
+
| { readonly kind: 'replaced'; readonly rel: string }
|
|
368
451
|
| { readonly kind: 'delete'; readonly rel: string }
|
|
369
452
|
| { readonly kind: 'retain'; readonly note: string }
|
|
370
453
|
| { readonly kind: 'stale'; readonly note: string };
|
|
371
454
|
|
|
372
455
|
// Two things the prune pass must never do: resolve through a symlink it (or an
|
|
373
456
|
// older CLI's lock) put in the parent chain, and recursively delete a
|
|
374
|
-
// destination the consumer turned into a directory.
|
|
375
|
-
//
|
|
376
|
-
//
|
|
457
|
+
// destination the consumer turned into a directory. Ancestor inspection uses
|
|
458
|
+
// planned canonical replacements so dry-run and application classify the same
|
|
459
|
+
// stale lock entries.
|
|
377
460
|
const pruneOutcome = async (
|
|
378
461
|
consumer: string,
|
|
379
462
|
rel: string,
|
|
@@ -390,13 +473,21 @@ const pruneOutcome = async (
|
|
|
390
473
|
// whether or not a directory still stands there while a dry run looks.
|
|
391
474
|
const current =
|
|
392
475
|
ancestor === null ? await inspectManagedPath(join(consumer, rel)) : null;
|
|
476
|
+
if (ancestor?.kind === 'file' && planned.has(ancestor.rel)) {
|
|
477
|
+
return { kind: 'replaced', rel };
|
|
478
|
+
}
|
|
393
479
|
if (current === null) {
|
|
394
|
-
return {
|
|
480
|
+
return {
|
|
481
|
+
kind: 'stale',
|
|
482
|
+
note: `${rel} (already absent; nothing was deleted)`,
|
|
483
|
+
};
|
|
395
484
|
}
|
|
396
485
|
if (current.kind === 'directory') {
|
|
397
486
|
return {
|
|
398
487
|
kind: 'retain',
|
|
399
|
-
note: `${rel}
|
|
488
|
+
note: [...planned.keys()].some((path) => path.startsWith(`${rel}/`))
|
|
489
|
+
? `${rel}: removed upstream as a file, but retained as a directory containing canonical paths; keep those managed descendants`
|
|
490
|
+
: `${rel}: removed upstream but now a directory this repository does not manage; delete it yourself if you no longer want it`,
|
|
400
491
|
};
|
|
401
492
|
}
|
|
402
493
|
return { kind: 'delete', rel };
|
|
@@ -434,7 +525,16 @@ const prune = async (
|
|
|
434
525
|
deleted.map((rel) => rm(join(consumer, rel), { force: true })),
|
|
435
526
|
);
|
|
436
527
|
}
|
|
437
|
-
return {
|
|
528
|
+
return {
|
|
529
|
+
deleted: [
|
|
530
|
+
...deleted,
|
|
531
|
+
...outcomes.flatMap((outcome) =>
|
|
532
|
+
outcome.kind === 'replaced' ? [outcome.rel] : [],
|
|
533
|
+
),
|
|
534
|
+
],
|
|
535
|
+
retained,
|
|
536
|
+
stale,
|
|
537
|
+
};
|
|
438
538
|
};
|
|
439
539
|
|
|
440
540
|
// Mirror managed files into the consumer, deleting any previously-locked file
|
|
@@ -477,9 +577,8 @@ const mirror = async ({
|
|
|
477
577
|
}
|
|
478
578
|
if (!dryRun) {
|
|
479
579
|
await mkdir(dirname(dest), { recursive: true });
|
|
480
|
-
// The precondition
|
|
481
|
-
//
|
|
482
|
-
// rather than destroying consumer work.
|
|
580
|
+
// The precondition proved every non-directory entry is locked. Empty
|
|
581
|
+
// directories are not tracked and are removed with the old shape.
|
|
483
582
|
if (current?.kind === 'directory') {
|
|
484
583
|
await rm(dest, { recursive: true, force: true });
|
|
485
584
|
}
|
|
@@ -669,7 +768,7 @@ const runSync = async (
|
|
|
669
768
|
|
|
670
769
|
// Offline drift detection: every locked file must still match its hash. Catches
|
|
671
770
|
// local edits or deletions of canonical files. Does NOT detect upstream moving
|
|
672
|
-
// on
|
|
771
|
+
// on. Mirror preconditions reject those ancestor conflicts before any writes.
|
|
673
772
|
const runCheck = async (consumer: string): Promise<boolean> => {
|
|
674
773
|
const lock = await readLock(consumer);
|
|
675
774
|
if (lock === null || Object.keys(lock.files).length === 0) {
|
|
@@ -696,7 +795,10 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
696
795
|
return null;
|
|
697
796
|
}),
|
|
698
797
|
);
|
|
699
|
-
const problems =
|
|
798
|
+
const problems = [
|
|
799
|
+
...results.filter((p): p is string => p !== null),
|
|
800
|
+
...symlinkTargetProblems(lockedFiles, true),
|
|
801
|
+
];
|
|
700
802
|
const directiveFiles =
|
|
701
803
|
await findManagedFilesContainingBiomeDirectiveToken(lockedFiles);
|
|
702
804
|
if (problems.length > 0) {
|
|
@@ -822,6 +924,15 @@ const prepareProspectiveDependabot = async (
|
|
|
822
924
|
const existingLocal = await readTextIfPresent(
|
|
823
925
|
join(consumer, DEPENDABOT_LOCAL_FILE),
|
|
824
926
|
);
|
|
927
|
+
if (
|
|
928
|
+
existingLocal === null &&
|
|
929
|
+
localSeed !== null &&
|
|
930
|
+
localSeed.kind !== 'file'
|
|
931
|
+
) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`source seed ${DEPENDABOT_LOCAL_FILE} must be a regular file; replace its symlink with the overlay content`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
825
936
|
const local =
|
|
826
937
|
existingLocal ??
|
|
827
938
|
(localSeed === null || localSeed.kind !== 'file'
|
package/src/creds-add-github.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { inspectDestinations } from './creds-add-preflight';
|
|
|
8
8
|
import { resolveContext } from './creds-dest';
|
|
9
9
|
import { verifyGithubAppInstallation } from './creds-github-app-api';
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
refreshOwnedGithubStore,
|
|
12
12
|
sameGithubApp,
|
|
13
13
|
selectGithubAppForRepo,
|
|
14
14
|
} from './creds-github-apps';
|
|
@@ -26,9 +26,11 @@ export const runCredsAddGithub = async (
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
const appIdPath = `${context.dest.key}.app_id`;
|
|
29
|
+
const clientIdPath = `${context.dest.key}.client_id`;
|
|
29
30
|
const privateKeyPath = `${context.dest.key}.private_key`;
|
|
30
31
|
const blocked = await inspectDestinations(consumer, context.rel, [
|
|
31
32
|
appIdPath,
|
|
33
|
+
clientIdPath,
|
|
32
34
|
privateKeyPath,
|
|
33
35
|
]);
|
|
34
36
|
if (blocked.problem !== null) {
|
|
@@ -36,7 +38,11 @@ export const runCredsAddGithub = async (
|
|
|
36
38
|
return false;
|
|
37
39
|
}
|
|
38
40
|
const storePath = resolveBrokerPath();
|
|
39
|
-
const loaded = await
|
|
41
|
+
const loaded = await refreshOwnedGithubStore(
|
|
42
|
+
storePath,
|
|
43
|
+
undefined,
|
|
44
|
+
context.repo.split('/')[0],
|
|
45
|
+
);
|
|
40
46
|
if (!loaded.ok) {
|
|
41
47
|
console.error(`standards creds: ${loaded.problem}`);
|
|
42
48
|
return false;
|
|
@@ -54,7 +60,7 @@ export const runCredsAddGithub = async (
|
|
|
54
60
|
console.error(`standards creds: ${installation.problem}`);
|
|
55
61
|
return false;
|
|
56
62
|
}
|
|
57
|
-
const { appId, privateKey, slug } = selected.value;
|
|
63
|
+
const { appId, clientId, privateKey, slug } = selected.value;
|
|
58
64
|
let writeResult:
|
|
59
65
|
| { readonly ok: true }
|
|
60
66
|
| { readonly ok: false; readonly problem: string };
|
|
@@ -75,6 +81,7 @@ export const runCredsAddGithub = async (
|
|
|
75
81
|
}
|
|
76
82
|
return setSopsValues(consumer, context.rel, [
|
|
77
83
|
{ path: appIdPath, value: String(appId) },
|
|
84
|
+
{ path: clientIdPath, value: clientId },
|
|
78
85
|
{ path: privateKeyPath, value: privateKey },
|
|
79
86
|
]);
|
|
80
87
|
});
|
|
@@ -89,6 +96,7 @@ export const runCredsAddGithub = async (
|
|
|
89
96
|
}
|
|
90
97
|
const verified = [
|
|
91
98
|
{ path: appIdPath, value: String(appId) },
|
|
99
|
+
{ path: clientIdPath, value: clientId },
|
|
92
100
|
{ path: privateKeyPath, value: privateKey },
|
|
93
101
|
].map(({ path, value }) => ({
|
|
94
102
|
path,
|
|
@@ -109,7 +117,7 @@ export const runCredsAddGithub = async (
|
|
|
109
117
|
return false;
|
|
110
118
|
}
|
|
111
119
|
console.log(
|
|
112
|
-
`standards creds: wrote App ${slug} credentials to ${context.rel} at ${context.dest.key}.{app_id,private_key}`,
|
|
120
|
+
`standards creds: wrote App ${slug} credentials to ${context.rel} at ${context.dest.key}.{app_id,client_id,private_key}`,
|
|
113
121
|
);
|
|
114
122
|
console.log(
|
|
115
123
|
' workflows mint short-lived installation tokens from these at runtime (actions/create-github-app-token), scoped per repository and permission',
|
package/src/creds-add.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
s3PairPaths,
|
|
20
20
|
} from './creds-r2';
|
|
21
21
|
import { readEncryptedKeys, setSopsValues } from './creds-sops';
|
|
22
|
+
import { resolveBrokerPath } from './creds-store';
|
|
23
|
+
import { withBrokerLock } from './creds-store-lock';
|
|
22
24
|
import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
|
|
23
25
|
|
|
24
26
|
const DEFAULT_TTL_DAYS = 90;
|
|
@@ -80,7 +82,7 @@ const warnOccupiedDestinations = (
|
|
|
80
82
|
}
|
|
81
83
|
};
|
|
82
84
|
|
|
83
|
-
|
|
85
|
+
const addCloudflareWhileLocked = async (
|
|
84
86
|
consumer: string,
|
|
85
87
|
options: {
|
|
86
88
|
readonly dest: string | undefined;
|
|
@@ -196,3 +198,14 @@ export const runCredsAddCloudflare = async (
|
|
|
196
198
|
addRefreshEvidence(format, context.dest),
|
|
197
199
|
);
|
|
198
200
|
};
|
|
201
|
+
|
|
202
|
+
// Share the transaction boundary with renewal and duplicate retirement. No
|
|
203
|
+
// contender may mint from an outdated collision check or retire an in-flight
|
|
204
|
+
// token before its destination is durably verified.
|
|
205
|
+
export const runCredsAddCloudflare = (
|
|
206
|
+
consumer: string,
|
|
207
|
+
options: Parameters<typeof addCloudflareWhileLocked>[1],
|
|
208
|
+
): Promise<boolean> =>
|
|
209
|
+
withBrokerLock(resolveBrokerPath(), () =>
|
|
210
|
+
addCloudflareWhileLocked(consumer, options),
|
|
211
|
+
);
|
package/src/creds-commands.ts
CHANGED
|
@@ -31,7 +31,7 @@ Options:
|
|
|
31
31
|
--dir <path> Repository to operate on (default: current directory)
|
|
32
32
|
--dest <target>:<key> SOPS destination, e.g. ci:ci.cloudflare_dns_token
|
|
33
33
|
--permissions <list> Comma-separated Cloudflare permission group names
|
|
34
|
-
--account <id> Cloudflare account
|
|
34
|
+
--account <id> Cloudflare account for login, add, revoke, or permissions; plan/apply always cover all accounts
|
|
35
35
|
--ttl-days <n> Token lifetime in days (default: 90)
|
|
36
36
|
--bucket <name> Scope a Cloudflare token to one R2 bucket
|
|
37
37
|
--zone <zone-id> Add a zone resource for zone-scoped groups (comma-separated IDs)
|
|
@@ -112,6 +112,12 @@ export const runCredsCommand = (
|
|
|
112
112
|
console.log(CREDS_USAGE);
|
|
113
113
|
return Promise.resolve(route === 'help');
|
|
114
114
|
}
|
|
115
|
+
if (flags.account !== undefined && (route === 'plan' || route === 'apply')) {
|
|
116
|
+
console.error(
|
|
117
|
+
`standards creds: ${route} always covers every configured account; --account is not supported for this command`,
|
|
118
|
+
);
|
|
119
|
+
return Promise.resolve(false);
|
|
120
|
+
}
|
|
115
121
|
const handlers: Readonly<Record<string, () => Promise<boolean>>> = {
|
|
116
122
|
'login github': () =>
|
|
117
123
|
runCredsLoginGithub({ name: flags.name, org: flags.org }),
|
package/src/creds-github-apps.ts
CHANGED
|
@@ -57,6 +57,72 @@ export const loadOwnedGithubStore = async (
|
|
|
57
57
|
return { ok: true, value: migrated };
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
// Refresh authenticated metadata before selecting by a mutable owner login.
|
|
61
|
+
// Owner changes are committed together, and concurrent credential replacement
|
|
62
|
+
// aborts the refresh rather than attaching stale metadata to a different App.
|
|
63
|
+
export const refreshOwnedGithubStore = async (
|
|
64
|
+
path: string,
|
|
65
|
+
lookupOwner: (
|
|
66
|
+
app: GithubBrokerApp,
|
|
67
|
+
) => Promise<GithubAppsResult<string>> = resolveGithubAppOwner,
|
|
68
|
+
requestedOwner?: string,
|
|
69
|
+
): Promise<GithubAppsResult<BrokerStore>> => {
|
|
70
|
+
const loaded = await loadOwnedGithubStore(path, lookupOwner);
|
|
71
|
+
if (!loaded.ok) {
|
|
72
|
+
return loaded;
|
|
73
|
+
}
|
|
74
|
+
const observed = loaded.value.github;
|
|
75
|
+
const hasDirectMatch = observed.some(
|
|
76
|
+
(app) => app.owner?.toLowerCase() === requestedOwner?.toLowerCase(),
|
|
77
|
+
);
|
|
78
|
+
const results = await Promise.all(
|
|
79
|
+
observed.map((app) =>
|
|
80
|
+
hasDirectMatch &&
|
|
81
|
+
app.owner?.toLowerCase() !== requestedOwner?.toLowerCase()
|
|
82
|
+
? Promise.resolve({ ok: true as const, value: app.owner ?? '' })
|
|
83
|
+
: lookupOwner(app),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
const failed = results.find((result) => !result.ok);
|
|
87
|
+
if (failed !== undefined && !failed.ok) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
problem: `cannot refresh authenticated GitHub App owners: ${failed.problem}; keep existing credentials and retry before creating another App`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const refreshed = observed.map((app, index) => {
|
|
94
|
+
const result = results[index];
|
|
95
|
+
return result?.ok ? { ...app, owner: result.value } : app;
|
|
96
|
+
});
|
|
97
|
+
if (refreshed.every((app, index) => app.owner === observed[index]?.owner)) {
|
|
98
|
+
return loaded;
|
|
99
|
+
}
|
|
100
|
+
const committed = { value: loaded.value };
|
|
101
|
+
try {
|
|
102
|
+
await updateBrokerStore(path, (current) => {
|
|
103
|
+
if (
|
|
104
|
+
current.github.length !== observed.length ||
|
|
105
|
+
!current.github.every((app, index) => {
|
|
106
|
+
const previous = observed[index];
|
|
107
|
+
return previous !== undefined && sameGithubApp(app, previous);
|
|
108
|
+
})
|
|
109
|
+
) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'GitHub Apps changed while authenticated owners were refreshed; retry',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
committed.value = { ...current, github: refreshed };
|
|
115
|
+
return committed.value;
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
problem: error instanceof Error ? error.message : String(error),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, value: committed.value };
|
|
124
|
+
};
|
|
125
|
+
|
|
60
126
|
// Private Apps belong to one owner. Selecting by repository owner keeps each
|
|
61
127
|
// owner's credentials separate; installation access is verified before export.
|
|
62
128
|
export const selectGithubAppForRepo = (
|
|
@@ -79,10 +145,13 @@ export const selectGithubAppForRepo = (
|
|
|
79
145
|
const loginHint =
|
|
80
146
|
apps.length === 0
|
|
81
147
|
? 'no broker GitHub Apps are configured'
|
|
82
|
-
: `configured owners: ${apps.map((app) => app.owner).join(', ')}`;
|
|
148
|
+
: `authenticated configured owners: ${apps.map((app) => app.owner).join(', ')}; if an account was renamed, update this checkout origin to the current owner shown here before retrying; do not create a duplicate App for a renamed account`;
|
|
83
149
|
return {
|
|
84
150
|
ok: false,
|
|
85
|
-
problem:
|
|
151
|
+
problem:
|
|
152
|
+
apps.length > 0
|
|
153
|
+
? `no broker GitHub App matches repository owner ${owner} (${loginHint}); verify the origin and existing App ownership before configuring a new App`
|
|
154
|
+
: `no broker GitHub App is configured for repository owner ${owner} (${loginHint}); run \`standards creds login github --org ${owner}\` for an organization, or run it without --org while signed in as ${owner}`,
|
|
86
155
|
};
|
|
87
156
|
};
|
|
88
157
|
|
|
@@ -83,6 +83,12 @@ export const verifyCloudflareBootstrapAuthority = async (
|
|
|
83
83
|
problem: `the token is named "${identified.value.name}", inside the broker's minted-token namespace — bootstrap credentials must remain distinguishable from minted credentials; rename it ${BROKER_IDENTITY_NAME} in the dashboard and re-run login`,
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
if (identified.value.name.toLowerCase() !== BROKER_IDENTITY_NAME) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
problem: `bootstrap tokens must use the reserved name ${BROKER_IDENTITY_NAME}; rename this token in the dashboard and re-run login so other machines cannot revoke it as unmanaged`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
86
92
|
return { ok: true, value: { tokenName: identified.value.name } };
|
|
87
93
|
};
|
|
88
94
|
|
|
@@ -136,12 +142,6 @@ export const runCredsLoginCloudflare = async (options: {
|
|
|
136
142
|
);
|
|
137
143
|
return false;
|
|
138
144
|
}
|
|
139
|
-
const { tokenName } = verified.value;
|
|
140
|
-
if (tokenName !== BROKER_IDENTITY_NAME) {
|
|
141
|
-
console.error(
|
|
142
|
-
`standards creds: warning — the token is named "${tokenName}", not "${BROKER_IDENTITY_NAME}"; rename it in the dashboard so it stays identifiable`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
145
|
await updateBrokerStore(storePath, (current) => {
|
|
146
146
|
if (current.cloudflare.some((entry) => entry.accountId === accountId)) {
|
|
147
147
|
throw new Error(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { openInBrowser } from './creds-browser';
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
refreshOwnedGithubStore,
|
|
5
5
|
sameGithubApp,
|
|
6
6
|
upsertGithubApp,
|
|
7
7
|
} from './creds-github-apps';
|
|
@@ -115,7 +115,11 @@ export const runCredsLoginGithub = async (
|
|
|
115
115
|
return false;
|
|
116
116
|
}
|
|
117
117
|
const storePath = resolveBrokerPath();
|
|
118
|
-
const loaded = await
|
|
118
|
+
const loaded = await refreshOwnedGithubStore(
|
|
119
|
+
storePath,
|
|
120
|
+
undefined,
|
|
121
|
+
options.org,
|
|
122
|
+
);
|
|
119
123
|
if (!loaded.ok) {
|
|
120
124
|
console.error(`standards creds: ${loaded.problem}`);
|
|
121
125
|
return false;
|
package/src/creds-naming.ts
CHANGED
|
@@ -60,7 +60,10 @@ export const parseTokenName = (
|
|
|
60
60
|
repo: string,
|
|
61
61
|
): BrokeredTokenRef | null => {
|
|
62
62
|
const prefix = repoTokenPrefix(repo);
|
|
63
|
-
if (
|
|
63
|
+
if (
|
|
64
|
+
!isInMintedNamespace(name) ||
|
|
65
|
+
name.slice(0, prefix.length).toLowerCase() !== prefix.toLowerCase()
|
|
66
|
+
) {
|
|
64
67
|
return null;
|
|
65
68
|
}
|
|
66
69
|
const rest = name.slice(prefix.length);
|
package/src/creds-plan-renew.ts
CHANGED
|
@@ -8,7 +8,8 @@ import type { PlannedAction } from './creds-plan-types';
|
|
|
8
8
|
import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
|
|
9
9
|
import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
|
|
10
10
|
import { verifySopsStoredValue } from './creds-sops-value';
|
|
11
|
-
import type
|
|
11
|
+
import { type CloudflareBrokerAccount, resolveBrokerPath } from './creds-store';
|
|
12
|
+
import { withBrokerLock } from './creds-store-lock';
|
|
12
13
|
import { resolveTargetRelResult } from './creds-target';
|
|
13
14
|
import type { BrokeredRefreshEvidence } from './dev-env-brokered-refresh';
|
|
14
15
|
|
|
@@ -63,7 +64,7 @@ const inspectRenewDestinations = async (
|
|
|
63
64
|
: null;
|
|
64
65
|
};
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
const renewWhileLocked = async (
|
|
67
68
|
consumer: string,
|
|
68
69
|
account: CloudflareBrokerAccount,
|
|
69
70
|
action: RenewAction,
|
|
@@ -151,3 +152,14 @@ export const renewPlannedToken = async (
|
|
|
151
152
|
refreshEvidence(action, 'verified'),
|
|
152
153
|
);
|
|
153
154
|
};
|
|
155
|
+
|
|
156
|
+
// Duplicate retirement uses this same lock. Hold it before minting until the
|
|
157
|
+
// replacement is durably verified and the old token's deletion has finished.
|
|
158
|
+
export const renewPlannedToken = (
|
|
159
|
+
consumer: string,
|
|
160
|
+
account: CloudflareBrokerAccount,
|
|
161
|
+
action: RenewAction,
|
|
162
|
+
): Promise<RenewPlannedTokenResult> =>
|
|
163
|
+
withBrokerLock(resolveBrokerPath(), () =>
|
|
164
|
+
renewWhileLocked(consumer, account, action),
|
|
165
|
+
);
|
package/src/creds-plan-report.ts
CHANGED
|
@@ -25,7 +25,7 @@ const revokeCommand = (token: UnmanagedToken, extra: string): string =>
|
|
|
25
25
|
// send the operator down a dead end. The remedy is the dashboard, which is
|
|
26
26
|
// also the only place the machine it belongs to can be confirmed.
|
|
27
27
|
const unmanagedRemedy = (token: UnmanagedToken): string =>
|
|
28
|
-
token.name === BROKER_IDENTITY_NAME
|
|
28
|
+
token.name.toLowerCase() === BROKER_IDENTITY_NAME
|
|
29
29
|
? "the reserved name for a machine's broker bootstrap credential, which `revoke` refuses; retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to"
|
|
30
30
|
: `retire it with ${revokeCommand(token, '')}`;
|
|
31
31
|
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import type { CloudflareToken } from './creds-cloudflare-api';
|
|
9
9
|
import {
|
|
10
10
|
type BrokeredTokenRef,
|
|
11
|
-
isInMintedNamespace,
|
|
12
11
|
parseAnyTokenName,
|
|
13
12
|
parseTokenName,
|
|
14
13
|
repoTokenPrefix,
|
|
@@ -39,7 +38,9 @@ const classifyAccountToken = (
|
|
|
39
38
|
if (token.status === 'expired') {
|
|
40
39
|
return { kind: 'ignored' };
|
|
41
40
|
}
|
|
42
|
-
if (
|
|
41
|
+
if (
|
|
42
|
+
token.name.toLowerCase().startsWith(repoTokenPrefix(repo).toLowerCase())
|
|
43
|
+
) {
|
|
43
44
|
return { kind: 'malformed' };
|
|
44
45
|
}
|
|
45
46
|
// A brokered name carries the repository that owns it, which normally
|
|
@@ -53,9 +54,7 @@ const classifyAccountToken = (
|
|
|
53
54
|
if (elsewhere !== null) {
|
|
54
55
|
return { kind: 'brokered-elsewhere', repo: elsewhere.repo };
|
|
55
56
|
}
|
|
56
|
-
return
|
|
57
|
-
? { kind: 'ignored' }
|
|
58
|
-
: { kind: 'unmanaged' };
|
|
57
|
+
return { kind: 'unmanaged' };
|
|
59
58
|
};
|
|
60
59
|
|
|
61
60
|
export type ManagedTokenRef = AccountToken & {
|
package/src/creds-plan.ts
CHANGED
|
@@ -133,7 +133,7 @@ const dispositionForGroup = (
|
|
|
133
133
|
const [first] = group;
|
|
134
134
|
return {
|
|
135
135
|
kind: 'finding',
|
|
136
|
-
finding: `ambiguous Cloudflare tokens have destination footprints intersecting in ${first?.ref.target}: ${group.map((candidate) => `${candidate.ref.key} (${candidate.accountId}/${candidate.token.id})`).join(', ')};
|
|
136
|
+
finding: `ambiguous Cloudflare tokens have destination footprints intersecting in ${first?.ref.target}: ${group.map((candidate) => `${candidate.ref.key} (${candidate.accountId}/${candidate.token.id})`).join(', ')}; for same-name duplicates, keep the encrypted key intact and use \`standards creds revoke --account <account-id> --token-id <duplicate-id>\` to retire only a token proven not to be stored; resolve other intersecting destinations before plan/apply`,
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
const [entry] = group;
|