@lenne.tech/nest-server 11.34.0 → 11.34.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,132 @@
1
+ # Migration Guide: 11.34.0 → 11.34.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None in signatures. One **client-visible text** change: validation error messages are now rendered the way class-validator itself renders them (§1, §2) |
8
+ | **Bugfixes** | A custom `message` in `ValidationOptions` is no longer ignored (§1). Default messages no longer reach API clients with raw placeholders such as `$constraint1` (§2) |
9
+ | **Migration Effort** | Nothing to configure. Read §1 and §2 if any test — or any frontend — asserts on exact validation error strings. Read §3 before you put `$value` in a custom message |
10
+
11
+ `MapAndValidatePipe` re-implements class-validator's validation executor, so it also has to render
12
+ error messages the way that executor does. It did neither: it built every message from the
13
+ constraint's `defaultMessage()` alone, and substituted only `$property`.
14
+
15
+ ---
16
+
17
+ ## Quick Migration
18
+
19
+ ```bash
20
+ pnpm update @lenne.tech/nest-server@11.34.1
21
+ pnpm run build
22
+ pnpm test
23
+ ```
24
+
25
+ No configuration change is required, and no source change is required in a typical project.
26
+
27
+ **Vendor-mode projects: this release ADDS a file, so the sync is atomic.**
28
+ `src/core/common/pipes/map-and-validate.pipe.ts` now imports the new
29
+ `src/core/common/helpers/validation-message.helper.ts`. Taking the pipe without the helper does not
30
+ fail a rights check or a test — it fails the build. Sync both files together.
31
+
32
+ ---
33
+
34
+ ## 1. A custom `message` in `ValidationOptions` is now honored
35
+
36
+ Previously the pipe built the message from the constraint's own `defaultMessage()` and never looked
37
+ at `metadata.message`, so the message you wrote was silently discarded and the client received the
38
+ generic default instead.
39
+
40
+ ```typescript
41
+ class UpdateUserInput {
42
+ @IsIn(['admin'], { each: true, message: 'roles may only contain assignable roles (admin)' })
43
+ @IsOptional()
44
+ roles?: string[];
45
+ }
46
+ ```
47
+
48
+ | | Before | After |
49
+ |---|---|---|
50
+ | Response | `each value in roles must be one of the following values: $constraint1` | `roles may only contain assignable roles (admin)` |
51
+
52
+ Custom messages now take precedence over the constraint's default — the same order class-validator's
53
+ own executor uses. The function form (`message: (args) => string`) works too, and its result is
54
+ interpolated afterwards.
55
+
56
+ **What you may notice:** a custom message that was written but never delivered starts being
57
+ delivered. If a frontend or a test was written against the *default* text your project was
58
+ accidentally serving, it now sees the text the decorator actually asks for.
59
+
60
+ ---
61
+
62
+ ## 2. Default messages no longer leak raw placeholders
63
+
64
+ Only `$property` was substituted, so every other token reached the client verbatim:
65
+
66
+ | | Before | After |
67
+ |---|---|---|
68
+ | Response | `each value in roles must be one of the following values: $constraint1` | `each value in roles must be one of the following values: alpha, beta` |
69
+
70
+ All four special tokens are now interpolated — `$constraint1..N`, `$value`, `$property` and
71
+ `$target` — rendered exactly as class-validator renders them (arrays joined with `, `, symbols by
72
+ their description).
73
+
74
+ **Scope of the text change:** 44 of class-validator's built-in messages carry a `$constraint` token,
75
+ so those 44 change text. None of the built-ins uses `$value` or `$target`, so no built-in message
76
+ changes *shape* — they stop showing a placeholder and start showing the value it stood for.
77
+
78
+ **What to check:** any test or frontend string comparison that asserts on a full validation message.
79
+ A test that was pinned to the broken output (`…: $constraint1`) fails and should be updated to the
80
+ interpolated text.
81
+
82
+ This also applies to `@IsDefined`, which since class-validator 0.13 is the one built-in that does not
83
+ register through `ValidateBy` and therefore took a separate code path with the same two gaps.
84
+
85
+ ---
86
+
87
+ ## 3. Security: `$value` echoes the submitted value, and validation errors are not scrubbed
88
+
89
+ `$value` now works — which means a custom message containing it renders the value the client
90
+ submitted, straight back into the error response.
91
+
92
+ **Validation errors bypass the secret-field filtering you may be assuming.**
93
+ `security.secretFields` is applied by `CheckSecurityInterceptor` on the **response** path, whereas
94
+ the `BadRequestException` raised by the pipe goes to the exception filter and is forwarded verbatim.
95
+ The guard inside the renderer is a **type** guard (`boolean | number | string`), not a secrecy guard
96
+ — it will happily echo a password or a token.
97
+
98
+ ```typescript
99
+ // DANGEROUS — the rejected password is echoed back in the error response
100
+ @MinLength(12, { message: 'password "$value" is too short' })
101
+ password: string;
102
+
103
+ // SAFE — describe the rule, never the submitted value
104
+ @MinLength(12, { message: 'password must be at least $constraint1 characters' })
105
+ password: string;
106
+ ```
107
+
108
+ No built-in message uses `$value`, so reaching this is opt-in: it requires a custom message that
109
+ names the token. Note that class-validator's own `validate()` behaves identically — this is not a
110
+ new exposure introduced by the framework, but it becomes reachable in projects where `$value`
111
+ previously did nothing.
112
+
113
+ **Action:** grep your input classes for `$value` in custom messages and confirm none of them sits on
114
+ a credential, token, or otherwise sensitive field.
115
+
116
+ ---
117
+
118
+ ## Under the hood
119
+
120
+ The token renderer is a port of class-validator internals
121
+ (`ValidationUtils.replaceMessageSpecialTokens`, `constraintToString`), which are not part of its
122
+ public API. Because vendor-mode projects resolve their own class-validator, that port can meet a
123
+ version this framework never installed. It is therefore pinned by an invariant test that compares it
124
+ against the installed implementation over a fixture table, so a divergence surfaces as a failing
125
+ test rather than as two renderers disagreeing about one decorator.
126
+
127
+ ---
128
+
129
+ ## Module Documentation
130
+
131
+ - Request lifecycle and the validation pipe: [`docs/REQUEST-LIFECYCLE.md`](../docs/REQUEST-LIFECYCLE.md)
132
+ - Field decorators and validation: `src/core/common/decorators/unified-field.decorator.ts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.34.0",
3
+ "version": "11.34.1",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -0,0 +1,83 @@
1
+ import { ValidationArguments } from 'class-validator';
2
+
3
+ /**
4
+ * Renders a single constraint for use in an error message.
5
+ *
6
+ * Mirror of class-validator's internal `constraintToString`
7
+ * (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
8
+ *
9
+ * See {@link replaceMessageSpecialTokens} for why this lives here and what holds it in sync.
10
+ */
11
+ export function constraintToString(constraint: unknown): string {
12
+ if (Array.isArray(constraint)) {
13
+ return constraint.join(', ');
14
+ }
15
+ if (typeof constraint === 'symbol') {
16
+ constraint = constraint.description;
17
+ }
18
+ return `${constraint}`;
19
+ }
20
+
21
+ /**
22
+ * Resolves a message (string or function) and interpolates the special tokens
23
+ * $constraint1..N, $value, $property and $target.
24
+ *
25
+ * Mirror of class-validator's internal `ValidationUtils.replaceMessageSpecialTokens`
26
+ * (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
27
+ * `MapAndValidatePipe` re-implements the validation executor and therefore has to render messages
28
+ * exactly the way class-validator's own `validate()` would — otherwise the same decorator produces
29
+ * two different strings depending on which of the two ran.
30
+ *
31
+ * WHY IT IS NOT BARREL-EXPORTED
32
+ * Deliberately absent from `src/index.ts`. It exists to reproduce a dependency's internal
33
+ * behaviour, so it has to stay free to follow that dependency; exporting it would owe consumers
34
+ * backward compatibility for a surface we do not control. Vendor-mode consumers resolve their own
35
+ * class-validator, so the copy in `src/core/` can meet a version this repo never installed —
36
+ * `tests/unit/validation-message-mirror.spec.ts` compares it against the INSTALLED implementation
37
+ * over a fixture table so a divergence surfaces as a failing test rather than as two renderers of
38
+ * one contract.
39
+ *
40
+ * SECURITY — `$value` echoes the SUBMITTED value back to the client, and validation errors are
41
+ * never scrubbed: `security.secretFields` is applied by `CheckSecurityInterceptor` on the RESPONSE
42
+ * path, whereas the `BadRequestException` raised from the pipe goes to the exception filter and is
43
+ * forwarded verbatim. The check below is a TYPE guard (boolean | number | string), not a secrecy
44
+ * guard — it happily admits a password or a token. No built-in message uses `$value`, so reaching
45
+ * it is opt-in: never put `$value` in a custom message on a sensitive field.
46
+ */
47
+ export function replaceMessageSpecialTokens(
48
+ message: ((args: ValidationArguments) => string) | string,
49
+ validationArguments: ValidationArguments,
50
+ ): string {
51
+ let messageString = '';
52
+ if (typeof message === 'function') {
53
+ messageString = message(validationArguments);
54
+ } else if (typeof message === 'string') {
55
+ messageString = message;
56
+ }
57
+
58
+ if (messageString && Array.isArray(validationArguments.constraints)) {
59
+ validationArguments.constraints.forEach((constraint, index) => {
60
+ messageString = messageString.replace(
61
+ new RegExp(`\\$constraint${index + 1}`, 'g'),
62
+ constraintToString(constraint),
63
+ );
64
+ });
65
+ }
66
+
67
+ if (
68
+ messageString &&
69
+ validationArguments.value !== undefined &&
70
+ validationArguments.value !== null &&
71
+ ['boolean', 'number', 'string'].includes(typeof validationArguments.value)
72
+ ) {
73
+ messageString = messageString.replace(/\$value/g, `${validationArguments.value}`);
74
+ }
75
+ if (messageString) {
76
+ messageString = messageString.replace(/\$property/g, validationArguments.property);
77
+ }
78
+ if (messageString) {
79
+ messageString = messageString.replace(/\$target/g, validationArguments.targetName);
80
+ }
81
+
82
+ return messageString;
83
+ }
@@ -20,6 +20,7 @@ import {
20
20
  maxLength,
21
21
  min,
22
22
  minLength,
23
+ ValidationArguments,
23
24
  ValidationError,
24
25
  } from 'class-validator';
25
26
  import { ValidationMetadata } from 'class-validator/types/metadata/ValidationMetadata';
@@ -27,6 +28,7 @@ import { inspect } from 'util';
27
28
 
28
29
  import { getUnifiedFieldKeys, nestedTypeRegistry } from '../decorators/unified-field.decorator';
29
30
  import { isBasicType } from '../helpers/input.helper';
31
+ import { replaceMessageSpecialTokens } from '../helpers/validation-message.helper';
30
32
  import { ConfigService } from '../services/config.service';
31
33
  import { ErrorCode } from '../../modules/error-code/error-codes';
32
34
 
@@ -268,18 +270,21 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
268
270
  isValid = validationResult instanceof Promise ? await validationResult : validationResult;
269
271
  }
270
272
 
271
- // Get default message and constraint name if validation failed
273
+ // Get message and constraint name if validation failed
272
274
  if (!isValid) {
273
275
  // Use metadata.name for the constraint key (e.g., "isEmail", "isString")
274
276
  const constraintName = metadata.name || 'customValidation';
275
277
 
276
- if (typeof constraintInstance.defaultMessage === 'function') {
277
- errorMessage = constraintInstance.defaultMessage(validationArgs);
278
- // Replace $property placeholder with actual property name
279
- errorMessage = errorMessage.replace(/\$property/g, propertyName);
280
- } else {
281
- errorMessage = `${propertyName} failed custom validation`;
278
+ // A custom message from ValidationOptions takes precedence over the
279
+ // constraint's default message — same order as class-validator's executor
280
+ let messageTemplate: string | ((args: ValidationArguments) => string) | undefined =
281
+ metadata.message as string | ((args: ValidationArguments) => string) | undefined;
282
+ if (!messageTemplate && typeof constraintInstance.defaultMessage === 'function') {
283
+ messageTemplate = constraintInstance.defaultMessage(validationArgs);
282
284
  }
285
+ errorMessage = messageTemplate
286
+ ? replaceMessageSpecialTokens(messageTemplate, validationArgs)
287
+ : `${propertyName} failed custom validation`;
283
288
 
284
289
  // Add to constraints with the proper name
285
290
  propertyError.constraints[constraintName] = errorMessage;
@@ -578,6 +583,19 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
578
583
 
579
584
  // Add constraint violation if validation failed
580
585
  if (!isValid) {
586
+ // A custom message from ValidationOptions takes precedence over the built-in message
587
+ if (metadata.message) {
588
+ errorMessage = replaceMessageSpecialTokens(
589
+ metadata.message as string | ((args: ValidationArguments) => string),
590
+ {
591
+ constraints: metadata.constraints || [],
592
+ object: tempInstance,
593
+ property: propertyName,
594
+ targetName: targetClass.name,
595
+ value: propertyValue,
596
+ },
597
+ );
598
+ }
581
599
  propertyError.constraints[constraintType] = errorMessage;
582
600
  }
583
601
  }