CognxSafeTrack commited on
Commit
c8a4f4b
·
1 Parent(s): e73f123

fix(security): audit fixes — Stripe webhook scope, fail-fast secrets, prisma singleton, worker auth, Zod validation, WA number env var

Browse files
.env.example ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment Variables — EdTech
2
+
3
+ # Copy this file to .env and fill in the values.
4
+ # NEVER commit the real .env file to git.
5
+
6
+ # ─── API Auth ──────────────────────────────────────────────────────────────────
7
+ ADMIN_API_KEY= # Strong random secret, e.g. openssl rand -hex 32
8
+
9
+ # ─── WhatsApp / Meta ───────────────────────────────────────────────────────────
10
+ WHATSAPP_VERIFY_TOKEN= # Token you set in the Meta App dashboard
11
+ WHATSAPP_APP_SECRET= # App Secret from Meta App Settings > Basic
12
+ WHATSAPP_ACCESS_TOKEN= # Permanent System User token from Meta Business
13
+
14
+ # ─── Database ──────────────────────────────────────────────────────────────────
15
+ DATABASE_URL=postgresql://user:password@localhost:5432/edtech?schema=public
16
+
17
+ # ─── Redis ─────────────────────────────────────────────────────────────────────
18
+ REDIS_URL= # e.g. redis://default:password@host:6379
19
+ # Or individual connection params (if REDIS_URL is not set):
20
+ # REDIS_HOST=localhost
21
+ # REDIS_PORT=6379
22
+ # REDIS_USERNAME=default
23
+ # REDIS_PASSWORD=
24
+ # REDIS_TLS=false
25
+
26
+ # ─── Stripe ────────────────────────────────────────────────────────────────────
27
+ STRIPE_SECRET_KEY= # sk_live_... (or sk_test_... for dev)
28
+ STRIPE_WEBHOOK_SECRET= # whsec_... from Stripe dashboard > Webhooks
29
+
30
+ # ─── OpenAI / AI ───────────────────────────────────────────────────────────────
31
+ OPENAI_API_KEY= # sk-...
32
+
33
+ # ─── Frontend ──────────────────────────────────────────────────────────────────
34
+ VITE_CLIENT_URL=https://your-frontend.netlify.app
35
+ VITE_WHATSAPP_NUMBER=221771234567 # Without + prefix, for wa.me links
36
+
37
+ # ─── Internal (Worker → API) ───────────────────────────────────────────────────
38
+ API_URL=http://localhost:3001 # In prod: full URL of the Fastify API
apps/api/src/index.ts CHANGED
@@ -3,7 +3,16 @@ import cors from '@fastify/cors';
3
  import { whatsappRoutes } from './routes/whatsapp';
4
  import { adminRoutes } from './routes/admin';
5
  import { aiRoutes } from './routes/ai';
6
- import { paymentRoutes } from './routes/payments';
 
 
 
 
 
 
 
 
 
7
 
