mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
Generated
+1278
-76
File diff suppressed because it is too large
Load Diff
+20
-4
@@ -3,8 +3,11 @@
|
||||
"version": "3.0.22",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "node scripts/build.mjs",
|
||||
"start:prod": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
@@ -14,6 +17,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -30,22 +36,30 @@
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "^4.12.16",
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"picomatch": "^4.0.4",
|
||||
"ip-address": "^10.1.1"
|
||||
"ip-address": "^10.1.1",
|
||||
"multer": "^2.1.1",
|
||||
"ws": "^8.21.0",
|
||||
"qs": "^6.15.2",
|
||||
"file-type": "^21.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -66,7 +80,9 @@
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"nodemon": "^3.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
// tsc emits JS even with type errors (noEmitOnError:false), but still exits
|
||||
// non-zero to report them. We must run tsc-alias regardless, so run tsc in a
|
||||
// try/catch and always proceed to the path-rewrite step.
|
||||
// Type correctness is enforced separately via `npm run typecheck`.
|
||||
try {
|
||||
execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).');
|
||||
}
|
||||
|
||||
execSync('tsc-alias -p tsconfig.build.json', { stdio: 'inherit' });
|
||||
console.log('[build] dist ready (path aliases rewritten).');
|
||||
@@ -0,0 +1,22 @@
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
|
||||
// Dev runtime for the co-hosted NestJS + legacy Express server.
|
||||
// NestJS DI needs decorator metadata, which the old tsx/esbuild runtime does not
|
||||
// emit — so dev runs the tsc build with watchers (same toolchain as prod `dist`).
|
||||
// Initial build first so `node --watch dist/index.js` has something to start.
|
||||
console.log('[dev] initial build...');
|
||||
execSync('node scripts/build.mjs', { stdio: 'inherit' });
|
||||
|
||||
const watchers = [
|
||||
['npx', ['tsc', '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput']],
|
||||
['npx', ['tsc-alias', '-w', '-p', 'tsconfig.build.json']],
|
||||
['node', ['--watch', 'dist/index.js']],
|
||||
];
|
||||
|
||||
const children = watchers.map(([cmd, args]) =>
|
||||
spawn(cmd, args, { stdio: 'inherit', shell: true }),
|
||||
);
|
||||
|
||||
const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
+56
-5
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 + '/'));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
|
||||
function context(req: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
|
||||
it('rejects with the legacy 401 { error, code } when no token is present', () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
guard.canActivate(context({ headers: {}, cookies: {} }));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HttpException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(401);
|
||||
expect((thrown as HttpException).getResponse()).toEqual({
|
||||
error: 'Access token required',
|
||||
code: 'AUTH_REQUIRED',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
|
||||
|
||||
function mockHost() {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
|
||||
const host = { switchToHttp: () => ({ getResponse: () => res }) } as never;
|
||||
return { res, host };
|
||||
}
|
||||
|
||||
describe('TrekExceptionFilter', () => {
|
||||
const filter = new TrekExceptionFilter();
|
||||
|
||||
it('passes through { error, code } bodies (auth guards) unchanged', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401), host);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('normalises a string HttpException to { error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException('Bad thing', 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Bad thing' });
|
||||
});
|
||||
|
||||
it('maps unknown errors to 500 { error: Internal server error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new Error('boom'), host);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||
import { HealthService } from '../../../src/nest/health/health.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
describe('Nest dependency injection (vitest + swc)', () => {
|
||||
it('injects HealthService + DatabaseService into HealthController by type', async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
{ provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const controller = moduleRef.get(HealthController);
|
||||
expect(controller.getHealth()).toEqual({
|
||||
ok: true,
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
userCount: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler';
|
||||
|
||||
describe('strangler toggle', () => {
|
||||
const original = process.env.NEST_PREFIXES;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.NEST_PREFIXES;
|
||||
else process.env.NEST_PREFIXES = original;
|
||||
});
|
||||
|
||||
it('defaults to /api/_nest when NEST_PREFIXES is unset', () => {
|
||||
delete process.env.NEST_PREFIXES;
|
||||
expect(getNestPrefixes()).toEqual(['/api/_nest']);
|
||||
});
|
||||
|
||||
it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
|
||||
process.env.NEST_PREFIXES = '/api/weather, /api/airports';
|
||||
expect(getNestPrefixes()).toEqual(['/api/weather', '/api/airports']);
|
||||
});
|
||||
|
||||
it('treats an empty NEST_PREFIXES as "all routes on legacy"', () => {
|
||||
process.env.NEST_PREFIXES = '';
|
||||
expect(getNestPrefixes()).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches exact prefixes and subpaths but not lookalikes', () => {
|
||||
const match = makeNestPathMatcher(['/api/_nest']);
|
||||
expect(match('/api/_nest')).toBe(true);
|
||||
expect(match('/api/_nest/health')).toBe(true);
|
||||
expect(match('/api/_nestxyz')).toBe(false);
|
||||
expect(match('/api/health')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { AppModule } from '../../../src/nest/app.module';
|
||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
|
||||
function ctx(user: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
|
||||
}
|
||||
|
||||
describe('AppModule wiring', () => {
|
||||
it('compiles with the global filter + DB provider and resolves the controller', async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(DatabaseService)
|
||||
.useValue({ get: () => ({ n: 0 }) })
|
||||
.compile();
|
||||
expect(moduleRef.get(HealthController)).toBeInstanceOf(HealthController);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
const guard = new AdminGuard();
|
||||
it('allows admins', () => {
|
||||
expect(guard.canActivate(ctx({ role: 'admin' }))).toBe(true);
|
||||
});
|
||||
it('blocks non-admins and anonymous with 403 { error }', () => {
|
||||
expect(() => guard.canActivate(ctx({ role: 'user' }))).toThrow(HttpException);
|
||||
expect(() => guard.canActivate(ctx(undefined))).toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseService (shared connection)', () => {
|
||||
it('runs real queries against the existing SQLite connection', () => {
|
||||
const svc = new DatabaseService();
|
||||
expect(svc.get('SELECT 1 AS one')).toEqual({ one: 1 });
|
||||
expect(svc.all('SELECT 1 AS one')).toEqual([{ one: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
describe('ZodValidationPipe', () => {
|
||||
const pipe = new ZodValidationPipe(z.object({ name: z.string().min(1) }));
|
||||
const meta = {} as never;
|
||||
|
||||
it('returns the parsed value for valid input', () => {
|
||||
expect(pipe.transform({ name: 'x' }, meta)).toEqual({ name: 'x' });
|
||||
});
|
||||
|
||||
it('throws TREK { error } envelope with status 400 on invalid input', () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
pipe.transform({ name: '' }, meta);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HttpException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(400);
|
||||
expect((thrown as HttpException).getResponse()).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Smoke test: proves the server toolchain (tsx / vitest) resolves @trek/shared.
|
||||
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
|
||||
|
||||
describe('@trek/shared resolves in the server toolchain', () => {
|
||||
it('imports and uses a shared schema', () => {
|
||||
expect(idParamSchema.parse('7')).toBe(7);
|
||||
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"noEmitOnError": false,
|
||||
"outDir": "./dist",
|
||||
"sourceMap": false,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "tests", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
+15
-10
@@ -3,6 +3,9 @@
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"baseUrl": ".",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false,
|
||||
@@ -19,16 +22,18 @@
|
||||
// (e.g. "./*": "./dist/esm/*") which TypeScript cannot resolve — it only strips .js suffixes.
|
||||
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
|
||||
"paths": {
|
||||
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
|
||||
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"],
|
||||
"@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"],
|
||||
"@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"],
|
||||
"@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"],
|
||||
"@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"],
|
||||
"@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"],
|
||||
"@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"],
|
||||
"@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"],
|
||||
"@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"]
|
||||
"@trek/shared": ["../shared/src/index.ts"],
|
||||
"@trek/shared/*": ["../shared/src/*"],
|
||||
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js"],
|
||||
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js"],
|
||||
"@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router.js"],
|
||||
"@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize.js"],
|
||||
"@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register.js"],
|
||||
"@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider.js"],
|
||||
"@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients.js"],
|
||||
"@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors.js"],
|
||||
"@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types.js"],
|
||||
"@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth.js"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import swc from 'unplugin-swc';
|
||||
|
||||
export default defineConfig({
|
||||
// SWC transform so NestJS decorator metadata is emitted in tests
|
||||
// (vitest's default esbuild does not emit it -> type-based DI would break).
|
||||
plugins: [
|
||||
swc.vite({
|
||||
jsc: {
|
||||
parser: { syntax: 'typescript', decorators: true },
|
||||
transform: { legacyDecorator: true, decoratorMetadata: true },
|
||||
keepClassNames: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
root: '.',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
@@ -16,10 +28,18 @@ export default defineConfig({
|
||||
reporter: ['lcov', 'text'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
// Coverage gate scoped to the new NestJS code only — the legacy codebase
|
||||
// is intentionally ungated. Ratchet these up as more modules are migrated.
|
||||
thresholds: {
|
||||
'src/nest/**/*.ts': { statements: 60, branches: 55, functions: 55, lines: 60 },
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// @trek/shared — Zod contract package (tests resolve it to TS source,
|
||||
// mirroring the tsconfig `paths` the tsx runtime uses).
|
||||
'@trek/shared': new URL('../shared/src/index.ts', import.meta.url).pathname,
|
||||
'@modelcontextprotocol/sdk/server/mcp': new URL(
|
||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js',
|
||||
import.meta.url
|
||||
|
||||
Reference in New Issue
Block a user