@davidvornholt/standards 0.21.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.
package/README.md CHANGED
@@ -29,7 +29,7 @@ standards poller --print-units --config <path>
29
29
 
30
30
  `dependabot --write` regenerates the composed `.github/dependabot.yml` from the canonical `.github/dependabot.base.yml` and the optional repo-owned `.github/dependabot.local.yml`; `init` and `sync` do the same automatically. `dependabot --check` verifies the generated file still matches its sources, and `doctor`/`check` include the same verification. The overlay is deliberately lean and additive only: new update blocks for repo-specific ecosystems, top-level private registry definitions, and `ignore` or `registries` entries appended by repeating a canonical block's normalized target. Matching blocks cannot add labels, groups, cooldowns, pull-request limits, or other policy. Update blocks for the same ecosystem and target branch must not select intersecting directories after Dependabot's path normalization. A plural `directories` target is expanded as a glob when it contains `*`, `?`, or both `[` and `]`: `*` and bare `**` stay within one path segment, `**/` matches zero or more directory segments, `?` matches one non-separator character, and bracket expressions match character sets or ranges. Singular `directory` values are always literal.
31
31
 
32
- `dev-env` composes each workspace's dev environment from three layers sharing one shape — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`), each mapping portable environment variable names to string values: the tracked plain `config/dev.yaml`, the SOPS-encrypted `secrets/dev.yaml` (required, decrypted via sops), and the gitignored plain `config/dev.local.yaml`, applied in that order with later layers overriding per key. A key declared in both tracked shared layers is a validation problem — a value is either configuration or a secret — while a local override of either is the file's purpose. Plain layers are optional and may be comment-only; `config/dev.local.yaml` must itself be gitignored because it may override secret values. The command transactionally writes every declared workspace's `.env.local` with owner-only permissions and a do-not-edit header naming the contributing sources. It also discovers direct `apps/*` and `packages/*` directories containing `package.json` and removes a stale `.env.local` only when its first line is that exact header; hand-owned files, edited headers, nested directories, and symlinked paths are left untouched. Writes and removals commit or roll back as one transaction, preserving prior contents, modes, and absent files. The final files and the transaction's actual random `.env.local.standards-*.tmp` and `.env.local.standards-*.bak` paths must all be gitignored before staging starts. Existing consumers must add those two artifact patterns themselves because `.gitignore` is seeded only during `init`; `sync` does not change it. Its Bun-specific dotenv codec exactly round-trips every value it accepts without adding helper environment variables; a value the codec cannot encode exactly is gathered as a validation problem instead of being changed silently. Validation gathers all document, containment, destination-type, workspace, and gitignore problems before writing anything; symlink destinations, duplicate resolved destinations, changed parent-directory identities, and symlinked workspace paths are rejected without following the link. Cleanup trouble after a completed commit is reported loudly as a warning rather than falsely reporting generation failure. Parent identities are checked immediately before each path operation; an unavoidable swap in the syscall-sized interval after a check remains outside the non-hostile maintainer threat model. The canonical `justfile` exposes the command as `just dev-env-generate`, and `just dev-refresh` chains a `just secrets edit dev` session into regeneration.
32
+ `dev-env` composes each workspace's dev environment from three layers sharing one workspace shape — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`): the tracked plain `config/dev.yaml`, the SOPS-encrypted `secrets/dev.yaml` (required, decrypted via sops), and the gitignored plain `config/dev.local.yaml`, applied in that order with later layers overriding per key. Secret-layer env values are strings; a plain-layer env value may be a string or the brokered reference described below. A key declared in both tracked shared layers is a validation problem — a value is either configuration or a secret — while a local override of either is the file's purpose. Plain layers are optional and may be comment-only; `config/dev.local.yaml` must itself be gitignored because it may override secret values. The command transactionally writes every declared workspace's `.env.local` with owner-only permissions and a do-not-edit header naming the contributing sources. It also discovers direct `apps/*` and `packages/*` directories containing `package.json` and removes a stale `.env.local` only when its first line is that exact header; hand-owned files, edited headers, nested directories, and symlinked paths are left untouched. Writes and removals commit or roll back as one transaction, preserving prior contents, modes, and absent files. The final files and the transaction's actual random `.env.local.standards-*.tmp` and `.env.local.standards-*.bak` paths must all be gitignored before staging starts. Existing consumers must add those two artifact patterns themselves because `.gitignore` is seeded only during `init`; `sync` does not change it. Its Bun-specific dotenv codec exactly round-trips every value it accepts without adding helper environment variables; a value the codec cannot encode exactly is gathered as a validation problem instead of being changed silently. Validation gathers all document, containment, destination-type, workspace, and gitignore problems before writing anything; symlink destinations, duplicate resolved destinations, changed parent-directory identities, and symlinked workspace paths are rejected without following the link. Cleanup trouble after a completed commit is reported loudly as a warning rather than falsely reporting generation failure. Parent identities are checked immediately before each path operation; an unavoidable swap in the syscall-sized interval after a check remains outside the non-hostile maintainer threat model. The canonical `justfile` exposes the command as `just dev-env-generate`, and `just dev-refresh` chains a `just secrets edit dev` session into regeneration. A plain configuration layer may declare a **brokered S3 pair reference** instead of a literal: an object value `{ brokeredS3: <secrets-target>, key: <dotted.key>, part: access_key_id | secret_access_key }` pointing at a credential pair `creds add cloudflare --s3` minted into another SOPS target. An existing target name must bind exactly one contained file, either `infra/hosts/<name>/secrets.yaml` or `secrets/<name>.yaml`; both files existing is an ambiguity error, never host preference. The SOPS-encrypted `secrets/dev.yaml` must authorize every referenced pair with its exact `<secrets-target>:<dotted.key>` string under the reserved top-level `brokeredReferences` list. Plain layers reject that reserved key, and the secrets layer rejects reference objects: configuration selects a pair while encrypted policy authorizes its release into plaintext `.env.local`. Resolution rejects an unauthorized pair with the exact allowlist entry to add before invoking SOPS on that target. It then requires both `access_key_id` and `secret_access_key` to be strings, even when the reference selects only one part. Resolution runs after layer composition, so a later layer's literal overrides an earlier layer's reference and a reference that lost the merge is never decrypted; each authorized referenced target is decrypted once per run, and a missing target, ambiguous target, missing or partial pair, or unencodable resolved value is gathered as a named validation problem without exposing secret values. The secret value therefore keeps exactly one home — the broker's ledger. The loop closes on the broker side: after `creds add` verifies an exact referenced pair write, and after `creds apply` verifies a renewal in one, the command regenerates the dev env files itself, so a rotated pair reaches every generated `.env.local` without a manual step. Refresh evidence is pair-granular: a bearer sibling in the same SOPS file cannot trigger rendering of an unsafe pair, and a verified S3 sibling updates while an unsafe pair keeps its prior generated lines. If those prior owned lines cannot be proven and preserved, the refresh fails without changing any env file; every refresh failure is reported after the durable SOPS write remains committed.
33
33
 
