@davidvornholt/standards 0.21.0 → 0.22.1

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,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
+ };
@@ -0,0 +1,129 @@
1
+ // After the creds broker writes into a SOPS target, the generated dev env
2
+ // files are stale if any workspace env key references a brokered pair in
3
+ // that target. Regenerating here closes the rotation loop: a renewed pair
4
+ // reaches every generated .env.local without a manual copy step.
5
+
6
+ import { resolveTargetRelResult } from './creds-target';
7
+ import { runDevEnv } from './dev-env';
8
+ import { parseDevEnvDocument } from './dev-env-document';
9
+ import { readPlainLayer } from './dev-env-plain-layer';
10
+
11
+ const DEV_PLAIN_LAYERS = ['config/dev.yaml', 'config/dev.local.yaml'] as const;
12
+
13
+ type LayerReferenceDiscovery = {
14
+ readonly references: ReadonlyArray<{
15
+ readonly target: string;
16
+ readonly key: string;
17
+ }>;
18
+ readonly problems: ReadonlyArray<string>;
19
+ };
20
+
21
+ const layerReferenceTargets = (
22
+ consumer: string,
23
+ layerRel: string,
24
+ ): LayerReferenceDiscovery => {
25
+ const layer = readPlainLayer(consumer, layerRel);
26
+ if (layer.input === null) {
27
+ return { references: [], problems: layer.problems };
28
+ }
29
+ const document = parseDevEnvDocument(
30
+ layer.input.raw,
31
+ layerRel,
32
+ 'configuration',
33
+ );
34
+ return {
35
+ references: document.targets.flatMap((target) =>
36
+ Object.values(target.env).flatMap((value) =>
37
+ typeof value === 'string'
38
+ ? []
39
+ : [{ target: value.brokeredS3, key: value.key }],
40
+ ),
41
+ ),
42
+ problems: [...layer.problems, ...document.problems],
43
+ };
44
+ };
45
+
46
+ type BrokeredReferenceDiscovery = {
47
+ readonly references: ReadonlySet<string>;
48
+ readonly problems: ReadonlyArray<string>;
49
+ };
50
+
51
+ export type BrokeredRefreshEvidence = {
52
+ readonly target: string;
53
+ readonly key: string;
54
+ readonly safety: 'verified' | 'unsafe';
55
+ };
56
+
57
+ const referenceIdentity = (target: string, key: string): string =>
58
+ `${target}:${key}`;
59
+
60
+ const discoverBrokeredReferenceRels = (
61
+ consumer: string,
62
+ ): BrokeredReferenceDiscovery => {
63
+ const references = new Set<string>();
64
+ const problems: Array<string> = [];
65
+ for (const layerRel of DEV_PLAIN_LAYERS) {
66
+ const layer = layerReferenceTargets(consumer, layerRel);
67
+ problems.push(...layer.problems);
68
+ for (const reference of layer.references) {
69
+ const resolved = resolveTargetRelResult(consumer, reference.target);
70
+ if (resolved.ok) {
71
+ references.add(referenceIdentity(reference.target, reference.key));
72
+ } else {
73
+ problems.push(
74
+ resolved.kind === 'ambiguous'
75
+ ? `dev config references ${resolved.problem}`
76
+ : `dev config references secrets target "${reference.target}", but it is missing or is not a contained regular SOPS file`,
77
+ );
78
+ }
79
+ }
80
+ }
81
+ return { references, problems };
82
+ };
83
+
84
+ export const refreshDevEnvForSopsWrites = async (
85
+ consumer: string,
86
+ evidence: ReadonlyArray<BrokeredRefreshEvidence>,
87
+ ): Promise<boolean> => {
88
+ if (evidence.length === 0) {
89
+ return true;
90
+ }
91
+ const discovery = discoverBrokeredReferenceRels(consumer);
92
+ if (discovery.problems.length > 0) {
93
+ console.error(
94
+ `standards creds: cannot determine whether changed credentials require dev env regeneration because the configuration has ${discovery.problems.length} problem(s):`,
95
+ );
96
+ console.error(
97
+ discovery.problems.map((problem) => ` - ${problem}`).join('\n'),
98
+ );
99
+ console.error(
100
+ 'standards creds: credential changes were written, but generated .env.local files were not updated; fix the configuration and run `bun standards dev-env`',
101
+ );
102
+ return false;
103
+ }
104
+ const unsafe = new Set(
105
+ evidence
106
+ .filter((entry) => entry.safety === 'unsafe')
107
+ .map((entry) => referenceIdentity(entry.target, entry.key)),
108
+ );
109
+ const verified = new Set(
110
+ evidence
111
+ .filter((entry) => entry.safety === 'verified')
112
+ .map((entry) => referenceIdentity(entry.target, entry.key)),
113
+ );
114
+ const changedReferences = [...verified].filter(
115
+ (reference) =>
116
+ discovery.references.has(reference) && !unsafe.has(reference),
117
+ );
118
+ if (changedReferences.length === 0) {
119
+ return true;
120
+ }
121
+ console.log(
122
+ 'standards creds: regenerating dev env files (a brokered pair referenced by the dev config changed)',
123
+ );
124
+ return await runDevEnv(consumer, {
125
+ preservedBrokeredReferences: new Set(
126
+ [...unsafe].filter((reference) => discovery.references.has(reference)),
127
+ ),
128
+ });
129
+ };
@@ -0,0 +1,158 @@
1
+ // Resolves brokered S3 pair references in the composed dev env into literal
2
+ // values by decrypting each referenced SOPS target once. Resolution runs
3
+ // after layer composition so a later layer's literal can override an earlier
4
+ // layer's reference, and a reference that lost the merge is never decrypted.
5
+
6
+ import { resolveTargetRelResult } from './creds-target';
7
+ import type { BrokeredS3Reference } from './dev-env-brokered';
8
+ import type { ComposedDevEnvTarget } from './dev-env-compose';
9
+ import { encodeBunDotenvValue } from './dev-env-dotenv-value';
10
+ import { isRecord } from './github-settings-parse';
11
+ import { runSops } from './sops-exec';
12
+
13
+ export type ResolvedDevEnvTarget = {
14
+ readonly group: string;
15
+ readonly workspace: string;
16
+ readonly env: Readonly<Record<string, string>>;
17
+ readonly sources: ReadonlyArray<string>;
18
+ };
19
+
20
+ export type ResolvedDevEnv = {
21
+ readonly targets: ReadonlyArray<ResolvedDevEnvTarget>;
22
+ readonly problems: ReadonlyArray<string>;
23
+ };
24
+
25
+ type SopsDocumentResult =
26
+ | { readonly ok: true; readonly value: unknown }
27
+ | { readonly ok: false; readonly problem: string };
28
+
29
+ const decryptSopsJson = (consumer: string, rel: string): SopsDocumentResult => {
30
+ const result = runSops(['--decrypt', '--output-type', 'json', rel], consumer);
31
+ if (result.status !== 0) {
32
+ const detail = result.errorMessage ?? result.stderr.trim();
33
+ return {
34
+ ok: false,
35
+ problem: detail
36
+ ? `could not decrypt ${rel}: ${detail}`
37
+ : `could not decrypt ${rel}`,
38
+ };
39
+ }
40
+ try {
41
+ return { ok: true, value: JSON.parse(result.stdout) as unknown };
42
+ } catch (error) {
43
+ return {
44
+ ok: false,
45
+ problem: `could not parse decrypted ${rel} as JSON: ${
46
+ error instanceof Error ? error.message : String(error)
47
+ }`,
48
+ };
49
+ }
50
+ };
51
+
52
+ const lookupReference = (
53
+ document: unknown,
54
+ reference: BrokeredS3Reference,
55
+ ): { readonly value: string | null; readonly problem: string | null } => {
56
+ let node: unknown = document;
57
+ for (const segment of reference.key.split('.')) {
58
+ if (!isRecord(node) || node[segment] === undefined) {
59
+ return {
60
+ value: null,
61
+ problem: `has no key "${reference.key}"; mint the pair with \`bun standards creds add cloudflare --s3 --dest ${reference.brokeredS3}:${reference.key}\``,
62
+ };
63
+ }
64
+ node = node[segment];
65
+ }
66
+ if (
67
+ !isRecord(node) ||
68
+ typeof node.access_key_id !== 'string' ||
69
+ typeof node.secret_access_key !== 'string'
70
+ ) {
71
+ return {
72
+ value: null,
73
+ problem: `key "${reference.key}" does not hold a complete brokered S3 pair; both "access_key_id" and "secret_access_key" must be strings`,
74
+ };
75
+ }
76
+ return { value: node[reference.part] as string, problem: null };
77
+ };
78
+
79
+ export const resolveBrokeredReferences = (
80
+ consumer: string,
81
+ targets: ReadonlyArray<ComposedDevEnvTarget>,
82
+ allowedReferences: ReadonlySet<string>,
83
+ preservedReferences: ReadonlySet<string> = new Set(),
84
+ ): ResolvedDevEnv => {
85
+ const problems: Array<string> = [];
86
+ const documents = new Map<string, SopsDocumentResult>();
87
+ const readDocument = (targetName: string): SopsDocumentResult => {
88
+ const cached = documents.get(targetName);
89
+ if (cached !== undefined) {
90
+ return cached;
91
+ }
92
+ const resolved = resolveTargetRelResult(consumer, targetName);
93
+ const result = resolved.ok
94
+ ? decryptSopsJson(consumer, resolved.rel)
95
+ : {
96
+ ok: false as const,
97
+ problem:
98
+ resolved.kind === 'ambiguous'
99
+ ? resolved.problem
100
+ : `secrets target "${targetName}" does not exist; create it and mint the pair with \`bun standards creds add cloudflare --s3\``,
101
+ };
102
+ documents.set(targetName, result);
103
+ return result;
104
+ };
105
+ const resolveValue = (
106
+ label: string,
107
+ reference: BrokeredS3Reference,
108
+ ): { readonly value: string | null; readonly problem: string | null } => {
109
+ const allowlistEntry = `${reference.brokeredS3}:${reference.key}`;
110
+ if (!allowedReferences.has(allowlistEntry)) {
111
+ return {
112
+ value: null,
113
+ problem: `${label}: unauthorized brokered S3 pair; add "${allowlistEntry}" to the encrypted secrets/dev.yaml brokeredReferences allowlist`,
114
+ };
115
+ }
116
+ if (preservedReferences.has(allowlistEntry)) {
117
+ return { value: '', problem: null };
118
+ }
119
+ const document = readDocument(reference.brokeredS3);
120
+ if (!document.ok) {
121
+ return { value: null, problem: `${label}: ${document.problem}` };
122
+ }
123
+ const lookup = lookupReference(document.value, reference);
124
+ if (lookup.value === null) {
125
+ return { value: null, problem: `${label}: ${lookup.problem}` };
126
+ }
127
+ if (encodeBunDotenvValue(lookup.value) === null) {
128
+ return {
129
+ value: null,
130
+ problem: `${label}: resolved value cannot be represented losslessly in Bun dotenv syntax`,
131
+ };
132
+ }
133
+ return { value: lookup.value, problem: null };
134
+ };
135
+ const resolved = targets.map((target) => {
136
+ const env = Object.create(null) as Record<string, string>;
137
+ for (const [key, value] of Object.entries(target.env)) {
138
+ if (typeof value === 'string') {
139
+ env[key] = value;
140
+ } else {
141
+ const label = `${target.group}.${target.workspace}.${key} reference to secrets target "${value.brokeredS3}"`;
142
+ const outcome = resolveValue(label, value);
143
+ if (outcome.value === null) {
144
+ problems.push(outcome.problem ?? `${label}: unresolved reference`);
145
+ } else {
146
+ env[key] = outcome.value;
147
+ }
148
+ }
149
+ }
150
+ return {
151
+ group: target.group,
152
+ workspace: target.workspace,
153
+ env,
154
+ sources: target.sources,
155
+ };
156
+ });
157
+ return { targets: resolved, problems };
158
+ };
@@ -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
+ };
@@ -4,7 +4,11 @@
4
4
  // per env key. A key declared by both tracked shared layers is a placement
