@davidvornholt/standards 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +7 -5
  2. package/package.json +19 -1
  3. package/src/cli.ts +2 -2
  4. package/src/creds-add-cloudflare-commit.ts +2 -5
  5. package/src/creds-add-github.ts +82 -30
  6. package/src/creds-add-policy.ts +106 -26
  7. package/src/creds-add-preflight.ts +68 -0
  8. package/src/creds-add.ts +29 -20
  9. package/src/creds-args.ts +14 -0
  10. package/src/creds-cloudflare-account.ts +2 -2
  11. package/src/creds-cloudflare-id.ts +10 -0
  12. package/src/creds-cloudflare-zone.ts +40 -0
  13. package/src/creds-commands.ts +34 -11
  14. package/src/creds-github-app-api.ts +132 -0
  15. package/src/creds-github-apps.ts +107 -0
  16. package/src/creds-login-github-manifest.ts +38 -15
  17. package/src/creds-login-github.ts +78 -24
  18. package/src/creds-naming.ts +13 -0
  19. package/src/creds-plan-renew.ts +2 -5
  20. package/src/creds-plan-report.ts +87 -0
  21. package/src/creds-plan-run.ts +2 -11
  22. package/src/creds-plan-types.ts +19 -0
  23. package/src/creds-plan-unmanaged.ts +102 -0
  24. package/src/creds-plan.ts +25 -21
  25. package/src/creds-revoke.ts +162 -0
  26. package/src/creds-sops-value.ts +89 -0
  27. package/src/creds-sops.ts +0 -53
  28. package/src/creds-store-github.ts +121 -0
  29. package/src/creds-store-lock-holder.ts +105 -0
  30. package/src/creds-store-lock-inspection.ts +125 -0
  31. package/src/creds-store-lock-lease.ts +32 -0
  32. package/src/creds-store-lock.ts +28 -51
  33. package/src/creds-store.ts +14 -43
  34. package/src/dev-env-compose.ts +120 -0
  35. package/src/dev-env-destination-duplicates.ts +10 -10
  36. package/src/dev-env-destination-gitignore.ts +22 -0
  37. package/src/dev-env-destination.ts +52 -30
  38. package/src/dev-env-document.ts +19 -6
  39. package/src/dev-env-dotenv.ts +12 -3
  40. package/src/dev-env-reconciliation.ts +170 -0
  41. package/src/dev-env-secrets.ts +41 -0
  42. package/src/dev-env-transaction-recovery.ts +74 -11
  43. package/src/dev-env-transaction.ts +61 -15
  44. package/src/dev-env.ts +115 -63
  45. package/src/github-command-shared.ts +8 -1
  46. package/src/github-live-drift.ts +3 -3
  47. package/src/poller-closing-reference.ts +179 -0
  48. package/src/poller-fix-outcome.ts +9 -0
  49. package/src/poller-fix-publication.ts +5 -12
  50. package/src/poller-fix-run.ts +2 -3
  51. package/src/poller-fix-validation.ts +7 -8
  52. package/src/poller-outcome.ts +4 -1
  53. package/src/poller-protected-paths.ts +1 -25
  54. package/src/poller-protocol.ts +0 -42
  55. package/src/poller-review-publication.ts +2 -8
  56. package/src/poller-review-validation.ts +5 -12
@@ -1,15 +1,20 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { openInBrowser } from './creds-browser';
3
+ import {
4
+ loadOwnedGithubStore,
5
+ sameGithubApp,
6
+ upsertGithubApp,
7
+ } from './creds-github-apps';
3
8
  import {
4
9
  buildAppManifest,
5
10
  convertManifestCode,
6
11
  createManifestState,
7
12
  MANIFEST_STATE_BYTES,
8
13
  manifestFormHtml,
14
+ resolveGithubAppName,
9
15
  } from './creds-login-github-manifest';
10
- import { BROKER_IDENTITY_NAME } from './creds-naming';
11
16
  import {
12
- readBrokerStore,
17
+ type GithubBrokerApp,
13
18
  resolveBrokerPath,
14
19
  updateBrokerStore,
15
20
  } from './creds-store';
@@ -84,50 +89,99 @@ export const waitForCode = (
84
89
  };
85
90
  export const githubInstallMessage = (installUrl: string): string =>
86
91
  `Install it only on the selected repositories that need broker credentials: ${installUrl}`;
