From b3dacea363f5a3b7db0bcdd329b459f4ffd2acca Mon Sep 17 00:00:00 2001 From: chnnick Date: Wed, 22 Jul 2026 00:10:13 -0400 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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], }) From a464e2efe1156cc3956d3251a2c597c033d8e5c4 Mon Sep 17 00:00:00 2001 From: chnnick Date: Wed, 26 Aug 2026 23:17:04 -0400 Subject: [PATCH 13/20] match optional + lower case check in module to service --- apps/backend/src/aws/ses/email.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/aws/ses/email.service.ts b/apps/backend/src/aws/ses/email.service.ts index 6301e1744..8d68f0811 100644 --- a/apps/backend/src/aws/ses/email.service.ts +++ b/apps/backend/src/aws/ses/email.service.ts @@ -49,8 +49,10 @@ export class EmailsService { const validated = plainToInstance(SendEmailDTO, dto); await validateOrReject(validated); - if (process.env.SEND_AUTOMATED_EMAILS !== 'true') { - this.logger.warn('SEND_AUTOMATED_EMAILS is not "true". Email not sent.'); + 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; } From 444d9ddb63af38db11095dda9f99fc9407433e99 Mon Sep 17 00:00:00 2001 From: chnnick Date: Thu, 3 Sep 2026 00:15:51 -0400 Subject: [PATCH 14/20] normalize aws env variables --- apps/backend/src/aws/s3/README.md | 12 +++++---- apps/backend/src/aws/s3/aws-s3.module.spec.ts | 26 +++++++++---------- apps/backend/src/aws/s3/aws-s3.module.ts | 8 ++++-- .../backend/src/aws/s3/aws-s3.service.spec.ts | 4 +-- apps/backend/src/aws/s3/aws-s3.service.ts | 6 ++--- apps/backend/src/aws/ses/email.module.ts | 4 ++- example.env | 20 ++++++++++---- 7 files changed, 49 insertions(+), 31 deletions(-) diff --git a/apps/backend/src/aws/s3/README.md b/apps/backend/src/aws/s3/README.md index 429459727..25acbd639 100644 --- a/apps/backend/src/aws/s3/README.md +++ b/apps/backend/src/aws/s3/README.md @@ -8,12 +8,14 @@ Add these variables to `.env` and `example.env`: ``` AWS_REGION=us-east-2 -AWS_ACCESS_KEY=your-access-key-id -AWS_SECRET_KEY=your-secret-access-key +AWS_ACCESS_KEY_ID=your-access-key-id +AWS_SECRET_ACCESS_KEY=your-secret-access-key # one entry per bucket — see "Adding a New Bucket" below AWS_MY_BUCKET_NAME=my-bucket-name ``` +`AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` are shared across every AWS module in this app (S3, SES, …) and use the AWS SDK's standard names — define them once and don't rename them per-service. Only the bucket vars are S3-specific. + Import `AWSS3Module` once in your root `AppModule`. Because the module is `@Global()`, `AWSS3Service` is injectable in all feature modules without additional imports. 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. @@ -40,8 +42,8 @@ export enum S3Buckets { ```typescript const REQUIRED_ENV_VARS = [ - 'AWS_ACCESS_KEY', - 'AWS_SECRET_KEY', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', 'AWS_MY_BUCKET_NAME', ] as const; ``` @@ -50,7 +52,7 @@ No change to `aws-s3.service.ts` is needed: its constructor resolves `process.en ## Required IAM Permissions -The credentials supplied via `AWS_ACCESS_KEY` / `AWS_SECRET_KEY` must have: +The credentials supplied via `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` must have: | Permission | Required by | |---|---| 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 f97dfc92e..9c226111f 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.spec.ts @@ -6,8 +6,8 @@ describe('AWSS3Module', () => { let logSpy: jest.SpyInstance; beforeEach(() => { - process.env.AWS_ACCESS_KEY = 'test-access-key'; - process.env.AWS_SECRET_KEY = 'test-secret-key'; + process.env.AWS_ACCESS_KEY_ID = 'test-access-key'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key'; module = new AWSS3Module(); warnSpy = jest @@ -29,42 +29,42 @@ describe('AWSS3Module', () => { expect(logSpy).toHaveBeenCalledWith('S3 configured'); }); - it('should warn if AWS_ACCESS_KEY is missing', () => { - delete process.env.AWS_ACCESS_KEY; + it('should warn if AWS_ACCESS_KEY_ID is missing', () => { + delete process.env.AWS_ACCESS_KEY_ID; expect(() => module.onModuleInit()).not.toThrow(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_ACCESS_KEY'), + expect.stringContaining('AWS_ACCESS_KEY_ID'), ); }); - it('should warn if AWS_SECRET_KEY is missing', () => { - delete process.env.AWS_SECRET_KEY; + it('should warn if AWS_SECRET_ACCESS_KEY is missing', () => { + delete process.env.AWS_SECRET_ACCESS_KEY; expect(() => module.onModuleInit()).not.toThrow(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_SECRET_KEY'), + expect.stringContaining('AWS_SECRET_ACCESS_KEY'), ); }); it('should warn if an env var is whitespace-only', () => { - process.env.AWS_ACCESS_KEY = ' '; + process.env.AWS_ACCESS_KEY_ID = ' '; expect(() => module.onModuleInit()).not.toThrow(); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_ACCESS_KEY'), + expect.stringContaining('AWS_ACCESS_KEY_ID'), ); }); it('should list every missing env var in a single warning', () => { - delete process.env.AWS_ACCESS_KEY; - delete process.env.AWS_SECRET_KEY; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; module.onModuleInit(); expect(warnSpy).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_ACCESS_KEY, AWS_SECRET_KEY'), + expect.stringContaining('AWS_ACCESS_KEY_ID, AWS_SECRET_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 9e932590c..fe87d1c82 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.ts @@ -2,8 +2,12 @@ import { Global, Logger, Module, OnModuleInit } from '@nestjs/common'; import { AWSS3Service } from './aws-s3.service'; // Required s3 env values. -// Add one entry per bucket here: -const REQUIRED_ENV_VARS = ['AWS_ACCESS_KEY', 'AWS_SECRET_KEY'] as const; +// The credentials are the shared AWS ones (also used by the SES module). +// Add one entry per bucket here: +const REQUIRED_ENV_VARS = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', +] as const; @Global() @Module({ 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 2fb04a526..274b3cffe 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.spec.ts @@ -22,8 +22,8 @@ describe('AWSS3Service', () => { let service: AWSS3Service; beforeEach(() => { - process.env.AWS_ACCESS_KEY = 'test-access-key'; - process.env.AWS_SECRET_KEY = 'test-secret-key'; + process.env.AWS_ACCESS_KEY_ID = 'test-access-key'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key'; process.env.AWS_REGION = region; process.env.AWS_TEST_BUCKET_NAME = testBucket; diff --git a/apps/backend/src/aws/s3/aws-s3.service.ts b/apps/backend/src/aws/s3/aws-s3.service.ts index 8ef4a04f0..de738f19c 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.ts @@ -41,7 +41,7 @@ export class AWSS3Service { constructor() { this.region = process.env.AWS_REGION ?? 'us-east-2'; - // Every bucket in the S3Buckets enum is read from an env var named AWS__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; @@ -55,8 +55,8 @@ export class AWSS3Service { this.client = new S3Client({ region: this.region, credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY ?? '', - secretAccessKey: process.env.AWS_SECRET_KEY ?? '', + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '', }, }); } diff --git a/apps/backend/src/aws/ses/email.module.ts b/apps/backend/src/aws/ses/email.module.ts index 316e85ba4..35f0887f6 100644 --- a/apps/backend/src/aws/ses/email.module.ts +++ b/apps/backend/src/aws/ses/email.module.ts @@ -3,7 +3,9 @@ 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') +// Env vars required only when SES dispatch is enabled (SEND_AUTOMATED_EMAILS === 'true'). +// AWS_REGION and the credentials are the shared AWS ones (also used by the S3 module); +// only AWS_SES_SENDER_EMAIL is specific to SES. const REQUIRED_ENV_VARS_WHEN_ENABLED = [ 'AWS_REGION', 'AWS_ACCESS_KEY_ID', diff --git a/example.env b/example.env index c67f2ec65..96bbda514 100644 --- a/example.env +++ b/example.env @@ -5,7 +5,17 @@ NX_DB_PASSWORD= NX_DB_DATABASE=jumpstart NX_DB_PORT=5432 -# AWS S3 +# Swagger UI: set to true only in local and staging — not in production. +SWAGGER_ENABLED=false + +# --- AWS (shared by the S3 and SES modules) --- +# One IAM user's credentials, read by every AWS module. These are the AWS SDK's +# standard variable names, so don't rename them per-service. +# AWS_ACCESS_KEY_ID='ABCDEFGHIJK12345678' +# AWS_SECRET_ACCESS_KEY='bhduerv797887veerfwev78899y87tre' +AWS_REGION='us-east-2' + +# --- AWS S3 --- # Add one env var per S3 bucket (see apps/backend/src/aws/s3/README.md for the full setup steps) # Example: # AWS_MY_BUCKET_NAME_1=my-bucket-name-1 @@ -14,8 +24,8 @@ NX_DB_PORT=5432 # Swagger UI: set to true only in local and staging — not in production. SWAGGER_ENABLED=false -# AWS_ACCESS_KEY_ID = 'ABCDEFGHIJK12345678' -# AWS_SECRET_ACCESS_KEY = 'bhduerv797887veerfwev78899y87tre' -AWS_REGION = 'us-east-2' -AWS_SES_SENDER_EMAIL = 'example@example.com' +# --- AWS SES --- +# Set SEND_AUTOMATED_EMAILS=true to enable sends; the vars above plus the sender +# are only required when it is true (see apps/backend/src/aws/ses/README.md). SEND_AUTOMATED_EMAILS=false +AWS_SES_SENDER_EMAIL='example@example.com' From 9e2028d2a3d161ea9defc51912dbcb25ed99d040 Mon Sep 17 00:00:00 2001 From: chnnick Date: Thu, 3 Sep 2026 00:25:34 -0400 Subject: [PATCH 15/20] typed bucket in getImageData method, updated tests to pass newly typed testbucket --- apps/backend/src/aws/s3/aws-s3.service.spec.ts | 16 ++++++++++------ apps/backend/src/aws/s3/aws-s3.service.ts | 7 +++++-- 2 files changed, 15 insertions(+), 8 deletions(-) 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 274b3cffe..332be191c 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.spec.ts @@ -160,15 +160,19 @@ describe('AWSS3Service', () => { } as unknown as GetObjectCommandOutput['Body'], }); - const result = await service.getImageData('photo.jpg', testBucket); + const result = await service.getImageData('photo.jpg', testBucketEnum); + const commandCall = s3Mock.call(0); + expect((commandCall.args[0] as GetObjectCommand).input.Bucket).toBe( + testBucket, + ); expect(result).toBe(imageBytes); }); it('should return null when response body is missing', async () => { s3Mock.on(GetObjectCommand).resolves({ Body: undefined }); - const result = await service.getImageData('photo.jpg', testBucket); + const result = await service.getImageData('photo.jpg', testBucketEnum); expect(result).toBeNull(); }); @@ -178,7 +182,7 @@ describe('AWSS3Service', () => { .on(GetObjectCommand) .rejects(new NoSuchKey({ message: 'Not found', $metadata: {} })); - const result = await service.getImageData('missing.jpg', testBucket); + const result = await service.getImageData('missing.jpg', testBucketEnum); expect(result).toBeNull(); }); @@ -199,11 +203,11 @@ describe('AWSS3Service', () => { .spyOn(service['logger'], 'error') .mockImplementation(() => undefined); - const result = await service.getImageData('photo.jpg', testBucket); + const result = await service.getImageData('photo.jpg', testBucketEnum); expect(result).toBeNull(); expect(loggerErrorSpy).toHaveBeenCalledWith( - `S3 error retrieving object: key=photo.jpg, bucket=${testBucket}, error=Access denied`, + `S3 error retrieving object: key=photo.jpg, bucket=${testBucketEnum}, error=Access denied`, ); }); @@ -211,7 +215,7 @@ describe('AWSS3Service', () => { s3Mock.on(GetObjectCommand).rejects(new Error('network error')); await expect( - service.getImageData('photo.jpg', testBucket), + service.getImageData('photo.jpg', testBucketEnum), ).rejects.toThrow('network error'); }); }); diff --git a/apps/backend/src/aws/s3/aws-s3.service.ts b/apps/backend/src/aws/s3/aws-s3.service.ts index de738f19c..c4cbec5e0 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.ts @@ -118,10 +118,13 @@ export class AWSS3Service { async getImageData( objectKey: string, - bucket: string, + bucket: S3Buckets, ): Promise { try { - const command = new GetObjectCommand({ Bucket: bucket, Key: objectKey }); + const command = new GetObjectCommand({ + Bucket: this.bucketNames[bucket], + Key: objectKey, + }); const response = await this.client.send(command); if (!response.Body) { return null; From d5400d011e4b94149dbfd6edea71acae618f3d7e Mon Sep 17 00:00:00 2001 From: chnnick Date: Thu, 3 Sep 2026 00:36:35 -0400 Subject: [PATCH 16/20] s3 enabled flag --- apps/backend/src/aws/s3/README.md | 15 +++++++++++---- apps/backend/src/aws/s3/aws-s3.module.ts | 19 ++++++++++++++----- apps/backend/src/aws/s3/aws-s3.service.ts | 2 +- example.env | 10 +++++++--- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/aws/s3/README.md b/apps/backend/src/aws/s3/README.md index 25acbd639..1990ef9ba 100644 --- a/apps/backend/src/aws/s3/README.md +++ b/apps/backend/src/aws/s3/README.md @@ -7,6 +7,7 @@ A global NestJS module providing S3 file upload and retrieval via `AWSS3Service` Add these variables to `.env` and `example.env`: ``` +S3_ENABLED=true AWS_REGION=us-east-2 AWS_ACCESS_KEY_ID=your-access-key-id AWS_SECRET_ACCESS_KEY=your-secret-access-key @@ -14,13 +15,19 @@ AWS_SECRET_ACCESS_KEY=your-secret-access-key AWS_MY_BUCKET_NAME=my-bucket-name ``` -`AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` are shared across every AWS module in this app (S3, SES, …) and use the AWS SDK's standard names — define them once and don't rename them per-service. Only the bucket vars are S3-specific. +`AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` are shared across every AWS module in this app (S3, SES, …) and use the AWS SDK's standard names — define them once and don't rename them per-service. Only `S3_ENABLED` and the bucket vars are S3-specific. Import `AWSS3Module` once in your root `AppModule`. Because the module is `@Global()`, `AWSS3Service` is injectable in all feature modules without additional imports. 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. +## `S3_ENABLED` + +`S3_ENABLED` gates the startup config check, mirroring `SEND_AUTOMATED_EMAILS` in the SES module. When it is anything other than `'true'` (case-insensitive), `AWSS3Module.onModuleInit` skips validation entirely and logs `S3 disabled: …`, so a project that doesn't use S3 boots without AWS config and without a warning on every startup. + +When it is `'true'`, `onModuleInit` logs a warning listing any env var in its `REQUIRED_ENV_VARS_WHEN_ENABLED` list that is unset or blank. + +The flag only controls that check — it does not disable `AWSS3Service`. Calls to `upload()` / `getImageData()` still reach AWS and fail there if credentials are absent. ## Adding a New Bucket @@ -38,10 +45,10 @@ export enum S3Buckets { } ``` -**3. Add the env var name to `REQUIRED_ENV_VARS`** (`aws-s3.module.ts`), so a missing value is reported at startup: +**3. Add the env var name to `REQUIRED_ENV_VARS_WHEN_ENABLED`** (`aws-s3.module.ts`), so a missing value is reported at startup when `S3_ENABLED=true`: ```typescript -const REQUIRED_ENV_VARS = [ +const REQUIRED_ENV_VARS_WHEN_ENABLED = [ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_MY_BUCKET_NAME', diff --git a/apps/backend/src/aws/s3/aws-s3.module.ts b/apps/backend/src/aws/s3/aws-s3.module.ts index fe87d1c82..7b3a3dd0e 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.ts @@ -1,10 +1,10 @@ import { Global, Logger, Module, OnModuleInit } from '@nestjs/common'; import { AWSS3Service } from './aws-s3.service'; -// Required s3 env values. +// Env vars required only when S3 is enabled (S3_ENABLED === 'true'). // The credentials are the shared AWS ones (also used by the SES module). // Add one entry per bucket here: -const REQUIRED_ENV_VARS = [ +const REQUIRED_ENV_VARS_WHEN_ENABLED = [ 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', ] as const; @@ -18,20 +18,29 @@ export class AWSS3Module implements OnModuleInit { private readonly logger = new Logger(AWSS3Module.name); onModuleInit(): void { + // S3 is disabled: skip validation so teams not using S3 can boot without + // any AWS config (and without a warning on every startup). + if (process.env.S3_ENABLED?.toLowerCase() !== 'true') { + this.logger.log( + 'S3 disabled: S3_ENABLED is not "true". Uploads and downloads will fail.', + ); + return; + } + // Treat unset and empty/whitespace-only values as missing. - const missing = REQUIRED_ENV_VARS.filter((name) => { + const missing = REQUIRED_ENV_VARS_WHEN_ENABLED.filter((name) => { const value = process.env[name]; return !value || value.trim().length === 0; }); if (missing.length > 0) { this.logger.warn( - `S3 not fully configured: missing env vars (${missing.join( + `S3 enabled but not fully configured: missing env vars (${missing.join( ', ', )}). S3 uploads and downloads will fail.`, ); } else { - this.logger.log('S3 configured'); + this.logger.log('S3 enabled'); } } } diff --git a/apps/backend/src/aws/s3/aws-s3.service.ts b/apps/backend/src/aws/s3/aws-s3.service.ts index c4cbec5e0..74795bef9 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.ts @@ -42,7 +42,7 @@ export class AWSS3Service { this.region = process.env.AWS_REGION ?? 'us-east-2'; // 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 + // - e.g. S3Buckets.DOCUMENTS reads AWS_DOCUMENTS_BUCKET_NAME. Add each of those names to REQUIRED_ENV_VARS_WHEN_ENABLED this.bucketNames = {} as Record; for (const bucket of Object.values(S3Buckets) as unknown as S3Buckets[]) { diff --git a/example.env b/example.env index 96bbda514..8d4407ac3 100644 --- a/example.env +++ b/example.env @@ -16,16 +16,20 @@ SWAGGER_ENABLED=false AWS_REGION='us-east-2' # --- AWS S3 --- +# Set S3_ENABLED=true to enable S3; the credentials above plus the bucket vars +# are only required when it is true (see apps/backend/src/aws/s3/README.md). +S3_ENABLED=false # Add one env var per S3 bucket (see apps/backend/src/aws/s3/README.md for the full setup steps) # Example: # AWS_MY_BUCKET_NAME_1=my-bucket-name-1 # AWS_MY_BUCKET_NAME_2=my-bucket-name2 - -# Swagger UI: set to true only in local and staging — not in production. -SWAGGER_ENABLED=false # --- AWS SES --- # Set SEND_AUTOMATED_EMAILS=true to enable sends; the vars above plus the sender # are only required when it is true (see apps/backend/src/aws/ses/README.md). SEND_AUTOMATED_EMAILS=false AWS_SES_SENDER_EMAIL='example@example.com' + + +# Swagger UI: set to true only in local and staging — not in production. +SWAGGER_ENABLED=false From f92c1b58f35f1e5e686bca81d787bbf97eb6f22a Mon Sep 17 00:00:00 2001 From: chnnick Date: Thu, 3 Sep 2026 00:37:49 -0400 Subject: [PATCH 17/20] tests for new s3 enabled flag, --- apps/backend/src/aws/s3/aws-s3.module.spec.ts | 116 +++++++++++++----- 1 file changed, 83 insertions(+), 33 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 9c226111f..6e09adc79 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.spec.ts @@ -1,13 +1,32 @@ import { AWSS3Module } from './aws-s3.module'; describe('AWSS3Module', () => { + const ENV_VARS = [ + 'S3_ENABLED', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + ] as const; + + const REQUIRED_WHEN_ENABLED = [ + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + ] as const; + + const originalEnv: Record = {}; let module: AWSS3Module; let warnSpy: jest.SpyInstance; let logSpy: jest.SpyInstance; beforeEach(() => { + for (const name of ENV_VARS) { + originalEnv[name] = process.env[name]; + } + + // Default to a fully-configured, enabled setup; individual tests override. + process.env.S3_ENABLED = 'true'; process.env.AWS_ACCESS_KEY_ID = 'test-access-key'; process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key'; + module = new AWSS3Module(); warnSpy = jest @@ -20,51 +39,82 @@ describe('AWSS3Module', () => { afterEach(() => { jest.restoreAllMocks(); + + for (const name of ENV_VARS) { + if (originalEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalEnv[name]; + } + } }); - it('should log and not warn when required env vars are set', () => { - module.onModuleInit(); + describe('onModuleInit', () => { + it('logs and does not warn when all required env vars are set and enabled', () => { + module.onModuleInit(); - expect(warnSpy).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith('S3 configured'); - }); + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('S3 enabled'); + }); - it('should warn if AWS_ACCESS_KEY_ID is missing', () => { - delete process.env.AWS_ACCESS_KEY_ID; + it('does not warn when disabled, even if required vars are missing', () => { + process.env.S3_ENABLED = 'false'; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } - expect(() => module.onModuleInit()).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_ACCESS_KEY_ID'), - ); - }); + module.onModuleInit(); - it('should warn if AWS_SECRET_ACCESS_KEY is missing', () => { - delete process.env.AWS_SECRET_ACCESS_KEY; + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('S3 disabled'), + ); + }); - expect(() => module.onModuleInit()).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_SECRET_ACCESS_KEY'), - ); - }); + it('does not warn when S3_ENABLED is unset', () => { + delete process.env.S3_ENABLED; + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + + module.onModuleInit(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('S3 disabled'), + ); + }); - it('should warn if an env var is whitespace-only', () => { - process.env.AWS_ACCESS_KEY_ID = ' '; + it.each(REQUIRED_WHEN_ENABLED)( + 'warns when enabled and %s is missing', + (name) => { + delete process.env[name]; - expect(() => module.onModuleInit()).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_ACCESS_KEY_ID'), + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(name)); + }, ); - }); - it('should list every missing env var in a single warning', () => { - delete process.env.AWS_ACCESS_KEY_ID; - delete process.env.AWS_SECRET_ACCESS_KEY; + it('warns when enabled and a required var is empty/whitespace-only', () => { + process.env.AWS_ACCESS_KEY_ID = ' '; - module.onModuleInit(); + expect(() => module.onModuleInit()).not.toThrow(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_ACCESS_KEY_ID'), + ); + }); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY'), - ); + it('lists every missing env var in a single warning', () => { + for (const name of REQUIRED_WHEN_ENABLED) { + delete process.env[name]; + } + + module.onModuleInit(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY'), + ); + }); }); }); From ba32e1440f575acdc48727e6b48a65e16d2a291d Mon Sep 17 00:00:00 2001 From: chnnick Date: Sat, 5 Sep 2026 01:09:56 -0400 Subject: [PATCH 18/20] utils function for getMissingEnvVars, added 140-cognito's isNonEmptyEnv --- apps/backend/src/aws/s3/aws-s3.module.ts | 7 ++----- apps/backend/src/aws/ses/email.module.ts | 7 ++----- apps/backend/src/utils/env.ts | 25 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/utils/env.ts diff --git a/apps/backend/src/aws/s3/aws-s3.module.ts b/apps/backend/src/aws/s3/aws-s3.module.ts index 7b3a3dd0e..907ef2ea8 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.ts @@ -1,5 +1,6 @@ import { Global, Logger, Module, OnModuleInit } from '@nestjs/common'; import { AWSS3Service } from './aws-s3.service'; +import { getMissingEnvVars } from '../../utils/env'; // Env vars required only when S3 is enabled (S3_ENABLED === 'true'). // The credentials are the shared AWS ones (also used by the SES module). @@ -27,11 +28,7 @@ export class AWSS3Module implements OnModuleInit { return; } - // Treat unset and empty/whitespace-only values as missing. - const missing = REQUIRED_ENV_VARS_WHEN_ENABLED.filter((name) => { - const value = process.env[name]; - return !value || value.trim().length === 0; - }); + const missing = getMissingEnvVars(REQUIRED_ENV_VARS_WHEN_ENABLED); if (missing.length > 0) { this.logger.warn( diff --git a/apps/backend/src/aws/ses/email.module.ts b/apps/backend/src/aws/ses/email.module.ts index 35f0887f6..68ab43aa4 100644 --- a/apps/backend/src/aws/ses/email.module.ts +++ b/apps/backend/src/aws/ses/email.module.ts @@ -2,6 +2,7 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { EmailsService } from './email.service'; import { AmazonSESWrapper } from './awsSes.wrapper'; import { AmazonSESClientFactory } from './awsSesClient.factory'; +import { getMissingEnvVars } from '../../utils/env'; // Env vars required only when SES dispatch is enabled (SEND_AUTOMATED_EMAILS === 'true'). // AWS_REGION and the credentials are the shared AWS ones (also used by the S3 module); @@ -30,11 +31,7 @@ export class AWSSESModule implements OnModuleInit { return; } - // Treat unset and empty/whitespace-only values as missing. - const missing = REQUIRED_ENV_VARS_WHEN_ENABLED.filter((name) => { - const value = process.env[name]; - return !value || value.trim().length === 0; - }); + const missing = getMissingEnvVars(REQUIRED_ENV_VARS_WHEN_ENABLED); if (missing.length > 0) { this.logger.warn( diff --git a/apps/backend/src/utils/env.ts b/apps/backend/src/utils/env.ts new file mode 100644 index 000000000..a2211fc07 --- /dev/null +++ b/apps/backend/src/utils/env.ts @@ -0,0 +1,25 @@ +/** + * Checks whether an environment variable value is a defined, non-empty string. + * + * @param value The environment variable value to check. + * @returns `true` if the value is a defined, non-empty string; `false` otherwise. + * + * Also tells TypeScript that provided `value` is a `string` if the function returns true. + */ +export function isNonEmptyEnv(value: string | undefined): value is string { + if (value === undefined) { + return false; + } + return value.trim() !== ''; +} + +/** + * Finds which of the given environment variables are not usably set. + * Unset and empty/whitespace-only values both count as missing. + * + * @param names The environment variable names to check. + * @returns The names that are missing, in the order they were given. + */ +export function getMissingEnvVars(names: readonly string[]): string[] { + return names.filter((name) => !isNonEmptyEnv(process.env[name])); +} From 25cda1857c23bf450b7fc130b529d7d292b5daa3 Mon Sep 17 00:00:00 2001 From: chnnick Date: Sat, 5 Sep 2026 01:20:43 -0400 Subject: [PATCH 19/20] comment update don't reference other modules --- apps/backend/src/aws/s3/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/aws/s3/README.md b/apps/backend/src/aws/s3/README.md index 1990ef9ba..367078c71 100644 --- a/apps/backend/src/aws/s3/README.md +++ b/apps/backend/src/aws/s3/README.md @@ -23,7 +23,7 @@ Every bucket env var **must** follow the `AWS__BUCKET_NAME` format, wher ## `S3_ENABLED` -`S3_ENABLED` gates the startup config check, mirroring `SEND_AUTOMATED_EMAILS` in the SES module. When it is anything other than `'true'` (case-insensitive), `AWSS3Module.onModuleInit` skips validation entirely and logs `S3 disabled: …`, so a project that doesn't use S3 boots without AWS config and without a warning on every startup. +`S3_ENABLED` gates the startup config check. When it is anything other than `'true'` (case-insensitive), `AWSS3Module.onModuleInit` skips validation entirely and logs `S3 disabled: …`, so a project that doesn't use S3 boots without AWS config and without a warning on every startup. When it is `'true'`, `onModuleInit` logs a warning listing any env var in its `REQUIRED_ENV_VARS_WHEN_ENABLED` list that is unset or blank. From 5df70a8607df694953268432d564b52c0734fa74 Mon Sep 17 00:00:00 2001 From: chnnick Date: Sat, 5 Sep 2026 01:21:50 -0400 Subject: [PATCH 20/20] change SEND_AUTOMATED_EMAILS name to SES_ENABLED --- apps/backend/src/aws/s3/README.md | 2 +- apps/backend/src/aws/ses/README.md | 6 ++--- apps/backend/src/aws/ses/awsSes.wrapper.ts | 2 +- .../src/aws/ses/awsSesClient.factory.ts | 6 ++--- apps/backend/src/aws/ses/email.module.spec.ts | 10 ++++---- apps/backend/src/aws/ses/email.module.ts | 6 ++--- .../backend/src/aws/ses/email.service.spec.ts | 24 +++++++++---------- apps/backend/src/aws/ses/email.service.ts | 6 ++--- example.env | 4 ++-- 9 files changed, 33 insertions(+), 33 deletions(-) diff --git a/apps/backend/src/aws/s3/README.md b/apps/backend/src/aws/s3/README.md index 367078c71..e3ab1d252 100644 --- a/apps/backend/src/aws/s3/README.md +++ b/apps/backend/src/aws/s3/README.md @@ -23,7 +23,7 @@ Every bucket env var **must** follow the `AWS__BUCKET_NAME` format, wher ## `S3_ENABLED` -`S3_ENABLED` gates the startup config check. When it is anything other than `'true'` (case-insensitive), `AWSS3Module.onModuleInit` skips validation entirely and logs `S3 disabled: …`, so a project that doesn't use S3 boots without AWS config and without a warning on every startup. +`S3_ENABLED` gates the startup config check.SES_ENABLED When it is anything other than `'true'` (case-insensitive), `AWSS3Module.onModuleInit` skips validation entirely and logs `S3 disabled: …`, so a project that doesn't use S3 boots without AWS config and without a warning on every startup. When it is `'true'`, `onModuleInit` logs a warning listing any env var in its `REQUIRED_ENV_VARS_WHEN_ENABLED` list that is unset or blank. diff --git a/apps/backend/src/aws/ses/README.md b/apps/backend/src/aws/ses/README.md index 999362617..cd512b728 100644 --- a/apps/backend/src/aws/ses/README.md +++ b/apps/backend/src/aws/ses/README.md @@ -58,9 +58,9 @@ To verify a sender: If you swap `AWS_SES_SENDER_EMAIL` later, the new address must be verified separately — verification is per-identity, not per-account. -## `SEND_AUTOMATED_EMAILS` flag +## `SES_ENABLED` flag 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 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. +- When `SES_ENABLED === '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 `SES_ENABLED` is unset or any other value: `sendEmail` still runs DTO validation (so a bad payload still throws), then logs a warning (`SES_ENABLED 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 d0caf79dc..bdee97d7a 100644 --- a/apps/backend/src/aws/ses/awsSes.wrapper.ts +++ b/apps/backend/src/aws/ses/awsSes.wrapper.ts @@ -31,7 +31,7 @@ export class AmazonSESWrapper { */ async sendEmail(dto: SendEmailDTO): Promise { // 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. + // sendEmail is only ever reached when SES_ENABLED is 'true', so senderEmail is expected to be present here. const senderEmail = process.env.AWS_SES_SENDER_EMAIL ?? ''; const mailOptions: Mail.Options = { diff --git a/apps/backend/src/aws/ses/awsSesClient.factory.ts b/apps/backend/src/aws/ses/awsSesClient.factory.ts index 1f4de8c3f..183a43451 100644 --- a/apps/backend/src/aws/ses/awsSesClient.factory.ts +++ b/apps/backend/src/aws/ses/awsSesClient.factory.ts @@ -11,12 +11,12 @@ export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT'; export const AmazonSESClientFactory: Provider = { provide: AMAZON_SES_CLIENT, useFactory: () => { - // Create dummy client that is NOT used when email sending is unset or 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.SES_ENABLED?.toLowerCase() !== 'true') { return new SESv2Client({}); } - // If email sending is enabled, AWSSESModule.onModuleInit() warns when these env vars are missing. + // 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 ?? '', diff --git a/apps/backend/src/aws/ses/email.module.spec.ts b/apps/backend/src/aws/ses/email.module.spec.ts index b33f7c967..570a76916 100644 --- a/apps/backend/src/aws/ses/email.module.spec.ts +++ b/apps/backend/src/aws/ses/email.module.spec.ts @@ -2,7 +2,7 @@ import { AWSSESModule } from './email.module'; describe('AWSSESModule', () => { const ENV_VARS = [ - 'SEND_AUTOMATED_EMAILS', + 'SES_ENABLED', 'AWS_REGION', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', @@ -27,7 +27,7 @@ describe('AWSSESModule', () => { } // Default to a fully-configured, enabled setup; individual tests override. - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = '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'; @@ -64,7 +64,7 @@ describe('AWSSESModule', () => { }); it('does not warn when disabled, even if required vars are missing', () => { - process.env.SEND_AUTOMATED_EMAILS = 'false'; + process.env.SES_ENABLED = 'false'; for (const name of REQUIRED_WHEN_ENABLED) { delete process.env[name]; } @@ -77,8 +77,8 @@ describe('AWSSESModule', () => { ); }); - it('does not warn when SEND_AUTOMATED_EMAILS is unset', () => { - delete process.env.SEND_AUTOMATED_EMAILS; + it('does not warn when SES_ENABLED is unset', () => { + delete process.env.SES_ENABLED; for (const name of REQUIRED_WHEN_ENABLED) { delete process.env[name]; } diff --git a/apps/backend/src/aws/ses/email.module.ts b/apps/backend/src/aws/ses/email.module.ts index 68ab43aa4..48a7e9451 100644 --- a/apps/backend/src/aws/ses/email.module.ts +++ b/apps/backend/src/aws/ses/email.module.ts @@ -4,7 +4,7 @@ import { AmazonSESWrapper } from './awsSes.wrapper'; import { AmazonSESClientFactory } from './awsSesClient.factory'; import { getMissingEnvVars } from '../../utils/env'; -// Env vars required only when SES dispatch is enabled (SEND_AUTOMATED_EMAILS === 'true'). +// Env vars required only when SES dispatch is enabled (SES_ENABLED === 'true'). // AWS_REGION and the credentials are the shared AWS ones (also used by the S3 module); // only AWS_SES_SENDER_EMAIL is specific to SES. const REQUIRED_ENV_VARS_WHEN_ENABLED = [ @@ -24,9 +24,9 @@ export class AWSSESModule 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') { + if (process.env.SES_ENABLED?.toLowerCase() !== 'true') { this.logger.log( - 'SES disabled: SEND_AUTOMATED_EMAILS is not "true". No emails will be sent.', + 'SES disabled: SES_ENABLED is not "true". No emails will be sent.', ); return; } diff --git a/apps/backend/src/aws/ses/email.service.spec.ts b/apps/backend/src/aws/ses/email.service.spec.ts index a094500d4..a7ebc0674 100644 --- a/apps/backend/src/aws/ses/email.service.spec.ts +++ b/apps/backend/src/aws/ses/email.service.spec.ts @@ -8,7 +8,7 @@ describe('EmailsService', () => { let service: EmailsService; let mockWrapper: { sendEmail: jest.Mock }; - const originalSendFlag = process.env.SEND_AUTOMATED_EMAILS; + const originalSendFlag = process.env.SES_ENABLED; const validDto: SendEmailDTO = { toEmail: 'recipient@example.com', @@ -36,15 +36,15 @@ describe('EmailsService', () => { afterEach(() => { if (originalSendFlag === undefined) { - delete process.env.SEND_AUTOMATED_EMAILS; + delete process.env.SES_ENABLED; } else { - process.env.SEND_AUTOMATED_EMAILS = originalSendFlag; + process.env.SES_ENABLED = originalSendFlag; } }); describe('sendEmail', () => { - it('does not call the wrapper when SEND_AUTOMATED_EMAILS is not "true"', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'false'; + it('does not call the wrapper when SES_ENABLED is not "true"', async () => { + process.env.SES_ENABLED = 'false'; const result = await service.sendEmail(validDto); @@ -53,7 +53,7 @@ describe('EmailsService', () => { }); it('rate-limits sends to roughly 14 per second', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockResolvedValue(successOutput); const calls = 10; @@ -74,7 +74,7 @@ describe('EmailsService', () => { }); it('rejects without calling the wrapper when the DTO is invalid', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; const invalidDto: SendEmailDTO = { toEmail: 'not-a-real-email', @@ -87,7 +87,7 @@ describe('EmailsService', () => { }); it('returns the wrapper output when sending succeeds', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockResolvedValue(successOutput); const result = await service.sendEmail(validDto); @@ -97,7 +97,7 @@ describe('EmailsService', () => { }); it('propagates errors thrown by the wrapper', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockRejectedValue( new Error('SES rejected: throttled'), ); @@ -109,7 +109,7 @@ describe('EmailsService', () => { }); it('passes ccEmails and bccEmails through to the wrapper', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; mockWrapper.sendEmail.mockResolvedValue(successOutput); const dto: SendEmailDTO = { @@ -132,7 +132,7 @@ describe('EmailsService', () => { }); it('rejects when ccEmails contains an invalid address', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; const dto: SendEmailDTO = { toEmail: 'recipient@example.com', @@ -146,7 +146,7 @@ describe('EmailsService', () => { }); it('rejects when bccEmails contains an invalid address', async () => { - process.env.SEND_AUTOMATED_EMAILS = 'true'; + process.env.SES_ENABLED = 'true'; const dto: SendEmailDTO = { toEmail: 'recipient@example.com', diff --git a/apps/backend/src/aws/ses/email.service.ts b/apps/backend/src/aws/ses/email.service.ts index 8d68f0811..cb42cc4eb 100644 --- a/apps/backend/src/aws/ses/email.service.ts +++ b/apps/backend/src/aws/ses/email.service.ts @@ -31,7 +31,7 @@ export class EmailsService { * etc.) causes the method to reject with a ValidationError[] before any * SES request is made. * - * Sending is skipped (with a warning) when SEND_AUTOMATED_EMAILS is not + * Sending is skipped (with a warning) when SES_ENABLED is not * set to 'true'. * * @param dto the email payload - validated against SendEmailDTO's decorators @@ -49,9 +49,9 @@ export class EmailsService { const validated = plainToInstance(SendEmailDTO, dto); await validateOrReject(validated); - if (process.env.SEND_AUTOMATED_EMAILS?.toLowerCase() !== 'true') { + if (process.env.SES_ENABLED?.toLowerCase() !== 'true') { this.logger.log( - 'SES disabled: SEND_AUTOMATED_EMAILS is not "true". No emails will be sent.', + 'SES disabled: SES_ENABLED is not "true". No emails will be sent.', ); return; } diff --git a/example.env b/example.env index 8d4407ac3..ed758c708 100644 --- a/example.env +++ b/example.env @@ -25,9 +25,9 @@ S3_ENABLED=false # AWS_MY_BUCKET_NAME_2=my-bucket-name2 # --- AWS SES --- -# Set SEND_AUTOMATED_EMAILS=true to enable sends; the vars above plus the sender +# Set SES_ENABLED=true to enable sends; the vars above plus the sender # are only required when it is true (see apps/backend/src/aws/ses/README.md). -SEND_AUTOMATED_EMAILS=false +SES_ENABLED=false AWS_SES_SENDER_EMAIL='example@example.com'