diff --git a/.env.example b/.env.example index 3c09ca2..c4119c9 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,23 @@ +APP_NAME="Auth API" +APP_URL="http://localhost:3000" +APP_KEY="change-me" NODE_ENV="local" -APP_NAME="Node Starter API" -PORT="3000" -APP_URL="http://127.0.0.1:3000" -APP_KEY= - -JWT_ACCESS_SECRET= -JWT_REFRESH_SECRET= - -DATABASE_URL="postgresql://postgres:@localhost:5432/DATABASE?schema=public" +PORT=3000 +LOG_LEVEL="info" +DATABASE_URL="postgresql://user:password@localhost:5432/dbname" REDIS_URL="redis://localhost:6379" +GOOGLE_CLIENT_ID="your-google-client-id" +JWT_ACCESS_SECRET="change-me" +JWT_REFRESH_SECRET="change-me" JWT_ACCESS_EXPIRES_IN="120m" JWT_REFRESH_EXPIRES_IN="7d" -MAIL_HOST=0.0.0.0 -MAIL_PORT=1025 -MAIL_FROM="API Demo " +SWAGGER_ENABLED=false -GOOGLE_CLIENT_ID= - -SWAGGER_ENABLED= \ No newline at end of file +MAIL_HOST="localhost" +MAIL_PORT=1025 +MAIL_USERNAME="" +MAIL_PASSWORD="" +MAIL_FROM="noreply@example.com" diff --git a/README.md b/README.md index 369964f..b604b3c 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,61 @@ ## Environment Config -API is configured to use PostgreSQL. +API is configured to use PostgreSQL. Copy `.env.example` to `.env` and adjust values for your environment. + +| Variable | Description | Default | +| --- | --- | --- | +| `APP_NAME` | Display name for docs and emails | `Auth API` | +| `APP_URL` | Base URL used in emails and Swagger docs | Required | +| `APP_KEY` | Application signing key for verification tokens | Required | +| `NODE_ENV` | Runtime environment (`local`, `development`, `test`, `production`) | `local` | +| `PORT` | Port the API listens on | `3000` | +| `LOG_LEVEL` | Pino log level | `info` | +| `DATABASE_URL` | PostgreSQL connection string | Required | +| `REDIS_URL` | Redis connection string | `redis://localhost:6379` | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | Required | +| `JWT_ACCESS_SECRET` | JWT access token secret | Required | +| `JWT_REFRESH_SECRET` | JWT refresh token secret | Required | +| `JWT_ACCESS_EXPIRES_IN` | Access token TTL (e.g. `120m`) | `120m` | +| `JWT_REFRESH_EXPIRES_IN` | Refresh token TTL (e.g. `7d`) | `7d` | +| `SWAGGER_ENABLED` | Enable Swagger docs | `false` | +| `MAIL_HOST` | SMTP host | `localhost` | +| `MAIL_PORT` | SMTP port | `1025` | +| `MAIL_USERNAME` | SMTP username | empty | +| `MAIL_PASSWORD` | SMTP password | empty | +| `MAIL_FROM` | Default sender email | `noreply@example.com` | + +### Example `.env` + +```env +APP_NAME="Auth API" +APP_URL="http://localhost:3000" +APP_KEY="change-me" +NODE_ENV="local" +PORT=3000 +LOG_LEVEL="info" + +DATABASE_URL="postgresql://user:password@localhost:5432/dbname" +REDIS_URL="redis://localhost:6379" + +GOOGLE_CLIENT_ID="your-google-client-id" +JWT_ACCESS_SECRET="change-me" +JWT_REFRESH_SECRET="change-me" +JWT_ACCESS_EXPIRES_IN="120m" +JWT_REFRESH_EXPIRES_IN="7d" + +SWAGGER_ENABLED=false + +MAIL_HOST="localhost" +MAIL_PORT=1025 +MAIL_USERNAME="" +MAIL_PASSWORD="" +MAIL_FROM="noreply@example.com" +``` ## Setup ```bash - cp .env.example .env # Install JavaScript dependencies @@ -53,4 +102,4 @@ To test Google Auth Flow, use the following: 4. Top right, click Settings -> Use your own OAuth credentials 5. Enter Client ID and Client Secret 6. Click Authorize APIs and then Exchange authorization code for tokens -7. Copy the id_token and paste it in the request body \ No newline at end of file +7. Copy the id_token and paste it in the request body diff --git a/app.ts b/app.ts index c138b4d..6af504b 100644 --- a/app.ts +++ b/app.ts @@ -5,36 +5,38 @@ import {setupSwagger} from "./src/config/swagger"; import {errorMiddleware} from "./src/middleware/errorMiddleware"; import {pinoHttp} from "pino-http"; import {logger} from "./src/lib/logger"; +import {env} from "./src/utils/environment-variables"; export function createApp() { const app = express(); - const isProd = process.env.NODE_ENV === "production"; - if (isProd) { - app.use( - pinoHttp({ - logger, - autoLogging: { - ignore: (req) => req.url.startsWith("/docs"), - }, - customLogLevel(req, res, err) { - if (res.statusCode >= 500) return "error"; - if (res.statusCode >= 400) return "warn"; - return "info"; - }, - customSuccessMessage(req, res) { - return `${req.method} ${req.url} → ${res.statusCode}`; - }, - redact: { - paths: [ - "req.headers.cookie", - "req.headers.authorization", - ], - remove: true, - }, - }) - ); - } + app.use( + pinoHttp({ + logger, + autoLogging: { + ignore: (req) => req.url.startsWith("/docs"), + }, + customLogLevel(req, res, err) { + if (err || res.statusCode >= 500) return "error"; + if (res.statusCode >= 400) return "warn"; + return "info"; + }, + customSuccessMessage(req, res) { + return `${req.method} ${req.url} → ${res.statusCode}`; + }, + redact: { + paths: [ + "req.headers.cookie", + "req.headers.authorization", + ], + remove: true, + }, + customAttributeKeys: env.NODE_ENV === "production" + ? undefined + : {responseTime: "responseTime"}, + }) + ); + app.use(express.json()); app.use(express.urlencoded({extended: true})); app.use(cookieParser()); diff --git a/server.ts b/server.ts index 037c8b8..69b3e38 100644 --- a/server.ts +++ b/server.ts @@ -3,6 +3,7 @@ import {createApp} from "./app"; import {connectDB, disconnectDB} from "./src/config/db"; import {initRedis} from "./src/config/redis"; import {logger} from "./src/lib/logger"; +import {env} from "./src/utils/environment-variables"; (async () => { try { @@ -10,14 +11,14 @@ import {logger} from "./src/lib/logger"; await initRedis(); const app = createApp(); - const port = process.env.PORT ? Number(process.env.PORT) : 3000; + const port = env.PORT; const server = app.listen(port, () => { logger.info(`🚀 Server running on port ${port}`); }); process.on("unhandledRejection", (err) => { - const isDev = process.env.NODE_ENV === "local"; + const isDev = env.NODE_ENV === "local"; if (isDev) console.error("Unhandled Rejection:", err); server.close(async () => { await disconnectDB(); @@ -25,14 +26,17 @@ import {logger} from "./src/lib/logger"; }); }); - process.on("SIGTERM", () => { + const gracefulShutdown = () => { server.close(async () => { disconnectDB().finally(() => process.exit(0)); }); - }); + }; + + process.on("SIGTERM", gracefulShutdown); + process.on("SIGINT", gracefulShutdown); } catch (err) { - const isDev = process.env.NODE_ENV === "local"; + const isDev = env.NODE_ENV === "local"; if (isDev) console.error("Failed to start server:", err); process.exit(1); } diff --git a/src/config/db.ts b/src/config/db.ts index 0cf28a2..65432cf 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -9,14 +9,9 @@ const schema = isTest ? "test" : "public"; let adapter: PrismaPg; -if (!process.env.DATABASE_URL) { - logger.fatal("[DB] DATABASE_URL is not set"); - process.exit(1); -} - try { adapter = new PrismaPg( - {connectionString: process.env.DATABASE_URL}, + {connectionString: env.DATABASE_URL}, {schema} ); diff --git a/src/config/redis.ts b/src/config/redis.ts index 74eb99f..f514025 100644 --- a/src/config/redis.ts +++ b/src/config/redis.ts @@ -1,8 +1,9 @@ import {createClient} from "redis"; import {logger} from "../lib/logger.js"; +import {env} from "../utils/environment-variables"; export const redis = createClient({ - url: process.env.REDIS_URL ?? "redis://localhost:6379", + url: env.REDIS_URL, }); redis.on("error", (err) => { @@ -30,4 +31,3 @@ export async function initRedis() { process.exit(1); } } - diff --git a/src/config/swagger.ts b/src/config/swagger.ts index ca770a6..e6b6731 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -1,21 +1,21 @@ import swaggerUi from "swagger-ui-express"; import swaggerJsdoc from "swagger-jsdoc"; import {Express, Request, Response} from "express"; +import {env} from "../utils/environment-variables"; export function setupSwagger(app: Express) { - const enabled = (process.env.SWAGGER_ENABLED || "").toLowerCase() === "true"; - if (!enabled) + if (!env.SWAGGER_ENABLED) return; const options = { definition: { openapi: "3.0.3", info: { - title: process.env.APP_NAME ? `${process.env.APP_NAME} API` : "API Docs", + title: env.APP_NAME ? `${env.APP_NAME} API` : "API Docs", version: "0.0.1", description: "API with full Auth pipeline.", }, servers: [ - {url: process.env.APP_URL || `http://localhost:${process.env.PORT || 3000}`}, + {url: env.APP_URL || `http://localhost:${env.PORT}`}, ], components: { securitySchemes: { diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 2bd7df1..69ec30b 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,12 +1,19 @@ import pino from "pino"; +import {env} from "../utils/environment-variables"; + +const isProduction = env.NODE_ENV === "production"; export const logger = pino({ - level: process.env.LOG_LEVEL || "info", - transport: { - target: "pino-pretty", - options: { - colorize: true, - translateTime: "SYS:standard", - }, - }, + level: env.LOG_LEVEL, + ...(isProduction + ? {} + : { + transport: { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "SYS:standard", + }, + }, + }), }); diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts index 919ec41..f4be827 100644 --- a/src/lib/mailer.ts +++ b/src/lib/mailer.ts @@ -1,21 +1,22 @@ import "dotenv/config"; import nodemailer from "nodemailer"; +import {env} from "../utils/environment-variables"; export const mailer = nodemailer.createTransport({ - host: process.env.MAIL_HOST ?? "localhost", - port: Number(process.env.MAIL_PORT) ?? 1025, + host: env.MAIL_HOST, + port: env.MAIL_PORT, secure: false, - auth: process.env.MAIL_USERNAME + auth: env.MAIL_USERNAME ? { - user: process.env.MAIL_USERNAME, - pass: process.env.MAIL_PASSWORD, + user: env.MAIL_USERNAME, + pass: env.MAIL_PASSWORD, } : undefined, }); export async function sendMail(to: string, subject: string, html: string) { await mailer.sendMail({ - from: process.env.MAIL_FROM ?? "noreply@example.com", + from: env.MAIL_FROM, to, subject, html, diff --git a/src/services/auth/RegisterService.ts b/src/services/auth/RegisterService.ts index a4e20bb..2ba5a48 100644 --- a/src/services/auth/RegisterService.ts +++ b/src/services/auth/RegisterService.ts @@ -6,6 +6,7 @@ import {container} from "../../lib/container"; import {buildEmailTemplate, sendMail} from "../../lib/mailer"; import {toSafeUser} from "../../lib/safe-user"; import {EmailTakenError} from "../../lib/errors"; +import {env} from "../../utils/environment-variables"; export class RegisterService { async register(data: { email: string; password: string; first_name: string; last_name: string; }) { @@ -25,7 +26,7 @@ export class RegisterService { include: {profile: true}, }); const verificationToken = container.emailVerificationService.generateVerificationToken(user.id, user.email); - const verificationUrl = `${process.env.APP_URL}/auth/verify-email?token=${verificationToken}`; + const verificationUrl = `${env.APP_URL}/auth/verify-email?token=${verificationToken}`; await sendMail(user.email, "Verify your email", buildEmailTemplate({ name: user.profile?.first_name ?? "there", diff --git a/src/utils/environment-variables.ts b/src/utils/environment-variables.ts index 5bc3eee..a09ff27 100644 --- a/src/utils/environment-variables.ts +++ b/src/utils/environment-variables.ts @@ -1,30 +1,78 @@ -function required(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Environment variable ${name} is missing`); +import {z} from "zod"; + +export class EnvValidationError extends Error { + readonly status = 500; + readonly errors: Record; + + constructor(issues: string[]) { + super("Invalid environment configuration"); + + this.errors = Object.fromEntries( + issues.map((msg, idx) => [idx + 1, msg]) + ); } - return value; -} -function optional(name: string, fallback: T): string { - return process.env[name] ?? fallback; + toJSON() { + return { + status: this.status, + message: this.message, + errors: this.errors, + }; + } } -export const env = { - APP_NAME: optional("APP_NAME", "Auth API"), - APP_KEY: required("APP_KEY"), - APP_URL: required("APP_URL"), - NODE_ENV: optional("NODE_ENV", "local"), - GOOGLE_CLIENT_ID: required("GOOGLE_CLIENT_ID"), - JWT_REFRESH_SECRET: required("JWT_REFRESH_SECRET"), - JWT_ACCESS_SECRET: required("JWT_ACCESS_SECRET"), +const EnvSchema = z.object({ + APP_NAME: z.string().default("Auth API"), + APP_KEY: z.string({message: "APP_KEY is required"}), + APP_URL: z + .string({message: "APP_URL is required"}) + .url("APP_URL must be a valid URL"), + + NODE_ENV: z + .enum(["local", "development", "test", "production"]) + .default("local"), + + GOOGLE_CLIENT_ID: z.string({message: "GOOGLE_CLIENT_ID is required"}), + + JWT_REFRESH_SECRET: z.string({message: "JWT_REFRESH_SECRET is required"}), + JWT_ACCESS_SECRET: z.string({message: "JWT_ACCESS_SECRET is required"}), + + PORT: z.coerce.number().int().positive().default(3000), + SWAGGER_ENABLED: z + .enum(["true", "false"]) + .default("false") + .transform(v => v === "true"), + JWT_ACCESS_EXPIRES_IN: z.string().default("120m"), + JWT_REFRESH_EXPIRES_IN: z.string().default("7d"), - PORT: optional("PORT", "3000"), - SWAGGER_ENABLED: optional("SWAGGER_ENABLED", "false"), + DATABASE_URL: z + .string({message: "DATABASE_URL is required"}) + .url("DATABASE_URL must be a valid URL"), - JWT_ACCESS_EXPIRES_IN: optional("JWT_ACCESS_EXPIRES_IN", "120m"), - JWT_REFRESH_EXPIRES_IN: optional("JWT_REFRESH_EXPIRES_IN", "7d"), + REDIS_URL: z.string().url().default("redis://localhost:6379"), + + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + + MAIL_HOST: z.string().default("localhost"), + MAIL_PORT: z.coerce.number().int().positive().default(1025), + MAIL_USERNAME: z.string().optional(), + MAIL_PASSWORD: z.string().optional(), + MAIL_FROM: z.string().default("noreply@example.com"), +}); + +const parsedEnv = EnvSchema.safeParse(process.env); + +if (!parsedEnv.success) { + const issues = parsedEnv.error.issues.map(i => i.message); + + const error = new EnvValidationError(issues); + + console.error(JSON.stringify(error.toJSON(), null, 2)); + + throw error; +} - // add more as needed... -}; +export const env = parsedEnv.data;