Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)

Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
This commit is contained in:
Maurice
2026-05-25 14:29:30 +02:00
committed by GitHub
parent e27be5c965
commit 0b218d53b2
43 changed files with 3790 additions and 176 deletions
@@ -0,0 +1,42 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Response } from 'express';
/**
* Normalises every Nest exception to TREK's legacy error envelope so migrated
* routes are byte-identical for the client:
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
* This replaces Nest's default { statusCode, message, error } body, which the
* TREK client does not expect.
*/
@Catch()
export class TrekExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
res.status(status).json(body);
return;
}
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
const message =
status < 500
? Array.isArray(raw)
? raw.join(', ')
: String(raw ?? 'Error')
: 'Internal server error';
res.status(status).json({ error: message });
return;
}
// Unknown/unhandled error — mirror the legacy 500 behaviour.
console.error('Unhandled error:', exception);
res.status(500).json({ error: 'Internal server error' });
}
}
@@ -0,0 +1,26 @@
import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
import type { ZodType } from 'zod';
/**
* Validates an incoming @Body()/@Query() against a Zod schema (from @trek/shared)
* and returns the parsed, typed value. On failure it throws TREK's error envelope
* `{ error: string }` with status 400 — the same shape the legacy routes produce,
* so the client's error handling is unaffected.
*
* Usage: `@Body(new ZodValidationPipe(someSchema)) dto: Dto`.
*/
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodType) {}
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
const result = this.schema.safeParse(value);
if (!result.success) {
const message = result.error.issues
.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
.join('; ');
throw new HttpException({ error: message }, 400);
}
return result.data;
}
}