@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 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.26.4",
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 === 'symlink' ? symlinkTargetProblem(rel, entry.target) : null,
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: 'absent' }
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. Every answer is decided
375
- // against the shape the mirror leaves behind, not the shape on disk right now,
376
- // 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.
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 { kind: 'absent' };
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}: 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`,
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 { 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
+ };
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 above proved every path inside this directory is one
481
- // the lock already records, so replacing it restores the managed shape
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 — see the "known limitation" in the standards repository README.
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 = 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
+ ];
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'
@@ -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;
@@ -60,7 +60,10 @@ export const parseTokenName = (
60
60
  repo: string,
61
61
  ): BrokeredTokenRef | null => {
62
62
  const prefix = repoTokenPrefix(repo);
63
- if (!name.startsWith(prefix)) {
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);
@@ -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 { CloudflareBrokerAccount } from './creds-store';
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
- export const renewPlannedToken = async (
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
+ );
@@ -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 (token.name.startsWith(repoTokenPrefix(repo))) {
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 isInMintedNamespace(token.name)
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(', ')}; revoke duplicates manually before plan/apply`,
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;