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
+56 -5
View File
@@ -1,7 +1,16 @@
import 'reflect-metadata';
import 'dotenv/config';
import path from 'node:path';
import fs from 'node:fs';
import http from 'node:http';
import express from 'express';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { createApp } from './app';
import { AppModule } from './nest/app.module';
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
// Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
@@ -16,7 +25,10 @@ const tmpDir = path.join(__dirname, '../data/tmp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
const app = createApp();
// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
// everything else falls through to this app via a fallback middleware.
const legacyApp = createApp();
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
@@ -49,6 +61,11 @@ const onListen = () => {
'──────────────────────────────────────',
];
banner.forEach(l => console.log(l));
sLogInfo(
NEST_PREFIXES.length
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
: 'NestJS prefixes: none — all routes served by the legacy Express app',
);
if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
@@ -84,9 +101,42 @@ const onListen = () => {
});
};
const server = HOST
? app.listen(PORT, HOST, onListen)
: app.listen(PORT, onListen);
let server: http.Server;
let nestApp: INestApplication;
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
const NEST_PREFIXES = getNestPrefixes();
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
async function bootstrap(): Promise<void> {
// Nest runs on its own Express instance (bodyParser off so request bodies reach
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
// Nest body parsing is safe here: the dispatcher only forwards migrated
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
// is reached separately and never passes through Nest's parser.
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
nestApp.use(cookieParser());
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
await nestApp.init();
const nestInstance = nestApp.getHttpAdapter().getInstance();
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
// only applies within migrated prefixes.
const top = express();
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
top.use(legacyApp);
server = http.createServer(top);
if (HOST) server.listen(PORT, HOST, onListen);
else server.listen(PORT, onListen);
}
bootstrap().catch((err) => {
console.error('Fatal: failed to bootstrap server', err);
process.exit(1);
});
// Graceful shutdown
function shutdown(signal: string): void {
@@ -95,6 +145,7 @@ function shutdown(signal: string): void {
sLogInfo(`${signal} received — shutting down gracefully...`);
scheduler.stop();
closeMcpSessions();
void nestApp?.close();
server.close(() => {
sLogInfo('HTTP server closed');
const { closeDb } = require('./db/database');
@@ -111,4 +162,4 @@ function shutdown(signal: string): void {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
export default app;
export default legacyApp;
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { DatabaseModule } from './database/database.module';
import { HealthController } from './health/health.controller';
import { HealthService } from './health/health.service';
import { TrekExceptionFilter } from './common/trek-exception.filter';
/**
* Root NestJS module for the incremental migration. Domain modules
* (weather, notifications, ...) get registered here as they are migrated.
*/
@Module({
imports: [DatabaseModule],
controllers: [HealthController],
providers: [
HealthService,
// Global error-envelope normaliser (DI-registered so it also catches
// framework-level exceptions like the not-found handler).
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
],
})
export class AppModule {}
+19
View File
@@ -0,0 +1,19 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
/**
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
* Use together with JwtAuthGuard (which populates req.user):
* `@UseGuards(JwtAuthGuard, AdminGuard)`.
*/
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request & { user?: User }>();
if (!req.user || req.user.role !== 'admin') {
throw new HttpException({ error: 'Admin access required' }, 403);
}
return true;
}
}
@@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { User } from '../../types';
/**
* Resolves the authenticated user attached by JwtAuthGuard.
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): User | undefined => {
return context.switchToHttp().getRequest().user;
},
);
+28
View File
@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
/**
* Validates TREK's existing JWT session — the same httpOnly `trek_session`
* cookie (or `Authorization: Bearer`) the legacy app uses. Reuses the canonical
* `verifyJwtAndLoadUser` so the secret, the password_version invalidation gate
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
*
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = verifyJwtAndLoadUser(token);
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
(req as Request & { user?: unknown }).user = user;
return true;
}
}
@@ -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;
}
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
/**
* Global so every migrated module can inject DatabaseService without re-importing.
* Wraps the existing better-sqlite3 singleton (no new connection).
*/
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import type Database from 'better-sqlite3';
import { db } from '../../db/database';
/**
* Injectable wrapper around TREK's existing better-sqlite3 connection.
*
* `db` is a Proxy onto the singleton connection the legacy app already uses
* (WAL enabled), so Nest modules share the exact same connection — no second
* connection, no split state, single writer preserved.
*/
@Injectable()
export class DatabaseService {
/** The shared better-sqlite3 connection (same singleton the legacy app uses). */
get connection(): Database.Database {
return db;
}
prepare(sql: string): Database.Statement {
return db.prepare(sql);
}
get<T = unknown>(sql: string, ...params: unknown[]): T | undefined {
return db.prepare(sql).get(...params) as T | undefined;
}
all<T = unknown>(sql: string, ...params: unknown[]): T[] {
return db.prepare(sql).all(...params) as T[];
}
run(sql: string, ...params: unknown[]): Database.RunResult {
return db.prepare(sql).run(...params);
}
/** Run `fn` inside a synchronous better-sqlite3 transaction. */
transaction<T>(fn: (conn: Database.Database) => T): T {
return db.transaction(() => fn(db))();
}
}
@@ -0,0 +1,41 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import type { User } from '../../types';
import { HealthService } from './health.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
// Local demo schema (real domains import their schema from @trek/shared).
const echoSchema = z.object({ name: z.string().min(1) });
/**
* Foundation smoke endpoints for the co-hosted NestJS app.
* Proves: boot, routing, type-based DI, the shared SQLite connection, the
* JWT-cookie auth guard, and the Zod validation pipe + error-envelope parity.
*
* Lives under /api/_nest/* so it never collides with the legacy Express API.
*/
@Controller('api/_nest')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get('health')
getHealth() {
return { ok: true, ...this.healthService.info() };
}
/** Guarded: returns the authenticated user, proving JwtAuthGuard + @CurrentUser. */
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: User) {
return user;
}
/** Validated: proves the Zod pipe (400 + { error } on failure) and body parsing. */
@Post('echo')
@UseGuards(JwtAuthGuard)
echo(@Body(new ZodValidationPipe(echoSchema)) body: z.infer<typeof echoSchema>) {
return { youSent: body };
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
/**
* Smoke service proving NestJS DI works under the chosen runtime AND that the
* injected DatabaseService talks to TREK's existing SQLite connection.
*/
@Injectable()
export class HealthService {
constructor(private readonly database: DatabaseService) {}
info() {
const row = this.database.get<{ n: number }>('SELECT COUNT(*) AS n FROM users');
return {
runtime: 'nestjs',
diInjected: true,
// Proof the shared connection works: real row count from the existing DB.
userCount: row?.n ?? null,
};
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Strangler toggle for the incremental NestJS migration.
*
* `getNestPrefixes()` returns the request path prefixes that NestJS handles;
* every other path falls through to the legacy Express app. The default is the
* set of prefixes whose Nest modules exist. Operators can override it at runtime
* via the `NEST_PREFIXES` env var (comma-separated) for instant Nest<->Express
* rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
* everything back to the legacy app.
*/
const DEFAULT_NEST_PREFIXES = ['/api/_nest'];
export function getNestPrefixes(): string[] {
const raw = process.env.NEST_PREFIXES;
if (raw !== undefined) {
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
return DEFAULT_NEST_PREFIXES;
}
/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */
export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean {
return (path) => prefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/'));
}