diff --git a/apps/api/src/config/db.ts b/apps/api/src/config/db.ts index 8d1acbe..fcf8653 100644 --- a/apps/api/src/config/db.ts +++ b/apps/api/src/config/db.ts @@ -1,14 +1,14 @@ import pg from "pg"; -import { env } from "./env.js"; +import { runtimeEnv } from "./runtime-env.js"; const { Pool } = pg; export const pool = new Pool({ - connectionString: env.DATABASE_URL, - ssl: env.DATABASE_SSL_ENABLED + connectionString: runtimeEnv.DATABASE_URL, + ssl: runtimeEnv.DATABASE_SSL_ENABLED ? { - rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED, + rejectUnauthorized: runtimeEnv.DATABASE_SSL_REJECT_UNAUTHORIZED, } : undefined, }); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 07b2ea5..e54d77b 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -1,14 +1,9 @@ -import dotenv from "dotenv"; - import { DEFAULT_ACCESS_TOKEN_EXPIRES_IN, DEFAULT_PASSWORD_RESET_EXPIRES_IN_MINUTES, DEFAULT_REFRESH_TOKEN_EXPIRES_IN, } from "../constants/auth.constants.js"; - -dotenv.config(); - -type NodeEnvironment = "development" | "test" | "production"; +import { runtimeEnv } from "./runtime-env.js"; const getRequiredEnv = (key: string): string => { const value = process.env[key]; @@ -20,32 +15,6 @@ const getRequiredEnv = (key: string): string => { return value; }; -const getNodeEnv = (): NodeEnvironment => { - const value = process.env.NODE_ENV; - - if (value === "production" || value === "test") { - return value; - } - - return "development"; -}; - -const parsePort = (): number => { - const value = process.env.PORT; - - if (!value) { - return 3080; - } - - const parsed = Number.parseInt(value, 10); - - if (Number.isNaN(parsed)) { - throw new Error("PORT must be a valid number"); - } - - return parsed; -}; - const parseInteger = (key: string, fallback: number): number => { const value = process.env[key]; @@ -62,33 +31,8 @@ const parseInteger = (key: string, fallback: number): number => { return parsed; }; -const parseBoolean = (key: string, fallback: boolean): boolean => { - const value = process.env[key]; - - if (!value) { - return fallback; - } - - if (value === "true") { - return true; - } - - if (value === "false") { - return false; - } - - throw new Error(`${key} must be either "true" or "false"`); -}; - export const env = { - NODE_ENV: getNodeEnv(), - PORT: parsePort(), - DATABASE_URL: getRequiredEnv("DATABASE_URL"), - DATABASE_SSL_ENABLED: parseBoolean("DATABASE_SSL_ENABLED", false), - DATABASE_SSL_REJECT_UNAUTHORIZED: parseBoolean( - "DATABASE_SSL_REJECT_UNAUTHORIZED", - false, - ), + ...runtimeEnv, CSRF_SECRET: getRequiredEnv("CSRF_SECRET"), JWT_ACCESS_SECRET: getRequiredEnv("JWT_ACCESS_SECRET"), JWT_REFRESH_SECRET: getRequiredEnv("JWT_REFRESH_SECRET"), @@ -101,26 +45,14 @@ export const env = { STRIPE_SUCCESS_URL: process.env.STRIPE_SUCCESS_URL ?? "", STRIPE_CANCEL_URL: process.env.STRIPE_CANCEL_URL ?? "", BILLING_ENABLED: process.env.BILLING_ENABLED === "true", - AWS_REGION: process.env.AWS_REGION ?? "us-west-2", - COST_SYNC_LOOKBACK_DAYS: Number.parseInt( - process.env.COST_SYNC_LOOKBACK_DAYS ?? "30", - 10, - ), - ALERT_EMAIL_FROM: process.env.ALERT_EMAIL_FROM ?? "alerts@example.com", - EMAIL_PROVIDER: process.env.EMAIL_PROVIDER ?? "console", CLIENT_URL: process.env.CLIENT_URL ?? "http://localhost:5174", PASSWORD_RESET_EXPIRES_IN_MINUTES: Number.parseInt( process.env.PASSWORD_RESET_EXPIRES_IN_MINUTES ?? String(DEFAULT_PASSWORD_RESET_EXPIRES_IN_MINUTES), 10, ), - AWS_SES_REGION: process.env.AWS_SES_REGION ?? process.env.AWS_REGION ?? "us-west-2", - AWS_SES_ACCESS_KEY_ID: process.env.AWS_SES_ACCESS_KEY_ID ?? "", - AWS_SES_SECRET_ACCESS_KEY: process.env.AWS_SES_SECRET_ACCESS_KEY ?? "", - AUTH_EMAIL_FROM: process.env.AUTH_EMAIL_FROM ?? "auth@example.com", AUTH_COOKIE_DOMAIN: process.env.AUTH_COOKIE_DOMAIN ?? "", AUTH_COOKIE_SAME_SITE: process.env.AUTH_COOKIE_SAME_SITE ?? "lax", - LOG_LEVEL: process.env.LOG_LEVEL ?? "info", RATE_LIMIT_AUTH_WINDOW_MS: parseInteger("RATE_LIMIT_AUTH_WINDOW_MS", 60_000), RATE_LIMIT_AUTH_MAX_REQUESTS: parseInteger("RATE_LIMIT_AUTH_MAX_REQUESTS", 10), RATE_LIMIT_MUTATION_WINDOW_MS: parseInteger("RATE_LIMIT_MUTATION_WINDOW_MS", 60_000), diff --git a/apps/api/src/config/runtime-env.ts b/apps/api/src/config/runtime-env.ts new file mode 100644 index 0000000..afc0e48 --- /dev/null +++ b/apps/api/src/config/runtime-env.ts @@ -0,0 +1,96 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +type NodeEnvironment = "development" | "test" | "production"; + +const getRequiredEnv = (key: string): string => { + const value = process.env[key]; + + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + + return value; +}; + +const getNodeEnv = (): NodeEnvironment => { + const value = process.env.NODE_ENV; + + if (value === "production" || value === "test") { + return value; + } + + return "development"; +}; + +const parsePort = (): number => { + const value = process.env.PORT; + + if (!value) { + return 3080; + } + + const parsed = Number.parseInt(value, 10); + + if (Number.isNaN(parsed)) { + throw new Error("PORT must be a valid number"); + } + + return parsed; +}; + +const parseInteger = (key: string, fallback: number): number => { + const value = process.env[key]; + + if (!value) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + + if (Number.isNaN(parsed)) { + throw new Error(`${key} must be a valid number`); + } + + return parsed; +}; + +const parseBoolean = (key: string, fallback: boolean): boolean => { + const value = process.env[key]; + + if (!value) { + return fallback; + } + + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + throw new Error(`${key} must be either "true" or "false"`); +}; + +export const runtimeEnv = { + NODE_ENV: getNodeEnv(), + PORT: parsePort(), + DATABASE_URL: getRequiredEnv("DATABASE_URL"), + DATABASE_SSL_ENABLED: parseBoolean("DATABASE_SSL_ENABLED", false), + DATABASE_SSL_REJECT_UNAUTHORIZED: parseBoolean( + "DATABASE_SSL_REJECT_UNAUTHORIZED", + false, + ), + AWS_REGION: process.env.AWS_REGION ?? "us-west-2", + AWS_SES_REGION: process.env.AWS_SES_REGION ?? process.env.AWS_REGION ?? "us-west-2", + AWS_SES_ACCESS_KEY_ID: process.env.AWS_SES_ACCESS_KEY_ID ?? "", + AWS_SES_SECRET_ACCESS_KEY: process.env.AWS_SES_SECRET_ACCESS_KEY ?? "", + COST_SYNC_LOOKBACK_DAYS: parseInteger("COST_SYNC_LOOKBACK_DAYS", 30), + ALERT_EMAIL_FROM: process.env.ALERT_EMAIL_FROM ?? "alerts@example.com", + EMAIL_PROVIDER: process.env.EMAIL_PROVIDER ?? "console", + AUTH_EMAIL_FROM: process.env.AUTH_EMAIL_FROM ?? "auth@example.com", + LOG_LEVEL: process.env.LOG_LEVEL ?? "info", +} as const; + diff --git a/apps/api/src/services/aws-credentials.service.ts b/apps/api/src/services/aws-credentials.service.ts index 4d99865..b4292d5 100644 --- a/apps/api/src/services/aws-credentials.service.ts +++ b/apps/api/src/services/aws-credentials.service.ts @@ -1,6 +1,6 @@ import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; -import { env } from "../config/env.js"; +import { runtimeEnv } from "../config/runtime-env.js"; import type { AwsAccount } from "../types/aws-account.types.js"; import { AppError } from "../utils/app-error.js"; import { toAssumeRoleError } from "../utils/aws-account.js"; @@ -20,7 +20,7 @@ type AwsAction = "verify" | "sync"; export const awsCredentialsService = { buildBaseStsClient(): Pick { - return new STSClient({ region: env.AWS_REGION }); + return new STSClient({ region: runtimeEnv.AWS_REGION }); }, buildSessionName(action: AwsAction): string { diff --git a/apps/api/src/services/aws-role.service.ts b/apps/api/src/services/aws-role.service.ts index 9b94e46..88e432c 100644 --- a/apps/api/src/services/aws-role.service.ts +++ b/apps/api/src/services/aws-role.service.ts @@ -3,14 +3,14 @@ import { STSClient, } from "@aws-sdk/client-sts"; -import { env } from "../config/env.js"; +import { runtimeEnv } from "../config/runtime-env.js"; import type { AwsAccount } from "../types/aws-account.types.js"; import { AppError } from "../utils/app-error.js"; import { awsCredentialsService, type AssumedRoleSession } from "./aws-credentials.service.js"; export const awsRoleService = { buildStsClient(credentials: AssumedRoleSession["credentials"]): Pick { - return new STSClient({ region: env.AWS_REGION, credentials }); + return new STSClient({ region: runtimeEnv.AWS_REGION, credentials }); }, async verifyConnection(account: AwsAccount): Promise { diff --git a/apps/api/src/services/cost-explorer.service.ts b/apps/api/src/services/cost-explorer.service.ts index 65c6595..c339450 100644 --- a/apps/api/src/services/cost-explorer.service.ts +++ b/apps/api/src/services/cost-explorer.service.ts @@ -5,7 +5,7 @@ import { GetCostAndUsageCommand, } from "@aws-sdk/client-cost-explorer"; -import { env } from "../config/env.js"; +import { runtimeEnv } from "../config/runtime-env.js"; import type { AwsAccount } from "../types/aws-account.types.js"; import { toCostExplorerError } from "../utils/aws-account.js"; import { awsCredentialsService, type AssumedRoleSession } from "./aws-credentials.service.js"; @@ -21,7 +21,7 @@ export const costExplorerService = { buildCostExplorerClient( credentials: AssumedRoleSession["credentials"], ): Pick { - return new CostExplorerClient({ region: env.AWS_REGION, credentials }); + return new CostExplorerClient({ region: runtimeEnv.AWS_REGION, credentials }); }, async fetchCostData( diff --git a/apps/api/src/services/cost.service.ts b/apps/api/src/services/cost.service.ts index d376e19..13cf67b 100644 --- a/apps/api/src/services/cost.service.ts +++ b/apps/api/src/services/cost.service.ts @@ -1,4 +1,4 @@ -import { env } from "../config/env.js"; +import { runtimeEnv } from "../config/runtime-env.js"; import { logger } from "../lib/logger.js"; import { awsAccountRepository } from "../repositories/aws-account.repository.js"; import { costRepository } from "../repositories/cost.repository.js"; @@ -20,7 +20,7 @@ import { workspaceService } from "./workspace.service.js"; const defaultDateRange = (): { from: string; to: string } => { const end = new Date(); const start = new Date(); - start.setDate(end.getDate() - env.COST_SYNC_LOOKBACK_DAYS); + start.setDate(end.getDate() - runtimeEnv.COST_SYNC_LOOKBACK_DAYS); return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10), diff --git a/apps/api/src/services/email.service.ts b/apps/api/src/services/email.service.ts index 55237b6..dcdab9c 100644 --- a/apps/api/src/services/email.service.ts +++ b/apps/api/src/services/email.service.ts @@ -1,6 +1,6 @@ import { SendEmailCommand, SESClient } from "@aws-sdk/client-ses"; -import { env } from "../config/env.js"; +import { runtimeEnv } from "../config/runtime-env.js"; let sesClient: SESClient | null = null; @@ -10,16 +10,16 @@ const getSesClient = (): SESClient => { } sesClient = - env.AWS_SES_ACCESS_KEY_ID && env.AWS_SES_SECRET_ACCESS_KEY + runtimeEnv.AWS_SES_ACCESS_KEY_ID && runtimeEnv.AWS_SES_SECRET_ACCESS_KEY ? new SESClient({ - region: env.AWS_SES_REGION, + region: runtimeEnv.AWS_SES_REGION, credentials: { - accessKeyId: env.AWS_SES_ACCESS_KEY_ID, - secretAccessKey: env.AWS_SES_SECRET_ACCESS_KEY, + accessKeyId: runtimeEnv.AWS_SES_ACCESS_KEY_ID, + secretAccessKey: runtimeEnv.AWS_SES_SECRET_ACCESS_KEY, }, }) : new SESClient({ - region: env.AWS_SES_REGION, + region: runtimeEnv.AWS_SES_REGION, }); return sesClient; diff --git a/apps/api/src/test/scheduled-cost-sync.test.ts b/apps/api/src/test/scheduled-cost-sync.test.ts index 090bd28..87b357b 100644 --- a/apps/api/src/test/scheduled-cost-sync.test.ts +++ b/apps/api/src/test/scheduled-cost-sync.test.ts @@ -1,18 +1,22 @@ import assert from "node:assert/strict"; import test from "node:test"; -const ensureTestEnv = (): void => { +const ensureScheduledRuntimeEnv = (): void => { process.env.NODE_ENV = "test"; process.env.DATABASE_URL ??= "postgresql://postgres:postgres@localhost:5432/underflow_test"; - process.env.CSRF_SECRET ??= "test-csrf-secret"; - process.env.JWT_ACCESS_SECRET ??= "test-access-secret"; - process.env.JWT_REFRESH_SECRET ??= "test-refresh-secret"; - process.env.CLIENT_URL ??= "http://localhost:5174"; + process.env.DATABASE_SSL_ENABLED ??= "false"; + process.env.DATABASE_SSL_REJECT_UNAUTHORIZED ??= "false"; + process.env.AWS_SES_REGION ??= "us-west-2"; + process.env.COST_SYNC_LOOKBACK_DAYS ??= "30"; + process.env.LOG_LEVEL ??= "info"; + delete process.env.CSRF_SECRET; + delete process.env.JWT_ACCESS_SECRET; + delete process.env.JWT_REFRESH_SECRET; }; test("scheduled cost sync runner connects to the database and returns sync summary", async () => { - ensureTestEnv(); + ensureScheduledRuntimeEnv(); const { runScheduledCostSync, scheduledCostSyncDependencies } = await import( "../jobs/scheduled-cost-sync.js" @@ -48,7 +52,7 @@ test("scheduled cost sync runner connects to the database and returns sync summa }); test("scheduled cost sync Lambda handler returns a 200 with the sync summary body", async () => { - ensureTestEnv(); + ensureScheduledRuntimeEnv(); const [{ handler }, { scheduledCostSyncDependencies }] = await Promise.all([ import("../jobs/scheduled-cost-sync.lambda.js"), @@ -82,7 +86,7 @@ test("scheduled cost sync Lambda handler returns a 200 with the sync summary bod }); test("scheduled cost sync Lambda handler surfaces failures", async () => { - ensureTestEnv(); + ensureScheduledRuntimeEnv(); const [{ handler }, { scheduledCostSyncDependencies }] = await Promise.all([ import("../jobs/scheduled-cost-sync.lambda.js"), @@ -106,7 +110,7 @@ test("scheduled cost sync Lambda handler surfaces failures", async () => { }); test("syncAllVerifiedAccounts returns per-account summary counts", async () => { - ensureTestEnv(); + ensureScheduledRuntimeEnv(); const [ { costService },