refactor(server): bootstrap and middleware read config via app-config

This commit is contained in:
jubnl
2026-07-25 15:17:33 +02:00
parent e17f165e44
commit a34ab288f5
9 changed files with 45 additions and 22 deletions
+8 -1
View File
@@ -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<INestApplication> {
// 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<ConfigType<typeof httpConfig>>(httpConfig.KEY);
applyGlobalMiddleware(instance, { bodyParser: false, http });
applyPlatformUploads(instance);
applyPlatformTransport(instance);
applyPlatformStatic(instance);
+2 -1
View File
@@ -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;
}
+17 -11
View File
@@ -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
+2 -1
View File
@@ -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;
}
@@ -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;
}
+2 -1
View File
@@ -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(
+5 -2
View File
@@ -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) => {
+3 -2
View File
@@ -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;
}
+3 -2
View File
@@ -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 {