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:
@@ -8,10 +8,33 @@ on:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
shared-contracts:
|
||||
name: Shared Contracts (Zod)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: shared/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd shared && npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: cd shared && npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: cd shared && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,6 +51,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd server && npm ci
|
||||
|
||||
- name: Build (tsc + tsc-alias -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
- name: Typecheck (informational)
|
||||
# Legacy code still has pre-existing type errors; this surfaces them
|
||||
# without blocking the migration. Ratchet to blocking once cleaned up.
|
||||
continue-on-error: true
|
||||
run: cd server && npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
server/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
|
||||
|
||||
+15
-5
@@ -6,7 +6,18 @@ RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production server
|
||||
# Stage 2: Build server (TypeScript -> dist via tsc + tsc-alias)
|
||||
# --ignore-scripts: tsc only transpiles, so we skip native builds (better-sqlite3)
|
||||
# here; the production stage builds the native module.
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY server/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production server (runs the compiled JS — NestJS DI needs the
|
||||
# decorator metadata that tsc emits; the old tsx runtime did not).
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
@@ -19,12 +30,11 @@ RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
apk del python3 make g++ && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
COPY server/ ./
|
||||
COPY --from=server-builder /app/dist ./dist
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
RUN rm -f package-lock.json && \
|
||||
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
|
||||
@@ -39,4 +49,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node dist/index.js"]
|
||||
|
||||
Generated
+10
-75
@@ -28,6 +28,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2153,9 +2154,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2173,9 +2171,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2193,9 +2188,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2211,9 +2203,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2231,9 +2220,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2251,9 +2237,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2271,9 +2254,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2297,9 +2277,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2323,9 +2300,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2347,9 +2321,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2373,9 +2344,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2399,9 +2367,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3160,9 +3125,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3177,9 +3139,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3194,9 +3153,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3211,9 +3167,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3228,9 +3181,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3245,9 +3195,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3262,9 +3209,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3279,9 +3223,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3296,9 +3237,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3313,9 +3251,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3330,9 +3265,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3345,9 +3277,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3362,9 +3291,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11048,6 +10974,15 @@
|
||||
"version": "3.2.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Smoke test: proves the client toolchain (vite / vitest) resolves @trek/shared.
|
||||
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
|
||||
|
||||
describe('@trek/shared resolves in the client toolchain', () => {
|
||||
it('imports and uses a shared schema', () => {
|
||||
expect(idParamSchema.parse('7')).toBe(7);
|
||||
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@trek/shared": ["../shared/src/index.ts"],
|
||||
"@trek/shared/*": ["../shared/src/*"]
|
||||
},
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -88,6 +89,15 @@ export default defineConfig({
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
// @trek/shared — Zod contract package (dev: resolved to TS source).
|
||||
'@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
|
||||
},
|
||||
// @trek/shared imports zod from its own source; it lives outside this root,
|
||||
// so pin zod to the client's copy (one instance, resolvable from anywhere).
|
||||
dedupe: ['zod'],
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
modulePreload: { polyfill: true },
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// @trek/shared — Zod contract package (tests resolve it to TS source,
|
||||
// mirroring the alias in vite.config.js used by the dev server / build).
|
||||
'@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
|
||||
},
|
||||
// Mirror vite.config.js: keep a single zod instance resolvable from the
|
||||
// shared source, which lives outside this project root.
|
||||
dedupe: ['zod'],
|
||||
},
|
||||
test: {
|
||||
root: '.',
|
||||
globals: true,
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# @trek/shared
|
||||
|
||||
Single source of truth for TREK's API contracts, expressed as [Zod](https://zod.dev) schemas
|
||||
and consumed by **both** the server (request validation + inferred DTO types) and the client
|
||||
(typed requests/responses).
|
||||
|
||||
This package is part of the incremental NestJS + React 19 migration
|
||||
(see the "Brownfield Rewrite" board). It is intentionally **dormant** until modules start
|
||||
importing it — adding it changes nothing for users.
|
||||
|
||||
## Rules
|
||||
|
||||
- **One folder per domain**: `src/<domain>/<domain>.schema.ts` (+ `.spec.ts`).
|
||||
- Domain-agnostic building blocks live in `src/common/`.
|
||||
- A route is only considered **migrated** once its contract lives here.
|
||||
- Schemas are the source of truth; server DTOs and client types are *inferred* from them
|
||||
(`z.infer<typeof schema>`), never hand-duplicated.
|
||||
|
||||
## Consumption (dev)
|
||||
|
||||
Both apps resolve `@trek/shared` to this package's TypeScript source:
|
||||
|
||||
- **Server** (`tsx`): via `paths` in `server/tsconfig.json`.
|
||||
- **Client** (`vite`): via `resolve.alias` in `client/vite.config.ts` (+ `paths` for the type-checker).
|
||||
|
||||
> Production packaging (Docker / workspace wiring) is introduced in card **F2**, when the
|
||||
> server first depends on this package at runtime. Until then prod builds are untouched.
|
||||
|
||||
## Not yet here
|
||||
|
||||
The canonical **error envelope** is finalised in card **F5** (it must match TREK's current
|
||||
Express error responses byte-for-byte), so it is deliberately not invented in F1.
|
||||
Generated
+1619
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Generic pagination query helper. Individual endpoints opt in by extending
|
||||
* this; it is NOT applied globally (many TREK list endpoints return full sets).
|
||||
* Defaults are conservative and only used where a route already paginates.
|
||||
*/
|
||||
export const paginationQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
perPage: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { idSchema, idParamSchema, nonEmptyString, isoDateTime } from './primitives.schema';
|
||||
import { paginationQuerySchema } from './pagination.schema';
|
||||
|
||||
describe('@trek/shared primitives', () => {
|
||||
it('idSchema accepts positive integers, rejects others', () => {
|
||||
expect(idSchema.parse(1)).toBe(1);
|
||||
expect(idSchema.safeParse(0).success).toBe(false);
|
||||
expect(idSchema.safeParse(-3).success).toBe(false);
|
||||
expect(idSchema.safeParse(1.5).success).toBe(false);
|
||||
});
|
||||
|
||||
it('idParamSchema coerces string params to a positive int', () => {
|
||||
expect(idParamSchema.parse('42')).toBe(42);
|
||||
expect(idParamSchema.safeParse('abc').success).toBe(false);
|
||||
});
|
||||
|
||||
it('nonEmptyString trims and rejects empty', () => {
|
||||
expect(nonEmptyString.parse(' hi ')).toBe('hi');
|
||||
expect(nonEmptyString.safeParse(' ').success).toBe(false);
|
||||
});
|
||||
|
||||
it('isoDateTime accepts an ISO timestamp', () => {
|
||||
expect(isoDateTime.safeParse('2026-05-25T08:38:14Z').success).toBe(true);
|
||||
expect(isoDateTime.safeParse('not-a-date').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('@trek/shared pagination', () => {
|
||||
it('applies defaults and coerces', () => {
|
||||
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||
expect(paginationQuerySchema.parse({ page: '2', perPage: '10' })).toEqual({ page: 2, perPage: 10 });
|
||||
});
|
||||
|
||||
it('enforces bounds', () => {
|
||||
expect(paginationQuerySchema.safeParse({ perPage: 0 }).success).toBe(false);
|
||||
expect(paginationQuerySchema.safeParse({ perPage: 999 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Primitive, domain-agnostic building blocks shared by every contract.
|
||||
* Domain schemas (trips, places, ...) live in their own folders and reuse these.
|
||||
*/
|
||||
|
||||
/** TREK uses auto-increment integer primary keys. */
|
||||
export const idSchema = z.number().int().positive();
|
||||
export type Id = z.infer<typeof idSchema>;
|
||||
|
||||
/**
|
||||
* Numeric id coming from a URL param / query string. Express hands these over
|
||||
* as strings, so we coerce, then enforce a positive integer.
|
||||
*/
|
||||
export const idParamSchema = z.coerce.number().int().positive();
|
||||
|
||||
/** Non-empty, trimmed string. */
|
||||
export const nonEmptyString = z.string().trim().min(1);
|
||||
|
||||
/** ISO-8601 timestamp string (the shape TREK serialises dates as in JSON). */
|
||||
export const isoDateTime = z.string().datetime({ offset: true });
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @trek/shared — single source of truth for TREK's API contracts.
|
||||
*
|
||||
* Zod schemas defined here are consumed by BOTH the server (validation +
|
||||
* inferred DTO types) and the client (typed requests/responses). A route is
|
||||
* only considered "migrated" once its contract lives in this package.
|
||||
*
|
||||
* Layout: one folder per domain (e.g. src/trip/trip.schema.ts), plus the
|
||||
* domain-agnostic primitives below. See the board card "Module blueprint".
|
||||
*/
|
||||
export * from './common/primitives.schema';
|
||||
export * from './common/pagination.schema';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user