Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/api/src/config/db.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Expand Down
72 changes: 2 additions & 70 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand All @@ -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];

Expand All @@ -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"),
Expand All @@ -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),
Expand Down
96 changes: 96 additions & 0 deletions apps/api/src/config/runtime-env.ts
Original file line number Diff line number Diff line change
@@ -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;

4 changes: 2 additions & 2 deletions apps/api/src/services/aws-credentials.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,7 +20,7 @@ type AwsAction = "verify" | "sync";

export const awsCredentialsService = {
buildBaseStsClient(): Pick<STSClient, "send"> {
return new STSClient({ region: env.AWS_REGION });
return new STSClient({ region: runtimeEnv.AWS_REGION });
},

buildSessionName(action: AwsAction): string {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/services/aws-role.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<STSClient, "send"> {
return new STSClient({ region: env.AWS_REGION, credentials });
return new STSClient({ region: runtimeEnv.AWS_REGION, credentials });
},

async verifyConnection(account: AwsAccount): Promise<void> {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/services/cost-explorer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,7 +21,7 @@ export const costExplorerService = {
buildCostExplorerClient(
credentials: AssumedRoleSession["credentials"],
): Pick<CostExplorerClient, "send"> {
return new CostExplorerClient({ region: env.AWS_REGION, credentials });
return new CostExplorerClient({ region: runtimeEnv.AWS_REGION, credentials });
},

async fetchCostData(
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/services/cost.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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),
Expand Down
12 changes: 6 additions & 6 deletions apps/api/src/services/email.service.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
Expand Down
22 changes: 13 additions & 9 deletions apps/api/src/test/scheduled-cost-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand All @@ -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 },
Expand Down
Loading