@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
@@ -0,0 +1,83 @@
1
+ // Brokered S3 pair references let a plain configuration layer point a
2
+ // workspace env key at a credential pair the creds broker owns in another
3
+ // SOPS target. The secret value then has exactly one home — the broker's
4
+ // ledger — so rotation stays the broker's job and no copy can go stale.
5
+ // A reference is configuration, never a secret, so the SOPS-encrypted
6
+ // secrets layer must not declare one.
7
+
8
+ import { parseDestination } from './creds-dest';
9
+ import { parseSopsKeyPath } from './creds-sops-structure';
10
+ import { isRecord } from './github-settings-parse';
11
+
12
+ export const BROKERED_S3_PARTS = [
13
+ 'access_key_id',
14
+ 'secret_access_key',
15
+ ] as const;
16
+
17
+ export type BrokeredS3Part = (typeof BROKERED_S3_PARTS)[number];
18
+
19
+ export type BrokeredS3Reference = {
20
+ readonly brokeredS3: string;
21
+ readonly key: string;
22
+ readonly part: BrokeredS3Part;
23
+ };
24
+
25
+ const REFERENCE_PROPERTIES = ['brokeredS3', 'key', 'part'] as const;
26
+
27
+ export const isBrokeredS3ReferenceShape = (
28
+ value: unknown,
29
+ ): value is Record<string, unknown> => isRecord(value) && 'brokeredS3' in value;
30
+
31
+ export type BrokeredS3ParseResult =
32
+ | { readonly ok: true; readonly reference: BrokeredS3Reference }
33
+ | { readonly ok: false; readonly problems: ReadonlyArray<string> };
34
+
35
+ const isBrokeredS3Part = (value: unknown): value is BrokeredS3Part =>
36
+ BROKERED_S3_PARTS.includes(value as BrokeredS3Part);
37
+
38
+ // Target existence and contained-path resolution are owned by
39
+ // `resolveTargetRel` at resolution time; parsing rejects a target name that
40
+ // could never name a broker destination.
41
+ export const parseBrokeredS3Reference = (
42
+ label: string,
43
+ raw: Record<string, unknown>,
44
+ ): BrokeredS3ParseResult => {
45
+ const problems: Array<string> = [];
46
+ for (const property of Object.keys(raw)) {
47
+ if (!REFERENCE_PROPERTIES.includes(property as 'brokeredS3')) {
48
+ problems.push(
49
+ `${label} brokered S3 pair reference has unknown property ${JSON.stringify(property)}; allowed properties are brokeredS3, key, and part`,
50
+ );
51
+ }
52
+ }
53
+ const { brokeredS3: target, key, part } = raw;
54
+ if (
55
+ typeof target !== 'string' ||
56
+ parseDestination(`${target}:reference_validation`) === null
57
+ ) {
58
+ problems.push(
59
+ `${label} brokered S3 pair reference needs a valid "brokeredS3" secrets target name`,
60
+ );
61
+ }
62
+ if (typeof key !== 'string' || parseSopsKeyPath(key) === null) {
63
+ problems.push(
64
+ `${label} brokered S3 pair reference needs a "key" naming the pair's dotted SOPS key`,
65
+ );
66
+ }
67
+ if (!isBrokeredS3Part(part)) {
68
+ problems.push(
69
+ `${label} brokered S3 pair reference needs a "part" of either "access_key_id" or "secret_access_key"`,
70
+ );
71
+ }
72
+ if (problems.length > 0) {
73
+ return { ok: false, problems };
74
+ }
75
+ return {
76
+ ok: true,
77
+ reference: {
78
+ brokeredS3: target as string,
79
+ key: key as string,
80
+ part: part as BrokeredS3Part,
81
+ },
82
+ };
83
+ };
@@ -0,0 +1,129 @@
1
+ // Composes the dev env layers — tracked config/dev.yaml, decrypted
2
+ // secrets/dev.yaml, and the gitignored per-machine config/dev.local.yaml —
3
+ // into one workspace-keyed target set. Later layers override earlier ones
4
+ // per env key. A key declared by both tracked shared layers is a placement
5
+ // error: a value is either configuration or a secret, never both.
6
+
7
+ import type {
8
+ DevEnvDocument,
9
+ DevEnvValue,
10
+ EnvValues,
11
+ } from './dev-env-document';
12
+
13
+ export type DevEnvLayer = {
14
+ readonly source: string;
15
+ readonly document: DevEnvDocument;
16
+ };
17
+
18
+ export type ComposedDevEnvTarget = {
19
+ readonly group: string;
20
+ readonly workspace: string;
21
+ readonly env: EnvValues;
22
+ readonly sources: ReadonlyArray<string>;
23
+ };
24
+
25
+ export type ComposedDevEnv = {
26
+ readonly targets: ReadonlyArray<ComposedDevEnvTarget>;
27
+ readonly brokeredReferences: ReadonlySet<string>;
28
+ readonly problems: ReadonlyArray<string>;
29
+ };
30
+
31
+ type MutableTarget = {
32
+ readonly group: string;
33
+ readonly workspace: string;
34
+ readonly env: Record<string, DevEnvValue>;
35
+ readonly sources: Array<string>;
36
+ };
37
+
38
+ const copyEnv = (source: EnvValues): Record<string, DevEnvValue> => {
39
+ const copy = Object.create(null) as Record<string, DevEnvValue>;
40
+ for (const [key, value] of Object.entries(source)) {
41
+ copy[key] = value;
42
+ }
43
+ return copy;
44
+ };
45
+
46
+ const applyLayer = (
47
+ merged: Map<string, MutableTarget>,
48
+ layer: DevEnvLayer,
49
+ ): void => {
50
+ for (const target of layer.document.targets) {
51
+ const key = `${target.group}.${target.workspace}`;
52
+ const existing = merged.get(key);
53
+ if (existing === undefined) {
54
+ merged.set(key, {
55
+ group: target.group,
56
+ workspace: target.workspace,
57
+ env: copyEnv(target.env),
58
+ sources: [layer.source],
59
+ });
60
+ } else {
61
+ for (const [envKey, value] of Object.entries(target.env)) {
62
+ existing.env[envKey] = value;
63
+ }
64
+ existing.sources.push(layer.source);
65
+ }
66
+ }
67
+ };
68
+
69
+ const sharedOverlapProblems = (
70
+ config: DevEnvLayer,
71
+ secrets: DevEnvLayer,
72
+ ): ReadonlyArray<string> => {
73
+ const secretTargets = new Map(
74
+ secrets.document.targets.map((target) => [
75
+ `${target.group}.${target.workspace}`,
76
+ target,
77
+ ]),
78
+ );
79
+ return config.document.targets.flatMap((target) => {
80
+ const counterpart = secretTargets.get(
81
+ `${target.group}.${target.workspace}`,
82
+ );
83
+ if (counterpart === undefined) {
84
+ return [];
85
+ }
86
+ return [...target.declaredKeys]
87
+ .filter((key) => counterpart.declaredKeys.has(key))
88
+ .map(
89
+ (key) =>
90
+ `${target.group}.${target.workspace}.${key} is declared in both ${config.source} and ${secrets.source}; a value is either configuration or a secret, so keep it in exactly one`,
91
+ );
92
+ });
93
+ };
94
+
95
+ export const composeDevEnv = (
96
+ config: DevEnvLayer | null,
97
+ secrets: DevEnvLayer,
98
+ local: DevEnvLayer | null,
99
+ ): ComposedDevEnv => {
100
+ const problems: ReadonlyArray<string> = [
101
+ ...(config === null ? [] : config.document.problems),
102
+ ...secrets.document.problems,
103
+ ...(local === null ? [] : local.document.problems),
104
+ ...(config === null ? [] : sharedOverlapProblems(config, secrets)),
105
+ ];
106
+ const merged = new Map<string, MutableTarget>();
107
+ for (const layer of [config, secrets, local]) {
108
+ if (layer !== null) {
109
+ applyLayer(merged, layer);
110
+ }
111
+ }
112
+ const targets = [...merged.values()]
113
+ .sort((a, b) =>
114
+ a.group === b.group
115
+ ? a.workspace.localeCompare(b.workspace)
116
+ : a.group.localeCompare(b.group),
117
+ )
118
+ .map((target) => ({
119
+ group: target.group,
120
+ workspace: target.workspace,
121
+ env: target.env,
122
+ sources: target.sources,
123
+ }));
124
+ return {
125
+ targets,
126
+ brokeredReferences: secrets.document.brokeredReferences,
127
+ problems,
128
+ };
129
+ };
@@ -1,19 +1,19 @@
1
1
  import { resolve } from 'node:path';
