From b3dacea363f5a3b7db0bcdd329b459f4ffd2acca Mon Sep 17 00:00:00 2001 From: chnnick Date: Wed, 22 Jul 2026 00:10:13 -0400 Subject: [PATCH 01/12] s3 module throws error on startup if missing env variables --- apps/backend/src/aws/s3/aws-s3.module.spec.ts | 39 +++++++++++++++++++ apps/backend/src/aws/s3/aws-s3.module.ts | 17 +++++++- .../backend/src/aws/s3/aws-s3.service.spec.ts | 16 -------- apps/backend/src/aws/s3/aws-s3.service.ts | 18 ++++----- 4 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 apps/backend/src/aws/s3/aws-s3.module.spec.ts diff --git a/apps/backend/src/aws/s3/aws-s3.module.spec.ts b/apps/backend/src/aws/s3/aws-s3.module.spec.ts new file mode 100644 index 000000000..1336efb9d --- /dev/null +++ b/apps/backend/src/aws/s3/aws-s3.module.spec.ts @@ -0,0 +1,39 @@ +import { AWSS3Module } from './aws-s3.module'; + +describe('AWSS3Module', () => { + let module: AWSS3Module; + + beforeEach(() => { + process.env.AWS_ACCESS_KEY = 'test-access-key'; + process.env.AWS_SECRET_KEY = 'test-secret-key'; + module = new AWSS3Module(); + }); + + it('should not throw when required env vars are set', () => { + expect(() => module.onModuleInit()).not.toThrow(); + }); + + it('should throw if AWS_ACCESS_KEY is missing', () => { + delete process.env.AWS_ACCESS_KEY; + + expect(() => module.onModuleInit()).toThrow( + 'Missing required environment variable: AWS_ACCESS_KEY', + ); + }); + + it('should throw if AWS_SECRET_KEY is missing', () => { + delete process.env.AWS_SECRET_KEY; + + expect(() => module.onModuleInit()).toThrow( + 'Missing required environment variable: AWS_SECRET_KEY', + ); + }); + + it('should throw if an env var is whitespace-only', () => { + process.env.AWS_ACCESS_KEY = ' '; + + expect(() => module.onModuleInit()).toThrow( + 'Missing required environment variable: AWS_ACCESS_KEY', + ); + }); +}); diff --git a/apps/backend/src/aws/s3/aws-s3.module.ts b/apps/backend/src/aws/s3/aws-s3.module.ts index a3a6a2482..8f40f5b4d 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.ts @@ -1,9 +1,22 @@ -import { Global, Module } from '@nestjs/common'; +import { Global, Module, OnModuleInit } from '@nestjs/common'; import { AWSS3Service } from './aws-s3.service'; +// Required s3 env values +const REQUIRED_ENV_VARS = ['AWS_ACCESS_KEY', 'AWS_SECRET_KEY'] as const; + @Global() @Module({ providers: [AWSS3Service], exports: [AWSS3Service], }) -export class AWSS3Module {} +export class AWSS3Module implements OnModuleInit { + onModuleInit(): void { + for (const name of REQUIRED_ENV_VARS) { + const value = process.env[name]; + // Treat unset and empty/whitespace-only values as missing. + if (!value || value.trim().length === 0) { + throw new Error(`Missing required environment variable: ${name}`); + } + } + } +} diff --git a/apps/backend/src/aws/s3/aws-s3.service.spec.ts b/apps/backend/src/aws/s3/aws-s3.service.spec.ts index 0e7659d08..661925dde 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.spec.ts @@ -33,22 +33,6 @@ describe('AWSS3Service', () => { service['bucketNames'][testBucketEnum] = testBucket; }); - describe('constructor', () => { - it('should throw if AWS_ACCESS_KEY is missing', () => { - delete process.env.AWS_ACCESS_KEY; - expect(() => new AWSS3Service()).toThrow( - 'Missing required environment variable: AWS_ACCESS_KEY', - ); - }); - - it('should throw if AWS_SECRET_KEY is missing', () => { - delete process.env.AWS_SECRET_KEY; - expect(() => new AWSS3Service()).toThrow( - 'Missing required environment variable: AWS_SECRET_KEY', - ); - }); - }); - describe('upload', () => { const validInput: S3UploadInput = { fileBuffer: Buffer.from('test'), diff --git a/apps/backend/src/aws/s3/aws-s3.service.ts b/apps/backend/src/aws/s3/aws-s3.service.ts index f4da46362..928fade60 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.ts @@ -53,19 +53,15 @@ export class AWSS3Service { } } - const accessKeyId = process.env.AWS_ACCESS_KEY; - const secretAccessKey = process.env.AWS_SECRET_KEY; - - if (!accessKeyId) { - throw new Error('Missing required environment variable: AWS_ACCESS_KEY'); - } - if (!secretAccessKey) { - throw new Error('Missing required environment variable: AWS_SECRET_KEY'); - } - + // AWS credentials are validated at module initialization (see AWSS3Module). + // The ?? '' only satisfies the type checker: if either var were missing, + // module init throws and the app never boots, so this client is never used. this.client = new S3Client({ region: this.region, - credentials: { accessKeyId, secretAccessKey }, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY ?? '', + secretAccessKey: process.env.AWS_SECRET_KEY ?? '', + }, }); } From c6492517046aa095989010c83fb3e9a40c74aa60 Mon Sep 17 00:00:00 2001 From: chnnick Date: Wed, 22 Jul 2026 00:32:25 -0400 Subject: [PATCH 02/12] if SEND_AUTOMATED_EMAILS env variable is set to true, throws errors if other ses values are missing. --- apps/backend/src/aws/ses/README.md | 2 +- apps/backend/src/aws/ses/awsSes.wrapper.ts | 5 +- .../src/aws/ses/awsSesClient.factory.ts | 16 ++-- apps/backend/src/aws/ses/email.module.spec.ts | 85 +++++++++++++++++++ apps/backend/src/aws/ses/email.module.ts | 28 +++++- 5 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 apps/backend/src/aws/ses/email.module.spec.ts diff --git a/apps/backend/src/aws/ses/README.md b/apps/backend/src/aws/ses/README.md index 3841717d9..b2a6e63c3 100644 --- a/apps/backend/src/aws/ses/README.md +++ b/apps/backend/src/aws/ses/README.md @@ -62,5 +62,5 @@ If you swap `AWS_SES_SENDER_EMAIL` later, the new address must be verified separ A boolean env var (`'true'` to enable, anything else — including unset — to disable) that gates real SES dispatch. -- When `SEND_AUTOMATED_EMAILS === 'true'`: `sendEmail` runs DTO validation, then schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata). `AWS_SES_SENDER_EMAIL` must be set at this point, or the send throws. +- When `SEND_AUTOMATED_EMAILS === 'true'`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SES_SENDER_EMAIL` are validated at module initialization (`EmailsModule.onModuleInit`), so the app fails to boot if any are missing while enabled. `sendEmail` runs DTO validation, schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata). - When `SEND_AUTOMATED_EMAILS` is unset or any other value: `sendEmail` still runs DTO validation (so a bad payload still throws), then logs a warning (`SEND_AUTOMATED_EMAILS is not "true". Email not sent.`) and returns `void` without contacting SES. Neither `AWS_SES_SENDER_EMAIL` nor the AWS credentials need to be defined — teams not using SES can omit them entirely and the app still boots. diff --git a/apps/backend/src/aws/ses/awsSes.wrapper.ts b/apps/backend/src/aws/ses/awsSes.wrapper.ts index c3893bbd3..2714f8096 100644 --- a/apps/backend/src/aws/ses/awsSes.wrapper.ts +++ b/apps/backend/src/aws/ses/awsSes.wrapper.ts @@ -30,8 +30,9 @@ export class AmazonSESWrapper { * or if SES rejects the send (bad recipient, throttling, unverified sender, quota exceeded). */ async sendEmail(dto: SendEmailDTO): Promise { - const senderEmail = process.env.AWS_SES_SENDER_EMAIL; - if (!senderEmail) throw new Error('AWS_SES_SENDER_EMAIL is not defined'); + // Validated at module initialization (see EmailsModule) when SES is enabled; + // sendEmail is only ever reached when SEND_AUTOMATED_EMAILS is 'true', so senderEmail is guaranteed present here. + const senderEmail = process.env.AWS_SES_SENDER_EMAIL ?? ''; const mailOptions: Mail.Options = { from: senderEmail, diff --git a/apps/backend/src/aws/ses/awsSesClient.factory.ts b/apps/backend/src/aws/ses/awsSesClient.factory.ts index ea288070f..090bd77cb 100644 --- a/apps/backend/src/aws/ses/awsSesClient.factory.ts +++ b/apps/backend/src/aws/ses/awsSesClient.factory.ts @@ -15,18 +15,14 @@ export const AmazonSESClientFactory: Provider = { if (process.env.SEND_AUTOMATED_EMAILS !== 'true') { return new SESv2Client({}); } - const region = process.env.AWS_REGION; - const accessKeyId = process.env.AWS_ACCESS_KEY_ID; - const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - - if (!region) throw new Error('AWS_REGION is not defined'); - if (!accessKeyId) throw new Error('AWS_ACCESS_KEY_ID is not defined'); - if (!secretAccessKey) - throw new Error('AWS_SECRET_ACCESS_KEY is not defined'); + // Region and credentials are validated at module initialization (see EmailsModule) return new SESv2Client({ - region, - credentials: { accessKeyId, secretAccessKey }, + region: process.env.AWS_REGION ?? '', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '', + }, }); }, }; diff --git a/apps/backend/src/aws/ses/email.module.spec.ts b/apps/backend/src/aws/ses/email.module.spec.ts new file mode 100644 index 000000000..0c1359b55 --- /dev/null +++ b/apps/backend/src/aws/ses/email.module.spec.ts @@ -0,0 +1,85 @@ +import { EmailsModule } from './email.module'; + +describe('EmailsModule', () => { + const ENV_VARS = [ + 'SEND_AUTOMATED_EMAILS', + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', + ] as const; + + const REQUIRED_WHEN_ENABLED = [ + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', + ] as const; + + const originalEnv: Record = {}; + let module: EmailsModule; + + beforeEach(() => { + for (const name of ENV_VARS) { + originalEnv[name] = process.env[name]; + } + + // Default to a fully-configured, enabled setup; individual tests override. + process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.AWS_REGION = 'us-east-2'; + process.env.AWS_ACCESS_KEY_ID = 'test-access-key-id'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-access-key'; + process.env.AWS_SES_SENDER_EMAIL = 'sender@example.com'; + + module = new EmailsModule(); + }); + + afterEach(() => { + for (const name of ENV_VARS) { + if (originalEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalEnv[name]; + } + } + }); + + describe('onModuleInit', () => { + it('does not throw when all required env vars are set and enabled', () => { + expect(() => module.onModuleInit()).not.toThrow(); + }); + + it('does not throw when disabled, even if required vars are missing', () => { + process.env.SEND_AUTOMATED_EMAILS = 'false'; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + expect(() => module.onModuleInit()).not.toThrow(); + }); + + it('does not throw when SEND_AUTOMATED_EMAILS is unset', () => { + delete process.env.SEND_AUTOMATED_EMAILS; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + expect(() => module.onModuleInit()).not.toThrow(); + }); + + it.each(REQUIRED_WHEN_ENABLED)( + 'throws when enabled and %s is missing', + (name) => { + delete process.env[name]; + expect(() => module.onModuleInit()).toThrow( + `Missing required environment variable: ${name}`, + ); + }, + ); + + it('throws when enabled and a required var is empty/whitespace-only', () => { + process.env.AWS_SES_SENDER_EMAIL = ' '; + expect(() => module.onModuleInit()).toThrow( + 'Missing required environment variable: AWS_SES_SENDER_EMAIL', + ); + }); + }); +}); diff --git a/apps/backend/src/aws/ses/email.module.ts b/apps/backend/src/aws/ses/email.module.ts index a6cd1bd12..9f74fa3c1 100644 --- a/apps/backend/src/aws/ses/email.module.ts +++ b/apps/backend/src/aws/ses/email.module.ts @@ -1,10 +1,34 @@ -import { Module } from '@nestjs/common'; +import { Module, OnModuleInit } from '@nestjs/common'; import { EmailsService } from './email.service'; import { AmazonSESWrapper } from './awsSes.wrapper'; import { AmazonSESClientFactory } from './awsSesClient.factory'; +// Env vars required only when SES dispatch is enabled (SEND_AUTOMATED_EMAILS === 'true') +const REQUIRED_ENV_VARS_WHEN_ENABLED = [ + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', +] as const; + @Module({ providers: [AmazonSESWrapper, AmazonSESClientFactory, EmailsService], exports: [EmailsService], }) -export class EmailsModule {} +export class EmailsModule implements OnModuleInit { + onModuleInit(): void { + // Email sending is disabled: skip validation so teams not using SES can + // boot without any AWS config. + if (process.env.SEND_AUTOMATED_EMAILS?.toLowerCase() !== 'true') { + return; + } + + for (const name of REQUIRED_ENV_VARS_WHEN_ENABLED) { + const value = process.env[name]; + // Treat unset and empty/whitespace-only values as missing. + if (!value || value.trim().length === 0) { + throw new Error(`Missing required environment variable: ${name}`); + } + } + } +} From 6705b1bbb337ab2de481cc07df1458160d3a0430 Mon Sep 17 00:00:00 2001 From: chnnick Date: Wed, 22 Jul 2026 00:49:26 -0400 Subject: [PATCH 03/12] clarification comment on factory for potential bad env variables in sesv2client --- apps/backend/src/aws/ses/awsSesClient.factory.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/aws/ses/awsSesClient.factory.ts b/apps/backend/src/aws/ses/awsSesClient.factory.ts index 090bd77cb..b38989d5c 100644 --- a/apps/backend/src/aws/ses/awsSesClient.factory.ts +++ b/apps/backend/src/aws/ses/awsSesClient.factory.ts @@ -16,7 +16,9 @@ export const AmazonSESClientFactory: Provider = { return new SESv2Client({}); } - // Region and credentials are validated at module initialization (see EmailsModule) + // If email sending is enabled, EmailsModule.onModuleInit() aborts startup + // when these env vars are missing, so a client built with empty-string + // fallbacks is never actually used to send mail. return new SESv2Client({ region: process.env.AWS_REGION ?? '', credentials: { From 66111340fff1d8869fbaa93efb39e495288c3606 Mon Sep 17 00:00:00 2001 From: chnnick Date: Mon, 17 Aug 2026 19:52:42 -0400 Subject: [PATCH 04/12] make dummy client case insensitive --- apps/backend/src/aws/ses/awsSesClient.factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/aws/ses/awsSesClient.factory.ts b/apps/backend/src/aws/ses/awsSesClient.factory.ts index b38989d5c..f233be8d6 100644 --- a/apps/backend/src/aws/ses/awsSesClient.factory.ts +++ b/apps/backend/src/aws/ses/awsSesClient.factory.ts @@ -12,7 +12,7 @@ export const AmazonSESClientFactory: Provider = { provide: AMAZON_SES_CLIENT, useFactory: () => { // Create dummy client that is never used when email sending is set to false - if (process.env.SEND_AUTOMATED_EMAILS !== 'true') { + if (process.env.SEND_AUTOMATED_EMAILS.toLowerCase() !== 'true') { return new SESv2Client({}); } From c381b3c86b386f130a04cac0592a7afcaab389de Mon Sep 17 00:00:00 2001 From: chnnick Date: Mon, 24 Aug 2026 21:49:53 -0400 Subject: [PATCH 05/12] optional check for SEND_AUTOMATED_EMAILS env var --- apps/backend/src/aws/ses/awsSesClient.factory.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/aws/ses/awsSesClient.factory.ts b/apps/backend/src/aws/ses/awsSesClient.factory.ts index f233be8d6..1f4de8c3f 100644 --- a/apps/backend/src/aws/ses/awsSesClient.factory.ts +++ b/apps/backend/src/aws/ses/awsSesClient.factory.ts @@ -11,14 +11,13 @@ export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT'; export const AmazonSESClientFactory: Provider = { provide: AMAZON_SES_CLIENT, useFactory: () => { - // Create dummy client that is never used when email sending is set to false - if (process.env.SEND_AUTOMATED_EMAILS.toLowerCase() !== 'true') { + // Create dummy client that is NOT used when email sending is unset or set to false. + if (process.env.SEND_AUTOMATED_EMAILS?.toLowerCase() !== 'true') { return new SESv2Client({}); } - // If email sending is enabled, EmailsModule.onModuleInit() aborts startup - // when these env vars are missing, so a client built with empty-string - // fallbacks is never actually used to send mail. + // If email sending is enabled, AWSSESModule.onModuleInit() warns when these env vars are missing. + // The empty-string fallbacks keep the client constructible; sends against it fail at the SES call. return new SESv2Client({ region: process.env.AWS_REGION ?? '', credentials: { From d142e24ed1b36032b583a7df8a93eebb06ec689b Mon Sep 17 00:00:00 2001 From: chnnick Date: Tue, 25 Aug 2026 00:18:02 -0400 Subject: [PATCH 06/12] warn when missing any ses/s3-related env vars, list missing vars in warning (not client-facing) --- apps/backend/src/aws/s3/aws-s3.module.ts | 25 ++++++++++++++++------ apps/backend/src/aws/ses/email.module.ts | 27 ++++++++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/aws/s3/aws-s3.module.ts b/apps/backend/src/aws/s3/aws-s3.module.ts index 8f40f5b4d..9e932590c 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.ts @@ -1,7 +1,8 @@ -import { Global, Module, OnModuleInit } from '@nestjs/common'; +import { Global, Logger, Module, OnModuleInit } from '@nestjs/common'; import { AWSS3Service } from './aws-s3.service'; -// Required s3 env values +// Required s3 env values. +// Add one entry per bucket here: const REQUIRED_ENV_VARS = ['AWS_ACCESS_KEY', 'AWS_SECRET_KEY'] as const; @Global() @@ -10,13 +11,23 @@ const REQUIRED_ENV_VARS = ['AWS_ACCESS_KEY', 'AWS_SECRET_KEY'] as const; exports: [AWSS3Service], }) export class AWSS3Module implements OnModuleInit { + private readonly logger = new Logger(AWSS3Module.name); + onModuleInit(): void { - for (const name of REQUIRED_ENV_VARS) { + // Treat unset and empty/whitespace-only values as missing. + const missing = REQUIRED_ENV_VARS.filter((name) => { const value = process.env[name]; - // Treat unset and empty/whitespace-only values as missing. - if (!value || value.trim().length === 0) { - throw new Error(`Missing required environment variable: ${name}`); - } + return !value || value.trim().length === 0; + }); + + if (missing.length > 0) { + this.logger.warn( + `S3 not fully configured: missing env vars (${missing.join( + ', ', + )}). S3 uploads and downloads will fail.`, + ); + } else { + this.logger.log('S3 configured'); } } } diff --git a/apps/backend/src/aws/ses/email.module.ts b/apps/backend/src/aws/ses/email.module.ts index 9f74fa3c1..316e85ba4 100644 --- a/apps/backend/src/aws/ses/email.module.ts +++ b/apps/backend/src/aws/ses/email.module.ts @@ -1,4 +1,4 @@ -import { Module, OnModuleInit } from '@nestjs/common'; +import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { EmailsService } from './email.service'; import { AmazonSESWrapper } from './awsSes.wrapper'; import { AmazonSESClientFactory } from './awsSesClient.factory'; @@ -15,20 +15,33 @@ const REQUIRED_ENV_VARS_WHEN_ENABLED = [ providers: [AmazonSESWrapper, AmazonSESClientFactory, EmailsService], exports: [EmailsService], }) -export class EmailsModule implements OnModuleInit { +export class AWSSESModule implements OnModuleInit { + private readonly logger = new Logger(AWSSESModule.name); + onModuleInit(): void { // Email sending is disabled: skip validation so teams not using SES can // boot without any AWS config. if (process.env.SEND_AUTOMATED_EMAILS?.toLowerCase() !== 'true') { + this.logger.log( + 'SES disabled: SEND_AUTOMATED_EMAILS is not "true". No emails will be sent.', + ); return; } - for (const name of REQUIRED_ENV_VARS_WHEN_ENABLED) { + // Treat unset and empty/whitespace-only values as missing. + const missing = REQUIRED_ENV_VARS_WHEN_ENABLED.filter((name) => { const value = process.env[name]; - // Treat unset and empty/whitespace-only values as missing. - if (!value || value.trim().length === 0) { - throw new Error(`Missing required environment variable: ${name}`); - } + return !value || value.trim().length === 0; + }); + + if (missing.length > 0) { + this.logger.warn( + `SES enabled but not fully configured: missing env vars (${missing.join( + ', ', + )}). Email sends will fail.`, + ); + } else { + this.logger.log('SES enabled'); } } } From 1935c1f21e53f320f975c980d665d5aca7dbe8dc Mon Sep 17 00:00:00 2001 From: chnnick Date: Tue, 25 Aug 2026 00:27:18 -0400 Subject: [PATCH 07/12] require specific name for buckets, clarification in documentation --- apps/backend/src/aws/s3/aws-s3.service.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/aws/s3/aws-s3.service.ts b/apps/backend/src/aws/s3/aws-s3.service.ts index 928fade60..8ef4a04f0 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.ts @@ -41,21 +41,17 @@ export class AWSS3Service { constructor() { this.region = process.env.AWS_REGION ?? 'us-east-2'; - // Add one entry per bucket in s3Buckets enum. - // Example: [s3Buckets.DOCUMENTS]: process.env.AWS_DOCUMENTS_BUCKET_NAME, + // Every bucket in the S3Buckets enum is read from an env var named AWS__BUCKET_NAME: + // - e.g. S3Buckets.DOCUMENTS reads AWS_DOCUMENTS_BUCKET_NAME. Add each of those names to REQUIRED_ENV_VARS this.bucketNames = {} as Record; for (const bucket of Object.values(S3Buckets) as unknown as S3Buckets[]) { - if (!this.bucketNames[bucket]) { - throw new Error( - `Missing required environment variable for S3 bucket: ${bucket}`, - ); - } + this.bucketNames[bucket] = process.env[`AWS_${bucket}_BUCKET_NAME`] ?? ''; } - // AWS credentials are validated at module initialization (see AWSS3Module). - // The ?? '' only satisfies the type checker: if either var were missing, - // module init throws and the app never boots, so this client is never used. + // AWS credentials are checked at module initialization (see AWSS3Module), + // which warns rather than throws. The ?? '' keeps the client constructible + // when they are absent; requests against it then fail at the AWS call. this.client = new S3Client({ region: this.region, credentials: { From e66c74f108162778738a2329a55c4ebd504f27bf Mon Sep 17 00:00:00 2001 From: chnnick Date: Tue, 25 Aug 2026 00:31:16 -0400 Subject: [PATCH 08/12] change name of email test, checks for warnings instead of errors. CHecks that warnings show missing env variables --- apps/backend/src/aws/ses/email.module.spec.ts | 74 ++++++++++++++----- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/apps/backend/src/aws/ses/email.module.spec.ts b/apps/backend/src/aws/ses/email.module.spec.ts index 0c1359b55..b33f7c967 100644 --- a/apps/backend/src/aws/ses/email.module.spec.ts +++ b/apps/backend/src/aws/ses/email.module.spec.ts @@ -1,6 +1,6 @@ -import { EmailsModule } from './email.module'; +import { AWSSESModule } from './email.module'; -describe('EmailsModule', () => { +describe('AWSSESModule', () => { const ENV_VARS = [ 'SEND_AUTOMATED_EMAILS', 'AWS_REGION', @@ -17,7 +17,9 @@ describe('EmailsModule', () => { ] as const; const originalEnv: Record = {}; - let module: EmailsModule; + let module: AWSSESModule; + let warnSpy: jest.SpyInstance; + let logSpy: jest.SpyInstance; beforeEach(() => { for (const name of ENV_VARS) { @@ -31,10 +33,19 @@ describe('EmailsModule', () => { process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-access-key'; process.env.AWS_SES_SENDER_EMAIL = 'sender@example.com'; - module = new EmailsModule(); + module = new AWSSESModule(); + + warnSpy = jest + .spyOn(module['logger'], 'warn') + .mockImplementation(() => undefined); + logSpy = jest + .spyOn(module['logger'], 'log') + .mockImplementation(() => undefined); }); afterEach(() => { + jest.restoreAllMocks(); + for (const name of ENV_VARS) { if (originalEnv[name] === undefined) { delete process.env[name]; @@ -45,40 +56,69 @@ describe('EmailsModule', () => { }); describe('onModuleInit', () => { - it('does not throw when all required env vars are set and enabled', () => { - expect(() => module.onModuleInit()).not.toThrow(); + it('logs and does not warn when all required env vars are set and enabled', () => { + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('SES enabled'); }); - it('does not throw when disabled, even if required vars are missing', () => { + it('does not warn when disabled, even if required vars are missing', () => { process.env.SEND_AUTOMATED_EMAILS = 'false'; for (const name of REQUIRED_WHEN_ENABLED) { delete process.env[name]; } - expect(() => module.onModuleInit()).not.toThrow(); + + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('SES disabled'), + ); }); - it('does not throw when SEND_AUTOMATED_EMAILS is unset', () => { + it('does not warn when SEND_AUTOMATED_EMAILS is unset', () => { delete process.env.SEND_AUTOMATED_EMAILS; for (const name of REQUIRED_WHEN_ENABLED) { delete process.env[name]; } - expect(() => module.onModuleInit()).not.toThrow(); + + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('SES disabled'), + ); }); it.each(REQUIRED_WHEN_ENABLED)( - 'throws when enabled and %s is missing', + 'warns when enabled and %s is missing', (name) => { delete process.env[name]; - expect(() => module.onModuleInit()).toThrow( - `Missing required environment variable: ${name}`, - ); + + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(name)); }, ); - it('throws when enabled and a required var is empty/whitespace-only', () => { + it('warns when enabled and a required var is empty/whitespace-only', () => { process.env.AWS_SES_SENDER_EMAIL = ' '; - expect(() => module.onModuleInit()).toThrow( - 'Missing required environment variable: AWS_SES_SENDER_EMAIL', + + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_SES_SENDER_EMAIL'), + ); + }); + + it('lists every missing env var in a single warning', () => { + delete process.env.AWS_REGION; + delete process.env.AWS_SES_SENDER_EMAIL; + + module.onModuleInit(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_REGION, AWS_SES_SENDER_EMAIL'), ); }); }); From f5d2d8e68107b8918420ca7574df4b998c35fb2a Mon Sep 17 00:00:00 2001 From: chnnick Date: Tue, 25 Aug 2026 00:34:50 -0400 Subject: [PATCH 09/12] s3 tests --- apps/backend/src/aws/s3/aws-s3.module.spec.ts | 53 +++++++++++++++---- .../backend/src/aws/s3/aws-s3.service.spec.ts | 2 + 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/aws/s3/aws-s3.module.spec.ts b/apps/backend/src/aws/s3/aws-s3.module.spec.ts index 1336efb9d..f97dfc92e 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.spec.ts @@ -2,38 +2,69 @@ import { AWSS3Module } from './aws-s3.module'; describe('AWSS3Module', () => { let module: AWSS3Module; + let warnSpy: jest.SpyInstance; + let logSpy: jest.SpyInstance; beforeEach(() => { process.env.AWS_ACCESS_KEY = 'test-access-key'; process.env.AWS_SECRET_KEY = 'test-secret-key'; module = new AWSS3Module(); + + warnSpy = jest + .spyOn(module['logger'], 'warn') + .mockImplementation(() => undefined); + logSpy = jest + .spyOn(module['logger'], 'log') + .mockImplementation(() => undefined); }); - it('should not throw when required env vars are set', () => { - expect(() => module.onModuleInit()).not.toThrow(); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should log and not warn when required env vars are set', () => { + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('S3 configured'); }); - it('should throw if AWS_ACCESS_KEY is missing', () => { + it('should warn if AWS_ACCESS_KEY is missing', () => { delete process.env.AWS_ACCESS_KEY; - expect(() => module.onModuleInit()).toThrow( - 'Missing required environment variable: AWS_ACCESS_KEY', + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_ACCESS_KEY'), ); }); - it('should throw if AWS_SECRET_KEY is missing', () => { + it('should warn if AWS_SECRET_KEY is missing', () => { delete process.env.AWS_SECRET_KEY; - expect(() => module.onModuleInit()).toThrow( - 'Missing required environment variable: AWS_SECRET_KEY', + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_SECRET_KEY'), ); }); - it('should throw if an env var is whitespace-only', () => { + it('should warn if an env var is whitespace-only', () => { process.env.AWS_ACCESS_KEY = ' '; - expect(() => module.onModuleInit()).toThrow( - 'Missing required environment variable: AWS_ACCESS_KEY', + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_ACCESS_KEY'), + ); + }); + + it('should list every missing env var in a single warning', () => { + delete process.env.AWS_ACCESS_KEY; + delete process.env.AWS_SECRET_KEY; + + module.onModuleInit(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_ACCESS_KEY, AWS_SECRET_KEY'), ); }); }); diff --git a/apps/backend/src/aws/s3/aws-s3.service.spec.ts b/apps/backend/src/aws/s3/aws-s3.service.spec.ts index 661925dde..2fb04a526 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.spec.ts @@ -30,6 +30,8 @@ describe('AWSS3Service', () => { s3Mock.reset(); service = new AWSS3Service(); + // The constructor resolves AWS__BUCKET_NAME for every member of + // S3Buckets, but the scaffold enum is empty — inject the sentinel by hand. service['bucketNames'][testBucketEnum] = testBucket; }); From 103d2793ecd51bff128f2e22c3221ceb5c5208f8 Mon Sep 17 00:00:00 2001 From: chnnick Date: Tue, 25 Aug 2026 00:36:29 -0400 Subject: [PATCH 10/12] rename emailsmodule to sesmodule --- apps/backend/src/aws/ses/awsSes.wrapper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/aws/ses/awsSes.wrapper.ts b/apps/backend/src/aws/ses/awsSes.wrapper.ts index 2714f8096..d0caf79dc 100644 --- a/apps/backend/src/aws/ses/awsSes.wrapper.ts +++ b/apps/backend/src/aws/ses/awsSes.wrapper.ts @@ -30,8 +30,8 @@ export class AmazonSESWrapper { * or if SES rejects the send (bad recipient, throttling, unverified sender, quota exceeded). */ async sendEmail(dto: SendEmailDTO): Promise { - // Validated at module initialization (see EmailsModule) when SES is enabled; - // sendEmail is only ever reached when SEND_AUTOMATED_EMAILS is 'true', so senderEmail is guaranteed present here. + // Checked at module initialization (see AWSSESModule) when SES is enabled; + // sendEmail is only ever reached when SEND_AUTOMATED_EMAILS is 'true', so senderEmail is expected to be present here. const senderEmail = process.env.AWS_SES_SENDER_EMAIL ?? ''; const mailOptions: Mail.Options = { From 98940b048d6f0f651a4ebf8d0f27ef1467daf180 Mon Sep 17 00:00:00 2001 From: chnnick Date: Tue, 25 Aug 2026 00:37:28 -0400 Subject: [PATCH 11/12] add instructions for required env variable handling + naming, rename SES module final touch --- apps/backend/src/aws/s3/README.md | 22 +++++++++++++--------- apps/backend/src/aws/ses/README.md | 10 +++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/aws/s3/README.md b/apps/backend/src/aws/s3/README.md index ad89861ff..429459727 100644 --- a/apps/backend/src/aws/s3/README.md +++ b/apps/backend/src/aws/s3/README.md @@ -16,33 +16,37 @@ AWS_MY_BUCKET_NAME=my-bucket-name Import `AWSS3Module` once in your root `AppModule`. Because the module is `@Global()`, `AWSS3Service` is injectable in all feature modules without additional imports. -The service throws at startup if any bucket env var is unset, so misconfiguration is caught immediately rather than at runtime. +Every bucket env var **must** follow the `AWS__BUCKET_NAME` format, where `` is the `S3Buckets` enum member verbatim. `AWSS3Service` builds its bucket lookup from that convention, so a name that doesn't match will never be found. + +`AWSS3Module.onModuleInit` logs a warning listing any env var in its `REQUIRED_ENV_VARS` list that is unset or blank. ## Adding a New Bucket -**1. Add an env var** in `.env` and `example.env`: +**1. Add an env var** in `.env` and `example.env`, named `AWS__BUCKET_NAME`: ``` AWS_MY_BUCKET_NAME=my-bucket-name ``` -**2. Add an entry to the `s3Buckets` enum** (`types/s3Buckets.ts`): +**2. Add an entry to the `S3Buckets` enum** (`types/s3Buckets.ts`), matching the middle of that env var name: ```typescript -export enum s3Buckets { +export enum S3Buckets { MY_BUCKET = 'MY_BUCKET', } ``` -**3. Add a mapping to `mapBucket`** (`aws-s3.service.ts`): +**3. Add the env var name to `REQUIRED_ENV_VARS`** (`aws-s3.module.ts`), so a missing value is reported at startup: ```typescript -const bucketNames: Record = { - [s3Buckets.MY_BUCKET]: process.env.AWS_MY_BUCKET_NAME, -}; +const REQUIRED_ENV_VARS = [ + 'AWS_ACCESS_KEY', + 'AWS_SECRET_KEY', + 'AWS_MY_BUCKET_NAME', +] as const; ``` -Because `mapBucket` uses a `Record`, TypeScript will produce a compile error if you add an enum entry without adding the corresponding mapping — catching missed steps at build time. +No change to `aws-s3.service.ts` is needed: its constructor resolves `process.env['AWS_' + bucket + '_BUCKET_NAME']` for every member of `S3Buckets`. A bucket whose env var is missing resolves to `''`, and any `upload()` to it throws `Missing required environment variable for S3 bucket: MY_BUCKET`. ## Required IAM Permissions diff --git a/apps/backend/src/aws/ses/README.md b/apps/backend/src/aws/ses/README.md index b2a6e63c3..999362617 100644 --- a/apps/backend/src/aws/ses/README.md +++ b/apps/backend/src/aws/ses/README.md @@ -4,16 +4,16 @@ Thin wrapper around Amazon SES v2 for sending transactional emails (with optiona ## Injecting `EmailsService` -`EmailsModule` exports `EmailsService`, so any consuming module just needs to import `EmailsModule` and then inject `EmailsService` through the constructor. +`AWSSESModule` exports `EmailsService`, so any consuming module just needs to import `AWSSESModule` and then inject `EmailsService` through the constructor. -1. **Import `EmailsModule`** in the consuming module: +1. **Import `AWSSESModule`** in the consuming module: ```ts // users.module.ts - import { EmailsModule } from '../aws/ses/email.module'; + import { AWSSESModule } from '../aws/ses/email.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), EmailsModule], + imports: [TypeOrmModule.forFeature([User]), AWSSESModule], controllers: [UsersController], providers: [UsersService], }) @@ -62,5 +62,5 @@ If you swap `AWS_SES_SENDER_EMAIL` later, the new address must be verified separ A boolean env var (`'true'` to enable, anything else — including unset — to disable) that gates real SES dispatch. -- When `SEND_AUTOMATED_EMAILS === 'true'`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SES_SENDER_EMAIL` are validated at module initialization (`EmailsModule.onModuleInit`), so the app fails to boot if any are missing while enabled. `sendEmail` runs DTO validation, schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata). +- When `SEND_AUTOMATED_EMAILS === 'true'`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SES_SENDER_EMAIL` are checked at module initialization (`AWSSESModule.onModuleInit`), which logs a warning listing any that are missing and boots anyway — sends then fail at the SES call. In production a missing var almost certainly means missing secrets: raise that `logger.warn` to `logger.error`, or throw, so the app fails at startup instead. `sendEmail` runs DTO validation, schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata). - When `SEND_AUTOMATED_EMAILS` is unset or any other value: `sendEmail` still runs DTO validation (so a bad payload still throws), then logs a warning (`SEND_AUTOMATED_EMAILS is not "true". Email not sent.`) and returns `void` without contacting SES. Neither `AWS_SES_SENDER_EMAIL` nor the AWS credentials need to be defined — teams not using SES can omit them entirely and the app still boots. From b2f819771d20eb9a5e1e72015184b41159499f4b Mon Sep 17 00:00:00 2001 From: chnnick Date: Tue, 25 Aug 2026 00:37:46 -0400 Subject: [PATCH 12/12] update ses module name within users --- apps/backend/src/users/users.module.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 2638f52fc..e59be58db 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -6,10 +6,10 @@ import { User } from './user.entity'; import { JwtStrategy } from '../auth/jwt.strategy'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthService } from '../auth/auth.service'; -import { EmailsModule } from '../aws/ses/email.module'; +import { AWSSESModule } from '../aws/ses/email.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), EmailsModule], + imports: [TypeOrmModule.forFeature([User]), AWSSESModule], controllers: [UsersController], providers: [UsersService, AuthService, JwtStrategy, CurrentUserInterceptor], })