@davidvornholt/standards 0.20.0 → 0.22.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 (40) hide show
  1. package/README.md +4 -3
  2. package/package.json +17 -1
  3. package/src/cli.ts +1 -1
  4. package/src/creds-add.ts +19 -1
  5. package/src/creds-args.ts +9 -0
  6. package/src/creds-cloudflare-account.ts +2 -2
  7. package/src/creds-cloudflare-api.ts +6 -10
  8. package/src/creds-cloudflare-id.ts +10 -0
  9. package/src/creds-cloudflare-request.ts +31 -0
  10. package/src/creds-cloudflare-zone.ts +3 -3
  11. package/src/creds-commands.ts +13 -2
  12. package/src/creds-dest.ts +40 -25
  13. package/src/creds-naming.ts +13 -0
  14. package/src/creds-plan-renew.ts +45 -13
  15. package/src/creds-plan-report.ts +87 -0
  16. package/src/creds-plan-run.ts +39 -26
  17. package/src/creds-plan-types.ts +19 -0
  18. package/src/creds-plan-unmanaged.ts +102 -0
  19. package/src/creds-plan.ts +25 -21
  20. package/src/creds-revoke.ts +162 -0
  21. package/src/creds-store-lock-holder.ts +8 -1
  22. package/src/creds-store-lock.ts +2 -1
  23. package/src/creds-target.ts +57 -0
  24. package/src/dev-env-brokered-allowlist.ts +44 -0
  25. package/src/dev-env-brokered-preserve.ts +65 -0
  26. package/src/dev-env-brokered-refresh.ts +129 -0
  27. package/src/dev-env-brokered-resolve.ts +158 -0
  28. package/src/dev-env-brokered.ts +83 -0
  29. package/src/dev-env-compose.ts +129 -0
  30. package/src/dev-env-destination-duplicates.ts +10 -10
  31. package/src/dev-env-destination-gitignore.ts +22 -0
  32. package/src/dev-env-destination.ts +52 -30
  33. package/src/dev-env-document.ts +104 -22
  34. package/src/dev-env-dotenv.ts +50 -5
  35. package/src/dev-env-plain-layer.ts +53 -0
  36. package/src/dev-env-reconciliation.ts +170 -0
  37. package/src/dev-env-secrets.ts +41 -0
  38. package/src/dev-env-transaction-recovery.ts +74 -11
  39. package/src/dev-env-transaction.ts +61 -15
  40. package/src/dev-env.ts +132 -79
@@ -4,15 +4,34 @@
4
4
  // mismatch deletes the replacement and preserves the old token.
5
5
 
6
6
  import { createAccountToken, deleteAccountToken } from './creds-cloudflare';
7
- import { resolveTargetRel } from './creds-dest';
8
7
  import type { PlannedAction } from './creds-plan-types';
9
8
  import { destinationWrites, s3AccessKeyPath, s3PairPaths } from './creds-r2';
10
9
  import { inspectSopsScalarDestination, setSopsValues } from './creds-sops';
11
10
  import { verifySopsStoredValue } from './creds-sops-value';
12
11
  import type { CloudflareBrokerAccount } from './creds-store';
12
+ import { resolveTargetRelResult } from './creds-target';
13
+ import type { BrokeredRefreshEvidence } from './dev-env-brokered-refresh';
13
14
 
14
15
  type RenewAction = Extract<PlannedAction, { readonly kind: 'renew' }>;
15
16
 
