# KAR-613 Gate 3 review diff (branch kar-613-gate3-local). NO push/PR/deploy.
# jwt.constants.ts excluded (removed line held the leaked fallback). New content reads JWT_SECRET from env.
# Lockfile (pnpm-lock.yaml) changes omitted from this view for brevity (throttler@6.4.0 added).
# ---

.gitignore                                         | 14 ++++++
 apps/backend/.env.example                          | 57 ++++++++++++++++++++++
 apps/backend/jest.setup.ts                         | 11 +++++
 apps/backend/src/app/core/core.module.ts           | 10 ++++
 .../src/app/features/auth/auth.controller.ts       |  7 +++
 .../controllers/customer-reservation.controller.ts |  3 ++
 .../reservation.service.search-customers.spec.ts   | 31 ++++++++++++
 .../reservation/services/reservation.service.ts    |  9 ++++
 .../room-plan/controllers/token.controller.ts      |  6 ++-
 apps/backend/src/main.ts                           |  7 ++-
 infra/src/main.tf                                  |  1 +
 infra/src/modules/ecs/main.tf                      |  1 +
 infra/src/modules/ecs/variables.tf                 |  5 ++
 infra/src/variables.tf                             |  5 ++
 .../auth/src/lib/constants/jwt.constants.spec.ts   | 32 ++++++++++++
 libs/backend/config/src/lib/env.validation.spec.ts | 33 +++++++++++++
 libs/backend/config/src/lib/env.validation.ts      | 37 +++++++++++++-
 package.json                                       |  1 +
 18 files changed, 267 insertions(+), 3 deletions(-)

--- Changes ---

.gitignore
  @@ -24,6 +24,20 @@
  +# secrets — never commit (KAR-613). Use *.example files with placeholder values.
  +**/.env
  +**/.env.*
  +!**/.env.example
  +**/env_config.json
  +!**/env_config.example.json
  +*.tfvars
  +*.tfvars.json
  +*.pem
  +*.key
  +*-service-account*.json
  +/dumps
  +/backups
  +
   # misc
   /.sass-cache
   /connect.lock
  +14 -0

apps/backend/.env.example
  @@ -0,0 +1,57 @@
  +# KADiCon backend — environment contract (KAR-613)
  +# Copy to .env and fill with real values. NEVER commit the real .env.
  +# Secrets use placeholder tokens here — replace them, do not commit real values.
  +
  +# Runtime
  +NODE_ENV=development
  +APP_MODE=local
  +LOG_LEVEL=info
  +PORT=3000
  +
  +# Auth (required — app fails fast at boot if unset; min length 32)
  +JWT_SECRET=JWT_SECRET_REQUIRED_IN_ENV_CHANGE_ME_MIN_32_CHARS
  +
  +# AWS (S3 + SES)
  +AWS_REGION=eu-central-1
  +AWS_ACCESS_KEY=CHANGE_ME
  +AWS_SECRET_KEY=CHANGE_ME
  +AWS_S3_PUBLIC_BUCKET=kadicon-public-bucket-name
  +
  +# Database (Postgres)
  +DATABASE_HOST=localhost
  +DATABASE_PORT=5432
  +DATABASE_USERNAME=postgres
  +DATABASE_PASSWORD=CHANGE_ME
  +DATABASE_NAME=kadicon
  +DB_TIMEOUT=30000
  +
  +# Redis / Valkey
  +REDIS_HOST=localhost
  +REDIS_PORT=6379
  +REDIS_DB=0
  +
  +# Stripe
  +STRIPE_API_KEY=CHANGE_ME
  +STRIPE_WEBHOOK_SECRET=CHANGE_ME
  +
  +# Email / URLs
  +FROM_EMAIL=no-reply@example.com
  +DASHBOARD_URL=https://dashboard.example.com
  +CUSTOMER_APP_URL=https://app.example.com
  +
  +# Firebase (service-account JSON as a single-line string)
  +FIREBASE=CHANGE_ME
  +
  +# Google (Maps + Action Center) — optional unless those features are enabled
  +GOOGLE_MAPS_API_KEY=CHANGE_ME
  +GOOGLE_ACTION_CENTER_PARTNER_ID=CHANGE_ME
  +GOOGLE_ACTION_CENTER_SERVICE_ACCOUNT_CLIENT_EMAIL=CHANGE_ME
  +GOOGLE_ACTION_CENTER_SERVICE_ACCOUNT_PRIVATE_KEY=CHANGE_ME
  +GOOGLE_ACTION_CENTER_BOOKING_SERVER_USERNAME=CHANGE_ME
  +GOOGLE_ACTION_CENTER_BOOKING_SERVER_PASSWORD=CHANGE_ME
  +GOOGLE_ACTION_CENTER_FEEDS_SFTP_KEY=CHANGE_ME
  +GOOGLE_ACTION_CENTER_FEEDS_URL=
  +GOOGLE_ACTION_CENTER_FEEDS_PORT=
  +GOOGLE_ACTION_CENTER_FEEDS_USERNAMES_MERCHANTS=
  +GOOGLE_ACTION_CENTER_FEEDS_USERNAMES_SERVICES=
  +GOOGLE_ACTION_CENTER_FEEDS_USERNAMES_AVAILABILITY=
  +57 -0