34
34
  `check` also rejects the raw token formed by `biome-` + `ignore` anywhere in lock-listed canonical files. The intentionally blunt policy keeps synced sources compatible with consumer Biome configurations without invoking Biome or maintaining language-aware comment parsing.
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,6 +34,7 @@
34
34
  "src/creds-cloudflare-expiry.ts",
35
35
  "src/creds-cloudflare-id.ts",
36
36
  "src/creds-cloudflare-pagination.ts",
37
+ "src/creds-cloudflare-request.ts",
37
38
  "src/creds-cloudflare-zone.ts",
38
39
  "src/creds-commands.ts",
39
40
  "src/creds-args.ts",
@@ -65,6 +66,7 @@
65
66
  "src/creds-store-lock-holder.ts",
66
67
  "src/creds-store-lock-inspection.ts",
67
68
  "src/creds-store-lock-lease.ts",
69
+ "src/creds-target.ts",
68
70
  "src/dependabot-compose-input.ts",
69
71
  "src/dependabot-compose.ts",
70
72
  "src/dependabot-directory-character-class.ts",
@@ -72,6 +74,12 @@
72
74
  "src/dependabot-directory-overlap.ts",
73
75
  "src/dependabot-inspect.ts",
74
76
  "src/dependabot-update.ts",
