mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): bootstrap and middleware read config via app-config
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user