apps/backend/jest.setup.ts
  @@ -1,3 +1,14 @@
  +// KAR-613: required secrets now fail-fast at module import (no insecure fallback).
  +// Provide placeholder values for the test environment so importing auth/config
  +// modules does not throw. These are NOT real secrets.
  +process.env['JWT_SECRET'] ??= 'test-jwt-secret-placeholder-not-a-real-secret'
  +process.env['DATABASE_PASSWORD'] ??= 'test-placeholder'
  +process.env['STRIPE_API_KEY'] ??= 'test-placeholder'
  +process.env['STRIPE_WEBHOOK_SECRET'] ??= 'test-placeholder'
  +process.env['AWS_ACCESS_KEY'] ??= 'test-placeholder'
  +process.env['AWS_SECRET_KEY'] ??= 'test-placeholder'
  +process.env['FIREBASE'] ??= 'test-placeholder'
  +
   class MockHandlebarsAdapter {}
   
   jest.mock('@nestjs-modules/mailer', () => ({
  +11 -0

apps/backend/src/app/core/core.module.ts
  @@ -5,9 +5,11 @@ import KeyvValkey from '@keyv/valkey'
  +import { APP_GUARD } from '@nestjs/core'
   import { EventEmitterModule } from '@nestjs/event-emitter'
   import { JwtModule } from '@nestjs/jwt'
   import { ScheduleModule } from '@nestjs/schedule'
  +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'
   import { CronModule } from '../base'
   import { GOOGLE, REDIS_OPTIONS } from '../config'
   import { GlobalModule } from '../global'
  @@ -19,6 +21,10 @@ import { QueueModule } from './queue'
  +		// Global rate limiting (KAR-613). The default applies to every route; sensitive
  +		// endpoints tighten it with @Throttle. In-memory storage is fine at
  +		// desired_count=1 — switch to a shared (Valkey) store before scaling out.
  +		ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
   		DatabaseModule,
   		AwsModule,
   		QueueModule,
  @@ -45,5 +51,9 @@ import { QueueModule } from './queue'
  +	providers: [
  +		// Apply the throttler globally as a guard (KAR-613).
  +		{ provide: APP_GUARD, useClass: ThrottlerGuard },
  +	],
   })
   export class CoreModule {}
  +10 -0

apps/backend/src/app/features/auth/auth.controller.ts
  @@ -1,6 +1,7 @@
  +import { Throttle } from '@nestjs/throttler'
   import { AuthService } from './services'
   import {
   	LoginRequest,
  @@ -18,6 +19,8 @@ import {
  +	// Strict rate limit against credential brute-force (KAR-613).
  +	@Throttle({ default: { limit: 5, ttl: 60_000 } })
   	@Post('login')
   	@HttpCode(200)
   	@ApiResponse({
  @@ -50,6 +53,8 @@ export class AuthController {
  +	// Strict rate limit against abuse/account enumeration (KAR-613).
  +	@Throttle({ default: { limit: 5, ttl: 60_000 } })
   	@Post('password/reset')
   	@HttpCode(204)
   	@ApiResponse({
  @@ -60,6 +65,8 @@ export class AuthController {
  +	// Strict rate limit against reset-token brute-force (KAR-613).
  +	@Throttle({ default: { limit: 5, ttl: 60_000 } })
   	@Post('password/reset/new')
   	@HttpCode(204)
   	@ApiResponse({
  +7 -0

apps/backend/src/app/features/reservation/controllers/customer-reservation.controller.ts
  @@ -10,6 +10,7 @@ import {
  +import { Throttle } from '@nestjs/throttler'
   import { ParseDatePipe } from '../../../common'
   import { ReservationService, TimeslotService } from '../services'
   import {
  @@ -28,6 +29,8 @@ export class CustomerReservationController {
  +	// Strict rate limit: this endpoint is brute-forcible by table/query (KAR-613).
  +	@Throttle({ default: { limit: 10, ttl: 60_000 } })
   	@Get('verify/:tableId/:query')
   	@ApiOkResponse({ type: VerifyReservationResponse })
   	@ApiBadRequestResponse()
  +3 -0

apps/backend/src/app/features/reservation/services/reservation.service.search-customers.spec.ts
  @@ -0,0 +1,31 @@
  +import { TestBed } from '@automock/jest'
  +import { ReservationRepository } from '@backend/repository'
  +import { BadRequestException } from '@nestjs/common'
  +import { ReservationService } from './reservation.service'
  +
  +jest.mock('typeorm-transactional', () => ({
  +	Transactional: () => () => ({}),
  +	initializeTransactionalContext: () => undefined,
  +}))
  +
  +describe('ReservationService.searchCustomers (KAR-613 field allowlist)', () => {
  +	let service: ReservationService
  +	let reservationRepository: jest.Mocked<ReservationRepository>
  +
  +	beforeEach(() => {
  +		const { unit, unitRef } = TestBed.create(ReservationService).compile()
  +		service = unit
  +		reservationRepository = unitRef.get(ReservationRepository)
  +	})
  +
  +	it('rejects a non-allowlisted field with BadRequestException', async () => {
  +		await expect(service.searchCustomers('passwordHash', 'x')).rejects.toBeInstanceOf(BadRequestException)
  +		expect(reservationRepository.find).not.toHaveBeenCalled()
  +	})
  +
  +	it.each(['name', 'email', 'phoneNumber'])('allows the allowlisted field "%s"', async (field) => {
  +		reservationRepository.find.mockResolvedValue([])
  +		await expect(service.searchCustomers(field, 'x')).resolves.toEqual([])
  +		expect(reservationRepository.find).toHaveBeenCalledTimes(1)
  +	})
  +})
  +31 -0

apps/backend/src/app/features/reservation/services/reservation.service.ts
  @@ -424,6 +424,15 @@ export class ReservationService {
  +		// Allowlist the searchable field to prevent dynamic-field injection into the
  +		// TypeORM where-clause (KAR-613). Only these customerData fields may be queried.
  +		const allowedFields = ['name', 'email', 'phoneNumber'] as const
  +		if (!allowedFields.includes(field as (typeof allowedFields)[number])) {
  +			throw new BadRequestException(
  +				`Invalid search field '${field}'. Allowed fields: ${allowedFields.join(', ')}.`,
  +			)
  +		}
  +
   		return this._reservationRepository
   			.find({
   				where: { customerData: { [field]: ILike(`%${query.toLowerCase()}%`) } },
  +9 -0

apps/backend/src/app/features/room-plan/controllers/token.controller.ts
  @@ -3,6 +3,7 @@ import { TableRepository, TableTokenRepository } from '@backend/repository'
  +import { Throttle } from '@nestjs/throttler'
   import { FastifyReply } from 'fastify'
   import PDFDocument from 'pdfkit'
   import QRCode from 'qrcode'
  @@ -20,6 +21,8 @@ export class TokenController {
  +	// Strict rate limit: token verification is brute-forcible (KAR-613).
  +	@Throttle({ default: { limit: 10, ttl: 60_000 } })
   	@Get('verify/:token')
   	@ApiOkResponse({ type: TableTokenResponse })
   	@ApiForbiddenResponse()
  @@ -27,7 +30,8 @@ export class TokenController {
  -			this.logger.warn(`Invalid table token provided ${token}`)
  +			// Do not log the raw token (KAR-613) — avoid leaking a credential into logs.
  +			this.logger.warn('Invalid table token provided')
   			throw new ForbiddenException('Token is invalid')
   		}
   		return { tableId: foundToken.tableId, tenantId: foundToken.tenantId }
  +5 -1

apps/backend/src/main.ts
  @@ -37,7 +37,12 @@ async function bootstrap() {
  -	SwaggerModule.setup(globalPrefix, app, document)
  +
  +	// Do not expose the Swagger UI in production (KAR-613). The OpenAPI spec can still
  +	// be generated via the --openapi build flag below.
  +	if (process.env.NODE_ENV !== 'production') {
  +		SwaggerModule.setup(globalPrefix, app, document)
  +	}
   
   	if (process.argv.includes('--openapi')) {
   		const outputPath = join(__dirname, './openapi.json')
  +6 -1

infra/src/main.tf
  @@ -97,6 +97,7 @@ module "ecs" {
  +  jwt_secret = var.jwt_secret
   
     reviews_cron_schedule            = var.reviews_cron_schedule
     reservation_seeder_cron_schedule = var.reservation_seeder_cron_schedule
  +1 -0

infra/src/modules/ecs/main.tf
  @@ -134,6 +134,7 @@ resource "aws_ecs_task_definition" "backend" {
  +          { name = "JWT_SECRET", value = var.jwt_secret },
             { name = "FROM_EMAIL", value : var.from_email },
             { name = "DASHBOARD_URL", value : "https://dashboard.${var.domain}" },
             { name = "CUSTOMER_APP_URL", value : "https://app.${var.domain}" },
  +1 -0

infra/src/modules/ecs/variables.tf
  @@ -34,6 +34,11 @@ variable "datadog_api_key" {
  +variable "jwt_secret" {
  +  type      = string
  +  sensitive = true
  +}
  +
   variable "datadog_site" {
     type = string
   }
  +5 -0

infra/src/variables.tf
  @@ -118,3 +118,8 @@ variable "google" {
  +
  +variable "jwt_secret" {
  +  type      = string
  +  sensitive = true
  +}
  +5 -0

libs/backend/auth/src/lib/constants/jwt.constants.spec.ts
  @@ -0,0 +1,32 @@
  +describe('JWT_CONSTANTS (KAR-613 fail-fast, no fallback)', () => {
  +	const original = process.env['JWT_SECRET']
  +
  +	afterEach(() => {
  +		if (original === undefined) {
  +			delete process.env['JWT_SECRET']
  +		} else {
  +			process.env['JWT_SECRET'] = original
  +		}
  +		jest.resetModules()
  +	})
  +
  +	it('throws at import when JWT_SECRET is unset (no insecure fallback)', () => {
  +		delete process.env['JWT_SECRET']
  +		jest.resetModules()
  +		expect(() => require('./jwt.constants')).toThrow(/JWT_SECRET/)
  +	})
  +
  +	it('throws when JWT_SECRET is empty/whitespace', () => {
  +		process.env['JWT_SECRET'] = '   '
  +		jest.resetModules()
  +		expect(() => require('./jwt.constants')).toThrow(/JWT_SECRET/)
  +	})
  +
  +	it('exposes the secret from the environment when set', () => {
  +		const secret = 'x'.repeat(32)
  +		process.env['JWT_SECRET'] = secret
  +		jest.resetModules()
  +		const { JWT_CONSTANTS } = require('./jwt.constants')
  +		expect(JWT_CONSTANTS.SECRET).toBe(secret)
  +	})
  +})
  +32 -0

libs/backend/config/src/lib/env.validation.spec.ts
  @@ -0,0 +1,33 @@
  +import { LogLevel, StageId } from '@core/types'
  +import { validate } from './env.validation'
  +
  +describe('env validation (KAR-613)', () => {
  +	const validEnv = {
  +		APP_MODE: StageId.STAGING,
  +		LOG_LEVEL: LogLevel.INFO,
  +		JWT_SECRET: 'x'.repeat(32),
  +		DATABASE_PASSWORD: 'placeholder',
  +		STRIPE_API_KEY: 'placeholder',
  +		STRIPE_WEBHOOK_SECRET: 'placeholder',
  +		AWS_ACCESS_KEY: 'placeholder',
  +		AWS_SECRET_KEY: 'placeholder',
  +		FIREBASE: 'placeholder',
  +	}
  +
  +	it('passes when all required secrets are present', () => {
  +		expect(() => validate({ ...validEnv })).not.toThrow()
  +	})
  +
  +	it('throws when JWT_SECRET is missing', () => {
  +		const { JWT_SECRET: _omit, ...withoutJwt } = validEnv
  +		expect(() => validate(withoutJwt)).toThrow(/JWT_SECRET/)
  +	})
  +
  +	it('throws when JWT_SECRET is shorter than 32 chars', () => {
  +		expect(() => validate({ ...validEnv, JWT_SECRET: 'too-short' })).toThrow()
  +	})
  +
  +	it('throws when a required secret is empty', () => {
  +		expect(() => validate({ ...validEnv, DATABASE_PASSWORD: '' })).toThrow()
  +	})
  +})
  +33 -0

libs/backend/config/src/lib/env.validation.ts
  @@ -1,6 +1,6 @@
  -import { IsEnum, validateSync } from 'class-validator'
  +import { IsEnum, IsNotEmpty, IsString, MinLength, validateSync } from 'class-validator'
   import 'reflect-metadata'
   
   export class EnvironmentVariables {
  @@ -9,6 +9,41 @@ export class EnvironmentVariables {
  +
  +	/**
  +	 * Required secrets. Boot fails fast (validateSync with skipMissingProperties: false)
  +	 * if any of these is missing or empty — no insecure runtime defaults.
  +	 * NOTE: keep this set reconciled with the live ECS task-definition env (Gate 2.1).
  +	 * Local dev and test environments must provide placeholder values for these.
  +	 */
  +	@IsString()
  +	@IsNotEmpty()
  +	@MinLength(32)
  +	JWT_SECRET: string
  +
  +	@IsString()
  +	@IsNotEmpty()
  +	DATABASE_PASSWORD: string
  +
  +	@IsString()
  +	@IsNotEmpty()
  +	STRIPE_API_KEY: string
  +
  +	@IsString()
  +	@IsNotEmpty()
  +	STRIPE_WEBHOOK_SECRET: string
  +
  +	@IsString()
  +	@IsNotEmpty()
  +	AWS_ACCESS_KEY: string
  +
  +	@IsString()
  +	@IsNotEmpty()
  +	AWS_SECRET_KEY: string
  +
  +	@IsString()
  +	@IsNotEmpty()
  +	FIREBASE: string
   }
   
   export function validate(config: Record<string, unknown>): typeof EnvironmentVariables {
  +36 -1

package.json
  @@ -63,6 +63,7 @@
  +		"@nestjs/throttler": "6.4.0",
   		"@nestjs/typeorm": "11.0.1",
   		"@nestjs/websockets": "11.1.19",
   		"@ng-select/ng-select": "21.8.0",
  +1 -0
