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>
74 KiB
Backend — Risques & vigilance : Général
Extrait de la base de connaissance Lead_tech. Voir
knowledge/backend/risques/README.mdpour 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
upsertpar 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
upsertunitaire 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()ouschema.parse(body)qui précède l'appel au guard d'autorisation dans un handler. -
Cas vécu :
handleUpdateReportManualSectionsdansseasonReportService.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 denull - Le type DTO ment (
stringau lieu destring | null), et tout consommateur testantif (profile.field)a un comportement incorrect sur""
Symptômes
?? ''sur une date issue de?.toISOString()dans un repository Prisma- Type DTO avec
someDate: stringnon optionnel pour un modèle qui autorisenullen DB
Bonnes pratiques / mitigations
-
Si un champ de date peut être
nullen DB, le type DTO doit êtrestring | 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.createsansfindFirstpré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
created'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 aussinew 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 aussinew 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/updatedu 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
datade l'appel Prisma -
Contexte technique : Zod / Prisma — RL799_V2 06-04-2026
Catch vide avalant les exceptions sans logging
Risques
- Les
catch {}oucatch { // 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 {sansconsole.errorni 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 } })sansisValidUuid(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 typeStringsans 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 PrismaStringsans 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 typeString -
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/endDatesans 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 →updateen 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 unUPDATE ... 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
ApiErrorCodegé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.tsimporteResponse, construit les headers et status HTTP, et retourne un objetResponse - 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
Responsedans 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()sanswhereinclut 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()sanswherepour 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()sanswheredans 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 premierimport- Les tests qui modifient
process.envaprès l'import ne voient pas l'effet - Config non-reloadable sans redémarrage
Symptômes
process.env.Xassigné à 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
RateLimiteren 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-aftermanquant dans la réponse HTTP
Bonnes pratiques / mitigations
-
Pour chaque rate limiter intégré dans un endpoint, ajouter au minimum :
- Un test d'intégration qui dépasse le seuil via le handler HTTP et vérifie status 429 + body + header
retry-after - Un test d'expiration de fenêtre (mock
Date.nowet avancer le temps) - Vérifier que le logging sécurité est déclenché (au minimum visible dans la sortie test)
- Un test d'intégration qui dépasse le seuil via le handler HTTP et vérifie status 429 + body + header
-
Contexte technique : backend / rate limiting — RL799_V2 07-04-2026
Nommage des métriques agrégées dans les DTO
Risques
- Nommer un champ
xxxCountouxxxThisYearsans que le nom reflète exactement ce qui est compté - Confusion en maintenance, bugs d'affichage, labels UI incohérents
Symptômes
tenuesThisYearqui 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àtenuesThisYearsi on compte les attendances d'un user, réservertenuesThisYearpour le COUNT de tenues elles-mêmes -
Signal review : tout champ
xxxCountouxxxThisYeardont 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.gitignoresans 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.exampleexiste sur le disque mais n'apparaît pas dansgit status - Les développeurs ne savent pas quelles variables configurer
Bonnes pratiques / mitigations
-
Toujours ajouter
!.env.exampleaprè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.gitignoresans!.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 :
wheresurtableA.fieldavecselectsurtableB.fieldpour 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.createsur une entité à contrainte unique doit avoir un catchP2002 -
Contexte technique : Prisma / concurrence — RL799_V2 08-04-2026
Double update non transactionnel sur la même entité
Risques
- Enchaîner deux
prisma.updatesé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.updateou les envelopper dans une$transaction -
Signal review : deux
prisma.updateséquentiels sur le mêmewhere: { 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
UNAUTHORIZEDdeFORBIDDEN), les assertions de status HTTP sont mises à jour mais pas les assertions sur le body JSON
Symptômes
assert.equal(response.status, 401)passe, maisassert.equal(body.error.code, 'FORBIDDEN')échoue — le code réel estUNAUTHORIZED- 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
requireRoleAccessqui ne retourne que{ email, role }mais pasuserId. 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 JWTsubclaim, 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
errorResponselocale 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 } }sansrequestId, d'autres incluent lerequestId - 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
createApiErrorResponsepartagé danslib/pour éviter la divergence -
Signal review : réponse d'erreur sans
requestIddans 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 .envexporte 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/cutou équivalent). -
Réserver
source .envaux 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
healthcheckhomogè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.1ou réseau privé explicite). -
Ne pas dépendre d'un
.envoptionnel 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
timeZonedanstoLocaleDateString/Intl.DateTimeFormatpour 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 | nulldans un mapper TenueSummary, copie partielle de champs) parce que le champ racine manque - Helper partagé (
getSoireeLifecycle(input)) qui accepte unSoireeLifecycleInputqu'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 :
openedAtpersistant) : 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
linkUrlconstant 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 lirer.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
- 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 - Réconciliation atomique : si on touche un helper de permission, mettre à jour les deux couches (granulaire
permissions.ts+ fonctionnelledocumentPermissions.ts) dans la même PR - 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
- 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.idest unStringlibre 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 unuserIdsans 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 voudraitz.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.idmatchent 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@relationversUser) -
Pattern de migration : UUID v5 déterministe via
seedUserId(slug)(cf.pattern-uuid-v5-deterministe-seeddanspatterns/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
topiclongs - 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(Nodecryptonatif 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 <= 32ETtopic.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.jsondoitincludele dossiersrc/types/**/*.d.ts -
documenter en commentaire en tête du
.d.tsPOURQUOI 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 :
- 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-cleandanspatterns/auth.md) - Token one-shot DB : génère un token aléatoire stocké en DB, consommé à la 1ʳᵉ requête, expire ensuite
- 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/fooparce queAPP_URLn'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
localhostdans 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 ANALYZEré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 :
- Caller documenté dans le JSDoc (route admin ? cron ? script manuel ?)
- Cron actif (crontab, scheduler in-process, job BullMQ) qui l'invoque réellement
- Audit log écrit à chaque exécution (sinon impossible de savoir si la purge tourne en prod)
- Endpoint admin
GET /maintenance/statsexposanttotal+oldestRowpour 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 uneHttpExceptionNestJS (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:
degradedRedis down vsexceededvraie limite)
Symptômes
import { HttpException } from '@nestjs/common'dans un fichiershared/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. SeulLoggerest toléré comme dépendance framework (instrumentation transverse). -
Signal review : import d'un type de transport (
HttpException,Response) dans un fichierutils/. -
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çoitfalse) - 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(pasbeforeAll: 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.userIda une FKNOT NULLsurUser, 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.createdans un handler public sansuserIdauthentifié - Action anonyme auditable bloquée par le modèle
Bonnes pratiques / mitigations
Trois options selon le contexte métier :
- 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. - User système (
id: 'system', un seul row réservé) utilisé commeuserIddes actions anonymes. Audit DB cohérent, mais pollue la table User et complique les queries. - 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
userIdauthentifié) restent dansAuditLog -
Contexte technique : audit / actions publiques — RL799_V2 13-05-2026
http.Server.keepAliveTimeout = 0 ne désactive PAS le keep-alive
Risques
keepAliveTimeout = 0en 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épondreConnection: keep-alive- Utilisé en croyant "couper le keep-alive",
= 0fait le CONTRAIRE de l'intention courante
Symptômes
- Code de test/prod posant
server.keepAliveTimeout = 0comme "kill switch" du keep-alive — probablement du code mort qui ne fait rien - Le header
Connectionrestekeep-alivemalgré 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 viakeepAliveTimeout = 0 - Ne jamais utiliser
= 0comme 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 unGET /resource/:idqui retourne tout le contexte indépendamment du store de liste -
Garde-fou de review : à chaque ajout d'un endpoint upsert (
POST /resource), auditer l'endpointGET /listcorrespondant — 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),
tscvert 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
mergedAVANT 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 à
truepour 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/deleteManyglobal 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 :
- Le garde-fou s'exécute AVANT toute connexion DB et AVANT le truncate (sinon il truncate puis refuse)
- Liste BLANCHE d'hôtes locaux (
localhost/127.0.0.1/::1/db/postgres) ; tout host non listé → REFUS (fail-safe, jamais fail-open) DATABASE_URLabsente/malformée → REFUS (pas de crash, pas d'accept)- Refus aussi si
NODE_ENV=production - Bypass uniquement par flag explicite (
--force/SEED_FORCE=1), jamais activable par accident - 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 (unfindUniquepar élément), précisément là où le code avait factorisé enfindMany
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 : unexpiresAten 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
- Comparer en secondes :
expiresAt <= Math.floor(Date.now() / 1000), jamais<= Date.now() - Documenter l'unité dans le type/JSDoc au point d'écriture ET de lecture (
/** epoch en SECONDES */) - 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
- 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@uniqueP2002 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
okopaque 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 :
- Le wrapper bas-niveau remonte un discriminant SANS rethrow (il reste non-bloquant) :
{ ok: false, collision: code === 'P2002', errorCode: code } - L'appelant module le NIVEAU de log selon le discriminant :
warnpour l'attendu/bénin,errorpour la panne inattendue (qui doit remonter au monitoring) - Ne jamais se contenter d'un booléen
okopaque — 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 renvoyantcollisionpour toute erreur, corrigé en remontanterrorCode+ 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-tscet 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
- 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 - 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
- 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)
- 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
--optimizedindique à Keycloak de démarrer SANS re-évaluer les options build-time, en supposant unkc.sh buildpré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--optimizedprovoque 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 buildpuisstart --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 | stringet convertit vianew Date(str)peut recevoir une date invalide (new Date('invalid')) NaN > xetNaN < xsont TOUJOURSfalseen JS : une date invalide passée à un filtre de fenêtre temporelle produit unfalsesilencieux (événement ignoré) au lieu d'une erreur explicite
Symptômes
- Helper de fenêtre temporelle qui retourne
falsesans raison apparente pour certaines entrées localDayKey/comparaison qui produitNaNnon 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 :
isEventInSeasondanspackages/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(enumactive/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 (
statusvs 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 champstatus -
Cas vécu :
hospitalierVeilleRepository.getActiveSeason(bornes datées) divergeait deseasonRepository.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 (existence404 USER_NOT_FOUNDvs403access 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/:targetUserIdmais absent sur lePOST /.../messagesjumeau 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 distinguant404 USER_NOT_FOUNDd'un403 access deniedselon 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 entregetEligibilityet lePOSTjumeau -
Implémentation type : méthode
assertXxxRateLimit(userId, now)appelée en première instruction du handler ; constante de limite danspackages/contracts/.../<domain>.schemas.ts(réutilisable côté mobile pour hint UX) -
Contexte technique : sécurité / anti-énumération — app-alexandrie 13-05-2026