From e17f165e44976f1edfa298ca33c14cb0fb22bfe3 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 25 Jul 2026 15:12:38 +0200 Subject: [PATCH] feat(server): add nest AppConfigModule bound to @nestjs/config --- package-lock.json | 43 +++++++++ server/package.json | 1 + .../src/nest/app-config/app-config.module.ts | 38 ++++++++ server/src/nest/app-config/index.ts | 13 +++ .../nest/app-config/runtime-env.service.ts | 45 +++++++++ server/src/nest/app-config/tokens.ts | 50 ++++++++++ server/src/nest/app.module.ts | 3 +- server/tests/integration/app-config.test.ts | 94 ++++++++++++++++++ server/tests/unit/nest/app-config.test.ts | 95 +++++++++++++++++++ 9 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 server/src/nest/app-config/app-config.module.ts create mode 100644 server/src/nest/app-config/index.ts create mode 100644 server/src/nest/app-config/runtime-env.service.ts create mode 100644 server/src/nest/app-config/tokens.ts create mode 100644 server/tests/integration/app-config.test.ts create mode 100644 server/tests/unit/nest/app-config.test.ts diff --git a/package-lock.json b/package-lock.json index 5b372b6f9..f8c37fcd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4908,6 +4908,33 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "11.1.27", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", @@ -10105,6 +10132,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/drag-drop-touch": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/drag-drop-touch/-/drag-drop-touch-1.3.1.tgz", @@ -21327,6 +21369,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/common": "^11.1.24", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", "@nestjs/swagger": "^11.4.5", diff --git a/server/package.json b/server/package.json index 2898d7df9..e13407584 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/common": "^11.1.24", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.24", "@nestjs/platform-express": "^11.1.24", "@nestjs/swagger": "^11.4.5", diff --git a/server/src/nest/app-config/app-config.module.ts b/server/src/nest/app-config/app-config.module.ts new file mode 100644 index 000000000..3f89b39e7 --- /dev/null +++ b/server/src/nest/app-config/app-config.module.ts @@ -0,0 +1,38 @@ +/** + * Global binding of @nestjs/config onto TREK's app-config layer. + * + * INVARIANTS — breaking either silently breaks ~60 env-mutating tests: + * - `cache: false`, always. `cache: true` would freeze ConfigService lookups + * for the whole app lifetime; the runtime-toggled vars (DEMO_MODE, NODE_ENV, + * OIDC_*, …) must stay live via RuntimeEnvService/readEnv(). + * - No `validate`/`validationSchema` here. Fail-fast validation runs exactly + * once, from the production entrypoint (src/app-config/boot-validate.ts) — + * buildApp() is invoked dozens of times per test run and must stay + * side-effect-free. + * - `ignoreEnvFile: true`, always. dotenv is loaded ONCE in index.ts; tests + * deliberately never load .env, and reading it here would leak a + * developer's local server/.env into every integration test. + * + * The framework's ConfigModule is aliased ONLY inside this file: app.module.ts + * already imports a local `ConfigModule` (the /api/config endpoint module), so + * the alias keeps the two from colliding anywhere else. + */ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import { BOOT_STABLE_TOKENS } from './tokens'; +import { RuntimeEnvService } from './runtime-env.service'; + +@Global() +@Module({ + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + cache: false, + load: BOOT_STABLE_TOKENS, + }), + ], + providers: [RuntimeEnvService], + exports: [RuntimeEnvService], +}) +export class AppConfigModule {} diff --git a/server/src/nest/app-config/index.ts b/server/src/nest/app-config/index.ts new file mode 100644 index 000000000..df7fd6a9c --- /dev/null +++ b/server/src/nest/app-config/index.ts @@ -0,0 +1,13 @@ +export { AppConfigModule } from './app-config.module'; +export { RuntimeEnvService } from './runtime-env.service'; +export { + httpConfig, + sessionConfig, + mcpConfig, + integrationsConfig, + backupConfig, + dbConfig, + pathsConfig, + netConfig, + BOOT_STABLE_TOKENS, +} from './tokens'; diff --git a/server/src/nest/app-config/runtime-env.service.ts b/server/src/nest/app-config/runtime-env.service.ts new file mode 100644 index 000000000..636021b70 --- /dev/null +++ b/server/src/nest/app-config/runtime-env.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { readEnv, type AppEnv } from '../../app-config'; + +/** + * DI-friendly access to the RUNTIME-TOGGLED env values (DEMO_MODE, NODE_ENV, + * APP_VERSION, OIDC_*, …). Every method delegates to readEnv() at call time — + * never snapshot a namespace into a field or the constructor, or the ~60 tests + * that mutate process.env mid-lifetime break. + * + * Boot-stable values are NOT accessed through this service; inject their + * registerAs token instead (see tokens.ts). + */ +@Injectable() +export class RuntimeEnvService { + /** The full live-derived env — prefer the convenience getters where one exists. */ + env(): AppEnv { + return readEnv(); + } + + isDemoMode(): boolean { + return readEnv().demo.enabled; + } + + nodeEnv(): string | undefined { + return readEnv().app.nodeEnv; + } + + isProduction(): boolean { + return readEnv().app.isProduction; + } + + isDevelopment(): boolean { + return readEnv().app.isDevelopment; + } + + /** Raw APP_VERSION — call sites keep their own fallback ('0.0.0' vs package.json vs semver). */ + appVersion(): string | undefined { + return readEnv().app.appVersion; + } + + /** Raw APP_URL — call sites keep their own trailing-slash handling. */ + appUrl(): string | undefined { + return readEnv().app.appUrl; + } +} diff --git a/server/src/nest/app-config/tokens.ts b/server/src/nest/app-config/tokens.ts new file mode 100644 index 000000000..cf93991e6 --- /dev/null +++ b/server/src/nest/app-config/tokens.ts @@ -0,0 +1,50 @@ +/** + * registerAs tokens over the app-config derive functions, for the BOOT-STABLE + * namespaces only (see src/app-config/README.md for the classification). + * + * A token's factory runs during NestFactory.create(), so the injected object is + * a SNAPSHOT per built app — integration tests that mutate env and then rebuild + * buildApp() get fresh values automatically. Runtime-toggled values (DEMO_MODE, + * NODE_ENV, OIDC_*, …) must NOT get tokens here; they go through + * RuntimeEnvService / readEnv() so mid-lifetime env mutations stay visible. + * + * Consumption pattern: + * constructor(@Inject(httpConfig.KEY) private readonly http: ConfigType) {} + */ +import { registerAs } from '@nestjs/config'; +import { + deriveBackup, + deriveDb, + deriveHttp, + deriveIntegrations, + deriveMcp, + deriveNet, + derivePaths, + deriveSession, +} from '../../app-config'; + +/** + * http is snapshot-consumed only by the pre-init Express layer (bootstrap + * threads it into applyGlobalMiddleware) — apply-time freeze per built app. + * Request-time reads of the same vars (cookie.ts FORCE_HTTPS checks) stay live + * via readEnv(). + */ +export const httpConfig = registerAs('http', () => deriveHttp(process.env)); +export const sessionConfig = registerAs('session', () => deriveSession(process.env)); +export const mcpConfig = registerAs('mcp', () => deriveMcp(process.env)); +export const integrationsConfig = registerAs('integrations', () => deriveIntegrations(process.env)); +export const backupConfig = registerAs('backup', () => deriveBackup(process.env)); +export const dbConfig = registerAs('db', () => deriveDb(process.env)); +export const pathsConfig = registerAs('paths', () => derivePaths(process.env)); +export const netConfig = registerAs('net', () => deriveNet(process.env)); + +export const BOOT_STABLE_TOKENS = [ + httpConfig, + sessionConfig, + mcpConfig, + integrationsConfig, + backupConfig, + dbConfig, + pathsConfig, + netConfig, +]; diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 603aeb21e..fb2d937f3 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; +import { AppConfigModule } from './app-config/app-config.module'; import { DatabaseModule } from './database/database.module'; import { HealthController } from './health/health.controller'; import { HealthService } from './health/health.service'; @@ -52,7 +53,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PluginsModule, BookingImportModule], + imports: [AppConfigModule, DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PluginsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/tests/integration/app-config.test.ts b/server/tests/integration/app-config.test.ts new file mode 100644 index 000000000..d02b3e9ac --- /dev/null +++ b/server/tests/integration/app-config.test.ts @@ -0,0 +1,94 @@ +/** + * AppConfigModule wired into the real buildApp(): boot-stable registerAs + * snapshots must re-derive on every app build (the env-mutate-then-rebuild + * pattern the integration suite relies on), and RuntimeEnvService must stay + * live within a single app's lifetime. + */ +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import type { INestApplication } from '@nestjs/common'; + +const { testDb, dbMock } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); + +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb } from '../helpers/test-db'; +import { buildApp } from '../../src/bootstrap'; +import { httpConfig, RuntimeEnvService } from '../../src/nest/app-config'; +import type { ConfigType } from '@nestjs/config'; + +describe('AppConfigModule in the real buildApp()', () => { + let app: INestApplication | undefined; + let prevForceHttps: string | undefined; + let prevDemo: string | undefined; + + beforeAll(() => { + createTables(testDb); + runMigrations(testDb); + resetTestDb(testDb); + prevForceHttps = process.env.FORCE_HTTPS; + prevDemo = process.env.DEMO_MODE; + }); + + afterEach(async () => { + await app?.close(); + app = undefined; + if (prevForceHttps === undefined) delete process.env.FORCE_HTTPS; + else process.env.FORCE_HTTPS = prevForceHttps; + if (prevDemo === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = prevDemo; + }); + + it('boot-stable snapshots re-derive per app build (mutate → rebuild → new value)', async () => { + process.env.FORCE_HTTPS = 'true'; + app = await buildApp(); + let http = app.get>(httpConfig.KEY); + expect(http.forceHttps).toBe(true); + await app.close(); + + process.env.FORCE_HTTPS = 'off'; + app = await buildApp(); + http = app.get>(httpConfig.KEY); + expect(http.forceHttps).toBe(false); + }); + + it('a snapshot does NOT move within one app lifetime, RuntimeEnvService does', async () => { + delete process.env.FORCE_HTTPS; + delete process.env.DEMO_MODE; + app = await buildApp(); + const http = app.get>(httpConfig.KEY); + const runtime = app.get(RuntimeEnvService); + + process.env.FORCE_HTTPS = 'true'; + process.env.DEMO_MODE = 'true'; + expect(http.forceHttps).toBe(false); // frozen at build — by design + expect(runtime.isDemoMode()).toBe(true); // live — by design + }); +}); diff --git a/server/tests/unit/nest/app-config.test.ts b/server/tests/unit/nest/app-config.test.ts new file mode 100644 index 000000000..bb06bd012 --- /dev/null +++ b/server/tests/unit/nest/app-config.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Test } from '@nestjs/testing'; +import { ConfigService, ConfigType } from '@nestjs/config'; + +import { AppConfigModule } from '../../../src/nest/app-config/app-config.module'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { httpConfig, mcpConfig, BOOT_STABLE_TOKENS } from '../../../src/nest/app-config/tokens'; + +describe('RuntimeEnvService', () => { + const service = new RuntimeEnvService(); + let prevDemo: string | undefined; + + beforeEach(() => { + prevDemo = process.env.DEMO_MODE; + }); + afterEach(() => { + if (prevDemo === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = prevDemo; + }); + + it('reads live — a mid-lifetime env mutation is visible on the next call', () => { + process.env.DEMO_MODE = 'true'; + expect(service.isDemoMode()).toBe(true); + process.env.DEMO_MODE = 'off'; + expect(service.isDemoMode()).toBe(false); + delete process.env.DEMO_MODE; + expect(service.isDemoMode()).toBe(false); + }); + + it('env() exposes the full live-derived namespaces', () => { + process.env.DEMO_MODE = 'yes'; + expect(service.env().demo.enabled).toBe(true); + expect(service.env().app.port).toBeTypeOf('number'); + }); +}); + +describe('boot-stable tokens', () => { + it('factories re-derive from the current env on each invocation', () => { + const prev = process.env.FORCE_HTTPS; + try { + process.env.FORCE_HTTPS = 'on'; + expect(httpConfig().forceHttps).toBe(true); + process.env.FORCE_HTTPS = 'off'; + expect(httpConfig().forceHttps).toBe(false); + } finally { + if (prev === undefined) delete process.env.FORCE_HTTPS; + else process.env.FORCE_HTTPS = prev; + } + }); + + it('every token carries a namespaced KEY for @Inject', () => { + for (const token of BOOT_STABLE_TOKENS) { + expect(token.KEY).toMatch(/^CONFIGURATION\(.+\)$/); + } + }); +}); + +describe('AppConfigModule', () => { + it('provides ConfigService, RuntimeEnvService and the loaded namespaces', async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppConfigModule] }).compile(); + try { + const config = moduleRef.get(ConfigService); + const runtime = moduleRef.get(RuntimeEnvService); + const mcp = moduleRef.get>(mcpConfig.KEY); + expect(runtime).toBeInstanceOf(RuntimeEnvService); + expect(mcp.rateLimitMax).toBe(300); + // Plain get() falls through to live process.env (cache: false). + expect(config.get('NODE_ENV')).toBe('test'); + } finally { + await moduleRef.close(); + } + }); + + // Guards `ignoreEnvFile: true`: a marker var present only in server/.env must + // stay invisible. Skipped locally when a real server/.env exists (we won't + // clobber it); CI has none, so the regression is always covered there. + const envPath = path.resolve(process.cwd(), '.env'); + it.skipIf(fs.existsSync(envPath))('never reads a .env file (dotenv stays index.ts-only)', async () => { + fs.writeFileSync(envPath, 'TREK_TEST_ENVFILE_MARKER=leaked\n'); + try { + const moduleRef = await Test.createTestingModule({ imports: [AppConfigModule] }).compile(); + try { + const config = moduleRef.get(ConfigService); + expect(config.get('TREK_TEST_ENVFILE_MARKER')).toBeUndefined(); + expect(process.env.TREK_TEST_ENVFILE_MARKER).toBeUndefined(); + } finally { + await moduleRef.close(); + } + } finally { + fs.rmSync(envPath, { force: true }); + } + }); +});