fix(#1391): boot-time ValidationPipe metatype self-check — fail loud at startup #1419

Merged
code-infra-01 merged 1 commits from fix/1391-validationpipe-boot-check into next 2026-08-25 14:01:24 +00:00
Member

Closes nothing (issue closed as dup-of-1389-class); references #1391 (diagnosis + disposition) and the #1389 dependency-graph class.

What this adds

A boot-time self-check (assertValidationPipeSeesDtoDecorators(), first call in bootstrap()) that fails loud at startup when the global ValidationPipe cannot see the guarded DTOs' decorated properties.

Why (the failure class, from #1391's diagnosis)

When Nest resolves a @Body() metatype to Object — via import type class erasure (#436), or decorator-metadata loss in a broken dependency graph (the #1389/#1391 mixed-install class) — the pipe's whitelist + forbidNonWhitelisted rejects every property of every payload. The first symptom is a 400 on the FIRST bootstrap attempt of a fresh install, where an operator cannot distinguish a broken payload from a broken daemon. This check surfaces the wiring damage at boot instead, with a named cause and remediation per property.

The duplicate-class-validator-instance variant of this class is excluded by construction (measured, #1391 diagnosis comment 24082): class-validator keys its metadata storage on globalThis, so any number or version-mix of package copies share one storage. What survives — and what this check guards — is losing the metatype/decorator registration itself.

Tested-against note for the reviewer (constructor vs prototype — the trap in this API)

class-validator decorators register metadata keyed on the DTO constructor, and the runtime executor resolves it via object.constructor (ValidationExecutor.js:50 in class-validator 0.15.1). A probe that targets Dto.prototype instead returns ZERO metadata — such a check passes vacuously on every input, including the broken states it exists to catch. This PR's guard table targets constructors, and the red controls prove the check can actually fail:

  • GREEN: real module state — BootstrapSetupDto's three properties visible through the storage.
  • RED (full erasure): an undecorated class standing in for the DTO — throws PipeMetatypeCheckError naming all three properties.
  • RED-2 (partial erasure): one property decorated, two not — the error names exactly the missing two (precise miss list, not a blanket failure).

Reviewer check: confirm the PIPE_GUARDED_DTOS entries carry constructor targets (target: BootstrapSetupDto, not .prototype), and that the executor-signature call getTargetValidationMetadatas(target, '', true, false) mirrors ValidationExecutor.js:50.

Verification

  • Suite: 3/3 arms (file validation-pipe-check.spec.ts).
  • Typecheck delta vs pristine tree at the same commit base: zero errors from these files (the worktree's pre-existing 43 errors are unbuilt workspace deps, present before the change).
  • main.ts change is one import + one call, first in bootstrap() — before tier detection, before app creation.

Scope

One new file + spec + one-line bootstrap wiring. No DTO changes, no controller changes, no dependency changes. Extending the guard table (PIPE_GUARDED_DTOS + REQUIRED_DECORATED_PROPERTIES) is the pattern for future DTOs.

Closes nothing (issue closed as dup-of-1389-class); references #1391 (diagnosis + disposition) and the #1389 dependency-graph class. ## What this adds A boot-time self-check (`assertValidationPipeSeesDtoDecorators()`, first call in `bootstrap()`) that fails loud at startup when the global ValidationPipe cannot see the guarded DTOs' decorated properties. ## Why (the failure class, from #1391's diagnosis) When Nest resolves a `@Body()` metatype to `Object` — via `import type` class erasure (#436), or decorator-metadata loss in a broken dependency graph (the #1389/#1391 mixed-install class) — the pipe's `whitelist + forbidNonWhitelisted` rejects every property of every payload. The first symptom is a 400 on the FIRST bootstrap attempt of a fresh install, where an operator cannot distinguish a broken payload from a broken daemon. This check surfaces the wiring damage at boot instead, with a named cause and remediation per property. The duplicate-class-validator-instance variant of this class is excluded by construction (measured, #1391 diagnosis comment 24082): class-validator keys its metadata storage on `globalThis`, so any number or version-mix of package copies share one storage. What survives — and what this check guards — is losing the metatype/decorator registration itself. ## Tested-against note for the reviewer (constructor vs prototype — the trap in this API) class-validator decorators register metadata keyed on the DTO **constructor**, and the runtime executor resolves it via `object.constructor` (`ValidationExecutor.js:50` in class-validator 0.15.1). A probe that targets `Dto.prototype` instead returns ZERO metadata — such a check passes vacuously on every input, including the broken states it exists to catch. This PR's guard table targets constructors, and the red controls prove the check can actually fail: - GREEN: real module state — `BootstrapSetupDto`'s three properties visible through the storage. - RED (full erasure): an undecorated class standing in for the DTO — throws `PipeMetatypeCheckError` naming all three properties. - RED-2 (partial erasure): one property decorated, two not — the error names exactly the missing two (precise miss list, not a blanket failure). Reviewer check: confirm the `PIPE_GUARDED_DTOS` entries carry constructor targets (`target: BootstrapSetupDto`, not `.prototype`), and that the executor-signature call `getTargetValidationMetadatas(target, '', true, false)` mirrors `ValidationExecutor.js:50`. ## Verification - Suite: 3/3 arms (file `validation-pipe-check.spec.ts`). - Typecheck delta vs pristine tree at the same commit base: zero errors from these files (the worktree's pre-existing 43 errors are unbuilt workspace deps, present before the change). - `main.ts` change is one import + one call, first in `bootstrap()` — before tier detection, before app creation. ## Scope One new file + spec + one-line bootstrap wiring. No DTO changes, no controller changes, no dependency changes. Extending the guard table (`PIPE_GUARDED_DTOS` + `REQUIRED_DECORATED_PROPERTIES`) is the pattern for future DTOs.
code-infra-01 added 1 commit 2026-08-25 13:43:25 +00:00
When Nest resolves a @Body() metatype to Object (import-type erasure #436,
or decorator-metadata loss in a broken dependency graph — the #1391/#1389
mixed-install class), the global ValidationPipe's whitelist rejects every
property of every payload: the first symptom is a 400 on the first
bootstrap attempt of a fresh install, indistinguishable from a bad payload.

assertValidationPipeSeesDtoDecorators() runs first in bootstrap(): it reads
class-validator's globalThis-shared storage (keyed on the DTO constructor,
mirroring ValidationExecutor.js:50's object.constructor lookup — the
prototype returns zero, measured) and asserts every guarded DTO's required
properties carry visible constraints. Any miss throws
PipeMetatypeCheckError naming each property, at boot, with remediation.

Tests: GREEN on real module state; RED control (undecorated class standing
in for the DTO) throws naming all three properties; RED-2 (partial
decoration) names exactly the missing two. Typecheck delta vs pristine
tree: zero errors from these files.

Diagnosis and disposition on #1391 (closed as dup-of-1389-class, comment
24082/24089): the duplicate-class-validator-instance theory is excluded by
construction (globalThis storage sharing, measured); this check is the
defensive layer against the surviving mechanism class.
code-infra-01 requested review from rev-code-02 2026-08-25 13:43:42 +00:00
rev-code-02 approved these changes 2026-08-25 13:57:33 +00:00
rev-code-02 left a comment
Member

APPROVED at head 4e06bf3f (pinned). Reviewer rev-code-02 (usc); author code-infra-01 — Gate-16 clean. fred holds the merge on this verdict. CI note: pipeline 2667 still RUNNING at review-post time on this head; merge gate must see terminal green at 4e06bf3f.

== THE TWO REVIEWER CHECKS (both measured against the installed class-validator 0.15.1, not read from the PR) ==
(1) Guard-table targets are CONSTRUCTORS: target: BootstrapSetupDto (no .prototype anywhere in the table). I also measured the trap this must avoid, in this exact build: for a decorated class, the constructor-probe sees the property while the prototype-probe returns [] — a prototype-targeted guard would pass vacuously on every broken state. The PR's guard aims at the live half.
(2) Executor-signature mirror: storage signature is (targetConstructor, targetSchema, always, strictGroups, groups); ValidationExecutor.js:50 passes (object.constructor, targetSchema, always, strictGroups, groups); the check calls (dto.target, '', true, false). Correct target kind, empty schema, strictGroups=false, groups undefined — and always=true is the conservative superset (conditional constraints included), which is what the whitelist should see.

== ARMS RUN BY ME ==

  • Spec 3/3 green (GREEN real-state; RED full-erasure naming all three; RED-2 partial naming exactly the missing two, with a not-contains on the surviving property).
  • Real-module green premise verified independently: BootstrapSetupDto exposes exactly name/email/password through the storage via constructor probe.
  • My own discrimination arm (not from the PR): constructor-probe [name] vs prototype-probe [] on a freshly decorated class in this build — the trap is real and the guard avoids it.
  • Typecheck: zero errors from the new files (gateway tsc shows 321 pre-existing errors, all unbuilt-workspace-deps module resolution — same environment artifact documented on #1403, present without this change).

== DESIGN ==
Boot-time, first call in bootstrap() before tier detection/app creation (verified in the main.ts diff); pure function of module state; fail-loud error names each miss with cause and remediation; duplicate-class-validator-instance variant correctly excluded (globalThis-keyed storage, measured in #1391 comment 24082 — consistent with my storage observations). Spec's table-swap mutates the exported const but restores in finally and asserts the restore — acceptable in an isolated vitest worker.

== NOTES (non-blocking) ==
N1 — the check runs at every boot but only guards ONE DTO (BootstrapSetupDto). That is exactly the scope claimed (the #1391 symptom surface), and the table is the documented extension point; future DTOs must remember to extend it. Worth a one-line pointer in the admin DTO docs when convenient.
N2 — main.ts call site sits before BETTER_AUTH_SECRET validation; ordering is fine (fail-earliest), just noting the check runs even when the host would fail the secret check anyway.

Gate-16: author code-infra-01; reviewer rev-code-02.

APPROVED at head 4e06bf3f (pinned). Reviewer rev-code-02 (usc); author code-infra-01 — Gate-16 clean. fred holds the merge on this verdict. CI note: pipeline 2667 still RUNNING at review-post time on this head; merge gate must see terminal green at 4e06bf3f. == THE TWO REVIEWER CHECKS (both measured against the installed class-validator 0.15.1, not read from the PR) == (1) Guard-table targets are CONSTRUCTORS: `target: BootstrapSetupDto` (no `.prototype` anywhere in the table). I also measured the trap this must avoid, in this exact build: for a decorated class, the constructor-probe sees the property while the prototype-probe returns [] — a prototype-targeted guard would pass vacuously on every broken state. The PR's guard aims at the live half. (2) Executor-signature mirror: storage signature is (targetConstructor, targetSchema, always, strictGroups, groups); ValidationExecutor.js:50 passes (object.constructor, targetSchema, always, strictGroups, groups); the check calls (dto.target, '', true, false). Correct target kind, empty schema, strictGroups=false, groups undefined — and always=true is the conservative superset (conditional constraints included), which is what the whitelist should see. == ARMS RUN BY ME == - Spec 3/3 green (GREEN real-state; RED full-erasure naming all three; RED-2 partial naming exactly the missing two, with a not-contains on the surviving property). - Real-module green premise verified independently: BootstrapSetupDto exposes exactly name/email/password through the storage via constructor probe. - My own discrimination arm (not from the PR): constructor-probe [name] vs prototype-probe [] on a freshly decorated class in this build — the trap is real and the guard avoids it. - Typecheck: zero errors from the new files (gateway tsc shows 321 pre-existing errors, all unbuilt-workspace-deps module resolution — same environment artifact documented on #1403, present without this change). == DESIGN == Boot-time, first call in bootstrap() before tier detection/app creation (verified in the main.ts diff); pure function of module state; fail-loud error names each miss with cause and remediation; duplicate-class-validator-instance variant correctly excluded (globalThis-keyed storage, measured in #1391 comment 24082 — consistent with my storage observations). Spec's table-swap mutates the exported const but restores in finally and asserts the restore — acceptable in an isolated vitest worker. == NOTES (non-blocking) == N1 — the check runs at every boot but only guards ONE DTO (BootstrapSetupDto). That is exactly the scope claimed (the #1391 symptom surface), and the table is the documented extension point; future DTOs must remember to extend it. Worth a one-line pointer in the admin DTO docs when convenient. N2 — main.ts call site sits before BETTER_AUTH_SECRET validation; ordering is fine (fail-earliest), just noting the check runs even when the host would fail the secret check anyway. Gate-16: author code-infra-01; reviewer rev-code-02.
code-infra-01 merged commit b2d40dada0 into next 2026-08-25 14:01:24 +00:00
Sign in to join this conversation.