77
+ "src/dev-env-brokered.ts",
78
+ "src/dev-env-brokered-allowlist.ts",
79
+ "src/dev-env-brokered-preserve.ts",
80
+ "src/dev-env-brokered-resolve.ts",
81
+ "src/dev-env-brokered-refresh.ts",
82
+ "src/dev-env-plain-layer.ts",
75
83
  "src/dev-env-compose.ts",
76
84
  "src/dev-env-document.ts",
77
85
  "src/dev-env-destination-duplicates.ts",
package/src/creds-add.ts CHANGED
@@ -19,10 +19,25 @@ import {
19
19
  s3PairPaths,
20
20
  } from './creds-r2';
21
21
  import { readEncryptedKeys, setSopsValues } from './creds-sops';
22
+ import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
22
23
 
23
24
  const DEFAULT_TTL_DAYS = 90;
24
25
  const DAY_MS = 86_400_000;
25
26
 
27
+ const addRefreshEvidence = (
28
+ format: DestinationFormat,
29
+ destination: { readonly target: string; readonly key: string },
30
+ ) =>
31
+ format === 's3'
32
+ ? [
33
+ {
34
+ target: destination.target,
35
+ key: destination.key,
36
+ safety: 'verified' as const,
37
+ },
38
+ ]
39
+ : [];
40
+
26
41
  const printSuccess = (input: {
27
42
  readonly name: string;
28
43
  readonly permissions: ReadonlyArray<string>;
@@ -168,5 +183,8 @@ export const runCredsAddCloudflare = async (
168
183
  : DEFAULT_R2_JURISDICTION,
169
184
  policies: resolved.policies,
170
185
  });
171
- return true;
186
+ return await refreshDevEnvForSopsWrites(
187
+ consumer,
188
+ addRefreshEvidence(format, context.dest),
189
+ );
172
190
  };
@@ -5,10 +5,9 @@ import {
5
5
  type DecodedTokenCondition,
6
6
  decodeTokenCondition,
7
7
  } from './creds-cloudflare-condition';
8
+ import { requestCloudflare } from './creds-cloudflare-request';
8
9
  import { isRecord } from './github-settings-parse';
9
10
 
10
- const API_ROOT = 'https://api.cloudflare.com/client/v4';
11
-
12
11
  export type CfResult<T> =
13
12
  | { readonly ok: true; readonly value: T }
14
13
  | { readonly ok: false; readonly problem: string };