87
- export const runCredsLoginGithub = async (options: {
88
- readonly name: string | undefined;
89
- readonly org: string | undefined;
90
- }): Promise<boolean> => {
92
+
93
+ type GithubLoginDependencies = {
94
+ readonly waitForCode: typeof waitForCode;
95
+ readonly convertManifestCode: typeof convertManifestCode;
96
+ readonly openInBrowser: typeof openInBrowser;
97
+ };
98
+
99
+ const DEFAULT_LOGIN_DEPENDENCIES: GithubLoginDependencies = {
100
+ waitForCode,
101
+ convertManifestCode,
102
+ openInBrowser,
103
+ };
104
+
105
+ export const runCredsLoginGithub = async (
106
+ options: {
107
+ readonly name: string | undefined;
108
+ readonly org: string | undefined;
109
+ },
110
+ dependencies: GithubLoginDependencies = DEFAULT_LOGIN_DEPENDENCIES,
111
+ ): Promise<boolean> => {
112
+ const resolvedName = resolveGithubAppName(options.name, options.org);
113
+ if (!resolvedName.ok) {
114
+ console.error(`standards creds: ${resolvedName.problem}`);
115
+ return false;
116
+ }
91
117
  const storePath = resolveBrokerPath();
92
- const store = await readBrokerStore(storePath);
93
- if (store.github !== null) {
94
- console.error(
95
- `standards creds: a broker GitHub App is already configured (${store.github.htmlUrl}); remove the "github" section from ${storePath} to re-run login`,
96
- );
118
+ const loaded = await loadOwnedGithubStore(storePath);
119
+ if (!loaded.ok) {
120
+ console.error(`standards creds: ${loaded.problem}`);
97
121
  return false;
98
122
  }
99
123
  const action =
100
124
  options.org === undefined
101
125
  ? 'https://github.com/settings/apps/new'
102
126
  : `https://github.com/organizations/${options.org}/settings/apps/new`;
103
- const name = options.name ?? BROKER_IDENTITY_NAME;
104
- const code = await waitForCode((port, state) =>
127
+ const code = await dependencies.waitForCode((port, state) =>
105
128
  manifestFormHtml(
106
129
  action,
107
130
  JSON.stringify(
108
- buildAppManifest(name, `http://127.0.0.1:${port}/callback`),
131
+ buildAppManifest(
132
+ resolvedName.value,
133
+ `http://127.0.0.1:${port}/callback`,
134
+ ),
109
135
  ),
110
136
  state,
111
137
  ),
112
138
  );
113
- const conversion = await convertManifestCode(code);
139
+ const conversion = await dependencies.convertManifestCode(code);
114
140
  if (!conversion.ok) {
115
141
  console.error(`standards creds: ${conversion.problem}`);
116
142
  return false;
117
143
  }
118
144
  const { app } = conversion;
119
- await updateBrokerStore(storePath, (current) => {
120
- if (current.github !== null) {
121
- throw new Error(
122
- `a broker GitHub App was configured while login was in progress (${current.github.htmlUrl}); the newly created App ${app.htmlUrl} was not stored and should be deleted`,
145
+ const observed = loaded.value.github.find(
146
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
147
+ );
148
+ let replaced: GithubBrokerApp | undefined;
149
+ try {
150
+ await updateBrokerStore(storePath, (current) => {
151
+ const currentEntry = current.github.find(
152
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
123
153
  );
124
- }
125
- return { ...current, github: app };
126
- });
127
- console.log(`standards creds: created App ${app.slug} (${app.htmlUrl})`);
154
+ const unchanged =
155
+ observed === undefined
156
+ ? currentEntry === undefined
157
+ : currentEntry !== undefined && sameGithubApp(currentEntry, observed);
158
+ if (!unchanged) {
159
+ throw new Error(
160
+ `the broker App for ${app.owner} changed while this App was being created`,
161
+ );
162
+ }
163
+ const upserted = upsertGithubApp(current.github, app);
164
+ replaced = upserted.replaced ?? undefined;
165
+ return { ...current, github: upserted.apps };
166
+ });
167
+ } catch (error) {
168
+ const message = error instanceof Error ? error.message : String(error);
169
+ console.error(
170
+ `standards creds: the newly created App ${app.htmlUrl} was not stored (${message}); retry login, and delete the new App if it is still absent from \`creds status\``,
171
+ );
172
+ return false;
173
+ }
174
+ console.log(
175
+ `standards creds: created App ${app.slug} for ${app.owner} (${app.htmlUrl})`,
176
+ );
128
177
  console.log(` credentials stored in ${storePath}`);
178
+ if (replaced !== undefined) {
179
+ console.log(
180
+ ` replaced App ${replaced.slug} for ${app.owner}; update its SOPS destinations, verify token minting, then delete the old App`,
181
+ );
182
+ }
129
183
  const installUrl = `${app.htmlUrl}/installations/new`;
130
184
  console.log(githubInstallMessage(installUrl));
131
- openInBrowser(installUrl);
185
+ dependencies.openInBrowser(installUrl);
132
186
  return true;
133
187
  };
@@ -80,3 +80,16 @@ export const parseTokenName = (
80
80
  }
81
81
  return ref;
82
82
  };
83
+
84
+ // The repository a minted name belongs to is carried by the name itself, so a
85
+ // command holding no repository — `creds revoke` is account-scoped — can still
86
+ // tell a token reconciliation owns from one nothing manages.
87
+ export const parseAnyTokenName = (name: string): BrokeredTokenRef | null => {
88
+ if (!isInMintedNamespace(name)) {
89
+ return null;
90
+ }
91
+ const [owner, repo] = name.slice(`${NAME_PREFIX}/`.length).split('/');
92
+ return owner === undefined || repo === undefined
93
+ ? null
94
+ : parseTokenName(name, `${owner}/${repo}`);
95
+ };
@@ -7,11 +7,8 @@ import { createAccountToken, deleteAccountToken } from './creds-cloudflare';
7
7
  import { resolveTargetRel } from './creds-dest';
8
8
  import type { PlannedAction } from './creds-plan-types';
9
9
  import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
10
- import {
11
- inspectSopsScalarDestination,
12
- setSopsValues,
13
- verifySopsStoredValue,
14
- } from './creds-sops';
10
+ import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
11
+ import { verifySopsStoredValue } from './creds-sops-value';
15
12
  import type { CloudflareBrokerAccount } from './creds-store';
16
13
 
17
14
  type RenewAction = Extract<PlannedAction, { readonly kind: 'renew' }>;
@@ -0,0 +1,87 @@
1
+ // The operator-facing rendering of a computed plan. Every block is labelled and
2
+ // printed only when it has entries, because the three lists say different
3
+ // things: what reconciliation will do, what it will never touch, and what no
4
+ // repository reconciles at all. The summary counts them apart for the same
5
+ // reason.
6
+
7
+ import { BROKER_IDENTITY_NAME } from './creds-naming';
8
+ import type {
9
+ BrokeredElsewhereToken,
10
+ CredsPlan,
11
+ PlannedAction,
12
+ UnmanagedToken,
13
+ } from './creds-plan-types';
14
+
15
+ // Each listed token is offered with the exact command that retires it rather
16
+ // than with identifiers to reassemble: `plan` already holds the account, and
17
+ // `revoke` demands it whenever the broker holds more than one.
18
+ const revokeCommand = (token: UnmanagedToken, extra: string): string =>
19
+ `\`standards creds revoke --account ${token.accountId} --token-id ${token.tokenId}${extra}\``;
20
+
21
+ // One unmanaged shape has no revoke command to offer: the reserved bootstrap
22
+ // name. This machine's own bootstrap is excluded from the listing by verified
23
+ // ID, so a row under that name is another machine's or a superseded one, and
24
+ // `revoke` refuses it by name — printing a command that always fails would
25
+ // send the operator down a dead end. The remedy is the dashboard, which is
26
+ // also the only place the machine it belongs to can be confirmed.
27
+ const unmanagedRemedy = (token: UnmanagedToken): string =>
28
+ token.name === BROKER_IDENTITY_NAME
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
+ : `retire it with ${revokeCommand(token, '')}`;
31
+
32
+ const reportActions = (
33
+ actions: ReadonlyArray<PlannedAction>,
34
+ apply: boolean,
35
+ ): void => {
36
+ if (actions.length === 0) {
37
+ return;
38
+ }
39
+ console.log(`standards creds: ${apply ? '' : 'planned '}actions`);
40
+ for (const action of actions) {
41
+ console.log(
42
+ ` ${apply ? '' : 'would '}${action.kind} ${action.name} (${action.reason})`,
43
+ );
44
+ }
45
+ };
46
+
47
+ const reportUnmanaged = (tokens: ReadonlyArray<UnmanagedToken>): void => {
48
+ if (tokens.length === 0) {
49
+ return;
50
+ }
51
+ console.log(
52
+ 'standards creds: unmanaged tokens — reconciliation mints nothing of this shape, and plan and apply never mutate them',
53
+ );
54
+ for (const token of tokens) {
55
+ console.log(
56
+ ` ${token.name} (${token.status}) — ${unmanagedRemedy(token)}`,
57
+ );
58
+ }
59
+ };
60
+
61
+ const reportBrokeredElsewhere = (
62
+ tokens: ReadonlyArray<BrokeredElsewhereToken>,
63
+ ): void => {
64
+ if (tokens.length === 0) {
65
+ return;
66
+ }
67
+ console.log(
68
+ 'standards creds: tokens brokered to another repository — never mutated here, and reconciled there only if a checkout still mints that exact name',
69
+ );
70
+ for (const token of tokens) {
71
+ console.log(
72
+ ` ${token.name} (${token.status}) — brokered to ${token.repo}; if no checkout mints this name any more — ${token.repo} renamed, transferred, or deleted — then nothing reconciles the token, and only then: ${revokeCommand(token, ' --force')}`,
73
+ );
74
+ }
75
+ };
76
+
77
+ export const reportCredsPlan = (plan: CredsPlan, apply: boolean): void => {
78
+ reportActions(plan.actions, apply);
79
+ reportUnmanaged(plan.unmanaged);
80
+ reportBrokeredElsewhere(plan.brokeredElsewhere);
81
+ for (const finding of plan.findings) {
82
+ console.error(`standards creds: ${finding}`);
83
+ }
84
+ console.log(
85
+ `standards creds: ${plan.actions.length} action(s), ${plan.findings.length} finding(s), ${plan.healthy} brokered token(s) healthy, ${plan.unmanaged.length} unmanaged token(s), ${plan.brokeredElsewhere.length} token(s) brokered to another repository`,
86
+ );
87
+ };
@@ -2,6 +2,7 @@ import { listSecretsTargets } from './creds-dest';
2
2
  import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
3
3
  import { computeCredsPlan } from './creds-plan';
4
4
  import { renewPlannedToken } from './creds-plan-renew';
5
+ import { reportCredsPlan } from './creds-plan-report';
5
6
  import { revokePlannedToken } from './creds-plan-revoke';
6
7
  import type { AccountToken, PlannedAction } from './creds-plan-types';
7
8
  import { readEncryptedKeys } from './creds-sops';
@@ -109,17 +110,7 @@ export const runCredsPlan = async (
109
110
  tokens: state.tokens,
110
111
  now: new Date(),
111
112
  });
112
- for (const action of plan.actions) {
113
- console.log(
114
- ` ${apply ? '' : 'would '}${action.kind} ${action.name} (${action.reason})`,
115
- );
116
- }
117
- for (const finding of plan.findings) {
118
- console.error(`standards creds: ${finding}`);
119
- }
120
- console.log(
121
- `standards creds: ${plan.actions.length} action(s), ${plan.findings.length} finding(s), ${plan.healthy} brokered token(s) healthy`,
122
- );
113
+ reportCredsPlan(plan, apply);
123
114
  if (plan.findings.length > 0) {
124
115
  return fail('reconciliation aborted until every finding is resolved');
125
116
  }
@@ -7,6 +7,21 @@ export type AccountToken = {
7
7
  readonly token: CloudflareToken;
8
8
  };
9
9
 
10
+ export type UnmanagedToken = {
11
+ readonly accountId: string;
12
+ readonly tokenId: string;
13
+ readonly name: string;
14
+ readonly status: string;
15
+ };
16
+
17
+ // A token the broker minted under another repository's name. That repository
18
+ // normally reconciles it, so it is not unmanaged — the distinction matters
19
+ // because it stops being true when that repository is renamed, transferred, or
20
+ // deleted, and this account is then the only place the token is still visible.
21
+ export type BrokeredElsewhereToken = UnmanagedToken & {
22
+ readonly repo: string;
23
+ };
24
+
10
25
  export type PlannedAction =
11
26
  | {
12
27
  readonly kind: 'revoke';
@@ -33,4 +48,8 @@ export type CredsPlan = {
33
48
  readonly actions: ReadonlyArray<PlannedAction>;
34
49
  readonly findings: ReadonlyArray<string>;
35
50
  readonly healthy: number;
51
+ // Tokens in the account the broker did not mint. Reported, never mutated.
52
+ readonly unmanaged: ReadonlyArray<UnmanagedToken>;
53
+ // Tokens the broker minted for another repository. Reported, never mutated.
54
+ readonly brokeredElsewhere: ReadonlyArray<BrokeredElsewhereToken>;
36
55
  };
@@ -0,0 +1,102 @@
1
+ // Reconciliation matches tokens to a repository by name and used to discard
2
+ // every other token in the account silently. That silence is where a forgotten
3
+ // credential hides: replacing a hand-made token means minting a brokered one
4
+ // and deleting the original, and nothing noticed when the second half was
5
+ // skipped. Classification makes the leftovers visible without giving the broker
6
+ // any power over them — a token named here is never mutated automatically.
7
+
8
+ import type { CloudflareToken } from './creds-cloudflare-api';
9
+ import {
10
+ type BrokeredTokenRef,
11
+ isInMintedNamespace,
12
+ parseAnyTokenName,
13
+ parseTokenName,
14
+ repoTokenPrefix,
15
+ } from './creds-naming';
16
+ import type {
17
+ AccountToken,
18
+ BrokeredElsewhereToken,
19
+ UnmanagedToken,
20
+ } from './creds-plan-types';
21
+
22
+ type Classification =
23
+ | { readonly kind: 'managed'; readonly ref: BrokeredTokenRef }
24
+ | { readonly kind: 'malformed' }
25
+ | { readonly kind: 'unmanaged' }
26
+ | { readonly kind: 'brokered-elsewhere'; readonly repo: string }
27
+ | { readonly kind: 'ignored' };
28
+
29
+ const classifyAccountToken = (
30
+ token: CloudflareToken,
31
+ repo: string,
32
+ ): Classification => {
33
+ const ref = parseTokenName(token.name, repo);
34
+ if (ref !== null) {
35
+ return { kind: 'managed', ref };
36
+ }
37
+ // An expired token grants nothing, and listing years of them would bury the
38
+ // live ones. Brokered tokens keep their own expiry handling in creds-plan.
39
+ if (token.status === 'expired') {
40
+ return { kind: 'ignored' };
41
+ }
42
+ if (token.name.startsWith(repoTokenPrefix(repo))) {
43
+ return { kind: 'malformed' };
44
+ }
45
+ // A brokered name carries the repository that owns it, which normally
46
+ // reconciles it. That premise fails exactly once: rename, transfer, or delete
47
+ // that repository and no checkout resolves to the old name, so nothing renews
48
+ // the token, nothing revokes it, and no repository would ever mention it.
49
+ // Every account sharer reporting it is the price of the orphan being seen at
50
+ // all. It stays out of the unmanaged bucket, which asserts something else —
51
+ // that the broker mints nothing of this shape anywhere.
52
+ const elsewhere = parseAnyTokenName(token.name);
53
+ if (elsewhere !== null) {
54
+ return { kind: 'brokered-elsewhere', repo: elsewhere.repo };
55
+ }
56
+ return isInMintedNamespace(token.name)
57
+ ? { kind: 'ignored' }
58
+ : { kind: 'unmanaged' };
59
+ };
60
+
61
+ export type ManagedTokenRef = AccountToken & {
62
+ readonly ref: BrokeredTokenRef;
63
+ };
64
+
65
+ export type TokenPartition = {
66
+ readonly managed: ReadonlyArray<ManagedTokenRef>;
67
+ readonly unmanaged: ReadonlyArray<UnmanagedToken>;
68
+ readonly brokeredElsewhere: ReadonlyArray<BrokeredElsewhereToken>;
69
+ readonly findings: ReadonlyArray<string>;
70
+ };
71
+
72
+ const reported = (entry: AccountToken): UnmanagedToken => ({
73
+ accountId: entry.accountId,
74
+ tokenId: entry.token.id,
75
+ name: entry.token.name,
76
+ status: entry.token.status,
77
+ });
78
+
79
+ export const partitionAccountTokens = (
80
+ tokens: ReadonlyArray<AccountToken>,
81
+ repo: string,
82
+ ): TokenPartition => {
83
+ const managed: Array<ManagedTokenRef> = [];
84
+ const unmanaged: Array<UnmanagedToken> = [];
85
+ const brokeredElsewhere: Array<BrokeredElsewhereToken> = [];
86
+ const findings: Array<string> = [];
87
+ for (const entry of tokens) {
88
+ const classification = classifyAccountToken(entry.token, repo);
89
+ if (classification.kind === 'managed') {
90
+ managed.push({ ...entry, ref: classification.ref });
91
+ } else if (classification.kind === 'malformed') {
92
+ findings.push(
93
+ `${entry.token.name} claims this repository's brokered namespace but is not a name this broker mints; rename it in the dashboard or retire it with \`standards creds revoke --account ${entry.accountId} --token-id ${entry.token.id}\``,
94
+ );
95
+ } else if (classification.kind === 'unmanaged') {
96
+ unmanaged.push(reported(entry));
97
+ } else if (classification.kind === 'brokered-elsewhere') {
98
+ brokeredElsewhere.push({ ...reported(entry), repo: classification.repo });
99
+ }
100
+ }
101
+ return { managed, unmanaged, brokeredElsewhere, findings };
102
+ };
package/src/creds-plan.ts CHANGED
@@ -3,23 +3,28 @@
3
3
  // naming scheme). A brokered token whose secret key vanished from SOPS is
4
4
  // revoked; one nearing expiry is replaced while copying its live policy and
5
5
  // lifetime. Secret keys without a brokered token are simply unmanaged — most
6
- // secrets are — and are never touched. Execution lives in creds-plan-run.ts.
6
+ // secrets are — and are never touched. Tokens the broker did not mint, and
7
+ // tokens it minted for another repository, are reported
8
+ // (creds-plan-unmanaged.ts) but never mutated. Execution lives in
9
+ // creds-plan-run.ts.
7
10
 
8
11
  import { cloudflareExpiresOn } from './creds-cloudflare-expiry';
9
- import { parseTokenName } from './creds-naming';
10
12
  import { groupByIntersectingFootprint } from './creds-plan-groups';
11
13
  import type {
12
14
  AccountToken,
13
15
  CredsPlan,
14
16
  PlannedAction,
15
17
  } from './creds-plan-types';
18
+ import {
19
+ type ManagedTokenRef,
20
+ partitionAccountTokens,
21
+ } from './creds-plan-unmanaged';
16
22
  import { destinationFormatOf, inferredDestinationFootprint } from './creds-r2';
17
23
 
18
24
  const DEFAULT_RENEW_WITHIN_DAYS = 30;
19
25
  const DAY_MS = 86_400_000;
20
26
 
21
- type ManagedToken = AccountToken & {
22
- readonly ref: { readonly target: string; readonly key: string };
27
+ type ManagedToken = ManagedTokenRef & {
23
28
  readonly footprint: ReadonlyArray<string>;
24
29
  };
25
30
 
@@ -145,23 +150,16 @@ export const computeCredsPlan = (input: {
145
150
  const renewWithin =
146
151
  (input.renewWithinDays ?? DEFAULT_RENEW_WITHIN_DAYS) * DAY_MS;
147
152
  const actions: Array<PlannedAction> = [];
148
- const findings: Array<string> = [];
153
+ const partition = partitionAccountTokens(input.tokens, input.repo);
154
+ const findings: Array<string> = [...partition.findings];
149
155
  let healthy = 0;
150
- const managed = input.tokens.flatMap((entry) => {
151
- const ref = parseTokenName(entry.token.name, input.repo);
152
- return ref === null
153
- ? []
154
- : [
155
- {
156
- ...entry,
157
- ref,
158
- footprint: inferredDestinationFootprint(
159
- input.keysByTarget.get(ref.target),
160
- ref.key,
161
- ),
162
- },
163
- ];
164
- });
156
+ const managed = partition.managed.map((entry) => ({
157
+ ...entry,
158
+ footprint: inferredDestinationFootprint(
159
+ input.keysByTarget.get(entry.ref.target),
160
+ entry.ref.key,
161
+ ),
162
+ }));
165
163
  const dispositions = groupByIntersectingFootprint(managed).map((group) =>
166
164
  dispositionForGroup(group, input, renewWithin),
167
165
  );
@@ -174,5 +172,11 @@ export const computeCredsPlan = (input: {
174
172
  healthy += 1;
175
173
  }
176
174
  }
177
- return { actions, findings, healthy };
175
+ return {
176
+ actions,
177
+ findings,
178
+ healthy,
179
+ unmanaged: partition.unmanaged,
180
+ brokeredElsewhere: partition.brokeredElsewhere,
181
+ };
178
182
  };
@@ -0,0 +1,162 @@
1
+ // One-shot retirement of a Cloudflare token the broker does not manage, named
2
+ // explicitly by ID. Replacing a hand-made credential means minting a brokered
3
+ // one and deleting the original; without this the second half could only be
4
+ // done in the dashboard. The power granted is deliberately momentary: nothing
5
+ // is recorded, nothing becomes automatic, and a human names the target every
6
+ // time. Tokens reconciliation owns are refused — those are retired by deleting
7
+ // their SOPS key and running `standards creds apply`, which keeps the secret
8
+ // and the provider in step.
9
+
10
+ import { deleteAccountToken } from './creds-cloudflare';
11
+ import { isCloudflareId } from './creds-cloudflare-id';
12
+ import { selectAccount } from './creds-dest';
13
+ import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
14
+ import {
15
+ BROKER_IDENTITY_NAME,
16
+ type BrokeredTokenRef,
17
+ parseAnyTokenName,
18
+ } from './creds-naming';
19
+ import { readBrokerStore, resolveBrokerPath } from './creds-store';
20
+ import { resolveGithubRepo } from './github-api';
21
+
22
+ const fail = (message: string): false => {
23
+ console.error(`standards creds: ${message}`);
24
+ return false;
25
+ };
26
+
27
+ // GitHub owner and repository names are case-insensitive, so a checkout cloned
28
+ // as `DavidVornholt/Example` owns the token minted as `davidvornholt/example`.
29
+ // An exact comparison would call that token foreign and let --force delete a
30
+ // live credential this very checkout reconciles. Owner and name are compared
31
+ // together: `otherowner/example` shares this repository's name and nothing
32
+ // else.
33
+ const sameRepo = (left: string, right: string): boolean =>
34
+ left.toLowerCase() === right.toLowerCase();
35
+
36
+ // A brokered name carries the repository that owns it, and the reconciled
37
+ // remedy — delete the SOPS key, run `apply` — only works in that repository.
38
+ // Naming a bare `<target>:<key>` to an operator standing somewhere else would
39
+ // have them destroy their own live credential of the same name, so the owning
40
+ // repository is always named and the remedy is placed where it runs.
41
+ const brokeredRefusal = ({
42
+ tokenId,
43
+ name,
44
+ brokered,
45
+ currentRepo,
46
+ force,
47
+ }: {
48
+ readonly tokenId: string;
49
+ readonly name: string;
50
+ readonly brokered: BrokeredTokenRef;
51
+ readonly currentRepo: string | null;
52
+ readonly force: boolean;
53
+ }): string | null => {
54
+ // Without a repository here, the one thing --force claims to override — that
55
+ // this checkout is not the owner — is the thing that could not be checked,
56
+ // so the override is refused rather than granted on an unevaluated guard.
57
+ if (currentRepo === null) {
58
+ return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout cannot resolve the GitHub repository from its origin remote, so whether this checkout owns the token could not be determined; --force widens only that ownership check, so here it has nothing to widen. Re-run from a checkout whose origin remote names a GitHub repository, or point --dir at one. If ${brokered.repo} still exists, the remedy runs there: delete ${brokered.target}:${brokered.key} from its SOPS target and run \`standards creds apply\` in that checkout`;
59
+ }
60
+ if (brokered.repo === currentRepo) {
61
+ return `token ${tokenId} is brokered as ${name} for this repository (${brokered.repo}); delete ${brokered.target}:${brokered.key} from this repository's SOPS target and run \`standards creds apply\`, which revokes it and keeps the secret in step`;
62
+ }
63
+ // Revoke compares repository names case-insensitively, but reconciliation
64
+ // matches the token name exactly, so a checkout whose origin differs only in
65
+ // capitalisation renews nothing and revokes nothing. Sending the operator to
66
+ // the reconciled remedy would prescribe a no-op.
67
+ if (sameRepo(brokered.repo, currentRepo)) {
68
+ return `token ${tokenId} is brokered as ${name}, and this checkout's origin names ${currentRepo}, which differs from ${brokered.repo} only in capitalisation; reconciliation matches the token name exactly, so from here it will neither renew nor revoke this token. Re-point the origin remote at ${brokered.repo} to bring the token back under reconciliation, or retire it in the Cloudflare dashboard`;
69
+ }
70
+ if (force) {
71
+ return null;
72
+ }
73
+ return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout is ${currentRepo}; the remedy runs there: delete ${brokered.target}:${brokered.key} from ${brokered.repo}'s SOPS target and run \`standards creds apply\` in that checkout. Pass --force only if ${brokered.repo} was renamed, transferred, or deleted, so no checkout resolves to that name and no apply will ever revoke this token`;
74
+ };
75
+
76
+ export const runCredsRevoke = async (options: {
77
+ readonly account: string | undefined;
78
+ readonly tokenId: string | undefined;
79
+ readonly dir: string;
80
+ readonly force: boolean;
81
+ }): Promise<boolean> => {
82
+ const { tokenId } = options;
83
+ if (tokenId === undefined) {
84
+ return fail(
85
+ 'revoke requires --token-id <id>; `standards creds plan` prints the ID inside every revoke command it offers, and the Cloudflare dashboard has the ID of anything that listing leaves out',
86
+ );
87
+ }
88
+ if (!isCloudflareId(tokenId)) {
89
+ return fail(
90
+ `not a token ID: ${tokenId}; --token-id takes the 32-character hexadecimal token ID`,
91
+ );
92
+ }
93
+ const store = await readBrokerStore(resolveBrokerPath());
94
+ const account = selectAccount(store, options.account);
95
+ if (account === null) {
96
+ return false;
97
+ }
98
+ const identified = await identifyCloudflareBootstrapAuthority(
99
+ account.accountId,
100
+ account.token,
101
+ );
102
+ if (!identified.ok) {
103
+ return fail(`account ${account.accountId}: ${identified.problem}`);
104
+ }
105
+ // The bootstrap token is matched by its verified ID rather than its name,
106
+ // because deleting it would strip the broker of every power it has and no
107
+ // rename should be able to disguise it.
108
+ if (identified.value.id === tokenId) {
109
+ return fail(
110
+ `token ${tokenId} is this account's broker bootstrap credential; revoking it would leave the broker unable to mint or reconcile anything`,
111
+ );
112
+ }
113
+ const target = identified.value.tokens.find((entry) => entry.id === tokenId);
114
+ if (target === undefined) {
115
+ return fail(
116
+ `no token ${tokenId} in account ${account.accountId}; revoke names a token in the account the bootstrap credential belongs to`,
117
+ );
118
+ }
119
+ // The broker store is machine-global and `creds login` is a per-machine
120
+ // bootstrap, so one account can hold several tokens under the reserved name
121
+ // — one per machine. Only this machine's is identified by ID above; the rest
122
+ // are refused by name, because deleting one locks another machine out of the
123
+ // account and the broker cannot re-mint its own root credential.
124
+ if (target.name === BROKER_IDENTITY_NAME) {
125
+ return fail(
126
+ `token ${tokenId} is named ${BROKER_IDENTITY_NAME}, the reserved name for a machine's broker bootstrap credential; this machine's own bootstrap is a different token, so this one belongs to another machine or is a superseded one — retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to`,
127
+ );
128
+ }
129
+ const brokered = parseAnyTokenName(target.name);
130
+ if (brokered !== null) {
131
+ const refusal = brokeredRefusal({
132
+ tokenId,
133
+ name: target.name,
134
+ brokered,
135
+ currentRepo: resolveGithubRepo(options.dir),
136
+ force: options.force,
137
+ });
138
+ if (refusal !== null) {
139
+ return fail(refusal);
140
+ }
141
+ }
142
+ const deleted = await deleteAccountToken(
143
+ account.accountId,
144
+ account.token,
145
+ tokenId,
146
+ );
147
+ if (!deleted.ok) {
148
+ return fail(`${target.name}: ${deleted.problem}`);
149
+ }
150
+ console.log(
151
+ `standards creds: revoked ${target.name} (${account.accountId}/${tokenId})`,
152
+ );
153
+ if (brokered !== null) {
154
+ console.log(
155
+ ` it was brokered to ${brokered.repo}; --force took it because this checkout is not that repository`,
156
+ );
157
+ }
158
+ console.log(
159
+ ' any stored copy of its value is now dead; replace it with `standards creds add cloudflare` if something still needs it',
160
+ );
161
+ return true;
162
+ };