diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 544755a..11681df 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -41,7 +41,7 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars run: pnpm run lint unit-tests: @@ -75,7 +75,8 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars + USE_REDIS_CACHE: 'false' run: pnpm test --coverage --passWithNoTests - name: Upload coverage reports @@ -131,7 +132,8 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars + USE_REDIS_CACHE: 'false' run: pnpm run test:e2e build: @@ -165,7 +167,7 @@ jobs: DATABASE_USER: test DATABASE_PASSWORD: test DATABASE_NAME: station_test - JWT_SECRET: test-secret-key-for-ci + JWT_SECRET: test-secret-key-for-ci-minimum-32-chars run: pnpm run build - name: Upload build artifacts diff --git a/backend/.env.example b/backend/.env.example index a22536e..09f99ca 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,29 +1,31 @@ -# Database Configuration +# ─── Application ─────────────────────────────────────────────────────────────── +NODE_ENV=development +PORT=3001 +APP_NAME=STATION BACKEND + +# ─── JWT ──────────────────────────────────────────────────────────────────────── +# Required. Minimum 32 characters. Use a strong random value in production. +JWT_SECRET=your-super-secret-jwt-key-minimum-32-chars + +# ─── Database ─────────────────────────────────────────────────────────────────── DATABASE_HOST=localhost DATABASE_PORT=5433 DATABASE_USER=stationDbUser DATABASE_PASSWORD=stationDbPassword1 DATABASE_NAME=stationDb -# JWT Configuration -JWT_SECRET=your-super-secret-jwt-key-change-this-in-production - -# Redis Configuration +# ─── Redis ────────────────────────────────────────────────────────────────────── REDIS_HOST=localhost REDIS_PORT=6379 USE_REDIS_CACHE=true -# Application Configuration -# Allowed values: development | production | test -NODE_ENV=development -PORT=3001 -APP_NAME=STATION BACKEND - -# CORS Configuration -# Production: https://station.drdnt.org +# ─── CORS / Frontend ──────────────────────────────────────────────────────────── +# Origin allowed by CORS (frontend URL). Must be a valid URI. ALLOWED_ORIGIN=http://localhost:5173 +# Base URL used to construct password reset links sent via email. +FRONTEND_URL=http://localhost:5173 -# UEX Sync Configuration +# ─── UEX Sync ─────────────────────────────────────────────────────────────────── UEX_SYNC_ENABLED=true UEX_CATEGORIES_SYNC_ENABLED=true UEX_ITEMS_SYNC_ENABLED=true diff --git a/backend/.env.test b/backend/.env.test index c27afb3..e98bd8b 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -3,7 +3,7 @@ DATABASE_PORT=5433 DATABASE_USER=stationDbUser DATABASE_PASSWORD=stationDbPassword1 DATABASE_NAME=stationDb -JWT_SECRET=test-jwt-secret-for-e2e-tests-only +JWT_SECRET=test-jwt-secret-for-e2e-tests-only-32chars PORT=3000 APP_NAME=STATION BACKEND TEST USE_REDIS_CACHE=false diff --git a/backend/package.json b/backend/package.json index 7f3b7f6..72d1c09 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,6 +54,7 @@ "dotenv": "^16.4.5", "figlet": "^1.8.0", "helmet": "^8.0.0", + "joi": "^17.13.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 80e507e..606e040 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { UserOrganizationRolesModule } from './modules/user-organization-roles/u import { PermissionsModule } from './modules/permissions/permissions.module'; import { AppService } from './app.service'; import { AppController } from './app.controller'; +import { envValidationSchema } from './config/env.validation'; import { DatabaseSeederModule } from './database/seeds/database-seeder.module'; import { AuditLogsModule } from './modules/audit-logs/audit-logs.module'; import { GamesModule } from './modules/games/games.module'; @@ -35,6 +36,8 @@ if (!isTest) { imports: [ ConfigModule.forRoot({ isGlobal: true, + validationSchema: envValidationSchema, + validationOptions: { abortEarly: false }, }), ...conditionalImports, CacheModule.registerAsync({ diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts new file mode 100644 index 0000000..4116460 --- /dev/null +++ b/backend/src/config/env.validation.ts @@ -0,0 +1,58 @@ +import * as Joi from 'joi'; + +export const envValidationSchema = Joi.object({ + // Application + NODE_ENV: Joi.string() + .valid('development', 'production', 'test') + .default('development'), + PORT: Joi.number().default(3001), + APP_NAME: Joi.string().default('STATION BACKEND'), + + // JWT + JWT_SECRET: Joi.string().min(32).required(), + + // Database + DATABASE_HOST: Joi.string().required(), + DATABASE_PORT: Joi.number().default(5433), + DATABASE_USER: Joi.string().required(), + DATABASE_PASSWORD: Joi.string().required(), + DATABASE_NAME: Joi.string().required(), + + // Redis + REDIS_HOST: Joi.string().default('localhost'), + REDIS_PORT: Joi.number().default(6379), + USE_REDIS_CACHE: Joi.string().valid('true', 'false').default('true'), + + // CORS / Frontend — required in production; default to localhost in dev/test + ALLOWED_ORIGIN: Joi.string() + .uri() + .when('NODE_ENV', { + is: 'production', + then: Joi.required(), + otherwise: Joi.string().uri().default('http://localhost:5173'), + }), + FRONTEND_URL: Joi.string() + .uri() + .when('NODE_ENV', { + is: 'production', + then: Joi.required(), + otherwise: Joi.string().uri().default('http://localhost:5173'), + }), + + // UEX Sync (all optional — service degrades gracefully when disabled) + // Use Joi.boolean() so env strings like 'false' are coerced to actual + // booleans; configService.get(...) in the scheduler relies on this. + UEX_SYNC_ENABLED: Joi.boolean().default(false), + UEX_CATEGORIES_SYNC_ENABLED: Joi.boolean().default(false), + UEX_ITEMS_SYNC_ENABLED: Joi.boolean().default(false), + UEX_LOCATIONS_SYNC_ENABLED: Joi.boolean().default(false), + UEX_API_BASE_URL: Joi.string().uri().default('https://uexcorp.space/api/2.0'), + UEX_TIMEOUT_MS: Joi.number().default(60000), + UEX_BATCH_SIZE: Joi.number().default(100), + UEX_CONCURRENT_CATEGORIES: Joi.number().default(3), + UEX_RETRY_ATTEMPTS: Joi.number().default(3), + UEX_BACKOFF_BASE_MS: Joi.number().default(1000), + UEX_RATE_LIMIT_PAUSE_MS: Joi.number().default(2000), + UEX_ENDPOINTS_PAUSE_MS: Joi.number().default(2000), + UEX_API_KEY: Joi.string().allow('').default(''), +}); diff --git a/backend/src/main.ts b/backend/src/main.ts index fac16cf..1a0a583 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,3 +1,9 @@ +// dotenv/config must be the very first import so that process.env is populated +// before any other module is evaluated. Module-level code (e.g. decorator +// arguments and top-level constants) in NestJS modules runs at require() time, +// which is before bootstrap() — moving the load here ensures .env values are +// visible to those expressions during local development. +import 'dotenv/config'; import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; @@ -5,13 +11,10 @@ import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import * as figlet from 'figlet'; -import * as dotenv from 'dotenv'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; -dotenv.config(); - async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 12cc63d..2121759 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; describe('AuthController - Password Reset', () => { let controller: AuthController; @@ -25,6 +26,12 @@ describe('AuthController - Password Reset', () => { provide: AuthService, useValue: mockAuthService, }, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('test'), + }, + }, ], }).compile(); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 7f7e90e..32ae89d 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -16,6 +16,7 @@ import { ApiBody, ApiBearerAuth, } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { LocalAuthGuard } from './local-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -32,12 +33,15 @@ import { @ApiTags('auth') @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private configService: ConfigService, + ) {} private cookieOptions(maxAge: number) { return { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: this.configService.get('NODE_ENV') === 'production', sameSite: 'strict' as const, path: '/', maxAge, diff --git a/backend/test/auth-password-reset.e2e-spec.ts b/backend/test/auth-password-reset.e2e-spec.ts index 223fb75..964c33e 100644 --- a/backend/test/auth-password-reset.e2e-spec.ts +++ b/backend/test/auth-password-reset.e2e-spec.ts @@ -46,7 +46,7 @@ describe('Auth - Password Reset (e2e)', () => { isActive: true, }); - // Login to get access token + // Login to get access token cookie const loginResponse = await request(app.getHttpServer()) .post('/auth/login') .send({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5c8f45..5c4aae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: helmet: specifier: ^8.0.0 version: 8.1.0 + joi: + specifier: ^17.13.3 + version: 17.13.3 passport: specifier: ^0.7.0 version: 0.7.0 @@ -716,6 +719,12 @@ packages: resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -1288,6 +1297,15 @@ packages: cpu: [x64] os: [win32] + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -2601,11 +2619,12 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -3027,6 +3046,9 @@ packages: node-notifier: optional: true + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4961,6 +4983,12 @@ snapshots: '@eslint/js@9.39.1': {} + '@hapi/hoek@9.3.0': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -5596,6 +5624,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.2': optional: true + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -7773,6 +7809,14 @@ snapshots: - supports-color - ts-node + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + js-tokens@4.0.0: {} js-yaml@3.14.2: