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
-
-
-
-
-
+[](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/).
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");
+ });
+});
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',
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();
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();
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,
});
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');
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;
}
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 });
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);
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');