@ontrails/config 1.0.0-beta.30 → 1.0.0-beta.32
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/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/app-config.ts +3 -3
- package/src/collect.ts +4 -5
- package/src/derive/env.ts +4 -0
- package/src/derive-provenance.ts +67 -3
- package/src/doctor.ts +34 -15
- package/src/index.ts +2 -2
- package/src/resolve.ts +10 -95
- package/src/trails/config-check.ts +39 -4
- package/src/zod-utils.ts +104 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @ontrails/config
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.32
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2b819f4: Rename config check reports to `ConfigReport` and `ConfigFieldReport`, and return field reports from `config.check`.
|
|
8
|
+
- Updated dependencies [3e5c0fc]
|
|
9
|
+
- Updated dependencies [f3c4fef]
|
|
10
|
+
- Updated dependencies [cb0a9d8]
|
|
11
|
+
- Updated dependencies [21c6dda]
|
|
12
|
+
- Updated dependencies [fe72b84]
|
|
13
|
+
- @ontrails/core@1.0.0-beta.32
|
|
14
|
+
|
|
15
|
+
## 1.0.0-beta.31
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [4cd5d4e]
|
|
20
|
+
- Updated dependencies [38907cc]
|
|
21
|
+
- @ontrails/core@1.0.0-beta.31
|
|
22
|
+
|
|
3
23
|
## 1.0.0-beta.30
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ export const myTrail = trail('my.trail', {
|
|
|
166
166
|
|
|
167
167
|
### `config.check`
|
|
168
168
|
|
|
169
|
-
Validate config values against the schema. Returns
|
|
169
|
+
Validate config values against the schema. Returns a config report with field-level status (valid, missing, invalid, deprecated, default) and the checked field values when available.
|
|
170
170
|
|
|
171
171
|
### `config.describe`
|
|
172
172
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/config",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.32",
|
|
4
4
|
"files": [
|
|
5
5
|
"src/**/*.ts",
|
|
6
6
|
"!src/**/__tests__/**",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@ontrails/core": "^1.0.0-beta.
|
|
25
|
+
"@ontrails/core": "^1.0.0-beta.32",
|
|
26
26
|
"zod": "^4.3.5"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/app-config.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { dirname, join } from 'node:path';
|
|
|
7
7
|
import { NotFoundError, Result, ValidationError } from '@ontrails/core';
|
|
8
8
|
import type { z } from 'zod';
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type { ConfigReport } from './doctor.js';
|
|
11
11
|
import { checkConfig } from './doctor.js';
|
|
12
12
|
import type { FieldDescription } from './derive-fields.js';
|
|
13
13
|
import { deriveConfigFields } from './derive-fields.js';
|
|
@@ -58,11 +58,11 @@ export interface AppConfig<T extends z.ZodType> {
|
|
|
58
58
|
/** Describe all fields in the schema without needing values. */
|
|
59
59
|
describe(): readonly FieldDescription[];
|
|
60
60
|
|
|
61
|
-
/** Check a config object against the schema and return
|
|
61
|
+
/** Check a config object against the schema and return field reports. */
|
|
62
62
|
check(
|
|
63
63
|
values: Record<string, unknown>,
|
|
64
64
|
options?: { readonly env?: Record<string, string | undefined> }
|
|
65
|
-
):
|
|
65
|
+
): ConfigReport;
|
|
66
66
|
|
|
67
67
|
/** Show which source won for each config field. */
|
|
68
68
|
explain(
|
package/src/collect.ts
CHANGED
|
@@ -79,6 +79,10 @@ const walkObjectShape = (
|
|
|
79
79
|
|
|
80
80
|
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
81
81
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
82
|
+
const meta = extractConfigMeta(fieldSchema);
|
|
83
|
+
if (meta) {
|
|
84
|
+
result.set(path, meta);
|
|
85
|
+
}
|
|
82
86
|
|
|
83
87
|
if (isZodObject(fieldSchema)) {
|
|
84
88
|
queue.push({
|
|
@@ -87,11 +91,6 @@ const walkObjectShape = (
|
|
|
87
91
|
Record<string, z.ZodType>
|
|
88
92
|
>,
|
|
89
93
|
});
|
|
90
|
-
} else {
|
|
91
|
-
const meta = extractConfigMeta(fieldSchema);
|
|
92
|
-
if (meta) {
|
|
93
|
-
result.set(path, meta);
|
|
94
|
-
}
|
|
95
94
|
}
|
|
96
95
|
}
|
|
97
96
|
};
|
package/src/derive/env.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { collectConfigMeta } from '../collect.js';
|
|
|
10
10
|
import type { ConfigFieldMeta } from '../extensions.js';
|
|
11
11
|
|
|
12
12
|
import { isLikelySecret } from '../secret-heuristics.js';
|
|
13
|
+
import { isZodContainer } from '../zod-utils.js';
|
|
13
14
|
|
|
14
15
|
import {
|
|
15
16
|
formatValue,
|
|
@@ -80,6 +81,9 @@ const collectEnvEntries = (
|
|
|
80
81
|
if (!fieldSchema) {
|
|
81
82
|
continue;
|
|
82
83
|
}
|
|
84
|
+
if (isZodContainer(fieldSchema)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
83
87
|
entries.push(...envEntry(fieldMeta.env, fieldSchema, fieldMeta));
|
|
84
88
|
entries.push('');
|
|
85
89
|
}
|
package/src/derive-provenance.ts
CHANGED
|
@@ -8,7 +8,14 @@ import type { z } from 'zod';
|
|
|
8
8
|
|
|
9
9
|
import { collectConfigMeta } from './collect.js';
|
|
10
10
|
import { isLikelySecret } from './secret-heuristics.js';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getAtPath,
|
|
13
|
+
getSchemaAtPath,
|
|
14
|
+
isZodContainer,
|
|
15
|
+
isZodObject,
|
|
16
|
+
unwrapToBase,
|
|
17
|
+
zodDef,
|
|
18
|
+
} from './zod-utils.js';
|
|
12
19
|
|
|
13
20
|
// ---------------------------------------------------------------------------
|
|
14
21
|
// Types
|
|
@@ -64,21 +71,73 @@ const buildSecretSet = (
|
|
|
64
71
|
return result;
|
|
65
72
|
};
|
|
66
73
|
|
|
74
|
+
/** Build a set of env-backed container paths that env overlay skips. */
|
|
75
|
+
const buildSkippedEnvContainerSet = (
|
|
76
|
+
schema: z.ZodObject<Record<string, z.ZodType>>,
|
|
77
|
+
envMap: Map<string, string>
|
|
78
|
+
): Set<string> => {
|
|
79
|
+
const result = new Set<string>();
|
|
80
|
+
for (const path of envMap.keys()) {
|
|
81
|
+
const fieldSchema = getSchemaAtPath(schema, path);
|
|
82
|
+
if (fieldSchema && isZodContainer(fieldSchema)) {
|
|
83
|
+
result.add(path);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
|
|
67
89
|
/** Source entries in reverse precedence order for winner detection. */
|
|
68
90
|
type SourceEntry = readonly [
|
|
69
91
|
name: ProvenanceEntry['source'],
|
|
70
92
|
values: Record<string, unknown> | undefined,
|
|
71
93
|
];
|
|
72
94
|
|
|
95
|
+
/** Compare JSON-shaped config values for provenance winner detection. */
|
|
96
|
+
const areConfigValuesEqual = (left: unknown, right: unknown): boolean => {
|
|
97
|
+
if (Object.is(left, right)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
101
|
+
if (!(Array.isArray(left) && Array.isArray(right))) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return (
|
|
105
|
+
left.length === right.length &&
|
|
106
|
+
left.every((value, index) => areConfigValuesEqual(value, right[index]))
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
typeof left !== 'object' ||
|
|
111
|
+
left === null ||
|
|
112
|
+
typeof right !== 'object' ||
|
|
113
|
+
right === null
|
|
114
|
+
) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const leftRecord = left as Record<string, unknown>;
|
|
118
|
+
const rightRecord = right as Record<string, unknown>;
|
|
119
|
+
const leftKeys = Object.keys(leftRecord);
|
|
120
|
+
const rightKeys = Object.keys(rightRecord);
|
|
121
|
+
return (
|
|
122
|
+
leftKeys.length === rightKeys.length &&
|
|
123
|
+
leftKeys.every(
|
|
124
|
+
(key) =>
|
|
125
|
+
Object.hasOwn(rightRecord, key) &&
|
|
126
|
+
areConfigValuesEqual(leftRecord[key], rightRecord[key])
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
73
131
|
/** Determine which source provided the winning value for a given path. */
|
|
74
132
|
const determineSource = (
|
|
75
133
|
path: string,
|
|
76
134
|
resolved: Record<string, unknown>,
|
|
77
135
|
sources: readonly SourceEntry[],
|
|
78
136
|
envMap: Map<string, string>,
|
|
137
|
+
skippedEnvContainers: Set<string>,
|
|
79
138
|
envVars: Record<string, string | undefined> | undefined
|
|
80
139
|
): ProvenanceEntry['source'] => {
|
|
81
|
-
if (envVars && envMap.has(path)) {
|
|
140
|
+
if (envVars && envMap.has(path) && !skippedEnvContainers.has(path)) {
|
|
82
141
|
const envVar = envMap.get(path);
|
|
83
142
|
if (envVar && envVars[envVar] !== undefined) {
|
|
84
143
|
return 'env';
|
|
@@ -87,7 +146,10 @@ const determineSource = (
|
|
|
87
146
|
|
|
88
147
|
const resolvedValue = getAtPath(resolved, path);
|
|
89
148
|
for (const [name, values] of sources) {
|
|
90
|
-
if (
|
|
149
|
+
if (
|
|
150
|
+
values &&
|
|
151
|
+
areConfigValuesEqual(getAtPath(values, path), resolvedValue)
|
|
152
|
+
) {
|
|
91
153
|
return name;
|
|
92
154
|
}
|
|
93
155
|
}
|
|
@@ -143,6 +205,7 @@ export const deriveConfigProvenance = <T extends z.ZodType>(
|
|
|
143
205
|
>;
|
|
144
206
|
const envMap = buildEnvMap(objSchema);
|
|
145
207
|
const secretSet = buildSecretSet(objSchema);
|
|
208
|
+
const skippedEnvContainers = buildSkippedEnvContainerSet(objSchema, envMap);
|
|
146
209
|
|
|
147
210
|
const sources: readonly SourceEntry[] = [
|
|
148
211
|
['local', options.local],
|
|
@@ -158,6 +221,7 @@ export const deriveConfigProvenance = <T extends z.ZodType>(
|
|
|
158
221
|
options.resolved,
|
|
159
222
|
sources,
|
|
160
223
|
envMap,
|
|
224
|
+
skippedEnvContainers,
|
|
161
225
|
options.env
|
|
162
226
|
);
|
|
163
227
|
const envVarName = envMap.get(path);
|
package/src/doctor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Config doctor — structured
|
|
2
|
+
* Config doctor — structured field reports for a config object against a schema.
|
|
3
3
|
*
|
|
4
4
|
* Reports which fields are valid, missing, using defaults, deprecated, or invalid.
|
|
5
5
|
*/
|
|
@@ -7,14 +7,22 @@
|
|
|
7
7
|
import type { z } from 'zod';
|
|
8
8
|
|
|
9
9
|
import { collectConfigMeta } from './collect.js';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
coerceEnvValue,
|
|
12
|
+
getAtPath,
|
|
13
|
+
getSchemaAtPath,
|
|
14
|
+
isZodContainer,
|
|
15
|
+
isZodObject,
|
|
16
|
+
unwrapToBase,
|
|
17
|
+
zodDef,
|
|
18
|
+
} from './zod-utils.js';
|
|
11
19
|
|
|
12
20
|
// ---------------------------------------------------------------------------
|
|
13
21
|
// Types
|
|
14
22
|
// ---------------------------------------------------------------------------
|
|
15
23
|
|
|
16
|
-
/**
|
|
17
|
-
export interface
|
|
24
|
+
/** Validation status for a single config field. */
|
|
25
|
+
export interface ConfigFieldReport {
|
|
18
26
|
readonly path: string;
|
|
19
27
|
readonly status: 'valid' | 'missing' | 'invalid' | 'deprecated' | 'default';
|
|
20
28
|
readonly message: string;
|
|
@@ -22,8 +30,8 @@ export interface ConfigDiagnostic {
|
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/** Aggregated result from checking config against a schema. */
|
|
25
|
-
export interface
|
|
26
|
-
readonly
|
|
33
|
+
export interface ConfigReport {
|
|
34
|
+
readonly fields: readonly ConfigFieldReport[];
|
|
27
35
|
readonly valid: boolean;
|
|
28
36
|
}
|
|
29
37
|
|
|
@@ -74,9 +82,20 @@ const applyEnvToValues = (
|
|
|
74
82
|
const meta = collectConfigMeta(schema);
|
|
75
83
|
const result = structuredClone(values) as Record<string, unknown>;
|
|
76
84
|
for (const [path, fieldMeta] of meta) {
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
const fieldSchema = getSchemaAtPath(schema, path);
|
|
86
|
+
const envName = fieldMeta.env;
|
|
87
|
+
const envValue = envName ? envVars[envName] : undefined;
|
|
88
|
+
if (!envName || envValue === undefined) {
|
|
89
|
+
continue;
|
|
79
90
|
}
|
|
91
|
+
if (fieldSchema && isZodContainer(fieldSchema)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
setAtPath(
|
|
95
|
+
result,
|
|
96
|
+
path,
|
|
97
|
+
fieldSchema ? coerceEnvValue(envValue, fieldSchema) : envValue
|
|
98
|
+
);
|
|
80
99
|
}
|
|
81
100
|
return result;
|
|
82
101
|
};
|
|
@@ -96,7 +115,7 @@ const validateFieldValue = (
|
|
|
96
115
|
path: string,
|
|
97
116
|
fieldSchema: z.ZodType,
|
|
98
117
|
value: unknown
|
|
99
|
-
):
|
|
118
|
+
): ConfigFieldReport => {
|
|
100
119
|
const result = fieldSchema.safeParse(value);
|
|
101
120
|
if (result.success) {
|
|
102
121
|
return { message: 'OK', path, status: 'valid', value };
|
|
@@ -112,7 +131,7 @@ const classifyField = (
|
|
|
112
131
|
fieldSchema: z.ZodType,
|
|
113
132
|
values: Record<string, unknown>,
|
|
114
133
|
deprecatedMeta: Map<string, string>
|
|
115
|
-
):
|
|
134
|
+
): ConfigFieldReport => {
|
|
116
135
|
const value = getAtPath(values, path);
|
|
117
136
|
const deprecationMsg = deprecatedMeta.get(path);
|
|
118
137
|
|
|
@@ -190,7 +209,7 @@ const collectLeaves = (schema: z.ZodType): WalkEntry[] => {
|
|
|
190
209
|
// ---------------------------------------------------------------------------
|
|
191
210
|
|
|
192
211
|
/**
|
|
193
|
-
* Check a config object against a schema and return structured
|
|
212
|
+
* Check a config object against a schema and return structured field reports.
|
|
194
213
|
*
|
|
195
214
|
* Reports which fields are valid, missing, using defaults, deprecated, or invalid.
|
|
196
215
|
*/
|
|
@@ -198,7 +217,7 @@ export const checkConfig = <T extends z.ZodType>(
|
|
|
198
217
|
schema: T,
|
|
199
218
|
values: Record<string, unknown>,
|
|
200
219
|
options?: { readonly env?: Record<string, string | undefined> }
|
|
201
|
-
):
|
|
220
|
+
): ConfigReport => {
|
|
202
221
|
const objSchema = schema as unknown as z.ZodObject<Record<string, z.ZodType>>;
|
|
203
222
|
const effectiveValues = options?.env
|
|
204
223
|
? applyEnvToValues(values, objSchema, options.env)
|
|
@@ -207,13 +226,13 @@ export const checkConfig = <T extends z.ZodType>(
|
|
|
207
226
|
const deprecatedMeta = collectDeprecatedPaths(objSchema);
|
|
208
227
|
const leaves = collectLeaves(objSchema);
|
|
209
228
|
|
|
210
|
-
const
|
|
229
|
+
const fields = leaves.map((leaf) =>
|
|
211
230
|
classifyField(leaf.path, leaf.schema, effectiveValues, deprecatedMeta)
|
|
212
231
|
);
|
|
213
232
|
|
|
214
|
-
const valid =
|
|
233
|
+
const valid = fields.every(
|
|
215
234
|
(d) => d.status !== 'missing' && d.status !== 'invalid'
|
|
216
235
|
);
|
|
217
236
|
|
|
218
|
-
return {
|
|
237
|
+
return { fields, valid };
|
|
219
238
|
};
|
package/src/index.ts
CHANGED
|
@@ -36,8 +36,8 @@ export {
|
|
|
36
36
|
export { deriveConfigFields, type FieldDescription } from './derive-fields.js';
|
|
37
37
|
export {
|
|
38
38
|
checkConfig,
|
|
39
|
-
type
|
|
40
|
-
type
|
|
39
|
+
type ConfigReport,
|
|
40
|
+
type ConfigFieldReport,
|
|
41
41
|
} from './doctor.js';
|
|
42
42
|
export { env, secret, deprecated, type ConfigFieldMeta } from './extensions.js';
|
|
43
43
|
export {
|
package/src/resolve.ts
CHANGED
|
@@ -9,7 +9,12 @@ import { Result, ValidationError } from '@ontrails/core';
|
|
|
9
9
|
|
|
10
10
|
import { collectConfigMeta } from './collect.js';
|
|
11
11
|
import { deepMerge } from './merge.js';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
coerceEnvValue,
|
|
14
|
+
getSchemaAtPath,
|
|
15
|
+
isZodContainer,
|
|
16
|
+
zodDef,
|
|
17
|
+
} from './zod-utils.js';
|
|
13
18
|
|
|
14
19
|
// ---------------------------------------------------------------------------
|
|
15
20
|
// Types
|
|
@@ -25,72 +30,6 @@ export interface DeriveConfigOptions<T extends z.ZodType> {
|
|
|
25
30
|
readonly env?: Record<string, string | undefined> | undefined;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// Env coercion helpers (defined before consumers)
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
/** Boolean string values we accept from environment variables. */
|
|
33
|
-
const BOOL_TRUE = new Set(['true', '1']);
|
|
34
|
-
const BOOL_FALSE = new Set(['false', '0']);
|
|
35
|
-
|
|
36
|
-
/** Primitive type names we can coerce env strings into. */
|
|
37
|
-
const PRIMITIVE_TYPES = new Set(['number', 'boolean', 'string']);
|
|
38
|
-
|
|
39
|
-
/** Try to advance one level through a Zod wrapper, returning the inner type or undefined. */
|
|
40
|
-
const unwrapOne = (
|
|
41
|
-
schema: z.ZodType
|
|
42
|
-
): { typeName: string | undefined; inner: z.ZodType | undefined } => {
|
|
43
|
-
const def = zodDef(schema);
|
|
44
|
-
return {
|
|
45
|
-
inner: def['innerType'] as z.ZodType | undefined,
|
|
46
|
-
typeName: def['type'] as string | undefined,
|
|
47
|
-
};
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/** Unwrap ZodDefault / ZodOptional / ZodNullable to find the base type name. */
|
|
51
|
-
const resolveBaseTypeName = (schema: z.ZodType): string => {
|
|
52
|
-
let current: z.ZodType = schema;
|
|
53
|
-
|
|
54
|
-
for (let depth = 0; depth < 10; depth += 1) {
|
|
55
|
-
const { typeName, inner } = unwrapOne(current);
|
|
56
|
-
if (typeName && PRIMITIVE_TYPES.has(typeName)) {
|
|
57
|
-
return typeName;
|
|
58
|
-
}
|
|
59
|
-
if (!inner) {
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
current = inner;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return 'string';
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
/** Coerce a boolean env string. Returns the original string if unrecognized. */
|
|
69
|
-
const coerceBooleanEnv = (raw: string): unknown => {
|
|
70
|
-
if (BOOL_TRUE.has(raw)) {
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
if (BOOL_FALSE.has(raw)) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
return raw;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/** Coerce env var lookup table keyed by base type name. */
|
|
80
|
-
const ENV_COERCERS: Record<string, (raw: string) => unknown> = {
|
|
81
|
-
boolean: coerceBooleanEnv,
|
|
82
|
-
number: (raw: string) => {
|
|
83
|
-
const n = Number(raw);
|
|
84
|
-
return Number.isNaN(n) ? raw : n;
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/** Coerce a string env value to the type expected by the schema field. */
|
|
89
|
-
const coerceEnvValue = (raw: string, schema: z.ZodType): unknown => {
|
|
90
|
-
const coercer = ENV_COERCERS[resolveBaseTypeName(schema)];
|
|
91
|
-
return coercer ? coercer(raw) : raw;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
33
|
// ---------------------------------------------------------------------------
|
|
95
34
|
// Path utilities
|
|
96
35
|
// ---------------------------------------------------------------------------
|
|
@@ -132,33 +71,6 @@ const setAtPath = (
|
|
|
132
71
|
parent[parts.at(-1) as string] = value;
|
|
133
72
|
};
|
|
134
73
|
|
|
135
|
-
/** Resolve one step of a Zod shape walk: find the field schema for a key. */
|
|
136
|
-
const resolveShapeStep = (
|
|
137
|
-
current: z.ZodType,
|
|
138
|
-
key: string
|
|
139
|
-
): z.ZodType | undefined => {
|
|
140
|
-
const shape = zodDef(current)['shape'] as
|
|
141
|
-
| Record<string, z.ZodType>
|
|
142
|
-
| undefined;
|
|
143
|
-
return shape?.[key];
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
/** Walk a Zod schema shape to find the field at a dot-separated path. */
|
|
147
|
-
const getFieldSchema = (
|
|
148
|
-
schema: z.ZodType,
|
|
149
|
-
path: string
|
|
150
|
-
): z.ZodType | undefined => {
|
|
151
|
-
let current: z.ZodType = schema;
|
|
152
|
-
for (const part of path.split('.')) {
|
|
153
|
-
const next = resolveShapeStep(current, part);
|
|
154
|
-
if (!next) {
|
|
155
|
-
return undefined;
|
|
156
|
-
}
|
|
157
|
-
current = next;
|
|
158
|
-
}
|
|
159
|
-
return current;
|
|
160
|
-
};
|
|
161
|
-
|
|
162
74
|
// ---------------------------------------------------------------------------
|
|
163
75
|
// Env overlay
|
|
164
76
|
// ---------------------------------------------------------------------------
|
|
@@ -170,7 +82,10 @@ const applyOneEnvOverride = (
|
|
|
170
82
|
path: string,
|
|
171
83
|
envValue: string
|
|
172
84
|
): void => {
|
|
173
|
-
const fieldSchema =
|
|
85
|
+
const fieldSchema = getSchemaAtPath(schema, path);
|
|
86
|
+
if (fieldSchema && isZodContainer(fieldSchema)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
174
89
|
const coerced = fieldSchema
|
|
175
90
|
? coerceEnvValue(envValue, fieldSchema)
|
|
176
91
|
: envValue;
|
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Infrastructure trail that validates config values against a schema.
|
|
3
3
|
*
|
|
4
|
-
* Returns structured
|
|
4
|
+
* Returns structured field reports indicating which fields are valid,
|
|
5
5
|
* missing, invalid, deprecated, or using defaults.
|
|
6
6
|
*/
|
|
7
7
|
import { Result, trail } from '@ontrails/core';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
|
|
10
10
|
import { configResource } from '../config-resource.js';
|
|
11
|
+
import { collectConfigMeta } from '../collect.js';
|
|
11
12
|
import { checkConfig } from '../doctor.js';
|
|
12
13
|
import { deepMerge } from '../merge.js';
|
|
14
|
+
import { isLikelySecret } from '../secret-heuristics.js';
|
|
13
15
|
|
|
14
|
-
const
|
|
16
|
+
const fieldReportSchema = z.object({
|
|
15
17
|
message: z.string(),
|
|
16
18
|
path: z.string(),
|
|
19
|
+
redacted: z.boolean().optional(),
|
|
17
20
|
status: z.enum(['valid', 'missing', 'invalid', 'deprecated', 'default']),
|
|
21
|
+
value: z.unknown().optional(),
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
const outputSchema = z.object({
|
|
21
|
-
|
|
25
|
+
fields: z.array(fieldReportSchema),
|
|
22
26
|
valid: z.boolean(),
|
|
23
27
|
});
|
|
24
28
|
|
|
29
|
+
type ConfigCheckFieldReport = ReturnType<typeof checkConfig>['fields'][number] &
|
|
30
|
+
Readonly<{
|
|
31
|
+
redacted?: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
25
34
|
/** Merge input values on top of resolved config values. */
|
|
26
35
|
const mergeValues = (
|
|
27
36
|
resolved: Record<string, unknown>,
|
|
@@ -31,13 +40,39 @@ const mergeValues = (
|
|
|
31
40
|
return hasOverrides ? deepMerge(resolved, overrides) : resolved;
|
|
32
41
|
};
|
|
33
42
|
|
|
43
|
+
const redactSecretFields = (
|
|
44
|
+
schema: z.ZodObject<Record<string, z.ZodType>>,
|
|
45
|
+
fields: ReturnType<typeof checkConfig>['fields']
|
|
46
|
+
): ConfigCheckFieldReport[] => {
|
|
47
|
+
const meta = collectConfigMeta(schema);
|
|
48
|
+
const redactedPaths = new Set(
|
|
49
|
+
[...meta.entries()]
|
|
50
|
+
.filter(
|
|
51
|
+
([, fieldMeta]) =>
|
|
52
|
+
fieldMeta.secret === true ||
|
|
53
|
+
(fieldMeta.env !== undefined && isLikelySecret(fieldMeta.env))
|
|
54
|
+
)
|
|
55
|
+
.map(([path]) => path)
|
|
56
|
+
);
|
|
57
|
+
return fields.map((field) => {
|
|
58
|
+
const shouldRedact = [...redactedPaths].some(
|
|
59
|
+
(path) => field.path === path || field.path.startsWith(`${path}.`)
|
|
60
|
+
);
|
|
61
|
+
if (!shouldRedact || !('value' in field) || field.value === undefined) {
|
|
62
|
+
return field;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ...field, redacted: true, value: '[REDACTED]' };
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
34
69
|
export const configCheck = trail('config.check', {
|
|
35
70
|
blaze: (input, ctx) => {
|
|
36
71
|
const state = configResource.from(ctx);
|
|
37
72
|
const effective = mergeValues(state.resolved, input.values);
|
|
38
73
|
const checked = checkConfig(state.schema, effective);
|
|
39
74
|
return Result.ok({
|
|
40
|
-
|
|
75
|
+
fields: redactSecretFields(state.schema, checked.fields),
|
|
41
76
|
valid: checked.valid,
|
|
42
77
|
});
|
|
43
78
|
},
|
package/src/zod-utils.ts
CHANGED
|
@@ -9,15 +9,27 @@ import type { z } from 'zod';
|
|
|
9
9
|
export const zodDef = (schema: z.ZodType): Record<string, unknown> =>
|
|
10
10
|
schema.def as unknown as Record<string, unknown>;
|
|
11
11
|
|
|
12
|
-
/** Wrapper types that
|
|
13
|
-
const
|
|
12
|
+
/** Wrapper types that preserve object traversal shape. */
|
|
13
|
+
const TRAVERSAL_WRAPPER_TYPES = new Set(['optional', 'default', 'nullable']);
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/** Wrapper types env overlay can inspect before coercing a string value. */
|
|
16
|
+
const ENV_WRAPPER_TYPES = new Set([
|
|
17
|
+
...TRAVERSAL_WRAPPER_TYPES,
|
|
18
|
+
'catch',
|
|
19
|
+
'nonoptional',
|
|
20
|
+
'prefault',
|
|
21
|
+
'readonly',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** Unwrap through selected wrappers to find the base schema. */
|
|
25
|
+
const unwrapWith = (
|
|
26
|
+
schema: z.ZodType,
|
|
27
|
+
wrapperTypes: ReadonlySet<string>
|
|
28
|
+
): z.ZodType => {
|
|
17
29
|
let current = schema;
|
|
18
30
|
for (let depth = 0; depth < 10; depth += 1) {
|
|
19
31
|
const def = zodDef(current);
|
|
20
|
-
if (!
|
|
32
|
+
if (!wrapperTypes.has(def['type'] as string)) {
|
|
21
33
|
return current;
|
|
22
34
|
}
|
|
23
35
|
const inner = def['innerType'] as z.ZodType | undefined;
|
|
@@ -29,6 +41,14 @@ export const unwrapToBase = (schema: z.ZodType): z.ZodType => {
|
|
|
29
41
|
return current;
|
|
30
42
|
};
|
|
31
43
|
|
|
44
|
+
/** Unwrap through shape-preserving wrappers to find the base schema. */
|
|
45
|
+
export const unwrapToBase = (schema: z.ZodType): z.ZodType =>
|
|
46
|
+
unwrapWith(schema, TRAVERSAL_WRAPPER_TYPES);
|
|
47
|
+
|
|
48
|
+
/** Unwrap through env-coercion wrappers to find the env target schema. */
|
|
49
|
+
const unwrapToEnvBase = (schema: z.ZodType): z.ZodType =>
|
|
50
|
+
unwrapWith(schema, ENV_WRAPPER_TYPES);
|
|
51
|
+
|
|
32
52
|
/** Check if a schema is (or wraps) a ZodObject by inspecting its def. */
|
|
33
53
|
export const isZodObject = (
|
|
34
54
|
schema: z.ZodType
|
|
@@ -37,6 +57,85 @@ export const isZodObject = (
|
|
|
37
57
|
return def['type'] === 'object' && 'shape' in def;
|
|
38
58
|
};
|
|
39
59
|
|
|
60
|
+
/** Container types an env string should not replace wholesale. */
|
|
61
|
+
const CONTAINER_TYPES = new Set([
|
|
62
|
+
'object',
|
|
63
|
+
'array',
|
|
64
|
+
'tuple',
|
|
65
|
+
'record',
|
|
66
|
+
'map',
|
|
67
|
+
'set',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Check whether a schema is a container shape after unwrapping defaults. */
|
|
71
|
+
export const isZodContainer = (schema: z.ZodType): boolean => {
|
|
72
|
+
const def = zodDef(unwrapToEnvBase(schema));
|
|
73
|
+
return CONTAINER_TYPES.has(def['type'] as string);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Boolean string values we accept from environment variables. */
|
|
77
|
+
const BOOL_TRUE = new Set(['true', '1']);
|
|
78
|
+
const BOOL_FALSE = new Set(['false', '0']);
|
|
79
|
+
|
|
80
|
+
/** Primitive type names we can coerce env strings into. */
|
|
81
|
+
const PRIMITIVE_TYPES = new Set(['number', 'boolean', 'string']);
|
|
82
|
+
|
|
83
|
+
/** Resolve the primitive base type name after unwrapping defaults. */
|
|
84
|
+
const resolvePrimitiveBaseTypeName = (
|
|
85
|
+
schema: z.ZodType
|
|
86
|
+
): string | undefined => {
|
|
87
|
+
const def = zodDef(unwrapToEnvBase(schema));
|
|
88
|
+
const typeName = def['type'] as string | undefined;
|
|
89
|
+
return typeName && PRIMITIVE_TYPES.has(typeName) ? typeName : undefined;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Coerce a boolean env string. Returns the original string if unrecognized. */
|
|
93
|
+
const coerceBooleanEnv = (raw: string): unknown => {
|
|
94
|
+
if (BOOL_TRUE.has(raw)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (BOOL_FALSE.has(raw)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return raw;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Coerce env var lookup table keyed by base type name. */
|
|
104
|
+
const ENV_COERCERS: Record<string, (raw: string) => unknown> = {
|
|
105
|
+
boolean: coerceBooleanEnv,
|
|
106
|
+
number: (raw: string) => {
|
|
107
|
+
const n = Number(raw);
|
|
108
|
+
return Number.isNaN(n) ? raw : n;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Coerce a string env value to the type expected by the schema field. */
|
|
113
|
+
export const coerceEnvValue = (raw: string, schema: z.ZodType): unknown => {
|
|
114
|
+
const typeName = resolvePrimitiveBaseTypeName(schema);
|
|
115
|
+
const coercer = typeName ? ENV_COERCERS[typeName] : undefined;
|
|
116
|
+
return coercer ? coercer(raw) : raw;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Resolve a schema at a dot-separated path through nested object shapes. */
|
|
120
|
+
export const getSchemaAtPath = (
|
|
121
|
+
schema: z.ZodType,
|
|
122
|
+
path: string
|
|
123
|
+
): z.ZodType | undefined => {
|
|
124
|
+
let current = schema;
|
|
125
|
+
for (const part of path.split('.')) {
|
|
126
|
+
const base = unwrapToBase(current);
|
|
127
|
+
const shape = zodDef(base)['shape'] as
|
|
128
|
+
| Record<string, z.ZodType>
|
|
129
|
+
| undefined;
|
|
130
|
+
const next = shape?.[part];
|
|
131
|
+
if (!next) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
current = next;
|
|
135
|
+
}
|
|
136
|
+
return current;
|
|
137
|
+
};
|
|
138
|
+
|
|
40
139
|
/** Read a value at a dot-separated path from a plain object. */
|
|
41
140
|
export const getAtPath = (
|
|
42
141
|
obj: Record<string, unknown>,
|