@@ -57,14 +56,11 @@ export const cfRequest = async (
57
56
  path: string,
58
57
  body?: unknown,
59
58
  ): Promise<CfResult<Envelope>> => {
60
- const response = await fetch(`${API_ROOT}${path}`, {
61
- method,
62
- headers: {
63
- authorization: `Bearer ${token}`,
64
- ...(body === undefined ? {} : { 'content-type': 'application/json' }),
65
- },
66
- body: body === undefined ? undefined : JSON.stringify(body),
67
- });
59
+ const requested = await requestCloudflare(token, method, path, body);
60
+ if (!requested.ok) {
61
+ return requested;
62
+ }
63
+ const response = requested.value;
68
64
  let parsed: unknown = null;
69
65
  try {
70
66
  parsed = (await response.json()) as unknown;
@@ -0,0 +1,31 @@
1
+ const API_ROOT = 'https://api.cloudflare.com/client/v4';
2
+
3
+ type CloudflareRequestResult =
4
+ | { readonly ok: true; readonly value: Response }
5
+ | { readonly ok: false; readonly problem: string };
6
+
7
+ export const requestCloudflare = async (
8
+ token: string,
9
+ method: string,
10
+ path: string,
11
+ body: unknown,
12
+ ): Promise<CloudflareRequestResult> => {
13
+ try {
14
+ const response = await fetch(`${API_ROOT}${path}`, {
15
+ method,
16
+ headers: {
17
+ authorization: `Bearer ${token}`,
18
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
19
+ },
20
+ body: body === undefined ? undefined : JSON.stringify(body),
21
+ });
22
+ return { ok: true, value: response };
23
+ } catch (error) {
24
+ return {
25
+ ok: false,
26
+ problem: `${method} ${path}: request failed: ${
27
+ error instanceof Error ? error.message : String(error)
28
+ }`,
29
+ };
30
+ }
31
+ };
package/src/creds-dest.ts CHANGED
@@ -11,16 +11,22 @@ import {
11
11
  readBrokerStore,
12
12
  resolveBrokerPath,
13
13
  } from './creds-store';
14
+ import {
15
+ isSafeSecretsTargetName,
16
+ resolveTargetRelResult,
17
+ } from './creds-target';
14
18
  import { resolveGithubRepo } from './github-api';
15
19
  import { isRecord } from './github-settings-parse';
16
20
 
17
- const SAFE_TARGET = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/u;
18
-
19
21
  export type CredsDestination = {
20
22
  readonly target: string;
21
23
  readonly key: string;
22
24
  };
23
25
  export type SecretsTarget = { readonly target: string; readonly rel: string };
26
+ export type SecretsTargetInventory = {
27
+ readonly targets: ReadonlyArray<SecretsTarget>;
28
+ readonly problems: ReadonlyArray<string>;
29
+ };
24
30
  export type ResolvedContext = {
25
31
  readonly repo: string;
26
32
  readonly rel: string;
@@ -34,7 +40,7 @@ export const parseDestination = (raw: string): CredsDestination | null => {
34
40
  const key = raw.slice(separator + 1);
35
41
  return separator > 0 &&
36
42
  separator < raw.length - 1 &&
37
- SAFE_TARGET.test(target) &&
43
+ isSafeSecretsTargetName(target) &&
38
44
  parseSopsKeyPath(key) !== null
39
45
  ? { target, key }
40
46
  : null;
@@ -88,38 +94,38 @@ const isHostTargetCandidate = (consumer: string, name: string): boolean => {
88
94
 
89
95
  export const listSecretsTargets = (
90
96
  consumer: string,
91
- ): ReadonlyArray<SecretsTarget> => {
97
+ ): SecretsTargetInventory => {
92
98
  const flat = listDir(consumer, 'secrets')
93
99
  .filter(isYamlSecrets)
94
100
  .map((name) => ({
95
101
  target: name.slice(0, -'.yaml'.length),
96
102
  rel: `secrets/${name}`,
97
103
  }))
98
- .filter(({ target }) => SAFE_TARGET.test(target));
104
+ .filter(({ target }) => isSafeSecretsTargetName(target));
99
105
  const hosts = listDir(consumer, 'infra/hosts')
100
106
  .filter(
101
- (name) => SAFE_TARGET.test(name) && isHostTargetCandidate(consumer, name),
107
+ (name) =>
108
+ isSafeSecretsTargetName(name) && isHostTargetCandidate(consumer, name),
102
109
  )
103
110
  .map((name) => ({
104
111
  target: name,
105
112
  rel: `infra/hosts/${name}/secrets.yaml`,
106
113
  }));
107
- return [...flat, ...hosts];
108
- };
109
-
110
- export const resolveTargetRel = (
111
- consumer: string,
112
- target: string,
113
- ): string | null => {
114
- if (!SAFE_TARGET.test(target)) {
115
- return null;
114
+ const targets = [...flat, ...hosts];
115
+ const relsByTarget = new Map<string, Array<string>>();
116
+ for (const { target, rel } of targets) {
117
+ const rels = relsByTarget.get(target) ?? [];
118
+ rels.push(rel);
119
+ relsByTarget.set(target, rels);
116
120
  }
117
- const host = `infra/hosts/${target}/secrets.yaml`;
118
- if (isContainedSopsPath(consumer, host, 'file')) {
119
- return host;
120
- }
121
- const flat = `secrets/${target}.yaml`;
122
- return isContainedSopsPath(consumer, flat, 'file') ? flat : null;
121
+ const problems = [...relsByTarget.entries()].flatMap(([target, rels]) =>
122
+ rels.length > 1
123
+ ? [
124
+ `secrets target "${target}" is ambiguous because ${rels.join(' and ')} both claim that identity; rename one target so the name binds exactly one encrypted file`,
125
+ ]
126
+ : [],
127
+ );
128
+ return { targets, problems };
123
129
  };
124
130
 
125
131
  export const resolveContext = async (
@@ -144,14 +150,23 @@ export const resolveContext = async (
144
150
  );
145
151
  return null;
146
152
  }
147
- const rel = resolveTargetRel(consumer, dest.target);
148
- if (rel === null) {
153
+ const resolved = resolveTargetRelResult(consumer, dest.target);
154
+ if (!resolved.ok) {
149
155
  console.error(
150
- `standards creds: secrets target "${dest.target}" not found; create it with \`just secrets edit ${dest.target}\` first`,
156
+ `standards creds: ${
157
+ resolved.kind === 'missing'
158
+ ? `${resolved.problem}; create it with \`just secrets edit ${dest.target}\` first`
159
+ : resolved.problem
160
+ }`,
151
161
  );
152
162
  return null;
153
163
  }
154
- return { repo, rel, dest, store: await readBrokerStore(resolveBrokerPath()) };
164
+ return {
165
+ repo,
166
+ rel: resolved.rel,
167
+ dest,
168
+ store: await readBrokerStore(resolveBrokerPath()),
169
+ };
155
170
  };
156
171
 
157
172
  export const selectAccount = (
@@ -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
  };
@@ -1,7 +1,10 @@
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';
5
8
  import { reportCredsPlan } from './creds-plan-report';
6
9
  import { revokePlannedToken } from './creds-plan-revoke';
7
10
  import type { AccountToken, PlannedAction } from './creds-plan-types';
@@ -11,12 +14,21 @@ import {
11
14
  readBrokerStore,
12
15
  resolveBrokerPath,
13
16
  } from './creds-store';
17
+ import { refreshDevEnvForSopsWrites } from './dev-env-brokered-refresh';
14
18
  import { resolveGithubRepo } from './github-api';
15
19
 
16
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
+ }
17
29
  const keysByTarget = new Map<string, ReadonlySet<string>>();
18
30
  const targetKeys = await Promise.all(
19
- listSecretsTargets(consumer).map(async ({ target, rel }) => ({
31
+ inventory.targets.map(async ({ target, rel }) => ({
20
32
  target,
21
33
  rel,
22
34
  keys: await readEncryptedKeys(consumer, rel),
@@ -61,18 +73,23 @@ const applyAction = (
61
73
  consumer: string,
62
74
  store: BrokerStore,
63
75
  action: PlannedAction,
64
- ): Promise<string | null> => {
76
+ ): Promise<RenewPlannedTokenResult> => {
65
77
  const account = store.cloudflare.find(
66
78
  (entry) => entry.accountId === action.accountId,
67
79
  );
68
80
  if (account === undefined) {
69
- return Promise.resolve(
70
- `${action.name}: account ${action.accountId} is not in the broker store`,
71
- );
81
+ return Promise.resolve({
82
+ failure: `${action.name}: account ${action.accountId} is not in the broker store`,
83
+ refreshEvidence: null,
84
+ });
85
+ }
86
+ if (action.kind === 'renew') {
87
+ return renewPlannedToken(consumer, account, action);
72
88
  }
73
- return action.kind === 'revoke'
74
- ? revokePlannedToken(account, action)
75
- : renewPlannedToken(consumer, account, action);
89
+ return revokePlannedToken(account, action).then((failure) => ({
90
+ failure,
91
+ refreshEvidence: null,
92
+ }));
76
93
  };
77
94
 
78
95
  const fail = (message: string): false => {
@@ -117,13 +134,18 @@ export const runCredsPlan = async (
117
134
  if (!apply || plan.actions.length === 0) {
118
135
  return true;
119
136
  }
120
- const failures = (
121
- await Promise.all(
122
- plan.actions.map((action) => applyAction(consumer, store, action)),
123
- )
124
- ).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
+ );
125
143
  for (const failure of failures) {
126
144
  console.error(`standards creds: ${failure}`);
127
145
  }
128
- 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;
129
151
  };
@@ -0,0 +1,57 @@
1
+ import { isContainedSopsPath } from './creds-sops-structure';
2
+
3
+ const SAFE_TARGET = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/u;
4
+
5
+ export const isSafeSecretsTargetName = (target: string): boolean =>
6
+ SAFE_TARGET.test(target);
7
+
8
+ export type TargetRelResolution =
9
+ | { readonly ok: true; readonly rel: string }
10
+ | {
11
+ readonly ok: false;
12
+ readonly kind: 'invalid' | 'missing' | 'ambiguous';
13
+ readonly problem: string;
14
+ };
15
+
16
+ export const resolveTargetRelResult = (
17
+ consumer: string,
18
+ target: string,
19
+ ): TargetRelResolution => {
20
+ if (!isSafeSecretsTargetName(target)) {
21
+ return {
22
+ ok: false,
23
+ kind: 'invalid',
24
+ problem: `secrets target "${target}" is not a safe target name`,
25
+ };
26
+ }
27
+ const host = `infra/hosts/${target}/secrets.yaml`;
28
+ const flat = `secrets/${target}.yaml`;
29
+ const hostExists = isContainedSopsPath(consumer, host, 'file');
30
+ const flatExists = isContainedSopsPath(consumer, flat, 'file');
31
+ if (hostExists && flatExists) {
32
+ return {
33
+ ok: false,
34
+ kind: 'ambiguous',
35
+ problem: `secrets target "${target}" is ambiguous because both ${flat} and ${host} exist; rename one target so the name binds exactly one encrypted file`,
36
+ };
37
+ }
38
+ if (hostExists) {
39
+ return { ok: true, rel: host };
40
+ }
41
+ if (flatExists) {
42
+ return { ok: true, rel: flat };
43
+ }
44
+ return {
45
+ ok: false,
46
+ kind: 'missing',
47
+ problem: `secrets target "${target}" does not exist`,
48
+ };
49
+ };
50
+
51
+ export const resolveTargetRel = (
52
+ consumer: string,
53
+ target: string,
54
+ ): string | null => {
55
+ const resolved = resolveTargetRelResult(consumer, target);
56
+ return resolved.ok ? resolved.rel : null;
57
+ };
@@ -0,0 +1,44 @@
1
+ import { parseDestination } from './creds-dest';
2
+ import type { DevEnvLayerKind } from './dev-env-document';
3
+
4
+ export const BROKERED_REFERENCES_KEY = 'brokeredReferences';
5
+
6
+ export type ParsedBrokeredAllowlist = {
7
+ readonly entries: ReadonlySet<string>;
8
+ readonly problems: ReadonlyArray<string>;
9
+ };
10
+
11
+ export const parseBrokeredAllowlist = (
12
+ source: string,
13
+ raw: unknown,
14
+ layerKind: DevEnvLayerKind,
15
+ ): ParsedBrokeredAllowlist => {
16
+ if (layerKind === 'configuration') {
17
+ return {
18
+ entries: new Set(),
19
+ problems: [
20
+ `${source} reserved top-level key "${BROKERED_REFERENCES_KEY}" belongs only in the SOPS-encrypted secrets/dev.yaml layer`,
21
+ ],
22
+ };
23
+ }
24
+ if (!Array.isArray(raw)) {
25
+ return {
26
+ entries: new Set(),
27
+ problems: [
28
+ `${source} "${BROKERED_REFERENCES_KEY}" must list exact "<target>:<dotted.key>" entries`,
29
+ ],
30
+ };
31
+ }
32
+ const entries = new Set<string>();
33
+ const problems: Array<string> = [];
34
+ for (const [index, entry] of raw.entries()) {
35
+ if (typeof entry !== 'string' || parseDestination(entry) === null) {
36
+ problems.push(
37
+ `${source} "${BROKERED_REFERENCES_KEY}" entry ${index + 1} must be an exact "<target>:<dotted.key>" string`,
38
+ );
39
+ } else {
40
+ entries.add(entry);
41
+ }
42
+ }
43
+ return { entries, problems };
44
+ };
@@ -0,0 +1,65 @@
1
+ import { lstatSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { DevEnvValue } from './dev-env-document';
4
+ import { preserveRenderedDotenvValues, renderDotenv } from './dev-env-dotenv';
5
+
6
+ export type DevEnvRunOptions = {
7
+ readonly preservedBrokeredReferences: ReadonlySet<string>;
8
+ };
9
+
10
+ type RenderInput = {
11
+ readonly consumer: string;
12
+ readonly rel: string;
13
+ readonly sourcePath: string;
14
+ readonly sources: ReadonlyArray<string>;
15
+ readonly resolvedEnv: Readonly<Record<string, string>>;
16
+ readonly composedEnv: Readonly<Record<string, DevEnvValue>>;
17
+ readonly preservedReferences: ReadonlySet<string>;
18
+ };
19
+
20
+ type RenderResult =
21
+ | { readonly ok: true; readonly content: string }
22
+ | { readonly ok: false; readonly problem: string };
23
+
24
+ const preservedKeys = (
25
+ env: Readonly<Record<string, DevEnvValue>>,
26
+ references: ReadonlySet<string>,
27
+ ): ReadonlyArray<string> =>
28
+ Object.entries(env).flatMap(([key, value]) =>
29
+ typeof value !== 'string' &&
30
+ references.has(`${value.brokeredS3}:${value.key}`)
31
+ ? [key]
32
+ : [],
33
+ );
34
+
35
+ export const renderDevEnvPreservingUnsafeReferences = (
36
+ input: RenderInput,
37
+ ): RenderResult => {
38
+ const rendered = renderDotenv(
39
+ input.sourcePath,
40
+ input.sources,
41
+ input.resolvedEnv,
42
+ );
43
+ const keys = preservedKeys(input.composedEnv, input.preservedReferences);
44
+ if (keys.length === 0) {
45
+ return { ok: true, content: rendered };
46
+ }
47
+ let previous: string;
48
+ try {
49
+ const path = join(input.consumer, input.rel);
50
+ const stat = lstatSync(path);
51
+ if (!stat.isFile() || stat.isSymbolicLink()) {
52
+ throw new Error('not a regular file');
53
+ }
54
+ previous = readFileSync(path, 'utf8');
55
+ } catch {
56
+ return {
57
+ ok: false,
58
+ problem: `${input.rel} has no readable prior generated values for unsafe brokered references`,
59
+ };
60
+ }
61
+ const preserved = preserveRenderedDotenvValues(previous, rendered, keys);
62
+ return preserved.ok
63
+ ? preserved
64
+ : { ok: false, problem: `${input.rel}: ${preserved.problem}` };
65
+ };