From a34ab288f534b2c7a662a3159e9bdb9a4b164b9a Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 25 Jul 2026 15:17:33 +0200 Subject: [PATCH] refactor(server): bootstrap and middleware read config via app-config --- server/src/bootstrap.ts | 9 +++++- server/src/middleware/auth.ts | 3 +- server/src/middleware/globalMiddleware.ts | 28 +++++++++++-------- server/src/middleware/mfaPolicy.ts | 3 +- .../src/nest/common/api-docs.kill-switch.ts | 4 ++- server/src/nest/platform/api-docs.ts | 3 +- server/src/nest/platform/platform.routes.ts | 7 +++-- server/src/nest/plugins/kill-switch.ts | 5 ++-- server/tests/integration/api-docs.test.ts | 5 ++-- 9 files changed, 45 insertions(+), 22 deletions(-) diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index 1399b8221..110c2de97 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -1,7 +1,9 @@ import { NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; import type { INestApplication } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; import { AppModule } from './nest/app.module'; +import { httpConfig } from './nest/app-config'; import { applyGlobalMiddleware } from './middleware/globalMiddleware'; import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes'; import { apiDocsEnabled } from './nest/common/api-docs.kill-switch'; @@ -43,7 +45,12 @@ export async function buildApp(): Promise { // parsed JSON alone can't be re-serialised byte-for-byte). const app = await NestFactory.create(AppModule, new ExpressAdapter(), { rawBody: true }); const instance = app.getHttpAdapter().getInstance(); - applyGlobalMiddleware(instance, { bodyParser: false }); + // ConfigModule.forRoot's load factories already ran inside NestFactory.create, + // so the boot-stable snapshot is resolvable here, BEFORE app.init() — this is + // the one bridge that lets the pre-init Express layer consume the validated + // config instead of reading process.env itself. + const http = app.get>(httpConfig.KEY); + applyGlobalMiddleware(instance, { bodyParser: false, http }); applyPlatformUploads(instance); applyPlatformTransport(instance); applyPlatformStatic(instance); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 6631a4524..e5ab0482c 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { JWT_SECRET } from '../config'; import { AuthRequest, OptionalAuthRequest, User } from '../types'; @@ -109,7 +110,7 @@ const adminOnly = (req: Request, res: Response, next: NextFunction): void => { const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => { const authReq = req as AuthRequest; - if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(authReq.user?.email)) { + if (readEnv().demo.enabled && isDemoEmail(authReq.user?.email)) { res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }); return; } diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index 74240b6c8..13f6305a9 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -3,6 +3,7 @@ import compression from 'compression'; import cors from 'cors'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; +import { readEnv, type AppEnv } from '../app-config'; import { logDebug, logWarn, logError } from '../services/auditLog'; import { enforceGlobalMfaPolicy } from './mfaPolicy'; @@ -20,13 +21,18 @@ import { enforceGlobalMfaPolicy } from './mfaPolicy'; */ export function applyGlobalMiddleware( app: express.Application, - opts: { bodyParser?: boolean } = {}, + opts: { bodyParser?: boolean; http?: AppEnv['http'] } = {}, ): void { - const { bodyParser = true } = opts; + // The whole pipeline is configured at APPLY time (the per-request closures + // capture these values), so a snapshot is the correct semantic. bootstrap + // threads in the DI-loaded httpConfig; direct callers fall back to an + // apply-time readEnv() — same values, same freeze point. + const { bodyParser = true, http = readEnv().http } = opts; + const { nodeEnv, isProduction } = readEnv().app; // Trust first proxy (nginx/Docker) for correct req.ip - if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) { - app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1); + if (isProduction || http.trustProxyRaw) { + app.set('trust proxy', http.trustProxy); } // Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country @@ -44,9 +50,7 @@ export function applyGlobalMiddleware( }), ); - const allowedOrigins = process.env.ALLOWED_ORIGINS - ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean) - : null; + const allowedOrigins = http.corsOrigins; let corsOrigin: cors.CorsOptions['origin']; if (allowedOrigins) { @@ -54,13 +58,13 @@ export function applyGlobalMiddleware( if (!origin || allowedOrigins.includes(origin)) callback(null, true); else callback(new Error('Not allowed by CORS')); }; - } else if (process.env.NODE_ENV?.toLowerCase() === 'production') { + } else if (isProduction) { corsOrigin = false; } else { corsOrigin = true; } - const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true'; + const shouldForceHttps = http.forceHttps; // HSTS is worth enabling any time we're serving production traffic, // not only when FORCE_HTTPS is set. Self-hosters behind Traefik / // Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the @@ -72,8 +76,10 @@ export function applyGlobalMiddleware( // sibling subdomain the same operator may still be running over plain // HTTP. Operators who want the stricter policy opt in with // `HSTS_INCLUDE_SUBDOMAINS=true`. - const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production'; - const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; + // nodeEnv compared case-sensitively here on purpose (legacy parity — the + // trust-proxy/CORS checks above are the case-insensitive ones). + const hstsActive = shouldForceHttps || nodeEnv === 'production'; + const hstsIncludeSubdomains = http.hstsIncludeSubdomains; // RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable. // /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it diff --git a/server/src/middleware/mfaPolicy.ts b/server/src/middleware/mfaPolicy.ts index e15384195..dad4a7b6c 100644 --- a/server/src/middleware/mfaPolicy.ts +++ b/server/src/middleware/mfaPolicy.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from 'express'; +import { readEnv } from '../app-config'; import { db } from '../db/database'; import { extractToken, verifyJwtAndLoadUser } from './auth'; import { DEMO_EMAILS } from '../services/demo'; @@ -76,7 +77,7 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu return; } - if (process.env.DEMO_MODE?.toLowerCase() === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) { + if (readEnv().demo.enabled && verified.email && DEMO_EMAILS.has(verified.email)) { next(); return; } diff --git a/server/src/nest/common/api-docs.kill-switch.ts b/server/src/nest/common/api-docs.kill-switch.ts index 186ea8df7..edaa14fc7 100644 --- a/server/src/nest/common/api-docs.kill-switch.ts +++ b/server/src/nest/common/api-docs.kill-switch.ts @@ -6,6 +6,8 @@ * Off by default — the generated spec enumerates every route including the * admin surface, so exposing it is an explicit self-hoster decision. */ +import { readEnv } from '../../app-config'; + export function apiDocsEnabled(): boolean { - return (process.env.TREK_API_DOCS_ENABLED || '').trim().toLowerCase() === 'true'; + return readEnv().http.apiDocsEnabled; } diff --git a/server/src/nest/platform/api-docs.ts b/server/src/nest/platform/api-docs.ts index 96b200c3c..efa6031db 100644 --- a/server/src/nest/platform/api-docs.ts +++ b/server/src/nest/platform/api-docs.ts @@ -1,5 +1,6 @@ import type { INestApplication } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { readEnv } from '../../app-config'; import { attachZodBodySchemas } from '../common/api-zod'; /** @@ -16,7 +17,7 @@ import { attachZodBodySchemas } from '../common/api-zod'; * accepts `Authorization: Bearer` everywhere as the cookie fallback. */ export function setupApiDocs(app: INestApplication): void { - const version: string = process.env.APP_VERSION || (require('../../../package.json') as { version: string }).version; + const version: string = readEnv().app.appVersion || (require('../../../package.json') as { version: string }).version; const config = new DocumentBuilder() .setTitle('TREK API') .setDescription( diff --git a/server/src/nest/platform/platform.routes.ts b/server/src/nest/platform/platform.routes.ts index a2db413b6..9f84e7e5f 100644 --- a/server/src/nest/platform/platform.routes.ts +++ b/server/src/nest/platform/platform.routes.ts @@ -2,6 +2,7 @@ import express, { Request, Response, NextFunction } from 'express'; import path from 'node:path'; import fs from 'node:fs'; +import { readEnv } from '../../app-config'; import { verifyJwtAndLoadUser } from '../../middleware/auth'; import { db } from '../../db/database'; import { mcpHandler } from '../../mcp'; @@ -239,7 +240,8 @@ export function applyPlatformTransport(app: express.Application): void { */ export function applyPlatformSpa(app: express.Application): void { applyPlatformStatic(app); - if (process.env.NODE_ENV !== 'production') return; + // Case-sensitive on purpose (legacy parity). + if (readEnv().app.nodeEnv !== 'production') return; // /.*/ rather than '*' so the helper is Express-4 and Express-5 safe. app.get(/.*/, (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); @@ -255,7 +257,8 @@ export function applyPlatformSpa(app: express.Application): void { * app.get catch-all; Nest: SpaFallbackFilter). No-op outside production. */ export function applyPlatformStatic(app: express.Application): void { - if (process.env.NODE_ENV !== 'production') return; + // Case-sensitive on purpose (legacy parity). + if (readEnv().app.nodeEnv !== 'production') return; app.use( express.static(PUBLIC_DIR, { setHeaders: (res, filePath) => { diff --git a/server/src/nest/plugins/kill-switch.ts b/server/src/nest/plugins/kill-switch.ts index 0804b8d82..5d767431a 100644 --- a/server/src/nest/plugins/kill-switch.ts +++ b/server/src/nest/plugins/kill-switch.ts @@ -9,7 +9,8 @@ * directly here. Read at call time so tests and runtime env changes take effect * immediately. */ +import { readEnv } from '../../app-config'; + export function pluginsEnabled(): boolean { - const v = (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase(); - return !['false', '0', 'off', 'no'].includes(v); + return readEnv().plugins.enabled; } diff --git a/server/tests/integration/api-docs.test.ts b/server/tests/integration/api-docs.test.ts index 795d2762b..bb03dbfdb 100644 --- a/server/tests/integration/api-docs.test.ts +++ b/server/tests/integration/api-docs.test.ts @@ -66,7 +66,7 @@ describe('API-DOCS (#1412) — flag-gated OpenAPI surface', () => { testDb.close(); }); - it('DOCS-001 — the kill switch parses the env strictly', () => { + it('DOCS-001 — the kill switch parses the boolean-like env family', () => { const prev = process.env.TREK_API_DOCS_ENABLED; try { process.env.TREK_API_DOCS_ENABLED = 'true'; @@ -75,8 +75,9 @@ describe('API-DOCS (#1412) — flag-gated OpenAPI surface', () => { expect(apiDocsEnabled()).toBe(true); process.env.TREK_API_DOCS_ENABLED = 'false'; expect(apiDocsEnabled()).toBe(false); + // '1' counts as truthy since the unified boolean coercion (app-config). process.env.TREK_API_DOCS_ENABLED = '1'; - expect(apiDocsEnabled()).toBe(false); + expect(apiDocsEnabled()).toBe(true); delete process.env.TREK_API_DOCS_ENABLED; expect(apiDocsEnabled()).toBe(false); } finally {