CognxSafeTrack commited on
Commit ·
5e23bc3
1
Parent(s): 3d1299e
docs: add developer audit guide (2026-05-15)
Browse files
docs/audit-developpeur-2026-05-15.md
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Audit Technique XAMLÉ — Guide Développeur
|
| 2 |
+
**Date :** 15 Mai 2026
|
| 3 |
+
**Statut :** Référence active — à mettre à jour après chaque correction majeure
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Résumé exécutif
|
| 8 |
+
|
| 9 |
+
| # | Problème | Sévérité | Statut |
|
| 10 |
+
|---|---|---|---|
|
| 11 |
+
| 1 | `.env` commité avec secrets réels (tokens Meta, OpenAI, DB, R2) | 🔴 CRITIQUE | À régler |
|
| 12 |
+
| 2 | SQL Injection sur `/v1/analytics/query` (text-to-SQL) | 🔴 CRITIQUE | À régler |
|
| 13 |
+
| 3 | JWT token exposé en query param dans les URLs SSE | 🟠 HAUT | Workaround actif |
|
| 14 |
+
| 4 | OAuth WhatsApp sans validation CSRF (state parameter) | 🟠 HAUT | À régler |
|
| 15 |
+
| 5 | File upload sans limite de taille dans `/v1/admin/training/upload` | 🟠 HAUT | À régler |
|
| 16 |
+
| 6 | Pas de Dead Letter Queue — jobs BullMQ échoués silencieusement | 🟠 HAUT | À régler |
|
| 17 |
+
| 7 | Validation Zod incomplète au démarrage Worker | 🟠 HAUT | Partiel |
|
| 18 |
+
| 8 | `META_GRAPH_API_VERSION` : fallback hardcodé dans 7 fichiers | 🟡 MOYEN | Partiel |
|
| 19 |
+
| 9 | Index DB manquants sur tables Analytics/Message/User | 🟡 MOYEN | À régler |
|
| 20 |
+
| 10 | JWT en sessionStorage (XSS possible) | 🟡 MOYEN | À régler |
|
| 21 |
+
| 11 | Catch silencieux dans `fetchMetaStatus` | 🟡 MOYEN | Partiellement corrigé |
|
| 22 |
+
| 12 | `wabaId` sans contrainte unique (retiré le 15/05) | 🟡 MOYEN | Choix délibéré, surveiller |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Architecture réelle du système
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 30 |
+
│ Admin Frontend (Netlify + React/Vite) │
|
| 31 |
+
│ VITE_API_URL = https://safetrack-edtech.hf.space │
|
| 32 |
+
│ Auth : JWT en sessionStorage → header Authorization: Bearer ... │
|
| 33 |
+
│ SSE : ?token= en query param (limite EventSource) │
|
| 34 |
+
└───────────────────────────┬──────────────────────────────────────┘
|
| 35 |
+
│ REST (Bearer JWT)
|
| 36 |
+
│ SSE (?token=)
|
| 37 |
+
▼
|
| 38 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 39 |
+
│ HuggingFace Space "safetrack/edtech" [port 7860] │
|
| 40 |
+
│ apps/api — Fastify v4 + Prisma + Redis │
|
| 41 |
+
│ Routes : /v1/auth, /v1/organizations, /v1/analytics, /v1/admin │
|
| 42 |
+
│ ⚠️ Réseau HF bloqué vers graph.facebook.com │
|
| 43 |
+
└───────────────────────────┬──────────────────────────────────────┘
|
| 44 |
+
│ BullMQ (Upstash Redis partagé)
|
| 45 |
+
│ HTTP POST + ADMIN_API_KEY
|
| 46 |
+
▼
|
| 47 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 48 |
+
│ Railway "whatsapp-worker" │
|
| 49 |
+
│ PM2 : API Fastify [7860] + Worker BullMQ + Bridge [8082] │
|
| 50 |
+
│ ✅ Seul service à pouvoir appeler graph.facebook.com │
|
| 51 |
+
│ ✅ Seul service à déchiffrer + utiliser systemUserToken │
|
| 52 |
+
└──────────────────────────────────────────────────────────────────┘
|
| 53 |
+
│ │
|
| 54 |
+
▼ ▼
|
| 55 |
+
Neon PostgreSQL Cloudflare R2
|
| 56 |
+
(multi-tenant) (audio/images)
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### Règles fondamentales à ne pas violer
|
| 60 |
+
|
| 61 |
+
1. **Répondre 200 OK en < 100ms** au webhook Meta, puis `setImmediate()` pour le traitement
|
| 62 |
+
2. **Prisma AVANT sendWhatsApp** — ne jamais envoyer un message si la DB a échoué
|
| 63 |
+
3. **Verrou Redis (300s)** sur chaque job qui appelle un LLM ou envoie un message WhatsApp
|
| 64 |
+
4. **HuggingFace ne peut pas appeler Meta directement** — tout passe par BullMQ → Worker Railway
|
| 65 |
+
5. **ENCRYPTION_SECRET identique** sur HF et Railway — sinon les tokens ne peuvent pas être déchiffrés
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## Problèmes détaillés
|
| 70 |
+
|
| 71 |
+
### 🔴 CRITIQUE — `.env` commité avec secrets réels
|
| 72 |
+
|
| 73 |
+
**Fichier :** `/.env` (à la racine du repo)
|
| 74 |
+
|
| 75 |
+
**Ce qui est exposé :**
|
| 76 |
+
- `WHATSAPP_ACCESS_TOKEN` (token Meta long-lived `EAA...`)
|
| 77 |
+
- `OPENAI_API_KEY` (clé OpenAI)
|
| 78 |
+
- `GOOGLE_AI_API_KEY` (clé Gemini)
|
| 79 |
+
- `DATABASE_URL` (URL Neon PostgreSQL avec mot de passe)
|
| 80 |
+
- `R2_SECRET_ACCESS_KEY` (clé Cloudflare R2)
|
| 81 |
+
- `ENCRYPTION_SECRET` (clé de chiffrement des tokens orgs en DB)
|
| 82 |
+
|
| 83 |
+
**Impact :** Toute personne avec accès au repo (collaborateurs, forks, GitHub history) peut accéder à tous les services de production.
|
| 84 |
+
|
| 85 |
+
**Procédure de correction :**
|
| 86 |
+
```bash
|
| 87 |
+
# 1. Ajouter au .gitignore (si pas déjà fait)
|
| 88 |
+
echo ".env" >> .gitignore
|
| 89 |
+
|
| 90 |
+
# 2. Retirer du tracking git
|
| 91 |
+
git rm --cached .env
|
| 92 |
+
|
| 93 |
+
# 3. Supprimer de l'historique git (BFG)
|
| 94 |
+
brew install bfg
|
| 95 |
+
bfg --delete-files .env
|
| 96 |
+
git reflog expire --expire=now --all && git gc --prune=now --aggressive
|
| 97 |
+
git push origin main --force
|
| 98 |
+
|
| 99 |
+
# 4. Révoquer TOUS les secrets immédiatement (même si l'historique est nettoyé)
|
| 100 |
+
# - Meta : developers.facebook.com → App → WhatsApp → Révoquer token
|
| 101 |
+
# - OpenAI : platform.openai.com → API Keys → Delete
|
| 102 |
+
# - Google AI : console.cloud.google.com → APIs & Services → Credentials
|
| 103 |
+
# - Neon : console.neon.tech → Reset password
|
| 104 |
+
# - Cloudflare R2 : Cloudflare Dashboard → R2 → API tokens → Delete
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
### 🔴 CRITIQUE — SQL Injection sur `/v1/analytics/query`
|
| 110 |
+
|
| 111 |
+
**Fichier :** `apps/api/src/routes/analytics.ts`
|
| 112 |
+
|
| 113 |
+
**Fonctionnement actuel :** Le endpoint reçoit une question en langage naturel → GPT-4o génère du SQL → le SQL est exécuté via `prisma.$executeRawUnsafe`.
|
| 114 |
+
|
| 115 |
+
**Défenses actuelles (insuffisantes) :**
|
| 116 |
+
```typescript
|
| 117 |
+
// 1. Regex UNION — bypassable par commentaires SQL
|
| 118 |
+
if (/union/i.test(sql)) return 400; // ❌ /*uni*/on passe
|
| 119 |
+
|
| 120 |
+
// 2. Whitelist de tables — extraction regex incomplète
|
| 121 |
+
const tables = [...sql.matchAll(/(?:FROM|JOIN)\s+"?(\w+)"?/gi)];
|
| 122 |
+
// ❌ Rate les sous-requêtes : SELECT * FROM (SELECT * FROM Organization) AS t
|
| 123 |
+
|
| 124 |
+
// 3. Injection organizationId — remplacement naïf
|
| 125 |
+
const safeSql = sql.replace(/'<ORG_ID>'/g, `'${orgId}'`);
|
| 126 |
+
// ❌ Si le LLM laisse <ORG_ID> dans un contexte non prévu
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
**Scénario d'attaque :** Un admin formule une question qui conduit GPT à générer un SQL accédant aux données d'une autre organisation.
|
| 130 |
+
|
| 131 |
+
**Correction recommandée :**
|
| 132 |
+
```typescript
|
| 133 |
+
// Option A : Remplacer par un query builder paramétré
|
| 134 |
+
// Le LLM retourne une structure JSON, pas du SQL brut
|
| 135 |
+
const QuerySchema = z.object({
|
| 136 |
+
table: z.enum(['Message', 'Enrollment', 'User', 'Contact']),
|
| 137 |
+
filters: z.record(z.union([z.string(), z.number()])),
|
| 138 |
+
groupBy: z.string().optional(),
|
| 139 |
+
orderBy: z.string().optional(),
|
| 140 |
+
limit: z.number().max(1000).optional(),
|
| 141 |
+
});
|
| 142 |
+
// Construire le SQL avec Prisma à partir de cette structure
|
| 143 |
+
|
| 144 |
+
// Option B : Parser le SQL (AST) avant exécution
|
| 145 |
+
import { parse } from 'node-sql-parser';
|
| 146 |
+
const ast = parse(sql);
|
| 147 |
+
// Vérifier que toutes les tables dans l'AST sont dans la whitelist
|
| 148 |
+
// Vérifier qu'il n'y a pas de sous-requêtes, CTEs, etc.
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
### 🟠 HAUT — JWT en query param pour SSE
|
| 154 |
+
|
| 155 |
+
**Fichiers :**
|
| 156 |
+
- `apps/api/src/middleware/verifyJwt.ts` (backend)
|
| 157 |
+
- `apps/admin/src/components/layouts/MainLayout.tsx` (frontend)
|
| 158 |
+
- `apps/admin/src/pages/CrmConversationalDashboard.tsx` (frontend)
|
| 159 |
+
|
| 160 |
+
**Contexte :** L'API `EventSource` du navigateur ne supporte pas les headers custom. Solution de contournement : passer le JWT en `?token=` dans l'URL.
|
| 161 |
+
|
| 162 |
+
**Risques :**
|
| 163 |
+
- Token visible dans les logs proxy/CDN/WAF
|
| 164 |
+
- Token dans l'historique du navigateur
|
| 165 |
+
- Token exposé en cas de partage d'écran ou de screenshot DevTools
|
| 166 |
+
|
| 167 |
+
**Code actuel :**
|
| 168 |
+
```typescript
|
| 169 |
+
// Backend (verifyJwt.ts)
|
| 170 |
+
const queryToken = (request.query as Record<string, string>)?.token;
|
| 171 |
+
if (queryToken && !request.headers.authorization) {
|
| 172 |
+
request.headers.authorization = `Bearer ${queryToken}`;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Frontend (MainLayout.tsx)
|
| 176 |
+
const es = new EventSource(`${apiBase}/v1/organizations/${orgId}/stream?token=${encodeURIComponent(token)}`);
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
**Correction à long terme :** Utiliser un token SSE éphémère (court TTL, scope limité) :
|
| 180 |
+
```typescript
|
| 181 |
+
// 1. Frontend demande un token SSE court (15 min)
|
| 182 |
+
const { sseToken } = await api.post('/v1/auth/sse-token', {}, longJwt);
|
| 183 |
+
|
| 184 |
+
// 2. Backend génère un JWT avec scope='sse' + TTL 15 min
|
| 185 |
+
app.post('/v1/auth/sse-token', async (req) => {
|
| 186 |
+
const sseToken = fastify.jwt.sign(
|
| 187 |
+
{ userId: req.user.id, orgId: req.user.organizationId, scope: 'sse' },
|
| 188 |
+
{ expiresIn: '15m' }
|
| 189 |
+
);
|
| 190 |
+
return { sseToken };
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
// 3. Backend vérifie le scope sur le stream endpoint
|
| 194 |
+
if (payload.scope !== 'sse') return reply.code(403);
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
### 🟠 HAUT — OAuth WhatsApp sans CSRF protection
|
| 200 |
+
|
| 201 |
+
**Fichier :** `apps/api/src/routes/organizations.ts` (ligne ~163)
|
| 202 |
+
|
| 203 |
+
**Problème :** L'échange du code OAuth Meta ne vérifie pas un paramètre `state`, exposant au Cross-Site Request Forgery :
|
| 204 |
+
```typescript
|
| 205 |
+
// ❌ Pas de vérification state
|
| 206 |
+
const exchangeRes = await fetch(
|
| 207 |
+
`https://graph.facebook.com/oauth/access_token?client_id=${appId}&code=${realToken}&redirect_uri=`
|
| 208 |
+
);
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
**Correction :**
|
| 212 |
+
```typescript
|
| 213 |
+
// Avant de lancer le flow OAuth
|
| 214 |
+
const state = crypto.randomUUID();
|
| 215 |
+
await redis.set(`oauth:state:${state}`, organizationId, 'EX', 600);
|
| 216 |
+
|
| 217 |
+
// Dans le callback
|
| 218 |
+
const savedOrgId = await redis.get(`oauth:state:${req.body.state}`);
|
| 219 |
+
if (!savedOrgId || savedOrgId !== id) {
|
| 220 |
+
return reply.code(400).send({ error: 'Invalid OAuth state — possible CSRF attack' });
|
| 221 |
+
}
|
| 222 |
+
await redis.del(`oauth:state:${req.body.state}`);
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
### 🟠 HAUT — File upload sans limite de taille
|
| 228 |
+
|
| 229 |
+
**Fichier :** `apps/api/src/routes/admin.ts` (endpoint `POST /training/upload`)
|
| 230 |
+
|
| 231 |
+
**Problème :**
|
| 232 |
+
```typescript
|
| 233 |
+
for await (const part of parts) {
|
| 234 |
+
if (part.type === 'file') {
|
| 235 |
+
fileBuffer = await part.toBuffer(); // ❌ Aucune limite de taille
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Un fichier de 1 GB serait chargé entièrement en mémoire → crash du service.
|
| 241 |
+
|
| 242 |
+
**Correction :**
|
| 243 |
+
```typescript
|
| 244 |
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
| 245 |
+
// Configurer aussi dans Fastify :
|
| 246 |
+
fastify.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } });
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
### 🟠 HAUT — Pas de Dead Letter Queue (DLQ) sur BullMQ
|
| 252 |
+
|
| 253 |
+
**Fichier :** `apps/whatsapp-worker/src/index.ts`
|
| 254 |
+
|
| 255 |
+
**Problème :** Après 3 tentatives échouées, un job disparaît sans alerte. Les messages WhatsApp ratés ne sont pas notifiés.
|
| 256 |
+
|
| 257 |
+
**Correction :**
|
| 258 |
+
```typescript
|
| 259 |
+
worker.on('failed', async (job, err) => {
|
| 260 |
+
logger.error({ jobId: job?.id, name: job?.name, err }, '[WORKER] Job failed after all retries');
|
| 261 |
+
|
| 262 |
+
// Stocker en DLQ Redis pour inspection manuelle
|
| 263 |
+
await redis.lpush(`dlq:${job?.data?.organizationId}`, JSON.stringify({
|
| 264 |
+
jobId: job?.id,
|
| 265 |
+
name: job?.name,
|
| 266 |
+
error: err.message,
|
| 267 |
+
data: job?.data,
|
| 268 |
+
failedAt: new Date().toISOString()
|
| 269 |
+
}));
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
worker.on('error', (err) => {
|
| 273 |
+
logger.error({ err }, '[WORKER] Worker-level error');
|
| 274 |
+
});
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
---
|
| 278 |
+
|
| 279 |
+
### 🟡 MOYEN — Validation d'environnement incomplète au démarrage
|
| 280 |
+
|
| 281 |
+
**Fichiers :** `apps/whatsapp-worker/src/config.ts`, `apps/api/src/index.ts`
|
| 282 |
+
|
| 283 |
+
**Variables critiques non validées au démarrage :**
|
| 284 |
+
|
| 285 |
+
| Variable | Service | Problème |
|
| 286 |
+
|---|---|---|
|
| 287 |
+
| `META_GRAPH_API_VERSION` | API + Worker | Hardcodé `'v22.0'` comme fallback — n'échoue pas si absent |
|
| 288 |
+
| `DISABLE_WORKER_CONSUMER` | Worker | Simple check booléen sans validation |
|
| 289 |
+
| `WHATSAPP_APP_SECRET` | API | Non validé — utilisé seulement dans l'OAuth flow |
|
| 290 |
+
| `META_APP_ID` | API | Non validé — requis pour OAuth |
|
| 291 |
+
| `WORKER_CONCURRENCY` | Worker | Pas de validation min/max |
|
| 292 |
+
|
| 293 |
+
**Correction — ajouter dans le schéma Zod existant :**
|
| 294 |
+
```typescript
|
| 295 |
+
// apps/whatsapp-worker/src/config.ts
|
| 296 |
+
const envSchema = z.object({
|
| 297 |
+
// ... existing vars ...
|
| 298 |
+
META_GRAPH_API_VERSION: z.string().regex(/^v\d+\.0$/).default('v22.0'),
|
| 299 |
+
DISABLE_WORKER_CONSUMER: z.enum(['true', 'false']).default('false'),
|
| 300 |
+
WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(20).default(5),
|
| 301 |
+
});
|
| 302 |
+
```
|
| 303 |
+
|
| 304 |
+
---
|
| 305 |
+
|
| 306 |
+
### 🟡 MOYEN — Index DB manquants
|
| 307 |
+
|
| 308 |
+
**Fichier :** `packages/database/prisma/schema.prisma`
|
| 309 |
+
|
| 310 |
+
**Index à ajouter :**
|
| 311 |
+
```prisma
|
| 312 |
+
model User {
|
| 313 |
+
// Ajouter :
|
| 314 |
+
@@index([organizationId, role]) // pour listing admins
|
| 315 |
+
@@index([organizationId, deletedAt]) // pour soft-deletes
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
model Message {
|
| 319 |
+
// Ajouter :
|
| 320 |
+
@@index([organizationId, direction, status, createdAt]) // pour analytics
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
model Enrollment {
|
| 324 |
+
// Ajouter :
|
| 325 |
+
@@index([organizationId, status]) // pour users actifs
|
| 326 |
+
}
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
**Procédure d'application :**
|
| 330 |
+
```bash
|
| 331 |
+
# Éditer le schema, puis :
|
| 332 |
+
pnpm --filter @repo/database migrate:dev --name add_missing_indexes
|
| 333 |
+
# ⚠️ La shadow DB Neon bloque sur pgvector — appliquer en SQL direct si nécessaire
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
---
|
| 337 |
+
|
| 338 |
+
### 🟡 MOYEN — JWT en sessionStorage (vulnérabilité XSS)
|
| 339 |
+
|
| 340 |
+
**Fichier :** `apps/admin/src/lib/auth.tsx`
|
| 341 |
+
|
| 342 |
+
**Problème :** Un script XSS peut voler le token depuis `sessionStorage.getItem('session')`.
|
| 343 |
+
|
| 344 |
+
**Correction à long terme :** Passer aux cookies `httpOnly` :
|
| 345 |
+
```typescript
|
| 346 |
+
// Backend : Set-Cookie à la connexion
|
| 347 |
+
reply.setCookie('jwt', token, {
|
| 348 |
+
httpOnly: true,
|
| 349 |
+
secure: true,
|
| 350 |
+
sameSite: 'strict',
|
| 351 |
+
maxAge: 86400,
|
| 352 |
+
path: '/'
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
// Frontend : le cookie est envoyé automatiquement, pas besoin de le stocker
|
| 356 |
+
// Ajouter `credentials: 'include'` dans les fetch
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
### 🟡 MOYEN — `wabaId` sans contrainte unique
|
| 362 |
+
|
| 363 |
+
**Contexte :** La contrainte `@unique` a été retirée le 15/05/2026 pour permettre à plusieurs orgs de partager le même compte WABA (ex: test4 + testcrm).
|
| 364 |
+
|
| 365 |
+
**Risque résiduel :** Si deux orgs ont le même `wabaId` par erreur, le routing des messages WhatsApp est déterministe (lié au `phoneNumberId`, pas au `wabaId`), donc pas de données mixées — mais c'est à surveiller.
|
| 366 |
+
|
| 367 |
+
**Vérification périodique :**
|
| 368 |
+
```typescript
|
| 369 |
+
// Ajouter un check au démarrage de l'API
|
| 370 |
+
const dupes = await prisma.$queryRaw`
|
| 371 |
+
SELECT "wabaId", COUNT(*) c FROM "Organization"
|
| 372 |
+
WHERE "wabaId" IS NOT NULL
|
| 373 |
+
GROUP BY "wabaId" HAVING COUNT(*) > 1
|
| 374 |
+
`;
|
| 375 |
+
if (dupes.length > 0) logger.warn({ dupes }, '[STARTUP] Duplicate wabaIds detected');
|
| 376 |
+
```
|
| 377 |
+
|
| 378 |
+
---
|
| 379 |
+
|
| 380 |
+
## Variables d'environnement — référence complète
|
| 381 |
+
|
| 382 |
+
### Variables par service
|
| 383 |
+
|
| 384 |
+
| Variable | API (HF) | Worker (Railway) | Validée Zod | Notes |
|
| 385 |
+
|---|---|---|---|---|
|
| 386 |
+
| `DATABASE_URL` | ✅ | ✅ | ✅ | Neon PostgreSQL — identique sur les deux |
|
| 387 |
+
| `REDIS_URL` | ✅ | ✅ | ✅ | Upstash Redis — identique sur les deux |
|
| 388 |
+
| `JWT_SECRET` | ✅ | — | ✅ check manuel | Min 64 chars recommandé |
|
| 389 |
+
| `ADMIN_API_KEY` | ✅ | ✅ | ✅ min 32 chars | Auth interne Worker→API |
|
| 390 |
+
| `ENCRYPTION_SECRET` | ✅ | ✅ | ✅ min 32 chars | **Doit être identique** |
|
| 391 |
+
| `WHATSAPP_ACCESS_TOKEN` | ✅ | ✅ | ❌ | Fallback global si token org absent |
|
| 392 |
+
| `WHATSAPP_PHONE_NUMBER_ID` | ✅ | ✅ | ❌ | Fallback global |
|
| 393 |
+
| `WHATSAPP_VERIFY_TOKEN` | ✅ | — | ❌ | Vérification webhook Meta |
|
| 394 |
+
| `WHATSAPP_APP_SECRET` | ✅ | — | ❌ | Requis pour OAuth Embedded Signup |
|
| 395 |
+
| `META_APP_ID` | ✅ | — | ❌ | Requis pour OAuth Embedded Signup |
|
| 396 |
+
| `META_GRAPH_API_VERSION` | ✅ | ✅ | ❌ | Défaut `v22.0` — changer ici d'abord |
|
| 397 |
+
| `OPENAI_API_KEY` | ✅ | ✅ | ❌ | STT + GPT-4o |
|
| 398 |
+
| `GOOGLE_AI_API_KEY` | ✅ | ✅ | ❌ | Gemini Flash/Pro |
|
| 399 |
+
| `SENTRY_DSN` | ✅ | ✅ | ❌ | Error tracking |
|
| 400 |
+
| `CORS_ORIGINS` | ✅ | — | ❌ | ex: `https://admin.xamle.studio` |
|
| 401 |
+
| `R2_ACCOUNT_ID` | ✅ | — | ❌ | Cloudflare R2 |
|
| 402 |
+
| `R2_ACCESS_KEY_ID` | ✅ | — | ❌ | Cloudflare R2 |
|
| 403 |
+
| `R2_SECRET_ACCESS_KEY` | ✅ | — | ❌ | Cloudflare R2 |
|
| 404 |
+
| `R2_BUCKET` | ✅ | — | ❌ | Cloudflare R2 |
|
| 405 |
+
| `VAPID_PUBLIC_KEY` | ✅ | — | ❌ | Web Push |
|
| 406 |
+
| `VAPID_PRIVATE_KEY` | ✅ | — | ❌ | Web Push |
|
| 407 |
+
| `DISABLE_WORKER_CONSUMER` | `true` | `false` ou absent | ❌ | Empêche HF de consommer la queue |
|
| 408 |
+
| `DISABLE_WHATSAPP_SEND` | optionnel | optionnel | ❌ | Feature flag pour tests |
|
| 409 |
+
| `RAILWAY_INTERNAL_URL` | ✅ | — | ❌ | URL bridge Railway (`:8082`) |
|
| 410 |
+
| `WORKER_CONCURRENCY` | — | `5` | ❌ | Jobs parallèles BullMQ |
|
| 411 |
+
| `PORT` | `7860` | `7860` | ✅ | |
|
| 412 |
+
| `NODE_ENV` | `production` | `production` | ✅ | |
|
| 413 |
+
|
| 414 |
+
### Règle critique `ENCRYPTION_SECRET`
|
| 415 |
+
|
| 416 |
+
> La valeur en DB est chiffrée avec la clé active au moment du premier `encrypt_existing_secrets.ts`. Si cette clé change, **tous les tokens org retournent le ciphertext brut** → Authentication Error Meta sur chaque message entrant. Ne jamais changer sans exécuter `apps/api/scratch/rotate_encryption_key.ts`.
|
| 417 |
+
|
| 418 |
+
---
|
| 419 |
+
|
| 420 |
+
## Organisations en base (état 15 mai 2026)
|
| 421 |
+
|
| 422 |
+
| ID | Nom | WABA ID | Token | Numéro WhatsApp |
|
| 423 |
+
|---|---|---|---|---|
|
| 424 |
+
| `default-org-id` | XAMLÉ Global | `938685848818318` | ✅ `EAAURe...` | `969048009628694` |
|
| 425 |
+
| `ba012b65-...` | testcrm | `1503271284790621` | ✅ | `+221788227676` |
|
| 426 |
+
| `12247ec1-...` | test4 | `1503271284790621` | ✅ copié testcrm | aucun (à configurer) |
|
| 427 |
+
| `136f72d9-...` | test | null | — | — |
|
| 428 |
+
| `xamle-admin-org` | XAMLÉ Admin | null | — | — |
|
| 429 |
+
|
| 430 |
+
**Note :** test4 et testcrm partagent le même WABA ID — c'est délibéré. La contrainte unique a été retirée pour ce cas d'usage.
|
| 431 |
+
|
| 432 |
+
---
|
| 433 |
+
|
| 434 |
+
## Scripts de diagnostic et maintenance
|
| 435 |
+
|
| 436 |
+
Tous les scripts sont dans `apps/api/scratch/`. Les exécuter depuis `apps/api/` avec les env vars du `.env`.
|
| 437 |
+
|
| 438 |
+
```bash
|
| 439 |
+
# Lister toutes les orgs
|
| 440 |
+
cd apps/api && npx tsx scratch/list_orgs.ts
|
| 441 |
+
|
| 442 |
+
# Configurer une nouvelle org avec un token existant
|
| 443 |
+
cd apps/api && npx tsx scratch/connect_<org_name>.ts
|
| 444 |
+
```
|
| 445 |
+
|
| 446 |
+
### Commandes de diagnostic rapide
|
| 447 |
+
|
| 448 |
+
```bash
|
| 449 |
+
# Vérifier les doublons wabaId
|
| 450 |
+
DATABASE_URL=... npx tsx -e "
|
| 451 |
+
import { PrismaClient } from '@prisma/client';
|
| 452 |
+
const p = new PrismaClient();
|
| 453 |
+
const r = await p.\$queryRaw\`SELECT \"wabaId\", COUNT(*) FROM \"Organization\" WHERE \"wabaId\" IS NOT NULL GROUP BY \"wabaId\" HAVING COUNT(*) > 1\`;
|
| 454 |
+
console.log(r);
|
| 455 |
+
"
|
| 456 |
+
|
| 457 |
+
# Vérifier les jobs BullMQ en attente / échoués
|
| 458 |
+
redis-cli -u $REDIS_URL LLEN "bull:whatsapp-queue:wait"
|
| 459 |
+
redis-cli -u $REDIS_URL LLEN "bull:whatsapp-queue:failed"
|
| 460 |
+
|
| 461 |
+
# Vider le cache Meta Status
|
| 462 |
+
redis-cli -u $REDIS_URL --scan --pattern "meta:status:*" | xargs redis-cli -u $REDIS_URL del
|
| 463 |
+
|
| 464 |
+
# Vérifier la version Meta Graph API au démarrage (lire les logs)
|
| 465 |
+
# Chercher : ✅ [META] Graph API version: v22.0
|
| 466 |
+
```
|
| 467 |
+
|
| 468 |
+
---
|
| 469 |
+
|
| 470 |
+
## Dépendances critiques entre services
|
| 471 |
+
|
| 472 |
+
```
|
| 473 |
+
Webhook Meta → API (HF) [< 100ms] → BullMQ (Redis) → Worker (Railway)
|
| 474 |
+
↑
|
| 475 |
+
Redis shared — REDIS_URL doit être identique !
|
| 476 |
+
|
| 477 |
+
Worker → graph.facebook.com (Meta) ← seul chemin autorisé
|
| 478 |
+
Worker → api-inference.huggingface.co (modèles HF optionnel)
|
| 479 |
+
|
| 480 |
+
API → Neon DB (queries + tenant extension)
|
| 481 |
+
API → Redis (cache + sessions)
|
| 482 |
+
API → OpenAI / Gemini (AI features)
|
| 483 |
+
API → Cloudflare R2 (audio/images)
|
| 484 |
+
|
| 485 |
+
Frontend → API (toutes les routes REST)
|
| 486 |
+
Frontend → API/stream (SSE notification badge)
|
| 487 |
+
```
|
| 488 |
+
|
| 489 |
+
**Points de défaillance uniques :**
|
| 490 |
+
|
| 491 |
+
| Service down | Impact | Récupération |
|
| 492 |
+
|---|---|---|
|
| 493 |
+
| Redis | Messages non envoyés, pas de sessions | Redémarrer + vider les queues orphelines |
|
| 494 |
+
| Neon DB | Toute l'app inopérante | Attendre la récupération Neon (HA automatique) |
|
| 495 |
+
| Meta API | Messages non envoyés | BullMQ retente 3× avec backoff |
|
| 496 |
+
| Worker Railway | Messages s'accumulent en queue | Redéployer le service Railway |
|
| 497 |
+
| HF Space | Frontend et API inaccessibles | Réveiller le Space (peut hiberner) |
|
| 498 |
+
|
| 499 |
+
---
|
| 500 |
+
|
| 501 |
+
## Problèmes connus non critiques
|
| 502 |
+
|
| 503 |
+
### `resolveOrgId` — Full scan sur slug
|
| 504 |
+
```typescript
|
| 505 |
+
// organisations.ts ligne ~29
|
| 506 |
+
// OR clause sur des champs non-indexés ensembles
|
| 507 |
+
const org = await prisma.organization.findFirst({
|
| 508 |
+
where: { OR: [{ slug: idOrSlug }, { id: idOrSlug }] },
|
| 509 |
+
});
|
| 510 |
+
// Correction : vérifier si UUID, puis lookup par slug, puis par id
|
| 511 |
+
```
|
| 512 |
+
|
| 513 |
+
### Token fallback implicite dans whatsapp-setup
|
| 514 |
+
La chaîne `body.token → env.WHATSAPP_ACCESS_TOKEN → org.systemUserToken` peut utiliser le mauvais token en multi-tenant. Ajouter un log sur la source choisie.
|
| 515 |
+
|
| 516 |
+
### SSE ne se reconnecte pas en cas d'erreur réseau
|
| 517 |
+
```typescript
|
| 518 |
+
// Actuel :
|
| 519 |
+
es.onerror = () => es.close(); // ❌ ferme sans se reconnecter
|
| 520 |
+
|
| 521 |
+
// Mieux :
|
| 522 |
+
let reconnectDelay = 1000;
|
| 523 |
+
const connectSSE = () => {
|
| 524 |
+
const es = new EventSource(url);
|
| 525 |
+
es.onerror = () => { es.close(); setTimeout(connectSSE, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, 30000); };
|
| 526 |
+
es.onopen = () => { reconnectDelay = 1000; };
|
| 527 |
+
};
|
| 528 |
+
connectSSE();
|
| 529 |
+
```
|
| 530 |
+
|
| 531 |
+
---
|
| 532 |
+
|
| 533 |
+
## Checklist démarrage rapide (nouveau développeur)
|
| 534 |
+
|
| 535 |
+
### Setup local
|
| 536 |
+
```bash
|
| 537 |
+
git clone https://github.com/blackpanthere/Edtech.git
|
| 538 |
+
cd Edtech
|
| 539 |
+
pnpm install
|
| 540 |
+
cp .env.example .env # Remplir avec des secrets DEV (pas production!)
|
| 541 |
+
pnpm --filter @repo/database generate
|
| 542 |
+
pnpm --filter @repo/database migrate:deploy
|
| 543 |
+
```
|
| 544 |
+
|
| 545 |
+
### Démarrer les services
|
| 546 |
+
```bash
|
| 547 |
+
# Terminal 1 — API
|
| 548 |
+
pnpm --filter api dev
|
| 549 |
+
# Vérifier dans les logs : ✅ [META] Graph API version: v22.0
|
| 550 |
+
|
| 551 |
+
# Terminal 2 — Worker
|
| 552 |
+
pnpm --filter whatsapp-worker dev
|
| 553 |
+
# Vérifier dans les logs : 🚀 WhatsApp Worker + Bridge listening on port 8082
|
| 554 |
+
|
| 555 |
+
# Terminal 3 — Admin frontend
|
| 556 |
+
pnpm --filter admin dev
|
| 557 |
+
# Ouvrir : http://localhost:5173
|
| 558 |
+
```
|
| 559 |
+
|
| 560 |
+
### Santé checks
|
| 561 |
+
```bash
|
| 562 |
+
curl http://localhost:7860/health # API
|
| 563 |
+
curl http://localhost:8082/health # Worker Bridge
|
| 564 |
+
```
|
| 565 |
+
|
| 566 |
+
### Avant tout commit
|
| 567 |
+
```bash
|
| 568 |
+
pnpm run typecheck
|
| 569 |
+
pnpm --filter api test
|
| 570 |
+
```
|
| 571 |
+
|
| 572 |
+
### Références obligatoires
|
| 573 |
+
- `docs/post-mortems.md` — bugs passés et règles à ne pas violer
|
| 574 |
+
- `tasks/lessons.md` — leçons tirées de l'expérience
|
| 575 |
+
- `docs/organisation-onboarding/configurer-token-nouvelle-org.md` — onboarding Meta
|
| 576 |
+
|
| 577 |
+
---
|
| 578 |
+
|
| 579 |
+
## Priorités de correction (par effort)
|
| 580 |
+
|
| 581 |
+
| Effort | Problème | Impact si corrigé |
|
| 582 |
+
|---|---|---|
|
| 583 |
+
| 1h | Catch silencieux → `logger.warn/error` | Bugs visibles en production |
|
| 584 |
+
| 1h | Limite taille file upload | Prévenir DoS |
|
| 585 |
+
| 2h | Secrets hors `.env` (rotation + .gitignore) | Sécurité critique |
|
| 586 |
+
| 2h | DLQ BullMQ + alerting jobs échoués | Visibilité opérationnelle |
|
| 587 |
+
| 3h | OAuth state parameter | Protection CSRF |
|
| 588 |
+
| 3h | SSE reconnexion automatique | UX en cas de coupure réseau |
|
| 589 |
+
| 4h | SQL Injection analytics → query builder | Sécurité critique |
|
| 590 |
+
| 4h | Token SSE éphémère | Réduire exposition JWT |
|
| 591 |
+
| 5h | httpOnly cookies | Supprimer risque XSS sur auth |
|
| 592 |
+
| 5h | Rate limit BullMQ par tenant | Équité multi-tenant |
|