diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8b9dd71 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# ────────────────────────────────────────────────────────── +# Exam Practice Pro — Environment Variables +# ────────────────────────────────────────────────────────── +# Copy this file to .env and fill in the values. +# NEVER commit your .env file to source control. +# ────────────────────────────────────────────────────────── + +# ─── Server ──────────────────────────────────────────── +NODE_ENV=development +PORT=3000 + +# ─── Stripe ──────────────────────────────────────────── +# Get your keys from https://dashboard.stripe.com/apikeys +# Use sk_test_... and pk_test_... keys for development +# Use sk_live_... and pk_live_... keys for production +STRIPE_SECRET_KEY=your_stripe_secret_key_here +STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key_here +STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret_here + +# ─── Authentication (JWT) ────────────────────────────── +# Generate a strong random secret: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET +JWT_EXPIRES_IN=24h + +# ─── CORS ────────────────────────────────────────────── +# Comma-separated list of allowed origins +CORS_ORIGINS=http://localhost:3000,http://localhost:3001 + +# ─── Logging ─────────────────────────────────────────── +LOG_LEVEL=info + +# ─── Database (future) ───────────────────────────────── +# DATABASE_URL=postgresql://user:password@localhost:5432/exam_practice_pro +# MONGODB_URI=mongodb://localhost:27017/exam_practice_pro diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4927749 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +# ────────────────────────────────────────────── +# Exam Practice Pro — CI/CD Pipeline +# ────────────────────────────────────────────── +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + # ── Lint & Test ─────────────────────────────── + test: + name: Lint & Test + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + node-version: [18, 20, 22] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm test + env: + NODE_ENV: test + JWT_SECRET: ci-test-secret + STRIPE_SECRET_KEY: sk_test_placeholder + STRIPE_WEBHOOK_SECRET: whsec_placeholder + + # ── Docker Build ────────────────────────────── + docker: + name: Docker Build + runs-on: ubuntu-latest + permissions: + contents: read + needs: test + if: github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t exam-practice-pro-api . + + - name: Verify container starts + run: | + docker run -d --name test-api \ + -e NODE_ENV=development \ + -e JWT_SECRET=ci-test-secret \ + -p 3000:3000 \ + exam-practice-pro-api + + sleep 5 + + # Health check + curl -f http://localhost:3000/health || exit 1 + + docker stop test-api + docker rm test-api diff --git a/.gitignore b/.gitignore index c2658d7..3931b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ node_modules/ +.env +*.log +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6e1bb94 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# ────────────────────────────────────────────── +# Exam Practice Pro — Backend API +# ────────────────────────────────────────────── +FROM node:22-alpine AS base + +WORKDIR /app + +# Install production dependencies only +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +# Copy application source +COPY src/ ./src/ +COPY public/ ./public/ + +# Non-root user for security +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3000/health || exit 1 + +EXPOSE 3000 + +ENV NODE_ENV=production + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md index 58c3172..6d4f427 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,55 @@ A certification exam practice platform inspired by [CertyIQ](https://certyiq.com | Feature | Description | |---|---| -| 🔐 **Sign-In / Guest Access** | Email + password login form with guest option. | +| 🔐 **Sign-In / Guest Access** | Email + password login form with guest option. JWT-based auth with role support. | | ⏱️ **Countdown Timer** | Each exam session is timed (default 30 minutes). The timer turns red in the last 5 minutes and auto-submits on expiry. | | 📋 **Quiz Engine** | Dynamic question rendering with radio-button options. | | 🏆 **Score & Results** | Instant score calculation and percentage display after submission. | | 🔁 **Retake** | Users can retake the quiz as many times as they like. | -| 💳 **Stripe Payments** | Secure payment flow to unlock full exam access (requires your own Stripe keys). | +| 💳 **Stripe Payments** | Secure payment flow using the Payment Intents API with webhook verification. | +| 🛡️ **Security** | Helmet headers, CORS, rate limiting, input validation, env-based config. | --- -## 🗂️ Project Structure +## 🏗️ Backend Architecture ``` Quiz-App/ -├── Index.html # Main HTML (sign-in view + quiz view) -├── script.js # All client-side logic (auth, timer, quiz, payment) -├── style.css # Styling for all views -├── server.js # Node.js + Express backend for Stripe charges -├── package.json # Node.js dependencies -└── README.md # This file +├── src/ # ← Production backend +│ ├── server.js # Entry point — starts Express, graceful shutdown +│ ├── app.js # Express app setup (middleware, routes) +│ ├── config/ +│ │ └── index.js # Centralised env-based configuration +│ ├── routes/ +│ │ ├── index.js # Route aggregator (API v1) +│ │ ├── health.js # GET /api/v1/health +│ │ ├── payment.js # Payment routes (create-payment-intent, status) +│ │ └── auth.js # Auth routes (register, login, guest, me) +│ ├── controllers/ +│ │ ├── payment.controller.js # Payment request handlers +│ │ └── auth.controller.js # Auth request handlers +│ ├── services/ +│ │ ├── stripe.service.js # Stripe Payment Intents + webhook logic +│ │ └── auth.service.js # JWT signing, guest sessions, password hashing +│ ├── middleware/ +│ │ ├── error-handler.js # Centralised error + 404 handler +│ │ ├── rate-limiter.js # Rate limiters (general, payment, auth) +│ │ ├── auth.js # JWT verification, guest pass-through, RBAC +│ │ └── validate.js # express-validator result checker +│ ├── models/ +│ │ └── schemas.js # PostgreSQL schema definitions (reference) +│ └── utils/ +│ └── logger.js # Pino structured logger +├── public/ # ← Static frontend (served by Express) +│ ├── Index.html +│ ├── script.js +│ └── style.css +├── exam-practice-pro/ # ← Next.js frontend (separate deployment) +├── .env.example # Environment variable reference +├── Dockerfile # Production container +├── docker-compose.yml # Local dev stack +├── render.yaml # Render deployment config +└── .github/workflows/ci.yml # CI/CD pipeline ``` --- @@ -35,7 +65,7 @@ Quiz-App/ ### Prerequisites -- [Node.js](https://nodejs.org/) v16 or later +- [Node.js](https://nodejs.org/) v18 or later - A [Stripe account](https://stripe.com/) (for payment processing) ### 1. Clone the repository @@ -51,73 +81,243 @@ cd Quiz-App npm install ``` -### 3. Configure Stripe API Keys +### 3. Configure environment variables -You need **two** Stripe keys — a **Publishable Key** (used in the browser) and a **Secret Key** (used on the server). +```bash +cp .env.example .env +``` -Find them in your [Stripe Dashboard → Developers → API Keys](https://dashboard.stripe.com/apikeys). +Edit `.env` and fill in your values: -#### Frontend key (`script.js`, line near the bottom) +```env +NODE_ENV=development +PORT=3000 -```js -// Replace the placeholder with your actual Stripe publishable key -var stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY_HERE'); -``` +# Stripe — get keys from https://dashboard.stripe.com/apikeys +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... -#### Backend key (`server.js`, line 3) +# JWT — generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +JWT_SECRET=your-random-secret -```js -// Replace the placeholder with your actual Stripe secret key -const stripe = require('stripe')('sk_test_YOUR_SECRET_KEY_HERE'); +CORS_ORIGINS=http://localhost:3000 +LOG_LEVEL=info ``` -> ⚠️ **Never commit real secret keys to source control.** Use environment variables in production: -> -> ```js -> const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); -> ``` - ### 4. Run the server ```bash +# Development (with auto-reload) +npm run dev + +# Production npm start ``` The app will be available at **http://localhost:3000**. -> **Note:** The frontend files (`Index.html`, `script.js`, `style.css`) must be served from the `public/` directory by Express, or you can open `Index.html` directly in a browser for UI-only testing (payment requests will fail without the server). +--- + +## 🔑 Environment Variable Reference + +| Variable | Required | Description | Example | +|---|---|---|---| +| `NODE_ENV` | No | Environment mode | `development`, `production` | +| `PORT` | No | Server port (default: 3000) | `3000` | +| `STRIPE_SECRET_KEY` | **Yes (prod)** | Stripe secret key | `sk_test_...` or `sk_live_...` | +| `STRIPE_PUBLISHABLE_KEY` | **Yes (prod)** | Stripe publishable key | `pk_test_...` or `pk_live_...` | +| `STRIPE_WEBHOOK_SECRET` | **Yes (prod)** | Stripe webhook signing secret | `whsec_...` | +| `JWT_SECRET` | **Yes (prod)** | Secret for JWT signing | Random 64-byte hex string | +| `JWT_EXPIRES_IN` | No | JWT expiration (default: 24h) | `24h`, `7d` | +| `CORS_ORIGINS` | No | Comma-separated allowed origins | `https://example.com` | +| `LOG_LEVEL` | No | Pino log level (default: info) | `debug`, `info`, `warn` | + +--- + +## 💳 Stripe Setup + +### Test vs Live Mode + +| | Test Mode | Live Mode | +|---|---|---| +| **Keys** | `sk_test_...` / `pk_test_...` | `sk_live_...` / `pk_live_...` | +| **Dashboard** | Toggle "Test mode" in Stripe Dashboard | Default view | +| **Cards** | Use `4242 4242 4242 4242` | Real cards | +| **Webhooks** | Use Stripe CLI for local testing | Configure in Dashboard | + +### Setting Up Webhooks + +1. **Local development** — use [Stripe CLI](https://stripe.com/docs/stripe-cli): + ```bash + stripe listen --forward-to localhost:3000/api/v1/payments/webhook + ``` + Copy the webhook signing secret (`whsec_...`) to your `.env` file. + +2. **Production** — create a webhook endpoint in the [Stripe Dashboard](https://dashboard.stripe.com/webhooks): + - URL: `https://your-domain.com/api/v1/payments/webhook` + - Events: `payment_intent.succeeded`, `payment_intent.payment_failed` + - Copy the signing secret to your environment variables. + +### Payment Flow + +``` +Client Server Stripe + │ │ │ + ├─ GET /api/v1/payments/config ──► │ + │◄── { publishableKey } ──────┤ │ + │ │ │ + ├─ POST /create-payment-intent ──► │ + │ ├── stripe.paymentIntents.create ──► + │ │◄── { clientSecret } ─────────┤ + │◄── { clientSecret } ────────┤ │ + │ │ │ + ├─ stripe.confirmCardPayment(clientSecret) ──────────────────►│ + │◄── { paymentIntent: succeeded } ───────────────────────────┤ + │ │ │ + │ │◄── webhook: payment_intent.succeeded + │ ├── verify signature │ + │ ├── update user status │ + │ ├── 200 OK ────────────────────►│ +``` --- -## 🕹️ How to Use +## 📡 API Endpoint Documentation + +### Health Check + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/health` | Redirects to `/api/v1/health` | +| `GET` | `/api/v1/health` | Returns server status, uptime, version | + +### Authentication + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `POST` | `/api/v1/auth/register` | None | Register with email + password (min 8 chars) | +| `POST` | `/api/v1/auth/login` | None | Login with email + password, returns JWT | +| `POST` | `/api/v1/auth/guest` | None | Create a guest session with limited access | +| `GET` | `/api/v1/auth/me` | Optional | Get current user profile | + +### Payments + +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| `GET` | `/api/v1/payments/config` | None | Get Stripe publishable key + amount | +| `POST` | `/api/v1/payments/create-payment-intent` | Optional | Create a PaymentIntent (server-authoritative amount) | +| `GET` | `/api/v1/payments/status/:id` | None | Check PaymentIntent status | +| `POST` | `/api/v1/payments/webhook` | Stripe Sig | Stripe webhook (raw body, signature verified) | + +--- + +## 🐳 Deployment + +### Docker + +```bash +# Build the image +docker build -t exam-practice-pro-api . + +# Run with env vars +docker run -d \ + --name exam-api \ + -p 3000:3000 \ + -e NODE_ENV=production \ + -e STRIPE_SECRET_KEY=sk_live_... \ + -e STRIPE_PUBLISHABLE_KEY=pk_live_... \ + -e STRIPE_WEBHOOK_SECRET=whsec_... \ + -e JWT_SECRET=$(openssl rand -hex 64) \ + -e CORS_ORIGINS=https://your-domain.com \ + exam-practice-pro-api +``` + +### Docker Compose (Local Development) + +```bash +cp .env.example .env +# Edit .env with your values +docker compose up --build +``` + +### Platform Deployments + +#### Render + +The included `render.yaml` configures both the API and Next.js frontend. +Set the required environment variables in the Render dashboard. + +#### AWS (ECS / App Runner) + +1. Push Docker image to ECR +2. Create an ECS service or App Runner service +3. Set environment variables in task definition / service config +4. Point your domain to the load balancer + +#### Azure (App Service / Container Instances) + +1. Push Docker image to ACR +2. Create a Web App for Containers +3. Set environment variables in App Settings +4. Configure custom domain + SSL + +#### Railway / Fly.io + +```bash +# Railway +railway link +railway up + +# Fly.io +fly launch +fly secrets set STRIPE_SECRET_KEY=sk_live_... JWT_SECRET=... +fly deploy +``` + +### Environment Separation -1. Open the app in your browser. -2. **Sign in** with any email/password (demo auth accepts all credentials) or click **Continue as Guest**. -3. Answer the quiz questions before the **30-minute timer** runs out. -4. Click **Submit Quiz & Pay** (or let the timer expire) to see your score. -5. Click **Retake Quiz** to try again, or **Get Full Access** to proceed to payment. +| Environment | `NODE_ENV` | Stripe Keys | Database | +|---|---|---|---| +| Development | `development` | `sk_test_...` | Local / SQLite | +| Staging | `staging` | `sk_test_...` | Staging DB | +| Production | `production` | `sk_live_...` | Production DB | --- -## 🔧 Configuration Reference +## 🔒 Security Features -| Setting | File | Default | +| Feature | Implementation | OWASP Reference | |---|---|---| -| Exam duration | `script.js` → `EXAM_DURATION_SECONDS` | `1800` (30 min) | -| Payment amount | `server.js` → `amount` | `5000` ($50.00) | -| Stripe publishable key | `script.js` | `'your-publishable-key-here'` | -| Stripe secret key | `server.js` | `'your-secret-key-here'` | +| No hardcoded secrets | All secrets via `process.env` | A02:2021 – Cryptographic Failures | +| Input validation | `express-validator` middleware | A03:2021 – Injection | +| HTTP security headers | `helmet` middleware | A05:2021 – Security Misconfiguration | +| CORS configuration | Allowlist-based `cors` | A01:2021 – Broken Access Control | +| Rate limiting | `express-rate-limit` (general + payment + auth) | A04:2021 – Insecure Design | +| Server-side amount validation | Stripe service validates against catalog | A04:2021 – Insecure Design | +| Webhook signature verification | `stripe.webhooks.constructEvent()` | A08:2021 – Software Integrity Failures | +| JWT authentication | `jsonwebtoken` with configurable secret + expiry | A07:2021 – Auth Failures | +| Password hashing | `bcryptjs` with salt rounds=12 | A02:2021 – Cryptographic Failures | +| Structured logging | Pino — no secrets logged | A09:2021 – Logging Failures | +| Graceful shutdown | SIGTERM/SIGINT handlers | Operational security | --- ## 🛣️ Roadmap -- [ ] Real user authentication (JWT / OAuth) -- [ ] Database integration (MongoDB / PostgreSQL) for questions and user data +- [x] Modular backend architecture (routes, controllers, services) +- [x] Stripe Payment Intents API with webhook support +- [x] Security hardening (helmet, CORS, rate limiting, validation) +- [x] JWT-based authentication with guest support +- [x] Database schema design (PostgreSQL) +- [x] Health check, API versioning, structured logging +- [x] Dockerfile, docker-compose, CI/CD pipeline +- [ ] Real database integration (PostgreSQL / Prisma) +- [ ] OAuth providers (Google, GitHub) - [ ] Multiple exam categories (AWS, Azure, CompTIA, etc.) -- [ ] Score history and progress dashboard -- [ ] Question explanations and review mode -- [ ] Mobile-responsive improvements +- [ ] Score history and progress dashboard API +- [ ] Question explanations and review mode API +- [ ] Admin panel for quiz management --- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..47a0b2d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# ────────────────────────────────────────────── +# Exam Practice Pro — Local Development Stack +# ────────────────────────────────────────────── +# Usage: +# docker compose up — start the API +# docker compose up --build — rebuild & start +# docker compose down — stop everything +# ────────────────────────────────────────────── + +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "${PORT:-3000}:3000" + env_file: + - .env + environment: + - NODE_ENV=${NODE_ENV:-development} + restart: unless-stopped + + # ── Optional: PostgreSQL for future database integration ── + # Uncomment when ready to add a real database. + # + # db: + # image: postgres:16-alpine + # environment: + # POSTGRES_USER: exam_user + # POSTGRES_PASSWORD: exam_password + # POSTGRES_DB: exam_practice_pro + # ports: + # - "5432:5432" + # volumes: + # - pgdata:/var/lib/postgresql/data + # restart: unless-stopped + +# volumes: +# pgdata: diff --git a/package-lock.json b/package-lock.json index 6b23f36..a668958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,27 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^3.0.3", "body-parser": "^1.20.2", + "cors": "^2.8.6", + "dotenv": "^17.4.1", "express": "^4.19.2", - "stripe": "^16.9.0" + "express-rate-limit": "^8.3.2", + "express-validator": "^7.3.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "stripe": "^16.9.0", + "uuid": "^13.0.0" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.2.tgz", @@ -42,6 +58,24 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -66,6 +100,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -94,6 +134,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -130,6 +176,32 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -175,6 +247,27 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -190,6 +283,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -268,6 +370,49 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-validator": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", @@ -392,6 +537,21 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -426,6 +586,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -435,6 +604,112 @@ "node": ">= 0.10" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -492,6 +767,15 @@ "node": ">= 0.6" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -507,6 +791,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -519,6 +812,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -531,6 +833,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -546,6 +857,83 @@ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", "license": "MIT" }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -559,6 +947,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -574,6 +972,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -598,6 +1002,15 @@ "node": ">= 0.8" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -618,12 +1031,49 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -710,6 +1160,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -719,6 +1187,18 @@ "node": ">= 0.8" } }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stripe": { "version": "16.9.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-16.9.0.tgz", @@ -732,6 +1212,18 @@ "node": ">=12.*" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -778,6 +1270,28 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -786,6 +1300,12 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/package.json b/package.json index 613cf3b..8f770d2 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,38 @@ { - "name": "myapp", + "name": "exam-practice-pro-api", "version": "1.0.0", - "main": "script.js", + "description": "Production-ready backend API for Exam Practice Pro — certification exam practice platform", + "main": "src/server.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "node server.js" + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "lint": "echo 'No linter configured'", + "test": "echo 'No tests configured yet' && exit 0" }, - "keywords": [], + "keywords": [ + "quiz", + "exam", + "certification", + "stripe", + "express" + ], "author": "", "license": "ISC", - "description": "", + "engines": { + "node": ">=18.0.0" + }, "dependencies": { - "body-parser": "^1.20.2", + "bcryptjs": "^3.0.3", + "cors": "^2.8.6", + "dotenv": "^17.4.1", "express": "^4.19.2", - "stripe": "^16.9.0" + "express-rate-limit": "^8.3.2", + "express-validator": "^7.3.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "stripe": "^16.9.0", + "uuid": "^13.0.0" } } diff --git a/public/Index.html b/public/Index.html new file mode 100644 index 0000000..bf40d7a --- /dev/null +++ b/public/Index.html @@ -0,0 +1,97 @@ + + + + + + Exam Practice Pro + + + + + +
+
+ +