8
  const server = Fastify({
9
  logger: true,
@@ -31,8 +40,10 @@ async function setupRateLimit() {
31
  // ── Public Routes (no auth) ────────────────────────────────────────────────────
32
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
33
 
 
 
 
34
  // ── Private Routes (require ADMIN_API_KEY) ─────────────────────────────────────
35
- // Inline addHook on guardedRoutes scope — properly encapsulated in Fastify v4
36
  server.register(async function guardedRoutes(scope) {
37
  scope.addHook('onRequest', async (request, reply) => {
38
  const apiKey = process.env.ADMIN_API_KEY;
 
3
  import { whatsappRoutes } from './routes/whatsapp';
4
  import { adminRoutes } from './routes/admin';
5
  import { aiRoutes } from './routes/ai';
6
+ import { paymentRoutes, stripeWebhookRoute } from './routes/payments';
7
+
8
+ // ── Fail-fast: vérifier les secrets critiques au démarrage ─────────────────────
9
+ const REQUIRED_ENV = ['ADMIN_API_KEY', 'WHATSAPP_APP_SECRET', 'WHATSAPP_VERIFY_TOKEN'];
10
+ for (const key of REQUIRED_ENV) {
11
+ if (!process.env[key]) {
12
+ console.error(`[STARTUP] ❌ Missing required environment variable: ${key}`);
13
+ process.exit(1);
14
+ }
15
+ }
16
 
17
  const server = Fastify({
18
  logger: true,
 
40
  // ── Public Routes (no auth) ────────────────────────────────────────────────────
41
  server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
42
 
43
+ // ── Stripe Webhook (public — Stripe can't send API Key, verified by signature) ─
44
+ server.register(stripeWebhookRoute, { prefix: '/v1/payments' });
45
+
46
  // ── Private Routes (require ADMIN_API_KEY) ─────────────────────────────────────
 
47
  server.register(async function guardedRoutes(scope) {
48
  scope.addHook('onRequest', async (request, reply) => {
49
  const apiKey = process.env.ADMIN_API_KEY;
apps/api/src/routes/admin.ts CHANGED
@@ -1,7 +1,6 @@
1
  import { FastifyInstance } from 'fastify';
2
- import { PrismaClient } from '@prisma/client';
3
 
4
- const prisma = new PrismaClient();
5
 
6
  export async function adminRoutes(fastify: FastifyInstance) {
7
  // 1. Get Dashboard Stats
 
1
  import { FastifyInstance } from 'fastify';
2
+ import { prisma } from '../services/prisma';
3
 
 
4
 
5
  export async function adminRoutes(fastify: FastifyInstance) {
6
  // 1. Get Dashboard Stats
apps/api/src/routes/payments.ts CHANGED
@@ -1,20 +1,26 @@
1
  import { FastifyInstance } from 'fastify';
2
  import { stripeService } from '../services/stripe';
 
 
3
 
4
- // Use the local PrismaClient to avoid the module resolution issue. We'll instantiate it here.
5
- import { PrismaClient } from '@prisma/client';
6
- const prisma = new PrismaClient();
 
 
7
 
 
8
  export async function paymentRoutes(fastify: FastifyInstance) {
9
 
10
  // Create a Checkout Session
11
  fastify.post('/checkout', async (request, reply) => {
12
- const { userId, trackId } = request.body as { userId: string, trackId: string };
13
-
14
- if (!userId || !trackId) {
15
- return reply.status(400).send({ error: 'Missing userId or trackId' });
16
  }
17
 
 
 
18
  try {
19
  // Validate the track exists and is premium
20
  const track = await prisma.track.findUnique({ where: { id: trackId } });
@@ -42,10 +48,21 @@ export async function paymentRoutes(fastify: FastifyInstance) {
42
  return reply.status(500).send({ error: 'Failed to create checkout session' });
43
  }
44
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- // Handle Stripe Webhook
47
- // Note: We need the raw body to verify the signature. Fastify requires specific config for this.
48
- fastify.post('/webhook', { config: { rawBody: true } }, async (request, reply) => {
49
  const sig = request.headers['stripe-signature'];
50
 
51
  if (!sig || typeof sig !== 'string') {
@@ -55,9 +72,10 @@ export async function paymentRoutes(fastify: FastifyInstance) {
55
  let event;
56
 
57
  try {
58
- // @ts-ignore - Assuming rawBody plugin is configured on the Fastify instance
59
- event = stripeService.verifyWebhookSignature(request.rawBody || request.body, sig);
60
  } catch (err: any) {
 
61
  return reply.status(400).send(`Webhook Error: ${err.message}`);
62
  }
63
 
@@ -71,7 +89,6 @@ export async function paymentRoutes(fastify: FastifyInstance) {
71
 
72
  if (userId && trackId) {
73
  try {
74
- // Start a transaction: Record payment and Create Enrollment
75
  await prisma.$transaction(async (tx: any) => {
76
  // 1. Record the payment
77
  await tx.payment.create({
@@ -85,8 +102,7 @@ export async function paymentRoutes(fastify: FastifyInstance) {
85
  }
86
  });
87
 
88
- // 2. Create the Enrollment
89
- // Check if an enrollment already exists to avoid duplicates
90
  const existingEnrollment = await tx.enrollment.findFirst({
91
  where: { userId, trackId }
92
  });
@@ -100,18 +116,16 @@ export async function paymentRoutes(fastify: FastifyInstance) {
100
  currentDay: 1
101
  }
102
  });
103
- fastify.log.info(`[PaymentRoute] Enrollment created for User ${userId}, Track ${trackId}`);
104
  }
105
  });
106
  } catch (dbError) {
107
- fastify.log.error(dbError, '[PaymentRoute] Database error during webhook processing');
108
- // Standard practice is to still return a 200 to Stripe so it doesn't retry infinitely,
109
- // but you'd want robust alerting here.
110
  }
111
  }
112
  }
113
 
114
- // Return a 200 response to acknowledge receipt of the event
115
  reply.send({ received: true });
116
  });
117
  }
 
1
  import { FastifyInstance } from 'fastify';
2
  import { stripeService } from '../services/stripe';
3
+ import { prisma } from '../services/prisma';
4
+ import { z } from 'zod';
5
 
6
+ // ─── Shared Zod schemas ────────────────────────────────────────────────────────
7
+ const checkoutSchema = z.object({
8
+ userId: z.string().uuid(),
9
+ trackId: z.string().uuid(),
10
+ });
11
 
12
+ // ─── Private routes (require ADMIN_API_KEY) ───────────────────────────────────
13
  export async function paymentRoutes(fastify: FastifyInstance) {
14
 
15
  // Create a Checkout Session
16
  fastify.post('/checkout', async (request, reply) => {
17
+ const parseResult = checkoutSchema.safeParse(request.body);
18
+ if (!parseResult.success) {
19
+ return reply.status(400).send({ error: 'Invalid request body', details: parseResult.error.flatten() });
 
20
  }
21
 
22
+ const { userId, trackId } = parseResult.data;
23
+
24
  try {
25
  // Validate the track exists and is premium
26
  const track = await prisma.track.findUnique({ where: { id: trackId } });
 
48
  return reply.status(500).send({ error: 'Failed to create checkout session' });
49
  }
50
  });
51
+ }
52
+
53
+ // ─── Public Stripe webhook (no API key auth — secured by Stripe signature) ────
54
+ export async function stripeWebhookRoute(fastify: FastifyInstance) {
55
+ // Capture raw body buffer for Stripe signature verification
56
+ fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
57
+ (req as any).rawBody = body;
58
+ try {
59
+ done(null, JSON.parse(body.toString('utf8')));
60
+ } catch (err: any) {
61
+ done(err as Error, undefined as unknown as Buffer);
62
+ }
63
+ });
64
 
65
+ fastify.post('/webhook', async (request, reply) => {
 
 
66
  const sig = request.headers['stripe-signature'];
67
 
68
  if (!sig || typeof sig !== 'string') {
 
72
  let event;
73
 
74
  try {
75
+ const rawBody = (request as any).rawBody as Buffer;
76
+ event = stripeService.verifyWebhookSignature(rawBody, sig);
77
  } catch (err: any) {
78
+ fastify.log.warn(`[Stripe Webhook] Signature verification failed: ${err.message}`);
79
  return reply.status(400).send(`Webhook Error: ${err.message}`);
80
  }
81
 
 
89
 
90
  if (userId && trackId) {
91
  try {
 
92
  await prisma.$transaction(async (tx: any) => {
93
  // 1. Record the payment
94
  await tx.payment.create({
 
102
  }
103
  });
104
 
105
+ // 2. Create the Enrollment (dedup)
 
106
  const existingEnrollment = await tx.enrollment.findFirst({
107
  where: { userId, trackId }
108
  });
 
116
  currentDay: 1
117
  }
118
  });
119
+ fastify.log.info(`[Stripe Webhook] Enrollment created for User ${userId}, Track ${trackId}`);
120
  }
121
  });
122
  } catch (dbError) {
123
+ fastify.log.error(dbError, '[Stripe Webhook] Database error during webhook processing');
124
+ // Still return 200 so Stripe doesn't retry endlessly — but alert monitoring.
 
125
  }
126
  }
127
  }
128
 
 
129
  reply.send({ received: true });
130
  });
131
  }
apps/api/src/services/stripe.ts CHANGED
@@ -6,9 +6,13 @@ export class StripeService {
6
  private clientUrl: string;
7
 
8
  constructor() {
9
- // Initialize Stripe. Defaults to a dummy key if env var is missing during dev.
10
- const secretKey = process.env.STRIPE_SECRET_KEY || 'sk_test_dummy';
11
- this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_dummy';
 
 
 
 
12
  this.clientUrl = process.env.VITE_CLIENT_URL || 'http://localhost:5174';
13
 
14
  this.stripe = new Stripe(secretKey, {
 
6
  private clientUrl: string;
7
 
8
  constructor() {
9
+ const secretKey = process.env.STRIPE_SECRET_KEY;
10
+ if (!secretKey) throw new Error('[StripeService] STRIPE_SECRET_KEY is required');
11
+
12
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
13
+ if (!webhookSecret) throw new Error('[StripeService] STRIPE_WEBHOOK_SECRET is required');
14
+
15
+ this.webhookSecret = webhookSecret;
16
  this.clientUrl = process.env.VITE_CLIENT_URL || 'http://localhost:5174';
17
 
18
  this.stripe = new Stripe(secretKey, {
apps/web/src/App.tsx CHANGED
@@ -1,6 +1,8 @@
1
  import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react-router-dom';
2
  import { BookOpen, FileText, Smartphone, ArrowRight, Phone } from 'lucide-react';
3
 
 
 
4
  function Navbar() {
5
  return (
6
  <nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
@@ -17,7 +19,7 @@ function Navbar() {
17
  Student Login
18
  </Link>
19
  <a
20
- href="https://wa.me/1234567890?text=Hello! I want to enroll." // Placeholder for actual bot link
21
  target="_blank"
22
  rel="noreferrer"
23
  className="bg-primary text-white px-5 py-2.5 rounded-full font-medium hover:bg-emerald-700 hover:shadow-lg hover:shadow-primary/30 transition-all active:scale-95 hidden sm:inline-flex items-center"
@@ -60,7 +62,7 @@ function Hero() {
60
 
61
  <div className="flex flex-col sm:flex-row justify-center items-center gap-4">
62
  <a
63
- href="https://wa.me/1234567890?text=Start"
64
  target="_blank"
65
  rel="noreferrer"
66
  className="w-full sm:w-auto bg-primary text-white text-lg px-8 py-4 rounded-full font-bold shadow-xl shadow-primary/30 hover:bg-emerald-700 hover:scale-105 transition-all flex items-center justify-center group"
 
1
  import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react-router-dom';
2
  import { BookOpen, FileText, Smartphone, ArrowRight, Phone } from 'lucide-react';
3
 
4
+ const WA_NUMBER = import.meta.env.VITE_WHATSAPP_NUMBER || '221771234567';
5
+
6
  function Navbar() {
7
  return (
8
  <nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
 
19
  Student Login
20
  </Link>
21
  <a
22
+ href={`https://wa.me/${WA_NUMBER}?text=Hello! I want to enroll.`}
23
  target="_blank"
24
  rel="noreferrer"
25
  className="bg-primary text-white px-5 py-2.5 rounded-full font-medium hover:bg-emerald-700 hover:shadow-lg hover:shadow-primary/30 transition-all active:scale-95 hidden sm:inline-flex items-center"
 
62
 
63
  <div className="flex flex-col sm:flex-row justify-center items-center gap-4">
64
  <a
65
+ href={`https://wa.me/${WA_NUMBER}?text=Start`}
66
  target="_blank"
67
  rel="noreferrer"
68
  className="w-full sm:w-auto bg-primary text-white text-lg px-8 py-4 rounded-full font-bold shadow-xl shadow-primary/30 hover:bg-emerald-700 hover:scale-105 transition-all flex items-center justify-center group"
apps/web/src/vite-env.d.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_API_URL: string;
5
+ readonly VITE_CLIENT_URL: string;
6
+ readonly VITE_WHATSAPP_NUMBER: string;
7
+ }
8
+
9
+ interface ImportMeta {
10
+ readonly env: ImportMetaEnv;
11
+ }
apps/whatsapp-worker/src/index.ts CHANGED
@@ -40,10 +40,14 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
40
  if (track.isPremium) {
41
  console.log(`[WORKER] User ${userId} requested Premium Track ${trackId}. Generating Payment Link...`);
42
  try {
43
- const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
 
44
  const checkoutRes = await fetch(`${API_URL}/v1/payments/checkout`, {
45
  method: 'POST',
46
- headers: { 'Content-Type': 'application/json' },
 
 
 
47
  body: JSON.stringify({ userId, trackId })
48
  });
49
 
@@ -87,10 +91,14 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
87
  if (user && user.activity) {
88
  try {
89
  console.log(`[WORKER] Personalizing lesson for User ${userId}'s activity: ${user.activity}`);
90
- const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
 
91
  const personalizeRes = await fetch(`${API_URL}/v1/ai/personalize-lesson`, {
92
  method: 'POST',
93
- headers: { 'Content-Type': 'application/json' },
 
 
 
94
  body: JSON.stringify({ lessonText: trackDay.textContent, userActivity: user.activity })
95
  });
96
 
@@ -109,10 +117,14 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
109
  let audioUrl = null;
110
  try {
111
  console.log(`[WORKER] Generating TTS Audio for User ${userId}...`);
112
- const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
 
113
  const ttsRes = await fetch(`${API_URL}/v1/ai/tts`, {
114
  method: 'POST',
115
- headers: { 'Content-Type': 'application/json' },
 
 
 
116
  body: JSON.stringify({ text: finalContent })
117
  });
118
 
@@ -154,12 +166,17 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
154
  // Update userContext to explicitly reference the user's sector/activity
155
  const userContext = `User ${userId} completed the Business Pitch track. Their business activity/sector is: ${user?.activity || 'Unknown'}. They want to build a business in this sector using the concepts learned in the track.`;
156
 
157
- const API_URL = process.env.VITE_API_URL || 'http://localhost:3001';
 
 
 
 
 
158
 
159
  // Trigger OnePager (PDF)
160
  const pdfRes = await fetch(`${API_URL}/v1/ai/onepager`, {
161
  method: 'POST',
162
- headers: { 'Content-Type': 'application/json' },
163
  body: JSON.stringify({ userContext })
164
  });
165
  const pdfData = await pdfRes.json();
@@ -167,7 +184,7 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
167
  // Trigger Pitch Deck (PPTX)
168
  const pptxRes = await fetch(`${API_URL}/v1/ai/deck`, {
169
  method: 'POST',
170
- headers: { 'Content-Type': 'application/json' },
171
  body: JSON.stringify({ userContext })
172
  });
173
  const pptxData = await pptxRes.json();
 
40
  if (track.isPremium) {
41
  console.log(`[WORKER] User ${userId} requested Premium Track ${trackId}. Generating Payment Link...`);
42
  try {
43
+ const API_URL = process.env.API_URL || 'http://localhost:3001';
44
+ const apiKey = process.env.ADMIN_API_KEY || '';
45
  const checkoutRes = await fetch(`${API_URL}/v1/payments/checkout`, {
46
  method: 'POST',
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ 'Authorization': `Bearer ${apiKey}`
50
+ },
51
  body: JSON.stringify({ userId, trackId })
52
  });
53
 
 
91
  if (user && user.activity) {
92
  try {
93
  console.log(`[WORKER] Personalizing lesson for User ${userId}'s activity: ${user.activity}`);
94
+ const API_URL = process.env.API_URL || 'http://localhost:3001';
95
+ const apiKey = process.env.ADMIN_API_KEY || '';
96
  const personalizeRes = await fetch(`${API_URL}/v1/ai/personalize-lesson`, {
97
  method: 'POST',
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ 'Authorization': `Bearer ${apiKey}`
101
+ },
102
  body: JSON.stringify({ lessonText: trackDay.textContent, userActivity: user.activity })
103
  });
104
 
 
117
  let audioUrl = null;
118
  try {
119
  console.log(`[WORKER] Generating TTS Audio for User ${userId}...`);
120
+ const API_URL = process.env.API_URL || 'http://localhost:3001';
121
+ const apiKey = process.env.ADMIN_API_KEY || '';
122
  const ttsRes = await fetch(`${API_URL}/v1/ai/tts`, {
123
  method: 'POST',
124
+ headers: {
125
+ 'Content-Type': 'application/json',
126
+ 'Authorization': `Bearer ${apiKey}`
127
+ },
128
  body: JSON.stringify({ text: finalContent })
129
  });
130
 
 
166
  // Update userContext to explicitly reference the user's sector/activity
167
  const userContext = `User ${userId} completed the Business Pitch track. Their business activity/sector is: ${user?.activity || 'Unknown'}. They want to build a business in this sector using the concepts learned in the track.`;
168
 
169
+ const API_URL = process.env.API_URL || 'http://localhost:3001';
170
+ const apiKey = process.env.ADMIN_API_KEY || '';
171
+ const authHeaders = {
172
+ 'Content-Type': 'application/json',
173
+ 'Authorization': `Bearer ${apiKey}`
174
+ };
175
 
176
  // Trigger OnePager (PDF)
177
  const pdfRes = await fetch(`${API_URL}/v1/ai/onepager`, {
178
  method: 'POST',
179
+ headers: authHeaders,
180
  body: JSON.stringify({ userContext })
181
  });
182
  const pdfData = await pdfRes.json();
 
184
  // Trigger Pitch Deck (PPTX)
185
  const pptxRes = await fetch(`${API_URL}/v1/ai/deck`, {
186
  method: 'POST',
187
+ headers: authHeaders,
188
  body: JSON.stringify({ userContext })
189
  });
190
  const pptxData = await pptxRes.json();