CognxSafeTrack commited on
Commit
5e23bc3
·
1 Parent(s): 3d1299e

docs: add developer audit guide (2026-05-15)

Browse files
Files changed (1) hide show
  1. docs/audit-developpeur-2026-05-15.md +592 -0
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 |