17
+ export type RenewPlannedTokenResult = {
18
+ readonly failure: string | null;
19
+ readonly refreshEvidence: BrokeredRefreshEvidence | null;
20
+ };
21
+
22
+ const renewalResult = (
23
+ failure: string | null,
24
+ evidence: BrokeredRefreshEvidence | null = null,
25
+ ): RenewPlannedTokenResult => ({ failure, refreshEvidence: evidence });
26
+
27
+ const refreshEvidence = (
28
+ action: RenewAction,
29
+ safety: BrokeredRefreshEvidence['safety'],
30
+ ): BrokeredRefreshEvidence | null =>
31
+ action.format === 's3'
32
+ ? { target: action.target, key: action.key, safety }
33
+ : null;
34
+
16
35
  const cleanupReplacement = async (
17
36
  account: CloudflareBrokerAccount,
18
37
  replacementId: string,
@@ -48,19 +67,20 @@ export const renewPlannedToken = async (
48
67
  consumer: string,
49
68
  account: CloudflareBrokerAccount,
50
69
  action: RenewAction,
51
- ): Promise<string | null> => {
70
+ ): Promise<RenewPlannedTokenResult> => {
52
71
  const { accountId, token: bootstrapToken } = account;
53
- const rel = resolveTargetRel(consumer, action.target);
54
- if (rel === null) {
55
- return `${action.name}: secrets target ${action.target} not found`;
72
+ const resolved = resolveTargetRelResult(consumer, action.target);
73
+ if (!resolved.ok) {
74
+ return renewalResult(`${action.name}: ${resolved.problem}`);
56
75
  }
76
+ const { rel } = resolved;
57
77
  const destinationProblem = await inspectRenewDestinations(
58
78
  consumer,
59
79
  rel,
60
80
  action,
61
81
  );
62
82
  if (destinationProblem !== null) {
63
- return destinationProblem;
83
+ return renewalResult(destinationProblem, refreshEvidence(action, 'unsafe'));
64
84
  }
65
85
  if (action.format === 's3') {
66
86
  const accessKey = verifySopsStoredValue(
@@ -70,7 +90,10 @@ export const renewPlannedToken = async (
70
90
  action.tokenId,
71
91
  );
72
92
  if (!(accessKey.ok && accessKey.matches)) {
73
- return `${action.name}: could not prove the stored S3 access key belongs to the managed token; renewal stopped before mutation`;
93
+ return renewalResult(
94
+ `${action.name}: could not prove the stored S3 access key belongs to the managed token; renewal stopped before mutation`,
95
+ refreshEvidence(action, 'unsafe'),
96
+ );
74
97
  }
75
98
  }
76
99
  const replacement = await createAccountToken(accountId, bootstrapToken, {
@@ -80,7 +103,7 @@ export const renewPlannedToken = async (
80
103
  condition: action.condition,
81
104
  });
82
105
  if (!replacement.ok) {
83
- return `${action.name}: ${replacement.problem}`;
106
+ return renewalResult(`${action.name}: ${replacement.problem}`);
84
107
  }
85
108
  const { id: replacementId, value } = replacement.value;
86
109
  const writes = destinationWrites(
@@ -99,7 +122,10 @@ export const renewPlannedToken = async (
99
122
  );
100
123
  if (unverifiable.length > 0) {
101
124
  const detail = unverifiable.join('; ');
102
- return `${action.name}: ${written.ok ? detail : `${written.problem}; ${detail}`}; account ${accountId} replacement ${replacementId} and old token ${action.tokenId} remain active because the stored value is unverifiable`;
125
+ return renewalResult(
126
+ `${action.name}: ${written.ok ? detail : `${written.problem}; ${detail}`}; account ${accountId} replacement ${replacementId} and old token ${action.tokenId} remain active because the stored value is unverifiable`,
127
+ refreshEvidence(action, 'unsafe'),
128
+ );
103
129
  }
104
130
  const mismatched = verified.flatMap(({ path, result }) =>
105
131
  result.ok && !result.matches ? [path] : [],
@@ -108,14 +134,20 @@ export const renewPlannedToken = async (
108
134
  const problem = written.ok
109
135
  ? `${action.name}: ${rel} at ${mismatched.join(', ')} now holds a value matching neither the old nor the replacement token; repair the stored value manually`
110
136
  : `${action.name}: ${written.problem}`;
111
- return cleanupReplacement(account, replacementId, action.tokenId, problem);
137
+ return renewalResult(
138
+ await cleanupReplacement(account, replacementId, action.tokenId, problem),
139
+ refreshEvidence(action, 'unsafe'),
140
+ );
112
141
  }
113
142
  const deleted = await deleteAccountToken(
114
143
  accountId,
115
144
  bootstrapToken,
116
145
  action.tokenId,
117
146
  );
118
- return deleted.ok
119
- ? null
120
- : `${action.name}: replacement ${replacementId} is stored, but old token ${action.tokenId} could not be revoked: ${deleted.problem}`;
147
+ return renewalResult(
148
+ deleted.ok
149
+ ? null
150
+ : `${action.name}: replacement ${replacementId} is stored, but old token ${action.tokenId} could not be revoked: ${deleted.problem}`,
151
+ refreshEvidence(action, 'verified'),
152
+ );
121
153
  };
@@ -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
+ };
@@ -1,7 +1,11 @@
1
1
  import { listSecretsTargets } from './creds-dest';
2
2
  import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
3
3
  import { computeCredsPlan } from './creds-plan';
4
- import { renewPlannedToken } from './creds-plan-renew';
4
+ import {
5
+ type RenewPlannedTokenResult,
6
+ renewPlannedToken,
7
+ } from './creds-plan-renew';
8
+ import { reportCredsPlan } from './creds-plan-report';
5
9
  import { revokePlannedToken } from './creds-plan-revoke';
6
10
  import type { AccountToken, PlannedAction } from './creds-plan-types';
7
11
  import { readEncryptedKeys } from './creds-sops';
@@ -10,12 +14,21 @@ import {
10
14
  readBrokerStore,
11
15
  resolveBrokerPath,
12
16
  } from './creds-store';
17
+ import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
13
18
  import { resolveGithubRepo } from './github-api';
14
19
 
15
20
  const gatherRepoState = async (consumer: string, store: BrokerStore) => {
21
+ const inventory = listSecretsTargets(consumer);
22
+ if (inventory.problems.length > 0) {
23
+ return {
24
+ keysByTarget: new Map<string, ReadonlySet<string>>(),
25
+ tokens: [] as Array<AccountToken>,
26
+ problems: inventory.problems,
27
+ };
28
+ }
16
29
  const keysByTarget = new Map<string, ReadonlySet<string>>();
17
30
  const targetKeys = await Promise.all(
18
- listSecretsTargets(consumer).map(async ({ target, rel }) => ({
31
+ inventory.targets.map(async ({ target, rel }) => ({
19
32
  target,
20
33
  rel,
21
34
  keys: await readEncryptedKeys(consumer, rel),
@@ -60,18 +73,23 @@ const applyAction = (
60
73
  consumer: string,
61
74
  store: BrokerStore,
62
75
  action: PlannedAction,
63
- ): Promise<string | null> => {
76
+ ): Promise<RenewPlannedTokenResult> => {
64
77
  const account = store.cloudflare.find(
65
78
  (entry) => entry.accountId === action.accountId,
66
79
  );
67
80
  if (account === undefined) {
68
- return Promise.resolve(
69
- `${action.name}: account ${action.accountId} is not in the broker store`,
70
- );
81
+ return Promise.resolve({
82
+ failure: `${action.name}: account ${action.accountId} is not in the broker store`,
83
+ refreshEvidence: null,
84
+ });
71
85
  }
72
- return action.kind === 'revoke'
73
- ? revokePlannedToken(account, action)
74
- : renewPlannedToken(consumer, account, action);
86
+ if (action.kind === 'renew') {
87
+ return renewPlannedToken(consumer, account, action);
88
+ }
89
+ return revokePlannedToken(account, action).then((failure) => ({
90
+ failure,
91
+ refreshEvidence: null,
92
+ }));
75
93
  };
76
94
 
77
95
  const fail = (message: string): false => {
@@ -109,30 +127,25 @@ export const runCredsPlan = async (
109
127
  tokens: state.tokens,
110
128
  now: new Date(),
111
129
  });
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
- );
130
+ reportCredsPlan(plan, apply);
123
131
  if (plan.findings.length > 0) {
124
132
  return fail('reconciliation aborted until every finding is resolved');
125
133
  }
126
134
  if (!apply || plan.actions.length === 0) {
127
135
  return true;
128
136
  }
129
- const failures = (
130
- await Promise.all(
131
- plan.actions.map((action) => applyAction(consumer, store, action)),
132
- )
133
- ).filter((failure): failure is string => failure !== null);
137
+ const results = await Promise.all(
138
+ plan.actions.map((action) => applyAction(consumer, store, action)),
139
+ );
140
+ const failures = results.flatMap(({ failure }) =>
141
+ failure === null ? [] : [failure],
142
+ );
134
143
  for (const failure of failures) {
135
144
  console.error(`standards creds: ${failure}`);
136
145
  }
137
- return failures.length === 0;
146
+ const refreshEvidence = results.flatMap(({ refreshEvidence: evidence }) =>
147
+ evidence === null ? [] : [evidence],
148
+ );
149
+ const refreshed = await refreshDevEnvForSopsWrites(consumer, refreshEvidence);
150
+ return failures.length === 0 && refreshed;
138
151
  };
@@ -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
  };