@davidvornholt/standards 0.26.3 → 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 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
- Bootstrap credentials and tokens still owned by an active repository are refused.
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
 
@@ -265,3 +296,7 @@ The worker accepts only exact issue or draft-PR revisions approved by a user wit
265
296
  The [root README](../../README.md) gives the short overview. The [adoption guide](../../docs/adoption.md) and [sync guide](../../docs/sync-and-ownership.md) explain repository ownership and first use.
266
297
 
267
298
  The poller timers use monotonic intervals: after boot, acknowledgement starts after one minute and work starts after two minutes. Missed downtime intervals are not replayed; each tick reads the current queue. The service budget reserves thirty minutes for scanning and setup in addition to its configured agent budget, with at most twelve watched repositories per poller. Split larger lists across pollers with independent API budgets; unusually slow provider or clone operations can exhaust that overhead and require investigating the service timeout before retrying.
299
+
300
+ ## Source text validation
301
+
302
+ `bun standards source-text` checks tracked source, configuration, and documentation files as UTF-8 text, regardless of Git’s binary classification. `bun standards check` includes this gate. Raw Unicode control characters are rejected except tab and LF; write control characters in fixtures and composite keys using escaped representations such as `\0` or `\u0000`. Binary asset formats and untracked files are outside this gate. Missing tracked source files and source symlinks fail validation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.26.3",
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",
@@ -162,6 +165,7 @@
162
165
  "src/screenshots-publish.ts",
163
166
  "src/screenshots-upload.ts",
164
167
  "src/sops-exec.ts",
168
+ "src/source-text.ts",
165
169
  "src/structure-check.ts",
166
170
  "src/structure-bun-version.ts",
167
171
  "src/structure-profile.ts",
@@ -172,7 +176,8 @@
172
176
  "src/structure-workspace.ts",
173
177
  "src/sync-policy.ts",
174
178
  "src/yaml-emit.ts",
175
- "src/yaml-parse.ts"
179
+ "src/yaml-parse.ts",
180
+ "src/sops-action-callers.ts"
176
181
  ],
177
182
  "scripts": {
178
183
  "lint": "biome check --error-on-warnings .",
package/src/cli.ts CHANGED
@@ -44,6 +44,8 @@ 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';
48
+ import { collectSourceTextProblems } from './source-text';
47
49
  import { collectStructureProblems } from './structure-check';
48
50
  import type { StructureProfile } from './structure-profile';
49
51
  import { hasSafeCommand } from './structure-script';
@@ -92,6 +94,7 @@ type Command =
92
94
  | 'help'
93
95
  | 'init'
94
96
  | 'poller'
97
+ | 'source-text'
95
98
  | 'structure'
96
99
  | 'sync';
97
100
 
@@ -153,13 +156,90 @@ const symlinkTargetProblem = (rel: string, target: string): string | null => {
153
156
  return null;
154
157
  };
155
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
+
156
225
  const symlinkTargetProblems = (
157
226
  entries: ReadonlyMap<string, ManagedEntry>,
227
+ requireManagedTarget = false,
158
228
  ): ReadonlyArray<string> =>
159
229
  [...entries]
160
- .map(([rel, entry]) =>
161
- entry.kind === 'symlink' ? symlinkTargetProblem(rel, entry.target) : null,
162
- )
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
+ })
163
243
  .filter((problem): problem is string => problem !== null);
164
244
 
165
245
  const parseManifest = (raw: unknown): Manifest => {
@@ -332,6 +412,10 @@ const mirrorPreconditionProblems = async (
332
412
  const locked = new Set(Object.keys(previous));
333
413
  const collisions = await Promise.all(
334
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
+ }
335
419
  const current = await inspectManagedPath(join(consumer, rel));
336
420
  if (current?.kind !== 'directory') {
337
421
  return null;
@@ -343,7 +427,8 @@ const mirrorPreconditionProblems = async (
343
427
  }),
344
428
  );
345
429
  return [
346
- ...symlinkTargetProblems(upstream),
430
+ ...symlinkTargetProblems(upstream, true),
431
+ ...(await collectSopsActionCallerProblems(consumer, upstream)),
347
432
  ...collisions.filter((problem): problem is string => problem !== null),
348
433
  ];
349
434
  };
