From 61e886d8252c40752ba7d5673c43afd07596c3d8 Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 19:58:31 +0530 Subject: [PATCH 01/11] Create config.ts Signed-off-by: Anika Mangla --- apps/backend/src/config.ts | 255 +++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 apps/backend/src/config.ts diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts new file mode 100644 index 0000000..254b5d9 --- /dev/null +++ b/apps/backend/src/config.ts @@ -0,0 +1,255 @@ +/** + * @file config.ts + * @description Validated environment configuration module for the DevCard backend. + * + * ALL backend code must import configuration values from this module. + * Direct reads of `process.env` anywhere else in `apps/backend` are forbidden. + * + * At module load time (i.e. server startup) every required variable is checked. + * If any variable is missing, empty, or fails a security constraint the process + * exits immediately with a descriptive error so the misconfiguration is caught + * before the server begins accepting requests. + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** All configuration values available to the backend. Fully typed — no `string | undefined`. */ +export interface AppConfig { + // Database + database: { + url: string; + }; + + // Redis + redis: { + url: string; + }; + + // JWT + jwt: { + secret: string; + }; + + // Encryption + encryption: { + key: string; + }; + + // GitHub OAuth + github: { + clientId: string; + clientSecret: string; + }; + + // Google OAuth + google: { + clientId: string; + clientSecret: string; + }; + + // App URLs + app: { + publicUrl: string; + backendUrl: string; + mobileRedirectUri: string; + }; + + // Server + server: { + port: number; + nodeEnv: "development" | "production" | "test"; + }; +} + +// ─── Validation helpers ─────────────────────────────────────────────────────── + +interface ValidationError { + variable: string; + reason: string; +} + +function getString( + errors: ValidationError[], + key: string, + options: { minLength?: number } = {} +): string { + const value = process.env[key]; + + if (value === undefined || value.trim() === "") { + errors.push({ variable: key, reason: "is required but was not set" }); + return ""; // placeholder; process will exit before this is used + } + + const trimmed = value.trim(); + + // Reject placeholder values left over from .env.example + const placeholderPatterns = [ + /^your-/i, + /^change-in-production$/i, + /^your_/i, + /-here$/i, + /^<.+>$/, + ]; + if (placeholderPatterns.some((re) => re.test(trimmed))) { + errors.push({ + variable: key, + reason: `looks like an unfilled placeholder ("${trimmed}"). Replace it with a real value.`, + }); + return ""; + } + + if (options.minLength !== undefined && trimmed.length < options.minLength) { + errors.push({ + variable: key, + reason: `must be at least ${options.minLength} characters long (got ${trimmed.length})`, + }); + return ""; + } + + return trimmed; +} + +function getPort(errors: ValidationError[], key: string): number { + const raw = process.env[key]; + + if (raw === undefined || raw.trim() === "") { + errors.push({ variable: key, reason: "is required but was not set" }); + return 0; + } + + const port = Number(raw.trim()); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + errors.push({ + variable: key, + reason: `must be a valid port number between 1 and 65535 (got "${raw}")`, + }); + return 0; + } + + return port; +} + +function getNodeEnv( + errors: ValidationError[], + key: string +): AppConfig["server"]["nodeEnv"] { + const allowed = ["development", "production", "test"] as const; + const value = process.env[key]; + + if (!value || value.trim() === "") { + // Default gracefully to development + return "development"; + } + + const trimmed = value.trim() as AppConfig["server"]["nodeEnv"]; + if (!allowed.includes(trimmed)) { + errors.push({ + variable: key, + reason: `must be one of: ${allowed.join(", ")} (got "${trimmed}")`, + }); + return "development"; + } + + return trimmed; +} + +// ─── Config factory (exported for testing) ─────────────────────────────────── + +/** + * Parses and validates all environment variables. + * Returns a fully-typed config object on success. + * Calls `onError` (default: process.exit) with a formatted message on failure. + * + * @param env - The environment object to read from (defaults to `process.env`) + * @param onError - Called with a human-readable error string when validation fails + */ +export function buildConfig( + env: NodeJS.ProcessEnv = process.env, + onError: (message: string) => never = (msg) => { + console.error(msg); + process.exit(1); + } +): AppConfig { + // Temporarily swap process.env so helper functions can read from `env` + const original = process.env; + // @ts-expect-error — deliberate override for testability + process.env = env; + + const errors: ValidationError[] = []; + + const config: AppConfig = { + database: { + url: getString(errors, "DATABASE_URL"), + }, + redis: { + url: getString(errors, "REDIS_URL"), + }, + jwt: { + // JWT secrets should be at least 32 characters to provide adequate entropy + secret: getString(errors, "JWT_SECRET", { minLength: 32 }), + }, + encryption: { + // 32-byte hex key = 64 hex chars + key: getString(errors, "ENCRYPTION_KEY", { minLength: 32 }), + }, + github: { + clientId: getString(errors, "GITHUB_CLIENT_ID"), + clientSecret: getString(errors, "GITHUB_CLIENT_SECRET"), + }, + google: { + clientId: getString(errors, "GOOGLE_CLIENT_ID"), + clientSecret: getString(errors, "GOOGLE_CLIENT_SECRET"), + }, + app: { + publicUrl: getString(errors, "PUBLIC_APP_URL"), + backendUrl: getString(errors, "BACKEND_URL"), + mobileRedirectUri: getString(errors, "MOBILE_REDIRECT_URI"), + }, + server: { + port: getPort(errors, "PORT"), + nodeEnv: getNodeEnv(errors, "NODE_ENV"), + }, + }; + + // Restore original process.env + // @ts-expect-error — restoring the override + process.env = original; + + if (errors.length > 0) { + const lines = [ + "", + "╔══════════════════════════════════════════════════════════════╗", + "║ DevCard — Environment Configuration Error ║", + "╚══════════════════════════════════════════════════════════════╝", + "", + "The server cannot start because the following environment", + "variable(s) are missing, empty, or invalid:", + "", + ...errors.map((e) => ` ✗ ${e.variable}: ${e.reason}`), + "", + "How to fix:", + " 1. Copy .env.example to .env → cp .env.example .env", + " 2. Fill in every value listed above", + " 3. Restart the server", + "", + ]; + onError(lines.join("\n")); + } + + return config; +} + +// ─── Singleton (loaded once at startup) ────────────────────────────────────── + +/** + * The validated, fully-typed application configuration. + * + * Import this in any backend module that needs config values: + * + * ```ts + * import { config } from "./config"; + * + * const db = new PrismaClient({ datasources: { db: { url: config.database.url } } }); + * ``` + */ +export const config: AppConfig = buildConfig(); From 2f9e3170798c5b46ba84e57f5cbf5260eae21646 Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 19:59:24 +0530 Subject: [PATCH 02/11] Create config.test.ts Signed-off-by: Anika Mangla --- apps/backend/src/__tests__/config.test.ts | 244 ++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 apps/backend/src/__tests__/config.test.ts diff --git a/apps/backend/src/__tests__/config.test.ts b/apps/backend/src/__tests__/config.test.ts new file mode 100644 index 0000000..c70f727 --- /dev/null +++ b/apps/backend/src/__tests__/config.test.ts @@ -0,0 +1,244 @@ +/** + * @file config.test.ts + * @description Unit tests for the environment config validation module. + * + * Run with: pnpm --filter @devcard/backend test + */ + +import { describe, it, expect, vi } from "vitest"; +import { buildConfig } from "../config"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** A complete, valid set of environment variables mirroring .env.example values. */ +const VALID_ENV: NodeJS.ProcessEnv = { + DATABASE_URL: "postgresql://devcard:devcard@localhost:5432/devcard?schema=public", + REDIS_URL: "redis://localhost:6379", + JWT_SECRET: "a-very-long-and-secure-jwt-secret-value-for-testing-purposes", + ENCRYPTION_KEY: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + GITHUB_CLIENT_ID: "real-github-client-id", + GITHUB_CLIENT_SECRET: "real-github-client-secret-value", + GOOGLE_CLIENT_ID: "real-google-client-id", + GOOGLE_CLIENT_SECRET: "real-google-client-secret-value", + PUBLIC_APP_URL: "http://localhost:5173", + BACKEND_URL: "http://localhost:3000", + MOBILE_REDIRECT_URI: "devcard://oauth/callback", + PORT: "3000", + NODE_ENV: "test", +}; + +function buildWithOverride(overrides: Partial): ReturnType { + const onError = vi.fn((_msg: string): never => { + throw new Error(_msg); + }); + return buildConfig({ ...VALID_ENV, ...overrides }, onError as never); +} + +function expectError(env: Partial, expectedSnippet: string) { + let capturedMessage = ""; + const onError = vi.fn((msg: string): never => { + capturedMessage = msg; + throw new Error(msg); + }); + + expect(() => buildConfig({ ...VALID_ENV, ...env } as NodeJS.ProcessEnv, onError as never)).toThrow(); + expect(capturedMessage).toContain(expectedSnippet); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("buildConfig — happy path", () => { + it("returns a typed config object when all variables are valid", () => { + const cfg = buildWithOverride({}); + expect(cfg.database.url).toBe(VALID_ENV.DATABASE_URL); + expect(cfg.redis.url).toBe(VALID_ENV.REDIS_URL); + expect(cfg.jwt.secret).toBe(VALID_ENV.JWT_SECRET); + expect(cfg.encryption.key).toBe(VALID_ENV.ENCRYPTION_KEY); + expect(cfg.github.clientId).toBe(VALID_ENV.GITHUB_CLIENT_ID); + expect(cfg.github.clientSecret).toBe(VALID_ENV.GITHUB_CLIENT_SECRET); + expect(cfg.google.clientId).toBe(VALID_ENV.GOOGLE_CLIENT_ID); + expect(cfg.google.clientSecret).toBe(VALID_ENV.GOOGLE_CLIENT_SECRET); + expect(cfg.app.publicUrl).toBe(VALID_ENV.PUBLIC_APP_URL); + expect(cfg.app.backendUrl).toBe(VALID_ENV.BACKEND_URL); + expect(cfg.app.mobileRedirectUri).toBe(VALID_ENV.MOBILE_REDIRECT_URI); + expect(cfg.server.port).toBe(3000); + expect(cfg.server.nodeEnv).toBe("test"); + }); + + it("trims whitespace from values", () => { + const cfg = buildWithOverride({ REDIS_URL: " redis://localhost:6379 " }); + expect(cfg.redis.url).toBe("redis://localhost:6379"); + }); + + it("defaults NODE_ENV to development when not set", () => { + const env = { ...VALID_ENV }; + delete env.NODE_ENV; + const onError = vi.fn((_: string): never => { throw new Error(_); }); + const cfg = buildConfig(env, onError as never); + expect(cfg.server.nodeEnv).toBe("development"); + }); +}); + +describe("buildConfig — missing variables", () => { + it("exits when DATABASE_URL is missing", () => { + const env = { ...VALID_ENV }; + delete env.DATABASE_URL; + expectError(env, "DATABASE_URL"); + }); + + it("exits when REDIS_URL is missing", () => { + const env = { ...VALID_ENV }; + delete env.REDIS_URL; + expectError(env, "REDIS_URL"); + }); + + it("exits when JWT_SECRET is missing", () => { + const env = { ...VALID_ENV }; + delete env.JWT_SECRET; + expectError(env, "JWT_SECRET"); + }); + + it("exits when ENCRYPTION_KEY is missing", () => { + const env = { ...VALID_ENV }; + delete env.ENCRYPTION_KEY; + expectError(env, "ENCRYPTION_KEY"); + }); + + it("exits when GITHUB_CLIENT_ID is missing", () => { + const env = { ...VALID_ENV }; + delete env.GITHUB_CLIENT_ID; + expectError(env, "GITHUB_CLIENT_ID"); + }); + + it("exits when GITHUB_CLIENT_SECRET is missing", () => { + const env = { ...VALID_ENV }; + delete env.GITHUB_CLIENT_SECRET; + expectError(env, "GITHUB_CLIENT_SECRET"); + }); + + it("exits when GOOGLE_CLIENT_ID is missing", () => { + const env = { ...VALID_ENV }; + delete env.GOOGLE_CLIENT_ID; + expectError(env, "GOOGLE_CLIENT_ID"); + }); + + it("exits when GOOGLE_CLIENT_SECRET is missing", () => { + const env = { ...VALID_ENV }; + delete env.GOOGLE_CLIENT_SECRET; + expectError(env, "GOOGLE_CLIENT_SECRET"); + }); + + it("exits when PORT is missing", () => { + const env = { ...VALID_ENV }; + delete env.PORT; + expectError(env, "PORT"); + }); + + it("collects multiple missing variables into a single error message", () => { + const env = { ...VALID_ENV }; + delete env.DATABASE_URL; + delete env.JWT_SECRET; + delete env.GITHUB_CLIENT_SECRET; + + let capturedMessage = ""; + const onError = vi.fn((msg: string): never => { + capturedMessage = msg; + throw new Error(msg); + }); + expect(() => buildConfig(env as NodeJS.ProcessEnv, onError as never)).toThrow(); + expect(capturedMessage).toContain("DATABASE_URL"); + expect(capturedMessage).toContain("JWT_SECRET"); + expect(capturedMessage).toContain("GITHUB_CLIENT_SECRET"); + }); +}); + +describe("buildConfig — empty values", () => { + it("exits when JWT_SECRET is an empty string", () => { + expectError({ JWT_SECRET: "" }, "JWT_SECRET"); + }); + + it("exits when DATABASE_URL is only whitespace", () => { + expectError({ DATABASE_URL: " " }, "DATABASE_URL"); + }); +}); + +describe("buildConfig — placeholder values", () => { + it("rejects JWT_SECRET that starts with 'your-'", () => { + expectError( + { JWT_SECRET: "your-super-secret-jwt-key-change-in-production" }, + "JWT_SECRET" + ); + }); + + it("rejects ENCRYPTION_KEY that ends with '-here'", () => { + expectError( + { ENCRYPTION_KEY: "your-32-byte-hex-encryption-key-here" }, + "ENCRYPTION_KEY" + ); + }); + + it("rejects GITHUB_CLIENT_ID that starts with 'your-'", () => { + expectError({ GITHUB_CLIENT_ID: "your-github-client-id" }, "GITHUB_CLIENT_ID"); + }); + + it("rejects GOOGLE_CLIENT_SECRET that starts with 'your-'", () => { + expectError({ GOOGLE_CLIENT_SECRET: "your-google-client-secret" }, "GOOGLE_CLIENT_SECRET"); + }); +}); + +describe("buildConfig — minimum length constraints", () => { + it("rejects JWT_SECRET shorter than 32 characters", () => { + expectError({ JWT_SECRET: "short-secret" }, "JWT_SECRET"); + }); + + it("accepts JWT_SECRET of exactly 32 characters", () => { + const cfg = buildWithOverride({ JWT_SECRET: "a".repeat(32) }); + expect(cfg.jwt.secret).toHaveLength(32); + }); + + it("rejects ENCRYPTION_KEY shorter than 32 characters", () => { + expectError({ ENCRYPTION_KEY: "tooshort" }, "ENCRYPTION_KEY"); + }); +}); + +describe("buildConfig — PORT validation", () => { + it("rejects a non-numeric PORT", () => { + expectError({ PORT: "abc" }, "PORT"); + }); + + it("rejects PORT 0", () => { + expectError({ PORT: "0" }, "PORT"); + }); + + it("rejects PORT above 65535", () => { + expectError({ PORT: "99999" }, "PORT"); + }); + + it("accepts a valid PORT", () => { + const cfg = buildWithOverride({ PORT: "8080" }); + expect(cfg.server.port).toBe(8080); + }); +}); + +describe("buildConfig — NODE_ENV validation", () => { + it("rejects an unknown NODE_ENV value", () => { + expectError({ NODE_ENV: "staging" }, "NODE_ENV"); + }); + + it("accepts production", () => { + const cfg = buildWithOverride({ NODE_ENV: "production" }); + expect(cfg.server.nodeEnv).toBe("production"); + }); +}); + +describe("buildConfig — error message quality", () => { + it("includes setup instructions in the error output", () => { + const env = { ...VALID_ENV }; + delete env.DATABASE_URL; + let msg = ""; + const onError = vi.fn((m: string): never => { msg = m; throw new Error(m); }); + expect(() => buildConfig(env as NodeJS.ProcessEnv, onError as never)).toThrow(); + expect(msg).toContain("cp .env.example .env"); + expect(msg).toContain("How to fix"); + }); +}); From 848c35dd5da1602280849d13ba71b055f311103a Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:05:30 +0530 Subject: [PATCH 03/11] feat(backend): add validated environment config module Signed-off-by: Anika Mangla --- CONTRIBUTING.md | 93 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f95620..d141a64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,6 @@ # Contributing to DevCard -

- - Discord Server - -

+[![Discord Server](https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/QueQN83wn) **Join the community** — ask questions, get help, discuss ideas, and meet other contributors on our [Discord server](https://discord.gg/QueQN83wn). @@ -17,6 +13,7 @@ - **Docker** & Docker Compose - **React Native** dev environment — follow the [official setup guide](https://reactnative.dev/docs/environment-setup) + ### Getting Started ```bash @@ -43,33 +40,110 @@ pnpm dev:backend # Backend API on :3000 pnpm dev:mobile # React Native app ``` -### Running Tests +--- + +## Environment Configuration + +The backend uses a **validated configuration module** (`apps/backend/src/config.ts`) that reads, validates, and types all environment variables at startup. + +### Why this exists + +Without validation, a server can boot silently with placeholder secrets from `.env.example`, then fail at runtime in ways that are hard to debug. The config module makes misconfiguration a **startup-time crash** with a clear, human-readable error instead of a mysterious runtime failure. + +### How it works + +1. On startup, `config.ts` reads every variable defined in `.env.example`. +2. Each variable is checked: it must be present, non-empty, not a placeholder value, and meet any security constraints (e.g. minimum length for secrets). +3. If **any** check fails the process exits immediately — before the server binds to a port — printing a message like: + +``` +╔══════════════════════════════════════════════════════════════╗ +║ DevCard — Environment Configuration Error ║ +╚══════════════════════════════════════════════════════════════╝ + +The server cannot start because the following environment +variable(s) are missing, empty, or invalid: + + ✗ JWT_SECRET: looks like an unfilled placeholder. Replace it with a real value. + ✗ DATABASE_URL: is required but was not set + +How to fix: + 1. Copy .env.example to .env → cp .env.example .env + 2. Fill in every value listed above + 3. Restart the server +``` + +### Security constraints enforced at startup + +| Variable | Constraint | +|---|---| +| `JWT_SECRET` | Minimum 32 characters | +| `ENCRYPTION_KEY` | Minimum 32 characters | +| All secrets | Must not match `.env.example` placeholder patterns (e.g. `your-…`, `…-here`) | +| `PORT` | Must be a valid integer between 1 and 65535 | +| `NODE_ENV` | Must be one of `development`, `production`, `test` | + +### Rules for contributors + +> **Never read `process.env` directly anywhere inside `apps/backend`.** + +All configuration values must be imported from the config module: + +```ts +// ❌ Forbidden — direct process.env access +const secret = process.env.JWT_SECRET; + +// ✅ Correct — import from config +import { config } from "./config"; +const secret = config.jwt.secret; +``` + +This rule is enforced by ESLint. A PR that introduces a direct `process.env` read in `apps/backend` will fail the lint check. + +### Adding a new environment variable + +1. Add the variable to `.env.example` with a clear placeholder and comment. +2. Add a corresponding field to the `AppConfig` interface in `config.ts`. +3. Parse and validate it in `buildConfig()` using the existing helper functions (`getString`, `getPort`, etc.). +4. Export it on the returned config object. +5. Add tests in `apps/backend/src/__tests__/config.test.ts` covering at least: missing value, empty value, and (if applicable) constraint violations. + +--- + +## Running Tests This project uses `pnpm` to run tests across different parts of the codebase. #### Run all tests -To execute all available tests: + ```bash pnpm -r test ``` #### apps/backend + The backend uses Vitest: + ```bash pnpm --filter @devcard/backend test pnpm --filter @devcard/backend test:watch ``` + #### apps/mobile + The mobile app uses Jest: + ```bash pnpm --filter @devcard/mobile test ``` + #### apps/web + Currently, the web app does not define a test script. #### packages/shared -The shared package does not include test scripts. It only provides linting and type checking. +The shared package does not include test scripts. It only provides linting and type checking. ## Project Structure @@ -88,6 +162,7 @@ devcard/ - **Conventional Commits** for commit messages (`feat:`, `fix:`, `docs:`, `chore:`) - Write tests for new features and bug fixes + ## Pull Request Process 1. Create a feature branch from `main`: `git checkout -b feat/your-feature` @@ -97,12 +172,14 @@ devcard/ 5. Open a PR against `main` with a clear description of the change 6. Wait for review — maintainers will respond within 48 hours + ## Reporting Issues - Use GitHub Issues for bug reports and feature requests - Include reproduction steps for bugs - Search existing issues before creating a new one + ## Code of Conduct Be kind, inclusive, and constructive. We follow the [Contributor Covenant](https://www.contributor-covenant.org/). From c1c9a39a0d3c24f54104318930c75ba8d8133d8c Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:12:06 +0530 Subject: [PATCH 04/11] Update app.ts Signed-off-by: Anika Mangla --- apps/backend/src/app.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 2471a92..f9c7ea8 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -8,6 +8,7 @@ import fastifyStatic from '@fastify/static'; import rateLimit from '@fastify/rate-limit'; import path from 'path'; import { fileURLToPath } from 'url'; +import { config } from './config.js'; import { prismaPlugin } from './plugins/prisma.js'; import { redisPlugin } from './plugins/redis.js'; @@ -26,9 +27,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export async function buildApp() { const app = Fastify({ logger: { - level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + level: config.server.nodeEnv === 'production' ? 'info' : 'debug', transport: - process.env.NODE_ENV !== 'production' + config.server.nodeEnv !== 'production' ? { target: 'pino-pretty', options: { colorize: true } } : undefined, }, @@ -36,7 +37,7 @@ export async function buildApp() { // ─── Core Plugins ─── await app.register(cors, { - origin: process.env.PUBLIC_APP_URL || 'http://localhost:5173', + origin: config.app.publicUrl, credentials: true, }); @@ -58,7 +59,7 @@ export async function buildApp() { }); await app.register(jwt, { - secret: process.env.JWT_SECRET || 'dev-secret-change-me', + secret: config.jwt.secret, }); await app.register(cookie); @@ -96,8 +97,9 @@ export async function buildApp() { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); -await app.register(nfcRoutes, { prefix: '/api/nfc' }); - await app.register(eventRoutes, { prefix: '/api/events' }); + await app.register(nfcRoutes, { prefix: '/api/nfc' }); + await app.register(eventRoutes, { prefix: '/api/events' }); + // ─── Health Check ─── app.get('/health', async () => ({ status: 'ok', From e07694c9a1c63dedc139b22e0175ac9a970d7331 Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:13:41 +0530 Subject: [PATCH 05/11] Update auth.ts Signed-off-by: Anika Mangla --- apps/backend/src/routes/auth.ts | 167 ++++++++++++++++---------------- 1 file changed, 83 insertions(+), 84 deletions(-) diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index 100a4b5..0ecb019 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { randomBytes } from 'crypto'; import { encrypt } from '../utils/encryption.js'; +import { config } from '../config.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -32,49 +33,48 @@ export async function authRoutes(app: FastifyInstance) { // ─── GitHub OAuth ─── - app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => { - const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; + const redirectUri = `${config.app.backendUrl}/auth/github/callback`; const clientState = (request.query as any).state || ''; const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; const state = buildOAuthState(clientState, mobileRedirectUri); - // Store state in a short-lived signed cookie before redirecting - reply.setCookie('oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 600, // 10 minutes — plenty for a login round-trip - }); + // Store state in a short-lived signed cookie before redirecting + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: config.server.nodeEnv === 'production', + sameSite: 'lax', + path: '/', + maxAge: 600, // 10 minutes — plenty for a login round-trip + }); - const params = new URLSearchParams({ - client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), - redirect_uri: redirectUri, - scope: 'read:user user:email', - state, + const params = new URLSearchParams({ + client_id: config.github.clientId, + redirect_uri: redirectUri, + scope: 'read:user user:email', + state, + }); + const authUrl = `${GITHUB_AUTH_URL}?${params}`; + console.log('--- GITHUB OAUTH REDIRECT ---'); + console.log('URL:', authUrl); + return reply.redirect(authUrl); }); - const authUrl = `${GITHUB_AUTH_URL}?${params}`; - console.log('--- GITHUB OAUTH REDIRECT ---'); - console.log('URL:', authUrl); - return reply.redirect(authUrl); -}); - -app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code, state } = request.query; - - // ── CSRF check ────────────────────────────────────────────────────────────── - const storedState = (request.cookies as any)?.oauth_state; - if (!state || !storedState || state !== storedState) { - return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); - } - // Clear the state cookie immediately — prevents replay - reply.clearCookie('oauth_state', { path: '/' }); - // ──────────────────────────────────────────────────────────────────────────── - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } + app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + const { code, state } = request.query; + + // ── CSRF check ────────────────────────────────────────────────────────────── + const storedState = (request.cookies as any)?.oauth_state; + if (!state || !storedState || state !== storedState) { + return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); + } + // Clear the state cookie immediately — prevents replay + reply.clearCookie('oauth_state', { path: '/' }); + // ──────────────────────────────────────────────────────────────────────────── + + if (!code) { + return reply.status(400).send({ error: 'Missing authorization code' }); + } try { // Exchange code for token @@ -85,10 +85,10 @@ app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthC Accept: 'application/json', }, body: JSON.stringify({ - client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), - client_secret: (process.env.GITHUB_CLIENT_SECRET || '').trim(), + client_id: config.github.clientId, + client_secret: config.github.clientSecret, code, - redirect_uri: `${process.env.BACKEND_URL}/auth/github/callback`, + redirect_uri: `${config.app.backendUrl}/auth/github/callback`, }), }); const tokenData = (await tokenRes.json()) as any; @@ -141,7 +141,6 @@ app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthC }); // Save the authentication token for 'user:email read:user' so we have a basic platform connection. - // Failure here is non-fatal — the user can still authenticate; the token can be reconnected later. try { const encryptedToken = encrypt(tokenData.access_token); await app.prisma.oAuthToken.upsert({ @@ -159,22 +158,22 @@ app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthC { expiresIn: '30d' } ); - // For mobile app: redirect with token as URL fragment (not sent to servers, keeps token out of logs) + // For mobile app: redirect with token as URL fragment if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; + const mobileRedirect = getMobileRedirectUri(request.query.state) || config.app.mobileRedirectUri; return reply.redirect(`${mobileRedirect}#token=${token}`); } // For web: set cookie and redirect reply.setCookie('token', token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: config.server.nodeEnv === 'production', sameSite: 'lax', path: '/', maxAge: 30 * 24 * 60 * 60, // 30 days }); - return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); + return reply.redirect(`${config.app.publicUrl}/dashboard`); } catch (err) { const message = err instanceof Error ? err.message : String(err); app.log.error({ err, message }, 'GitHub auth error'); @@ -185,58 +184,58 @@ app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthC // ─── Google OAuth ─── app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => { - const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; + const redirectUri = `${config.app.backendUrl}/auth/google/callback`; const clientState = (request.query as any).state || ''; const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; const state = buildOAuthState(clientState, mobileRedirectUri); - // Store state in a short-lived signed cookie before redirecting - reply.setCookie('oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: 600, - }); + // Store state in a short-lived signed cookie before redirecting + reply.setCookie('oauth_state', state, { + httpOnly: true, + secure: config.server.nodeEnv === 'production', + sameSite: 'lax', + path: '/', + maxAge: 600, + }); - const params = new URLSearchParams({ - client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(), - redirect_uri: redirectUri, - response_type: 'code', - scope: 'openid email profile', - state, - access_type: 'offline', + const params = new URLSearchParams({ + client_id: config.google.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'offline', + }); + const authUrl = `${GOOGLE_AUTH_URL}?${params}`; + console.log('--- GOOGLE OAUTH REDIRECT ---'); + console.log('URL:', authUrl); + return reply.redirect(authUrl); }); - const authUrl = `${GOOGLE_AUTH_URL}?${params}`; - console.log('--- GOOGLE OAUTH REDIRECT ---'); - console.log('URL:', authUrl); - return reply.redirect(authUrl); -}); - - app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code, state } = request.query; - - // ── CSRF check ────────────────────────────────────────────────────────────── - const storedState = (request.cookies as any)?.oauth_state; - if (!state || !storedState || state !== storedState) { - return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); - } - reply.clearCookie('oauth_state', { path: '/' }); - // ──────────────────────────────────────────────────────────────────────────── - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } + app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { + const { code, state } = request.query; + + // ── CSRF check ────────────────────────────────────────────────────────────── + const storedState = (request.cookies as any)?.oauth_state; + if (!state || !storedState || state !== storedState) { + return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); + } + reply.clearCookie('oauth_state', { path: '/' }); + // ──────────────────────────────────────────────────────────────────────────── + + if (!code) { + return reply.status(400).send({ error: 'Missing authorization code' }); + } try { const tokenRes = await fetch(GOOGLE_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - client_id: process.env.GOOGLE_CLIENT_ID, - client_secret: process.env.GOOGLE_CLIENT_SECRET, + client_id: config.google.clientId, + client_secret: config.google.clientSecret, code, - redirect_uri: `${process.env.BACKEND_URL}/auth/google/callback`, + redirect_uri: `${config.app.backendUrl}/auth/google/callback`, grant_type: 'authorization_code', }), }); @@ -283,19 +282,19 @@ app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthC ); if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; + const mobileRedirect = getMobileRedirectUri(request.query.state) || config.app.mobileRedirectUri; return reply.redirect(`${mobileRedirect}#token=${token}`); } reply.setCookie('token', token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: config.server.nodeEnv === 'production', sameSite: 'lax', path: '/', maxAge: 30 * 24 * 60 * 60, }); - return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); + return reply.redirect(`${config.app.publicUrl}/dashboard`); } catch (err) { const message = err instanceof Error ? err.message : String(err); app.log.error({ err, message }, 'Google auth error'); From 5dd9e05a7eab1d9c8a7ef4a88dc0c55026e3ae95 Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:14:22 +0530 Subject: [PATCH 06/11] Update server.ts Signed-off-by: Anika Mangla --- apps/backend/src/server.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index aea785d..80c6f0d 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -1,15 +1,13 @@ import './env.js'; import { buildApp } from './app.js'; - -const PORT = parseInt(process.env.PORT || '3000', 10); -const HOST = process.env.HOST || '0.0.0.0'; +import { config } from './config.js'; async function start() { const app = await buildApp(); try { - await app.listen({ port: PORT, host: HOST }); - app.log.info(`🚀 DevCard API running at http://${HOST}:${PORT}`); + await app.listen({ port: config.server.port, host: '0.0.0.0' }); + app.log.info(`🚀 DevCard API running at ${config.app.backendUrl}`); } catch (err) { app.log.error(err); process.exit(1); From 8287d9344bcfdea1ae45914aa67037251b12590f Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:15:15 +0530 Subject: [PATCH 07/11] Update prisma.ts Signed-off-by: Anika Mangla --- apps/backend/src/plugins/prisma.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/plugins/prisma.ts b/apps/backend/src/plugins/prisma.ts index f6ebede..d674b8c 100644 --- a/apps/backend/src/plugins/prisma.ts +++ b/apps/backend/src/plugins/prisma.ts @@ -1,6 +1,7 @@ import fp from 'fastify-plugin'; import { PrismaClient } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { config } from '../config.js'; declare module 'fastify' { interface FastifyInstance { @@ -14,7 +15,7 @@ declare module 'fastify' { export const prismaPlugin = fp(async (app: FastifyInstance) => { const prisma = new PrismaClient({ - log: process.env.NODE_ENV !== 'production' ? ['query', 'error', 'warn'] : ['error'], + log: config.server.nodeEnv !== 'production' ? ['query', 'error', 'warn'] : ['error'], }); await prisma.$connect(); From e4eb1fd0dbffd0e18790248069797e31f5c7194e Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:15:48 +0530 Subject: [PATCH 08/11] Update redis.ts Signed-off-by: Anika Mangla --- apps/backend/src/plugins/redis.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/plugins/redis.ts b/apps/backend/src/plugins/redis.ts index c7b6f94..0fc365c 100644 --- a/apps/backend/src/plugins/redis.ts +++ b/apps/backend/src/plugins/redis.ts @@ -1,6 +1,7 @@ import fp from 'fastify-plugin'; import Redis from 'ioredis'; import type { FastifyInstance } from 'fastify'; +import { config } from '../config.js'; declare module 'fastify' { interface FastifyInstance { @@ -9,7 +10,7 @@ declare module 'fastify' { } export const redisPlugin = fp(async (app: FastifyInstance) => { - const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', { + const redis = new Redis(config.redis.url, { maxRetriesPerRequest: 3, lazyConnect: true, }); From c4aea0a05271ee2ad110e8c648b8df0282b562e8 Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:17:06 +0530 Subject: [PATCH 09/11] Update encryption.ts Signed-off-by: Anika Mangla --- apps/backend/src/utils/encryption.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/utils/encryption.ts b/apps/backend/src/utils/encryption.ts index b910599..4c13d0e 100644 --- a/apps/backend/src/utils/encryption.ts +++ b/apps/backend/src/utils/encryption.ts @@ -1,14 +1,12 @@ import crypto from 'crypto'; +import { config } from '../config.js'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; const TAG_LENGTH = 16; function getEncryptionKey(): Buffer { - const key = process.env.ENCRYPTION_KEY; - if (!key) { - throw new Error('ENCRYPTION_KEY environment variable is required'); - } + const key = config.encryption.key; // If key is hex-encoded, decode it; otherwise hash it to 32 bytes if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) { return Buffer.from(key, 'hex'); From ee1b4cfddf53e2b72f7a95cf3ea2807d871c2f9f Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:18:08 +0530 Subject: [PATCH 10/11] Update connect.ts Signed-off-by: Anika Mangla --- apps/backend/src/routes/connect.ts | 36 ++++++++++++------------------ 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index c158440..3b15f53 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,6 +1,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { randomBytes } from 'crypto'; import { encrypt } from '../utils/encryption.js'; +import { config } from '../config.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -36,18 +37,16 @@ export async function connectRoutes(app: FastifyInstance) { app.get('/github', { preHandler: [app.authenticate], }, async (request: FastifyRequest, reply: FastifyReply) => { - // Generate a secure state token linking back to this user session - // In a real app, store this in Redis to cross-check in callback const state = JSON.stringify({ userId: (request.user as any).id, nonce: generateState(), }); - const redirectUri = `${process.env.BACKEND_URL}/api/connect/github/callback`; + const redirectUri = `${config.app.backendUrl}/api/connect/github/callback`; const params = new URLSearchParams({ - client_id: process.env.GITHUB_CLIENT_ID || '', + client_id: config.github.clientId, redirect_uri: redirectUri, - scope: 'user:follow', // ONLY asking for follow scope to avoid full profile access + scope: 'user:follow', state: Buffer.from(state).toString('base64'), }); @@ -58,23 +57,21 @@ export async function connectRoutes(app: FastifyInstance) { const { code, state } = request.query; if (!code || !state) { - return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=missing_params`); + return reply.redirect(`${config.app.publicUrl}/settings?error=missing_params`); } try { - // Decode state to find which user requested the connect const decodedState = parseOAuthState(state); if (!decodedState) { - return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); + return reply.redirect(`${config.app.publicUrl}/settings?error=connect_failed`); } const userId = decodedState.userId; if (!userId) { - return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=invalid_state`); + return reply.redirect(`${config.app.publicUrl}/settings?error=invalid_state`); } - // Exchange code for token const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST', headers: { @@ -82,10 +79,10 @@ export async function connectRoutes(app: FastifyInstance) { Accept: 'application/json', }, body: JSON.stringify({ - client_id: process.env.GITHUB_CLIENT_ID, - client_secret: process.env.GITHUB_CLIENT_SECRET, + client_id: config.github.clientId, + client_secret: config.github.clientSecret, code, - redirect_uri: `${process.env.BACKEND_URL}/api/connect/github/callback`, + redirect_uri: `${config.app.backendUrl}/api/connect/github/callback`, }), }); @@ -93,10 +90,9 @@ export async function connectRoutes(app: FastifyInstance) { if (tokenData.error) { app.log.error('GitHub connect token error:', tokenData); - return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); + return reply.redirect(`${config.app.publicUrl}/settings?error=connect_failed`); } - // Encrypt and store the token const encryptedToken = encrypt(tokenData.access_token); await app.prisma.oAuthToken.upsert({ @@ -118,22 +114,19 @@ export async function connectRoutes(app: FastifyInstance) { }, }); - // Redirect back to app settings - // If mobile, use custom scheme if (decodedState.nonce.startsWith('mobile_')) { - return reply.redirect(`${process.env.MOBILE_REDIRECT_URI}?connected=github`); + return reply.redirect(`${config.app.mobileRedirectUri}?connected=github`); } - return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?connected=github`); + return reply.redirect(`${config.app.publicUrl}/settings?connected=github`); } catch (err) { const message = err instanceof Error ? err.message : String(err); app.log.error({ err, message }, 'GitHub connect error'); - return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=server_error`); + return reply.redirect(`${config.app.publicUrl}/settings?error=server_error`); } }); - // ─── Disconnect ─── app.delete('/:platform', { @@ -162,7 +155,6 @@ function parseOAuthState(state: string): ParsedOAuthState | null { try { const decoded = JSON.parse(Buffer.from(state, 'base64').toString('utf-8')); - // validating the OAuth state structure which is expected if (typeof decoded.userId !== "string" || typeof decoded.nonce !== "string") { return null; } From 72a17e700da4a81f6158dec415df17f60ece5623 Mon Sep 17 00:00:00 2001 From: Anika Mangla Date: Sat, 23 May 2026 20:19:00 +0530 Subject: [PATCH 11/11] Update public.ts Signed-off-by: Anika Mangla --- apps/backend/src/routes/public.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 0a64e27..a9034ee 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; +import { config } from '../config.js'; type PublicProfileLink = { id: string; @@ -279,7 +280,6 @@ export async function publicRoutes(app: FastifyInstance) { }).catch(err => app.log.error('Failed to log card view:', err)); } - const response: UsernameCardPublicProfileResponse = { title: card.title, owner: { @@ -308,7 +308,7 @@ export async function publicRoutes(app: FastifyInstance) { app.get('/:username/qr', { config: { rateLimit: { - max: 50, // Lower limit for QR generation as it's more resource intensive + max: 50, timeWindow: '1 minute' } } @@ -320,7 +320,6 @@ export async function publicRoutes(app: FastifyInstance) { const format = (request.query as any).format || 'png'; const size = parseInt((request.query as any).size || '400', 10); - // Verify user exists const user = await app.prisma.user.findUnique({ where: { username }, }); @@ -329,7 +328,7 @@ export async function publicRoutes(app: FastifyInstance) { return reply.status(404).send({ error: 'User not found' }); } - const profileUrl = `${process.env.PUBLIC_APP_URL}/u/${username}`; + const profileUrl = `${config.app.publicUrl}/u/${username}`; if (format === 'svg') { const svg = await generateQRSvg(profileUrl, { width: size });