@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.
@@ -0,0 +1,127 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isContainedPath } from './contained-path';
4
+ import { deleteAccountToken, verifyAccountToken } from './creds-cloudflare';
5
+ import type { CloudflareToken } from './creds-cloudflare-api';
6
+ import type { BrokeredTokenRef } from './creds-naming';
7
+ import { lookupS3Pair, s3PairPaths } from './creds-r2';
8
+ import { verifySopsStoredValue } from './creds-sops-value';
9
+ import type { CloudflareBrokerAccount } from './creds-store';
10
+ import { resolveBrokerPath } from './creds-store';
11
+ import { withBrokerLock } from './creds-store-lock';
12
+ import { resolveTargetRelResult } from './creds-target';
13
+ import { isRecord } from './github-settings-parse';
14
+ import { decryptSopsJson } from './sops-exec';
15
+
16
+ type Proof =
17
+ | {
18
+ readonly ok: true;
19
+ readonly id: string | null;
20
+ readonly values: ReadonlyArray<{
21
+ readonly path: string;
22
+ readonly value: string;
23
+ }>;
24
+ }
25
+ | { readonly ok: false };
26
+ const storedProof = async (
27
+ document: unknown,
28
+ key: string,
29
+ accountId: string,
30
+ ): Promise<Proof> => {
31
+ let node = document;
32
+ for (const segment of key.split('.')) {
33
+ if (!isRecord(node)) {
34
+ return { ok: false };
35
+ }
36
+ if (!Object.hasOwn(node, segment)) {
37
+ return { ok: true, id: null, values: [] };
38
+ }
39
+ node = node[segment];
40
+ }
41
+ if (typeof node === 'string') {
42
+ const verified = await verifyAccountToken(accountId, node);
43
+ return verified.ok && verified.value.status === 'active'
44
+ ? {
45
+ ok: true,
46
+ id: verified.value.id,
47
+ values: [{ path: key, value: node }],
48
+ }
49
+ : { ok: false };
50
+ }
51
+ const pair = lookupS3Pair(document, key);
52
+ if (!pair.ok) {
53
+ return { ok: false };
54
+ }
55
+ const [accessPath, secretPath] = s3PairPaths(key);
56
+ return {
57
+ ok: true,
58
+ id: pair.accessKeyId,
59
+ values: [
60
+ { path: accessPath, value: pair.accessKeyId },
61
+ { path: secretPath, value: pair.secretAccessKey },
62
+ ],
63
+ };
64
+ };
65
+
66
+ const unchangedStoredValues = (
67
+ consumer: string,
68
+ rel: string,
69
+ proof: Extract<Proof, { readonly ok: true }>,
70
+ ciphertext: Buffer,
71
+ ): boolean =>
72
+ proof.values.every(({ path, value }) => {
73
+ const result = verifySopsStoredValue(consumer, rel, path, value);
74
+ return result.ok && result.matches;
75
+ }) && readFileSync(join(consumer, rel)).equals(ciphertext);
76
+
77
+ const isActiveDuplicate = (
78
+ id: string | null,
79
+ duplicates: ReadonlyArray<CloudflareToken>,
80
+ ): boolean =>
81
+ id === null ||
82
+ duplicates.some((token) => token.id === id && token.status === 'active');
83
+
84
+ // Only the explicit non-stored member of a same-name duplicate set may be
85
+ // retired. Keep the current secret and every token if identity is uncertain.
86
+ export const revokeStoredDuplicate = async (input: {
87
+ readonly consumer: string;
88
+ readonly account: CloudflareBrokerAccount;
89
+ readonly tokenId: string;
90
+ readonly ref: BrokeredTokenRef;
91
+ readonly duplicates: ReadonlyArray<CloudflareToken>;
92
+ }): Promise<string | null> =>
93
+ withBrokerLock(resolveBrokerPath(), async () => {
94
+ const { consumer, account, ref, tokenId, duplicates } = input;
95
+ const target = resolveTargetRelResult(consumer, ref.target);
96
+ if (!(target.ok && isContainedPath(consumer, target.rel, 'file'))) {
97
+ return 'cannot safely resolve the duplicate token SOPS destination; keep its stored key intact';
98
+ }
99
+ const ciphertext = readFileSync(join(consumer, target.rel));
100
+ const decrypted = decryptSopsJson(consumer, target.rel);
101
+ if (!decrypted.ok) {
102
+ return 'cannot decrypt the duplicate token destination; keep its stored key intact';
103
+ }
104
+ const proof = await storedProof(
105
+ decrypted.value,
106
+ ref.key,
107
+ account.accountId,
108
+ );
109
+ if (!proof.ok) {
110
+ return 'cannot prove which duplicate token the stored credential uses; keep its stored key intact';
111
+ }
112
+ if (!isActiveDuplicate(proof.id, duplicates)) {
113
+ return 'stored credential does not identify an active duplicate; no token revoked';
114
+ }
115
+ if (proof.id === tokenId) {
116
+ return `token ${tokenId} is the credential currently stored at ${ref.target}:${ref.key}; choose a different duplicate ID`;
117
+ }
118
+ if (!unchangedStoredValues(consumer, target.rel, proof, ciphertext)) {
119
+ return 'stored credential changed or became unreadable during duplicate verification; no token revoked';
120
+ }
121
+ const deleted = await deleteAccountToken(
122
+ account.accountId,
123
+ account.token,
124
+ tokenId,
125
+ );
126
+ return deleted.ok ? null : deleted.problem;
127
+ });
@@ -3,9 +3,9 @@
3
3
  // one and deleting the original; without this the second half could only be
