feat(server): add nest AppConfigModule bound to @nestjs/config

This commit is contained in:
jubnl
2026-07-25 15:12:38 +02:00
parent 48ba9092c3
commit e17f165e44
9 changed files with 381 additions and 1 deletions
+43
View File
@@ -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",
+1
View File
@@ -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",
@@ -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 {}
+13
View File
@@ -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';
@@ -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;
}
}
+50
View File
@@ -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<typeof httpConfig>) {}
*/
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,
];
+2 -1
View File
@@ -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,
@@ -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<ConfigType<typeof httpConfig>>(httpConfig.KEY);
expect(http.forceHttps).toBe(true);
await app.close();
process.env.FORCE_HTTPS = 'off';
app = await buildApp();
http = app.get<ConfigType<typeof httpConfig>>(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<ConfigType<typeof httpConfig>>(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
});
});
+95
View File
@@ -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<ConfigType<typeof mcpConfig>>(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 });
}
});
});