@zudojs/validation 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,14 @@ assertSizeWithinLimit(body, 1_000_000);
46
46
  assertNoCircularReference(body);
47
47
  ```
48
48
 
49
+ A too-deep payload throws `SerializationDepthError` with `statusCode` 400
50
+ and `expose: true` from both `assertDepthWithinLimit` and
51
+ `assertNoCircularReference`: untrusted input that nests too far is a client
52
+ error, and the message names only the observed depth and the limit
53
+ (`Maximum serialization depth exceeded: 40 > 32`), never the payload.
54
+ Constructed directly, `SerializationDepthError` still defaults to an
55
+ unexposed 500, which is right for data your own server built.
56
+
49
57
  ## Safety Notes
50
58
 
51
59
  - Constraint failures do **not** carry the rejected value. `ValidationError` is
@@ -1,3 +1,4 @@
1
+ import { formatCount } from "@zudojs/types";
1
2
  import { createConstraint, assertNonNegativeInteger, } from "../validationConstraints.base.js";
2
3
  /** Narrows an unknown value to a readonly array. */
3
4
  function isArrayOf(value) {
@@ -34,7 +35,7 @@ export function minItems(minimum) {
34
35
  return createConstraint((value) => value.length >= minimum, {
35
36
  name: `min_items_${minimum}`,
36
37
  code: "min_items",
37
- message: `Value must contain at least ${minimum} items.`,
38
+ message: `Value must contain at least ${formatCount(minimum, "item")}.`,
38
39
  guard: isArrayOf,
39
40
  });
40
41
  }
@@ -46,7 +47,7 @@ export function maxItems(maximum) {
46
47
  return createConstraint((value) => value.length <= maximum, {
47
48
  name: `max_items_${maximum}`,
48
49
  code: "max_items",
49
- message: `Value must contain at most ${maximum} items.`,
50
+ message: `Value must contain at most ${formatCount(maximum, "item")}.`,
50
51
  guard: isArrayOf,
51
52
  });
52
53
  }
@@ -58,7 +59,7 @@ export function exactItems(length) {
58
59
  return createConstraint((value) => value.length === length, {
59
60
  name: `exact_items_${length}`,
60
61
  code: "exact_items",
61
- message: `Value must contain exactly ${length} items.`,
62
+ message: `Value must contain exactly ${formatCount(length, "item")}.`,
62
63
  guard: isArrayOf,
63
64
  });
64
65
  }
@@ -1,4 +1,5 @@
1
1
  import { ValidationLength, ValidationPattern } from "@zudojs/constants";
2
+ import { formatCount } from "@zudojs/types";
2
3
  import { createConstraint, assertNonNegativeInteger, } from "../validationConstraints.base.js";
3
4
  /** Counts Unicode code points rather than UTF-16 code units. */
4
5
  function characterLength(value) {
@@ -22,7 +23,7 @@ export function minLength(minimum) {
22
23
  return createConstraint((value) => characterLength(value) >= minimum, {
23
24
  name: `min_length_${minimum}`,
24
25
  code: "min_length",
25
- message: `Value must contain at least ${minimum} characters.`,
26
+ message: `Value must contain at least ${formatCount(minimum, "character")}.`,
26
27
  guard: (value) => typeof value === "string",
27
28
  });
28
29
  }
@@ -34,7 +35,7 @@ export function maxLength(maximum) {
34
35
  return createConstraint((value) => characterLength(value) <= maximum, {
35
36
  name: `max_length_${maximum}`,
36
37
  code: "max_length",
37
- message: `Value must contain at most ${maximum} characters.`,
38
+ message: `Value must contain at most ${formatCount(maximum, "character")}.`,
38
39
  guard: (value) => typeof value === "string",
39
40
  });
40
41
  }
@@ -17,12 +17,19 @@
17
17
  * @param maxDepth - Depth ceiling, so a deep graph cannot exhaust the stack
18
18
  * before a cycle is reported.
19
19
  * @throws {CircularReferenceError} on the first cycle found.
20
+ * @throws {SerializationDepthError} when the graph is deeper than `maxDepth`
21
+ * (a 400 with `expose: true`, like `assertDepthWithinLimit`).
22
+ * Running out of depth is not evidence of a cycle, and reporting it as one
23
+ * told callers a payload referenced itself when it merely nested too far.
20
24
  */
21
25
  export declare function assertNoCircularReference(value: unknown, path?: string, maxDepth?: number): void;
22
26
  /**
23
27
  * Check whether a value contains circular references.
24
28
  *
25
- * Returns true if a cycle is found, false otherwise.
29
+ * Returns true if a cycle is found, false otherwise. A graph too deep to walk
30
+ * within the measurable ceiling reports false: no cycle was found, and the
31
+ * depth is the caller's own `assertDepthWithinLimit` to report.
32
+ *
26
33
  * Does not throw — use assertNoCircularReference for throwing behavior.
27
34
  */
28
35
  export declare function hasCircularReference(value: unknown): boolean;
@@ -4,8 +4,8 @@
4
4
  * Detects circular references in object graphs before serialization
5
5
  * or validation to prevent stack overflows and provide clear error messages.
6
6
  */
7
- import { CircularReferenceError } from "@zudojs/errors";
8
- import { MAX_MEASURABLE_DEPTH } from "./validationConstraints.depth.js";
7
+ import { CircularReferenceError, SerializationDepthError, } from "@zudojs/errors";
8
+ import { MAX_MEASURABLE_DEPTH, UNTRUSTED_DEPTH_ERROR, } from "./validationConstraints.depth.js";
9
9
  import { TraversalLimitError, traverse, } from "./validationConstraints.traverse.js";
10
10
  /**
11
11
  * Detect circular references in a value graph.
@@ -20,6 +20,10 @@ import { TraversalLimitError, traverse, } from "./validationConstraints.traverse
20
20
  * @param maxDepth - Depth ceiling, so a deep graph cannot exhaust the stack
21
21
  * before a cycle is reported.
22
22
  * @throws {CircularReferenceError} on the first cycle found.
23
+ * @throws {SerializationDepthError} when the graph is deeper than `maxDepth`
24
+ * (a 400 with `expose: true`, like `assertDepthWithinLimit`).
25
+ * Running out of depth is not evidence of a cycle, and reporting it as one
26
+ * told callers a payload referenced itself when it merely nested too far.
23
27
  */
24
28
  export function assertNoCircularReference(value, path = "root", maxDepth = MAX_MEASURABLE_DEPTH) {
25
29
  try {
@@ -30,7 +34,7 @@ export function assertNoCircularReference(value, path = "root", maxDepth = MAX_M
30
34
  throw new CircularReferenceError(error.path);
31
35
  }
32
36
  if (error instanceof TraversalLimitError && error.halt === "depth") {
33
- throw new CircularReferenceError(`${error.path} (exceeded ${maxDepth} levels)`);
37
+ throw new SerializationDepthError(error.observed, maxDepth, UNTRUSTED_DEPTH_ERROR);
34
38
  }
35
39
  throw error;
36
40
  }
@@ -38,7 +42,10 @@ export function assertNoCircularReference(value, path = "root", maxDepth = MAX_M
38
42
  /**
39
43
  * Check whether a value contains circular references.
40
44
  *
41
- * Returns true if a cycle is found, false otherwise.
45
+ * Returns true if a cycle is found, false otherwise. A graph too deep to walk
46
+ * within the measurable ceiling reports false: no cycle was found, and the
47
+ * depth is the caller's own `assertDepthWithinLimit` to report.
48
+ *
42
49
  * Does not throw — use assertNoCircularReference for throwing behavior.
43
50
  */
44
51
  export function hasCircularReference(value) {
@@ -49,6 +56,8 @@ export function hasCircularReference(value) {
49
56
  catch (error) {
50
57
  if (error instanceof CircularReferenceError)
51
58
  return true;
59
+ if (error instanceof SerializationDepthError)
60
+ return false;
52
61
  throw error;
53
62
  }
54
63
  }
@@ -11,6 +11,19 @@
11
11
  * that overflows the stack on the input it was checking protects nothing.
12
12
  */
13
13
  export declare const MAX_MEASURABLE_DEPTH = 512;
14
+ /**
15
+ * Error options for depth failures found by these guards.
16
+ *
17
+ * The guards exist to check UNTRUSTED input, so input that nests too deep is
18
+ * a client error: a 400 that may be exposed. `SerializationDepthError` alone
19
+ * defaults to an unexposed 500, which turned a too-deep request body into an
20
+ * opaque server error. The message carries only the observed depth and the
21
+ * limit, never the payload.
22
+ */
23
+ export declare const UNTRUSTED_DEPTH_ERROR: Readonly<{
24
+ statusCode: 400;
25
+ expose: true;
26
+ }>;
14
27
  /**
15
28
  * Compute the maximum nesting depth of a value.
16
29
  *
@@ -31,7 +44,8 @@ export declare function getSerializationDepth(value: unknown, limit?: number): n
31
44
  *
32
45
  * @param value - The value to check.
33
46
  * @param maxDepth - Maximum permitted nesting depth.
34
- * @throws {SerializationDepthError} when depth exceeds the limit.
47
+ * @throws {SerializationDepthError} when depth exceeds the limit, with
48
+ * `statusCode` 400 and `expose: true` (see {@link UNTRUSTED_DEPTH_ERROR}).
35
49
  */
36
50
  export declare function assertDepthWithinLimit(value: unknown, maxDepth: number): void;
37
51
  //# sourceMappingURL=validationConstraints.depth.d.ts.map
@@ -13,6 +13,19 @@ import { TraversalLimitError, traverse, } from "./validationConstraints.traverse
13
13
  * that overflows the stack on the input it was checking protects nothing.
14
14
  */
15
15
  export const MAX_MEASURABLE_DEPTH = 512;
16
+ /**
17
+ * Error options for depth failures found by these guards.
18
+ *
19
+ * The guards exist to check UNTRUSTED input, so input that nests too deep is
20
+ * a client error: a 400 that may be exposed. `SerializationDepthError` alone
21
+ * defaults to an unexposed 500, which turned a too-deep request body into an
22
+ * opaque server error. The message carries only the observed depth and the
23
+ * limit, never the payload.
24
+ */
25
+ export const UNTRUSTED_DEPTH_ERROR = Object.freeze({
26
+ statusCode: 400,
27
+ expose: true,
28
+ });
16
29
  /**
17
30
  * Compute the maximum nesting depth of a value.
18
31
  *
@@ -42,7 +55,8 @@ export function getSerializationDepth(value, limit = MAX_MEASURABLE_DEPTH) {
42
55
  *
43
56
  * @param value - The value to check.
44
57
  * @param maxDepth - Maximum permitted nesting depth.
45
- * @throws {SerializationDepthError} when depth exceeds the limit.
58
+ * @throws {SerializationDepthError} when depth exceeds the limit, with
59
+ * `statusCode` 400 and `expose: true` (see {@link UNTRUSTED_DEPTH_ERROR}).
46
60
  */
47
61
  export function assertDepthWithinLimit(value, maxDepth) {
48
62
  try {
@@ -50,7 +64,7 @@ export function assertDepthWithinLimit(value, maxDepth) {
50
64
  }
51
65
  catch (error) {
52
66
  if (error instanceof TraversalLimitError && error.halt === "depth") {
53
- throw new SerializationDepthError(error.observed, maxDepth);
67
+ throw new SerializationDepthError(error.observed, maxDepth, UNTRUSTED_DEPTH_ERROR);
54
68
  }
55
69
  throw error;
56
70
  }
@@ -12,8 +12,16 @@
12
12
  * from shared subtrees estimate at a few hundred bytes while serializing to
13
13
  * hundreds of megabytes.
14
14
  *
15
+ * Charging every occurrence means the subtree memo that keeps the walk linear
16
+ * has to be off, so the budget is the only thing that bounds the work: `n`
17
+ * shared `{a:node,b:node}` pairs expand to `2^n` visits. The default budget is
18
+ * therefore finite — {@link SerializationLimits.MAX_SIZE} — rather than
19
+ * `Infinity`, which left an untrusted 1 KB payload able to burn minutes of CPU.
20
+ * Pass an explicit `Infinity` only for input you trust.
21
+ *
15
22
  * @param value - The value to measure.
16
23
  * @param maxBytes - Stop counting past this budget; the budget is returned.
24
+ * Defaults to {@link SerializationLimits.MAX_SIZE}.
17
25
  * @returns The estimated serialized size in bytes.
18
26
  */
19
27
  export declare function estimateSerializedSize(value: unknown, maxBytes?: number): number;
@@ -4,6 +4,7 @@
4
4
  * Estimates serialized payload size to prevent memory exhaustion
5
5
  * from excessively large payloads.
6
6
  */
7
+ import { SerializationLimits } from "@zudojs/constants";
7
8
  import { SerializationPayloadTooLargeError } from "@zudojs/errors";
8
9
  import { MAX_MEASURABLE_DEPTH } from "./validationConstraints.depth.js";
9
10
  import { TraversalLimitError, traverse, } from "./validationConstraints.traverse.js";
@@ -69,11 +70,19 @@ function resolveToJson(value) {
69
70
  * from shared subtrees estimate at a few hundred bytes while serializing to
70
71
  * hundreds of megabytes.
71
72
  *
73
+ * Charging every occurrence means the subtree memo that keeps the walk linear
74
+ * has to be off, so the budget is the only thing that bounds the work: `n`
75
+ * shared `{a:node,b:node}` pairs expand to `2^n` visits. The default budget is
76
+ * therefore finite — {@link SerializationLimits.MAX_SIZE} — rather than
77
+ * `Infinity`, which left an untrusted 1 KB payload able to burn minutes of CPU.
78
+ * Pass an explicit `Infinity` only for input you trust.
79
+ *
72
80
  * @param value - The value to measure.
73
81
  * @param maxBytes - Stop counting past this budget; the budget is returned.
82
+ * Defaults to {@link SerializationLimits.MAX_SIZE}.
74
83
  * @returns The estimated serialized size in bytes.
75
84
  */
76
- export function estimateSerializedSize(value, maxBytes = Number.POSITIVE_INFINITY) {
85
+ export function estimateSerializedSize(value, maxBytes = SerializationLimits.MAX_SIZE) {
77
86
  try {
78
87
  return traverse(value, {
79
88
  maxDepth: MAX_MEASURABLE_DEPTH,
@@ -3,10 +3,9 @@
3
3
  *
4
4
  * @module validationParser/validationParser.collection
5
5
  */
6
+ import { SCHEMA_FORBIDDEN_KEYS } from "@zudojs/constants";
6
7
  import { validate, validateAsync, } from "../validationSchema/validationSchema.core.js";
7
8
  import { failure, success } from "../validationResult/validationResult.type.js";
8
- /** Property names that would mutate a prototype instead of adding a key. */
9
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
10
9
  /** Parses multiple values using the same schema. Succeeds only when every value is valid. */
11
10
  export function parseMany(schema, values) {
12
11
  const parsed = [];
@@ -52,7 +51,7 @@ export function parseRecord(schema, values) {
52
51
  const parsed = Object.create(null);
53
52
  const issues = [];
54
53
  for (const [key, value] of Object.entries(values)) {
55
- if (FORBIDDEN_KEYS.has(key)) {
54
+ if (SCHEMA_FORBIDDEN_KEYS.has(key)) {
56
55
  issues.push({
57
56
  path: [key],
58
57
  code: "forbidden_key",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/validation",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Schema validation with Zod integration, constraints, parsers, composers, circular detection, and depth/size checks.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -25,13 +25,14 @@
25
25
  "!dist/.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@zudojs/constants": "1.1.0",
29
- "@zudojs/errors": "1.1.0",
30
- "zod": "^4.4.3"
28
+ "@zudojs/constants": "1.1.2",
29
+ "@zudojs/errors": "1.3.0",
30
+ "@zudojs/types": "1.2.0",
31
+ "zod": "^4.6.5"
31
32
  },
32
33
  "devDependencies": {
33
34
  "typescript": "7.0.2",
34
- "vitest": "^4.1.11"
35
+ "vitest": "^5.0.1"
35
36
  },
36
37
  "engines": {
37
38
  "node": ">=24.0.0"
@@ -45,7 +46,7 @@
45
46
  "zod",
46
47
  "schemas"
47
48
  ],
48
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
49
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-validation",
49
50
  "bugs": {
50
51
  "url": "https://github.com/oyinlola-tech/zudo/issues"
51
52
  },