Files
_Assistant_Lead_Tech/knowledge/backend/risques/general.md
T
MaksTinyWorkshop f1b783407a docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)
Triage et intégration des propositions backend du buffer 95_a_capitaliser.md
(lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation
remote antérieure (triage 2026-05-02).

~73 entrées intégrées sur knowledge/backend/, dont :
- patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist
- patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C),
  row réactivable, endpoint replace atomique, updateMany conditionnel, etc.
- risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste,
  fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.)
- patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests
- compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...)
- README patterns/risques mis à jour

Doublons internes corrigés en relecture (suppression-champ .map() → general seul ;
e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés.
Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:25:02 +02:00

74 KiB

Backend — Risques & vigilance : Général

Extrait de la base de connaissance Lead_tech. Voir knowledge/backend/risques/README.md pour l'index complet.


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

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

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

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)


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


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


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

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


.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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

// 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

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

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


Web Push topic header > 32 chars rejeté/tronqué (RFC 8030)

Risques

  • La RFC 8030 §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

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


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/``
  • 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 :

// 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


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

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

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

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

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 :

// 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


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


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


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 :
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

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


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


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


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 :
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


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


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


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 :
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


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

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


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

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


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 :
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


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


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