@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.
- package/README.md +1 -1
- package/package.json +9 -1
- package/src/cli.ts +1 -1
- package/src/creds-add.ts +19 -1
- package/src/creds-cloudflare-api.ts +6 -10
- package/src/creds-cloudflare-request.ts +31 -0
- package/src/creds-dest.ts +40 -25
- package/src/creds-plan-renew.ts +45 -13
- package/src/creds-plan-run.ts +37 -15
- package/src/creds-sops-structure.ts +2 -1
- package/src/creds-target.ts +57 -0
- package/src/dev-env-brokered-allowlist.ts +44 -0
- package/src/dev-env-brokered-preserve.ts +65 -0
- package/src/dev-env-brokered-refresh.ts +129 -0
- package/src/dev-env-brokered-resolve.ts +158 -0
- package/src/dev-env-brokered.ts +83 -0
- package/src/dev-env-compose.ts +14 -5
- package/src/dev-env-document.ts +87 -18
- package/src/dev-env-dotenv.ts +38 -2
- package/src/dev-env-plain-layer.ts +53 -0
- package/src/dev-env.ts +60 -59
|
@@ -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
|
+
};
|
package/src/dev-env-compose.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
34
|
+
readonly env: Record<string, DevEnvValue>;
|
|
30
35
|
readonly sources: Array<string>;
|
|
31
36
|
};
|
|
32
37
|
|
|
33
|
-
const copyEnv = (source: EnvValues): Record<string,
|
|
34
|
-
const copy = Object.create(null) as Record<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 {
|
|
124
|
+
return {
|
|
125
|
+
targets,
|
|
126
|
+
brokeredReferences: secrets.document.brokeredReferences,
|
|
127
|
+
problems,
|
|
128
|
+
};
|
|
120
129
|
};
|
package/src/dev-env-document.ts
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
|
-
// Workspace-group keyed dev
|
|
2
|
-
// `packages.<name>` map env keys to
|
|
3
|
-
// secrets/dev.example.yaml.
|
|
4
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 (
|
|
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
|
};
|