@@ -362,16 +447,16 @@ const assertNoProblems = (
362
447
  };
363
448
 
364
449
  type PruneOutcome =
365
- | { readonly kind: 'absent' }
450
+ | { readonly kind: 'replaced'; readonly rel: string }
366
451
  | { readonly kind: 'delete'; readonly rel: string }
367
452
  | { readonly kind: 'retain'; readonly note: string }
368
453
  | { readonly kind: 'stale'; readonly note: string };
369
454
 
370
455
  // Two things the prune pass must never do: resolve through a symlink it (or an
371
456
  // older CLI's lock) put in the parent chain, and recursively delete a
372
- // destination the consumer turned into a directory. Every answer is decided
373
- // against the shape the mirror leaves behind, not the shape on disk right now,
374
- // so `--dry-run` predicts what the matching real run does.
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.
375
460
  const pruneOutcome = async (
376
461
  consumer: string,
377
462
  rel: string,
@@ -388,13 +473,21 @@ const pruneOutcome = async (
388
473
  // whether or not a directory still stands there while a dry run looks.
389
474
  const current =
390
475
  ancestor === null ? await inspectManagedPath(join(consumer, rel)) : null;
476
+ if (ancestor?.kind === 'file' && planned.has(ancestor.rel)) {
477
+ return { kind: 'replaced', rel };
478
+ }
391
479
  if (current === null) {
392
- return { kind: 'absent' };
480
+ return {
481
+ kind: 'stale',
482
+ note: `${rel} (already absent; nothing was deleted)`,
483
+ };
393
484
  }
394
485
  if (current.kind === 'directory') {
395
486
  return {
396
487
  kind: 'retain',
397
- note: `${rel}: removed upstream but now a directory this repository does not manage; delete it yourself if you no longer want it`,
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`,
398
491
  };
399
492
  }
400
493
  return { kind: 'delete', rel };
@@ -432,7 +525,16 @@ const prune = async (
432
525
  deleted.map((rel) => rm(join(consumer, rel), { force: true })),
433
526
  );
434
527
  }
435
- return { deleted, retained, stale };
528
+ return {
529
+ deleted: [
530
+ ...deleted,
531
+ ...outcomes.flatMap((outcome) =>
532
+ outcome.kind === 'replaced' ? [outcome.rel] : [],
533
+ ),
534
+ ],
535
+ retained,
536
+ stale,
537
+ };
436
538
  };
437
539
 
438
540
  // Mirror managed files into the consumer, deleting any previously-locked file
@@ -475,9 +577,8 @@ const mirror = async ({
475
577
  }
476
578
  if (!dryRun) {
477
579
  await mkdir(dirname(dest), { recursive: true });
478
- // The precondition above proved every path inside this directory is one
479
- // the lock already records, so replacing it restores the managed shape
480
- // 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.
481
582
  if (current?.kind === 'directory') {
482
583
  await rm(dest, { recursive: true, force: true });
483
584
  }
@@ -667,7 +768,7 @@ const runSync = async (
667
768
 
668
769
  // Offline drift detection: every locked file must still match its hash. Catches
669
770
  // local edits or deletions of canonical files. Does NOT detect upstream moving
670
- // on — see the "known limitation" in the standards repository README.
771
+ // on. Mirror preconditions reject those ancestor conflicts before any writes.
671
772
  const runCheck = async (consumer: string): Promise<boolean> => {
672
773
  const lock = await readLock(consumer);
673
774
  if (lock === null || Object.keys(lock.files).length === 0) {
@@ -694,7 +795,10 @@ const runCheck = async (consumer: string): Promise<boolean> => {
694
795
  return null;
695
796
  }),
696
797
  );
697
- const problems = results.filter((p): p is string => p !== null);
798
+ const problems = [
799
+ ...results.filter((p): p is string => p !== null),
800
+ ...symlinkTargetProblems(lockedFiles, true),
801
+ ];
698
802
  const directiveFiles =
699
803
  await findManagedFilesContainingBiomeDirectiveToken(lockedFiles);
700
804
  if (problems.length > 0) {
@@ -820,6 +924,15 @@ const prepareProspectiveDependabot = async (
820
924
  const existingLocal = await readTextIfPresent(
821
925
  join(consumer, DEPENDABOT_LOCAL_FILE),
822
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
+ }
823
936
  const local =
824
937
  existingLocal ??
825
938
  (localSeed === null || localSeed.kind !== 'file'
@@ -969,6 +1082,7 @@ Commands:
969
1082
  sync Mirror canonical files from upstream, regenerate the composed Dependabot config, and rewrite the lock
970
1083
  check Verify canonical files, extension seams, monorepo structure, and GitHub settings
971
1084
  doctor Validate extension seams only
1085
+ source-text Reject raw control characters in tracked source text
972
1086
  structure Validate monorepo structure rules only
973
1087
  dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
974
1088
  dev-env Compose each workspace's generated .env.local from its three dev layers and authorized broker-owned S3 pair references
@@ -1002,6 +1116,7 @@ const commandFromArg = (arg: string): Command => {
1002
1116
  arg === 'help' ||
1003
1117
  arg === 'init' ||
1004
1118
  arg === 'poller' ||
1119
+ arg === 'source-text' ||
1005
1120
  arg === 'structure' ||
1006
1121
  arg === 'sync'
1007
1122
  ) {
@@ -1209,10 +1324,24 @@ const runStructure = async (
1209
1324
  return true;
1210
1325
  };
1211
1326
 
1327
+ const runSourceTextCheck = (consumer: string): boolean => {
1328
+ const problems = collectSourceTextProblems(consumer);
1329
+ if (problems.length > 0) {
1330
+ console.error(`standards source-text: ${problems.length} problem(s):`);
1331
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
1332
+ return false;
1333
+ }
1334
+ console.log(
1335
+ 'standards source-text: tracked source text contains no forbidden control characters',
1336
+ );
1337
+ return true;
1338
+ };
1339
+
1212
1340
  const runCheckCommand = async (consumer: string): Promise<boolean> => {
1213
1341
  const driftIsClean = await runCheck(consumer);
1214
1342
  const integrationIsValid = await runDoctor(consumer);
1215
1343
  const structureIsValid = await runStructure(consumer, 'consumer');
1344
+ const sourceTextIsValid = runSourceTextCheck(consumer);
1216
1345
  // The GitHub gate activates with the synced declaration and then fails
1217
1346
  // closed: once .github/settings.json exists, an unreachable API or an
1218
1347
  // unreadable origin is a failure, not a skip.
@@ -1220,7 +1349,11 @@ const runCheckCommand = async (consumer: string): Promise<boolean> => {
1220
1349
  ? await runGithubCheckGate(consumer)
1221
1350
  : true;
1222
1351
  return (
1223
- driftIsClean && integrationIsValid && structureIsValid && githubIsConverged
1352
+ driftIsClean &&
1353
+ integrationIsValid &&
1354
+ structureIsValid &&
1355
+ sourceTextIsValid &&
1356
+ githubIsConverged
1224
1357
  );
1225
1358
  };
1226
1359
 
@@ -1296,6 +1429,7 @@ const runGateCommand = (
1296
1429
  | 'dev-env'
1297
1430
  | 'doctor'
1298
1431
  | 'github'
1432
+ | 'source-text'
1299
1433
  | 'structure',
1300
1434
  consumer: string,
1301
1435
  apply: boolean,
@@ -1313,6 +1447,9 @@ const runGateCommand = (
1313
1447
  if (command === 'doctor') {
1314
1448
  return runDoctor(consumer);
1315
1449
  }
1450
+ if (command === 'source-text') {
1451
+ return Promise.resolve(runSourceTextCheck(consumer));
1452
+ }
1316
1453
  if (command === 'structure') {
1317
1454
  return runStructure(consumer, profile);
1318
1455
  }
@@ -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
- loadOwnedGithubStore,
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 loadOwnedGithubStore(storePath);
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
- export const runCredsAddCloudflare = async (
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
+ );
@@ -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 when more than one is configured
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 }),
@@ -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: `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}`,
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
- loadOwnedGithubStore,
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 loadOwnedGithubStore(storePath);
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;