5
5
  // error: a value is either configuration or a secret, never both.
6
6
 
7
- import type { DevEnvDocument, EnvValues } from './dev-env-document';
7
+ import type {
8
+ DevEnvDocument,
9
+ DevEnvValue,
10
+ EnvValues,
11
+ } from './dev-env-document';
8
12
 
9
13
  export type DevEnvLayer = {
10
14
  readonly source: string;
@@ -20,18 +24,19 @@ export type ComposedDevEnvTarget = {
20
24
 
21
25
  export type ComposedDevEnv = {
22
26
  readonly targets: ReadonlyArray<ComposedDevEnvTarget>;
27
+ readonly brokeredReferences: ReadonlySet<string>;
23
28
  readonly problems: ReadonlyArray<string>;
24
29
  };
25
30
 
26
31
  type MutableTarget = {
27
32
  readonly group: string;
28
33
  readonly workspace: string;
29
- readonly env: Record<string, string>;
34
+ readonly env: Record<string, DevEnvValue>;
30
35
  readonly sources: Array<string>;
31
36
  };
32
37
 
33
- const copyEnv = (source: EnvValues): Record<string, string> => {
34
- const copy = Object.create(null) as Record<string, string>;
38
+ const copyEnv = (source: EnvValues): Record<string, DevEnvValue> => {
39
+ const copy = Object.create(null) as Record<string, DevEnvValue>;
35
40
  for (const [key, value] of Object.entries(source)) {
36
41
  copy[key] = value;
37
42
  }
@@ -116,5 +121,9 @@ export const composeDevEnv = (
116
121
  env: target.env,
117
122
  sources: target.sources,
118
123
  }));
119
- return { targets, problems };
124
+ return {
125
+ targets,
126
+ brokeredReferences: secrets.document.brokeredReferences,
127
+ problems,
128
+ };
120
129
  };
@@ -1,12 +1,29 @@
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;
@@ -17,6 +34,7 @@ export type DevEnvTarget = {
17
34
 
18
35
  export type DevEnvDocument = {
19
36
  readonly targets: ReadonlyArray<DevEnvTarget>;
37
+ readonly brokeredReferences: ReadonlySet<string>;
20
38
  readonly problems: ReadonlyArray<string>;
21
39
  };
22
40
 
@@ -29,16 +47,60 @@ type ParsedWorkspaces = {
29
47
  readonly problems: ReadonlyArray<string>;
30
48
  };
31
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
+
32
93
  const parseWorkspaceEnv = (
33
94
  label: string,
34
95
  raw: Record<string, unknown>,
96
+ layerKind: DevEnvLayerKind,
35
97
  ): {
36
98
  readonly env: EnvValues;
37
99
  readonly declaredKeys: ReadonlySet<string>;
38
100
  readonly problems: ReadonlyArray<string>;
39
101
  } => {
40
102
  const problems: Array<string> = [];
41
- const env = Object.create(null) as Record<string, string>;
103
+ const env = Object.create(null) as Record<string, DevEnvValue>;
42
104
  const declaredKeys = new Set<string>();
43
105
  for (const [key, value] of Object.entries(raw)) {
44
106
  const portableName = PORTABLE_ENV_NAME.test(key);
@@ -49,14 +111,10 @@ const parseWorkspaceEnv = (
49
111
  `${label} env key ${JSON.stringify(key)} must be a portable environment variable name`,
50
112
  );
51
113
  }
52
- if (typeof value !== 'string') {
53
- problems.push(`${label}.${key} must be a string value`);
54
- } else if (encodeBunDotenvValue(value) === null) {
55
- problems.push(
56
- `${label}.${key} cannot be represented losslessly in Bun dotenv syntax`,
57
- );
58
- } else if (portableName) {
59
- env[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;
60
118
  }
61
119
  }
62
120
  return { env, declaredKeys, problems };
@@ -66,6 +124,7 @@ const parseWorkspaces = (
66
124
  source: string,
67
125
  group: string,
68
126
  workspaces: Record<string, unknown>,
127
+ layerKind: DevEnvLayerKind,
69
128
  ): ParsedWorkspaces => {
70
129
  const problems: Array<string> = [];
71
130
  const targets: Array<DevEnvTarget> = [];
@@ -80,6 +139,7 @@ const parseWorkspaces = (
80
139
  const parsed = parseWorkspaceEnv(
81
140
  `${source} "${group}.${workspace}"`,
82
141
  env,
142
+ layerKind,
83
143
  );
84
144
  problems.push(...parsed.problems);
85
145
  if (validWorkspace) {
@@ -102,22 +162,31 @@ const parseWorkspaces = (
102
162
  export const parseDevEnvDocument = (
103
163
  raw: unknown,
104
164
  source: string,
165
+ layerKind: DevEnvLayerKind,
105
166
  ): DevEnvDocument => {
106
167
  if (!isRecord(raw)) {
107
168
  return {
108
169
  targets: [],
170
+ brokeredReferences: new Set(),
109
171
  problems: [`${source} must decrypt to a YAML object`],
110
172
  };
111
173
  }
112
174
  const problems: Array<string> = [];
113
175
  const targets: Array<DevEnvTarget> = [];
176
+ const brokeredReferences = new Set<string>();
114
177
  for (const [group, workspaces] of Object.entries(raw)) {
115
- 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)) {
116
185
  problems.push(
117
- `${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}"` : ''}`,
118
187
  );
119
188
  } else if (isRecord(workspaces)) {
120
- const parsed = parseWorkspaces(source, group, workspaces);
189
+ const parsed = parseWorkspaces(source, group, workspaces, layerKind);
121
190
  problems.push(...parsed.problems);
122
191
  targets.push(...parsed.targets);
123
192
  } else {
@@ -126,5 +195,5 @@ export const parseDevEnvDocument = (
126
195
  );
127
196
  }
128
197
  }
129
- return { targets, problems };
198
+ return { targets, brokeredReferences, problems };
130
199
  };