4
4
  // done in the dashboard. The power granted is deliberately momentary: nothing
5
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.
6
+ // time. Singular tokens reconciliation owns are retired through apply. A
7
+ // same-name duplicate may be explicitly retired after proving it is not the
8
+ // stored credential; this recovers interrupted renewal without deleting a key.
9
9
 
10
10
  import { deleteAccountToken } from './creds-cloudflare';
11
11
  import { isCloudflareId } from './creds-cloudflare-id';
@@ -16,6 +16,7 @@ import {
16
16
  type BrokeredTokenRef,
17
17
  parseAnyTokenName,
18
18
  } from './creds-naming';
19
+ import { revokeStoredDuplicate } from './creds-revoke-duplicate';
19
20
  import { readBrokerStore, resolveBrokerPath } from './creds-store';
20
21
  import { resolveGithubRepo } from './github-api';
21
22
 
@@ -30,8 +31,8 @@ const fail = (message: string): false => {
30
31
  // live credential this very checkout reconciles. Owner and name are compared
31
32
  // together: `otherowner/example` shares this repository's name and nothing
32
33
  // else.
33
- const sameRepo = (left: string, right: string): boolean =>
34
- left.toLowerCase() === right.toLowerCase();
34
+ const sameRepo = (left: string, right: string | null): boolean =>
35
+ right !== null && left.toLowerCase() === right.toLowerCase();
35
36
 
36
37
  // A brokered name carries the repository that owns it, and the reconciled
37
38
  // remedy — delete the SOPS key, run `apply` — only works in that repository.
@@ -57,15 +58,8 @@ const brokeredRefusal = ({
57
58
  if (currentRepo === null) {
58
59
  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
  }
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
61
  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`;
62
+ 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`;
69
63
  }
70
64
  if (force) {
71
65
  return null;
@@ -73,6 +67,16 @@ const brokeredRefusal = ({
73
67
  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
68
  };
75
69
 
70
+ const reportDuplicate = (problem: string | null, label: string): boolean => {
71
+ if (problem !== null) {
72
+ return fail(problem);
73
+ }
74
+ console.log(
75
+ `standards creds: revoked duplicate ${label}; preserved the stored credential`,
76
+ );
77
+ return true;
78
+ };
79
+
76
80
  export const runCredsRevoke = async (options: {
77
81
  readonly account: string | undefined;
78
82
  readonly tokenId: string | undefined;
@@ -121,18 +125,35 @@ export const runCredsRevoke = async (options: {
121
125
  // — one per machine. Only this machine's is identified by ID above; the rest
122
126
  // are refused by name, because deleting one locks another machine out of the
123
127
  // account and the broker cannot re-mint its own root credential.
124
- if (target.name === BROKER_IDENTITY_NAME) {
128
+ if (target.name.toLowerCase() === BROKER_IDENTITY_NAME) {
125
129
  return fail(
126
130
  `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
131
  );
128
132
  }
129
133
  const brokered = parseAnyTokenName(target.name);
130
134
  if (brokered !== null) {
135
+ const currentRepo = resolveGithubRepo(options.dir);
136
+ const duplicates = identified.value.tokens.filter(
137
+ (entry) => entry.name === target.name,
138
+ );
139
+ if (sameRepo(brokered.repo, currentRepo) && duplicates.length > 1) {
140
+ const problem = await revokeStoredDuplicate({
141
+ consumer: options.dir,
142
+ account,
143
+ tokenId,
144
+ ref: brokered,
145
+ duplicates,
146
+ });
147
+ return reportDuplicate(
148
+ problem,
149
+ `${target.name} (${account.accountId}/${tokenId})`,
150
+ );
151
+ }
131
152
  const refusal = brokeredRefusal({
132
153
  tokenId,
133
154
  name: target.name,
134
155
  brokered,
135
- currentRepo: resolveGithubRepo(options.dir),
156
+ currentRepo,
136
157
  force: options.force,
137
158
  });
138
159
  if (refusal !== null) {
@@ -1,4 +1,4 @@
1
- import { readdir, readFile, rmdir, stat, unlink } from 'node:fs/promises';
1
+ import { lstat, readdir, readFile, rmdir, unlink } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isRecord } from './github-settings-parse';
4
4
 
@@ -24,15 +24,11 @@ type ExistingGeneration =
24
24
  readonly modifiedAt: number;
25
25
  };
26
26
 
27
- const directoryModifiedAt = async (
28
- lockPath: string,
29
- ): Promise<number | null> => {
30
- try {
31
- return (await stat(lockPath)).mtimeMs;
32
- } catch {
33
- return null;
34
- }
35
- };
27
+ const missingOr = (
28
+ error: unknown,
29
+ fallback: ExistingGeneration,
30
+ ): ExistingGeneration =>
31
+ isErrorCode(error, 'ENOENT') ? { kind: 'missing' } : fallback;
36
32
 
37
33
  const readExistingGeneration = async (
38
34
  lockPath: string,
@@ -41,15 +37,12 @@ const readExistingGeneration = async (
41
37
  try {
42
38
  names = await readdir(lockPath);
43
39
  } catch (error) {
44
- return isErrorCode(error, 'ENOENT')
45
- ? { kind: 'missing' }
46
- : { kind: 'unknown' };
40
+ return missingOr(error, { kind: 'unknown' });
47
41
  }
48
42
  if (names.length === 0) {
49
- const modifiedAt = await directoryModifiedAt(lockPath);
50
- return modifiedAt === null
51
- ? { kind: 'missing' }
52
- : { entryPath: null, kind: 'incomplete', modifiedAt };
43
+ // Empty directories are immediately replaceable by an initialized
44
+ // candidate's atomic rename, including the holder-unlink release gap.
45
+ return { kind: 'missing' };
53
46
  }
54
47
  const entryName = names.length === 1 ? names[0] : undefined;
55
48
  if (entryName === undefined) {
@@ -58,9 +51,13 @@ const readExistingGeneration = async (
58
51
  const entryPath = join(lockPath, entryName);
59
52
  let modifiedAt: number;
60
53
  try {
61
- modifiedAt = (await stat(entryPath)).mtimeMs;
62
- } catch {
63
- return { kind: 'unknown' };
54
+ const entry = await lstat(entryPath);
55
+ if (!entry.isFile()) {
56
+ return { kind: 'unknown' };
57
+ }
58
+ modifiedAt = entry.mtimeMs;
59
+ } catch (error) {
60
+ return missingOr(error, { kind: 'unknown' });
64
61
  }
65
62
  const incomplete = {
66
63
  entryPath,
@@ -72,8 +69,16 @@ const readExistingGeneration = async (
72
69
  ) {
73
70
  return incomplete;
74
71
  }
72
+ let raw: string;
73
+ try {
74
+ raw = await readFile(entryPath, 'utf8');
75
+ } catch (error) {
76
+ // Only a vanished regular holder permits immediate retry. An I/O error
77
+ // cannot establish that a stale holder is malformed or safe to reclaim.
78
+ return missingOr(error, { kind: 'unknown' });
79
+ }
75
80
  try {
76
- const decoded: unknown = JSON.parse(await readFile(entryPath, 'utf8'));
81
+ const decoded: unknown = JSON.parse(raw);
77
82
  const generation = entryName.slice(
78
83
  HOLDER_PREFIX.length,
79
84
  -HOLDER_SUFFIX.length,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { mkdir } from 'node:fs/promises';
9
9
  import { dirname } from 'node:path';
10
+ import process from 'node:process';
10
11
  import {
11
12
  type HeldBrokerLock,
12
13
  releaseBrokerLock,
@@ -74,6 +75,18 @@ export const withBrokerLock = async <T>(
74
75
  try {
75
76
  return await operation();
76
77
  } finally {
77
- await releaseBrokerLock(held);
78
+ try {
79
+ await releaseBrokerLock(held);
80
+ } catch (error) {
81
+ // Cleanup must preserve either the durable operation result or its
82
+ // original failure. The stopped lease makes abandoned holders stale.
83
+ try {
84
+ process.stderr.write(
85
+ `standards creds: warning: lock cleanup failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}; the operation result is unchanged; retry after the stale-lock window\n`,
86
+ );
87
+ } catch {
88
+ // Even an unavailable diagnostic stream cannot change that result.
89
+ }
90
+ }
78
91
  }
79
92
  };
@@ -15,7 +15,7 @@ type Automaton = {
15
15
  readonly edges: ReadonlyArray<Edge>;
16
16
  };
17
17
 
18
- const compileDirectory = (
18
+ const compilePattern = (
19
19
  directory: string,
20
20
  supportsGlobs: boolean,
21
21
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby glob tokens have distinct NFA transitions that are clearest in one ordered parser.
@@ -94,15 +94,11 @@ const compileDirectory = (
94
94
  return { accept: current, edges };
95
95
  };
96
96
 
97
- export const directoriesOverlap = (
98
- leftDirectory: string,
99
- leftSupportsGlobs: boolean,
100
- rightDirectory: string,
101
- rightSupportsGlobs: boolean,
97
+ const automataOverlap = (
98
+ left: Automaton,
99
+ right: Automaton,
102
100
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The product walk must process both automata's epsilon and consuming transitions together.
103
101
  ): boolean => {
104
- const left = compileDirectory(leftDirectory, leftSupportsGlobs);
105
- const right = compileDirectory(rightDirectory, rightSupportsGlobs);
106
102
  const pending: Array<readonly [number, number]> = [[0, 0]];
107
103
  const visited = new Set<string>();
108
104
  for (const [leftState, rightState] of pending) {
@@ -137,3 +133,99 @@ export const directoriesOverlap = (
137
133
  }
138
134
  return false;
139
135
  };
136
+
137
+ const dotAliasScan = (segments: ReadonlyArray<string>) => {
138
+ const firstScan = segments.findIndex((segment) =>
139
+ compilePattern(segment, true).edges.some(
140
+ (edge) => edge.matcher === null || edge.matcher.kind !== 'literal',
141
+ ),
142
+ );
143
+ let aliasIndex = firstScan;
144
+ while (segments[aliasIndex] === '**' && aliasIndex < segments.length - 1) {
145
+ aliasIndex += 1;
146
+ }
147
+ return { firstScan, aliasIndex };
148
+ };
149
+
150
+ const compileDirectory = (
151
+ directory: string,
152
+ supportsGlobs: boolean,
153
+ ): Automaton => {
154
+ if (!supportsGlobs || directory === '/') {
155
+ return compilePattern(directory, supportsGlobs);
156
+ }
157
+ const edges: Array<Edge> = [];
158
+ let current = 0;
159
+ let states = 1;
160
+ const segments = directory.split('/').slice(1);
161
+ const { firstScan, aliasIndex } = dotAliasScan(segments);
162
+ let aliasStart = 0;
163
+ for (const [index, segment] of segments.entries()) {
164
+ if (index === firstScan) {
165
+ aliasStart = current;
166
+ }
167
+ if (segment === '**' && index < segments.length - 1) {
168
+ const loop = states;
169
+ states += 1;
170
+ edges.push({ from: current, to: loop, matcher: null });
171
+ current = loop;
172
+ const part = compilePattern('/*', true);
173
+ const offset = states;
174
+ states += part.accept + 1;
175
+ edges.push(
176
+ { from: current, to: offset, matcher: null },
177
+ ...part.edges.map((edge) => ({
178
+ ...edge,
179
+ from: edge.from + offset,
180
+ to: edge.to + offset,
181
+ })),
182
+ { from: offset + part.accept, to: current, matcher: null },
183
+ );
184
+ } else {
185
+ const part = compilePattern(`/${segment}`, true);
186
+ const offset = states;
187
+ states += part.accept + 1;
188
+ edges.push(
189
+ { from: current, to: offset, matcher: null },
190
+ ...part.edges.map((edge) => ({
191
+ ...edge,
192
+ from: edge.from + offset,
193
+ to: edge.to + offset,
194
+ })),
195
+ );
196
+ current = offset + part.accept;
197
+ if (
198
+ index === aliasIndex &&
199
+ automataOverlap(
200
+ compilePattern(segment, true),
201
+ compilePattern('.', false),
202
+ )
203
+ ) {
204
+ // Ruby sets FNM_GLOB_SKIPDOT after its first directory scan. A
205
+ // recursive prefix may match zero directories in that same scan;
206
+ // after consuming a child it cannot return to this alias start.
207
+ edges.push({ from: aliasStart, to: current, matcher: null });
208
+ }
209
+ }
210
+ }
211
+ const compiled = { accept: current, edges };
212
+ if (automataOverlap(compiled, { accept: 0, edges: [] })) {
213
+ edges.push({
214
+ from: 0,
215
+ to: current,
216
+ matcher: { kind: 'literal', value: '/'.codePointAt(0) ?? 0 },
217
+ });
218
+ }
219
+ return compiled;
220
+ };
221
+
222
+ export const directoriesOverlap = (
223
+ leftDirectory: string,
224
+ leftSupportsGlobs: boolean,
225
+ rightDirectory: string,
226
+ rightSupportsGlobs: boolean,
227
+ ): boolean =>
228
+ automataOverlap(
229
+ compileDirectory(leftDirectory, leftSupportsGlobs),
230
+ compileDirectory(rightDirectory, rightSupportsGlobs),
231
+ );
@@ -110,6 +110,17 @@ export const updateTarget = (
110
110
  const rawDirectories: ReadonlyArray<string> = hasDirectory
111
111
  ? [directory as string]
112
112
  : (directories as ReadonlyArray<string>);
113
+ if (
114
+ hasDirectories &&
115
+ rawDirectories.some(
116
+ (path) =>
117
+ isDependabotGlob(path) && (path.includes('{') || path.includes('}')),
118
+ )
119
+ ) {
120
+ problems.push(
121
+ `${label}.directories brace expansion is unsupported; list each alternative explicitly`,
122
+ );
123
+ }
113
124
  const normalized = [...new Set(rawDirectories)].sort();
114
125
  const normalizedDirectories = [
115
126
  ...new Set(normalized.map(normalizeDependabotDirectory)),
@@ -1,4 +1,4 @@
1
- import { parseDestination } from './creds-dest';
1
+ import { validBrokeredIdentity } from './dev-env-brokered-source';
2
2
  import type { DevEnvLayerKind } from './dev-env-document';
3
3
 
4
4
  export const BROKERED_REFERENCES_KEY = 'brokeredReferences';
@@ -32,7 +32,7 @@ export const parseBrokeredAllowlist = (
32
32
  const entries = new Set<string>();
33
33
  const problems: Array<string> = [];
34
34
  for (const [index, entry] of raw.entries()) {
35
- if (typeof entry !== 'string' || parseDestination(entry) === null) {
35
+ if (typeof entry !== 'string' || !validBrokeredIdentity(entry)) {
36
36
  problems.push(
37
37
  `${source} "${BROKERED_REFERENCES_KEY}" entry ${index + 1} must be an exact "<target>:<dotted.key>" string`,
38
38
  );
@@ -0,0 +1,69 @@
1
+ import type { BrokeredS3Reference } from './dev-env-brokered';
2
+ import {
3
+ brokeredReferenceIdentity,
4
+ resolveBrokeredSource,
5
+ } from './dev-env-brokered-source';
6
+ import type { ComposedDevEnvTarget } from './dev-env-compose';
7
+
8
+ type SelectedReference = Readonly<{
9
+ label: string;
10
+ reference: BrokeredS3Reference;
11
+ }>;
12
+ type Source = Readonly<{ root: string; rel: string }>;
13
+ const selectedReferences = (
14
+ targets: ReadonlyArray<ComposedDevEnvTarget>,
15
+ ): ReadonlyArray<SelectedReference> =>
16
+ targets.flatMap((target) =>
17
+ Object.entries(target.env).flatMap(([key, reference]) =>
18
+ typeof reference === 'string'
19
+ ? []
20
+ : [
21
+ {
22
+ label: `${target.group}.${target.workspace}.${key} reference to secrets target "${reference.brokeredS3}"`,
23
+ reference,
24
+ },
25
+ ],
26
+ ),
27
+ );
28
+
29
+ export const prepareBrokeredSources = (
30
+ consumer: string,
31
+ targets: ReadonlyArray<ComposedDevEnvTarget>,
32
+ allowed: ReadonlySet<string>,
33
+ preserved: ReadonlySet<string>,
34
+ ): Readonly<{
35
+ sources: ReadonlyMap<BrokeredS3Reference, Source>;
36
+ problems: ReadonlyArray<string>;
37
+ }> => {
38
+ const sources = new Map<BrokeredS3Reference, Source>();
39
+ const roots = new Map<string, string>();
40
+ const problems = selectedReferences(targets).flatMap(
41
+ ({ label, reference }) => {
42
+ const identity = brokeredReferenceIdentity(reference);
43
+ if (!allowed.has(identity)) {
44
+ return [
45
+ `${label}: unauthorized brokered S3 pair; add "${identity}" to the encrypted secrets/dev.yaml brokeredReferences allowlist`,
46
+ ];
47
+ }
48
+ if (preserved.has(identity)) {
49
+ return [];
50
+ }
51
+ const resolved = resolveBrokeredSource(consumer, reference);
52
+ if (!resolved.ok) {
53
+ return [`${label}: ${resolved.problem}`];
54
+ }
55
+ if (reference.source !== undefined) {
56
+ const previous = roots.get(reference.source.repository);
57
+ if (previous !== undefined && previous !== resolved.root) {
58
+ return [
59
+ `${label}: one source repository must resolve to one checkout per generation`,
60
+ ];
61
+ }
62
+ roots.set(reference.source.repository, resolved.root);
63
+ }
64
+ sources.set(reference, resolved);
65
+ return [];
66
+ },
67
+ );
68
+ return { sources, problems };
69
+ };
@@ -1,5 +1,6 @@
1
1
  import { lstatSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { brokeredReferenceIdentity } from './dev-env-brokered-source';
3
4
  import type { DevEnvValue } from './dev-env-document';
4
5
  import { preserveRenderedDotenvValues, renderDotenv } from './dev-env-dotenv';
5
6
 
@@ -27,7 +28,7 @@ const preservedKeys = (
27
28
  ): ReadonlyArray<string> =>
28
29
  Object.entries(env).flatMap(([key, value]) =>
29
30
  typeof value !== 'string' &&
30
- references.has(`${value.brokeredS3}:${value.key}`)
31
+ references.has(brokeredReferenceIdentity(value))
31
32
  ? [key]
32
33
  : [],
33
34
  );
@@ -34,7 +34,7 @@ const layerReferenceTargets = (
34
34
  return {
35
35
  references: document.targets.flatMap((target) =>
36
36
  Object.values(target.env).flatMap((value) =>
37
- typeof value === 'string'
37
+ typeof value === 'string' || value.source !== undefined
38
38
  ? []
39
39
  : [{ target: value.brokeredS3, key: value.key }],
40
40
  ),
@@ -4,8 +4,9 @@
4
4
  // layer's reference, and a reference that lost the merge is never decrypted.
5
5
 
6
6
  import { lookupS3Pair } from './creds-r2';
7
- import { resolveTargetRelResult } from './creds-target';
8
7
  import type { BrokeredS3Reference } from './dev-env-brokered';
8
+ import { prepareBrokeredSources } from './dev-env-brokered-preflight';
9
+ import { brokeredReferenceIdentity } from './dev-env-brokered-source';
9
10
  import type { ComposedDevEnvTarget } from './dev-env-compose';
10
11
  import { encodePortableDotenvValue } from './dev-env-dotenv-value';
11
12
  import { decryptSopsJson, type SopsJsonResult } from './sops-exec';
@@ -55,39 +56,49 @@ export const resolveBrokeredReferences = (
55
56
  ): ResolvedDevEnv => {
56
57
  const problems: Array<string> = [];
57
58
  const documents = new Map<string, SopsJsonResult>();
58
- const readDocument = (targetName: string): SopsJsonResult => {
59
- const cached = documents.get(targetName);
59
+ const preflight = prepareBrokeredSources(
60
+ consumer,
61
+ targets,
62
+ allowedReferences,
63
+ preservedReferences,
64
+ );
65
+ if (preflight.problems.length > 0) {
66
+ return {
67
+ problems: preflight.problems,
68
+ targets: targets.map((target) => ({
69
+ ...target,
70
+ env: Object.fromEntries(
71
+ Object.entries(target.env).filter(
72
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
73
+ ),
74
+ ),
75
+ })),
76
+ };
77
+ }
78
+ const { sources } = preflight;
79
+ const readDocument = (reference: BrokeredS3Reference): SopsJsonResult => {
80
+ const source = sources.get(reference);
81
+ if (source === undefined) {
82
+ return { ok: false, problem: 'unresolved source target' };
83
+ }
84
+ const documentKey = `${source.root}/${source.rel}`;
85
+ const cached = documents.get(documentKey);
60
86
  if (cached !== undefined) {
61
87
  return cached;
62
88
  }
63
- const resolved = resolveTargetRelResult(consumer, targetName);
64
- const result = resolved.ok
65
- ? decryptSopsJson(consumer, resolved.rel)
66
- : {
67
- ok: false as const,
68
- problem:
69
- resolved.kind === 'ambiguous'
70
- ? resolved.problem
71
- : `secrets target "${targetName}" does not exist; create it and mint the pair with \`bun standards creds add cloudflare --s3\``,
72
- };
73
- documents.set(targetName, result);
89
+ const result = decryptSopsJson(source.root, source.rel);
90
+ documents.set(documentKey, result);
74
91
  return result;
75
92
  };
76
93
  const resolveValue = (
77
94
  label: string,
78
95
  reference: BrokeredS3Reference,
79
96
  ): { readonly value: string | null; readonly problem: string | null } => {
80
- const allowlistEntry = `${reference.brokeredS3}:${reference.key}`;
81
- if (!allowedReferences.has(allowlistEntry)) {
82
- return {
83
- value: null,
84
- problem: `${label}: unauthorized brokered S3 pair; add "${allowlistEntry}" to the encrypted secrets/dev.yaml brokeredReferences allowlist`,
85
- };
86
- }
97
+ const allowlistEntry = brokeredReferenceIdentity(reference);
87
98
  if (preservedReferences.has(allowlistEntry)) {
88
99
  return { value: '', problem: null };
89
100
  }
90
- const document = readDocument(reference.brokeredS3);
101
+ const document = readDocument(reference);
91
102
  if (!document.ok) {
92
103
  return { value: null, problem: `${label}: ${document.problem}` };
93
104
  }