fix(#1391): boot-time ValidationPipe metatype self-check — fail loud at startup (#1419)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
This commit was merged in pull request #1419.
This commit is contained in:
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
|||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
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']) {
|
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
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