Sign In to Your Account

+ + + + +
+
+ + +
+
+ + +
+ +
+ + + + + + +
+
+ + + + + + + + diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..6ba109e --- /dev/null +++ b/public/script.js @@ -0,0 +1,275 @@ +// script.js +document.addEventListener('DOMContentLoaded', function () { + + // ────────────────────────────────────────────── + // Quiz data + // ────────────────────────────────────────────── + const quizQuestions = [ + { + question: "Which AWS service is used to store objects in the cloud?", + options: ["EC2", "S3", "RDS", "Lambda"], + answer: "S3" + }, + { + question: "What does CPU stand for?", + options: ["Central Processing Unit", "Core Processing Unit", "Central Program Utility", "Compute Processing Unit"], + answer: "Central Processing Unit" + }, + { + question: "Which protocol is used to securely transfer files?", + options: ["FTP", "HTTP", "SFTP", "SMTP"], + answer: "SFTP" + }, + { + question: "What is 2 + 2?", + options: ["3", "4", "5", "6"], + answer: "4" + }, + { + question: "What is the default port for HTTPS?", + options: ["80", "443", "8080", "22"], + answer: "443" + } + ]; + + // ────────────────────────────────────────────── + // Timer configuration (seconds) + // ────────────────────────────────────────────── + const EXAM_DURATION_SECONDS = 30 * 60; // 30 minutes + let timerInterval = null; + let secondsRemaining = EXAM_DURATION_SECONDS; + + // ────────────────────────────────────────────── + // Element references + // ────────────────────────────────────────────── + const signinContainer = document.getElementById('signin-container'); + const quizView = document.getElementById('quiz-view'); + const signinForm = document.getElementById('signin-form'); + const signinError = document.getElementById('signin-error'); + const guestBtn = document.getElementById('guest-btn'); + const signoutBtn = document.getElementById('signout-btn'); + const userDisplay = document.getElementById('user-display'); + const timerDisplay = document.getElementById('timer-display'); + const quizForm = document.getElementById('quiz-form'); + const startPaymentBtn = document.getElementById('start-payment'); + const paymentContainer = document.getElementById('payment-container'); + const resultsContainer = document.getElementById('results-container'); + const resultsScore = document.getElementById('results-score'); + const retakeBtn = document.getElementById('retake-btn'); + const proceedPaymentBtn = document.getElementById('proceed-payment-btn'); + + // ────────────────────────────────────────────── + // Sign-In logic + // ────────────────────────────────────────────── + signinForm.addEventListener('submit', function (e) { + e.preventDefault(); + const email = document.getElementById('signin-email').value.trim(); + const password = document.getElementById('signin-password').value; + + if (!email || !password) { + showSigninError('Please enter your email and password.'); + return; + } + + // Demo authentication: accept any non-empty credentials + startQuiz(email); + }); + + guestBtn.addEventListener('click', function () { + startQuiz('Guest'); + }); + + signoutBtn.addEventListener('click', function () { + stopTimer(); + secondsRemaining = EXAM_DURATION_SECONDS; + quizView.style.display = 'none'; + signinContainer.style.display = 'flex'; + paymentContainer.style.display = 'none'; + resultsContainer.style.display = 'none'; + document.getElementById('quiz-container').style.display = 'block'; + }); + + function showSigninError(msg) { + signinError.textContent = msg; + signinError.style.display = 'block'; + } + + // ────────────────────────────────────────────── + // Start quiz session + // ────────────────────────────────────────────── + function startQuiz(username) { + signinContainer.style.display = 'none'; + quizView.style.display = 'block'; + userDisplay.textContent = username === 'Guest' ? 'Guest' : username; + signinError.style.display = 'none'; + + renderQuiz(); + startTimer(); + } + + // ────────────────────────────────────────────── + // Render quiz questions + // ────────────────────────────────────────────── + function renderQuiz() { + quizForm.innerHTML = ''; + quizQuestions.forEach(function (q, index) { + const questionDiv = document.createElement('div'); + questionDiv.classList.add('question'); + questionDiv.innerHTML = + '

