fix(#1391): boot-time ValidationPipe metatype self-check — fail loud at startup
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
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.
This commit is contained in:
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
||||
// every request body into a 400 at first use; this surfaces it at boot.
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
||||
*
|
||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
||||
* its dependency-graph cousins. Red/green arms:
|
||||
*
|
||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
||||
* decorated and visible through the globalThis-shared storage.
|
||||
* RED a control class with NO decorators (the erasure shape): the
|
||||
* check throws PipeMetatypeCheckError naming every property.
|
||||
* RED-2 a control where one property is decorated and two are not: the
|
||||
* error names exactly the missing two — the miss list is precise,
|
||||
* not a blanket failure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IsString } from 'class-validator';
|
||||
import {
|
||||
assertValidationPipeSeesDtoDecorators,
|
||||
PipeMetatypeCheckError,
|
||||
} from './validation-pipe-check.js';
|
||||
|
||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
||||
// Simulate metatype erasure: an undecorated class standing where a
|
||||
// decorated DTO should be. Redefine the guard table for the test by
|
||||
// importing the module and pointing its table at the eroded class —
|
||||
// the check reads the table at call time, so a fresh module instance
|
||||
// with a swapped table reproduces the boot failure deterministically.
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class ErodedDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
expect(original).toBeDefined();
|
||||
// Swap in the eroded target (same declared properties, zero decorators).
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'ErodedDto',
|
||||
target: ErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('ErodedDto.name');
|
||||
expect(message).toContain('ErodedDto.email');
|
||||
expect(message).toContain('ErodedDto.password');
|
||||
}
|
||||
} finally {
|
||||
// Restore real module state for any later test in this file.
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
// And confirm the restore is real.
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class HalfErodedDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'HalfErodedDto',
|
||||
target: HalfErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('HalfErodedDto.email');
|
||||
expect(message).toContain('HalfErodedDto.password');
|
||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
||||
}
|
||||
} finally {
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
||||
*
|
||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
||||
* `import type` class erasure (#436), or a dependency graph where the
|
||||
* controller's decorators and the application's route enhancers disagree
|
||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
||||
* payload from a broken daemon.
|
||||
*
|
||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
||||
* decorated properties, the gateway refuses to start with a named cause.
|
||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
||||
* host, at the moment the damage exists rather than at first use.
|
||||
*
|
||||
* Storage sharing note: class-validator keys its metadata storage on
|
||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
||||
* what this asserts against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
||||
* decorators register metadata keyed on the constructor, and its executor
|
||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
||||
*/
|
||||
export const PIPE_GUARDED_DTOS: Array<{
|
||||
name: string;
|
||||
target: abstract new (...args: never[]) => unknown;
|
||||
properties: string[];
|
||||
}> = [
|
||||
{
|
||||
name: 'BootstrapSetupDto',
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
constructor(missing: string[]) {
|
||||
super(
|
||||
'ValidationPipe metatype check failed: ' +
|
||||
missing.join('; ') +
|
||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
||||
'every request body would be rejected as non-whitelisted. ' +
|
||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
||||
'dependency graph (see issues #436, #1391).',
|
||||
);
|
||||
this.name = 'PipeMetatypeCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
||||
*/
|
||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
||||
const storage = getMetadataStorage();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const dto of PIPE_GUARDED_DTOS) {
|
||||
// class-validator records constraints keyed on the DTO's constructor
|
||||
// (decorators run on the class), and its executor resolves them via
|
||||
// object.constructor. A property with no recorded metadata is invisible
|
||||
// to the whitelist — whatever the cause — and fails here.
|
||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
||||
// constraint regardless of grouping, which is what the whitelist sees.
|
||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
||||
|
||||
for (const property of dto.properties) {
|
||||
if (!decorated.has(property)) {
|
||||
missing.push(
|
||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new PipeMetatypeCheckError(missing);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user