Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clear-config-validation-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@croco/framework-config": patch
---

Report missing, malformed, and invalid environment settings distinctly while keeping input values out of validation diagnostics.
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
editUrl: false
next: false
Expand All @@ -15,11 +15,11 @@

### Constructor

> **new ConfigValidationProblem**(`missingPaths`): `ConfigValidationProblem`
> **new ConfigValidationProblem**(`diagnostics`): `ConfigValidationProblem`

#### Parameters

##### missingPaths
##### diagnostics

`string`[]

Expand Down
6 changes: 3 additions & 3 deletions packages/framework-config/src/libs/problems/ConfigProblems.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Problem, ProblemCategory } from "@croco/problems-core";

export class ConfigSchemaNotFoundProblem extends Problem {
Expand All @@ -9,13 +9,13 @@
}

export class ConfigValidationProblem extends Problem {
constructor(missingPaths: string[]) {
const missing = missingPaths.join(", ");
constructor(diagnostics: string[]) {
const detail = diagnostics.join("; ");

super(
"framework-config/config-validation-failed",
ProblemCategory.ValidationError,
`Missing required: ${missing}`,
`Config validation failed: ${detail}`,
);
}
}
Expand Down
199 changes: 199 additions & 0 deletions packages/framework-config/src/tests/ValidateConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,205 @@ describe("validateConfig", () => {
});

describe("with invalid config", () => {
it("should distinguish missing values from invalid values without exposing inputs", () => {
const schema = z.object({
MISSING: z.string(),
PORT: z.coerce.number(),
API_URL: z.url(),
MODE: z.enum(["development", "production"]),
});
const env = {
PORT: "secret-port-value",
API_URL: "secret-url-value",
MODE: "secret-mode-value",
};

let problem: ConfigValidationProblem | undefined;
try {
validateConfig(schema, env);
} catch (error) {
if (error instanceof ConfigValidationProblem) problem = error;
}

expect(problem).toBeInstanceOf(ConfigValidationProblem);
expect(problem?.detail).toContain("MISSING: Missing required");
expect(problem?.detail).toContain("PORT: invalid_type: Expected number");
expect(problem?.detail).toContain("API_URL: invalid_format: Invalid URL");
expect(problem?.detail).toContain("MODE: invalid_value: Invalid option");
expect(problem?.detail).not.toContain("secret-");
});

it("should report a missing coerced value as missing", () => {
const schema = z.object({ PORT: z.coerce.number() });

expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required");
});

it("should distinguish missing and invalid values through a wrapped object schema", () => {
const schema = z.object({ PORT: z.coerce.number() }).transform((value) => value);

expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required");
expect(() => validateConfig(schema, { PORT: "abc" })).toThrow(
"PORT: invalid_type: Expected number",
);
});

it("should distinguish missing and invalid values through an intersection", () => {
const schema = z.intersection(
z.object({ PORT: z.coerce.number() }),
z.object({ MODE: z.string().default("development") }),
);

expect(() => validateConfig(schema, {})).toThrow("<root>: Missing required");
expect(() => validateConfig(schema, { PORT: "abc" })).toThrow(
"<root>: invalid_type: Expected number",
);
});

it("should not call an invalid transformed child missing when its source is present", () => {
const schema = z.object({
CONFIG: z
.string()
.transform((value) => ({ PORT: value }))
.pipe(z.object({ PORT: z.number() })),
});

expect(() => validateConfig(schema, { CONFIG: "abc" })).toThrow(
"CONFIG: invalid_type: Expected number",
);
});

it("should not call a supplied value missing after a top-level transform renames it", () => {
const schema = z
.object({ RAW_PORT: z.string() })
.transform(({ RAW_PORT }) => ({ PORT: RAW_PORT }))
.pipe(z.object({ PORT: z.coerce.number() }));

expect(() => validateConfig(schema, { RAW_PORT: "abc" })).toThrow(
"PORT: invalid_type: Expected number",
);
});

it("should not infer a missing piped field from the pre-transform input", () => {
const schema = z
.object({ RAW_PORT: z.string().optional() })
.transform(({ RAW_PORT }) => ({ PORT: RAW_PORT ?? "abc" }))
.pipe(z.object({ PORT: z.coerce.number() }));

expect(() => validateConfig(schema, {})).toThrow("PORT: invalid_type: Expected number");
});

it("should classify piped output against its stage through a schema wrapper", () => {
const schema = z
.object({ RAW_PORT: z.string() })
.transform(({ RAW_PORT }) => ({ PORT: RAW_PORT }))
.pipe(z.object({ PORT: z.coerce.number() }))
.optional();

expect(() => validateConfig(schema, { RAW_PORT: "abc" })).toThrow(
"PORT: invalid_type: Expected number",
);
});

it("should not call a defaulted piped value missing", () => {
const schema = z
.object({ PORT: z.string().default("abc") })
.pipe(z.object({ PORT: z.coerce.number() }));

expect(() => validateConfig(schema, {})).toThrow("PORT: invalid_type: Expected number");
});

it("should report an input-stage missing value before a pipe runs", () => {
const schema = z.object({ PORT: z.string() }).pipe(z.object({ PORT: z.coerce.number() }));

expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required");
});

it("should report an input-stage missing coerced value before a pipe runs", () => {
const schema = z.object({ PORT: z.coerce.number() }).pipe(z.object({ PORT: z.number() }));

expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required");
});

it("should retain transformed issue provenance through an intersection", () => {
const transformedPort = z
.object({ RAW_PORT: z.string() })
.transform(({ RAW_PORT }) => ({ PORT: RAW_PORT }))
.pipe(z.object({ PORT: z.coerce.number() }));
const schema = z.intersection(transformedPort, z.object({ MODE: z.string().optional() }));

expect(() => validateConfig(schema, { RAW_PORT: "abc" })).toThrow(
"<root>: invalid_type: Expected number",
);
});

it("should report a missing field when its schema overrides the error message", () => {
const schema = z.object({ PORT: z.string({ error: "Port is required" }) });

expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required");
});

it("should report a missing nested object", () => {
const schema = z.object({ DATABASE: z.object({ URL: z.string() }) });

expect(() => validateConfig(schema, {})).toThrow("DATABASE: Missing required");
});

it("should report a missing field before its transform or pipe runs", () => {
const schema = z.object({
PORT: z.coerce.number().pipe(z.number().positive()),
});

expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required");
});

it("should not confuse a reused output validator with an optional input validator", () => {
const port = z.coerce.number();
const schema = z
.object({ PORT: port.optional() })
.transform(() => ({ PORT: "abc" }))
.pipe(z.object({ PORT: port }));

expect(() => validateConfig(schema, {})).toThrow("PORT: invalid_type: Expected number");
});

it("should report a missing field behind a lazy schema", () => {
const schema = z.object({ PORT: z.lazy(() => z.coerce.number()) });

expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required");
});

it("should exclude custom issue paths and type labels derived from input", () => {
const schema = z.object({
TOKEN: z.string().superRefine((value, context) => {
context.addIssue({ code: "invalid_type", expected: value, path: [value] });
}),
});

let problem: ConfigValidationProblem | undefined;
try {
validateConfig(schema, { TOKEN: "secret-token-value" });
} catch (error) {
if (error instanceof ConfigValidationProblem) problem = error;
}

expect(problem?.detail).toContain("TOKEN: invalid_type: Invalid type");
expect(problem?.detail).not.toContain("secret-token-value");
});

it("should not expose a custom validation message containing the input", () => {
const schema = z.object({
TOKEN: z.string().refine(() => false, { message: "Secret is secret-token-value" }),
});

expect(() => validateConfig(schema, { TOKEN: "secret-token-value" })).toThrow(
"TOKEN: custom: Invalid value",
);
expect(() => validateConfig(schema, { TOKEN: "secret-token-value" })).not.toThrow(
"secret-token-value",
);
});

it("should throw when required field is missing", () => {
const schema = z.object({
DATABASE_URL: z.string(),
Expand Down
127 changes: 121 additions & 6 deletions packages/framework-config/src/validateConfig.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,132 @@
import type { z } from "zod";
import { z } from "zod";
import { ConfigValidationProblem } from "./libs/problems/ConfigProblems";

function isMissingAtPath(data: unknown, path: PropertyKey[]): boolean {
let value = data;
for (const key of path) {
if (value === null || typeof value !== "object") return false;
if (!Object.prototype.hasOwnProperty.call(value, key)) return true;
value = Reflect.get(value, key);
if (value === undefined) return true;
}
return false;
}

function objectFieldNames(schema: z.ZodType): string[] {
if (schema instanceof z.ZodObject) return Object.keys(schema.shape);
if (schema instanceof z.ZodLazy) {
const inner = schema._zod.innerType;
return inner instanceof z.ZodType ? objectFieldNames(inner) : [];
}
if (schema instanceof z.ZodPipe) {
const inputFields = schema.in instanceof z.ZodType ? objectFieldNames(schema.in) : [];
const outputFields = schema.out instanceof z.ZodType ? objectFieldNames(schema.out) : [];
return [...new Set([...inputFields, ...outputFields])];
}
if ("unwrap" in schema && typeof schema.unwrap === "function") {
const inner = schema.unwrap();
if (inner instanceof z.ZodType) return objectFieldNames(inner);
}
return [];
}

function isRawInputIssue(
schema: z.ZodType,
path: PropertyKey[],
origin: unknown,
input: unknown,
): boolean {
if (
input === undefined &&
(schema instanceof z.ZodOptional ||
schema instanceof z.ZodDefault ||
schema instanceof z.ZodPrefault ||
schema instanceof z.ZodCatch)
) {
return false;
}
if (path.length === 0 && schema === origin) return true;
if (schema instanceof z.ZodPipe) {
return schema.in instanceof z.ZodType && isRawInputIssue(schema.in, path, origin, input);
}
if (schema instanceof z.ZodIntersection) {
return (
(schema.def.left instanceof z.ZodType &&
isRawInputIssue(schema.def.left, path, origin, input)) ||
(schema.def.right instanceof z.ZodType &&
isRawInputIssue(schema.def.right, path, origin, input))
);
}
if (schema instanceof z.ZodLazy) {
const inner = schema._zod.innerType;
return inner instanceof z.ZodType && isRawInputIssue(inner, path, origin, input);
}
if ("unwrap" in schema && typeof schema.unwrap === "function") {
const inner = schema.unwrap();
if (inner instanceof z.ZodType) return isRawInputIssue(inner, path, origin, input);
}
if (schema instanceof z.ZodObject) {
const field = path[0];
if (typeof field !== "string") return false;
const child = schema.shape[field];
const childInput =
input !== null && typeof input === "object" ? Reflect.get(input, field) : undefined;
return child instanceof z.ZodType && isRawInputIssue(child, path.slice(1), origin, childInput);
}
return false;
}

function safeIssuePath(schema: z.ZodType, path: PropertyKey[]): string {
const field = path[0];
return typeof field === "string" && objectFieldNames(schema).includes(field) ? field : "<root>";
}

function safeIssueMessage(issue: z.core.$ZodRawIssue): string {
switch (issue.code) {
case "invalid_type":
return /^(string|number|boolean|object|array|null|undefined|bigint|date|symbol|function)$/.test(
issue.expected,
)
? `Expected ${issue.expected}`
: "Invalid type";
case "invalid_format":
return issue.format === "url" ? "Invalid URL" : "Invalid format";
case "invalid_value":
return "Invalid option";
case "too_small":
return "Value is too small";
case "too_big":
return "Value is too large";
default:
return "Invalid value";
}
}

export function validateConfig<T>(
schema: z.ZodType<T>,
env?: Record<string, string | undefined>,
): T {
const data = env ?? process.env;
const result = schema.safeParse(data);
// safeParse removes issue.inst, which identifies the stage that rejected a value.
const result = schema._zod.run({ value: data, issues: [] }, { async: false });
if (result instanceof Promise) throw new z.core.$ZodAsyncError();

if (!result.success) {
const missingPaths = result.error.issues.map((issue) => issue.path.join("."));
throw new ConfigValidationProblem(missingPaths);
if (result.issues.length > 0) {
const diagnostics = result.issues.map((issue) => {
const issuePath = issue.path ?? [];
const path = safeIssuePath(schema, issuePath);
if (
issuePath.length > 0 &&
(issue.code === "invalid_type" || issue.code === "invalid_value") &&
isRawInputIssue(schema, issuePath, issue.inst, data) &&
isMissingAtPath(data, issuePath)
) {
return `${path}: Missing required`;
}
return `${path}: ${issue.code}: ${safeIssueMessage(issue)}`;
});
throw new ConfigValidationProblem(diagnostics);
}

return result.data;
return result.value as T;
}
2 changes: 1 addition & 1 deletion scripts/tests/verification-manifest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ describe("verification manifest", () => {
expect(
createHash("sha256").update(JSON.stringify(manifests)).digest("hex"),
"The pre-split monolithic manifest changed; update this digest only after intentionally verifying the new serialized commands.",
).toBe("d733978721940a09d0e1ab182e5e6c6223e8d1e86d5a5aa13ca4136ff2d753a5");
).toBe("c284a3b539bca69f0f2498cf321dc27415a8db411b0edde8bfc0ea31f3faea07");
});

it("classifies every dependency edge and every cross-lane edge for synthesis", () => {
Expand Down
2 changes: 1 addition & 1 deletion scripts/verification-manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ const spineOnly = (
? ["--tier", "spine-blocking"]
: affectedGeneratedSmokeCases),
),
timeoutMs: minutes(45),
timeoutMs: minutes(75),
applicable: generatedAppSmokeApplicable,
artifacts: [
{
Expand Down
Loading