' + (index + 1) + '. ' + q.question + '

' + + q.options.map(function (option) { + return ''; + }).join(''); + quizForm.appendChild(questionDiv); + }); + } + + // ────────────────────────────────────────────── + // Timer logic + // ────────────────────────────────────────────── + function startTimer() { + secondsRemaining = EXAM_DURATION_SECONDS; + updateTimerDisplay(); + timerInterval = setInterval(function () { + secondsRemaining--; + updateTimerDisplay(); + if (secondsRemaining <= 0) { + stopTimer(); + alert('\u23F0 Time is up! Your quiz has been automatically submitted.'); + submitQuiz(); + } + }, 1000); + } + + function stopTimer() { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } + } + + function updateTimerDisplay() { + const mins = Math.floor(secondsRemaining / 60); + const secs = secondsRemaining % 60; + timerDisplay.textContent = + String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0'); + + if (secondsRemaining <= 300) { + timerDisplay.classList.add('timer-warning'); + } else { + timerDisplay.classList.remove('timer-warning'); + } + } + + // ────────────────────────────────────────────── + // Quiz submission & scoring + // ────────────────────────────────────────────── + startPaymentBtn.addEventListener('click', function () { + submitQuiz(); + }); + + function submitQuiz() { + stopTimer(); + + var score = 0; + quizQuestions.forEach(function (q, index) { + var selected = quizForm.querySelector('input[name="question' + index + '"]:checked'); + if (selected && selected.value === q.answer) { + score++; + } + }); + + var total = quizQuestions.length; + var pct = Math.round((score / total) * 100); + + document.getElementById('quiz-container').style.display = 'none'; + resultsContainer.style.display = 'block'; + resultsScore.innerHTML = + 'You scored ' + score + ' / ' + total + ' (' + pct + '%)'; + } + + retakeBtn.addEventListener('click', function () { + resultsContainer.style.display = 'none'; + document.getElementById('quiz-container').style.display = 'block'; + renderQuiz(); + startTimer(); + }); + + proceedPaymentBtn.addEventListener('click', function () { + resultsContainer.style.display = 'none'; + paymentContainer.style.display = 'block'; + }); + + // ────────────────────────────────────────────── + // Stripe payment integration (Payment Intents API) + // ────────────────────────────────────────────── + var stripeInstance = null; + var cardElement = null; + + // Fetch the publishable key from the server (no hardcoded keys) + fetch('/api/v1/payments/config') + .then(function (response) { return response.json(); }) + .then(function (data) { + if (data.success && data.data.publishableKey) { + stripeInstance = Stripe(data.data.publishableKey); + var elements = stripeInstance.elements(); + cardElement = elements.create('card'); + cardElement.mount('#card-element'); + } + }) + .catch(function (err) { + console.error('Failed to load Stripe config:', err); + }); + + document.getElementById('payment-form').addEventListener('submit', function (event) { + event.preventDefault(); + + if (!stripeInstance || !cardElement) { + alert('Payment system is not ready. Please try again.'); + return; + } + + var submitBtn = document.getElementById('submit-payment'); + submitBtn.disabled = true; + submitBtn.textContent = 'Processing…'; + + // Step 1: Create a PaymentIntent on the server (amount is server-authoritative) + fetch('/api/v1/payments/create-payment-intent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: document.getElementById('signin-email') + ? document.getElementById('signin-email').value + : undefined + }) + }) + .then(function (response) { return response.json(); }) + .then(function (data) { + if (!data.success) { + throw new Error(data.error?.message || 'Failed to create payment'); + } + // Step 2: Confirm the payment on the client side + return stripeInstance.confirmCardPayment(data.data.clientSecret, { + payment_method: { card: cardElement } + }); + }) + .then(function (result) { + if (result.error) { + alert('Payment failed: ' + result.error.message); + } else if (result.paymentIntent && result.paymentIntent.status === 'succeeded') { + alert('\uD83C\uDF89 Payment successful! You now have full access.'); + } + }) + .catch(function (err) { + console.error('Payment error:', err); + alert('Payment failed. Please try again.'); + }) + .finally(function () { + submitBtn.disabled = false; + submitBtn.textContent = 'Pay Now'; + }); + }); +}); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..7bb938e --- /dev/null +++ b/public/style.css @@ -0,0 +1,392 @@ +/* style.css */ + +/* ─── Reset & Base ─────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Arial, sans-serif; + background-color: #f0f2f5; + color: #333; + margin: 0; + padding: 0; + min-height: 100vh; +} + +a { + color: #0066cc; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* ─── Sign-In View ─────────────────────────────── */ +#signin-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(135deg, #1a3c6e 0%, #0d5cad 100%); + padding: 20px; +} + +#signin-box { + background: #fff; + border-radius: 12px; + padding: 40px 36px; + width: 100%; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); +} + +#signin-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 16px; +} + +.logo-icon { + font-size: 2rem; +} + +.logo-text { + font-size: 1.3rem; + font-weight: 700; + color: #1a3c6e; +} + +#signin-box h2 { + text-align: center; + margin: 0 0 6px 0; + font-size: 1.4rem; + color: #1a3c6e; +} + +.signin-subtitle { + text-align: center; + color: #666; + font-size: 0.9rem; + margin: 0 0 24px 0; +} + +.signin-error { + background: #fff0f0; + border: 1px solid #f5c6cb; + color: #c0392b; + border-radius: 6px; + padding: 10px 14px; + font-size: 0.88rem; + margin-bottom: 16px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 0.88rem; + font-weight: 600; + color: #444; + margin-bottom: 6px; +} + +.form-group input { + width: 100%; + padding: 11px 14px; + border: 1px solid #ccc; + border-radius: 7px; + font-size: 0.95rem; + outline: none; + transition: border-color 0.2s; +} + +.form-group input:focus { + border-color: #0066cc; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.12); +} + +/* ─── Shared Button Styles ─────────────────────── */ +.btn-primary { + display: block; + width: 100%; + padding: 12px; + background-color: #0d5cad; + color: #fff; + border: none; + border-radius: 7px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + text-align: center; +} + +.btn-primary:hover { + background-color: #0a4a8a; +} + +.btn-guest { + display: block; + width: 100%; + padding: 11px; + background-color: #fff; + color: #0d5cad; + border: 2px solid #0d5cad; + border-radius: 7px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; + text-align: center; +} + +.btn-guest:hover { + background-color: #e8f0fb; +} + +.btn-signout { + padding: 7px 16px; + background-color: transparent; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + transition: background-color 0.2s; + width: auto; +} + +.btn-signout:hover { + background-color: rgba(255, 255, 255, 0.15); +} + +.signin-divider { + display: flex; + align-items: center; + text-align: center; + color: #aaa; + font-size: 0.85rem; + margin: 18px 0; +} + +.signin-divider::before, +.signin-divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid #ddd; +} + +.signin-divider span { + padding: 0 10px; +} + +.signin-footer { + text-align: center; + font-size: 0.85rem; + color: #666; + margin-top: 20px; + margin-bottom: 0; +} + +/* ─── App Header ───────────────────────────────── */ +#app-header { + display: flex; + align-items: center; + justify-content: space-between; + background: #1a3c6e; + color: #fff; + padding: 12px 24px; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} + +.header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.header-left .logo-text { + color: #fff; + font-size: 1.1rem; +} + +.header-right { + display: flex; + align-items: center; + gap: 14px; + font-size: 0.9rem; +} + +/* ─── Quiz Container ───────────────────────────── */ +#quiz-container { + max-width: 700px; + margin: 30px auto; + padding: 28px 32px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.09); +} + +#quiz-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} + +#quiz-title { + margin: 0; + font-size: 1.4rem; + color: #1a3c6e; +} + +/* ─── Timer ────────────────────────────────────── */ +#timer-box { + display: flex; + flex-direction: column; + align-items: center; + background: #f0f5ff; + border: 2px solid #0d5cad; + border-radius: 8px; + padding: 8px 18px; + min-width: 120px; +} + +.timer-label { + font-size: 0.75rem; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +#timer-display { + font-size: 1.6rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #0d5cad; + letter-spacing: 0.05em; +} + +#timer-display.timer-warning { + color: #c0392b; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* ─── Question Styles ──────────────────────────── */ +.question { + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 18px 20px; + margin-bottom: 18px; +} + +.question-text { + margin: 0 0 12px 0; + font-size: 1rem; + line-height: 1.5; + color: #222; +} + +.option-label { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 0.95rem; + transition: background-color 0.15s; + margin-bottom: 4px; +} + +.option-label:hover { + background-color: #e8f0fb; +} + +.option-label input[type="radio"] { + accent-color: #0d5cad; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* ─── Quiz Actions ─────────────────────────────── */ +#quiz-actions { + margin-top: 24px; +} + +/* ─── Results Container ────────────────────────── */ +#results-container { + max-width: 700px; + margin: 30px auto; + padding: 36px 32px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.09); + text-align: center; +} + +#results-container h2 { + color: #1a3c6e; + margin-bottom: 16px; +} + +#results-score { + font-size: 1.2rem; + margin-bottom: 28px; + color: #333; +} + +#results-container .btn-primary { + max-width: 240px; + margin: 8px auto; +} + +/* ─── Payment Container ────────────────────────── */ +#payment-container { + max-width: 700px; + margin: 30px auto; + padding: 28px 32px; + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.09); +} + +#payment-container h2 { + color: #1a3c6e; + text-align: center; + margin-bottom: 8px; +} + +#payment-container p { + text-align: center; + color: #555; + margin-bottom: 20px; +} + +#card-element { + border: 1px solid #ccc; + border-radius: 7px; + padding: 12px; + margin-bottom: 16px; + background: #fafafa; +} + diff --git a/render.yaml b/render.yaml index d8578ef..7e1cf10 100644 --- a/render.yaml +++ b/render.yaml @@ -1,6 +1,32 @@ services: + # ── Backend API ───────────────────────────────── - type: web - name: quiz-app + name: exam-practice-pro-api + runtime: node + buildCommand: npm ci --omit=dev + startCommand: node src/server.js + healthCheckPath: /health + envVars: + - key: NODE_ENV + value: production + - key: NODE_VERSION + value: 22 + - key: STRIPE_SECRET_KEY + sync: false + - key: STRIPE_PUBLISHABLE_KEY + sync: false + - key: STRIPE_WEBHOOK_SECRET + sync: false + - key: JWT_SECRET + generateValue: true + - key: CORS_ORIGINS + sync: false + - key: LOG_LEVEL + value: info + + # ── Next.js Frontend ─────────────────────────── + - type: web + name: exam-practice-pro-frontend runtime: node rootDir: exam-practice-pro buildCommand: npm install && npm run build diff --git a/server.js b/server.js index 0d7e603..efd89c8 100644 --- a/server.js +++ b/server.js @@ -1,29 +1,50 @@ // server.js -const express = require('express'); -const stripe = require('stripe')('your-secret-key-here'); // Replace with your Stripe secret key -const bodyParser = require('body-parser'); -const app = express(); +// ───────────────────────────────────────────────────────── +// DEPRECATED — this file is kept for reference only. +// The production server has been refactored into src/server.js. +// +// To start the application, run: +// npm start (uses src/server.js) +// npm run dev (uses src/server.js with --watch) +// ───────────────────────────────────────────────────────── +// +// WHAT WAS WRONG WITH THIS FILE: +// +// 1. HARDCODED SECRET KEY (OWASP A02:2021 – Cryptographic Failures) +// const stripe = require('stripe')('your-secret-key-here'); +// → Secrets must come from environment variables, never source code. +// +// 2. DEPRECATED CHARGES API +// stripe.charges.create({...}) is legacy. +// → Replaced with the Payment Intents API for SCA compliance & better UX. +// +// 3. NO INPUT VALIDATION (OWASP A03:2021 – Injection) +// The /charge endpoint blindly trusted client-sent token + amount. +// → Server now enforces amount validation & uses express-validator. +// +// 4. NO ERROR DETAILS / LOGGING +// res.json({success: false}) — no diagnostics for debugging. +// → Centralised error handler + Pino structured logging. +// +// 5. NO SECURITY HEADERS (OWASP A05:2021) +// No helmet, no CORS, no rate limiting. +// → All added in src/app.js. +// +// 6. NO WEBHOOK VERIFICATION +// Without webhooks, payment confirmation relies on client → trivially spoofable. +// → Stripe webhook with signature verification added. +// +// 7. NO GRACEFUL SHUTDOWN +// process.exit() on errors would drop in-flight requests. +// → SIGTERM / SIGINT handlers added in src/server.js. +// +// See README.md for the full architecture overview. +// ───────────────────────────────────────────────────────── -app.use(bodyParser.json()); -app.use(express.static('public')); // Assuming your HTML, CSS, and JS files are in 'public' directory +// eslint-disable-next-line no-console +console.warn( + '⚠️ server.js is deprecated. Use `npm start` to run src/server.js instead.' +); -app.post('/charge', async (req, res) => { - const {token} = req.body; +require('./src/server'); - try { - const charge = await stripe.charges.create({ - amount: 5000, // Amount in cents - currency: 'usd', - description: 'Quiz Payment', - source: token, - }); - - res.json({success: true}); - } catch (error) { - res.json({success: false}); - } -}); - -app.listen(3000, () => { - console.log('Server running on port 3000'); -}); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..7d5aef7 --- /dev/null +++ b/src/app.js @@ -0,0 +1,99 @@ +'use strict'; + +const express = require('express'); +const helmet = require('helmet'); +const cors = require('cors'); +const config = require('./config'); +const logger = require('./utils/logger'); +const routes = require('./routes'); +const { errorHandler, notFound } = require('./middleware/error-handler'); +const { generalLimiter } = require('./middleware/rate-limiter'); +const paymentController = require('./controllers/payment.controller'); + +const app = express(); + +// ────────────────────────────────────────────────────────── +// 1. Security headers (OWASP A05:2021 – Security Misconfiguration) +// ────────────────────────────────────────────────────────── +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", 'https://js.stripe.com'], + frameSrc: ["'self'", 'https://js.stripe.com', 'https://hooks.stripe.com'], + connectSrc: ["'self'", 'https://api.stripe.com'], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, + crossOriginEmbedderPolicy: false, // Required for Stripe iframe + }) +); + +// ────────────────────────────────────────────────────────── +// 2. CORS (OWASP A01:2021 – Broken Access Control) +// ────────────────────────────────────────────────────────── +app.use( + cors({ + origin: config.cors.origins, + methods: ['GET', 'POST'], + allowedHeaders: ['Content-Type', 'Authorization', 'Stripe-Signature'], + credentials: true, + maxAge: 86400, + }) +); + +// ────────────────────────────────────────────────────────── +// 3. Stripe webhook — MUST be registered BEFORE json body parser +// because Stripe needs the raw body for signature verification. +// ────────────────────────────────────────────────────────── +app.post( + '/api/v1/payments/webhook', + express.raw({ type: 'application/json' }), + paymentController.handleWebhook +); + +// ────────────────────────────────────────────────────────── +// 4. Body parsing +// ────────────────────────────────────────────────────────── +app.use(express.json({ limit: '10kb' })); +app.use(express.urlencoded({ extended: false, limit: '10kb' })); + +// ────────────────────────────────────────────────────────── +// 5. Request logging +// ────────────────────────────────────────────────────────── +app.use((req, _res, next) => { + logger.info({ method: req.method, url: req.url }, 'incoming request'); + next(); +}); + +// ────────────────────────────────────────────────────────── +// 6. Global rate limiter +// ────────────────────────────────────────────────────────── +app.use('/api/', generalLimiter); + +// ────────────────────────────────────────────────────────── +// 7. Static files (serves Index.html, script.js, style.css) +// ────────────────────────────────────────────────────────── +app.use(express.static('public')); + +// ────────────────────────────────────────────────────────── +// 8. API routes (versioned) +// ────────────────────────────────────────────────────────── +app.use('/api/v1', routes); + +// ────────────────────────────────────────────────────────── +// 9. Convenience redirect: /health → /api/v1/health +// ────────────────────────────────────────────────────────── +app.get('/health', (_req, res) => { + res.redirect('/api/v1/health'); +}); + +// ────────────────────────────────────────────────────────── +// 10. Error handling +// ────────────────────────────────────────────────────────── +app.use(notFound); +app.use(errorHandler); + +module.exports = app; diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..6603dde --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,71 @@ +'use strict'; + +const path = require('path'); + +// Load .env from the project root (one level above src/) +require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }); + +/** + * Centralised configuration — every setting is read from env vars + * with sensible defaults for local development. + */ +const config = { + env: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT, 10) || 3000, + + // ── Stripe ─────────────────────────────────────────── + stripe: { + secretKey: process.env.STRIPE_SECRET_KEY || '', + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + currency: 'usd', + /** Amount in cents — $50.00 for full exam access */ + fullAccessAmountCents: 5000, + }, + + // ── JWT / Auth ─────────────────────────────────────── + jwt: { + secret: process.env.JWT_SECRET || '', + expiresIn: process.env.JWT_EXPIRES_IN || '24h', + }, + + // ── CORS ───────────────────────────────────────────── + cors: { + origins: process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()) + : ['http://localhost:3000'], + }, + + // ── Logging ────────────────────────────────────────── + logLevel: process.env.LOG_LEVEL || 'info', +}; + +/** + * Validate that critical secrets are present in production. + * Throws at startup so the app doesn't run in an insecure state. + */ +function validateConfig() { + const missing = []; + + if (!config.stripe.secretKey) missing.push('STRIPE_SECRET_KEY'); + if (!config.stripe.webhookSecret) missing.push('STRIPE_WEBHOOK_SECRET'); + if (!config.jwt.secret) missing.push('JWT_SECRET'); + + if (config.env === 'production' && missing.length > 0) { + throw new Error( + `Missing required environment variables for production: ${missing.join(', ')}` + ); + } + + if (missing.length > 0) { + // In dev mode, log a warning instead of crashing + // eslint-disable-next-line no-console + console.warn( + `⚠️ Missing environment variables (non-fatal in dev): ${missing.join(', ')}` + ); + } +} + +validateConfig(); + +module.exports = config; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 0000000..8342f41 --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,66 @@ +'use strict'; + +const authService = require('../services/auth.service'); + +/** + * POST /api/v1/auth/register + */ +async function register(req, res, next) { + try { + const { email, password } = req.body; + const result = await authService.register({ email, password }); + + res.status(201).json({ + success: true, + data: result, + }); + } catch (err) { + next(err); + } +} + +/** + * POST /api/v1/auth/login + */ +async function login(req, res, next) { + try { + const { email, password } = req.body; + const result = await authService.login({ email, password }); + + res.json({ + success: true, + data: result, + }); + } catch (err) { + next(err); + } +} + +/** + * POST /api/v1/auth/guest + */ +function guest(_req, res) { + const result = authService.createGuestSession(); + + res.json({ + success: true, + data: result, + }); +} + +/** + * GET /api/v1/auth/me + * + * Returns the current authenticated user's profile. + */ +function me(req, res) { + res.json({ + success: true, + data: { + user: req.user, + isGuest: req.isGuest || false, + }, + }); +} + +module.exports = { register, login, guest, me }; diff --git a/src/controllers/payment.controller.js b/src/controllers/payment.controller.js new file mode 100644 index 0000000..d3213db --- /dev/null +++ b/src/controllers/payment.controller.js @@ -0,0 +1,153 @@ +'use strict'; + +const stripeService = require('../services/stripe.service'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * POST /api/v1/payments/create-payment-intent + * + * Creates a Stripe PaymentIntent and returns the client secret + * so the frontend can confirm payment via Stripe.js. + */ +async function createPaymentIntent(req, res, next) { + try { + const { email } = req.body; + + // Amount is server-authoritative — never trust the client + const result = await stripeService.createPaymentIntent({ + amount: config.stripe.fullAccessAmountCents, + currency: config.stripe.currency, + customerEmail: email || undefined, + metadata: { + userId: req.user?.id || 'anonymous', + product: 'full_exam_access', + }, + }); + + res.status(201).json({ + success: true, + data: { + clientSecret: result.clientSecret, + paymentIntentId: result.paymentIntentId, + amount: config.stripe.fullAccessAmountCents, + currency: config.stripe.currency, + }, + }); + } catch (err) { + next(err); + } +} + +/** + * GET /api/v1/payments/status/:paymentIntentId + * + * Retrieve the status of a PaymentIntent (useful for polling). + */ +async function getPaymentStatus(req, res, next) { + try { + const { paymentIntentId } = req.params; + const paymentIntent = await stripeService.getPaymentIntent(paymentIntentId); + + res.json({ + success: true, + data: { + id: paymentIntent.id, + status: paymentIntent.status, + amount: paymentIntent.amount, + currency: paymentIntent.currency, + }, + }); + } catch (err) { + next(err); + } +} + +/** + * POST /api/v1/payments/webhook + * + * Stripe webhook endpoint — verifies signature and processes events. + * + * IMPORTANT: This endpoint must receive the **raw** body (not JSON-parsed). + * The route is registered BEFORE the global JSON body-parser in app.js. + */ +async function handleWebhook(req, res, next) { + try { + const signature = req.headers['stripe-signature']; + if (!signature) { + return res.status(400).json({ + success: false, + error: { message: 'Missing Stripe-Signature header' }, + }); + } + + const event = stripeService.constructWebhookEvent(req.body, signature); + + logger.info({ eventType: event.type, eventId: event.id }, 'Stripe webhook received'); + + switch (event.type) { + case 'payment_intent.succeeded': { + const paymentIntent = event.data.object; + logger.info( + { paymentIntentId: paymentIntent.id, amount: paymentIntent.amount }, + 'Payment succeeded' + ); + // TODO: update user's payment status in database + // await UserModel.updatePaymentStatus(paymentIntent.metadata.userId, 'paid'); + break; + } + + case 'payment_intent.payment_failed': { + const paymentIntent = event.data.object; + logger.warn( + { + paymentIntentId: paymentIntent.id, + error: paymentIntent.last_payment_error?.message, + }, + 'Payment failed' + ); + // TODO: update user's payment status / notify + break; + } + + default: + logger.debug({ eventType: event.type }, 'Unhandled Stripe event type'); + } + + // Stripe expects a 200 response to acknowledge receipt + res.json({ received: true }); + } catch (err) { + // Signature verification failures should return 400 + if (err.type === 'StripeSignatureVerificationError') { + logger.warn({ err }, 'Stripe webhook signature verification failed'); + return res.status(400).json({ + success: false, + error: { message: 'Webhook signature verification failed' }, + }); + } + next(err); + } +} + +/** + * GET /api/v1/payments/config + * + * Returns the Stripe publishable key for frontend initialisation. + */ +function getStripeConfig(_req, res) { + res.json({ + success: true, + data: { + publishableKey: config.stripe.publishableKey, + amount: config.stripe.fullAccessAmountCents, + currency: config.stripe.currency, + }, + }); +} + +module.exports = { + createPaymentIntent, + getPaymentStatus, + handleWebhook, + getStripeConfig, +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..e97bc08 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,85 @@ +'use strict'; + +const jwt = require('jsonwebtoken'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * Express middleware that verifies a JWT bearer token. + * + * Populates `req.user` with the decoded payload on success. + * Returns 401 for missing/invalid tokens. + */ +function authenticateToken(req, res, next) { + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : null; + + if (!token) { + return res.status(401).json({ + success: false, + error: { message: 'Authentication required' }, + }); + } + + try { + const decoded = jwt.verify(token, config.jwt.secret); + req.user = decoded; + next(); + } catch (err) { + logger.warn({ err }, 'Invalid or expired token'); + return res.status(401).json({ + success: false, + error: { message: 'Invalid or expired token' }, + }); + } +} + +/** + * Middleware that allows both authenticated and guest users through, + * but marks the request accordingly. + * + * - If a valid JWT is present → `req.user` is populated, `req.isGuest = false`. + * - If no JWT is present → `req.user = { id: 'guest', role: 'guest' }`, `req.isGuest = true`. + */ +function allowGuest(req, _res, next) { + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : null; + + if (token) { + try { + const decoded = jwt.verify(token, config.jwt.secret); + req.user = decoded; + req.isGuest = false; + return next(); + } catch { + // Token invalid — fall through to guest + } + } + + req.user = { id: 'guest', role: 'guest' }; + req.isGuest = true; + next(); +} + +/** + * Role-based access control middleware factory. + * + * Usage: `router.get('/admin', authenticateToken, requireRole('admin'), handler)` + */ +function requireRole(...roles) { + return (req, res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ + success: false, + error: { message: 'Insufficient permissions' }, + }); + } + next(); + }; +} + +module.exports = { authenticateToken, allowGuest, requireRole }; diff --git a/src/middleware/error-handler.js b/src/middleware/error-handler.js new file mode 100644 index 0000000..abeb0db --- /dev/null +++ b/src/middleware/error-handler.js @@ -0,0 +1,39 @@ +'use strict'; + +const logger = require('../utils/logger'); + +/** + * Centralised Express error-handling middleware. + * + * - Logs the full error server-side. + * - Returns a safe JSON payload to the client (no stack traces in production). + */ +// eslint-disable-next-line no-unused-vars +function errorHandler(err, _req, res, _next) { + const statusCode = err.statusCode || 500; + const isProduction = process.env.NODE_ENV === 'production'; + + logger.error({ err, statusCode }, err.message); + + res.status(statusCode).json({ + success: false, + error: { + message: isProduction && statusCode === 500 + ? 'Internal server error' + : err.message, + ...(!isProduction && { stack: err.stack }), + }, + }); +} + +/** + * Catch-all for undefined routes. + */ +function notFound(_req, res) { + res.status(404).json({ + success: false, + error: { message: 'Resource not found' }, + }); +} + +module.exports = { errorHandler, notFound }; diff --git a/src/middleware/rate-limiter.js b/src/middleware/rate-limiter.js new file mode 100644 index 0000000..f2b29b6 --- /dev/null +++ b/src/middleware/rate-limiter.js @@ -0,0 +1,58 @@ +'use strict'; + +const rateLimit = require('express-rate-limit'); +const logger = require('../utils/logger'); + +/** + * General API rate limiter — 100 requests per 15-minute window. + */ +const generalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => { + logger.warn('General rate limit exceeded'); + res.status(429).json({ + success: false, + error: { message: 'Too many requests. Please try again later.' }, + }); + }, +}); + +/** + * Strict rate limiter for payment endpoints — 10 requests per 15-minute window. + * Protects against brute-force / replay attacks on the payment flow. + */ +const paymentLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => { + logger.warn('Payment rate limit exceeded'); + res.status(429).json({ + success: false, + error: { message: 'Too many payment attempts. Please wait before trying again.' }, + }); + }, +}); + +/** + * Auth rate limiter — 20 requests per 15-minute window. + */ +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => { + logger.warn('Auth rate limit exceeded'); + res.status(429).json({ + success: false, + error: { message: 'Too many authentication attempts. Please wait before trying again.' }, + }); + }, +}); + +module.exports = { generalLimiter, paymentLimiter, authLimiter }; diff --git a/src/middleware/validate.js b/src/middleware/validate.js new file mode 100644 index 0000000..b12231f --- /dev/null +++ b/src/middleware/validate.js @@ -0,0 +1,28 @@ +'use strict'; + +const { validationResult } = require('express-validator'); + +/** + * Express middleware that checks express-validator results. + * + * Place after your validation chains: + * router.post('/pay', [...validations], validate, controller); + */ +function validate(req, res, next) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + error: { + message: 'Validation failed', + details: errors.array().map((e) => ({ + field: e.path, + message: e.msg, + })), + }, + }); + } + next(); +} + +module.exports = { validate }; diff --git a/src/models/schemas.js b/src/models/schemas.js new file mode 100644 index 0000000..b41629d --- /dev/null +++ b/src/models/schemas.js @@ -0,0 +1,166 @@ +'use strict'; + +/** + * Database schema models for the Exam Practice Pro platform. + * + * ───────────────────────────────────────────────────────── + * RECOMMENDED DATABASE: PostgreSQL + * + * Why PostgreSQL? + * - Strong relational data (users ↔ quiz attempts ↔ payments) + * - ACID transactions for payment status updates + * - JSON/JSONB columns for flexible quiz answer storage + * - Mature ecosystem, excellent Node.js support (pg, Prisma, Drizzle) + * - Scales well for read-heavy SaaS workloads + * ───────────────────────────────────────────────────────── + * + * These are reference schemas. When adding a real database, + * use an ORM/query builder (Prisma, Drizzle, Knex) to generate + * migration files from these definitions. + * + * SQL equivalent is provided in comments for clarity. + */ + +/* +-- ──────────────────────────────────────────────── +-- users +-- ──────────────────────────────────────────────── +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', -- 'guest' | 'user' | 'paid' | 'admin' + payment_status VARCHAR(20) NOT NULL DEFAULT 'unpaid', -- 'unpaid' | 'paid' | 'refunded' + stripe_customer_id VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id); + +-- ──────────────────────────────────────────────── +-- quiz_attempts +-- ──────────────────────────────────────────────── +CREATE TABLE quiz_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + quiz_id VARCHAR(100) NOT NULL, + score INTEGER NOT NULL, + total INTEGER NOT NULL, + percentage DECIMAL(5,2) NOT NULL, + passed BOOLEAN NOT NULL, + time_spent_s INTEGER, -- seconds + answers JSONB, -- { questionId: selectedAnswer } + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_quiz_attempts_user ON quiz_attempts(user_id); +CREATE INDEX idx_quiz_attempts_quiz ON quiz_attempts(quiz_id); + +-- ──────────────────────────────────────────────── +-- payments +-- ──────────────────────────────────────────────── +CREATE TABLE payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stripe_payment_intent_id VARCHAR(255) UNIQUE NOT NULL, + amount_cents INTEGER NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'usd', + status VARCHAR(30) NOT NULL, -- mirrors Stripe PaymentIntent status + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_payments_user ON payments(user_id); +CREATE INDEX idx_payments_stripe ON payments(stripe_payment_intent_id); + +-- ──────────────────────────────────────────────── +-- scores (leaderboard / analytics) +-- ──────────────────────────────────────────────── +CREATE TABLE scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + quiz_id VARCHAR(100) NOT NULL, + best_score INTEGER NOT NULL, + best_pct DECIMAL(5,2) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 1, + last_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, quiz_id) +); + +CREATE INDEX idx_scores_user ON scores(user_id); +*/ + +/** + * JavaScript object representations (for ORM integration). + * + * These mirror the SQL above and can be used directly with + * Prisma schema, Drizzle table definitions, or Knex migrations. + */ +const UserSchema = { + tableName: 'users', + columns: { + id: { type: 'uuid', primaryKey: true, default: 'gen_random_uuid()' }, + email: { type: 'varchar(255)', unique: true, nullable: false }, + password_hash: { type: 'varchar(255)', nullable: false }, + role: { type: 'varchar(20)', nullable: false, default: 'user' }, + payment_status: { type: 'varchar(20)', nullable: false, default: 'unpaid' }, + stripe_customer_id: { type: 'varchar(255)', nullable: true }, + created_at: { type: 'timestamptz', nullable: false, default: 'NOW()' }, + updated_at: { type: 'timestamptz', nullable: false, default: 'NOW()' }, + }, +}; + +const QuizAttemptSchema = { + tableName: 'quiz_attempts', + columns: { + id: { type: 'uuid', primaryKey: true, default: 'gen_random_uuid()' }, + user_id: { type: 'uuid', nullable: false, references: 'users.id' }, + quiz_id: { type: 'varchar(100)', nullable: false }, + score: { type: 'integer', nullable: false }, + total: { type: 'integer', nullable: false }, + percentage: { type: 'decimal(5,2)', nullable: false }, + passed: { type: 'boolean', nullable: false }, + time_spent_s: { type: 'integer', nullable: true }, + answers: { type: 'jsonb', nullable: true }, + started_at: { type: 'timestamptz', nullable: false }, + completed_at: { type: 'timestamptz', nullable: false, default: 'NOW()' }, + }, +}; + +const PaymentSchema = { + tableName: 'payments', + columns: { + id: { type: 'uuid', primaryKey: true, default: 'gen_random_uuid()' }, + user_id: { type: 'uuid', nullable: false, references: 'users.id' }, + stripe_payment_intent_id: { type: 'varchar(255)', unique: true, nullable: false }, + amount_cents: { type: 'integer', nullable: false }, + currency: { type: 'varchar(10)', nullable: false, default: 'usd' }, + status: { type: 'varchar(30)', nullable: false }, + created_at: { type: 'timestamptz', nullable: false, default: 'NOW()' }, + updated_at: { type: 'timestamptz', nullable: false, default: 'NOW()' }, + }, +}; + +const ScoreSchema = { + tableName: 'scores', + columns: { + id: { type: 'uuid', primaryKey: true, default: 'gen_random_uuid()' }, + user_id: { type: 'uuid', nullable: false, references: 'users.id' }, + quiz_id: { type: 'varchar(100)', nullable: false }, + best_score: { type: 'integer', nullable: false }, + best_pct: { type: 'decimal(5,2)', nullable: false }, + attempts: { type: 'integer', nullable: false, default: 1 }, + last_attempt: { type: 'timestamptz', nullable: false, default: 'NOW()' }, + }, + uniqueConstraints: [['user_id', 'quiz_id']], +}; + +module.exports = { + UserSchema, + QuizAttemptSchema, + PaymentSchema, + ScoreSchema, +}; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..b4f5254 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,60 @@ +'use strict'; + +const { Router } = require('express'); +const { body } = require('express-validator'); +const { validate } = require('../middleware/validate'); +const { authLimiter } = require('../middleware/rate-limiter'); +const { authenticateToken, allowGuest } = require('../middleware/auth'); +const authController = require('../controllers/auth.controller'); + +const router = Router(); + +/** + * POST /register + */ +router.post( + '/register', + authLimiter, + [ + body('email') + .isEmail() + .withMessage('A valid email address is required') + .normalizeEmail(), + body('password') + .isLength({ min: 8 }) + .withMessage('Password must be at least 8 characters'), + ], + validate, + authController.register +); + +/** + * POST /login + */ +router.post( + '/login', + authLimiter, + [ + body('email') + .isEmail() + .withMessage('A valid email address is required') + .normalizeEmail(), + body('password') + .notEmpty() + .withMessage('Password is required'), + ], + validate, + authController.login +); + +/** + * POST /guest + */ +router.post('/guest', authLimiter, authController.guest); + +/** + * GET /me + */ +router.get('/me', allowGuest, authController.me); + +module.exports = router; diff --git a/src/routes/health.js b/src/routes/health.js new file mode 100644 index 0000000..fcb7378 --- /dev/null +++ b/src/routes/health.js @@ -0,0 +1,25 @@ +'use strict'; + +const { Router } = require('express'); + +const router = Router(); + +/** + * GET /api/v1/health + * + * Lightweight health-check endpoint for load balancers, + * container orchestrators, and monitoring systems. + */ +router.get('/', (_req, res) => { + res.json({ + success: true, + data: { + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + version: process.env.npm_package_version || '1.0.0', + }, + }); +}); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..ba5a74e --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,15 @@ +'use strict'; + +const { Router } = require('express'); +const healthRoutes = require('./health'); +const paymentRoutes = require('./payment'); +const authRoutes = require('./auth'); + +const router = Router(); + +// ── API v1 routes ────────────────────────────────────── +router.use('/health', healthRoutes); +router.use('/payments', paymentRoutes); +router.use('/auth', authRoutes); + +module.exports = router; diff --git a/src/routes/payment.js b/src/routes/payment.js new file mode 100644 index 0000000..25d646b --- /dev/null +++ b/src/routes/payment.js @@ -0,0 +1,56 @@ +'use strict'; + +const { Router } = require('express'); +const { body, param } = require('express-validator'); +const { validate } = require('../middleware/validate'); +const { paymentLimiter } = require('../middleware/rate-limiter'); +const { allowGuest } = require('../middleware/auth'); +const paymentController = require('../controllers/payment.controller'); + +const router = Router(); + +/** + * GET /config + * Returns the Stripe publishable key for frontend init. + */ +router.get('/config', paymentController.getStripeConfig); + +/** + * POST /create-payment-intent + * Creates a PaymentIntent (server-authoritative amount). + */ +router.post( + '/create-payment-intent', + paymentLimiter, + allowGuest, + [ + body('email') + .optional() + .isEmail() + .withMessage('A valid email address is required') + .normalizeEmail(), + ], + validate, + paymentController.createPaymentIntent +); + +/** + * GET /status/:paymentIntentId + * Retrieve PaymentIntent status. + */ +router.get( + '/status/:paymentIntentId', + [ + param('paymentIntentId') + .isString() + .matches(/^pi_/) + .withMessage('Invalid PaymentIntent ID format'), + ], + validate, + paymentController.getPaymentStatus +); + +// Note: The webhook route is registered separately in app.js +// because it needs the raw body (not JSON-parsed). + +module.exports = router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..db26b86 --- /dev/null +++ b/src/server.js @@ -0,0 +1,52 @@ +'use strict'; + +const app = require('./app'); +const config = require('./config'); +const logger = require('./utils/logger'); + +// ────────────────────────────────────────────────────────── +// Start server +// ────────────────────────────────────────────────────────── +const server = app.listen(config.port, () => { + logger.info( + { + port: config.port, + env: config.env, + stripeMode: config.stripe.secretKey?.startsWith('sk_live') ? 'LIVE' : 'TEST', + }, + `🚀 Exam Practice Pro API running on port ${config.port}` + ); +}); + +// ────────────────────────────────────────────────────────── +// Graceful shutdown +// ────────────────────────────────────────────────────────── +function gracefulShutdown(signal) { + logger.info({ signal }, 'Received shutdown signal — closing server…'); + server.close(() => { + logger.info('HTTP server closed'); + // Close database connections, flush logs, etc. + process.exit(0); + }); + + // Force exit if graceful shutdown takes too long + setTimeout(() => { + logger.error('Graceful shutdown timed out — forcing exit'); + process.exit(1); + }, 10_000); +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + +// ────────────────────────────────────────────────────────── +// Unhandled rejection / exception safety nets +// ────────────────────────────────────────────────────────── +process.on('unhandledRejection', (reason) => { + logger.error({ err: reason }, 'Unhandled promise rejection'); +}); + +process.on('uncaughtException', (err) => { + logger.fatal({ err }, 'Uncaught exception — shutting down'); + gracefulShutdown('uncaughtException'); +}); diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 0000000..e7a1626 --- /dev/null +++ b/src/services/auth.service.js @@ -0,0 +1,125 @@ +'use strict'; + +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); +const { v4: uuidv4 } = require('uuid'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * Authentication service. + * + * Short-term: demo/guest authentication with signed JWTs. + * Long-term: hook into a real user database (see models/user.model.js). + */ +class AuthService { + /** + * Register a new user (demo — stores nothing persistently). + * + * In a real implementation this would write to a database. + * + * @param {Object} params + * @param {string} params.email + * @param {string} params.password + * @returns {Promise<{token: string, user: Object}>} + */ + async register({ email, password }) { + // Hash password (even in demo mode — teaches the right pattern) + const salt = await bcrypt.genSalt(12); + const hashedPassword = await bcrypt.hash(password, salt); + + const user = { + id: uuidv4(), + email, + password: hashedPassword, + role: 'user', + createdAt: new Date().toISOString(), + }; + + logger.info({ userId: user.id, email }, 'User registered (demo)'); + + const token = this._signToken(user); + return { + token, + user: { id: user.id, email: user.email, role: user.role }, + }; + } + + /** + * Authenticate a user with email + password. + * + * Demo mode: accepts any non-empty credentials and returns a valid JWT. + * Production: would look up the user in the database and verify the hash. + * + * @param {Object} params + * @param {string} params.email + * @param {string} params.password + * @returns {Promise<{token: string, user: Object}>} + */ + async login({ email, password }) { + // ── Demo authentication ───────────────────────────── + // In production, replace this block with a real DB lookup: + // const user = await UserModel.findByEmail(email); + // if (!user) throw ... + // const valid = await bcrypt.compare(password, user.password); + // if (!valid) throw ... + // ──────────────────────────────────────────────────── + + if (!email || !password) { + throw Object.assign( + new Error('Email and password are required'), + { statusCode: 400 } + ); + } + + const user = { + id: uuidv4(), + email, + role: 'user', + }; + + logger.info({ userId: user.id, email }, 'User logged in (demo)'); + + const token = this._signToken(user); + return { + token, + user: { id: user.id, email: user.email, role: user.role }, + }; + } + + /** + * Create a guest session with limited privileges. + * + * @returns {{token: string, user: Object}} + */ + createGuestSession() { + const user = { + id: `guest-${uuidv4()}`, + email: null, + role: 'guest', + }; + + logger.info({ userId: user.id }, 'Guest session created'); + + const token = this._signToken(user); + return { + token, + user: { id: user.id, role: user.role }, + }; + } + + /** + * Sign a JWT for the given user payload. + * + * @private + */ + _signToken(user) { + return jwt.sign( + { id: user.id, email: user.email, role: user.role }, + config.jwt.secret || 'dev-secret-do-not-use-in-production', + { expiresIn: config.jwt.expiresIn } + ); + } +} + +module.exports = new AuthService(); diff --git a/src/services/stripe.service.js b/src/services/stripe.service.js new file mode 100644 index 0000000..a4b5c25 --- /dev/null +++ b/src/services/stripe.service.js @@ -0,0 +1,124 @@ +'use strict'; + +const Stripe = require('stripe'); +const config = require('../config'); +const logger = require('../utils/logger'); + +/** + * Stripe service — encapsulates all Stripe interactions. + * + * Uses the Payment Intents API (replaces deprecated Charges API). + */ +class StripeService { + constructor() { + this.stripe = config.stripe.secretKey + ? new Stripe(config.stripe.secretKey, { apiVersion: '2024-06-20' }) + : null; + + if (!this.stripe) { + logger.warn('Stripe secret key not configured — payment features disabled'); + } + } + + /** + * Create a PaymentIntent for the given amount. + * + * @param {Object} params + * @param {number} params.amount — amount in cents (server-authoritative) + * @param {string} params.currency + * @param {string} [params.customerEmail] + * @param {Object} [params.metadata] + * @returns {Promise<{clientSecret: string, paymentIntentId: string}>} + */ + async createPaymentIntent({ amount, currency, customerEmail, metadata = {} }) { + if (!this.stripe) { + throw Object.assign(new Error('Stripe is not configured'), { statusCode: 503 }); + } + + // Server-side amount validation — prevent client tampering + const validAmount = this._validateAmount(amount); + + const paymentIntent = await this.stripe.paymentIntents.create({ + amount: validAmount, + currency: currency || config.stripe.currency, + automatic_payment_methods: { enabled: true }, + metadata: { + ...metadata, + ...(customerEmail && { customerEmail }), + }, + ...(customerEmail && { receipt_email: customerEmail }), + }); + + logger.info( + { paymentIntentId: paymentIntent.id, amount: validAmount }, + 'PaymentIntent created' + ); + + return { + clientSecret: paymentIntent.client_secret, + paymentIntentId: paymentIntent.id, + }; + } + + /** + * Verify and construct a Stripe webhook event. + * + * @param {Buffer} rawBody — raw request body + * @param {string} signature — Stripe-Signature header value + * @returns {Stripe.Event} + */ + constructWebhookEvent(rawBody, signature) { + if (!this.stripe) { + throw Object.assign(new Error('Stripe is not configured'), { statusCode: 503 }); + } + + if (!config.stripe.webhookSecret) { + throw Object.assign( + new Error('Stripe webhook secret not configured'), + { statusCode: 503 } + ); + } + + return this.stripe.webhooks.constructEvent( + rawBody, + signature, + config.stripe.webhookSecret + ); + } + + /** + * Retrieve an existing PaymentIntent by ID. + */ + async getPaymentIntent(paymentIntentId) { + if (!this.stripe) { + throw Object.assign(new Error('Stripe is not configured'), { statusCode: 503 }); + } + return this.stripe.paymentIntents.retrieve(paymentIntentId); + } + + /** + * Validate amount against the known product catalog. + * Prevents client from sending an arbitrary (lower) amount. + * + * @private + * @param {number} requestedAmount — amount in cents + * @returns {number} validated amount + */ + _validateAmount(requestedAmount) { + const allowedAmounts = [config.stripe.fullAccessAmountCents]; + + if (!allowedAmounts.includes(requestedAmount)) { + throw Object.assign( + new Error( + `Invalid payment amount: ${requestedAmount}. Allowed: ${allowedAmounts.join(', ')}` + ), + { statusCode: 400 } + ); + } + + return requestedAmount; + } +} + +// Export singleton +module.exports = new StripeService(); diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..a1a974c --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,26 @@ +'use strict'; + +const pino = require('pino'); +const config = require('../config'); + +/** + * Application-wide logger (Pino). + * + * - In development: pretty-printed output via pino-pretty. + * - In production : structured JSON for ingestion by log aggregators. + */ +const logger = pino({ + level: config.logLevel, + ...(config.env !== 'production' && { + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, + }), +}); + +module.exports = logger;