mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(server): add nest AppConfigModule bound to @nestjs/config
This commit is contained in:
Generated
+43
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user