2
- import type { DevEnvWrite } from './dev-env-destination';
2
+ import type { DevEnvMutation } from './dev-env-destination';
3
3
 
4
4
  export const duplicateDestinationProblems = (
5
5
  root: string,
6
- writes: ReadonlyArray<DevEnvWrite>,
6
+ mutations: ReadonlyArray<DevEnvMutation>,
7
7
  ): ReadonlyArray<string> => {
8
- const rawDuplicates = writes.flatMap((write, index) => {
9
- const firstIndex = writes.findIndex(({ rel }) => rel === write.rel);
8
+ const rawDuplicates = mutations.flatMap((mutation, index) => {
9
+ const firstIndex = mutations.findIndex(({ rel }) => rel === mutation.rel);
10
10
  return firstIndex < index
11
- ? [`${write.rel} is declared more than once`]
11
+ ? [`${mutation.rel} is declared more than once`]
12
12
  : [];
13
13
  });
14
- const resolved = writes.map((write) => ({
15
- write,
16
- dest: resolve(root, write.rel),
14
+ const resolved = mutations.map((mutation) => ({
15
+ mutation,
16
+ dest: resolve(root, mutation.rel),
17
17
  }));
18
18
  const normalizedDuplicates = resolved.flatMap((destination, index) => {
19
19
  const firstIndex = resolved.findIndex(
@@ -22,9 +22,9 @@ export const duplicateDestinationProblems = (
22
22
  const first = resolved[firstIndex];
23
23
  return firstIndex < index &&
24
24
  first !== undefined &&
25
- first.write.rel !== destination.write.rel
25
+ first.mutation.rel !== destination.mutation.rel
26
26
  ? [
27
- `${destination.write.rel} resolves to the same destination as ${first.write.rel}`,
27
+ `${destination.mutation.rel} resolves to the same destination as ${first.mutation.rel}`,
28
28
  ]
29
29
  : [];
30
30
  });
@@ -0,0 +1,22 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ // Decrypted values are safe only when git proves it will ignore them.
4
+ export const devEnvGitIgnoreProblem = (
5
+ consumer: string,
6
+ rel: string,
7
+ ): string | null => {
8
+ const result = spawnSync('git', ['check-ignore', '-q', '--', rel], {
9
+ cwd: consumer,
10
+ encoding: 'utf8',
11
+ });
12
+ if (result.error !== undefined || result.status === null) {
13
+ return `cannot run git to verify ${rel} is gitignored`;
14
+ }
15
+ if (result.status === 0) {
16
+ return null;
17
+ }
18
+ if (result.status === 1) {
19
+ return `${rel} is not gitignored; ignore it before generating dev env files`;
20
+ }
21
+ return `cannot verify ${rel} is gitignored (git check-ignore exited ${result.status})`;
22
+ };
@@ -1,14 +1,29 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { Stats } from 'node:fs';
3
- import { lstat, realpath } from 'node:fs/promises';
3
+ import { realpath } from 'node:fs/promises';
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { duplicateDestinationProblems } from './dev-env-destination-duplicates';
6
+ import { devEnvGitIgnoreProblem } from './dev-env-destination-gitignore';
7
+ import {
8
+ devEnvStatOrNull,
9
+ matchesDevEnvRemoval,
10
+ } from './dev-env-reconciliation';
6
11
 
7
12
  export type DevEnvWrite = {
8
13
  readonly rel: string;
9
14
  readonly content: string;
10
15
  };
11
16
 
17
+ export type DevEnvRemoval = {
18
+ readonly rel: string;
19
+ readonly identity: {
20
+ readonly device: number;
21
+ readonly inode: number;
22
+ };
23
+ };
24
+
25
+ export type DevEnvMutation = DevEnvWrite | DevEnvRemoval;
26
+
12
27
  type DevEnvPathIdentity = {
13
28
  readonly path: string;
14
29
  readonly device: number;
@@ -16,7 +31,7 @@ type DevEnvPathIdentity = {
16
31
  };
17
32
 
18
33
  export type DevEnvDestination = {
19
- readonly write: DevEnvWrite;
34
+ readonly mutation: DevEnvMutation;
20
35
  readonly dest: string;
21
36
  readonly previous: Stats | null;
22
37
  readonly temp: string;
@@ -26,6 +41,7 @@ export type DevEnvDestination = {
26
41
  readonly realRoot: string;
27
42
  backupCreated: boolean;
28
43
  committed: boolean;
44
+ rollbackBlocked: boolean;
29
45
  };
30
46
 
31
47
  type PreflightResult =
@@ -40,27 +56,16 @@ const containedBy = (root: string, candidate: string): boolean => {
40
56
  return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
41
57
  };
42
58
 
43
- export const devEnvStatOrNull = async (path: string): Promise<Stats | null> => {
44
- try {
45
- return await lstat(path);
46
- } catch (error) {
47
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
48
- return null;
49
- }
50
- throw error;
51
- }
52
- };
53
-
54
59
  const inspectDestination = async (
55
60
  root: string,
56
61
  realRoot: string,
57
- write: DevEnvWrite,
62
+ mutation: DevEnvMutation,
58
63
  ): Promise<string | DevEnvDestination> => {
59
- const dest = resolve(root, write.rel);
64
+ const dest = resolve(root, mutation.rel);
60
65
  if (!containedBy(root, dest)) {
61
- return `${write.rel} escapes the consumer repository`;
66
+ return `${mutation.rel} escapes the consumer repository`;
62
67
  }
63
- const paths = dirname(write.rel)
68
+ const paths = dirname(mutation.rel)
64
69
  .split(sep)
65
70
  .map((_segment, index, segments) =>
66
71
  join(root, ...segments.slice(0, index + 1)),
@@ -73,19 +78,24 @@ const inspectDestination = async (
73
78
  entry === null || !entry.isDirectory() || entry.isSymbolicLink(),
74
79
  )
75
80
  ) {
76
- return `${write.rel} has an unsafe destination directory`;
81
+ return `${mutation.rel} has an unsafe destination directory`;
77
82
  }
78
83
  const realParent = await realpath(dirname(dest));
79
84
  if (!containedBy(realRoot, realParent)) {
80
- return `${write.rel} resolves outside the consumer repository`;
85
+ return `${mutation.rel} resolves outside the consumer repository`;
81
86
  }
82
87
  const previous = await devEnvStatOrNull(dest);
83
88
  if (previous !== null && (!previous.isFile() || previous.isSymbolicLink())) {
84
- return `${write.rel} must be absent or a regular file, not a symlink or other file type`;
89
+ return `${mutation.rel} must be absent or a regular file, not a symlink or other file type`;
90
+ }
91
+ if (
92
+ !('content' in mutation || (await matchesDevEnvRemoval(dest, mutation)))
93
+ ) {
94
+ return `${mutation.rel} changed after planning`;
85
95
  }
86
96
  const suffix = randomUUID();
87
97
  return {
88
- write,
98
+ mutation,
89
99
  dest,
90
100
  previous,
91
101
  temp: join(dirname(dest), `.env.local.standards-${suffix}.tmp`),
@@ -99,32 +109,44 @@ const inspectDestination = async (
99
109
  realRoot,
100
110
  backupCreated: false,
101
111
  committed: false,
112
+ rollbackBlocked: false,
102
113
  };
103
114
  };
104
115
 
105
116
  export const preflightDevEnvDestinations = async (
106
117
  consumer: string,
107
- writes: ReadonlyArray<DevEnvWrite>,
118
+ mutations: ReadonlyArray<DevEnvMutation>,
108
119
  ): Promise<PreflightResult> => {
109
120
  const root = resolve(consumer);
110
121
  const realRoot = await realpath(root);
111
- const duplicates = duplicateDestinationProblems(root, writes);
122
+ const duplicates = duplicateDestinationProblems(root, mutations);
112
123
  const checked = await Promise.all(
113
- writes.map(async (write) => {
124
+ mutations.map(async (mutation) => {
114
125
  try {
115
- return await inspectDestination(root, realRoot, write);
126
+ return await inspectDestination(root, realRoot, mutation);
116
127
  } catch (error) {
117
128
  const detail = error instanceof Error ? error.message : String(error);
118
- return `${write.rel} could not be preflighted: ${detail}`;
129
+ return `${mutation.rel} could not be preflighted: ${detail}`;
119
130
  }
120
131
  }),
121
132
  );
122
133
  const destinations = checked.filter(
123
134
  (item): item is DevEnvDestination => typeof item !== 'string',
124
135
  );
136
+ const gitignoreProblems = destinations.flatMap((destination) =>
137
+ [
138
+ destination.mutation.rel,
139
+ relative(root, destination.temp),
140
+ relative(root, destination.backup),
141
+ ].flatMap((rel) => {
142
+ const problem = devEnvGitIgnoreProblem(consumer, rel);
143
+ return problem === null ? [] : [problem];
144
+ }),
145
+ );
125
146
  const problems = [
126
147
  ...duplicates,
127
148
  ...checked.filter((item): item is string => typeof item === 'string'),
149
+ ...gitignoreProblems,
128
150
  ];
129
151
  return problems.length > 0
130
152
  ? { ok: false, problems }
@@ -158,18 +180,18 @@ export const devEnvParentProblem = async (
158
180
  realParent !== destination.realParent ||
159
181
  !containedBy(destination.realRoot, realParent)
160
182
  ) {
161
- return `${destination.write.rel} destination directory changed after preflight`;
183
+ return `${destination.mutation.rel} destination directory changed after preflight`;
162
184
  }
163
185
  return null;
164
186
  } catch {
165
- return `${destination.write.rel} destination directory changed after preflight`;
187
+ return `${destination.mutation.rel} destination directory changed after preflight`;
166
188
  }
167
189
  };
168
190
 
169
191
  export const devEnvDestinationProblems = async (
170
192
  consumer: string,
171
- writes: ReadonlyArray<DevEnvWrite>,
193
+ mutations: ReadonlyArray<DevEnvMutation>,
172
194
  ): Promise<ReadonlyArray<string>> => {
173
- const checked = await preflightDevEnvDestinations(consumer, writes);
195
+ const checked = await preflightDevEnvDestinations(consumer, mutations);
174
196
  return checked.ok ? [] : checked.problems;
175
197
  };
@@ -1,21 +1,40 @@
1
- // Workspace-group keyed dev secrets document: `apps.<name>` and
2
- // `packages.<name>` map env keys to string values — the shape mirrored in
3
- // secrets/dev.example.yaml. Parsing gathers every problem instead of failing
4
- // on the first one so a malformed document is repaired in one pass.
1
+ // Workspace-group keyed dev env document: `apps.<name>` and
2
+ // `packages.<name>` map env keys to values — the shape mirrored in
3
+ // secrets/dev.example.yaml. Plain configuration layers may also declare a
4
+ // brokered S3 pair reference instead of a literal; the secrets layer may
5
+ // not, because a reference is configuration. The secrets layer instead owns
6
+ // the encrypted allowlist that authorizes exact target/key pairs. Parsing
7
+ // gathers every problem instead of failing on the first one so a malformed
8
+ // document is repaired in one pass.
5
9
 
10
+ import {
11
+ type BrokeredS3Reference,
12
+ isBrokeredS3ReferenceShape,
13
+ parseBrokeredS3Reference,
14
+ } from './dev-env-brokered';
15
+ import {
16
+ BROKERED_REFERENCES_KEY,
17
+ parseBrokeredAllowlist,
18
+ } from './dev-env-brokered-allowlist';
6
19
  import { encodeBunDotenvValue } from './dev-env-dotenv-value';
7
20
  import { isRecord } from './github-settings-parse';
8
21
 
9
- export type EnvValues = Readonly<Record<string, string>>;
22
+ export type DevEnvValue = string | BrokeredS3Reference;
23
+
24
+ export type EnvValues = Readonly<Record<string, DevEnvValue>>;
25
+
26
+ export type DevEnvLayerKind = 'configuration' | 'secrets';
10
27
 
11
28
  export type DevEnvTarget = {
12
29
  readonly group: string;
13
30
  readonly workspace: string;
14
31
  readonly env: EnvValues;
32
+ readonly declaredKeys: ReadonlySet<string>;
15
33
  };
16
34
 
17
35
  export type DevEnvDocument = {
18
36
  readonly targets: ReadonlyArray<DevEnvTarget>;
37
+ readonly brokeredReferences: ReadonlySet<string>;
19
38
  readonly problems: ReadonlyArray<string>;
20
39
  };
21
40
 
@@ -28,36 +47,84 @@ type ParsedWorkspaces = {
28
47
  readonly problems: ReadonlyArray<string>;
29
48
  };
30
49
 
50
+ const parseWorkspaceValue = (
51
+ label: string,
52
+ key: string,
53
+ value: unknown,
54
+ layerKind: DevEnvLayerKind,
55
+ ): {
56
+ readonly value: DevEnvValue | null;
57
+ readonly problems: ReadonlyArray<string>;
58
+ } => {
59
+ if (typeof value === 'string') {
60
+ return encodeBunDotenvValue(value) === null
61
+ ? {
62
+ value: null,
63
+ problems: [
64
+ `${label}.${key} cannot be represented losslessly in Bun dotenv syntax`,
65
+ ],
66
+ }
67
+ : { value, problems: [] };
68
+ }
69
+ if (isBrokeredS3ReferenceShape(value)) {
70
+ if (layerKind === 'secrets') {
71
+ return {
72
+ value: null,
73
+ problems: [
74
+ `${label}.${key} is a brokered S3 pair reference; references are configuration and belong in config/dev.yaml or config/dev.local.yaml, not the secrets layer`,
75
+ ],
76
+ };
77
+ }
78
+ const parsed = parseBrokeredS3Reference(`${label}.${key}`, value);
79
+ return parsed.ok
80
+ ? { value: parsed.reference, problems: [] }
81
+ : { value: null, problems: parsed.problems };
82
+ }
83
+ return {
84
+ value: null,
85
+ problems: [
86
+ layerKind === 'configuration'
87
+ ? `${label}.${key} must be a string value or a brokered S3 pair reference`
88
+ : `${label}.${key} must be a string value`,
89
+ ],
90
+ };
91
+ };
92
+
31
93
  const parseWorkspaceEnv = (
32
94
  label: string,
33
95
  raw: Record<string, unknown>,
34
- ): { readonly env: EnvValues; readonly problems: ReadonlyArray<string> } => {
96
+ layerKind: DevEnvLayerKind,
97
+ ): {
98
+ readonly env: EnvValues;
99
+ readonly declaredKeys: ReadonlySet<string>;
100
+ readonly problems: ReadonlyArray<string>;
101
+ } => {
35
102
  const problems: Array<string> = [];
36
- const entries: Array<readonly [string, string]> = [];
103
+ const env = Object.create(null) as Record<string, DevEnvValue>;
104
+ const declaredKeys = new Set<string>();
37
105
  for (const [key, value] of Object.entries(raw)) {
38
106
  const portableName = PORTABLE_ENV_NAME.test(key);
39
- if (!portableName) {
107
+ if (portableName) {
108
+ declaredKeys.add(key);
109
+ } else {
40
110
  problems.push(
41
111
  `${label} env key ${JSON.stringify(key)} must be a portable environment variable name`,
42
112
  );
43
113
  }
44
- if (typeof value !== 'string') {
45
- problems.push(`${label}.${key} must be a string value`);
46
- } else if (encodeBunDotenvValue(value) === null) {
47
- problems.push(
48
- `${label}.${key} cannot be represented losslessly in Bun dotenv syntax`,
49
- );
50
- } else if (portableName) {
51
- entries.push([key, value]);
114
+ const parsed = parseWorkspaceValue(label, key, value, layerKind);
115
+ problems.push(...parsed.problems);
116
+ if (portableName && parsed.value !== null) {
117
+ env[key] = parsed.value;
52
118
  }
53
119
  }
54
- return { env: Object.fromEntries(entries), problems };
120
+ return { env, declaredKeys, problems };
55
121
  };
56
122
 
57
123
  const parseWorkspaces = (
58
124
  source: string,
59
125
  group: string,
60
126
  workspaces: Record<string, unknown>,
127
+ layerKind: DevEnvLayerKind,
61
128
  ): ParsedWorkspaces => {
62
129
  const problems: Array<string> = [];
63
130
  const targets: Array<DevEnvTarget> = [];
@@ -72,10 +139,16 @@ const parseWorkspaces = (
72
139
  const parsed = parseWorkspaceEnv(
73
140
  `${source} "${group}.${workspace}"`,
74
141
  env,
142
+ layerKind,
75
143
  );
76
144
  problems.push(...parsed.problems);
77
145
  if (validWorkspace) {
78
- targets.push({ group, workspace, env: parsed.env });
146
+ targets.push({
147
+ group,
148
+ workspace,
149
+ env: parsed.env,
150
+ declaredKeys: parsed.declaredKeys,
151
+ });
79
152
  }
80
153
  } else {
81
154
  problems.push(
@@ -89,22 +162,31 @@ const parseWorkspaces = (
89
162
  export const parseDevEnvDocument = (
90
163
  raw: unknown,
91
164
  source: string,
165
+ layerKind: DevEnvLayerKind,
92
166
  ): DevEnvDocument => {
93
167
  if (!isRecord(raw)) {
94
168
  return {
95
169
  targets: [],
170
+ brokeredReferences: new Set(),
96
171
  problems: [`${source} must decrypt to a YAML object`],
97
172
  };
98
173
  }
99
174
  const problems: Array<string> = [];
100
175
  const targets: Array<DevEnvTarget> = [];
176
+ const brokeredReferences = new Set<string>();
101
177
  for (const [group, workspaces] of Object.entries(raw)) {
102
- if (!WORKSPACE_GROUPS.includes(group)) {
178
+ if (group === BROKERED_REFERENCES_KEY) {
179
+ const parsed = parseBrokeredAllowlist(source, workspaces, layerKind);
180
+ problems.push(...parsed.problems);
181
+ for (const entry of parsed.entries) {
182
+ brokeredReferences.add(entry);
183
+ }
184
+ } else if (!WORKSPACE_GROUPS.includes(group)) {
103
185
  problems.push(
104
- `${source} top-level key "${group}" must be "apps" or "packages"`,
186
+ `${source} top-level key "${group}" must be "apps" or "packages"${layerKind === 'secrets' ? ` or "${BROKERED_REFERENCES_KEY}"` : ''}`,
105
187
  );
106
188
  } else if (isRecord(workspaces)) {
107
- const parsed = parseWorkspaces(source, group, workspaces);
189
+ const parsed = parseWorkspaces(source, group, workspaces, layerKind);
108
190
  problems.push(...parsed.problems);
109
191
  targets.push(...parsed.targets);
110
192
  } else {
@@ -113,5 +195,5 @@ export const parseDevEnvDocument = (
113
195
  );
114
196
  }
115
197
  }
116
- return { targets, problems };
198
+ return { targets, brokeredReferences, problems };
117
199
  };