# 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

### RBAC-before-parse — autoriser avant de parser le body

- Le guard d'authentification/autorisation doit TOUJOURS être évalué AVANT tout accès au body de la requête. Un attaquant non authentifié peut sinon sonder les erreurs de validation Zod (codes, messages, structure du schéma) avant d'être rejeté, ce qui expose la surface d'attaque.
- Ordre obligatoire dans chaque handler HTTP : (1) `requireAuth` / `requireRoleAccess`, (2) validation du path/query params, (3) `request.json()` + validation Zod du body. Ne jamais inverser les étapes 1 et 3.
- Ne pas parser un payload pour un appel qui sera de toute façon refusé : c'est à la fois une fuite d'info et un coût inutile.
- **Signal review** : `request.json()` ou `schema.parse(body)` qui précède l'appel au guard d'autorisation dans un handler.
- Cas vécu : `handleUpdateReportManualSections` dans `seasonReportService.ts` (RBAC après parse), corrigé en revue adversariale v2-3-1.

- Contexte technique : backend général — RL799_V2 02-04-2026 (RBAC-before-parse ajouté 19-06-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

---

<a id="risque-fonction-purge-sans-cron-caller"></a>
## Fonction `deleteOlderThan` exposée sans cron caller — dette silencieuse

### Risques

- Une fonction de purge existe dans un repository (`deleteOlderThan(days)`, `purgeOlderThan(date)`) avec un commentaire du type "préparation pour future politique de rétention" mais aucun caller ne l'invoque jamais en prod
- À faible volume c'est invisible ; à mesure que la table grossit, l'index sur `(userId, createdAt)` ou `(createdAt)` se dégrade linéairement et ralentit les queries les plus chaudes (dashboard)

### Symptômes

- Fonction de purge avec JSDoc "non exposé via API", aucun caller documenté
- `EXPLAIN ANALYZE` révèle un scan d'index dégradé sur une table jamais purgée — diagnostic difficile car la cause est invisible côté code applicatif

### Bonnes pratiques / mitigations

À l'ouverture de toute fonction `delete*OlderThan`, vérifier :

1. Caller documenté dans le JSDoc (route admin ? cron ? script manuel ?)
2. Cron actif (crontab, scheduler in-process, job BullMQ) qui l'invoque réellement
3. Audit log écrit à chaque exécution (sinon impossible de savoir si la purge tourne en prod)
4. Endpoint admin `GET /maintenance/stats` exposant `total` + `oldestRow` pour observer la croissance

Si la rétention est métier (RGPD 12/24 mois), exposer un endpoint admin `POST /maintenance/purges` avec un mode `dryRun` (retourne les compteurs sans supprimer pour valider la politique avant de tirer).

- Contexte technique : backend / rétention — RL799_V2 05-05-2026

---

<a id="risque-couplage-framework-shared-utils"></a>
## Couplage framework (NestJS) dans `shared/utils/`

### Risques

- Un helper utilitaire dans `shared/utils/` jette directement une `HttpException` NestJS (ou un autre objet framework)
- L'util devient non réutilisable hors contexte HTTP (workers, jobs cron, CLI), force les tests à mocker le framework, et l'appelant perd la possibilité de différencier les causes d'échec (ex: `degraded` Redis down vs `exceeded` vraie limite)

### Symptômes

- `import { HttpException } from '@nestjs/common'` dans un fichier `shared/utils/`
- Test d'un util obligé d'instancier ou mocker un contexte HTTP

### Bonnes pratiques / mitigations

Le helper retourne un union discriminé framework-agnostic ; le service Nest traduit en exception :

```typescript
// shared/utils/daily-quota.ts (zéro import @nestjs/common)
export type DailyQuotaResult =
  | { status: 'ok'; count: number }
  | { status: 'degraded' }
  | { status: 'exceeded'; count: number };

// modules/community/community.service.ts
const result = await consumeDailyQuota({ ... });
if (result.status === 'exceeded') {
  throw new HttpException({ error: { code: 'QUOTA_EXCEEDED' } }, HttpStatus.TOO_MANY_REQUESTS);
}
```

- Règle : `shared/utils/` reste framework-agnostic. Seul `Logger` est toléré comme dépendance framework (instrumentation transverse).
- **Signal review** : import d'un type de transport (`HttpException`, `Response`) dans un fichier `utils/`.

- Contexte technique : architecture en couches — app-alexandrie 13-05-2026

---

<a id="risque-cache-in-process-stale-en-test"></a>
## Cache in-process stale dans les tests qui mutent la DB directement

### Risques

- Un test mute un modèle via `prisma.<model>.update()` direct en fixture, mais les lectures applicatives passent par un cache in-process (TTL, Map module-level, getter memoizé)
- Le cache stale fait que le test échoue, ou pire passe pour la mauvaise raison : un test "no-op si pas de X" peut passer parce que le cache stale ne voit jamais le X posé en fixture, masquant un vrai bug

### Symptômes

- Test sur un service consommant un cache qui échoue sur une assertion d'effet de bord (mail envoyé, status changé) au lieu d'une assertion logique (test attend `false`, reçoit `false`)
- Mutation Prisma directe en fixture sans invalidation du cache correspondant

### Bonnes pratiques / mitigations

- Identifier les caches in-process du projet (chercher `cached`, `invalidate*Cache`, getters memoizés, Map module-level)
- Exporter l'invalidator de chaque cache (`invalidate<X>Cache()`) — utile aux tests ET au code applicatif pour les writes hors handlers normaux
- Appeler les invalidators nécessaires dans le `beforeEach` (pas `beforeAll` : la suite peut faire plusieurs mutations) ET immédiatement après chaque mutation directe
- Documenter explicitement dans le setup pourquoi l'invalidation est nécessaire
- **Pattern de détection** : si un test échoue sur l'effet de bord alors que la logique semble correcte, suspecter le cache stale en premier

- Contexte technique : tests / cache in-process — RL799_V2 13-05-2026

---

<a id="risque-auditlog-userid-not-null-action-publique"></a>
## `AuditLog.userId NOT NULL` incompatible avec les actions publiques sans auth

### Risques

- Un endpoint public (sans auth) déclenche une mutation auditable (ex: désabonnement public via token), le réflexe est d'écrire dans `AuditLog`
- Mais si `AuditLog.userId` a une FK `NOT NULL` sur `User`, on ne peut pas créer une ligne audit pour un acteur anonyme — la FK lève

### Symptômes

- Erreur de contrainte FK lors d'un `auditLog.create` dans un handler public sans `userId` authentifié
- Action anonyme auditable bloquée par le modèle

### Bonnes pratiques / mitigations

Trois options selon le contexte métier :

1. **Logger structuré** (pipeline externe ELK/Sentry, pas la DB) : `logger.info({ type, event, profileId, outcome })`. Zéro friction, mais rétention dépendante des pipelines logs. Choix par défaut RL799.
2. **User système** (`id: 'system'`, un seul row réservé) utilisé comme `userId` des actions anonymes. Audit DB cohérent, mais pollue la table User et complique les queries.
3. **Relâcher la FK** en `userId String?`. Modèle propre, mais tous les call sites doivent gérer le nullable + retro-compat.

- Documenter explicitement le choix (JSDoc du handler + catalogue audit : "action publique non auditée en DB — tracée via `logger.info`")
- À évaluer avant prod : si une obligation réglementaire impose l'audit DB strict, l'option 1 ne suffit pas → basculer en 2 ou 3
- Les actions admin équivalentes (avec `userId` authentifié) restent dans `AuditLog`

- Contexte technique : audit / actions publiques — RL799_V2 13-05-2026

---

<a id="risque-keepalivetimeout-zero"></a>
## `http.Server.keepAliveTimeout = 0` ne désactive PAS le keep-alive

### Risques

- `keepAliveTimeout = 0` en Node.js signifie "pas de timer de fermeture" : la connexion keep-alive est gardée indéfiniment, pas fermée. Le serveur continue de répondre `Connection: keep-alive`
- Utilisé en croyant "couper le keep-alive", `= 0` fait le CONTRAIRE de l'intention courante

### Symptômes

- Code de test/prod posant `server.keepAliveTimeout = 0` comme "kill switch" du keep-alive — probablement du code mort qui ne fait rien
- Le header `Connection` reste `keep-alive` malgré le réglage

### Bonnes pratiques / mitigations

- Pour réellement répondre `Connection: close`, poser l'en-tête via middleware ou fermer explicitement les sockets — pas via `keepAliveTimeout = 0`
- Ne jamais utiliser `= 0` comme désactivation du keep-alive ; vérifier empiriquement avant de s'y fier :

```js
const s = require('http').createServer((q, r) => r.end('ok'));
s.listen(0, () => {
  s.keepAliveTimeout = 0;
  require('http').get({ port: s.address().port }, res =>
    console.log(res.headers.connection)); // => "keep-alive", PAS "close"
});
```

- Contexte technique : Node.js / HTTP — app-alexandrie 21-05-2026

---

<a id="risque-upsert-filtre-liste-desync"></a>
## Upsert idempotent + filtre de liste sur attribut d'activité = pollution DB / désync client

### Risques

- Un endpoint upsert crée une ressource composite (DM, follow, room) sans attribut d'activité (`lastMessageAt`, `participantCount`), et l'endpoint de liste filtre sur cet attribut (`lastMessageAt: { not: null }`)
- Deux problèmes : (1) pollution DB silencieuse — un attaquant crée N ressources vides invisibles dans son UI ; (2) désynchronisation client → état illégal si le mobile dépend du store de liste pour des métadonnées (ex: `peerUserId`) et ne peut donc pas opérer sur la ressource fraîchement créée

### Symptômes

- Ressources vides accumulées en DB, jamais visibles côté client (filtre activité)
- Client incapable d'envoyer le 1er message / d'agir sur une ressource créée mais sans activité

### Bonnes pratiques / mitigations

- **Garbage collect** côté backend : job périodique supprimant les ressources vides depuis > X minutes (le plus propre)
- Ou retirer le filtre activité côté liste (exposer aussi les ressources vides — impact UI à arbitrer)
- Ou rendre l'écran de détail auto-suffisant : passer les métadonnées critiques en query param (`/messages/[id]?peerUserId=X`) ou exposer un `GET /resource/:id` qui retourne tout le contexte indépendamment du store de liste
- **Garde-fou de review** : à chaque ajout d'un endpoint upsert (`POST /resource`), auditer l'endpoint `GET /list` correspondant — si la liste a un filtre activité, l'écran de détail DOIT pouvoir s'auto-suffire

- Contexte technique : backend / upsert + REST — app-alexandrie 27-05-2026

---

<a id="risque-suppression-champ-typecheck-map"></a>
## Suppression de champ DB : le typecheck ne couvre PAS les objets construits via `.map()`

### Risques

- Après le retrait d'un champ d'un modèle (Prisma ou autre), `tsc` vert ne prouve PAS que tous les call-sites sont nettoyés
- L'excess-property-check de TypeScript ne s'applique qu'aux LITTÉRAUX d'objet directs, pas aux objets renvoyés par un callback `.map()`/`.reduce()` (typés "assignable", propriété en trop tolérée)
- Un `createMany({ data: items.map(i => ({ champRetiré: i.x })) })` compile et casse au runtime (Prisma : "Unknown argument")

### Symptômes

- Typecheck vert (seeds inclus) mais erreur runtime "Unknown argument 'X'" sur un seed/fixture utilisant `.map()`
- Champ retiré du modèle mais encore présent dans un callback de construction d'objet

### Bonnes pratiques / mitigations

- À chaque retrait de champ, faire un GREP textuel du nom du champ sur tout le repo (seeds, fixtures, scripts inclus) — ne pas se fier au seul typecheck
- Lancer le lint/tests sur les seeds et scripts, pas seulement sur les fichiers de la story (ces fichiers accumulent de la dette non vérifiée)
- Distinct de « Divergence schéma Prisma / spec story » (champ déclaré dans une story mais absent du schema) : ici le champ existait, a été retiré, et reste référencé via `.map()`

- Contexte technique : TypeScript / Prisma — app-alexandrie 02-06-2026

---

<a id="risque-gate-valeur-entrante-vs-cumulee"></a>
## Gate de seuil sur la valeur entrante au lieu de l'état cumulé

### Risques

- Quand un compteur de progression est "non-régressif" (on garde le max), un gate basé sur ce compteur qui lit la valeur du PAYLOAD courant au lieu de la valeur CUMULÉE refuse à tort une action déjà débloquée
- Un renvoi d'une valeur plus basse (autre device, rejeu, reset client) bloque une action légitime

### Symptômes

- Gate "≥ seuil" qui échoue alors que l'utilisateur a déjà dépassé le seuil sur un autre device
- Calcul du `merged = max(persisté, payload)` situé APRÈS le gate au lieu d'avant

### Bonnes pratiques / mitigations

- Tout gate basé sur un compteur non-régressif doit porter sur la valeur CUMULÉE (`max(persisté, payload)`), pas sur le seul payload
- Calculer le `merged` AVANT le gate, pas après

- Contexte technique : backend / progression — app-alexandrie 02-06-2026

---

<a id="risque-flag-capacite-non-reconcilie-transfert"></a>
## Flag de capacité global non réconcilié lors d'un transfert/réassignation

### Risques

- Une capacité utilisateur (`isPractitioner`, `isModerator`) est un BOOLÉEN global dérivé de relations N..1 (anime un pack, modère un forum)
- Lors d'un transfert de la relation, le code pose le flag sur le nouveau titulaire mais ne le retire jamais de l'ancien → le flag "colle" et reste à `true` pour d'anciens titulaires, état incohérent qui s'accumule

### Symptômes

- Ex-titulaire qui conserve une capacité globale sans plus rien animer/modérer
- Réassignation qui ne touche qu'un seul côté de la relation

### Bonnes pratiques / mitigations

- Tout transfert de relation doit RÉCONCILIER les deux côtés : poser le flag sur le nouveau ET le retirer de l'ancien s'il ne détient plus aucune relation qui le justifie
- Calculer la rétrogradation APRÈS le transfert (la relation courante ne compte plus), dans la même transaction :

```ts
if (previous && previous !== next) {
  await transfer(next);
  const stillJustified =
    (await count({ packs: { practitioner: previous } })) > 0 ||
    (await count({ forums: { moderator: previous } })) > 0;
  if (!stillJustified) await demote(previous);
}
```

- **Test obligatoire** : réassigner → l'ancien perd le flag s'il n'a plus rien, le garde s'il anime encore autre chose

- Contexte technique : backend / capacités RBAC — app-alexandrie 03-06-2026

---

<a id="risque-seed-destructif-garde-fou-fail-safe"></a>
## Garde-fou d'un seed destructif (TRUNCATE) — fail-safe obligatoire

### Risques

- Un seed qui TRUNCATE toute la base est destructif : exécuté par erreur sur une prod ou une DB distante = perte de données

### Symptômes

- Seed avec `TRUNCATE`/`deleteMany` global sans garde-fou, ou garde-fou exécuté après la connexion DB
- Garde-fou fail-open (accepte par défaut, refuse sur liste noire)

### Bonnes pratiques / mitigations

Règles non négociables pour un seed destructif :

1. Le garde-fou s'exécute AVANT toute connexion DB et AVANT le truncate (sinon il truncate puis refuse)
2. Liste BLANCHE d'hôtes locaux (`localhost`/`127.0.0.1`/`::1`/`db`/`postgres`) ; tout host non listé → REFUS (fail-safe, jamais fail-open)
3. `DATABASE_URL` absente/malformée → REFUS (pas de crash, pas d'accept)
4. Refus aussi si `NODE_ENV=production`
5. Bypass uniquement par flag explicite (`--force`/`SEED_FORCE=1`), jamais activable par accident
6. Tester le garde-fou : prod→refus, DB distante→refus, URL absente→refus, locale→accept, `--force`→accept

- Extraire le garde-fou en fonction PURE (`evaluateSeedGuard`) testable sans I/O

- Contexte technique : seed / sécurité données — app-alexandrie 03-06-2026

---

<a id="risque-migration-flag-stocke-vers-derive"></a>
## Migration flag stocké → valeur dérivée : retirer l'ancien flag, pas le laisser mort

### Risques

- On remplace un flag booléen stocké (`User.isPractitioner` écrit à chaque assignation) par un calcul dérivé (`count(packs animés) > 0`) exposé via les entitlements
- Si l'ancien flag stocké reste écrit sans être lu, c'est du code mort trompeur + une fausse source de vérité concurrente qui peut diverger du calcul dérivé

### Symptômes

- Colonne/flag encore écrit dans le code mais lu par personne (dette invisible)
- Deux sources de vérité concurrentes pour la même information

### Bonnes pratiques / mitigations

- À la bascule, soit supprimer la colonne/le flag stocké et tout code qui l'écrit, soit documenter explicitement pourquoi il survit
- Vérifier par grep que plus AUCUNE logique d'accès ne lit l'ancien flag avant de considérer la migration terminée
- Le calcul dérivé (source de vérité = relations) est plus robuste car il ne diverge jamais

- Contexte technique : backend / source de vérité — app-alexandrie 04-06-2026

---

<a id="risque-bypass-sur-liste-lookup-batche"></a>
## Bypass d'autorisation sur une liste = lookup batché (éviter le N+1)

### Risques

- Ajouter un "bypass admin" (rôle court-circuitant une garde) sur un chemin traitant une LISTE d'utilisateurs invite le réflexe `ids.map(id => isAdmin(id))`, qui réintroduit un N+1 silencieux (un `findUnique` par élément), précisément là où le code avait factorisé en `findMany`

### Symptômes

- `Promise.all(ids.map(() => fetchUnitaire()))` dans un helper d'autorisation appelé sur une collection
- Un appel DB par élément de liste pour une vérification de rôle/flag

### Bonnes pratiques / mitigations

- Tout helper d'autorisation dérivé du rôle/d'un flag, appelé sur une collection (interlocuteurs, membres, destinataires), doit exposer une variante BATCH :

```ts
const getAdminIdSet = async (ids: string[]): Promise<Set<string>> => {
  const rows = await prisma.user.findMany({
    where: { id: { in: ids }, role: 'ADMIN' },
    select: { id: true },
  });
  return new Set(rows.map(r => r.id));
};
```

- **Garde-fou de review** : si un nouveau `Promise.all(ids.map(() => fetchUnitaire()))` apparaît, exiger la version batch

- Contexte technique : backend / N+1 autorisation — app-alexandrie 04-06-2026

---

<a id="risque-epoch-secondes-vs-millisecondes"></a>
## `expiresAt`/`exp` : epoch en secondes (OIDC/JWT) vs millisecondes (`Date.now()`)

### Risques

- Les standards OIDC/JWT (`exp`, `iat`, `expires_in`) sont en SECONDES ; `Date.now()`, `new Date().getTime()` et la plupart des APIs JS sont en MILLISECONDES
- Comparer les deux sans conversion ne lève AUCUNE erreur (deux `number`) mais donne un résultat absurde : un `expiresAt` en secondes (~1.7e9) est TOUJOURS `<= Date.now()` en ms (~1.7e12) → tout est jugé "expiré"
- Le bug est aggravé par le découpage en lots (un lot écrit le champ, un autre le lit avec la mauvaise unité) et reste invisible tant que le chemin est dormant (derrière un flag off)

### Symptômes

- `expiresAt <= Date.now()` ou `< Date.now()` sur un champ issu d'un token/claim OIDC
- Tout est jugé expiré dès l'activation du flag ; aucun test ne couvre le chemin dormant

### Bonnes pratiques / mitigations

1. **Comparer en secondes** : `expiresAt <= Math.floor(Date.now() / 1000)`, jamais `<= Date.now()`
2. **Documenter l'unité** dans le type/JSDoc au point d'écriture ET de lecture (`/** epoch en SECONDES */`)
3. **Vérifier la cohérence end-to-end** quand écriture et lecture sont dans des lots/PR différents : tracer qui écrit, dans quelle unité, qui lit
4. **Signal review** : tout `<= Date.now()` / `< Date.now()` sur un champ issu d'un token/claim OIDC est suspect par défaut

- Contexte technique : auth / OIDC / unités de temps — RL799_V2 14-06-2026

---

<a id="risque-wrapper-fail-safe-catch-all"></a>
## Wrapper fail-safe catch-all qui noie les pannes anormales sous les échecs attendus

### Risques

- Un repo/service rendu non-bloquant par un `try/catch → return { ok: false }` global traite à l'identique deux causes opposées : l'échec ATTENDU/bénin (collision `@unique` P2002 sur un rejeu) et la panne INATTENDUE (connexion DB perdue, timeout, deadlock — anormal, mérite alerte ops)
- Une vraie panne devient indiscernable d'un cas nominal dans les logs, le diagnostic est noyé

### Symptômes

- Wrapper qui retourne un booléen `ok` opaque alors que plusieurs causes d'échec ont des implications ops différentes
- Logs uniformes pour une collision attendue et une perte de connexion DB

### Bonnes pratiques / mitigations

Qualifier l'échec avant de l'avaler :

1. Le wrapper bas-niveau remonte un discriminant SANS rethrow (il reste non-bloquant) : `{ ok: false, collision: code === 'P2002', errorCode: code }`
2. L'appelant module le NIVEAU de log selon le discriminant : `warn` pour l'attendu/bénin, `error` pour la panne inattendue (qui doit remonter au monitoring)
3. Ne jamais se contenter d'un booléen `ok` opaque — un champ de plus dans le type de retour garde le fail-safe ET la visibilité

- Cas vécu : `setKeycloakSubForUser` (RL799 K1.4) — catch-all renvoyant `collision` pour toute erreur, corrigé en remontant `errorCode` + log modulé.

- Contexte technique : observabilité / fail-safe — RL799_V2 15-06-2026

---

<a id="risque-retrait-route-asymetrique-front-back"></a>
## Retrait asymétrique front/back — route backend supprimée, call-sites frontend orphelins

### Risques

- Supprimer une route backend (cutover, dépréciation, refonte) sans retirer ses call-sites frontend produit des 404 silencieux
- Un frontend qui appelle la route via une URL en STRING (`fetch('/api/x')`) continue de COMPILER (pas d'import cassé) mais tape dans le vide → 404 runtime
- Si aucun test ne couvre ce parcours bout-en-bout, la suite reste 100% verte malgré le bug

### Symptômes

- 404 runtime sur un parcours utilisateur alors que `tsc`/`vue-tsc` et la suite de tests sont verts
- Route supprimée côté backend mais référencée par un service/composant frontend

### Bonnes pratiques / mitigations

1. **Retrait SYMÉTRIQUE** : retirer le handler backend ET le service/composant/route frontend dans le même lot. Grep `'/api/<route-retirée>'` côté frontend AVANT de considérer le retrait fait
2. **Préférer un 410 Gone à une suppression pure** quand le frontend ne peut pas être nettoyé immédiatement : un 410 avec message clair est une transition lisible (toast affiché), un 404 est opaque. Traiter TOUTES les routes du même retrait de façon homogène
3. **La couverture verte ne prouve PAS le retrait complet** : ajouter/garder un test asserant que le parcours client est soit retiré (route absente, bouton absent), soit géré (410 + message)
4. Attention aux composants PARTAGÉS : une page servant deux modes (reset-password ET invitation) ne doit pas être supprimée si un seul mode meurt — découper finement

- Contexte technique : retrait d'API / front-back — RL799_V2 15-06-2026

---

<a id="risque-keycloak-optimized-theme-volume"></a>
## Keycloak `start --optimized` incompatible avec un theme/provider monté en volume runtime

### Risques

- `--optimized` indique à Keycloak de démarrer SANS re-évaluer les options build-time, en supposant un `kc.sh build` préalable ayant FIGÉ la config dans l'image (theme/provider inclus au build)
- Monter un theme en volume runtime (`./themes/x:/opt/keycloak/themes/x:ro`) avec une image non-buildée et `--optimized` provoque soit un refus de démarrer ("The build time option … was changed, please rebuild"), soit un démarrage qui IGNORE le theme (liste figée au build)

### Symptômes

- Keycloak refuse de démarrer après ajout d'un theme/provider en volume
- Theme monté en volume mais non pris en compte (liste des thèmes figée)

### Bonnes pratiques / mitigations

- **Theme/provider en VOLUME runtime → `command: start`** (sans `--optimized`) : Keycloak lit la config et scanne les volumes au démarrage
- **Theme/provider DANS l'image → `kc.sh build` puis `start --optimized`** (boot plus rapide, mais rebuild d'image à chaque changement)
- Plus largement, pour toute appliance mêlant options build-time et runtime : ne pas combiner `--optimized` (qui présuppose un build) avec une config injectée au runtime. Vérifier au déploiement réel, pas seulement à la lecture du compose
- Bonus sécurité : épingler la version d'image et confirmer qu'elle matche la version installée avant un `up` — un up/downgrade Keycloak déclenche une migration de schéma potentiellement destructive

- Contexte technique : Keycloak / Docker Compose — RL799_V2 16-06-2026

---

<a id="risque-helper-comparaison-date-nan"></a>
## Helpers de comparaison de dates — garder contre `NaN` explicitement

### Risques

- Un helper qui accepte `Date | string` et convertit via `new Date(str)` peut recevoir une date invalide (`new Date('invalid')`)
- `NaN > x` et `NaN < x` sont TOUJOURS `false` en JS : une date invalide passée à un filtre de fenêtre temporelle produit un `false` silencieux (événement ignoré) au lieu d'une erreur explicite

### Symptômes

- Helper de fenêtre temporelle qui retourne `false` sans raison apparente pour certaines entrées
- `localDayKey`/comparaison qui produit `NaN` non détecté

### Bonnes pratiques / mitigations

- Tester `isNaN(d.getTime())` avant toute comparaison numérique sur une date convertie :

```ts
if (isNaN(event.getTime())) throw new Error('Date invalide passée à isEventInSeason');
```

- Placer le garde en tête du helper, avant toute comparaison `>`/`<`
- Cas vécu : `isEventInSeason` dans `packages/shared/src/utils/season.ts`, corrigé en revue v2-3-1.

- Contexte technique : dates / validation — RL799_V2 19-06-2026

---

<a id="risque-entite-active-via-status-pas-bornes"></a>
## Pattern "entité active" : utiliser le champ `status`, jamais reconstruire depuis les bornes temporelles

### Risques

- Quand un modèle a un champ `status` (enum `active/archived`, `active/ended`), recoder la requête "trouve l'entité active" via `{ startDate: { lte: now }, endDate: { gte: now } }` au lieu de `{ status: 'active' }` crée une divergence
- Ce critère daté : (1) casse avec les données de test où les bornes sont incomplètes/null, (2) diverge silencieusement du service existant si on oublie de les synchroniser, (3) échoue quand la logique "qui est active" change (ex: suspension manuelle)

### Symptômes

- Deux services pour la même sémantique avec des critères différents (`status` vs bornes datées)
- Query datée qui ne trouve jamais l'entité en test (seed avec `endDate: null`)

### Bonnes pratiques / mitigations

- La source de vérité de l'activité est le champ `status`, pas les bornes de dates : `where: { status: 'active' }`
- Les bornes restent utiles pour des requêtes analytiques ("quelles saisons couvraient cette date ?") mais pas pour "trouve l'entité courante"
- **Signal review** : `{ startDate: { lte: now }, endDate: { gte: now } }` dans un repo dont le modèle possède un champ `status`
- Cas vécu : `hospitalierVeilleRepository.getActiveSeason` (bornes datées) divergeait de `seasonRepository.getActiveSeason` (`status`).

- Contexte technique : Prisma / source de vérité — RL799_V2 20-06-2026

---

<a id="risque-anti-enumeration-codes-differencies-rate-limit"></a>
## Anti-énumération : endpoint à codes différenciés sur un userId cible doit être rate-limité

### Risques

- Un endpoint authentifié qui accepte un `:targetUserId` (ou équivalent) et renvoie des codes d'erreur DISTINCTS selon l'état du target (existence `404 USER_NOT_FOUND` vs `403` access denied, abonnement, relation sociale) permet l'énumération
- Un attaquant peut spammer le endpoint sur 10 000 userIds différents pour reconstituer le graphe social, les entitlements, ou la présence (user existe / supprimé) — même sans écriture

### Symptômes

- Endpoint authentifié sans rate-limit qui expose des relations (follow, blocages, packs partagés), un état calculé (entitlements, scores), ou un signal de présence
- Rate-limit présent sur le `GET /eligibility/:targetUserId` mais absent sur le `POST /.../messages` jumeau qui renvoie les mêmes bits d'info via ses codes d'erreur

### Bonnes pratiques / mitigations

- **Heuristique d'audit** pour tout nouvel endpoint authentifié : "que peut faire un attaquant qui spam ce endpoint sur 10 000 userIds ?". Si la réponse révèle une information dérivée par accumulation (relation, état calculé, présence), rate-limit obligatoire. Le critère n'est pas "écriture vs lecture" mais "exposition d'information dérivée"
- Cibler en particulier : `POST /<feature>/with/:targetUserId/...`, `GET /<feature>/eligibility/:targetUserId`, tout endpoint distinguant `404 USER_NOT_FOUND` d'un `403 access denied` selon l'existence du user
- Limite type : 60 req/min/user via Redis `incrWithExpireAt`, dégradation permissive si Redis KO
- **Clé Redis COMMUNE entre endpoints jumeaux** (`<service>-rate:<userId>:<window>`) : sinon l'attaquant multiplie sa surface en alternant entre `getEligibility` et le `POST` jumeau
- Implémentation type : méthode `assertXxxRateLimit(userId, now)` appelée en première instruction du handler ; constante de limite dans `packages/contracts/.../<domain>.schemas.ts` (réutilisable côté mobile pour hint UX)

- Contexte technique : sécurité / anti-énumération — app-alexandrie 13-05-2026
