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>
This commit is contained in:
MaksTinyWorkshop
2026-06-25 11:25:02 +02:00
parent ef24d85d57
commit f1b783407a
18 changed files with 2896 additions and 24 deletions
+526 -1
View File
@@ -90,7 +90,15 @@
- Pour les endpoints détail sensibles, filtrer l'accès dans la requête DB (`where` + scope grade/tenant) ou faire un pré-check minimal avant de charger les relations
- Les accès non autorisés ne doivent pas déclencher un fetch complet des données métier
- Contexte technique : backend général — RL799_V2 02-04-2026
### 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)
---
@@ -1145,3 +1153,520 @@ export const getBaseUrl = (): string => {
**Test** : couvrir les 4 cas (env défini avec slash, env défini sans slash, env undefined NODE_ENV=dev → fallback, env undefined NODE_ENV=prod → throw).
- Contexte technique : config / mails transactionnels — RL799_V2 29-04-2026
---
<a id="risque-fonction-purge-sans-cron-caller"></a>
## Fonction `deleteOlderThan` exposée sans cron caller — dette silencieuse
### Risques
- Une fonction de purge existe dans un repository (`deleteOlderThan(days)`, `purgeOlderThan(date)`) avec un commentaire du type "préparation pour future politique de rétention" mais aucun caller ne l'invoque jamais en prod
- À faible volume c'est invisible ; à mesure que la table grossit, l'index sur `(userId, createdAt)` ou `(createdAt)` se dégrade linéairement et ralentit les queries les plus chaudes (dashboard)
### Symptômes
- Fonction de purge avec JSDoc "non exposé via API", aucun caller documenté
- `EXPLAIN ANALYZE` révèle un scan d'index dégradé sur une table jamais purgée — diagnostic difficile car la cause est invisible côté code applicatif
### Bonnes pratiques / mitigations
À l'ouverture de toute fonction `delete*OlderThan`, vérifier :
1. Caller documenté dans le JSDoc (route admin ? cron ? script manuel ?)
2. Cron actif (crontab, scheduler in-process, job BullMQ) qui l'invoque réellement
3. Audit log écrit à chaque exécution (sinon impossible de savoir si la purge tourne en prod)
4. Endpoint admin `GET /maintenance/stats` exposant `total` + `oldestRow` pour observer la croissance
Si la rétention est métier (RGPD 12/24 mois), exposer un endpoint admin `POST /maintenance/purges` avec un mode `dryRun` (retourne les compteurs sans supprimer pour valider la politique avant de tirer).
- Contexte technique : backend / rétention — RL799_V2 05-05-2026
---
<a id="risque-couplage-framework-shared-utils"></a>
## Couplage framework (NestJS) dans `shared/utils/`
### Risques
- Un helper utilitaire dans `shared/utils/` jette directement une `HttpException` NestJS (ou un autre objet framework)
- L'util devient non réutilisable hors contexte HTTP (workers, jobs cron, CLI), force les tests à mocker le framework, et l'appelant perd la possibilité de différencier les causes d'échec (ex: `degraded` Redis down vs `exceeded` vraie limite)
### Symptômes
- `import { HttpException } from '@nestjs/common'` dans un fichier `shared/utils/`
- Test d'un util obligé d'instancier ou mocker un contexte HTTP
### Bonnes pratiques / mitigations
Le helper retourne un union discriminé framework-agnostic ; le service Nest traduit en exception :
```typescript
// shared/utils/daily-quota.ts (zéro import @nestjs/common)
export type DailyQuotaResult =
| { status: 'ok'; count: number }
| { status: 'degraded' }
| { status: 'exceeded'; count: number };
// modules/community/community.service.ts
const result = await consumeDailyQuota({ ... });
if (result.status === 'exceeded') {
throw new HttpException({ error: { code: 'QUOTA_EXCEEDED' } }, HttpStatus.TOO_MANY_REQUESTS);
}
```
- Règle : `shared/utils/` reste framework-agnostic. Seul `Logger` est toléré comme dépendance framework (instrumentation transverse).
- **Signal review** : import d'un type de transport (`HttpException`, `Response`) dans un fichier `utils/`.
- Contexte technique : architecture en couches — app-alexandrie 13-05-2026
---
<a id="risque-cache-in-process-stale-en-test"></a>
## Cache in-process stale dans les tests qui mutent la DB directement
### Risques
- Un test mute un modèle via `prisma.<model>.update()` direct en fixture, mais les lectures applicatives passent par un cache in-process (TTL, Map module-level, getter memoizé)
- Le cache stale fait que le test échoue, ou pire passe pour la mauvaise raison : un test "no-op si pas de X" peut passer parce que le cache stale ne voit jamais le X posé en fixture, masquant un vrai bug
### Symptômes
- Test sur un service consommant un cache qui échoue sur une assertion d'effet de bord (mail envoyé, status changé) au lieu d'une assertion logique (test attend `false`, reçoit `false`)
- Mutation Prisma directe en fixture sans invalidation du cache correspondant
### Bonnes pratiques / mitigations
- Identifier les caches in-process du projet (chercher `cached`, `invalidate*Cache`, getters memoizés, Map module-level)
- Exporter l'invalidator de chaque cache (`invalidate<X>Cache()`) — utile aux tests ET au code applicatif pour les writes hors handlers normaux
- Appeler les invalidators nécessaires dans le `beforeEach` (pas `beforeAll` : la suite peut faire plusieurs mutations) ET immédiatement après chaque mutation directe
- Documenter explicitement dans le setup pourquoi l'invalidation est nécessaire
- **Pattern de détection** : si un test échoue sur l'effet de bord alors que la logique semble correcte, suspecter le cache stale en premier
- Contexte technique : tests / cache in-process — RL799_V2 13-05-2026
---
<a id="risque-auditlog-userid-not-null-action-publique"></a>
## `AuditLog.userId NOT NULL` incompatible avec les actions publiques sans auth
### Risques
- Un endpoint public (sans auth) déclenche une mutation auditable (ex: désabonnement public via token), le réflexe est d'écrire dans `AuditLog`
- Mais si `AuditLog.userId` a une FK `NOT NULL` sur `User`, on ne peut pas créer une ligne audit pour un acteur anonyme — la FK lève
### Symptômes
- Erreur de contrainte FK lors d'un `auditLog.create` dans un handler public sans `userId` authentifié
- Action anonyme auditable bloquée par le modèle
### Bonnes pratiques / mitigations
Trois options selon le contexte métier :
1. **Logger structuré** (pipeline externe ELK/Sentry, pas la DB) : `logger.info({ type, event, profileId, outcome })`. Zéro friction, mais rétention dépendante des pipelines logs. Choix par défaut RL799.
2. **User système** (`id: 'system'`, un seul row réservé) utilisé comme `userId` des actions anonymes. Audit DB cohérent, mais pollue la table User et complique les queries.
3. **Relâcher la FK** en `userId String?`. Modèle propre, mais tous les call sites doivent gérer le nullable + retro-compat.
- Documenter explicitement le choix (JSDoc du handler + catalogue audit : "action publique non auditée en DB — tracée via `logger.info`")
- À évaluer avant prod : si une obligation réglementaire impose l'audit DB strict, l'option 1 ne suffit pas → basculer en 2 ou 3
- Les actions admin équivalentes (avec `userId` authentifié) restent dans `AuditLog`
- Contexte technique : audit / actions publiques — RL799_V2 13-05-2026
---
<a id="risque-keepalivetimeout-zero"></a>
## `http.Server.keepAliveTimeout = 0` ne désactive PAS le keep-alive
### Risques
- `keepAliveTimeout = 0` en Node.js signifie "pas de timer de fermeture" : la connexion keep-alive est gardée indéfiniment, pas fermée. Le serveur continue de répondre `Connection: keep-alive`
- Utilisé en croyant "couper le keep-alive", `= 0` fait le CONTRAIRE de l'intention courante
### Symptômes
- Code de test/prod posant `server.keepAliveTimeout = 0` comme "kill switch" du keep-alive — probablement du code mort qui ne fait rien
- Le header `Connection` reste `keep-alive` malgré le réglage
### Bonnes pratiques / mitigations
- Pour réellement répondre `Connection: close`, poser l'en-tête via middleware ou fermer explicitement les sockets — pas via `keepAliveTimeout = 0`
- Ne jamais utiliser `= 0` comme désactivation du keep-alive ; vérifier empiriquement avant de s'y fier :
```js
const s = require('http').createServer((q, r) => r.end('ok'));
s.listen(0, () => {
s.keepAliveTimeout = 0;
require('http').get({ port: s.address().port }, res =>
console.log(res.headers.connection)); // => "keep-alive", PAS "close"
});
```
- Contexte technique : Node.js / HTTP — app-alexandrie 21-05-2026
---
<a id="risque-upsert-filtre-liste-desync"></a>
## Upsert idempotent + filtre de liste sur attribut d'activité = pollution DB / désync client
### Risques
- Un endpoint upsert crée une ressource composite (DM, follow, room) sans attribut d'activité (`lastMessageAt`, `participantCount`), et l'endpoint de liste filtre sur cet attribut (`lastMessageAt: { not: null }`)
- Deux problèmes : (1) pollution DB silencieuse — un attaquant crée N ressources vides invisibles dans son UI ; (2) désynchronisation client → état illégal si le mobile dépend du store de liste pour des métadonnées (ex: `peerUserId`) et ne peut donc pas opérer sur la ressource fraîchement créée
### Symptômes
- Ressources vides accumulées en DB, jamais visibles côté client (filtre activité)
- Client incapable d'envoyer le 1er message / d'agir sur une ressource créée mais sans activité
### Bonnes pratiques / mitigations
- **Garbage collect** côté backend : job périodique supprimant les ressources vides depuis > X minutes (le plus propre)
- Ou retirer le filtre activité côté liste (exposer aussi les ressources vides — impact UI à arbitrer)
- Ou rendre l'écran de détail auto-suffisant : passer les métadonnées critiques en query param (`/messages/[id]?peerUserId=X`) ou exposer un `GET /resource/:id` qui retourne tout le contexte indépendamment du store de liste
- **Garde-fou de review** : à chaque ajout d'un endpoint upsert (`POST /resource`), auditer l'endpoint `GET /list` correspondant — si la liste a un filtre activité, l'écran de détail DOIT pouvoir s'auto-suffire
- Contexte technique : backend / upsert + REST — app-alexandrie 27-05-2026
---
<a id="risque-suppression-champ-typecheck-map"></a>
## Suppression de champ DB : le typecheck ne couvre PAS les objets construits via `.map()`
### Risques
- Après le retrait d'un champ d'un modèle (Prisma ou autre), `tsc` vert ne prouve PAS que tous les call-sites sont nettoyés
- L'excess-property-check de TypeScript ne s'applique qu'aux LITTÉRAUX d'objet directs, pas aux objets renvoyés par un callback `.map()`/`.reduce()` (typés "assignable", propriété en trop tolérée)
- Un `createMany({ data: items.map(i => ({ champRetiré: i.x })) })` compile et casse au runtime (Prisma : "Unknown argument")
### Symptômes
- Typecheck vert (seeds inclus) mais erreur runtime "Unknown argument 'X'" sur un seed/fixture utilisant `.map()`
- Champ retiré du modèle mais encore présent dans un callback de construction d'objet
### Bonnes pratiques / mitigations
- À chaque retrait de champ, faire un GREP textuel du nom du champ sur tout le repo (seeds, fixtures, scripts inclus) — ne pas se fier au seul typecheck
- Lancer le lint/tests sur les seeds et scripts, pas seulement sur les fichiers de la story (ces fichiers accumulent de la dette non vérifiée)
- Distinct de « Divergence schéma Prisma / spec story » (champ déclaré dans une story mais absent du schema) : ici le champ existait, a été retiré, et reste référencé via `.map()`
- Contexte technique : TypeScript / Prisma — app-alexandrie 02-06-2026
---
<a id="risque-gate-valeur-entrante-vs-cumulee"></a>
## Gate de seuil sur la valeur entrante au lieu de l'état cumulé
### Risques
- Quand un compteur de progression est "non-régressif" (on garde le max), un gate basé sur ce compteur qui lit la valeur du PAYLOAD courant au lieu de la valeur CUMULÉE refuse à tort une action déjà débloquée
- Un renvoi d'une valeur plus basse (autre device, rejeu, reset client) bloque une action légitime
### Symptômes
- Gate "≥ seuil" qui échoue alors que l'utilisateur a déjà dépassé le seuil sur un autre device
- Calcul du `merged = max(persisté, payload)` situé APRÈS le gate au lieu d'avant
### Bonnes pratiques / mitigations
- Tout gate basé sur un compteur non-régressif doit porter sur la valeur CUMULÉE (`max(persisté, payload)`), pas sur le seul payload
- Calculer le `merged` AVANT le gate, pas après
- Contexte technique : backend / progression — app-alexandrie 02-06-2026
---
<a id="risque-flag-capacite-non-reconcilie-transfert"></a>
## Flag de capacité global non réconcilié lors d'un transfert/réassignation
### Risques
- Une capacité utilisateur (`isPractitioner`, `isModerator`) est un BOOLÉEN global dérivé de relations N..1 (anime un pack, modère un forum)
- Lors d'un transfert de la relation, le code pose le flag sur le nouveau titulaire mais ne le retire jamais de l'ancien → le flag "colle" et reste à `true` pour d'anciens titulaires, état incohérent qui s'accumule
### Symptômes
- Ex-titulaire qui conserve une capacité globale sans plus rien animer/modérer
- Réassignation qui ne touche qu'un seul côté de la relation
### Bonnes pratiques / mitigations
- Tout transfert de relation doit RÉCONCILIER les deux côtés : poser le flag sur le nouveau ET le retirer de l'ancien s'il ne détient plus aucune relation qui le justifie
- Calculer la rétrogradation APRÈS le transfert (la relation courante ne compte plus), dans la même transaction :
```ts
if (previous && previous !== next) {
await transfer(next);
const stillJustified =
(await count({ packs: { practitioner: previous } })) > 0 ||
(await count({ forums: { moderator: previous } })) > 0;
if (!stillJustified) await demote(previous);
}
```
- **Test obligatoire** : réassigner → l'ancien perd le flag s'il n'a plus rien, le garde s'il anime encore autre chose
- Contexte technique : backend / capacités RBAC — app-alexandrie 03-06-2026
---
<a id="risque-seed-destructif-garde-fou-fail-safe"></a>
## Garde-fou d'un seed destructif (TRUNCATE) — fail-safe obligatoire
### Risques
- Un seed qui TRUNCATE toute la base est destructif : exécuté par erreur sur une prod ou une DB distante = perte de données
### Symptômes
- Seed avec `TRUNCATE`/`deleteMany` global sans garde-fou, ou garde-fou exécuté après la connexion DB
- Garde-fou fail-open (accepte par défaut, refuse sur liste noire)
### Bonnes pratiques / mitigations
Règles non négociables pour un seed destructif :
1. Le garde-fou s'exécute AVANT toute connexion DB et AVANT le truncate (sinon il truncate puis refuse)
2. Liste BLANCHE d'hôtes locaux (`localhost`/`127.0.0.1`/`::1`/`db`/`postgres`) ; tout host non listé → REFUS (fail-safe, jamais fail-open)
3. `DATABASE_URL` absente/malformée → REFUS (pas de crash, pas d'accept)
4. Refus aussi si `NODE_ENV=production`
5. Bypass uniquement par flag explicite (`--force`/`SEED_FORCE=1`), jamais activable par accident
6. Tester le garde-fou : prod→refus, DB distante→refus, URL absente→refus, locale→accept, `--force`→accept
- Extraire le garde-fou en fonction PURE (`evaluateSeedGuard`) testable sans I/O
- Contexte technique : seed / sécurité données — app-alexandrie 03-06-2026
---
<a id="risque-migration-flag-stocke-vers-derive"></a>
## Migration flag stocké → valeur dérivée : retirer l'ancien flag, pas le laisser mort
### Risques
- On remplace un flag booléen stocké (`User.isPractitioner` écrit à chaque assignation) par un calcul dérivé (`count(packs animés) > 0`) exposé via les entitlements
- Si l'ancien flag stocké reste écrit sans être lu, c'est du code mort trompeur + une fausse source de vérité concurrente qui peut diverger du calcul dérivé
### Symptômes
- Colonne/flag encore écrit dans le code mais lu par personne (dette invisible)
- Deux sources de vérité concurrentes pour la même information
### Bonnes pratiques / mitigations
- À la bascule, soit supprimer la colonne/le flag stocké et tout code qui l'écrit, soit documenter explicitement pourquoi il survit
- Vérifier par grep que plus AUCUNE logique d'accès ne lit l'ancien flag avant de considérer la migration terminée
- Le calcul dérivé (source de vérité = relations) est plus robuste car il ne diverge jamais
- Contexte technique : backend / source de vérité — app-alexandrie 04-06-2026
---
<a id="risque-bypass-sur-liste-lookup-batche"></a>
## Bypass d'autorisation sur une liste = lookup batché (éviter le N+1)
### Risques
- Ajouter un "bypass admin" (rôle court-circuitant une garde) sur un chemin traitant une LISTE d'utilisateurs invite le réflexe `ids.map(id => isAdmin(id))`, qui réintroduit un N+1 silencieux (un `findUnique` par élément), précisément là où le code avait factorisé en `findMany`
### Symptômes
- `Promise.all(ids.map(() => fetchUnitaire()))` dans un helper d'autorisation appelé sur une collection
- Un appel DB par élément de liste pour une vérification de rôle/flag
### Bonnes pratiques / mitigations
- Tout helper d'autorisation dérivé du rôle/d'un flag, appelé sur une collection (interlocuteurs, membres, destinataires), doit exposer une variante BATCH :
```ts
const getAdminIdSet = async (ids: string[]): Promise<Set<string>> => {
const rows = await prisma.user.findMany({
where: { id: { in: ids }, role: 'ADMIN' },
select: { id: true },
});
return new Set(rows.map(r => r.id));
};
```
- **Garde-fou de review** : si un nouveau `Promise.all(ids.map(() => fetchUnitaire()))` apparaît, exiger la version batch
- Contexte technique : backend / N+1 autorisation — app-alexandrie 04-06-2026
---
<a id="risque-epoch-secondes-vs-millisecondes"></a>
## `expiresAt`/`exp` : epoch en secondes (OIDC/JWT) vs millisecondes (`Date.now()`)
### Risques
- Les standards OIDC/JWT (`exp`, `iat`, `expires_in`) sont en SECONDES ; `Date.now()`, `new Date().getTime()` et la plupart des APIs JS sont en MILLISECONDES
- Comparer les deux sans conversion ne lève AUCUNE erreur (deux `number`) mais donne un résultat absurde : un `expiresAt` en secondes (~1.7e9) est TOUJOURS `<= Date.now()` en ms (~1.7e12) → tout est jugé "expiré"
- Le bug est aggravé par le découpage en lots (un lot écrit le champ, un autre le lit avec la mauvaise unité) et reste invisible tant que le chemin est dormant (derrière un flag off)
### Symptômes
- `expiresAt <= Date.now()` ou `< Date.now()` sur un champ issu d'un token/claim OIDC
- Tout est jugé expiré dès l'activation du flag ; aucun test ne couvre le chemin dormant
### Bonnes pratiques / mitigations
1. **Comparer en secondes** : `expiresAt <= Math.floor(Date.now() / 1000)`, jamais `<= Date.now()`
2. **Documenter l'unité** dans le type/JSDoc au point d'écriture ET de lecture (`/** epoch en SECONDES */`)
3. **Vérifier la cohérence end-to-end** quand écriture et lecture sont dans des lots/PR différents : tracer qui écrit, dans quelle unité, qui lit
4. **Signal review** : tout `<= Date.now()` / `< Date.now()` sur un champ issu d'un token/claim OIDC est suspect par défaut
- Contexte technique : auth / OIDC / unités de temps — RL799_V2 14-06-2026
---
<a id="risque-wrapper-fail-safe-catch-all"></a>
## Wrapper fail-safe catch-all qui noie les pannes anormales sous les échecs attendus
### Risques
- Un repo/service rendu non-bloquant par un `try/catch → return { ok: false }` global traite à l'identique deux causes opposées : l'échec ATTENDU/bénin (collision `@unique` P2002 sur un rejeu) et la panne INATTENDUE (connexion DB perdue, timeout, deadlock — anormal, mérite alerte ops)
- Une vraie panne devient indiscernable d'un cas nominal dans les logs, le diagnostic est noyé
### Symptômes
- Wrapper qui retourne un booléen `ok` opaque alors que plusieurs causes d'échec ont des implications ops différentes
- Logs uniformes pour une collision attendue et une perte de connexion DB
### Bonnes pratiques / mitigations
Qualifier l'échec avant de l'avaler :
1. Le wrapper bas-niveau remonte un discriminant SANS rethrow (il reste non-bloquant) : `{ ok: false, collision: code === 'P2002', errorCode: code }`
2. L'appelant module le NIVEAU de log selon le discriminant : `warn` pour l'attendu/bénin, `error` pour la panne inattendue (qui doit remonter au monitoring)
3. Ne jamais se contenter d'un booléen `ok` opaque — un champ de plus dans le type de retour garde le fail-safe ET la visibilité
- Cas vécu : `setKeycloakSubForUser` (RL799 K1.4) — catch-all renvoyant `collision` pour toute erreur, corrigé en remontant `errorCode` + log modulé.
- Contexte technique : observabilité / fail-safe — RL799_V2 15-06-2026
---
<a id="risque-retrait-route-asymetrique-front-back"></a>
## Retrait asymétrique front/back — route backend supprimée, call-sites frontend orphelins
### Risques
- Supprimer une route backend (cutover, dépréciation, refonte) sans retirer ses call-sites frontend produit des 404 silencieux
- Un frontend qui appelle la route via une URL en STRING (`fetch('/api/x')`) continue de COMPILER (pas d'import cassé) mais tape dans le vide → 404 runtime
- Si aucun test ne couvre ce parcours bout-en-bout, la suite reste 100% verte malgré le bug
### Symptômes
- 404 runtime sur un parcours utilisateur alors que `tsc`/`vue-tsc` et la suite de tests sont verts
- Route supprimée côté backend mais référencée par un service/composant frontend
### Bonnes pratiques / mitigations
1. **Retrait SYMÉTRIQUE** : retirer le handler backend ET le service/composant/route frontend dans le même lot. Grep `'/api/<route-retirée>'` côté frontend AVANT de considérer le retrait fait
2. **Préférer un 410 Gone à une suppression pure** quand le frontend ne peut pas être nettoyé immédiatement : un 410 avec message clair est une transition lisible (toast affiché), un 404 est opaque. Traiter TOUTES les routes du même retrait de façon homogène
3. **La couverture verte ne prouve PAS le retrait complet** : ajouter/garder un test asserant que le parcours client est soit retiré (route absente, bouton absent), soit géré (410 + message)
4. Attention aux composants PARTAGÉS : une page servant deux modes (reset-password ET invitation) ne doit pas être supprimée si un seul mode meurt — découper finement
- Contexte technique : retrait d'API / front-back — RL799_V2 15-06-2026
---
<a id="risque-keycloak-optimized-theme-volume"></a>
## Keycloak `start --optimized` incompatible avec un theme/provider monté en volume runtime
### Risques
- `--optimized` indique à Keycloak de démarrer SANS re-évaluer les options build-time, en supposant un `kc.sh build` préalable ayant FIGÉ la config dans l'image (theme/provider inclus au build)
- Monter un theme en volume runtime (`./themes/x:/opt/keycloak/themes/x:ro`) avec une image non-buildée et `--optimized` provoque soit un refus de démarrer ("The build time option … was changed, please rebuild"), soit un démarrage qui IGNORE le theme (liste figée au build)
### Symptômes
- Keycloak refuse de démarrer après ajout d'un theme/provider en volume
- Theme monté en volume mais non pris en compte (liste des thèmes figée)
### Bonnes pratiques / mitigations
- **Theme/provider en VOLUME runtime → `command: start`** (sans `--optimized`) : Keycloak lit la config et scanne les volumes au démarrage
- **Theme/provider DANS l'image → `kc.sh build` puis `start --optimized`** (boot plus rapide, mais rebuild d'image à chaque changement)
- Plus largement, pour toute appliance mêlant options build-time et runtime : ne pas combiner `--optimized` (qui présuppose un build) avec une config injectée au runtime. Vérifier au déploiement réel, pas seulement à la lecture du compose
- Bonus sécurité : épingler la version d'image et confirmer qu'elle matche la version installée avant un `up` — un up/downgrade Keycloak déclenche une migration de schéma potentiellement destructive
- Contexte technique : Keycloak / Docker Compose — RL799_V2 16-06-2026
---
<a id="risque-helper-comparaison-date-nan"></a>
## Helpers de comparaison de dates — garder contre `NaN` explicitement
### Risques
- Un helper qui accepte `Date | string` et convertit via `new Date(str)` peut recevoir une date invalide (`new Date('invalid')`)
- `NaN > x` et `NaN < x` sont TOUJOURS `false` en JS : une date invalide passée à un filtre de fenêtre temporelle produit un `false` silencieux (événement ignoré) au lieu d'une erreur explicite
### Symptômes
- Helper de fenêtre temporelle qui retourne `false` sans raison apparente pour certaines entrées
- `localDayKey`/comparaison qui produit `NaN` non détecté
### Bonnes pratiques / mitigations
- Tester `isNaN(d.getTime())` avant toute comparaison numérique sur une date convertie :
```ts
if (isNaN(event.getTime())) throw new Error('Date invalide passée à isEventInSeason');
```
- Placer le garde en tête du helper, avant toute comparaison `>`/`<`
- Cas vécu : `isEventInSeason` dans `packages/shared/src/utils/season.ts`, corrigé en revue v2-3-1.
- Contexte technique : dates / validation — RL799_V2 19-06-2026
---
<a id="risque-entite-active-via-status-pas-bornes"></a>
## Pattern "entité active" : utiliser le champ `status`, jamais reconstruire depuis les bornes temporelles
### Risques
- Quand un modèle a un champ `status` (enum `active/archived`, `active/ended`), recoder la requête "trouve l'entité active" via `{ startDate: { lte: now }, endDate: { gte: now } }` au lieu de `{ status: 'active' }` crée une divergence
- Ce critère daté : (1) casse avec les données de test où les bornes sont incomplètes/null, (2) diverge silencieusement du service existant si on oublie de les synchroniser, (3) échoue quand la logique "qui est active" change (ex: suspension manuelle)
### Symptômes
- Deux services pour la même sémantique avec des critères différents (`status` vs bornes datées)
- Query datée qui ne trouve jamais l'entité en test (seed avec `endDate: null`)
### Bonnes pratiques / mitigations
- La source de vérité de l'activité est le champ `status`, pas les bornes de dates : `where: { status: 'active' }`
- Les bornes restent utiles pour des requêtes analytiques ("quelles saisons couvraient cette date ?") mais pas pour "trouve l'entité courante"
- **Signal review** : `{ startDate: { lte: now }, endDate: { gte: now } }` dans un repo dont le modèle possède un champ `status`
- Cas vécu : `hospitalierVeilleRepository.getActiveSeason` (bornes datées) divergeait de `seasonRepository.getActiveSeason` (`status`).
- Contexte technique : Prisma / source de vérité — RL799_V2 20-06-2026
---
<a id="risque-anti-enumeration-codes-differencies-rate-limit"></a>
## Anti-énumération : endpoint à codes différenciés sur un userId cible doit être rate-limité
### Risques
- Un endpoint authentifié qui accepte un `:targetUserId` (ou équivalent) et renvoie des codes d'erreur DISTINCTS selon l'état du target (existence `404 USER_NOT_FOUND` vs `403` access denied, abonnement, relation sociale) permet l'énumération
- Un attaquant peut spammer le endpoint sur 10 000 userIds différents pour reconstituer le graphe social, les entitlements, ou la présence (user existe / supprimé) — même sans écriture
### Symptômes
- Endpoint authentifié sans rate-limit qui expose des relations (follow, blocages, packs partagés), un état calculé (entitlements, scores), ou un signal de présence
- Rate-limit présent sur le `GET /eligibility/:targetUserId` mais absent sur le `POST /.../messages` jumeau qui renvoie les mêmes bits d'info via ses codes d'erreur
### Bonnes pratiques / mitigations
- **Heuristique d'audit** pour tout nouvel endpoint authentifié : "que peut faire un attaquant qui spam ce endpoint sur 10 000 userIds ?". Si la réponse révèle une information dérivée par accumulation (relation, état calculé, présence), rate-limit obligatoire. Le critère n'est pas "écriture vs lecture" mais "exposition d'information dérivée"
- Cibler en particulier : `POST /<feature>/with/:targetUserId/...`, `GET /<feature>/eligibility/:targetUserId`, tout endpoint distinguant `404 USER_NOT_FOUND` d'un `403 access denied` selon l'existence du user
- Limite type : 60 req/min/user via Redis `incrWithExpireAt`, dégradation permissive si Redis KO
- **Clé Redis COMMUNE entre endpoints jumeaux** (`<service>-rate:<userId>:<window>`) : sinon l'attaquant multiplie sa surface en alternant entre `getEligibility` et le `POST` jumeau
- Implémentation type : méthode `assertXxxRateLimit(userId, now)` appelée en première instruction du handler ; constante de limite dans `packages/contracts/.../<domain>.schemas.ts` (réutilisable côté mobile pour hint UX)
- Contexte technique : sécurité / anti-énumération — app-alexandrie 13-05-2026