diff --git a/apps/gateway/src/main.ts b/apps/gateway/src/main.ts index d1eacf5f..cb066d18 100644 --- a/apps/gateway/src/main.ts +++ b/apps/gateway/src/main.ts @@ -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 { 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'); } diff --git a/apps/gateway/src/validation-pipe-check.spec.ts b/apps/gateway/src/validation-pipe-check.spec.ts new file mode 100644 index 00000000..3d6ca7d1 --- /dev/null +++ b/apps/gateway/src/validation-pipe-check.spec.ts @@ -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); + } + }); +}); diff --git a/apps/gateway/src/validation-pipe-check.ts b/apps/gateway/src/validation-pipe-check.ts new file mode 100644 index 00000000..61af929a --- /dev/null +++ b/apps/gateway/src/validation-pipe-check.ts @@ -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); + } +}