Files
_Assistant_Lead_Tech/knowledge/backend/risques/general.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

1148 lines
46 KiB
Markdown

# Backend — Risques & vigilance : Général
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet.
---
<a id="risque-observabilite-insuffisante"></a>
## Observabilité insuffisante (logs non structurés, pas de corrélation)
### Risques
- MTTR très élevé : on devine
- Incapacité à mesurer l'impact utilisateur
### Symptômes
- Logs "ça a crash" sans contexte
- Impossible de relier une requête à une erreur
- Latence qui dérive sans alerte
### Bonnes pratiques / mitigations
- Logs structurés + requestId/traceId
- Métriques de base (latence, erreurs, throughput)
- Alertes simples sur 5xx/latence
---
<a id="risque-migrations-risquees"></a>
## Migrations risquées / non reproductibles
### Risques
- Downtime
- Perte de données
- Incohérence entre environnements
### Symptômes
- "Ça marche en local" mais pas en prod
- Migration qui échoue à mi-chemin
- Rollback impossible
### Bonnes pratiques / mitigations
- Migrations versionnées + tests staging
- Stratégie expand/contract si besoin
- Plan de rollback/mitigation
---
<a id="risque-upsert-n-plus-un-provider"></a>
## Boucle `upsert` N+1 sur synchronisation provider
### Risques
- Latence multipliée par le nombre d'items
- Charge DB inutile
- Timeouts ou contention sur gros volumes
### Symptômes
- Une boucle applicative exécute un `upsert` par item
- Temps de traitement qui explose avec le volume
- Logs SQL répétitifs et séquentiels
### Bonnes pratiques / mitigations
- Batcher quand c'est possible
- Précharger les données nécessaires avant boucle
- Mesurer explicitement le coût d'un `upsert` unitaire dans les flux de sync
- Contexte technique : Prisma / synchronisation provider — 10-03-2026
---
<a id="risque-authorize-after-fetch"></a>
## Anti-pattern : Authorize-after-fetch (contrôle d'accès après chargement)
### Risques
- Le contrôle d'accès effectué après chargement des relations métier volumineuses augmente le coût DB et la surface de fuite temporelle pour des requêtes finalement refusées
### Symptômes
- Handler qui charge un agrégat complet (avec includes/relations) puis vérifie l'accès
- Requêtes refusées après un temps de réponse anormalement long
### Bonnes pratiques / mitigations
- Pour les endpoints détail sensibles, filtrer l'accès dans la requête DB (`where` + scope grade/tenant) ou faire un pré-check minimal avant de charger les relations
- Les accès non autorisés ne doivent pas déclencher un fetch complet des données métier
- Contexte technique : backend général — RL799_V2 02-04-2026
---
<a id="risque-valeur-sentinelle-dto-date"></a>
## Valeur sentinelle `""` au lieu de `null` dans un DTO de date nullable
### Risques
- Un champ de date optionnel dans le modèle Prisma (`Date | null`) sérialisé en `""` dans le DTO au lieu de `null`
- Le type DTO ment (`string` au lieu de `string | null`), et tout consommateur testant `if (profile.field)` a un comportement incorrect sur `""`
### Symptômes
- `?? ''` sur une date issue de `?.toISOString()` dans un repository Prisma
- Type DTO avec `someDate: string` non optionnel pour un modèle qui autorise `null` en DB
### Bonnes pratiques / mitigations
- Si un champ de date peut être `null` en DB, le type DTO doit être `string | null`
- Le repository utilise `?? null` (pas `?? ''`) pour les dates nulles
- Le frontend utilise `??` (nullish coalescing) pas `||` pour les fallbacks d'affichage sur ces champs
- Contexte technique : Prisma / DTO — RL799_V2 02-04-2026
---
<a id="risque-endpoint-idempotent-sans-doublon"></a>
## Endpoint idempotent sans contrôle de doublon
### Risques
- Un endpoint crée un event/enregistrement censé être unique par contexte (ex: un vote par tenue, un PDF par planche) sans vérifier l'existence préalable
- Un double-clic ou retry réseau crée des doublons silencieux, ou des fichiers orphelins en cas d'artefact physique
### Symptômes
- `writeFile` + `prisma.create` sans `findFirst` préalable dans le même handler
- Plusieurs enregistrements identiques avec le même contexte en base
- Fichiers physiques (PDF, export) dupliqués sur disque
### Bonnes pratiques / mitigations
- Avant tout `create` d'un event contextuel, lire les events existants avec le même filtre de contexte
- Retourner 409 si l'event existe déjà, ou mettre à jour l'existant selon la sémantique métier
- Pour les artefacts physiques : retourner l'existant (200) au lieu de recréer (201), ou supprimer l'ancien avant d'en créer un nouveau
- Contexte technique : backend général — RL799_V2 03-04-2026
---
<a id="risque-fichier-orphelin-echec-persistance"></a>
## Fichier orphelin sur échec de persistance en base
### Risques
- Un service écrit un fichier sur disque puis crée l'entrée correspondante en base ; si la création en base échoue, le fichier reste orphelin
- Un `catch {}` vide rend le debug impossible
### Symptômes
- Fichiers physiques présents sur disque sans enregistrement correspondant en base
- Erreurs de persistance invisibles dans les logs
### Bonnes pratiques / mitigations
```ts
const file = await writeFileToDisk(...);
try {
await createDbRecord(...);
} catch (err) {
await cleanupFile(file.path);
console.error('Erreur persistance:', err);
throw err;
}
```
- Si la création en base échoue, supprimer le fichier physique (cleanup)
- Toujours logger l'erreur dans le catch, même si on retourne une réponse d'erreur propre au client
- Contexte technique : backend / fichiers — RL799_V2 03-04-2026
---
<a id="risque-melange-date-locale-utc-seed"></a>
## Mélange Date locale / Date UTC dans les données de seed
### Risques
- Des dates dynamiques construites via `new Date(year, month, day, hour)` (heure locale) cohabitent avec des dates fixes en UTC (`new Date('...T19:00:00Z')`) dans le même fichier
- L'incohérence est silencieuse et produit des décalages horaires non reproductibles entre environnements
### Symptômes
- `new Date(year, month, day, hour)` (local) dans un fichier qui utilise aussi `new Date('...Z')` (UTC)
- Données de seed dont les heures varient selon la timezone de l'environnement dev
### Bonnes pratiques / mitigations
- Les dates dynamiques dans un seed doivent être construites en UTC (`Date.UTC(...)`) si les dates fixes sont en UTC
- **Signal review** : `new Date(year, month, day, hour)` dans un fichier qui utilise aussi `new Date('...Z')`
- Contexte technique : Node.js / seed — RL799_V2 03-04-2026
---
<a id="risque-champ-fantome-zod-non-persiste"></a>
## Champ fantôme accepté par validation Zod mais non persisté
### Risques
- Un champ optionnel ajouté dans un schéma Zod est accepté par la validation mais jamais exploité par la chaîne handler → repository → modèle Prisma
- L'utilisateur envoie une donnée sans erreur, mais elle est silencieusement perdue
### Symptômes
- Champ optionnel dans le schéma Zod absent du modèle Prisma
- Champ passé au service mais non inclus dans l'appel `create`/`update` du repository
### Bonnes pratiques / mitigations
- Avant d'ajouter un champ au schéma de validation, s'assurer que le modèle DB le supporte
- Si le modèle ne le supporte pas, ne pas accepter le champ du tout
- **Signal review** : champ dans le schéma Zod qui n'apparaît pas dans le `data` de l'appel Prisma
- Contexte technique : Zod / Prisma — RL799_V2 06-04-2026
---
<a id="risque-catch-vide-sans-logging"></a>
## Catch vide avalant les exceptions sans logging
### Risques
- Les `catch {}` ou `catch { // ignore }` dans les services métier masquent les erreurs réelles
- En production, un disque plein ou un bug PDFKit retourne un message générique sans trace serveur
### Symptômes
- `catch {` sans `console.error` ni logger dans un service métier
- Bloc catch qui retourne un message d'erreur générique sans capturer `err`
### Bonnes pratiques / mitigations
- Toujours capturer et logger l'exception dans les catch des services métier
- Les catch de rollback secondaire (ex: unlink d'un fichier) peuvent être silencieux mais devraient au minimum émettre un `warn`
- **Signal review** : `catch {` sans variable dans un service métier (hors rollback secondaire)
- Contexte technique : backend général — RL799_V2 06-04-2026
---
<a id="risque-params-route-non-valides"></a>
## Paramètres de route non validés avant passage à Prisma
### Risques
- Un paramètre de route (`id`, `memberId`) passé directement à `prisma.findUnique({ where: { id } })` sans validation de format (UUID, slug)
- Des chaînes arbitraires atteignent la couche DB, polluant les logs et violant le principe de validation aux frontières
### Symptômes
- `const { id } = params; prisma.model.findUnique({ where: { id } })` sans `isValidUuid(id)`
- Erreurs Prisma non significatives en production sur des identifiants malformés
### Bonnes pratiques / mitigations
- Toujours valider les identifiants (UUID, slug, etc.) dès l'entrée du handler ou du service, avant tout appel DB
- Retourner 400 immédiatement si invalide
- Contexte technique : backend / validation — RL799_V2 07-04-2026
---
<a id="risque-cast-ts-brut-valeurs-db"></a>
## Cast TypeScript brut sur valeurs DB non contraintes
### Risques
- Un cast `value as 'expected_a' | 'expected_b'` sur un champ Prisma de type `String` sans enum DB masque silencieusement toute valeur inattendue
- Le type-checking passe, mais une valeur inattendue en DB provoque des comportements incohérents en aval
### Symptômes
- `status as 'pending' | 'late'` sur un champ Prisma `String` sans enum DB
- Pas de fallback explicite pour les valeurs non attendues
### Bonnes pratiques / mitigations
- Utiliser des conditions explicites (`if (s === 'a') ... else if (s === 'b') ... else fallback`) ou un mapping validé
- Ne jamais faire confiance au type runtime via un simple cast TS
- **Signal review** : `as 'literal_a' | 'literal_b'` sur un champ Prisma de type `String`
- Contexte technique : TypeScript / Prisma — RL799_V2 07-04-2026
---
<a id="risque-creation-ressource-temporelle-sans-chevauchement"></a>
## Création de ressource temporelle sans détection de chevauchement
### Risques
- Un endpoint crée une ressource avec `startDate`/`endDate` sans vérifier les collisions avec les ressources existantes
- Un double-clic ou une erreur humaine génère des doublons avec les mêmes dates
### Symptômes
- Deux ressources avec des plages temporelles qui se chevauchent pour le même contexte (cotisations, abonnements, sessions)
### Bonnes pratiques / mitigations
- Avant insertion, query les ressources existantes dont les plages se chevauchent : `WHERE start < newEnd AND end > newStart`
- Retourner 409 Conflict si collision
- S'applique aux cotisations, abonnements, périodes de garde, sessions — tout ce qui a des bornes temporelles
- Contexte technique : backend général — RL799_V2 07-04-2026
---
<a id="risque-toctou-operations-conditionnelles"></a>
## TOCTOU sur opérations conditionnelles sans transaction
### Risques
- `findUnique` → check status → `update` en séquence sans transaction crée une fenêtre TOCTOU (Time-of-Check Time-of-Use)
- Un traitement concurrent peut modifier l'état entre la vérification et la mise à jour
### Symptômes
- Données incohérentes sous charge concurrente (rare en MVP mais critique en prod)
- Double relance de cotisation, double changement de statut
### Bonnes pratiques / mitigations
- Wrapper dans `prisma.$transaction()` ou utiliser un `UPDATE ... WHERE status != 'paid'` atomique
- **Règle** : toute séquence read → validate → write sur la même entité doit être atomique
- Contexte technique : Prisma / backend général — RL799_V2 07-04-2026
---
<a id="risque-biais-agregation-moyenne-ratio"></a>
## Calcul de moyenne vs ratio global — biais d'agrégation
### Risques
- Le ratio global (somme numérateurs / somme dénominateurs) donne un résultat biaisé vers les entités avec les dénominateurs les plus grands quand on veut calculer une "moyenne de taux" sur N entités
### Symptômes
- "% de présence moyenne par degré" calculé comme total_présents / total_inscrits au lieu d'une moyenne par tenue
- Tests verts car les données de test symétriques donnent le même résultat avec les deux méthodes
### Bonnes pratiques / mitigations
- Moyenne de présence par tenue → calculer le taux par tenue, puis la moyenne des taux — pas le ratio global
- Rédiger un test qui construit des données asymétriques pour détecter la différence (ex: une tenue avec 10 inscrits/8 présents, une avec 2 inscrits/2 présents → 83% global vs 90% moyenne correcte)
- Contexte technique : backend / statistiques — RL799_V2 07-04-2026
---
<a id="risque-couplage-semantique-types-erreur"></a>
## Couplage sémantique sur les types d'erreur partagés
### Risques
- Un helper générique (ex: authHelpers) importe un type nommé d'après un domaine métier spécifique (ex: ConvocationErrorCode)
- Chaque nouveau domaine qui utilise l'auth helper "dépend" conceptuellement du module d'origine
### Symptômes
- Un helper générique utilisé par 12+ fichiers importe un type nommé d'après un seul domaine métier
- Couplage sémantique incohérent hérité de la première implémentation
### Bonnes pratiques / mitigations
- À la prochaine refacto touchant le helper, extraire un type `ApiErrorCode` générique dans le package partagé et faire pointer les types domaine vers lui
- Contexte technique : backend / architecture — RL799_V2 07-04-2026
---
<a id="risque-service-http-aware"></a>
## Service HTTP-aware — violation de la séparation des couches
### Risques
- Un fichier `service.ts` importe `Response`, construit les headers et status HTTP, et retourne un objet `Response`
- Le service devient intestable sans mock HTTP, la logique métier est couplée au transport, et la réutilisation (CLI, cron, WebSocket) est impossible
### Symptômes
- Le fichier route.ts ne fait que `(req) => handleX(req)` — pass-through d'une ligne
- Import de `Response` dans un fichier service
### Bonnes pratiques / mitigations
- Le service doit retourner `{ data: T } | { error: { code, message } }` et la route construit la Response
- Refactoriser quand un service est touché
- Contexte technique : architecture en couches — RL799_V2 07-04-2026
---
<a id="risque-count-sans-filtre-user"></a>
## Count sans filtre sur User — confusion membres / utilisateurs
### Risques
- `prisma.user.count()` sans `where` inclut les comptes techniques (admin, secretaire) dans les comptages métier "nombre de membres inscrits"
- Les stats métier sont faussées silencieusement
### Symptômes
- `prisma.user.count()` sans `where` pour un affichage de type "effectifs inscrits"
- Écart entre le comptage affiché et la réalité métier
### Bonnes pratiques / mitigations
- Toujours filtrer `{ where: { role: { not: 'admin' } } }` (ou équivalent) pour les comptages métier
- À terme, ajouter un champ `status` (active/inactive/suspended) pour distinguer les membres actifs
- **Signal review** : `prisma.user.count()` sans `where` dans un contexte de statistiques
- Contexte technique : Prisma / stats — RL799_V2 07-04-2026
---
<a id="risque-env-top-level-module"></a>
## Variables d'environnement lues au top-level d'un module
### Risques
- `const FOO = process.env.FOO || 'default'` en dehors d'une fonction fige la valeur au premier `import`
- Les tests qui modifient `process.env` après l'import ne voient pas l'effet
- Config non-reloadable sans redémarrage
### Symptômes
- `process.env.X` assigné à une constante au top-level d'un module Node
- Tests qui doivent manipuler l'ordre d'import pour surcharger une variable d'environnement
### Bonnes pratiques / mitigations
- Encapsuler dans un getter : `const getFoo = () => process.env.FOO || 'default'`
- Permet la surcharge en test et le rechargement dynamique
- Contexte technique : Node.js / tests — RL799_V2 07-04-2026
---
<a id="risque-rate-limiting-couverture-test"></a>
## Rate limiting — couverture de test insuffisante
### Risques
- Tester uniquement l'objet `RateLimiter` en isolation (`.check()`, `.reset()`) donne une fausse confiance. Les bugs de câblage (limiter non appelé, mauvais limiter sur le mauvais endpoint, format de réponse 429 incorrect) passent au travers.
### Symptômes
- Tests unitaires verts mais réponse 429 absente ou mal formatée en intégration
- Header `retry-after` manquant dans la réponse HTTP
### Bonnes pratiques / mitigations
- Pour chaque rate limiter intégré dans un endpoint, ajouter au minimum :
1. Un test d'intégration qui dépasse le seuil via le handler HTTP et vérifie status 429 + body + header `retry-after`
2. Un test d'expiration de fenêtre (mock `Date.now` et avancer le temps)
3. Vérifier que le logging sécurité est déclenché (au minimum visible dans la sortie test)
- Contexte technique : backend / rate limiting — RL799_V2 07-04-2026
---
<a id="risque-nommage-metriques-agregees-dto"></a>
## Nommage des métriques agrégées dans les DTO
### Risques
- Nommer un champ `xxxCount` ou `xxxThisYear` sans que le nom reflète exactement ce qui est compté
- Confusion en maintenance, bugs d'affichage, labels UI incohérents
### Symptômes
- `tenuesThisYear` qui compte en réalité les *présences* du membre, pas le nombre total de tenues
- Labels frontend incohérents avec le comportement réel du champ
### Bonnes pratiques / mitigations
- Le nom du champ DTO doit refléter exactement l'entité comptée ET le scope du comptage
- Préférer `presencesThisYear` à `tenuesThisYear` si on compte les attendances d'un user, réserver `tenuesThisYear` pour le COUNT de tenues elles-mêmes
- **Signal review** : tout champ `xxxCount` ou `xxxThisYear` dont le nom ne correspond pas à la requête sous-jacente
- Contexte technique : backend / DTO — RL799_V2 07-04-2026
---
<a id="risque-gitignore-env-wildcard"></a>
## `.gitignore` — `.env*` wildcard capture `.env.example`
### Risques
- Utiliser `.env*` dans `.gitignore` sans exception exclut aussi `.env.example`, qui est le fichier standard de documentation des variables d'environnement et qui DOIT être versionné
### Symptômes
- Le fichier `.env.example` existe sur le disque mais n'apparaît pas dans `git status`
- Les développeurs ne savent pas quelles variables configurer
### Bonnes pratiques / mitigations
- Toujours ajouter `!.env.example` après le wildcard `.env*` dans chaque `.gitignore` (racine ET sous-projets du monorepo)
- Vérifier avec `git check-ignore <path>` que l'exception fonctionne
- **Signal review** : `.env*` dans `.gitignore` sans `!.env.example`
- Contexte technique : git / configuration — RL799_V2 08-04-2026
---
<a id="risque-strip-html-regex-single-pass"></a>
## Strip HTML regex — single-pass insuffisant
### Risques
- `input.replace(/<[^>]*>/g, '')` en un seul passage laisse des fragments exploitables sur des inputs malformés type `<scr<script>ipt>alert(1)</script>`
### Symptômes
- Après strip, le résultat contient encore des fragments `>` ou des reconstitutions partielles de tags
### Bonnes pratiques / mitigations
- Toujours (1) boucler le strip jusqu'à stabilisation (`while (prev !== result)`) ET (2) supprimer les chevrons orphelins `<>` après la boucle
- Pour du plain text (pas de rich text), c'est suffisant. Pour du rich text, utiliser une lib dédiée (DOMPurify, sanitize-html)
- **Signal review** : `replace(/<[^>]*>/g, '')` sans boucle dans un code qui traite de l'input utilisateur
- Contexte technique : sécurité / sanitisation — RL799_V2 08-04-2026
---
<a id="risque-incoherence-source-verite-filtrage-affichage"></a>
## Incohérence source de vérité — filtrage vs affichage sur des tables différentes
### Risques
- Filtrer des entités sur un champ d'une table relationnelle (`profile.grade`) tout en affichant le résultat depuis une autre table (`directory.grade`). Si les deux tables ne sont pas synchronisées en permanence, les résultats de filtrage et d'affichage divergent silencieusement.
### Symptômes
- Un membre apparaît éligible mais avec un grade affiché incohérent, ou inversement un membre éligible est invisible parce que seule la table d'affichage a été mise à jour
### Bonnes pratiques / mitigations
- Toujours utiliser la même table comme source de vérité pour le filtrage ET l'affichage d'un même attribut
- Si deux tables portent la même information, choisir celle qui fait autorité et aligner le code dessus
- **Signal review** : `where` sur `tableA.field` avec `select` sur `tableB.field` pour le même attribut
- Contexte technique : Prisma / relations — RL799_V2 08-04-2026
---
<a id="risque-check-then-create-p2002"></a>
## Check-then-create non atomique — catcher P2002
### Risques
- Vérifier l'existence d'un enregistrement (`findFirst/findUnique`) puis créer dans un second appel est une race condition classique
- Deux requêtes concurrentes passent le check, l'une échoue sur la contrainte unique
### Symptômes
- Erreur 500 générique au lieu d'un conflit 409, intermittent et difficile à reproduire
### Bonnes pratiques / mitigations
- Toujours catcher l'erreur Prisma `P2002` (unique constraint violation) dans le bloc catch de la création et la transformer en réponse métier explicite (`USER_ALREADY_EXISTS`, `CONFLICT`, etc.)
- Le check préalable reste utile pour le cas nominal mais ne doit pas être la seule protection
- **Règle** : tout `prisma.create` sur une entité à contrainte unique doit avoir un catch `P2002`
- Contexte technique : Prisma / concurrence — RL799_V2 08-04-2026
---
<a id="risque-double-update-non-transactionnel"></a>
## Double update non transactionnel sur la même entité
### Risques
- Enchaîner deux `prisma.update` séparés sur la même entité (ex: update status puis update metadata) sans transaction laisse l'entité dans un état partiel si le second échoue
### Symptômes
- L'entité est dans un état qui ne correspond à aucune transition définie
- Les flux aval (workflows, UI) se retrouvent bloqués
### Bonnes pratiques / mitigations
- Quand plusieurs champs d'une même entité doivent être modifiés ensemble pour représenter une transition d'état cohérente, les regrouper dans un seul appel `prisma.update` ou les envelopper dans une `$transaction`
- **Signal review** : deux `prisma.update` séquentiels sur le même `where: { id }` sans `$transaction`
- Contexte technique : Prisma / atomicité — RL799_V2 08-04-2026
---
<a id="risque-fallback-legacy-bypass-workflow"></a>
## Fallback "legacy" qui bypass un nouveau workflow
### Risques
- Laisser un fallback de compatibilité dans une transition d'état (ex: accepter `'draft'` en plus de `'pending_vm_approval'` comme état source de la publication) crée un chemin de contournement du nouveau workflow
- Un appel direct à la fonction interne bypass le contrôle métier
### Symptômes
- Transition d'état possible depuis un état qui ne devrait plus être accepté
- `{ in: ['ancien', 'nouveau'] }` dans un filtre Prisma sur une transition d'état
### Bonnes pratiques / mitigations
- Quand un nouveau workflow remplace un flux direct, retirer le fallback vers l'ancien état dans la transition atomique
- Si la rétro-compatibilité est nécessaire, l'encapsuler dans un flag explicite ou une route dédiée, pas dans un `{ in: ['ancien', 'nouveau'] }` silencieux
- Contexte technique : workflow / transitions d'état — RL799_V2 08-04-2026
---
<a id="risque-couverture-chemins-ecriture-securite"></a>
## Couverture incomplète des chemins d'écriture lors d'ajout de sécurité transverse
### Risques
- Quand on ajoute une mesure de sécurité (chiffrement, sanitisation, validation, audit) sur un chemin d'écriture (ex: upload), les chemins alternatifs vers la même ressource (ex: create-from-text, import CSV, seed) sont oubliés, laissant une faille
### Symptômes
- Un chemin d'écriture protégé, un autre non protégé, pour la même ressource
- Données non chiffrées ou non sanitisées créées par un chemin secondaire
### Bonnes pratiques / mitigations
- Avant de marquer une tâche sécurité comme terminée, lister TOUS les chemins d'écriture vers la ressource ciblée (grep `createDocument`, `writeFile`, `prisma.model.create`) et vérifier que chacun est couvert
- Documenter la liste dans les Dev Notes de la story
- Contexte technique : sécurité / transverse — RL799_V2 08-04-2026
---
<a id="risque-anonymisation-rgpd-pieges"></a>
## Anonymisation RGPD — pièges courants
### Risques
- **Données personnelles dans les audit logs** : stocker email ou nom dans les métadonnées d'un audit log d'anonymisation annule le droit à l'oubli (art. 17)
- **Password en clair après anonymisation** : remplacer le password par une string comme `'ANONYMIZED'` est un signal exploitable en base
- **Dernier admin anonymisable** : le système peut se retrouver sans administrateur
- **Sessions non révoquées** : le JWT existant reste valide jusqu'à expiration
### Symptômes
- Email en clair dans les métadonnées d'audit après anonymisation
- Password `'ANONYMIZED'` visible en base au lieu d'un hash bcrypt structurellement invalide
- Aucun admin actif restant après anonymisation
- Utilisateur anonymisé qui peut encore accéder à l'application
### Bonnes pratiques / mitigations
- Utiliser un hash tronqué (ex: `sha256(email).slice(0,12)`) pour la corrélation dans les audit logs, pas l'email brut
- Remplacer le password par un hash bcrypt structurellement invalide (ex: `$2b$10$INVALID...`)
- Vérifier qu'au moins un admin actif reste après anonymisation
- Révoquer tous les refresh tokens du user dans la même transaction
- Contexte technique : RGPD / sécurité — RL799_V2 08-04-2026
---
<a id="risque-assertions-body-erreur-migration-codes"></a>
## Assertions de body d'erreur non mises à jour après migration de codes
### Risques
- Lors d'une migration de codes d'erreur (ex: distinguer `UNAUTHORIZED` de `FORBIDDEN`), les assertions de status HTTP sont mises à jour mais pas les assertions sur le body JSON
### Symptômes
- `assert.equal(response.status, 401)` passe, mais `assert.equal(body.error.code, 'FORBIDDEN')` échoue — le code réel est `UNAUTHORIZED`
- Détecté seulement quand on relance la suite complète
### Bonnes pratiques / mitigations
- Lors de toute modification d'un code d'erreur dans un helper centralisé, rechercher TOUTES les assertions qui testent l'ancien code (`grep -rn 'FORBIDDEN' __tests__/`) et les mettre à jour en cohérence
- Ne pas se fier au fait que "les tests passent" sans les exécuter réellement
- Contexte technique : tests / migration de codes — RL799_V2 08-04-2026
---
<a id="risque-audit-conditionnel-lookup-secondaire"></a>
## Audit conditionnel sur un lookup DB secondaire
### Risques
- Le handler utilise `requireRoleAccess` qui ne retourne que `{ email, role }` mais pas `userId`. Pour logger l'audit, un second lookup (`getUserByEmail`) est nécessaire et peut échouer silencieusement
### Symptômes
- Une action sensible (création, promotion, suppression) réussit mais aucun log d'audit n'est écrit
- Le caller est pourtant authentifié
### Bonnes pratiques / mitigations
- Sur tout endpoint qui doit journaliser un audit, utiliser un helper qui retourne `{ userId, email, role }` directement depuis le JWT `sub` claim, plutôt qu'un helper + lookup DB
- Le userId est déjà dans le token — pas besoin d'aller le chercher en base
- Contexte technique : audit / auth — RL799_V2 08-04-2026
---
<a id="risque-derive-format-erreur-api"></a>
## Dérive du format d'erreur API entre services
### Risques
- Chaque service recrée sa propre fonction `errorResponse` locale au lieu de réutiliser un helper centralisé
- Le format standard `{ error: { code, message, requestId } }` n'est pas imposé par le typage
### Symptômes
- Certaines routes API retournent `{ error: { code, message } }` sans `requestId`, d'autres incluent le `requestId`
- Les erreurs sans requestId sont impossibles à tracer en production
### Bonnes pratiques / mitigations
- Tout nouveau service doit inclure `requestId: crypto.randomUUID()` dans ses réponses d'erreur
- Factoriser un helper `createApiErrorResponse` partagé dans `lib/` pour éviter la divergence
- **Signal review** : réponse d'erreur sans `requestId` dans un nouveau service
- Contexte technique : observabilité / API — RL799_V2 08-04-2026
---
<a id="risque-unicite-applicative-sans-contrainte-db"></a>
## Unicité applicative sans contrainte DB
### Risques
- Un contrôle d'unicité uniquement applicatif sur des affectations "actives" reste vulnérable aux races concurrentes et peut créer deux titulaires actifs sur un même rôle
### Symptômes
- Deux enregistrements actifs pour un slot censé être unique (ex: deux titulaires pour un rôle d'officier)
- Bug intermittent sous charge concurrente, invisible en dev
### Bonnes pratiques / mitigations
- Pour tout agrégat avec invariant "un seul actif" (ex: mandat d'officier par rôle), imposer une contrainte d'unicité au niveau base (index unique partiel ou stratégie équivalente) en plus des checks service
- Les checks applicatifs seuls ne suffisent pas sous concurrence
- Contexte technique : Prisma / contraintes — RL799_V2 08-04-2026
---
<a id="risque-deploiement-migrations-apres-redemarrage"></a>
## Docker Compose — migrations exécutées après redémarrage applicatif
### Risques
- Fenêtre de non-compatibilité entre code déployé et schéma DB.
- Crash silencieux sur colonnes/contraintes nouvellement requises.
### Symptômes
- Redéploiement "vert" puis erreurs runtime immédiates sur accès DB.
### Bonnes pratiques / mitigations
- Appliquer les migrations avant le redémarrage applicatif.
- Séquence recommandée : `docker compose build` -> `docker compose run --rm api prisma migrate deploy` -> `docker compose up -d`.
- Contexte technique : Docker Compose / déploiement — RL799_V2 08-04-2026
---
<a id="risque-shell-sourcing-global-env"></a>
## Scripts shell — sourcing global de `.env`
### Risques
- `set -a; source .env` exporte tous les secrets à tous les sous-processus.
### Symptômes
- Secrets inutiles visibles dans l'environnement de commandes annexes (docker/curl/webhooks).
### Bonnes pratiques / mitigations
- Charger uniquement les variables nécessaires (`grep`/`cut` ou équivalent).
- Réserver `source .env` aux scripts qui ont réellement besoin de tout le contexte.
- Contexte technique : shell / secrets env — RL799_V2 08-04-2026
---
<a id="risque-compose-service-sans-healthcheck"></a>
## Docker Compose — services auxiliaires sans `healthcheck`
### Risques
- Faux positifs de disponibilité d'un service qui démarre mais n'est pas prêt.
### Symptômes
- Service "up" mais non exploitable (port bloqué, DB locale corrompue, etc.).
### Bonnes pratiques / mitigations
- Ajouter un `healthcheck` homogène sur tous les services critiques et auxiliaires.
- Aligner la politique de readiness/liveness sur l'ensemble du compose.
- Contexte technique : Docker Compose / observabilité service — RL799_V2 08-04-2026
---
<a id="risque-securite-bind-fail-open-production"></a>
## Configuration fail-open d'un bind réseau en production
### Risques
- Dashboard/service exposé publiquement si variable d'environnement manquante.
### Symptômes
- Service censé rester localement accessible exposé sur `0.0.0.0`.
### Bonnes pratiques / mitigations
- Forcer le bind sûr dans l'override de production (`127.0.0.1` ou réseau privé explicite).
- Ne pas dépendre d'un `.env` optionnel pour une contrainte de sécurité.
- Contexte technique : Docker Compose / sécurité réseau — RL799_V2 08-04-2026
---
<a id="risque-logs-preview-pii"></a>
## Logging de previews payload/HTML contenant des PII
### Risques
- Fuite de données personnelles via logs applicatifs agrégés.
### Symptômes
- Logs de debug contenant noms/emails/contenu message en clair.
### Bonnes pratiques / mitigations
- Ne jamais logger le body complet d'un message métier en prod.
- Journaliser uniquement des métadonnées minimales (id, statut, taille, hash tronqué).
- Contexte technique : logs / conformité — RL799_V2 15-04-2026
---
<a id="risque-base64-buffer-from-sans-exception"></a>
## Base64 invalide — `Buffer.from(..., 'base64')` ne lève pas d'exception
### Risques
- Configuration/signature invalide traitée comme erreur avaleuse (401 répétés sans cause claire).
### Symptômes
- Échec systématique de vérification HMAC sans signal de configuration corrompue.
### Bonnes pratiques / mitigations
- Valider explicitement le format/base64 attendu avant dérivation HMAC.
- Retourner un signal d'erreur opérationnelle explicite (config invalide) côté logs internes.
- Contexte technique : crypto / intégration webhook — RL799_V2 15-04-2026
---
<a id="risque-date-locale-sans-timezone"></a>
## Dates localisées sans `timeZone` explicite
### Risques
- Rendu de date divergent entre dev/CI/prod selon TZ machine.
### Symptômes
- Même ISO affiché sur des jours différents selon environnement.
### Bonnes pratiques / mitigations
- Toujours passer `timeZone` dans `toLocaleDateString`/`Intl.DateTimeFormat` pour les sorties métier.
- Définir une timezone métier unique pour les communications utilisateur.
- Contexte technique : dates / formatage serveur — RL799_V2 15-04-2026
---
<a id="risque-derive-dto-liste-vs-detail"></a>
## Dérive silencieuse DTO liste vs DTO détail
### Risques
- Un DTO "détail" expose un ensemble complet de champs métier, pendant qu'un DTO "liste" ne propage qu'un sous-ensemble jugé "suffisant" au moment où il est créé
- Au fil du temps, le front a besoin de plus de champs et découvre que les DTOs de liste sont **amputés** — workarounds ad-hoc, champs morts produits jamais consommés, helpers partagés impossibles à appeler sur les listes sans cast
### Symptômes
- Un consommateur front appelle l'endpoint détail juste pour obtenir un champ qui existe côté détail mais pas liste (N+1 réseau déguisé)
- Workarounds ad-hoc (`soireeClosedAt: Date | null` dans un mapper TenueSummary, copie partielle de champs) parce que le champ racine manque
- Helper partagé (`getSoireeLifecycle(input)`) qui accepte un `SoireeLifecycleInput` qu'**aucun** DTO de liste n'implémente réellement
- Type "sous-ensemble" (`SoireeCalendarStatus = 'draft' | 'pending_vm_approval' | 'published'`) aligné sur un filtre SQL transitoire plutôt que sur la sémantique du domaine
### Bonnes pratiques / mitigations
- **Règle par défaut** : DTO liste = sous-ensemble de DTO détail, pas un type parallèle. Extraire une base commune si besoin (`SoireeCore`).
- Pour chaque champ scalaire ajouté au DTO détail, se poser la question : doit-il aussi être dans les DTOs de liste ? Si oui, le propager sur-le-champ
- **Typage fort sur les sous-ensembles** : `SoireeCalendarStatus = SoireeStatus` (alias) plutôt qu'une union locale qui reflète un filtre SQL
- Test de coverage statique qui vérifie, pour chaque DTO ciblé, que tous ses mappers exposent les champs requis
- Audit périodique après une livraison qui ajoute des champs (ex : `openedAt` persistant) : lister les DTOs de liste et vérifier qu'aucun n'est amputé
- Contexte technique : DTO / contrats partagés — RL799_V2 23-04-2026
---
<a id="risque-notif-linkurl-non-role-aware"></a>
## Notification `linkUrl` non rôle-aware → page vide / 403 silencieux
### Risques
- Une notification envoyée à N destinataires multi-rôles avec un `linkUrl` constant route certains utilisateurs vers une page à laquelle ils n'ont pas accès
- Symptôme côté membre : "la notif m'envoie sur une page vide" — UX cassée sans message d'erreur explicite
### Symptômes
- Code de création de notif qui fait `recipients.map((r) => ({ linkUrl: 'constant' }))` sans lire `r.role`
- Notif qui cible plusieurs rôles (ex : "tous les membres") mais utilise un linkUrl pointant vers un module à accès restreint
### Bonnes pratiques / mitigations
```typescript
// Toujours sélectionner role dans le select des recipients
const recipients = await prisma.user.findMany({
where: { isActive: true, role: { in: [...ROLES_ALL_ACTIVE] } },
select: { id: true, role: true },
});
// Brancher le linkUrl par rôle
const secretariatRoles = new Set(['secretaire', 'venerable', 'admin']);
linkUrl: secretariatRoles.has(recipient.role)
? `/secretariat?soireeId=${id}`
: `/tenues?tab=calendrier`;
```
**Règle d'or** : le `linkUrl` d'une notif doit ouvrir une page **que l'utilisateur a le droit de voir ET où le contexte de la notif est visible**. Un membre qui reçoit "Soirée annulée" doit atterrir sur le calendrier (carte rouge), pas sur un module secrétariat qu'il ne peut pas consulter.
**Test E2E suggéré** : publier une notif multi-rôles, se connecter avec chaque rôle, cliquer, vérifier que chacun arrive sur une page accessible et pertinente.
- Contexte technique : notifications / RBAC — RL799_V2 23-04-2026
---
<a id="risque-matrice-documentee-vs-code"></a>
## Matrice documentée ≠ code — dérive silencieuse
### Risques
- Une matrice de permissions / contrats publiée dans une story (markdown) diverge discrètement de l'implémentation
- La doc dit "X peut Y", le code refuse Y à X (ou inversement). Aucun test ne couvre la combinaison rare
- La divergence se paye au prochain audit RBAC ou au touchement suivant du module — souvent par surprise
### Symptômes
- Story d'origine qui annonce une perm que le code ne grant pas (ou inversement)
- Un nouvel agent lit la story et la matrice, pense que la perm est active, et écrit du code qui repose dessus → faux positif aval
- Bug détecté plusieurs cycles après publication, par hasard
### Bonnes pratiques / mitigations
1. **Audit pré-flight systématique** avant tout PATCH d'un module RBAC : `grep -rn '<helper-perm>' apps/` pour confirmer les call sites, comparer avec la matrice de la story d'origine
2. **Réconciliation atomique** : si on touche un helper de permission, mettre à jour les **deux couches** (granulaire `permissions.ts` + fonctionnelle `documentPermissions.ts`) dans la même PR
3. **Test de matrice dédié** : un test unitaire qui itère la matrice de la story et vérifie chaque cellule. Casse à la première dérive
4. Préférer **un seul source of truth** (le code) et générer la doc automatiquement (markdown depuis tests, ou inverse)
- Contexte technique : RBAC / documentation — RL799_V2 20-04-2026
---
<a id="risque-format-user-id-mixte"></a>
## Format `User.id` : UUID OU slug, jamais les deux
### Risques
- Un schéma où `User.id` est un `String` libre finit par mélanger deux formats : IDs lisibles du seed (`admin`, `membre-m05`) et vrais UUIDs générés à l'invitation
- Conséquence : impossible de mettre `z.string().uuid()` dans les DTOs qui prennent un `userId` sans casser la prod
- Surface d'injection grande (payloads de 100 caractères acceptés au lieu de UUID stricts)
### Symptômes
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne"
- Deux populations d'ids coexistantes en base (seed slug + invitations UUID)
### Bonnes pratiques / mitigations
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
- **Ne jamais mélanger**
- Ajouter un test d'invariant : à la fin du seed, assert que tous les `users.id` matchent le format choisi
- Si migration vers UUID en cours de route : prévoir un script qui propage sur **toutes** les FKs (`audit_logs.user_id`, `notifications.recipient_id`, `refresh_tokens.user_id`, et tout `@relation` vers `User`)
- Pattern de migration : UUID v5 déterministe via `seedUserId(slug)` (cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`)
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
---
<a id="risque-web-push-topic-32-chars"></a>
## Web Push `topic` header > 32 chars rejeté/tronqué (RFC 8030)
### Risques
- La [RFC 8030 §5.4](https://datatracker.ietf.org/doc/html/rfc8030#section-5.4) limite le header `Topic` à 32 caractères URL-safe
- FCM tronque silencieusement (topics distincts pour deux notifs censées dédupliquer), Apple Push rejette la requête, Mozilla autopush comportement variable
- Symptôme : déduplication absente → avalanche de notifs au reconnect d'un device offline
### Symptômes
- Push provider qui retourne 4xx sur des `topic` longs
- Plusieurs notifs reçues là où une seule devrait l'être
### Bonnes pratiques / mitigations
```typescript
import crypto from 'node:crypto';
const hashTopic = (seed: string): string =>
crypto.createHash('sha256').update(seed).digest('base64url').slice(0, 32);
await webpush.sendNotification(sub, body, {
TTL: 86_400,
urgency: 'high',
topic: hashTopic(`${type}-${contextId}`), // toujours ≤ 32 chars URL-safe
});
```
**Notes** :
- `base64url` (Node `crypto` natif depuis 16.x) produit un encoding URL-safe (`A-Za-z0-9_-`)
- Tronquer à 32 chars **après** encoding base64url, pas avant le hash
- Test unitaire : assert `topic.length <= 32` ET `topic.match(/^[A-Za-z0-9_-]+$/)` pour toutes les seeds réalistes
- Contexte technique : Web Push / RFC 8030 — RL799_V2 28-04-2026
---
<a id="risque-lib-npm-types-non-embarques"></a>
## Lib npm avec types annoncés mais non embarqués
### Risques
- Certaines libs Node prétendent embarquer leurs types TS depuis une version donnée mais le package npm publié ne les contient pas
- `@types/<lib>` DefinitelyTyped existe mais peut être legacy, non maintenu, ou en conflit avec les exports réels du package
### Symptômes
- `Could not find a declaration file for module '<lib>'. … Try \`npm i --save-dev @types/<lib>\``
- TS7016 après `pnpm add <lib>` alors que la doc annonce que les types sont embarqués
### Bonnes pratiques / mitigations
Créer une déclaration TS locale minimaliste qui couvre uniquement la surface consommée par le projet :
```typescript
// apps/api/src/types/web-push.d.ts (exemple)
declare module 'web-push' {
export interface PushSubscriptionLike {
endpoint: string;
keys: { p256dh: string; auth: string };
}
export interface RequestOptions {
TTL?: number;
urgency?: 'very-low' | 'low' | 'normal' | 'high';
topic?: string;
}
export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
export function sendNotification(
sub: PushSubscriptionLike,
payload?: string | Buffer | null,
options?: RequestOptions,
): Promise<{ statusCode: number; body: string; headers: Record<string, string> }>;
const _default: { setVapidDetails: typeof setVapidDetails; sendNotification: typeof sendNotification };
export default _default;
}
```
**Bénéfices** :
- on est maître du contrat utilisé (si la lib évolue, on étend volontairement)
- pas de dépendance `@types/*` legacy
- documentable : commentaire JSDoc en tête `Pourquoi pas @types/<lib>`
**Préventif** :
- `tsconfig.json` doit `include` le dossier `src/types/**/*.d.ts`
- documenter en commentaire en tête du `.d.ts` POURQUOI on a écrit ça soi-même
- Contexte technique : TypeScript / npm — RL799_V2 28-04-2026
---
<a id="risque-form-html-post-mail"></a>
## Form HTML POST dans un mail = neutralisé par tous les clients
### Risques
- Un `<form method="POST" action="...">` placé dans le corps HTML d'un mail transactionnel est **neutralisé par tous les clients mail majeurs** — c'est une mesure anti-phishing universelle, pas un bug
- Toute donnée structurée doit transiter par **l'URL** d'un GET (query string ou path), donc visible côté visiteur
### Symptômes
| Client | Comportement réel sur `<form method="POST">` |
| --- | --- |
| Gmail web | Rewrite l'action en GET, body en query string |
| Gmail iOS/Android | Bouton inactif ou ouvre en GET |
| Outlook web | Strip le `<form>` complètement |
| Apple Mail (macOS/iOS) | Désactive le submit, bouton no-op |
| Thunderbird | Bloqué par sécurité |
### Bonnes pratiques / mitigations
Mitigations pour ne pas exposer la donnée dans l'URL navigable :
1. **Pattern token signé court** (HMAC ou JWT) : encode la donnée dans un token opaque dans la query string, échangé immédiatement côté client contre un état serveur, puis `history.replaceState()` pour nettoyer l'URL (cf. `pattern-magic-link-url-clean` dans `patterns/auth.md`)
2. **Token one-shot DB** : génère un token aléatoire stocké en DB, consommé à la 1ʳᵉ requête, expire ensuite
3. **Cookie de session courte** : le 1ᵉʳ hit set un cookie httpOnly puis redirige vers une URL clean
À documenter dans toute spec de magic link / RSVP / one-shot URL pour éviter qu'un dev parte sur un POST mail.
- Contexte technique : email transactionnel — RL799_V2 30-04-2026
---
<a id="risque-env-vars-frontend-facing-fail-fast"></a>
## env vars frontend-facing — fail-fast strict hors dev (pas de fallback `localhost`)
### Risques
- Un mail prod qui contient un lien `http://localhost:3000/foo` parce que `APP_URL` n'a pas été défini sur l'instance prod
- Aucun signal serveur, aucune erreur au déploiement, aucune trace en logs. L'utilisateur final clique → page introuvable
- Le fallback dev-friendly (`process.env.APP_URL ?? 'http://localhost:3000'`) cache l'erreur de config en non-dev
### Symptômes
- URL `localhost` dans des emails reçus par des utilisateurs réels
- Détection uniquement par un humain qui reçoit le mail, pas par le serveur
### Bonnes pratiques / mitigations
```typescript
export const getBaseUrl = (): string => {
const raw = process.env.APP_URL;
if (raw !== undefined && raw !== '') return raw.replace(/\/+$/, '');
if (process.env.NODE_ENV === 'development') return 'http://localhost:3000';
throw new Error('APP_URL non configuré (requis hors dev). Le bouton ... pointerait vers un host invalide.');
};
```
- Dev local : fallback silencieux (workflow attendu)
- Prod / staging / test : throw au premier appel → erreur visible dans les logs du dispatch
- Le throw au boot du dispatch est préférable à un mail dégradé silencieux
**Variantes à étendre** : tout helper qui construit une URL frontend depuis le backend (reset password, invitation, convocation, notification mail) doit utiliser le même helper centralisé. Une seule source de vérité par projet — éviter le doublon `APP_URL` + `APP_BASE_URL`.
**Test** : couvrir les 4 cas (env défini avec slash, env défini sans slash, env undefined NODE_ENV=dev → fallback, env undefined NODE_ENV=prod → throw).
- Contexte technique : config / mails transactionnels — RL799_V2 29-04-2026