mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 01:53:40 +02:00
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:
@@ -8,14 +8,14 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
|
||||
|
||||
| Fichier | Domaine | Entrées clés |
|
||||
|---------|---------|--------------|
|
||||
| `auth.md` | Auth, sessions, tokens, erreurs API, corrélation | Format erreur standardisé, middleware requestId, anti-énumération, token usage unique, autorisation interne, opérations atomiques |
|
||||
| `contracts.md` | Contrats API, Zod, error codes, HTTP sémantique | Contracts-First/Zod-Infer/No-DTO, error codes comme contrat, HTTP 200 payload métier, idempotence POST = retour ressource existante |
|
||||
| `prisma.md` | Prisma, DB, migrations, pagination | Soft delete, pagination cursor, idempotency key, P2002 unique, Decimal sérialisation, migration manuelle P3014, filtrage métier dans service |
|
||||
| `stripe.md` | Stripe, paiements, webhooks entrants, subscriptions | Provider-Strategy, metadata subscription_data, parsing webhook unique, restauration achats, Trial vs Paid |
|
||||
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique |
|
||||
| `auth.md` | Auth, sessions, tokens, erreurs API, corrélation | Format erreur standardisé, middleware requestId, anti-énumération, token usage unique, autorisation interne, opérations atomiques, jose whitelist algo, membrane BFF fédérée (jonction unique, session stateless JWE, pont d'identité idpSub, refresh 100% serveur), cutover irréversible, token opaque quick-link, RBAC dérivé JWT enrichi, 403 vs 404 oracle d'existence |
|
||||
| `contracts.md` | Contrats API, Zod, error codes, HTTP sémantique | Contracts-First/Zod-Infer/No-DTO, error codes comme contrat, HTTP 200 payload métier, idempotence POST = retour ressource existante, typer strict à la source, prop audience templates mail/PDF, verbe HTTP marker idempotent, endpoint distinct vs paramètre force, Zod .optional() honnête |
|
||||
| `prisma.md` | Prisma, DB, migrations, pagination | Soft delete, pagination cursor, idempotency key, P2002 unique, Decimal sérialisation, migration manuelle P3014 (+ réalignement DB dev), filtrage métier dans service, colonnes plates vs table dédiée, étape dérivée source unique, row réactivable, endpoint replace atomique, bascule idempotente updateMany, pagination relation N-N (some), $queryRawUnsafe en façade, token usage-unique condition WHERE, FK + snapshot label dérivé, migration String/Int → enum (backfill + sans downtime), util crypto transverse |
|
||||
| `stripe.md` | Stripe, paiements, webhooks entrants, subscriptions | Provider-Strategy, metadata subscription_data, parsing webhook unique, restauration achats, Trial vs Paid, prix en base → price_data inline |
|
||||
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique, ressource paire non ordonnée (clé canonique), batched eligibility (anti N+1 .map async), providers externes via injection par token, e2e DB-based NestJS + Prisma v7 |
|
||||
| `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement |
|
||||
| `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe |
|
||||
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL, test de rollback pipeline multi-fichiers atomique |
|
||||
| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable, ordre canonique des gates HTTP, délégation agrégat → endpoint agrégé, anti-énumération DELETE 204, lazy init memoizé, cap LRU par-user, convention dot-notation audit, whitelist explicite audit, singleton DB config, invalidation cache avant mutation, pipeline CI/CD GitHub Actions → VPS |
|
||||
| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné, test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe |
|
||||
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL, test de rollback pipeline multi-fichiers atomique, Promise.all queries DB indépendantes, cron multi-instance lock Redis SET NX EX, verrou une-action-par-entité updateMany, persist-then-send (effet de bord à statut persistant) |
|
||||
| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable, ordre canonique des gates HTTP, délégation agrégat → endpoint agrégé, anti-énumération DELETE 204, lazy init memoizé, cap LRU par-user, convention dot-notation audit, whitelist explicite audit, singleton DB config, invalidation cache avant mutation, pipeline CI/CD GitHub Actions → VPS, contrôle d'accès conditionnel pure logic shared, safe path resolution anti-traversal, alias d'import @/* migration en masse, test d'invariant de structure (scan statique), script ops batch fail-closed, helper transverse paramétrable (sendMailWithRetry) |
|
||||
| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné (+ vérif absence side-effect), test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe, DI testable via hooks module-level `__setXForTests`, e2e DB-based NestJS + Prisma v7 (Jest/swc) |
|
||||
| `llm-providers.md` | Fournisseurs LLM, auth, coûts | OAuth consumer ≠ API key, prompt caching system prompt stable, budget cap fournisseur |
|
||||
|
||||
@@ -278,3 +278,168 @@ Tout pipeline qui écrit N fichiers avec rollback (suppression des déjà-écrit
|
||||
- que l'erreur est bien propagée à l'appelant.
|
||||
|
||||
Quand le nombre d'artefacts change (ajout d'un variant), mettre à jour ce test pour couvrir le nouveau N.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-promise-all-queries-db-independantes"></a>
|
||||
## Pattern : Promise.all des queries DB indépendantes dans un service handler
|
||||
|
||||
- Objectif : réduire la latence d'un service handler dont les queries DB sont posées en série alors qu'elles n'ont aucune dépendance de données.
|
||||
- Contexte : service handler API avec plusieurs `await` chaînés (lectures DB indépendantes).
|
||||
- Quand l'utiliser : >2 `await` dans le corps du handler, dont les arguments ne dérivent pas du résultat précédent.
|
||||
- Quand l'éviter :
|
||||
- contrôles d'autorisation (auth → user lookup → permission) : garder en série pour fail-fast et ne pas gaspiller de queries sur une requête non autorisée
|
||||
- boucles `for (...) await` à pagination progressive / arrêt anticipé : volontairement séquentielles
|
||||
- Avantage :
|
||||
- latence totale `~max()` au lieu de `~sum()`
|
||||
- gain proportionnellement **plus grand** en prod réseau (chaque round-trip cumule en série, s'absorbe en parallèle)
|
||||
- Limites / vigilance :
|
||||
- les queries conditionnelles doivent être emballées en `Promise.resolve(default)` pour garder le type `Promise<T>` dans le tuple
|
||||
- la transformation des résultats reste APRÈS le `Promise.all` ; les dérivations synchrones des seuls paramètres d'entrée peuvent passer AVANT
|
||||
- Validé le : 11-05-2026
|
||||
- Contexte technique : Next.js 16 API + Prisma 7 — RL799_V2
|
||||
|
||||
### Anti-pattern (séquentiel)
|
||||
|
||||
```ts
|
||||
const odjItems = soireeId ? await findOdjItemsBySoiree(soireeId) : [];
|
||||
const issues = await findLatestIssuesByTenueIds(tenueIds);
|
||||
const soireeContext = soireeId ? await getSoireeContext(soireeId) : null;
|
||||
const convocations = await findConvocationsByTenuesAndGrade(tenueIds, userId);
|
||||
// total : ~sum(latence_de_chaque_query)
|
||||
```
|
||||
|
||||
Symptômes : endpoint dans le top des plus lents, `curl -w "%{time_total}"` corrélé linéairement au nombre d'`await`, chaque query individuelle rapide (~50-70 ms) sans N+1 — c'est l'orchestration qui est en cause.
|
||||
|
||||
### Pattern (parallèle)
|
||||
|
||||
```ts
|
||||
const [odjItems, issues, soireeContext, convocations] = await Promise.all([
|
||||
soireeId ? findOdjItemsBySoiree(soireeId) : Promise.resolve([]),
|
||||
findLatestIssuesByTenueIds(tenueIds),
|
||||
soireeId ? getSoireeContext(soireeId) : Promise.resolve(null),
|
||||
findConvocationsByTenuesAndGrade(tenueIds, userId),
|
||||
]);
|
||||
// total : ~max(latence_de_chaque_query)
|
||||
```
|
||||
|
||||
### Détection
|
||||
|
||||
1. Mesurer (`curl -w "%{time_total}"` / `console.time()`), cibler les endpoints > 150 ms en local DB chaude.
|
||||
2. Compter les `await` du handler ; >2 hors contrôles d'autorisation = suspect.
|
||||
3. Pour chaque `await N+1`, vérifier si l'argument dérive du résultat de `await N`. Si NON → parallélisable.
|
||||
|
||||
### Tests
|
||||
|
||||
Le contrat de l'endpoint ne change pas. Les tests qui mockent les repositories passent sans modification. Si un test casse, c'est qu'il sur-spécifiait l'ordre d'exécution (anti-pattern de test).
|
||||
|
||||
Cas vécu : `dashboardService.handleProchaineTenue` — 4 queries séquentielles → `Promise.all`. 240 ms → 170 ms (-29% local, -40 à -50% projeté prod réseau), 1526/1526 tests verts sans modification.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-cron-multi-instance-lock-redis"></a>
|
||||
## Pattern : Cron multi-instance idempotent via lock Redis `SET NX EX`
|
||||
|
||||
- Objectif : garantir qu'un `@Cron` ne s'exécute qu'**une seule fois** en environnement multi-pods, sans introduire de scheduler distribué (Bull, etc.).
|
||||
- Contexte : `@Cron` (`@nestjs/schedule`) déployé sur N instances avec effets de bord (notifications, mails de campagne).
|
||||
- Quand l'utiliser : tout cron avec effet de bord non idempotent en déploiement multi-instance.
|
||||
- Quand l'éviter : instance unique garantie, ou job purement idempotent.
|
||||
- Avantage :
|
||||
- aucune dépendance supplémentaire
|
||||
- auto-libération du lock si le pod crashe (TTL)
|
||||
- Limites / vigilance :
|
||||
- TTL > durée du job mais < intervalle entre deux exécutions
|
||||
- Redis down → renvoyer `false` (s'abstenir : ne pas risquer une double exécution d'effets de bord)
|
||||
- expliciter l'expression cron ET la timezone — les raccourcis `CronExpression.*` partent en UTC serveur
|
||||
- Validé le : 04-06-2026
|
||||
- Contexte technique : NestJS / `@nestjs/schedule` / Redis — app-alexandrie
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// Lock distribué : n'exécuter le corps QUE si SET NX renvoie 'OK'
|
||||
async acquireLock(key: string, ttlSec: number): Promise<boolean> {
|
||||
const res = await client.set(key, '1', { NX: true, EX: ttlSec });
|
||||
return res === 'OK';
|
||||
}
|
||||
|
||||
@Cron('0 8 * * 1', { timeZone: 'Europe/Paris' }) // expression + TZ explicites
|
||||
async runWeeklyJob() {
|
||||
if (!(await this.acquireLock('cron:weekly-job', 600))) return; // un autre pod a gagné, ou Redis down
|
||||
// ... corps du job ...
|
||||
}
|
||||
```
|
||||
|
||||
### Règle
|
||||
|
||||
Expliciter l'expression cron ET la timezone (`{ timeZone: 'Europe/Paris' }`) : les raccourcis `CronExpression.EVERY_WEEK` / `EVERY_DAY_AT_9AM` s'exécutent en UTC (heure serveur) → décalage vs l'intention métier.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-verrou-une-action-par-entite-updatemany"></a>
|
||||
## Pattern : Verrou atomique « une action par entité » via `updateMany` conditionnel
|
||||
|
||||
- Objectif : garantir qu'une action ne peut être déclenchée qu'**au plus une fois** par entité (relance, remboursement, envoi de mail de campagne, confirmation), résistant aux requêtes concurrentes.
|
||||
- Contexte : endpoint qui déclenche un effet de bord one-shot marqué par un champ (`sentAt`, `refundedAt`...).
|
||||
- Quand l'utiliser : tout flow « au plus une fois par entité » exposé à des appels concurrents.
|
||||
- Quand l'éviter : action naturellement idempotente côté effet de bord.
|
||||
- Distinction avec le lock Redis cron : ici le verrou est porté par la **DB** (champ d'état de l'entité, atomicité de l'`UPDATE ... WHERE`), pas par un lock Redis externe ; il protège une action par entité, pas une exécution unique de job multi-pods.
|
||||
- Avantage :
|
||||
- guard précoce (fast-path) qui évite le coût des requêtes intermédiaires pour les appels normaux
|
||||
- `updateMany` conditionnel atomique qui tranche la race condition
|
||||
- Limites / vigilance :
|
||||
- le `if (alreadySent) return 409` initial seul **ne suffit pas** : deux requêtes concurrentes le passent toutes les deux
|
||||
- `lock.count === 0` = une autre requête a gagné la course → 409
|
||||
- Validé le : 20-06-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// Guard précoce (optimistic fast-path, non atomique — suffit pour 99% des cas)
|
||||
if (entity.sentAt) return errorResponse(409, 'ALREADY_SENT', '...');
|
||||
|
||||
// ... traitement coûteux ...
|
||||
|
||||
// Verrou atomique : UPDATE conditionnel WHERE sentAt IS NULL
|
||||
const lock = await prisma.entity.updateMany({
|
||||
where: { id: entityId, sentAt: null },
|
||||
data: { sentAt: new Date() },
|
||||
});
|
||||
if (lock.count === 0) {
|
||||
return errorResponse(409, 'ALREADY_SENT', '...');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-persist-then-send-statut-apres-effet"></a>
|
||||
## Pattern : Effet de bord externe avec statut persistant — « persist-then-send »
|
||||
|
||||
- Objectif : ne pas faire mentir un statut métier qui reflète l'état d'un effet de bord externe, en cas d'échec partiel.
|
||||
- Contexte : opération qui (1) persiste une donnée nécessaire à une vérification ultérieure (token hash, idempotency key), (2) déclenche un effet externe best-effort (mail, webhook), (3) expose un statut (`pending|emailed|active`).
|
||||
- Quand l'utiliser : dès que le statut exposé doit refléter l'état RÉEL de l'effet externe et que ce dernier peut échouer.
|
||||
- Quand l'éviter : pas d'effet externe, ou statut sans valeur métier (rejouabilité non requise).
|
||||
- Avantage :
|
||||
- tout artefact émis reste traçable côté serveur (donnée vérifiable posée d'abord)
|
||||
- le statut reste `pending` (rejouable) si l'effet externe échoue
|
||||
- Limites / vigilance :
|
||||
- exige DEUX écritures (poser la donnée vérifiable, puis marquer le statut) — pas un seul `update` atomique
|
||||
- Validé le : 16-06-2026
|
||||
- Contexte technique : Prisma / Resend / Keycloak — RL799_V2
|
||||
|
||||
### Règle
|
||||
|
||||
Séparer en deux écritures : d'abord poser la donnée vérifiable SANS toucher au statut, PUIS — seulement si l'effet externe réussit — faire passer le statut. Ordre `persist token → send → mark status`.
|
||||
|
||||
```txt
|
||||
// ✅ token toujours vérifiable même si le mail rate ; statut = état réel (reste pending → rejouable)
|
||||
setOnboardingToken(...) // hash + expiresAt, statut INCHANGÉ
|
||||
await executeActionsEmail() // effet externe (attendre le 204)
|
||||
markOnboardingEmailed(...) // statut passé à 'emailed' APRÈS confirmation
|
||||
|
||||
// ❌ un seul update atomique avant l'envoi : le statut ment si Resend/Keycloak est down
|
||||
update({ status: 'emailed', tokenHash, expiresAt })
|
||||
```
|
||||
|
||||
Cas vécu : RL799 Lot B onboarding Keycloak — `setOnboardingToken` (statut inchangé) puis `markOnboardingEmailed` (statut après 204 confirmé de `executeActionsEmail`).
|
||||
|
||||
@@ -297,6 +297,15 @@ Le helper `requireRoleAccess` doit retourner :
|
||||
- [ ] 403 uniquement quand le token est valide mais le rôle insuffisant
|
||||
- [ ] Frontend redirige vers `/login` sur 401, affiche "accès refusé" sur 403
|
||||
|
||||
### Complément — 403 vs 404 sur ressource existante non autorisée (oracle d'existence)
|
||||
|
||||
Sur une ressource cloisonnée par appartenance (colonne, tenant, propriétaire), le choix 403 vs 404 est lui-même un risque d'énumération. Si l'ordre des contrôles est « ressource introuvable → 404 » PUIS « non autorisé → 403 », un attaquant distingue « la ressource existe mais pas pour moi » (403) de « n'existe pas » (404) → énumération.
|
||||
|
||||
- Pour les ressources cloisonnées par appartenance, renvoyer **404 uniforme** dans le chemin non-privilégié (introuvable ET hors-périmètre confondus).
|
||||
- Réserver le **403** aux cas où l'existence de la ressource est déjà publique.
|
||||
- Trade-off : erreurs client moins précises. Pertinence proportionnelle à la prévisibilité des IDs (négligeable sur UUID v4, réelle sur IDs séquentiels).
|
||||
- Cas vécu : consultation d'instruction hors-colonne RL799 → bascule 403 → 404.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-auth-dernier-admin-actif"></a>
|
||||
@@ -677,3 +686,282 @@ test('rejette un token signé avec un autre purpose', async () => {
|
||||
- Tokens d'accès aux ressources publiques limitées dans le temps
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-jose-whitelist-algorithme"></a>
|
||||
## Pattern : jose — whitelister explicitement l'algorithme (signature ET chiffrement)
|
||||
|
||||
- Objectif : empêcher les attaques de confusion d'algorithme en figeant l'algo accepté à la vérification/déchiffrement, et non en se fiant à ce que la clé permet.
|
||||
- Contexte : tout module qui vérifie (`jwtVerify`) ou déchiffre (`jwtDecrypt`) des tokens/sessions avec `jose` — OIDC, JWE de session BFF, tokens internes.
|
||||
- Quand l'utiliser : à chaque appel `jose` qui consomme un token signé ou chiffré.
|
||||
- Quand l'éviter : jamais sur une surface qui consomme des tokens externes ou attaquables.
|
||||
- Avantage :
|
||||
- bloque la confusion d'algorithme (un attaquant ne peut pas forcer un algo plus faible présent dans le JWKS ou supporté par la clé)
|
||||
- rend explicite le contrat cryptographique attendu de l'émetteur
|
||||
- Limites / vigilance :
|
||||
- l'`alg` est un contrôle à part entière — `iss`/`aud`/`exp` ne le couvrent pas
|
||||
- garder la whitelist synchronisée avec ce que l'IdP/l'émetteur produit réellement
|
||||
- Validé le : 13-06-2026
|
||||
- Contexte technique : jose / OIDC / JWE — RL799_V2
|
||||
|
||||
### Règle
|
||||
|
||||
`jwtVerify(token, jwks, { issuer, audience })` SANS `algorithms: [...]` accepte tout algo présent dans le JWKS → confusion d'algorithme. Idem `jwtDecrypt(jwe, key)` sans `keyManagementAlgorithms`/`contentEncryptionAlgorithms` accepte tout algo supporté par jose avec la clé fournie.
|
||||
|
||||
- À chaque `jwtVerify` : passer `algorithms: [<algo émis par l'IdP>]` (Keycloak = `['RS256']`).
|
||||
- À chaque `jwtDecrypt` : passer `keyManagementAlgorithms` + `contentEncryptionAlgorithms` figés sur ce que l'émetteur produit (ex. `['dir']` + `['A256GCM']`).
|
||||
- L'`alg` est le 4ᵉ contrôle obligatoire après `iss`/`aud`/`exp`. S'applique à tout (dé)chiffrement de tokens/sessions, pas seulement OIDC.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] `algorithms: [...]` explicite sur chaque `jwtVerify`
|
||||
- [ ] `keyManagementAlgorithms` + `contentEncryptionAlgorithms` explicites sur chaque `jwtDecrypt`
|
||||
- [ ] Whitelist alignée sur l'émetteur réel
|
||||
- [ ] Test : un token signé/chiffré avec un autre algo est rejeté
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-membrane-auth-federee"></a>
|
||||
## Pattern : Membrane d'auth fédérée en coexistence — point de jonction unique
|
||||
|
||||
- Objectif : greffer un 2ᵉ système d'authentification (ex. Keycloak OIDC) à côté de l'auth maison sans la modifier, de façon rollback-safe et sans disperser l'AuthN/AuthZ.
|
||||
- Contexte : intégration d'un IdP fédéré en coexistence avec une auth existante, livrée par lots.
|
||||
- Quand l'utiliser : toute introduction d'un second mécanisme d'auth amené à coexister.
|
||||
- Quand l'éviter : remplacement immédiat sans phase de coexistence (cutover direct).
|
||||
- Avantage :
|
||||
- tout le code en aval ignore l'existence du 2ᵉ système (NFR « AuthN/AuthZ non dispersée »)
|
||||
- rollback par simple flag (zéro delta comportemental flag off)
|
||||
- invariant d'identité fédérée protège contre l'escalade de privilèges par claim forgé
|
||||
- Limites / vigilance :
|
||||
- la jonction devient async (chemin fédéré = déchiffrement + JWKS + DB) → propager `await` à tous les call sites
|
||||
- flag d'activation à lire à chaque requête, jamais memoizé
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : auth fédérée / OIDC / Keycloak BFF — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Un seul point de jonction** : `resolveAuthPayload(request)` traduit token → contexte métier `{ userId, email, role, offices }`. Tout le reste du code ignore le 2ᵉ système.
|
||||
2. **Discrimination par SOURCE, jamais par inspection du contenu** : cookie/header A → chemin maison ; cookie B → chemin fédéré. Si les deux coexistent, le plus conservateur (maison) GAGNE.
|
||||
3. **Flag d'activation fail-closed STRICT** : ON ssi `process.env.FLAG === 'true'` (toute autre valeur = OFF), lu à CHAQUE requête (jamais memoizé — sinon les tests qui togglent via `vi.stubEnv` deviennent ordre-dépendants ; seule la CONFIG est lazy-memoizée).
|
||||
4. **Invariant sacré de l'identité fédérée** : le validateur de token ne sort QUE l'identité (`sub` + `email` informatif), JAMAIS un claim d'autorisation (`realm_access.roles`, grade, office). `role`/`offices` dérivés EXCLUSIVEMENT de la DB locale. Test d'invariant obligatoire : forger un token avec `realm_access.roles: ['admin']` et vérifier qu'il n'accorde RIEN (403 sur route admin).
|
||||
5. **Migration des call sites async** : si la jonction rend les helpers d'auth async, propager `await` à TOUS les call sites par codemod mécanique guidé par `tsc`, et prouver le zéro-delta par la suite complète flag-off verte.
|
||||
|
||||
- Cas vécu : RL799 K1.1 (`authHelpers.ts::resolveAuthPayload`, 140 call sites migrés, test:api flag off vert).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-frontiere-session-bff-stateless"></a>
|
||||
## Pattern : Frontière de session BFF stateless — JWE `dir`+`A256GCM`, fail-closed
|
||||
|
||||
- Objectif : porter une session contenant des tokens (OIDC ou autres) dans un cookie httpOnly sans store serveur (pas de Redis/table).
|
||||
- Contexte : archi BFF où les tokens vivent côté serveur, chiffrés dans un cookie, jamais exposés au JS.
|
||||
- Quand l'utiliser : session stateless chiffrée portée par cookie.
|
||||
- Quand l'éviter : sessions à store serveur (Redis/table) déjà en place, ou besoin de révocation immédiate côté serveur.
|
||||
- Avantage :
|
||||
- équivalent iron-session sans dépendance supplémentaire si `jose` est déjà là
|
||||
- cookie altéré → échec de déchiffrement (AEAD), expiration vérifiée gratuitement par `jwtDecrypt`
|
||||
- frontière isolée : un seul module manipule les tokens bruts
|
||||
- Limites / vigilance :
|
||||
- taille du cookie à surveiller (< 4096 octets)
|
||||
- secret dédié durci, validé strictement
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : jose / cookie httpOnly / BFF — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Un SEUL module** (dé)chiffre et manipule les tokens bruts — vérifiable par `grep "from 'jose'"` limité à ce module + le validateur. Tout le reste reçoit du déjà-déchiffré.
|
||||
2. **JWE `alg: 'dir'` + `enc: 'A256GCM'`** (AEAD) avec `setIssuedAt`/`setExpirationTime` → l'`exp` du JWE donne l'expiration de session.
|
||||
3. **Whitelist explicite des algos au déchiffrement** (`keyManagementAlgorithms: ['dir']`, `contentEncryptionAlgorithms: ['A256GCM']`) — cf. [pattern jose — whitelist d'algorithme](#pattern-jose-whitelist-algorithme).
|
||||
4. **Secret DÉDIÉ par usage** (jamais le secret de chiffrement-au-repos), validé `/^[0-9a-f]{64}$/i` — PAS seulement `length === 64` (64 chars non-hex → Buffer < 32 bytes → erreur cryptique ; `A256GCM dir` exige exactement 32 bytes). Fail-fast avec hint de génération, lu lazy NON memoizé (testabilité).
|
||||
5. **Fail-closed asymétrique** : la clé est obtenue AVANT le try/catch (secret absent/malformé = erreur OPS → doit TRAVERSER → 500), MAIS toute erreur de déchiffrement/format/exp dans le try → `null` (cookie invalide, pas une panne) + log du TYPE d'échec (`decrypt_failed|expired|malformed`) SANS le contenu.
|
||||
6. **La frontière est une LIB** : elle ne fabrique jamais de `Response` — la couche jonction traduit `null` en 401.
|
||||
7. Logger via le logger structuré du projet (pino), pas `console.*`.
|
||||
|
||||
- Cas vécu : RL799 K1.2 `lib/keycloak/session.ts` (cookie ~3 ko < 4096, TTL 8 h aligné Max-Age + exp JWE).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-pont-identite-federee"></a>
|
||||
## Pattern : Pont d'identité fédérée — colonne `idpSub` nullable + finder fail-safe
|
||||
|
||||
- Objectif : relier un identifiant opaque d'IdP (le `sub` OIDC) à un `User` local, en posant d'abord l'interface (stub) puis la résolution réelle quand le découpage en lots l'impose.
|
||||
- Contexte : série « membrane d'auth fédérée » — liaison `sub IdP → User` local sans toucher la couche consommatrice.
|
||||
- Quand l'utiliser : besoin d'un mapping identité externe → user local, livré par lots.
|
||||
- Quand l'éviter : pas de fédération d'identité, ou liaison déjà existante.
|
||||
- Avantage :
|
||||
- migration purement additive, back-fill incrémental possible
|
||||
- shape figé par la jonction → toute divergence casse la jonction au typecheck
|
||||
- aucune fuite de l'`idpSub` (clé de liaison interne)
|
||||
- Limites / vigilance :
|
||||
- colonne nullable tant que le cutover n'est pas complet (NOT NULL seulement après)
|
||||
- le finder ne décide pas de l'accès — il remonte l'état brut
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : Prisma / OIDC / repository — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Migration purement additive** : `ADD COLUMN <sub> TEXT` + `CREATE UNIQUE INDEX`, **nullable, pas de NOT NULL, pas de DEFAULT, pas de back-fill**. Les comptes pré-fédération restent NULL (Postgres tolère plusieurs NULL sous un index `@unique` → back-fill incrémental). Le NOT NULL viendrait seulement après cutover complet.
|
||||
2. **Le finder vit au repository** (`route → service → repository`), retourne EXACTEMENT le shape figé par la couche jonction (ex. `{ id, email, role, isActive }`), via `select` minimal — jamais le mapper complet du record.
|
||||
3. **Fail-safe** : try/catch → `null` (une panne DB ne doit pas 500 le chemin authentifié).
|
||||
4. **Ne filtre PAS sur l'état actif** : retourne `isActive` tel quel — la décision d'accepter/rejeter appartient à la jonction (source unique).
|
||||
5. **Sens d'import** : la lib (subResolver) importe la VALEUR du finder ; le repository importe le TYPE du contrat en `import type` (effacé à la compilation → pas de cycle runtime).
|
||||
6. **No-leak** : l'`idpSub` est une clé de liaison interne — jamais en réponse API, jamais loggé. Le `userId` exposé en aval est l'`id` LOCAL, jamais le `sub`.
|
||||
7. **Prouver la levée du stub par un test E2E qui n'injecte PAS** le resolver de test (laisse le resolver de prod réel) : poser un `sub` sur un user de seed, forger un token, flag on → résolution DB réelle. Sans ce test, on prouve seulement le finder, pas le câblage.
|
||||
|
||||
- Cas vécu : RL799 K1.3 `findUserByKeycloakSub` + `User.keycloakSub`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-greffe-effet-de-bord-regalien"></a>
|
||||
## Pattern : Greffe d'effet de bord externe best-effort sur une opération régalienne
|
||||
|
||||
- Objectif : déclencher un effet de bord EXTERNE non-critique (provisionner une identité IdP, notifier un tiers) depuis une mutation locale CRITIQUE sans jamais coupler leur succès.
|
||||
- Contexte : mutation régalienne (créer un membre) qui doit déclencher un appel externe non-bloquant.
|
||||
- Quand l'utiliser : tout effet de bord externe non-critique greffé sur une opération locale qui ne doit jamais échouer.
|
||||
- Quand l'éviter : effet de bord faisant partie de la transaction logique (audit régalien — cf. [pattern audit best-effort vs régalien](#pattern-auth-audit-best-effort-vs-regalien)).
|
||||
- Avantage :
|
||||
- l'opération locale réussit même si l'effet de bord échoue
|
||||
- reprovisionnable au rejeu (idempotent), zéro doublon
|
||||
- rollback par flag (zéro appel réseau flag off)
|
||||
- Limites / vigilance :
|
||||
- timeout interne obligatoire pour ne pas retarder la réponse régalienne
|
||||
- service account dédié à périmètre minimal, secrets jamais loggés
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : auth fédérée / provisioning IdP / fetch — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Architecture en couches stricte** : `clientBas-niveau` (HTTP/fetch isolé, zéro logique métier) ← `serviceOrchestration` (chercher-avant-créer, fail-safe, écriture DB) ← greffe dans le service régalien. Jamais d'appel HTTP direct depuis le service métier.
|
||||
2. **Fail-safe TOTAL au service** : try/catch englobant → retourne `null` (jamais de throw vers l'appelant régalien), log + audit du statut. L'entité « non-provisionnée » est reprovisionnable au rejeu.
|
||||
3. **Idempotent par chercher-avant-créer** : interroger l'externe par clé naturelle (email `exact=true`) avant de créer ; réutiliser l'existant.
|
||||
4. **Timeout interne obligatoire** (`AbortController`, ex. 5 s) : un externe qui pend ne doit pas bloquer la réponse régalienne. Placer la greffe APRÈS les autres effets de bord prioritaires (ex. envoi de mail).
|
||||
5. **Token machine-to-machine memoizé avec marge d'expiration** (client credentials), pas un fetch par appel. Service account DÉDIÉ à périmètre minimal (`manage-users`), distinct du client d'auth utilisateur.
|
||||
6. **Minimisation des données** vers l'externe : body strictement limité à l'identité d'auth, zéro donnée métier sensible (invariant RGPD). Tester par assertion sur les clés exactes du payload.
|
||||
7. **Flag-gated `=== 'true'` lu à chaque requête** : flag off = zéro appel réseau, zéro branche, zéro delta.
|
||||
8. **Secrets jamais loggés** (token, client_secret) ; ne logger que des identifiants non-sensibles (email, userId, statut).
|
||||
|
||||
- Cas vécu : RL799 K1.4 `provisionMemberIdentity` greffé sur `handleCreateMember`, fetch + token injectables pour tests (aucun IdP vivant en CI).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-refresh-session-bff-serveur"></a>
|
||||
## Pattern : Refresh de session BFF 100 % serveur — signal `Set-Cookie` remonté à la frontière
|
||||
|
||||
- Objectif : rafraîchir l'access token expiré côté serveur, dans le point de jonction d'auth, sans que la couche LIB (sans accès à la `Response`) ne fabrique elle-même le cookie.
|
||||
- Contexte : archi BFF où les tokens OIDC sont chiffrés dans un cookie httpOnly, jamais exposés au JS.
|
||||
- Quand l'utiliser : refresh d'une session BFF stateless portée par cookie.
|
||||
- Quand l'éviter : session à store serveur où la rotation se fait côté store.
|
||||
- Avantage :
|
||||
- la jonction reste une LIB (pas de fabrication de `Response`)
|
||||
- re-login transparent côté front quand le SSO IdP est encore actif
|
||||
- aucun token exposé au JS
|
||||
- Limites / vigilance :
|
||||
- la réécriture du cookie doit être généralisée (cf. risque rotation refresh token IdP)
|
||||
- convention d'unité epoch en SECONDES (OIDC) au recalcul de `expiresAt`
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : openid-client / cookie httpOnly / BFF — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **La jonction remonte un champ `sessionCookieToApply?: string`** dans son contexte de retour — le `Set-Cookie` du nouveau cookie chiffré, calculé après refresh réussi. La LIB ne fabrique pas de cookie, elle REMONTE un signal.
|
||||
2. **Un handler de frontière l'attache** (`headers.append('Set-Cookie', ...)`). Point d'application : le handler d'HYDRATATION systématique post-login (`/me` / `/session`) appelé à chaque boot par les router guards.
|
||||
3. **Marge de refresh anticipé** (~30 s) : rafraîchir un token qui expire dans < marge, pour couvrir le temps de traitement + dérive d'horloge.
|
||||
4. **Fail-closed** : le helper de refresh retourne `null` sur tout échec (refresh token mort/révoqué, panne IdP), jamais de throw. La jonction mappe `null` → 401 code distinct (`SESSION_EXPIRED`) + purge du cookie zombie (clear immédiat, sinon le cookie mort reboucle).
|
||||
5. **Convention d'unité epoch en SECONDES** (OIDC) au recalcul de `expiresAt` post-refresh — un mélange s/ms est un bug classique coûteux.
|
||||
6. **Invariant minimisation** : après refresh, repasser par le validateur de token qui n'extrait QUE l'identité (sub/email). `role`/`permissions` viennent TOUJOURS de la DB.
|
||||
7. **Distinguer deux codes 401** : « access token expiré mais rattrapable par refresh » (transparent, pas remonté au front) vs « session non rafraîchissable » (`SESSION_EXPIRED` → re-login transparent côté front).
|
||||
8. **Hook de test injectable du grant** qui ENCAPSULE la résolution de config (discovery réseau) côté prod : un grant de test ne touche jamais le réseau. Tester sur le CODE DE PRODUCTION réel (jonction + handler `/me` + tokens RS256 forgés), pas sur des mocks.
|
||||
|
||||
- Cas vécu : RL799 K1.5 `refreshKeycloakSession` + `resolveAuthPayload` + `sessionCookieToApply` consommé par `handleGetSession`, openid-client v6 `refreshTokenGrant`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-cutover-auth-irreversible"></a>
|
||||
## Pattern : Cutover d'auth irréversible — isoler le point de non-retour hors du chemin auto
|
||||
|
||||
- Objectif : livrer une bascule destructive (retrait d'un système d'auth legacy au profit d'un nouveau) en gardant tout réversible sauf les 2-3 actions vraiment irréversibles, isolées derrière une gate et un prérequis ops.
|
||||
- Contexte : migration destructive — retrait d'un système legacy, `ALTER COLUMN SET NOT NULL` après backfill, `DROP TABLE`.
|
||||
- Quand l'utiliser : toute migration où le rollback cesse d'être possible une fois exécutée.
|
||||
- Quand l'éviter : changements purement additifs et réversibles.
|
||||
- Avantage :
|
||||
- le code livré reste réversible via git tant que l'irréversible n'est pas exécuté
|
||||
- aucun `migrate deploy` ne franchit le point de non-retour par accident
|
||||
- gate GO/NO-GO + runbook documenté
|
||||
- Limites / vigilance :
|
||||
- prérequis ops hors-code = bloquant explicite, story marquée NON-MERGEABLE si non vérifiable par l'agent
|
||||
- vérifier en review les 3 risques d'un retrait d'auth
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : migration destructive / Prisma / cutover — RL799_V2
|
||||
|
||||
### Règles d'or
|
||||
|
||||
1. **Séparer le réversible de l'irréversible.** Tout le code (retrait des branches legacy, nouveaux chemins) est livré et vert — réversible via git. Les 2-3 actions VRAIMENT irréversibles (migration `NOT NULL`, `DROP TABLE`) sont placées en **migration DRAFT HORS du dossier que l'outil applique automatiquement** (ex. `docs/infra/cutover-migration-draft/migration.sql`, PAS `prisma/migrations/`). Le schéma déclaré reste réversible (`String?`).
|
||||
2. **Gate bloquante AVANT l'irréversible** : un script lecture-seule (`cutover-check`) qui vérifie les pré-conditions (0 orphelin cassé par la contrainte) et émet GO/NO-GO. Ordre IMPOSÉ et documenté en runbook : prérequis ops → gate verte → migration → retrait final. Jamais inverser.
|
||||
3. **Prérequis ops hors-code = bloquant documenté** : si la bascule dépend d'une capacité externe (config realm IdP, SMTP, service account) que l'agent NE PEUT PAS vérifier, marquer la story explicitement NON-MERGEABLE et le tracer. Ne pas présumer « code vert = prêt à merger ».
|
||||
4. **Vérifier en review les 3 risques d'un retrait d'auth** : (a) tout hook/scaffolding de test sur le chemin d'auth est STRUCTURELLEMENT inerte en prod (call-site unique dans le setup de test, `null` au module-level, chargé seulement par le runner — grep exhaustif des call-sites) ; (b) retrait NET prouvé par grep 0-hit des primitives legacy (`createToken|verifyToken|verifyPassword`) en code de prod ; (c) le point de non-retour est bien hors du chemin auto.
|
||||
|
||||
- Cas vécu : RL799 K1.8 cutover Keycloak — migration NOT NULL+DROP en draft hors-Prisma, gate `runCutoverGate`, prérequis FR10 realm bloquant, hook resolver de test inerte vérifié.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-surface-publique-token-opaque"></a>
|
||||
## Pattern : Surface publique authentifiée par token opaque (quick-link mail, sans login)
|
||||
|
||||
- Objectif : exposer une surface publique non authentifiée par un token opaque (quick-link reçu par mail) en durcissant systématiquement contre l'énumération, le prefetch et la fuite de données.
|
||||
- Contexte : route accessible sans login via un token de mail (présence-sans-friction, instruction sans-friction, RSVP).
|
||||
- Quand l'utiliser : toute surface publique à token opaque.
|
||||
- Quand l'éviter : authentification membre classique — utiliser refresh + access httpOnly.
|
||||
- Avantage :
|
||||
- identité prouvée par possession du token, éligibilité re-vérifiée à chaque requête
|
||||
- pas d'oracle d'énumération (réponse neutre indistinguable)
|
||||
- GET read-only protège contre le prefetch (SafeLinks Outlook)
|
||||
- Limites / vigilance :
|
||||
- mapper de sortie dédié obligatoire (le mapper interne fuit des champs)
|
||||
- rate-limiter dédié à enregistrer dans le registre central de reset
|
||||
- Validé le : 23-06-2026
|
||||
- Contexte technique : surface publique / token opaque / mail — RL799_V2
|
||||
|
||||
### Checklist de durcissement (validée 2× sur RL799)
|
||||
|
||||
1. **Token opaque = identité, JAMAIS éligibilité.** Stocker `sha256(token)` en DB (jamais le clair), lookup par hash sur index unique. Le clair ne vit qu'en mémoire au dispatch.
|
||||
2. **Garde partagée GET+POST** (`resolveDeliveryAndGuards`) en union discriminée `{ ok: true, ... } | { ok: false, code }` — un seul point de revalidation appelé par les deux verbes (DRY + cohérence).
|
||||
3. **Re-valider l'éligibilité à CHAQUE requête** (membre actif + grade/colonne), pas seulement à l'émission du token — bloque la pollution après changement d'état.
|
||||
4. **401 NEUTRE indistinguable** : token forgé ET inéligibilité membre → MÊME code + MÊME message (constante `NEUTRAL_TOKEN_MESSAGE`). Pas d'oracle d'énumération RGPD. La distinction est tracée UNIQUEMENT côté serveur (`logSecurityEvent`). À TESTER : `expect(messageMismatch).toBe(messageForged)`.
|
||||
5. **Mapper de sortie DÉDIÉ minimal** — ne JAMAIS réutiliser le mapper interne (qui fuite `organizerId`/agrégat/id). À TESTER par clés EXACTES : `expect(Object.keys(data).sort()).toEqual([...])` + boucle sur les champs interdits.
|
||||
6. **GET strictement read-only** (anti-prefetch SafeLinks Outlook) ; mutation seulement au POST (clic explicite). À TESTER : un GET n'écrit rien en DB.
|
||||
7. **Rate-limiter IP dédié**, AJOUTÉ au registre central de reset (sinon flakiness inter-fichiers).
|
||||
8. **Domaine d'erreur dédié** (pas de réutilisation d'un code d'un autre domaine public).
|
||||
9. **Route fine** (export GET/POST → handler thin → service). Aucune logique dans la route.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-rbac-derive-source-unique"></a>
|
||||
## Pattern : RBAC dérivé d'une source de vérité — JWT enrichi + union au guard
|
||||
|
||||
- Objectif : dériver une autorité (rôle/capacité) d'une source unique (mandat, relation) sans la dupliquer ni introduire de fenêtre d'incohérence.
|
||||
- Contexte : un rôle/accès doit dériver d'une entité (ex. `OfficerMandate`) sans devenir une source concurrente.
|
||||
- Quand l'utiliser : autorité dérivable d'une relation, mutation de la source rare.
|
||||
- Quand l'éviter : besoin de fraîcheur immédiate (la source doit se propager en < TTL du token).
|
||||
- Avantage :
|
||||
- NFR « pas de fenêtre d'incohérence » satisfaite par construction (source lue, jamais dupliquée comme rôle séparé)
|
||||
- cumul des autorités préservé
|
||||
- reste zéro-DB au guard
|
||||
- Limites / vigilance :
|
||||
- un changement de la source met jusqu'à la durée de vie du token (ex. 15 min) à se propager — acceptable si la mutation est rare
|
||||
- Validé le : 12-06-2026
|
||||
- Contexte technique : auth / JWT / RBAC dérivé — RL799_V2
|
||||
|
||||
### Règle
|
||||
|
||||
NE PAS écrire le rôle dérivé dans la table user (duplication + fenêtre d'incohérence à gérer). À la place :
|
||||
1. calculer les attributs dérivés au login/refresh via un helper unique, les injecter dans le JWT à côté du rôle de base ;
|
||||
2. au guard, évaluer l'union `{rôle de base} ∪ {attributs dérivés}` — reste zéro-DB.
|
||||
|
||||
Trade-off assumé : un changement de la source met jusqu'à la durée de vie du token à se propager.
|
||||
|
||||
### Stratégie de migration sans coupure (app live)
|
||||
|
||||
Basculer par ADDITION : backfill source → enrichir le token en coexistence → guards en union sur-ensemble → réduire l'enum en DERNIER (le typecheck servant de filet d'exhaustivité).
|
||||
|
||||
- Cas vécu : refonte RBAC RL799 (offices dérivés des mandats).
|
||||
|
||||
---
|
||||
|
||||
@@ -75,6 +75,7 @@ packages/contracts/src/
|
||||
- Types inférés (`z.infer<>`)
|
||||
- Codes d'erreur applicatifs stables
|
||||
- Enums et constantes partagées (ex : liste officielle de sujets/topics)
|
||||
- **Tout texte business affichable dont backend ET client/mail/PDF partagent le contrôle** : motif d'erreur, label de statut, message de refus métier doit transiter par le contrat, sous forme de constante `as const` (ex : `DM_ELIGIBILITY_MESSAGES`). Le backend l'utilise dans `HttpException(...)`, le client dans sa fonction `getXxxCopy`. Anti-pattern : deux dictionnaires de strings parallèles (un dans le service Nest, un dans le module mobile) qui divergent au premier changement de wording. Ne s'applique PAS aux textes purement UI (titres de boutons, libellés de formulaire) qui appartiennent au client seul.
|
||||
|
||||
### Ce qui n'appartient PAS à contracts
|
||||
|
||||
@@ -298,6 +299,22 @@ test('PATCH .strict() rejette les champs hors-whitelist', async () => {
|
||||
- Schéma avec un commentaire "accepte toute chaîne pour compatibilité avec X" → dette à rigidifier dès que X est migré
|
||||
- `.min(1).max(128)` sur un champ conceptuellement UUID/email/enum → forme laxiste en attente de rigidification
|
||||
|
||||
### Sous-règle — `.optional()` uniquement si le producteur peut réellement omettre le champ
|
||||
|
||||
`.optional()` sur un schéma Zod doit **refléter la réalité du producteur**, jamais servir de filet pour la rétrocompatibilité des fixtures de tests. Si le serveur projette toujours le champ (valeur par défaut comprise), le schéma ne doit PAS le marquer `.optional()`.
|
||||
|
||||
```ts
|
||||
// ❌ Trompeur : le serveur projette toujours ces champs
|
||||
visibilityStatus: VisibilityStatusSchema.optional(),
|
||||
placeholderLabel: z.string().nullable().optional(),
|
||||
|
||||
// ✅ Honnête : reflète ce que le serveur retourne
|
||||
visibilityStatus: VisibilityStatusSchema,
|
||||
placeholderLabel: z.string().nullable(),
|
||||
```
|
||||
|
||||
Pourquoi : un `.optional()` permissif infère `T | undefined` → on est forcé d'écrire `x ?? 'DEFAULT'` partout côté client inutilement ; et il masque les drifts de fixtures (un objet construit en omettant le champ, ou avec un nom de champ **différent** comme `isAutoHidden: false` au lieu de `visibilityStatus: 'VISIBLE'`, passe le type-check). Méthode : pour chaque champ, demander « le producteur peut-il LÉGITIMEMENT omettre ce champ ? » — si non, pas de `.optional()` ; si oui (rétrocompat lecture seule), documenter la raison en commentaire. Sur app-alexandrie story 8.2, durcir 3 schémas a révélé un champ fantôme `isAutoHidden` côté store mobile (14 erreurs TS en cascade).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-enum-canonique-sous-ensembles-nommes"></a>
|
||||
@@ -480,3 +497,109 @@ const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
|
||||
- [ ] Si duplication forcée : commentaire `⚠️ DOIT correspondre à <chemin>` des deux côtés
|
||||
- [ ] Test croisé qui assert l'alignement string-wise des deux regex
|
||||
- [ ] JSDoc qui rappelle que c'est un contrat de cohérence (revue obligatoire si modif)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-typer-strict-a-la-source"></a>
|
||||
## Pattern : Typer strict à la source, pas au call-site
|
||||
|
||||
- Objectif : faire propager automatiquement le typage strict d'un symbole contraint à tous ses consommateurs, et attraper les drifts au compilateur là où ils naissent.
|
||||
- Contexte : symbole sémantiquement contraint (enum fermé, union de littéraux, identifiant typé : `UserRole`, `NotificationType`, `SoireeStatus`) déclaré comme `string` à la source.
|
||||
- Quand l'utiliser : dès qu'une constante, un retour de helper ou un champ DTO porte une valeur d'un type contraint.
|
||||
- Quand l'éviter : à la frontière externe (entrée HTTP, payload JSON brut, requête SQL raw) où la valeur est forcément `unknown`/`string` — on cast explicitement après validation ; ou quand le type strict crée une dépendance circulaire (rare).
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : TypeScript — RL799_V2 (chantier durcissement UserRole)
|
||||
|
||||
### Règle
|
||||
|
||||
Tout symbole sémantiquement contraint doit être déclaré avec son **type le plus strict à la source de définition**, pas au call-site qui en a besoin. Sinon le caller doit caster (`as UserRole`) et le bug ne sort qu'au moment où un code aval impose le typage strict — pas à la source du drift.
|
||||
|
||||
Exemples : `Set<UserRole>` plutôt que `Set<string>` pour les constantes RBAC ; retour `role: UserRole` plutôt que `role: string` pour les helpers d'auth ; `status: SoireeStatus` plutôt que `status: string` dans les DTOs.
|
||||
|
||||
### Anti-pattern vs pattern
|
||||
|
||||
```ts
|
||||
// ❌ Drift silencieux + cast à chaque call-site
|
||||
export const ROLES_ADMIN: ReadonlySet<string> = new Set(['admin']);
|
||||
if (ROLES_ADMIN.has(auth.role as UserRole)) { /* cast nécessaire ailleurs */ }
|
||||
|
||||
// ✅ Type fort propagé partout
|
||||
export const ROLES_ADMIN: ReadonlySet<UserRole> = new Set<UserRole>(['admin']);
|
||||
if (ROLES_ADMIN.has(auth.role)) { /* OK si auth.role: UserRole */ }
|
||||
```
|
||||
|
||||
### Bénéfice mesurable
|
||||
|
||||
Une seule modification à la source propage le typage strict à tous les call-sites. Sur RL799_V2, typer 15 constantes `ROLES_*` en `Set<UserRole>` a fait gagner le typage strict à 9 fichiers consommateurs et révélé un bug latent (`canPublishCommunicationType` qui recevait `auth.role: string`), résolu structurellement.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-audience-prop-templates"></a>
|
||||
## Pattern : Prop `audience` pour templates mail/PDF multi-cibles
|
||||
|
||||
- Objectif : servir plusieurs audiences (membre, visiteur…) depuis une seule source de vérité de template, sans dupliquer le fichier et donc sans drift entre versions.
|
||||
- Contexte : template de rendu (mail HTML, PDF) qu'on étend pour une 2ᵉ audience alors que < 40 % du contenu diverge.
|
||||
- Quand l'utiliser : tant que la divergence de contenu reste faible (< ~40 %) — un changement d'identité, signature, format de date propage alors automatiquement à toutes les audiences.
|
||||
- Quand l'éviter : au-dessus de ~40 % de divergence — dupliquer le template et extraire un partial commun devient plus maintenable que des conditions dispersées.
|
||||
- Validé le : 13-05-2026
|
||||
- Contexte technique : React Email / PDF templates / NestJS — RL799_V2 (chantier convocation visiteurs)
|
||||
|
||||
### Règle
|
||||
|
||||
1. Ajouter une prop `audience: 'audienceA' | 'audienceB'` à la source du template. Défaut = audience historique (rétrocompat : appelants existants sans la prop rendent la version originale).
|
||||
2. Ajouter les URLs/booleans spécifiques à chaque audience en props **optionnelles** (`visitorRegistrationUrl?`, `unsubscribeUrl?`) pour ne pas casser l'audience historique.
|
||||
3. Conditionner via un `const isVisitor = audience === 'visitor'` en tête de composant : heading, CTA principal, sections opt-out/désabonnement.
|
||||
4. Sortir des **chemins de stockage distincts** pour les artefacts persistants (`{tenueDir}/{grade}.pdf` membre vs `{tenueDir}/visitor-{grade}.pdf` visiteur) — évite l'écrasement quand les deux audiences sont servies pour la même entité.
|
||||
5. Factoriser la construction des props dans un builder commun (`buildConvocationProps({ audience, ... })`) qui applique les règles spécifiques.
|
||||
6. Tests : assertions ciblées par audience dans le même fichier, sections délimitées, avec assertions positives (présence) ET négatives (absence des éléments réservés à l'autre audience).
|
||||
|
||||
### Trade-off
|
||||
|
||||
Compter la ligne de divergence vs la ligne de partage avant d'appliquer. Sous ~40 %, la prop `audience` tient ; au-dessus, dupliquer + extraire un partial.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-verbe-http-marker-idempotent"></a>
|
||||
## Pattern : Verbe HTTP pour endpoint « marker » idempotent (set boolean sur ressource existante)
|
||||
|
||||
- Objectif : trancher une fois pour toutes `POST` vs `PATCH` sur un endpoint qui set un champ booléen sur une ressource déjà créée (ex : `POST/PATCH /users/me/onboarding/complete`).
|
||||
- Contexte : endpoint « marker » qui passe un champ existant `false → true` (`User.onboardingCompleted`).
|
||||
- Quand l'utiliser : dès que l'action met à jour un champ d'une row déjà existante.
|
||||
- Quand l'éviter : action qui crée une nouvelle ressource, déclenche un side-effect métier complexe (envoi email, paiement) ou n'a pas de mapping naturel sur une ressource → `POST`.
|
||||
- Validé le : 28-05-2026
|
||||
- Contexte technique : NestJS / contrat API — app-alexandrie (review IA-v2.7)
|
||||
|
||||
### Règle
|
||||
|
||||
Critère de décision : « est-ce que j'update un champ d'une row existante ? » → **PATCH** (mise à jour partielle, sémantique REST). **POST** réservé à la création / au side-effect métier.
|
||||
|
||||
Bonus cohérence : si le controller a déjà `PATCH /me/handle`, `PATCH /me/topics`, aligner `PATCH /me/onboarding/complete` plutôt qu'introduire un `POST` exotique au milieu.
|
||||
|
||||
Réponse : `204 No Content` quand il n'y a pas de payload de retour utile.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-endpoint-distinct-vs-force"></a>
|
||||
## Pattern : Endpoint distinct pour intention divergente (vs paramètre `force`)
|
||||
|
||||
- Objectif : exposer une opération qui viole un invariant protecteur d'un endpoint existant sans polluer cet endpoint d'un paramètre `force/bypass`.
|
||||
- Contexte : endpoint REST avec un invariant protégeant l'utilisateur d'une opération accidentelle (rétrogradation de statut, suppression silencieuse), et besoin légitime d'exposer l'opération inverse.
|
||||
- Quand l'utiliser : dès qu'on est tenté d'ajouter un param `force/skipChecks/admin/bypass` à un endpoint pour contourner son propre invariant.
|
||||
- Quand l'éviter : si l'invariant n'existe pas (l'endpoint accepte déjà nativement les deux intentions).
|
||||
- Validé le : 29-05-2026
|
||||
- Contexte technique : NestJS / contracts Zod — app-alexandrie (ux-cleanup-7)
|
||||
|
||||
### Règle
|
||||
|
||||
Un paramètre `force` oblige le client à comprendre qu'il bypass un invariant interne, la doc à expliquer « pourquoi force ? », le service à porter un `if (payload.force)`, et casse la lisibilité des audit logs. Préférer un **endpoint dédié** dont le verbe HTTP porte la sémantique.
|
||||
|
||||
```typescript
|
||||
// ❌ Anti-pattern : param `force` qui pollue la sémantique
|
||||
POST /content/:id/consumption { state: 'NOT_STARTED', force: true }
|
||||
|
||||
// ✅ Pattern : endpoint dédié à l'intention « reset »
|
||||
POST /content/:id/consumption { state: 'COMPLETED' } // markConsumed — idempotent, jamais dégrade
|
||||
DELETE /content/:id/consumption // resetConsumption — volontaire, dégrade explicitement
|
||||
```
|
||||
|
||||
Bénéfices : le verbe HTTP porte la sémantique destructive ; le service expose 2 méthodes distinctes (`markConsumed`, `resetConsumption`) avec invariants préservés ; les audit logs distinguent immédiatement les 2 opérations ; les contracts Zod restent propres (pas de discriminated union artificielle). L'effort marginal (5-10 lignes) est compensé par une clarté permanente.
|
||||
|
||||
@@ -597,3 +597,278 @@ Trigger : `workflow_run` du workflow `Tests` quand vert sur main, OU `workflow_d
|
||||
4. **Permissions dossier `/srv/sites/<app>/`** : si owner historique = autre user, `deploy` ne peut pas `scp`. Solution propre = groupe partagé avec `setgid`.
|
||||
|
||||
5. **Healthcheck timeout** : si l'API met longtemps à boot (Chromium/Puppeteer lazy load, migrations longues), augmenter au-delà du défaut 60 s.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-controle-acces-conditionnel-contexte-metier"></a>
|
||||
## Pattern : Contrôle d'accès conditionnel par contexte métier (pure logic shared)
|
||||
|
||||
- Objectif : décider l'accès à une ressource selon une **capacité dérivée** du user (pas seulement son rôle/grade) + un **contexte d'usage** porté par la ressource, avec une logique unique et testable, partagée front et back.
|
||||
- Contexte : ressource (typiquement un Document) avec accès inconditionnel pour certains rôles de curation (archiviste/admin) ET accès conditionnel pour les autres, basé sur une capacité du user (`canInvestigate`, `canTreasure`, …) et un contexte porté par la ressource (`'enquete:template'`, `'officier:mdc:memento'`).
|
||||
- Quand l'utiliser : l'accès dépend d'un contexte métier extensible et doit être calculable côté frontend (afficher/masquer un bouton sans round-trip) ET côté backend (gate d'autorisation).
|
||||
- Quand l'éviter : accès purement rôle/grade → un helper RBAC standard suffit.
|
||||
- Avantage :
|
||||
- une seule source de vérité, pas de divergence front/back possible
|
||||
- extensible sans cascade : un nouveau contexte = un `case` dans le `switch` shared
|
||||
- testable en pure logic (matrice de tests sans Prisma ni mocks)
|
||||
- allow-list strict : contexte non implémenté = `false` par défaut (pas de fuite d'autorisation)
|
||||
- Limites / vigilance :
|
||||
- défense en profondeur obligatoire : gater AUSSI le listing (`GET /documents?type=...`), pas seulement le `view`, sinon la ressource leak via la liste
|
||||
- `usageContext String?` simple en V1 ; migrer vers `String[]` seulement si un match multiple devient nécessaire (anticiper en `String[]` est de la sur-ingénierie)
|
||||
- n'envisager un service générique paramétré (`canUserViewContextualResource`) qu'à partir du 3ᵉ usage
|
||||
- Validé le : 06-05-2026
|
||||
- Contexte technique : Prisma / packages shared / Next.js + Vue — RL799_V2
|
||||
|
||||
### Forme du pattern
|
||||
|
||||
**1. Sur la ressource : champ `usageContext`** (NULL = pas de gate contextuelle, la ressource utilise les gates standard de son type).
|
||||
|
||||
```prisma
|
||||
model X {
|
||||
/// Contexte métier qui conditionne l'accès. NULL = pas de gate contextuelle.
|
||||
usageContext String? @map("usage_context")
|
||||
}
|
||||
```
|
||||
|
||||
**2. Service shared `canUserViewX(ctx, resource)` — pure logic**, exporté depuis `packages/shared` pour être consommé par l'API et le frontend.
|
||||
|
||||
```typescript
|
||||
export type XViewerContext = {
|
||||
role: UserRole;
|
||||
capabilities: { canInvestigate: boolean /* … */ };
|
||||
};
|
||||
|
||||
export function canUserViewX(
|
||||
ctx: XViewerContext,
|
||||
resource: { type: string; usageContext: string | null },
|
||||
): boolean {
|
||||
if (resource.type !== 'X_TYPE_GATED') return true; // hors scope gate
|
||||
if (ctx.role === 'archiviste' || ctx.role === 'admin') return true; // curation
|
||||
if (!resource.usageContext) return false; // orpheline → curation-only
|
||||
switch (resource.usageContext) {
|
||||
case 'enquete:template':
|
||||
return ctx.capabilities.canInvestigate;
|
||||
default:
|
||||
return false; // allow-list strict
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Côté API** : la gate appelle `canUserViewX` dans le handler de view → `403 FORBIDDEN` si refusé.
|
||||
**4. Côté frontend** : la même fonction calcule le rendu conditionnel (`v-if="canSeeHelper"`), zéro round-trip.
|
||||
|
||||
### Anti-patterns à éviter
|
||||
|
||||
- Logique backend-only : force le frontend à un round-trip pour savoir s'il affiche un bouton.
|
||||
- Champ `accessRoles: string[]` sur la ressource : ne capture pas une capacité dérivée (`canInvestigate` n'est pas un rôle).
|
||||
- `switch` éparpillé dans plusieurs handlers : centraliser dans un seul service shared.
|
||||
- Gate uniquement sur `view` sans gater le listing : fuite par la liste.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-safe-path-resolution-streaming-db-driven"></a>
|
||||
## Pattern : Safe path resolution pour streaming de fichiers DB-driven (anti path traversal)
|
||||
|
||||
- Objectif : empêcher un path traversal (`../../../etc/passwd`, chemin absolu hors racine) lors du streaming d'un fichier dont le chemin est stocké en DB, même si la requête vient d'un user authentifié et autorisé.
|
||||
- Contexte : endpoint qui streame un fichier disque dont le path vient de la DB (`Document.filePath`, `ConvocationIssue.pdfPathsByGrade`, …), en particulier quand le champ est un JSON libre côté Prisma que Zod ne peut pas valider strictement à l'écriture.
|
||||
- Quand l'utiliser : tout streaming de fichier dont le chemin n'est pas une constante du code.
|
||||
- Quand l'éviter : fichier servi depuis un chemin entièrement contrôlé par le code (constante, pas d'entrée DB).
|
||||
- Avantage :
|
||||
- défense en profondeur : protège même si la DB est compromise (row corrompue, migration foireuse, accès SQL direct d'un attaquant)
|
||||
- re-validation à la LECTURE qui couvre les rows insérées avant l'existence d'un schéma Zod
|
||||
- Limites / vigilance :
|
||||
- retourner `500` (+ `console.error`) et non `404` : un path hors racine est un bug applicatif/compromission, pas une erreur user — à surveiller via les logs prod
|
||||
- utiliser `path.relative`, **jamais** `String.startsWith(ROOT)` (casse avec les liens symboliques)
|
||||
- un blocage Zod en amont est insuffisant seul : Zod valide à l'écriture, pas à la lecture
|
||||
- Validé le : 06-05-2026
|
||||
- Contexte technique : Node.js / Prisma — RL799_V2
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
import { isAbsolute, join, normalize, relative } from 'node:path';
|
||||
|
||||
const SAFE_ROOT = '/srv/uploads/x';
|
||||
|
||||
const safeResolvePath = (storedPath: string): string | null => {
|
||||
const absolute = isAbsolute(storedPath) ? storedPath : join(SAFE_ROOT, storedPath);
|
||||
const normalized = normalize(absolute); // résout les `..`
|
||||
const rel = relative(SAFE_ROOT, normalized);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) return null; // s'évade de SAFE_ROOT
|
||||
return normalized;
|
||||
};
|
||||
|
||||
// Dans le handler :
|
||||
const absolutePath = safeResolvePath(storedPath);
|
||||
if (!absolutePath) {
|
||||
console.error('[handler] path stocké hors SAFE_ROOT:', storedPath);
|
||||
return errorResponse(500, 'FILE_NOT_FOUND', 'Fichier indisponible');
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- `path.join(ROOT, userInput)` puis `fs.readFile` sans normalisation : `'../../../etc/passwd'` passe.
|
||||
- `resolved.startsWith(ROOT)` : casse avec les symlinks — `path.relative` est la bonne API.
|
||||
- "Trust DB" ("c'est moi qui écris") : ignore le bug de migration et l'attaquant à accès SQL direct.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-alias-import-convention-migration"></a>
|
||||
## Pattern : Alias d'import `@/*` — convention et migration en masse
|
||||
|
||||
- Objectif : remplacer les imports relatifs profonds (`../../X`) par un alias `@/X` résolu depuis la racine `src/`, et migrer un projet établi sans casser les exceptions.
|
||||
- Contexte : projet (monorepo ou non) à 200+ fichiers où la profondeur des remontées relatives varie au fil du temps.
|
||||
- Quand l'utiliser : convention à graver tôt ; migration en masse d'un existant.
|
||||
- Quand l'éviter : voisins directs (un seul `../`) — les forcer en `@/` n'apporte que du bruit dans le diff.
|
||||
- Avantage :
|
||||
- refactor-friendly : déplacer un fichier dans `src/` ne casse aucun import en aval
|
||||
- lisibilité (`@/services/X` dit où on va, `../../../services/X` dit combien on remonte)
|
||||
- linter-friendly (`import/no-relative-parent-imports`)
|
||||
- Limites / vigilance :
|
||||
- `tsconfig.json` ET la config bundler doivent toutes deux connaître l'alias, sinon erreurs runtime obscures
|
||||
- typecheck + tests OBLIGATOIRES immédiatement après un sed de masse (faux positifs, fichiers hors `src/`)
|
||||
- Validé le : 11-05-2026
|
||||
- Contexte technique : monorepo pnpm (Next.js 16 + Vue 3.5 + Vite 7) — RL799_V2
|
||||
|
||||
### Setup
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }
|
||||
```
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }
|
||||
```
|
||||
|
||||
Next.js : alias TS reconnu automatiquement. Vitest : reconnaît les paths TS automatiquement.
|
||||
|
||||
### Convention de migration
|
||||
|
||||
**À migrer** : tout import qui traverse ≥ 2 niveaux (`../../X` ou plus) ET pointe vers un fichier sous `src/`.
|
||||
|
||||
**À laisser en relatif** (exceptions) :
|
||||
1. Voisins directs (`./sibling`, `../sibling` un seul niveau).
|
||||
2. Fichiers hors `src/` (ex. `next.config.ts` à la racine de l'app) — laisser en relatif + commentaire JSDoc justifiant l'exception.
|
||||
3. `__dirname`-relatif dans les `.mjs` (`resolve(here, '../../../src/...')`) : c'est un chemin filesystem, pas un import.
|
||||
4. Imports vers un sous-fichier précis pour éviter un cycle réintroduit par le barrel `@/X/index.ts`.
|
||||
|
||||
### Migration en masse via sed (avec garde-fous)
|
||||
|
||||
```bash
|
||||
# Repérer les candidats
|
||||
grep -rE "from '\.\./\.\./" src/ --include='*.ts' --include='*.tsx' -l
|
||||
|
||||
# DRY-RUN d'abord (sans -i) pour relire le diff
|
||||
sed -E "s|from '(\.\./){2,}([^']+)'|from '@/\2'|g" src/services/foo.ts
|
||||
|
||||
# Appliquer après validation du dry-run
|
||||
find src -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.vue' \) \
|
||||
-exec sed -i.bak -E "s|from '(\.\./){2,}([^']+)'|from '@/\2'|g" {} \;
|
||||
find src -name '*.bak' -delete
|
||||
```
|
||||
|
||||
Récupération après sed : `pnpm typecheck` → lire chaque import cassé → identifier les exceptions (fichiers hors `src/`) → `git checkout -p` ou correction manuelle.
|
||||
|
||||
### Anti-patterns à éviter
|
||||
|
||||
- Migrer les voisins directs "pour la cohérence" → bruit sans gain.
|
||||
- Migrer sans typecheck immédiat → l'exception se découvre en CI 3 commits plus tard.
|
||||
- Regex sed trop permissive (`s|\.\./|@/|g`) qui matche aussi `import.meta.url` ou des strings non-imports.
|
||||
|
||||
### Cas vécu
|
||||
|
||||
RL799_V2 (2026-05-11) : 246 imports migrés sur 124 fichiers côté `apps/api/`. Sed + typecheck → 1 seule erreur (`@/next.config`, fichier hors `src/`), corrigée à la main avec JSDoc. ~5 min de migration + 2 min de fix.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-test-invariant-structure-scan-statique"></a>
|
||||
## Pattern : Test d'invariant de structure — verrouiller par scan statique une propriété d'architecture
|
||||
|
||||
- Objectif : transformer en BUILD ROUGE une régression silencieuse sur une propriété architecturale correcte-par-construction (ex. "auth opt-in par route : une route est publique parce que son handler n'appelle aucun helper d'auth", "les handlers Z loggent toujours W").
|
||||
- Contexte : propriété de sécurité/correction vraie aujourd'hui par convention, mais vulnérable à la dérive (un dev colle `requireRoleAccess` dans un handler public par copier-coller → la route marche pour un membre loggé, casse pour un guest → régression silencieuse).
|
||||
- Quand l'utiliser : tout invariant architectural reposant sur une convention plutôt que sur un mécanisme dur (pas de middleware global, pas de contrainte DB).
|
||||
- Quand l'éviter : si l'invariant est déjà garanti par construction dure (middleware central, contrainte SQL) — le test devient redondant.
|
||||
- Avantage :
|
||||
- une régression de convention casse le build au lieu de passer en review
|
||||
- le scan statique n'a aucune dépendance runtime vivante
|
||||
- Limites / vigilance :
|
||||
- **prouver la mordacité** : un test d'invariant qui ne peut jamais échouer est un placebo
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : Vitest / scan statique `readFileSync` — RL799_V2 (K1.6 `keycloakGuestInvariant.test.ts`)
|
||||
|
||||
### Règles
|
||||
|
||||
1. **Scan statique récursif** (`readFileSync`, pas de runtime) sur la SURFACE pertinente. Pour l'auth : `route.ts` + imports DIRECTS de 1ᵉʳ niveau, **PAS** la fermeture transitive complète (un handler public importe légitimement des services partagés dont d'autres fonctions sont protégées → faux positifs). Documenter la granularité choisie et sa limite.
|
||||
2. **Garde anti-angle-mort DYNAMIQUE** : énumérer l'arborescence (`app/api/public/**`) plutôt qu'une liste figée → une nouvelle route entre AUTO dans le périmètre. Exceptions hors-arborescence : liste explicite versionnée + note assumant l'angle mort résiduel.
|
||||
3. **Checkpoint conscient** : une liste `KNOWN_*` comparée par égalité casse le build quand le périmètre change, forçant une DÉCISION humaine ("cette nouvelle route est-elle bien guest ?"). Ne pas le confondre avec la couverture de scan (c'est l'énumération dynamique qui couvre).
|
||||
4. **Invariant comportemental par SPIES non-appelés**, pas par code de réponse : injecter des hooks espions (`vi.fn` + `not.toHaveBeenCalled()`). NE PAS asserter sur un `error.code` qui peut être un homonyme métier (ex. `INVALID_TOKEN` domaine présence ≠ `INVALID_TOKEN` Keycloak). NE PAS `throw` dans le spy (un `catch` du handler pourrait l'avaler et masquer la violation).
|
||||
5. **CRITÈRE DE RÉUSSITE — prouver la mordacité** : en dev comme en revue, injecter temporairement une violation, vérifier que le test devient ROUGE avec un message pointant le fichier exact, puis retirer la probe.
|
||||
6. **Auto-documentation** : commentaire en tête expliquant POURQUOI (la propriété protégée) et COMMENT l'étendre sans casser l'esprit.
|
||||
|
||||
Modèle de référence : scan de couverture de catalogue (`auditCatalogCoverage` — toute action loggée doit figurer au catalogue).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-script-ops-batch-fail-closed"></a>
|
||||
## Pattern : Script ops batch fail-closed — rapport actionnable, dry-run honnête, revue par exécution réelle
|
||||
|
||||
- Objectif : un script ops one-shot qui mute des données en masse via un service externe (migration d'identités, back-fill, réconciliation) doit produire un rapport exploitable, ne jamais sur-promettre en dry-run, et être revu en l'EXÉCUTANT réellement (pas seulement via ses tests).
|
||||
- Contexte : script batch qui appelle un service de prod best-effort retournant souvent `null`/booléen opaque (qui mélange "externe down transitoire" et "entrée refusée définitivement").
|
||||
- Quand l'utiliser : tout script de migration/back-fill batch qui dépend d'un externe.
|
||||
- Quand l'éviter : mutation transactionnelle simple en un seul appel (pas de classification par item nécessaire).
|
||||
- Avantage :
|
||||
- le rapport devient l'outil de décision de l'ops (et d'une gate en aval)
|
||||
- l'exécution réelle révèle des findings que les tests (dépendance mockée) masquent
|
||||
- Limites / vigilance :
|
||||
- au-delà des invariants classiques (idempotent : `where: null` + chercher-avant-créer ; PII masquée ; secrets jamais loggés ; exit codes ; runner projet), 4 exigences spécifiques au BATCH sont souvent ratées (voir règles)
|
||||
- Validé le : 15-06-2026
|
||||
- Contexte technique : script ops Node + service externe — RL799_V2 (K1.7 `keycloak-migrate`)
|
||||
|
||||
### Règles
|
||||
|
||||
1. **FAIL-CLOSED ≠ FAIL-SAFE** : un batch doit CLASSER chaque item (`migrated`/`reused`/`blocked`/`failed`/`skipped`). Implémenter un orchestrateur DÉDIÉ qui appelle les mêmes briques basses et OBSERVE le résultat, SANS toucher le service de prod (qui reste fail-safe pour son usage).
|
||||
2. **RAPPORT EXPLOITABLE** : la raison d'un `failed` ne doit JAMAIS être `err.name` seul (sur une panne `fetch`, ça donne "Error", inactionnable). Remonter `name: message` nettoyé/borné (sans secret) + le statut HTTP des erreurs typées.
|
||||
3. **DRY-RUN HONNÊTE** : décider et DOCUMENTER ce que le dry-run touche. S'il interroge l'externe en LECTURE, le dire en tête ("interroge X en lecture seule"). Un dry-run NE DOIT PAS écrire d'artefact sur disque (stdout suffit). Documenter qu'il sur-estime les succès s'il ne tente pas l'écriture (ne détecte pas les refus de création).
|
||||
4. **ACTION SENSIBLE = AUDIT** : toute action destructive/sensible (révocation de token, ré-émission d'invitation) trace un audit persistant (`logAction`), pas un `console.log` volatil — même sans userId admin (tracer sur la cible). Ajouter l'action au catalogue d'audit si un test de couverture le vérifie.
|
||||
|
||||
### Méthode de review (l'apprentissage clé)
|
||||
|
||||
Pour un script ops, NE PAS se contenter des tests verts (la dépendance externe y est mockée). **EXÉCUTER le script en réel** (dry-run contre la vraie DB locale, externe absent). Cas vécu RL799 K1.7 : cette exécution a sorti M1 (raison `failed: Error` opaque) + M2 (dry-run qui écrit un fichier), tous deux invisibles en test. Les tests prouvent la logique ; l'exécution prouve l'expérience ops.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-helper-transverse-send-mail-with-retry"></a>
|
||||
## Pattern : Helper transverse paramétrable + wrapper local par caller (ex. `sendMailWithRetry`)
|
||||
|
||||
- Objectif : partager un helper technique entre deux domaines tout en préservant une configuration spécifique à un caller, sans réintroduire de duplication ni de couplage inter-domaines.
|
||||
- Contexte : une mécanique technique (boucle de retry mail, backoff) utilisée par plusieurs domaines, dont l'un a des paramètres distincts.
|
||||
- Quand l'utiliser : ≥ 2 domaines partagent la même logique technique mais avec des réglages différents.
|
||||
- Quand l'éviter : un seul caller → pas besoin de paramétrer ni de wrapper.
|
||||
- Avantage :
|
||||
- zéro duplication de la logique de retry (mono-source)
|
||||
- zéro couplage inter-domaines : le helper vit dans `lib/`, neutre
|
||||
- chaque domaine garde sa config co-localisée avec son usage
|
||||
- Limites / vigilance :
|
||||
- le helper neutre ne doit connaître aucun domaine (pas d'import repository métier)
|
||||
- Validé le : 23-06-2026
|
||||
- Contexte technique : Node.js / lib transverse — RL799_V2 (v2-6-2)
|
||||
|
||||
### Forme
|
||||
|
||||
```typescript
|
||||
// lib/mailRetry.ts — neutre, config en 2e param avec défaut
|
||||
export const sendMailWithRetry = (args: MailArgs, retry = MAIL_RETRY) => { /* … */ };
|
||||
|
||||
// Caller standard : appel direct (config par défaut)
|
||||
await sendMailWithRetry(args);
|
||||
|
||||
// Caller spécifique (flux visiteur : attempts:2, backoffMs:[1000]) :
|
||||
// const de config dédiée + wrapper local nommé qui fige la config
|
||||
const VISITOR_MAIL_RETRY = { attempts: 2, backoffMs: [1000] };
|
||||
const sendVisitorMailWithRetry = (args: MailArgs) => sendMailWithRetry(args, VISITOR_MAIL_RETRY);
|
||||
```
|
||||
|
||||
Le wrapper garde un nom métier lisible aux sites d'appel, la config custom reste près de son domaine, la logique de retry reste mono-source.
|
||||
|
||||
@@ -176,3 +176,167 @@ private async reconcileSessionStatus(session, now = new Date()) {
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-ressource-paire-non-ordonnee"></a>
|
||||
## Pattern : Ressource « paire non ordonnée » — adresser par les participants, pas par l'id
|
||||
|
||||
- Objectif : éviter un endpoint « créer la ressource » séparé avant le premier écrit, et la race condition associée, pour une ressource dont l'identité est dérivable d'une paire d'acteurs.
|
||||
- Contexte : DM 1:1, match, follow réciproque, partage 1:1 — toute ressource « paire non ordonnée » où l'id est déductible des deux participants.
|
||||
- Quand l'utiliser : dès que le premier écrit doit pouvoir créer la ressource implicitement (premier message à un utilisateur, première interaction).
|
||||
- Quand l'éviter : ressource avec identité propre (commande, document) qui existe indépendamment des participants.
|
||||
- Avantage :
|
||||
- 1 seul appel API au lieu de 2 (UX + perf)
|
||||
- pas de race condition : contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
|
||||
- l'endpoint GET par id reste légitime pour la pagination des fils existants
|
||||
- Limites / vigilance :
|
||||
- appliquer les vérifications d'éligibilité/quota AVANT le `findUnique` pour ne pas leak l'existence de la ressource
|
||||
- l'upsert doit se faire en transaction (création conversation + création message)
|
||||
- Validé le : 13-05-2026
|
||||
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2
|
||||
|
||||
### Implémentation (exemple minimal)
|
||||
|
||||
```txt
|
||||
POST /messaging/conversations/with/:targetUserId/messages
|
||||
```
|
||||
|
||||
Le service, après contrôles d'éligibilité/quota :
|
||||
|
||||
1. `findUnique({ where: { userAId_userBId: orderPair(currentUser, target) } })`
|
||||
2. Si absent → `create` la conversation
|
||||
3. `create` le message dans la même `$transaction`
|
||||
|
||||
Adresser par l'identité des deux participants (ou le seul participant cible, l'autre étant `req.user`), jamais par l'id de la ressource. Faire un upsert idempotent côté serveur en transaction.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Endpoint d'écriture adressé par les participants, pas par l'id de la ressource
|
||||
- [ ] Contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
|
||||
- [ ] Vérifications d'éligibilité/quota AVANT le `findUnique` (anti-leak d'existence)
|
||||
- [ ] Upsert conversation + message dans une même `$transaction`
|
||||
- [ ] Endpoint GET par id conservé pour la pagination
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-batched-eligibility-decoration"></a>
|
||||
## Pattern : Batched eligibility / decoration — ne jamais calculer dans `.map(async)`
|
||||
|
||||
- Objectif : éviter le N+1 caché quand chaque row d'une liste doit être décorée d'un flag calculé par une logique partagée (éligibilité, ACL, entitlements).
|
||||
- Contexte : `listX` qui ajoute un flag dérivé (`isReadOnly`, `canEdit`...) calculé par row à partir de queries internes.
|
||||
- Quand l'utiliser : tout listing qui décore N rows d'un flag dépendant de queries (DB ou cache) par row.
|
||||
- Quand l'éviter : flag purement synchrone dérivable de la row elle-même (pas de query).
|
||||
- Avantage :
|
||||
- passe de N×K requêtes à un nombre constant de queries bulk
|
||||
- lookup O(1) via `Set` / `Map` au moment de la décoration
|
||||
- Limites / vigilance :
|
||||
- le cache (ex : entitlements 60 s) n'amortit pas la première fenêtre froide
|
||||
- itérer la page **synchroniquement** après le chargement bulk — pas de `await` dans la boucle de décoration
|
||||
- Validé le : 13-05-2026
|
||||
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2 DM messaging
|
||||
|
||||
### Anti-pattern (N+1)
|
||||
|
||||
```typescript
|
||||
// ❌ N appels indépendants → N×K requêtes (K = queries internes de getEligibility)
|
||||
const items = await Promise.all(
|
||||
page.map(async (row) => {
|
||||
const eligibility = await this.getEligibilityForExistingConversation(
|
||||
currentUserId, peerIdFromRow(row),
|
||||
);
|
||||
return { ...row, isReadOnly: eligibility.isReadOnly };
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern (batched)
|
||||
|
||||
1. Extraire les IDs cibles : `const peerIds = page.map(peerIdFromRow)`.
|
||||
2. Charger TOUTES les dépendances en bulk :
|
||||
- `await entitlements.getEntitlementsForUser(currentUserId)` (1 fois)
|
||||
- `await Promise.all(peerIds.map(id => entitlements.getEntitlementsForUser(id)))` (parallèle, cache amortit)
|
||||
- `await prisma.follow.findMany({ where: { OR: [{ followerId: currentUserId, followingId: { in: peerIds } }, { followerId: { in: peerIds }, followingId: currentUserId }] } })` (1 seule requête)
|
||||
3. Construire des `Set` / `Map` pour lookup O(1).
|
||||
4. Itérer la page **synchroniquement** et décorer chaque row.
|
||||
|
||||
### Règle
|
||||
|
||||
Tout flag décoratif calculé par row qui dépend d'une logique partagée DOIT être factorisé en une méthode `compute<Flag>ForBatch(peerIds[])` retournant `Map<peerId, value>`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-providers-externes-injection-token"></a>
|
||||
## Pattern : Providers externes via injection par token (jamais `new XxxProvider()`)
|
||||
|
||||
- Objectif : rendre les providers externes mockables, swappables par env, et conformes au contrat DI Nest.
|
||||
- Contexte : service Nest qui dépend d'un système externe (email, billing, push, OAuth IdP, storage, geocoding...).
|
||||
- Quand l'utiliser : tout provider qui touche un système externe.
|
||||
- Quand l'éviter : services purement applicatifs (`UsersService`, `CommunityService`) — injectés par classe directement.
|
||||
- Avantage :
|
||||
- mock propre en test (`useValue`)
|
||||
- rotation d'implémentation par env sans rebuild
|
||||
- arbre DI complet : le module déclare la dépendance
|
||||
- Limites / vigilance :
|
||||
- un `new XxxProvider()` au constructeur s'exécute même quand un test injecte un mock du service → tests cassés
|
||||
- Validé le : 20-05-2026
|
||||
- Contexte technique : NestJS — app-alexandrie story infra-5
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```typescript
|
||||
// ❌ instancié au constructeur : non mockable, non swappable, contrat DI incomplet
|
||||
export class AuthService {
|
||||
private readonly emailProvider = new NoopEmailProvider();
|
||||
private readonly authProvider = new GoogleAuthProvider();
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
}
|
||||
// Conséquences observées (avant fix) : AuthService 5 fails, billing 26 fails, notifications 2 fails.
|
||||
```
|
||||
|
||||
### Pattern correct
|
||||
|
||||
```typescript
|
||||
// 1. Token + interface
|
||||
export const EMAIL_PROVIDER = Symbol('EMAIL_PROVIDER');
|
||||
export interface EmailProvider {
|
||||
sendVerificationEmail(email: string, token: string): Promise<void>;
|
||||
}
|
||||
|
||||
// 2. Implémentation @Injectable()
|
||||
@Injectable()
|
||||
export class NoopEmailProvider implements EmailProvider { /* ... */ }
|
||||
|
||||
// 3. Binding dans le module
|
||||
@Module({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: EMAIL_PROVIDER, useClass: NoopEmailProvider },
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
// 4. Injection par token
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(EMAIL_PROVIDER) private readonly emailProvider: EmailProvider,
|
||||
) {}
|
||||
}
|
||||
|
||||
// 5. Mock en test
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: EMAIL_PROVIDER, useValue: { sendVerificationEmail: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
```
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Token (`Symbol`) + interface dans un fichier dédié
|
||||
- [ ] Implémentation `@Injectable()` qui implémente l'interface
|
||||
- [ ] Binding `{ provide: TOKEN, useClass: Impl }` dans le module
|
||||
- [ ] Injection via `@Inject(TOKEN)` au constructeur
|
||||
- [ ] Aucun `new XxxProvider()` dans un service métier
|
||||
|
||||
@@ -249,6 +249,26 @@ npx prisma migrate resolve --applied <timestamp>_<nom>
|
||||
|
||||
**Ne pas utiliser `prisma db push` en production** — il ne versionne pas les migrations.
|
||||
|
||||
### Variante : réaligner une DB dev sur un schéma amendé (migration WIP non encore mergée)
|
||||
|
||||
Quand `migrate dev` est bloqué (P3014, user applicatif sans droit `CREATE DATABASE`) et qu'une migration **non encore mergée** doit être corrigée : amender directement le `migration.sql` existant (la DB dev est jetable), puis réaligner la base **sans nouvelle migration** :
|
||||
|
||||
```bash
|
||||
# 1. Générer le SQL d'écart DB → schéma cible
|
||||
npx prisma migrate diff \
|
||||
--from-config-datasource --to-schema prisma/schema.prisma \
|
||||
--config prisma.config.ts --script > diff.sql
|
||||
|
||||
# 2. Appliquer (v7 : PAS de --schema ici, datasource lue depuis prisma.config.ts ;
|
||||
# --file OU --stdin, une seule des deux)
|
||||
npx prisma db execute --file diff.sql --config prisma.config.ts
|
||||
|
||||
# 3. Vérifier
|
||||
npx prisma migrate diff ... --exit-code # doit afficher « No difference detected »
|
||||
```
|
||||
|
||||
⚠️ Valable **uniquement** pour une migration WIP non poussée. Une fois la migration mergée, créer une migration corrective **additive** (ne jamais amender une migration déjà partagée).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-filtrage-metier-service"></a>
|
||||
@@ -664,3 +684,510 @@ export const findInvitationByTokenHash = async (tokenHash: string) => {
|
||||
- [ ] Procédure rollback documentée (`pg_dump` avant migration)
|
||||
- [ ] Smoke test post-deploy (login, création, magic link)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-colonnes-plates-vs-table-duree-de-vie"></a>
|
||||
## Pattern : Colonnes plates vs table dédiée — choix par durée de vie de la donnée
|
||||
|
||||
- Objectif : choisir la bonne forme de stockage pour les données d'étapes/cycle de vie d'un agrégat selon que celles-ci survivent ou non à la fin du parcours.
|
||||
- Contexte : agrégat avec un parcours en N étapes (timeline d'un candidat, lifecycle d'un dossier, états d'un workflow).
|
||||
- Quand l'utiliser :
|
||||
- **Colonnes plates** sur la row principale → si les données sont purgées en même temps que la row à la fin du parcours (admission/clôture). Pas de table satellite : moins de JOINs, projection DTO plate, transactions plus simples.
|
||||
- **Table dédiée** (one-to-many ou one-to-one séparé) → si les données survivent à la fin du parcours (audit trail, archivage légal, historique multi-candidatures).
|
||||
- Quand l'éviter : si la cardinalité du détail est variable (préférer alors une table), ou si l'on est tenté de stocker N champs hétérogènes dans un seul blob JSON.
|
||||
- Avantage colonnes plates :
|
||||
- 0 JOIN sur le détail courant
|
||||
- DTO plat, sérialisation directe
|
||||
- transactions atomiques plus simples (1 seule row à locker)
|
||||
- Avantage table dédiée :
|
||||
- indépendance du cycle de vie (la donnée historique ne contraint pas la suppression du parent)
|
||||
- index dédiés possibles
|
||||
- cardinalité variable (vs N colonnes fixes)
|
||||
- Limites / vigilance :
|
||||
- colonnes plates : N colonnes nullable **bien nommées** (pas un JSON blob), DELETE de la row = perte définitive (acceptable seulement si la purge est prévue)
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||
|
||||
### Heuristique de décision
|
||||
|
||||
Question simple : « cette donnée doit-elle survivre à la suppression de la row parent ? » → si **non** → colonnes plates ; si **oui** → table dédiée.
|
||||
|
||||
### Exemple
|
||||
|
||||
RL799 — module Enquête profane : `Profane.letterReadAt`, `Profane.letterVoteOutcome`, `Profane.enquetesMarkedDoneAt`, `Profane.reportReadingAt`, etc. (9 colonnes timeline plates) plutôt qu'une table `ProfaneTimelineEvent`. Justifié : la row `Profane` est DELETE à l'admission (purge totale), donc l'historique de parcours n'a pas à survivre à la row.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-etape-courante-derivee-source-unique"></a>
|
||||
## Pattern : Étape courante dérivée de colonnes booléennes/timestamps (source unique de vérité)
|
||||
|
||||
- Objectif : éviter la duplication entre une colonne `status` explicite et l'état réel dérivé des timestamps/outcomes.
|
||||
- Contexte : agrégat avec parcours en N étapes où chaque étape laisse une trace (date, outcome). On veut connaître l'étape courante à un instant T.
|
||||
- Quand l'utiliser : dès qu'une colonne `currentStep`/`status` redondante risquerait de diverger de l'état réel dérivable des autres colonnes.
|
||||
- Quand l'éviter : si l'étape courante a une sémantique métier autre que ses propres timestamps (ex. dépend d'un autre agrégat).
|
||||
- Solution : un helper **pur** (côté package shared) qui prend en input les colonnes brutes (pas un objet ORM) et retourne un type union discriminant. Ordre de priorité explicite documenté en tête du helper (et qui matche l'ordre des `if`).
|
||||
- Avantage :
|
||||
- source unique de vérité — frontend et backend partagent le même calcul
|
||||
- testable en isolation (helper pur, pas de DB)
|
||||
- aucun drift possible entre `status` stocké et état réel
|
||||
- Limites / vigilance :
|
||||
- toute évolution du parcours nécessite de mettre à jour le helper + ses tests
|
||||
- **ne pas** exposer en parallèle une colonne `currentStep` stockée en DB : le helper EST la source, le frontend reçoit `currentStep` calculé dans le DTO mapper, pas lu d'une colonne
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : monorepo TS partagé front/back — RL799_V2
|
||||
|
||||
### Implémentation (exemple)
|
||||
|
||||
```typescript
|
||||
// packages/shared/src/dto/soirees.ts
|
||||
export const getSoireeLifecycle = (input, now?) => { /* … */ };
|
||||
// Priorité documentée : cancelledAt > closedAt > status('draft'|'pending')
|
||||
// > openedAt > status('published')
|
||||
|
||||
// packages/shared/src/utils/profaneTimeline.ts
|
||||
export const deriveCurrentStep = (input) => { /* … */ };
|
||||
// Priorité : status !== pending → 'closed' ; sinon
|
||||
// bandeauVoteOutcome === 'passed' → 'initiation' ;
|
||||
// reportReadingVoteOutcome === 'passed' → 'bandeau' ; etc.
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
Matrice paramétrée (~15-30 cas) couvrant toutes les transitions pertinentes (cf. `soireeLifecycle.test.ts`).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-row-reactivable-reset-cycle"></a>
|
||||
## Pattern : Row réactivable (reset des colonnes de cycle, identité préservée)
|
||||
|
||||
- Objectif : permettre à une même entité métier de traverser plusieurs cycles (candidatures, abonnements, mandats) en gardant la même identité technique.
|
||||
- Contexte : entité dont l'identité (nom/prénom/email humain) doit rester reconnaissable d'un cycle au suivant, mais dont l'état fonctionnel doit repartir de zéro.
|
||||
- Quand l'utiliser : entité multi-cycles dont l'identité technique doit rester stable (URLs persistantes, audit trail continu, comptage natif des cycles).
|
||||
- Quand préférer DELETE+INSERT à la place :
|
||||
- si l'entité doit garder un historique riche par cycle (rapports, pièces jointes spécifiques) → table satellite `<Entity>Cycle` avec FK vers la row principale
|
||||
- si l'identité change vraiment d'un cycle au suivant (changement légal, fusion d'entités)
|
||||
- Solution : une fonction `reactivate<Entity>()` qui reset **toutes** les colonnes "de cycle" + status à `pending` initial, sans toucher à l'identité (id, créateur, coordonnées). Compteur `attemptCount` incrémenté, gate métier sur la valeur (ex. max 3 cycles).
|
||||
- Avantage :
|
||||
- identité technique stable → URLs persistantes, audit trail continu
|
||||
- comptage natif des cycles (`attemptCount`)
|
||||
- pas de "fantôme" historique à filtrer en table
|
||||
- Limites / vigilance :
|
||||
- le reset doit être **exhaustif** — chaque nouvelle colonne de cycle doit être ajoutée à la fonction reset (à enforcer par revue ou test)
|
||||
- l'audit log doit conserver l'événement `<entity>:reactivated` (le reset efface tout sauf l'audit séparé)
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2
|
||||
|
||||
### Implémentation (exemple)
|
||||
|
||||
```typescript
|
||||
export const reactivateProfane = async (profaneId, client) => {
|
||||
await client.profane.update({
|
||||
where: { id: profaneId },
|
||||
data: {
|
||||
status: 'pending',
|
||||
refusedAt: null,
|
||||
rejectionReason: null,
|
||||
attemptCount: { increment: 1 },
|
||||
letterReadAt: null,
|
||||
letterVoteOutcome: null,
|
||||
// … toutes les colonnes timeline reset à null
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Service : gate `attemptCount >= 3 → 400 MAX_ATTEMPTS_REACHED` **avant** le reset. Audit `enquete:profane_reactivated` posé pour l'historique.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Fonction `reactivate` exhaustive (toutes les colonnes de cycle)
|
||||
- [ ] Compteur `attemptCount` incrémenté
|
||||
- [ ] Gate métier sur le compteur (limite max)
|
||||
- [ ] Audit log de la réactivation
|
||||
- [ ] Test d'intégration : 1 cycle complet → réactivation → état initial
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-endpoint-replace-atomique"></a>
|
||||
## Pattern : Endpoint replace atomique (remplacement à un slot)
|
||||
|
||||
- Objectif : remplacer un membre d'une collection de slots (3 enquêteurs, 5 officiers, etc.) en une seule transaction atomique, sans passer par "DELETE puis POST".
|
||||
- Contexte : agrégat avec une collection de slots où l'on veut remplacer un membre par un autre. Le chaînage `DELETE` puis `POST` ouvre une fenêtre où la collection est dans un état intermédiaire invalide (cardinalité < attendue) et double les notifications.
|
||||
- Quand l'utiliser : dès qu'un remplacement à un slot doit être atomique et que les notifications doivent être chirurgicales (1 sortie, 1 entrée).
|
||||
- Quand l'éviter : ajout/retrait simple sans sémantique de remplacement → POST/DELETE suffisent.
|
||||
- Solution : `PUT /resource/:id/members/:oldId` avec body `{ newMemberId }`. Le service exécute revoke + assign + side-effects (anonymisation, audit) dans la **même transaction Prisma**.
|
||||
- Avantage :
|
||||
- aucune fenêtre d'incohérence visible par un lecteur concurrent
|
||||
- une seule notification post-commit (`notifyAssigned(newId)` + `notifyRevoked(oldId)`) au lieu d'un mailing dupliqué aux membres inchangés
|
||||
- permet de gérer les invariants intermédiaires (ex. anonymisation du rapport déposé par le remplacé) en cohérence avec la modification
|
||||
- Limites / vigilance :
|
||||
- plus complexe qu'un POST (2 IDs au lieu d'1)
|
||||
- le frontend doit comprendre la sémantique "remplacement" et ne pas chaîner DELETE+POST
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Next.js App Router + transaction Prisma — RL799_V2
|
||||
|
||||
### Implémentation (exemple)
|
||||
|
||||
```typescript
|
||||
// PUT /api/venerable/profanes/:profaneId/enqueteurs/:oldEnqueteurId
|
||||
// Body: { newEnqueteurId }
|
||||
export const handleReplaceEnqueteur = async (req, profaneId, oldId) => {
|
||||
const { newEnqueteurId } = await validate(req);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Anonymiser l'éventuel rapport de l'ancien
|
||||
const rapport = await findRapportByEnqueteur(oldId, tx);
|
||||
if (rapport) await anonymizeRapport(rapport.id, tx);
|
||||
// 2. Revoke + assign
|
||||
await revokeEnqueteur(enqueteId, oldId, tx);
|
||||
await assignEnqueteurs(enqueteId, [newEnqueteurId], { /* … */ }, tx);
|
||||
// 3. Audit composite (1 seul log au lieu de 2)
|
||||
await logAction(tx, 'enquete:investigator_replaced', { oldId, newId: newEnqueteurId });
|
||||
});
|
||||
// Post-commit : notifications ciblées (diff connu : 1 sortie, 1 entrée)
|
||||
void notifyAssigned([newEnqueteurId]);
|
||||
void notifyRevoked(oldId);
|
||||
// Les autres membres de la collection ne reçoivent RIEN (cloisonnement)
|
||||
};
|
||||
```
|
||||
|
||||
### Cloisonnement des notifications
|
||||
|
||||
Avec un endpoint replace atomique, le service connaît exactement le diff (1 sortie, 1 entrée) → mailing chirurgical. Avec 2 appels DELETE+POST, le 2e appel voit la collection déjà réduite et re-mailerait les membres inchangés par défaut sans diff intelligent.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-bascule-etat-idempotente-updatemany"></a>
|
||||
## Pattern : Bascule d'état idempotente avec `updateMany` conditionnel (anti-race)
|
||||
|
||||
- Objectif : basculer une row d'un état A vers un état B au franchissement d'un seuil (compteur de reports, quota, vote) sans race ni double effet.
|
||||
- Contexte : transition pilotée par un seuil où le pattern "lire l'état puis updater" est vulnérable aux courses. Deux requêtes concurrentes voient l'état initial simultanément et écrasent toutes deux la transition.
|
||||
- Quand l'utiliser : transition dont la condition de garde peut s'exprimer entièrement dans un `WHERE` (état lu en base).
|
||||
- Quand l'éviter : si la condition de garde est calculée hors DB, ou si l'on doit retourner la row mise à jour (utiliser `update` + gestion `P2025`, mais l'idempotence est alors perdue).
|
||||
- Validé le : 05-05-2026
|
||||
- Contexte technique : Prisma / Postgres — app-alexandrie
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
```ts
|
||||
// ❌ DANGEREUX : race entre findUnique et update
|
||||
const thread = await prisma.thread.findUnique({ where: { id }, select: { visibilityStatus: true } });
|
||||
if (thread?.visibilityStatus !== 'VISIBLE') return;
|
||||
await prisma.thread.update({
|
||||
where: { id },
|
||||
data: { visibilityStatus: 'AUTO_HIDDEN', autoHiddenAt: new Date() },
|
||||
});
|
||||
// Deux requêtes concurrentes voient 'VISIBLE' et écrasent toutes deux autoHiddenAt.
|
||||
```
|
||||
|
||||
### Pattern correct
|
||||
|
||||
```ts
|
||||
// ✅ updateMany filtre côté SQL → idempotence garantie par le SGBD
|
||||
const result = await prisma.thread.updateMany({
|
||||
where: { id, visibilityStatus: 'VISIBLE' },
|
||||
data: { visibilityStatus: 'AUTO_HIDDEN', autoHiddenAt: new Date() },
|
||||
});
|
||||
if (result.count > 0) {
|
||||
logger.log(`Thread ${id} basculé (count=${result.count})`);
|
||||
}
|
||||
```
|
||||
|
||||
L'`UPDATE ... WHERE` est atomique au niveau row : pas de transaction explicite ni de `SELECT ... FOR UPDATE`. `result.count === 0` = no-op idempotent (le perdant de la course).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-pagination-relation-n-n-some"></a>
|
||||
## Pattern : Récupération paginée via relation N-N — `some` plutôt que double findMany
|
||||
|
||||
- Objectif : paginer une liste d'entités qui satisfont une relation N-N sans charger en mémoire un set intermédiaire non borné (risque DoS).
|
||||
- Contexte : "récupérer une liste paginée d'entités E qui satisfont une relation N-N (`UserPack`, `Member`, `Tag`)". La version naïve fait deux `findMany` séquentiels — le premier sans `take`, donc non borné si la relation explose (1000+ rows).
|
||||
- Quand l'utiliser : tout listing paginé dont le filtre passe par une relation N-N.
|
||||
- Quand l'éviter : si le set intermédiaire est borné par construction et petit (quelques rows).
|
||||
- Validé le : 27-05-2026
|
||||
- Contexte technique : Prisma — app-alexandrie
|
||||
|
||||
### Anti-pattern (DoS-able si la relation explose)
|
||||
|
||||
```ts
|
||||
// ❌ Charge potentiellement N×1000 lignes avant pagination
|
||||
const sharingUserPacks = await prisma.userPack.findMany({
|
||||
where: { packId: { in: myPacks.map(p => p.packId) } },
|
||||
select: { userId: true },
|
||||
distinct: ['userId'],
|
||||
});
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: sharingUserPacks.map(p => p.userId) } },
|
||||
take: limit + 1,
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern recommandé
|
||||
|
||||
```ts
|
||||
// ✅ Pagination bornée au niveau User, pas de chargement intermédiaire
|
||||
const myPacks = await prisma.userPack.findMany({
|
||||
where: { userId: currentUserId, revokedAt: null },
|
||||
select: { packId: true },
|
||||
});
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { not: currentUserId },
|
||||
deletedAt: null,
|
||||
userPacks: { some: { packId: { in: myPacks.map(p => p.packId) }, revokedAt: null } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1,
|
||||
});
|
||||
```
|
||||
|
||||
Prisma génère un sous-select `EXISTS` borné par l'`orderBy` + `take` du niveau supérieur. L'index utilisé est celui de la jointure (`UserPack (userId, packId)`).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-raw-sql-queryrawunsafe-facade"></a>
|
||||
## Pattern : Raw SQL via `$queryRawUnsafe` quand Prisma est encapsulé dans une façade
|
||||
|
||||
- Objectif : écrire une requête raw SQL paramétrée quand l'accès DB passe par un service-façade qui ne réexporte pas le tag template `$queryRaw`.
|
||||
- Contexte : Prisma encapsulé dans un `PrismaService` (façade NestJS qui ne réexpose que les modèles + quelques méthodes). Le tag template `$queryRaw\`...\`` n'est PAS disponible (`Property '$queryRaw' does not exist`), mais `$queryRawUnsafe(query, ...values)` l'est.
|
||||
- Quand l'utiliser : raw SQL nécessaire (agrégations, requêtes non exprimables via le query builder) à travers une façade Prisma.
|
||||
- Quand l'éviter : si la requête s'exprime via le query builder Prisma (préférer le typage natif).
|
||||
- Validé le : 08-06-2026
|
||||
- Contexte technique : NestJS / Prisma façade — app-alexandrie
|
||||
|
||||
### Règle
|
||||
|
||||
```typescript
|
||||
// Paramètres positionnels $1, $2… → toujours paramétrés, jamais d'interpolation
|
||||
const rows = await this.prisma.$queryRawUnsafe<Row[]>(
|
||||
'SELECT COUNT(*) AS cnt FROM members WHERE tenant_id = $1',
|
||||
tenantId,
|
||||
);
|
||||
const total = Number(rows[0].cnt); // bigint → Number
|
||||
```
|
||||
|
||||
- `$queryRawUnsafe` n'est "unsafe" que par son **nom** : `Unsafe` désigne le fait que le SQL est une string libre (non validée par Prisma), PAS l'absence de paramétrage. Avec des `$n` paramétrés il est aussi sûr que le tag template — jamais d'interpolation de chaîne dans le SQL.
|
||||
- ⚠️ Noms de tables/colonnes = noms DB **réels** (`@map`/`@@map`, souvent snake_case), pas les noms du client Prisma (camelCase). Une requête raw contourne le mapping → vérifier le schéma avant d'écrire le SQL.
|
||||
- Caster les agrégats : selon le driver, `COUNT(...)` revient en `bigint` → `Number(row.cnt)` côté TS.
|
||||
- Avant de suivre une tech-spec qui écrit du raw, vérifier ce que la façade expose réellement (grep des usages raw existants) plutôt que supposer l'API Prisma standard.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-token-usage-unique-updatemany-where"></a>
|
||||
## Pattern : Consommation concurrente d'un token usage-unique — condition dans le `WHERE` de l'UPDATE
|
||||
|
||||
- Objectif : rendre un token (ou flag) usage-unique sous concurrence (2 POST simultanés avec le même token) sans double consommation.
|
||||
- Contexte : sous `READ COMMITTED` (défaut Postgres/Prisma), un `findFirst(tokenHash)` + `update(WHERE id)` séparés laissent les deux lectures voir le token vivant → les deux updates réussissent (double consommation).
|
||||
- Quand l'utiliser : consommation atomique d'une ressource usage-unique (token, ticket, slot) sous concurrence possible (double-clic, retry, multi-onglets).
|
||||
- Quand l'éviter : ressource sans contrainte d'usage unique.
|
||||
- Validé le : 16-06-2026
|
||||
- Contexte technique : Prisma / Postgres — RL799_V2 (Lot C Keycloak onboarding)
|
||||
|
||||
### Règle
|
||||
|
||||
Faire un `updateMany` dont le `WHERE` porte la **condition de consommation** (le hash encore présent), pas seulement un SELECT préalable. Le verrou de ligne sérialise les transactions concurrentes : le 2e voit `count: 0` → traiter comme `token_not_found`.
|
||||
|
||||
```typescript
|
||||
const { count } = await tx.delivery.updateMany({
|
||||
where: { id, onboardingTokenHash: hash },
|
||||
data: { keycloakSub, onboardingTokenHash: null },
|
||||
});
|
||||
if (count === 0) return { ok: false, reason: 'token_not_found' };
|
||||
```
|
||||
|
||||
`update` (par `@id`) ne permet pas un `WHERE` composite → `updateMany` est l'outil, en lisant `result.count`.
|
||||
|
||||
Garde-fou complémentaire : un re-pointage de colonne `@unique` peut lever `P2002` sous race (un concurrent prend la valeur entre check et update) → catcher `P2002` et le mapper en « collision » plutôt que 500.
|
||||
|
||||
Test obligatoire : `Promise.all([POST, POST])` même token → attendu `[200, 400]`.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-fk-snapshot-label-vs-texte-libre"></a>
|
||||
## Pattern : « FK + snapshot label dérivé serveur » ≠ « FK + texte libre saisi client »
|
||||
|
||||
- Objectif : distinguer deux conceptions « FK + texte » visuellement identiques mais sémantiquement opposées, pour ne pas produire un label falsifiable ou une donnée perdue.
|
||||
- Contexte : un enregistrement référence une autre entité ET veut afficher son libellé même après disparition de la cible.
|
||||
- Quand l'utiliser : tout « sujet / cible » d'une entité pointant une autre entité supprimable.
|
||||
- Validé le : 18-06-2026
|
||||
- Contexte technique : Prisma / Next.js App Router — RL799_V2 (chantier ODJ)
|
||||
|
||||
### Les deux patterns
|
||||
|
||||
- **(a) Texte libre client** (ex. `plancheAuthorId` FK ⊕ `plancheAuthorName` saisi) : deux modes **exclusifs**, tous deux fournis par le client. Le handler prend le nom tel quel. Convient quand la cible peut ne pas exister en base (auteur non-membre).
|
||||
- **(b) Snapshot dérivé serveur** (ex. `subjectProfaneId`/`subjectUserId` FK + `subjectLabel` calculé) : le client envoie **seulement l'id**, le backend résout `firstName/lastName` de la cible et fige le label. Anti-falsification + survie à la suppression de la cible.
|
||||
|
||||
### Piège
|
||||
|
||||
Croire que (b) « imite » (a). NON — (a) ne fait aucune résolution serveur. Implémenter (b) en copiant (a) produit un label client falsifiable et désynchronisé.
|
||||
|
||||
### Règle
|
||||
|
||||
- Sujet/cible **référençant une entité connue** → pattern (b) : résolution + snapshot côté serveur à l'écriture, FK `onDelete: SetNull`.
|
||||
- Cible **hors-base** → pattern (a).
|
||||
|
||||
### Corollaire sur `onDelete: SetNull`
|
||||
|
||||
Sa justification dépend du cycle de vie réel de la cible :
|
||||
- réellement déclenché si la cible est **hard-deleted** (ex. `Profane` DELETE à l'admission → le snapshot est indispensable) ;
|
||||
- purement garde-fou FK si la cible est **soft-deleted/anonymisée** (ex. `User` jamais hard-deleted → le snapshot survit trivialement).
|
||||
|
||||
Ne pas copier le rationale d'un cas à l'autre.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-migration-string-int-enum-sans-downtime"></a>
|
||||
## Pattern : Migration Postgres String/Int → enum (backfill défensif + cast sans downtime)
|
||||
|
||||
- Objectif : durcir une colonne `String` libre ou `Int` en `enum` Postgres sans déploiement raté ni état hybride, en préservant l'audit et sans downtime.
|
||||
- Contexte : opération d'hygiène DB la plus fréquente et la plus piégeuse — la migration plante au premier `INSERT`/valeur qui ne matche pas l'enum, et `Int → enum` n'accepte pas le cast direct.
|
||||
- Quand l'utiliser : conversion d'une colonne à valeurs finies (`status`, `type`, `grade`) vers un `enum`.
|
||||
- Quand l'éviter : champ réellement libre (texte saisi), ou snapshot historique volontairement laissé en `String?`/`Int?` (cf. cascade ci-dessous).
|
||||
- Validé le : 11-05-2026
|
||||
- Contexte technique : Prisma 7 + PostgreSQL 16 — RL799_V2
|
||||
|
||||
### Étape 0 — Backfill défensif (pré-scan AVANT toute migration)
|
||||
|
||||
Avant TOUTE conversion, exécuter un script de pré-scan qui :
|
||||
|
||||
1. liste les **valeurs distinctes en DB** : `SELECT DISTINCT col FROM table` (avec cardinalités) ;
|
||||
2. compare aux valeurs **attendues par l'enum** (issues du DTO `as const` / du schéma Zod) ;
|
||||
3. identifie les **orphelins** (présents en DB mais pas dans l'enum) ;
|
||||
4. pour chaque orphelin, **décision explicite avant** la migration : mapper (`UPDATE col = 'new' WHERE col = 'orphan'`), NULLifier (si nullable), ou rejeter la migration si l'orphelin révèle un bug applicatif.
|
||||
|
||||
Anti-pattern : lancer `prisma migrate deploy` en pensant « la DB est cohérente parce que l'app valide via Zod » — la valeur peut venir d'un ancien feature flag, d'un import historique, d'une console SQL admin. (Cas RL799 V1.1 : pré-scan exécuté, 0 orphelin sur 7 colonnes → migration appliquée en confiance.)
|
||||
|
||||
### Cas A — `String → enum` (cast direct natif, pas de colonne tampon)
|
||||
|
||||
```sql
|
||||
-- 1. Créer l'enum
|
||||
CREATE TYPE "Grade" AS ENUM ('Apprenti', 'Compagnon', 'Maitre');
|
||||
|
||||
-- 2. Si la colonne a un DEFAULT, le drop avant ALTER TYPE
|
||||
ALTER TABLE "Document" ALTER COLUMN "grade" DROP DEFAULT;
|
||||
|
||||
-- 3. Cast direct (Postgres accepte String → enum via USING)
|
||||
ALTER TABLE "Document"
|
||||
ALTER COLUMN "grade" TYPE "Grade" USING "grade"::"Grade";
|
||||
|
||||
-- 4. Re-poser le DEFAULT typé enum
|
||||
ALTER TABLE "Document" ALTER COLUMN "grade" SET DEFAULT 'Apprenti'::"Grade";
|
||||
```
|
||||
|
||||
Les contraintes `UNIQUE` sur la colonne sont préservées automatiquement par Postgres tant que le nouveau type accepte les mêmes valeurs — pas de drop+recreate.
|
||||
|
||||
### Cas B — `String? → enum NOT NULL` (backfill des NULL AVANT le SET NOT NULL)
|
||||
|
||||
Le cast direct fonctionne sans colonne tampon, mais il faut backfiller les NULL **avant** `SET NOT NULL`, sinon il lève à la fin.
|
||||
|
||||
```sql
|
||||
-- 1. Backfill des NULL historiques vers la valeur par défaut métier
|
||||
UPDATE "table" SET "col" = 'DefaultValue' WHERE "col" IS NULL;
|
||||
|
||||
-- 2. Cast direct text → enum
|
||||
ALTER TABLE "table" ALTER COLUMN "col" TYPE "MyEnum" USING "col"::"MyEnum";
|
||||
|
||||
-- 3. SET NOT NULL après le backfill
|
||||
ALTER TABLE "table" ALTER COLUMN "col" SET NOT NULL;
|
||||
|
||||
-- 4. Garde-fou anti-NULL résiduel (la NOT NULL bloquerait déjà, mais log explicite pour le debug)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM "table" WHERE "col" IS NULL) THEN
|
||||
RAISE EXCEPTION 'table.col contient des NULL après backfill — anomalie';
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
### Cas C — `Int → enum` (cast direct REFUSÉ → colonne tampon obligatoire)
|
||||
|
||||
Postgres refuse `ALTER COLUMN x TYPE myEnum USING x::myEnum` quand `x` est `INTEGER`, même avec un `USING` explicite. Passer par une colonne tampon + `UPDATE CASE WHEN`, sans downtime (expand/contract) :
|
||||
|
||||
```sql
|
||||
-- 1. Enum cible
|
||||
CREATE TYPE "Grade" AS ENUM ('Apprenti', 'Compagnon', 'Maitre');
|
||||
|
||||
-- 2. Colonne tampon du type cible (expand)
|
||||
ALTER TABLE "OdjItem" ADD COLUMN "grade_new" "Grade";
|
||||
|
||||
-- 3. Remplir via UPDATE CASE/WHEN
|
||||
UPDATE "OdjItem"
|
||||
SET "grade_new" = CASE "grade"
|
||||
WHEN 1 THEN 'Apprenti'::"Grade"
|
||||
WHEN 2 THEN 'Compagnon'::"Grade"
|
||||
WHEN 3 THEN 'Maitre'::"Grade"
|
||||
END;
|
||||
|
||||
-- 4. Garde-fou : aucune ligne ne doit rester NULL après l'UPDATE
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM "OdjItem" WHERE "grade_new" IS NULL AND "grade" IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'Migration grade : valeur Int hors mapping détectée';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 5. Drop l'index éventuel sur l'ancienne colonne
|
||||
DROP INDEX IF EXISTS "OdjItem_grade_idx";
|
||||
|
||||
-- 6. Swap : drop old, rename new, SET NOT NULL si besoin (contract)
|
||||
ALTER TABLE "OdjItem" DROP COLUMN "grade";
|
||||
ALTER TABLE "OdjItem" RENAME COLUMN "grade_new" TO "grade";
|
||||
ALTER TABLE "OdjItem" ALTER COLUMN "grade" SET NOT NULL;
|
||||
|
||||
-- 7. Recréer l'index
|
||||
CREATE INDEX "OdjItem_grade_idx" ON "OdjItem"("grade");
|
||||
```
|
||||
|
||||
Pour une colonne `Int?` nullable : **omettre** le `SET NOT NULL` (étape 6) et adapter le garde-fou (`WHERE grade_new IS NULL AND grade IS NOT NULL`) pour ne pas crier sur les NULL légitimes.
|
||||
|
||||
### Récapitulatif des deux casts
|
||||
|
||||
- `String → enum` : USING natif **accepté** → pas de colonne tampon.
|
||||
- `Int → enum` : USING direct **refusé** → colonne tampon + `UPDATE CASE/WHEN` obligatoire.
|
||||
- Dans tous les cas : backfill défensif préalable + garde-fou `DO $$` + drop/recreate du DEFAULT typé.
|
||||
|
||||
### Cascade côté code (post-migration)
|
||||
|
||||
Après `prisma generate`, TypeScript révèle **toutes** les coercions implicites précédentes (`x as Grade`, comparaisons numériques) — effet iceberg : un fix SQL unique peut révéler 30-50 erreurs TS dormantes.
|
||||
|
||||
- **Helpers de conversion aux frontières** : `gradeToRank(g): 1|2|3` / `rankToGrade(r): Grade` exportés depuis `@app/shared/utils` (UI qui pivote par rang sans toucher au domain).
|
||||
- **Snapshots historiques** : laisser volontairement `String?`/`Int?` les colonnes de snapshot d'état (ex. `Attendance.gradeAtTime`). Le domain strict ne s'applique qu'aux entités vivantes.
|
||||
- **Validation API** : durcir les query params (`?grade=`) avec un type guard `isGrade(s): s is Grade` qui rejette aussi lowercase/abréviations.
|
||||
|
||||
Bug latent typique capté : un `=== 'apprenti'` (lowercase) qui ne matche jamais `'Apprenti'` (TitleCase) — invisible en `string`, signalé immédiatement par TS après la bascule en enum. Le typage strict révèle, ne crée pas, ces bugs.
|
||||
|
||||
### Vigilance
|
||||
|
||||
⚠️ Le pré-scan ne détecte PAS les **index partiels avec littéraux text** qui bloquent l'`ALTER` — cf. `risque-index-partiel-text-alter-enum` dans `risques/prisma.md`.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Pré-scan des valeurs distinctes vs enum attendu, orphelins décidés explicitement
|
||||
- [ ] DEFAULT droppé avant `ALTER TYPE`, re-posé typé enum après
|
||||
- [ ] `Int → enum` : colonne tampon + garde-fou `DO $$`
|
||||
- [ ] `String? → enum NOT NULL` : backfill des NULL avant `SET NOT NULL`
|
||||
- [ ] Grep préalable des index partiels littéraux (cf. risque compagnon)
|
||||
- [ ] Cascade TS gérée (helpers de frontière, snapshots laissés souples)
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-util-crypto-transverse-neutre"></a>
|
||||
## Pattern : Extraire un util crypto/transverse neutre partagé entre deux domaines
|
||||
|
||||
- Objectif : factoriser une mécanique technique partagée entre deux domaines métier (ici : tokens de réponse « quick-link » hashés sha256) **sans coupler les domaines**.
|
||||
- Contexte : deux domaines (convocations + instructions) partagent la même primitive crypto. Le piège est de réutiliser un repository du domaine A dans le domaine B. La distinction : on factorise un util **transverse neutre** (crypto), jamais du métier.
|
||||
- Quand l'utiliser : deux domaines partagent une mécanique purement technique (hash, génération de token, encodage).
|
||||
- Quand l'éviter : si le code partagé porte de la logique métier → préférer la duplication au couplage inter-domaines.
|
||||
- Validé le : 23-06-2026
|
||||
- Contexte technique : Prisma / monorepo — RL799_V2
|
||||
|
||||
### Règles
|
||||
|
||||
1. L'util (`lib/responseToken.ts`) ne connaît **aucun domaine** : pas d'import Prisma, pas d'import repository, JSDoc sans référence métier. Il produit/hashe, c'est tout.
|
||||
2. Chaque domaine pose **son propre** champ `responseToken` sur **son** modèle de delivery et gère **son** lookup.
|
||||
3. Pour ne pas casser les call-sites historiques pendant la migration, ré-exporter depuis l'ancien emplacement : `export { hashResponseToken } from '@/lib/responseToken'` — zéro modification chez le call-site legacy, zéro duplication.
|
||||
4. Vérifier l'absence de duplication résiduelle par grep ciblé sur la primitive (`randomBytes(32)`, `createHash('sha256')`) — tolérer les redéfinitions locales en zone test pure.
|
||||
|
||||
### Modèle de delivery « autonome »
|
||||
|
||||
Calquer la mécanique token d'un modèle existant (`ConvocationDelivery`) mais retirer **toute** la chaîne FK du domaine d'origine (issue/grade/mailLog/status) — ne garder que : id applicatif + 2 FK (parent métier + recipient) + `responseToken @unique` + timestamps. Migration : table créée **vide**, en-tête documentant explicitement « pas de backfill » + l'invariant d'isolation (zéro FK croisée).
|
||||
|
||||
|
||||
@@ -158,3 +158,43 @@ handlePackWebhookEvent(event): PackWebhookResult | null
|
||||
- Règle métier explicitée
|
||||
- Guards alignés sur la sémantique choisie
|
||||
- Fixtures et seeds cohérents
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-price-data-inline-checkout-dynamique"></a>
|
||||
|
||||
## Pattern : Prix défini en base → `price_data` inline pour un checkout dynamique
|
||||
|
||||
- Objectif : faire d'un checkout Stripe one-shot la source de vérité d'un montant qui vit dans NOTRE base (saisi par un admin métier), sans Price pré-créé dans le dashboard Stripe.
|
||||
- Contexte : produit one-shot dont le prix est défini en back-office et non figé dans Stripe.
|
||||
- Quand l'utiliser : checkout `mode: 'payment'` dont le montant est piloté par la base.
|
||||
- Quand l'éviter : récurrent/abonnement (`mode: 'subscription'`) — garder un `Price` recurring pré-créé.
|
||||
- Avantage :
|
||||
- l'admin ne saisit qu'un montant (+ devise) ; aucun objet `Price` à gérer dans Stripe
|
||||
- un changement de prix prend effet immédiatement au prochain checkout
|
||||
- Limites / vigilance :
|
||||
- `unit_amount` est en **centimes** = stockage entier (jamais de flottant)
|
||||
- le montant venant de notre base, ajouter une garde anti-fraude best-effort (voir ci-dessous)
|
||||
- Validé le : 05-06-2026
|
||||
- Contexte technique : Stripe Checkout / NestJS — app-alexandrie (ux-parcours-3/7 + bo-6)
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
stripe.checkout.sessions.create({
|
||||
mode: 'payment',
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency, // ISO 4217 minuscules, normalisée à l'écriture
|
||||
unit_amount: amountInCents, // entier, centimes
|
||||
product_data: { name },
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
### Garde-fous
|
||||
|
||||
- **Anti-fraude (best-effort, non bloquant)** : au webhook, comparer `amount_total`/`currency` au montant attendu (retrouvé via metadata) et logguer tout écart (`stripe_amount_suspicious`). Stripe a déjà encaissé → log, pas de blocage.
|
||||
- **Validation devise stricte côté écriture** (ISO 4217 minuscules) sur TOUS les modèles qui pilotent un checkout : un schéma lâche sur l'un et strict sur l'autre = devise invalide passée à Stripe. Défense serveur : défauter la devise quand un montant est posé sans elle (un montant sans devise = produit non achetable).
|
||||
|
||||
@@ -249,6 +249,160 @@ const notif = await waitForNotification({ type: 'X', recipientId: userId });
|
||||
- [ ] Timeout par défaut court (1500 ms)
|
||||
- [ ] Migration progressive — pas tous les tests d'un coup
|
||||
|
||||
### Pattern symétrique pour vérifier l'ABSENCE d'un side-effect
|
||||
|
||||
`waitForX` ne convient pas pour prouver qu'**aucun** event n'apparaît : le polling jusqu'au timeout ne distingue pas "aucun event" de "event arrivé après le timeout". Le remplacer par `setTimeout(100) + count()` souffre d'une double fragilité (trop court en CI lent → faux négatif si l'event fuyant arrive après ; trop long → temps gaspillé).
|
||||
|
||||
Le helper symétrique correct est un polling-borné **fail-fast** (`assertCountStable`) : il poll-vérifie que le compteur reste à la valeur attendue sur une fenêtre courte, et abandonne **dès** qu'un compteur surnuméraire est observé.
|
||||
|
||||
```typescript
|
||||
// __tests__/helpers/asyncWait.ts
|
||||
export const assertNotificationCountStable = async (
|
||||
query: { type: NotificationType; recipientId?: string },
|
||||
expected: number,
|
||||
options: { windowMs?: number; intervalMs?: number } = {},
|
||||
): Promise<number> => {
|
||||
const window = options.windowMs ?? 200;
|
||||
const interval = options.intervalMs ?? 50;
|
||||
const deadline = Date.now() + window;
|
||||
|
||||
let count = await prisma.notification.count({ where: query });
|
||||
if (count > expected) return count; // fail-fast immédiat
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
count = await prisma.notification.count({ where: query });
|
||||
if (count > expected) return count; // fail-fast
|
||||
}
|
||||
return count;
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ délai arbitraire — trop court en CI lent (faux négatif), trop long = temps perdu
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(await prisma.notification.count({ where: { /* … */ } })).toBe(1);
|
||||
|
||||
// ✅ polling borné, fail-fast si une notif fuyante apparaît
|
||||
const count = await assertNotificationCountStable({ type: 'X', recipientId: userId }, 1);
|
||||
expect(count).toBe(1);
|
||||
```
|
||||
|
||||
Checklist (absence) :
|
||||
|
||||
- [ ] Fenêtre courte (200 ms par défaut — durée typique d'un fire-and-forget non-désiré, pas un timeout)
|
||||
- [ ] Filtre exhaustif — `recipientId` ou `targetId` au minimum
|
||||
- [ ] Le test affirme `count === expected` APRÈS le helper, pas pendant
|
||||
|
||||
Cas vécu : `PATCH/DELETE payments → aucune nouvelle notif` (`cotisationsPayments.test.ts`), 05-05-2026.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-injection-dependance-hooks-module-level"></a>
|
||||
## Pattern : Injection de dépendance testable via hooks module-level `__setXForTests` / `__resetXForTests`
|
||||
|
||||
- Objectif : surcharger en test une dépendance d'un module de prod (getter de clés JWKS, sub-resolver DB, …) SANS polluer la signature publique (param de DI) ni recourir à `vi.mock` (fragile sur les imports transitifs, hoisting capricieux).
|
||||
- Contexte : module dont une dépendance interne doit varier en test mais dont la signature publique est un invariant ("signatures intouchables").
|
||||
- Quand l'utiliser : la dépendance est interne et `vi.mock` se révèle fragile (chaîne d'imports transitifs).
|
||||
- Quand l'éviter : la dépendance est déjà un paramètre explicite de la fonction (param-threading suffit) ; ou un `vi.mock` simple et stable fait l'affaire.
|
||||
- Avantage :
|
||||
- pas de param de DI dans le contrat public, pas de `vi.mock` fragile
|
||||
- bonus diagnostic : injecter un fake qui **throw si appelé** prouve qu'un chemin n'est PAS pris (ex. "le sub-resolver Keycloak ne doit pas être appelé quand le JWT-maison gagne")
|
||||
- Limites / vigilance :
|
||||
- **reset systématique dans `setupFile.ts` avant chaque fichier** (même garde-fou que les rate-limiters in-memory : un fichier qui injecte ne doit pas polluer le suivant → flakiness inter-fichiers évitée)
|
||||
- le défaut de PRODUCTION reste le comportement réel (ex. stub fail-closed `() => null` tant que la vraie impl n'existe pas)
|
||||
- Validé le : 14-06-2026
|
||||
- Contexte technique : Vitest — RL799_V2 (rate-limiters, puis Keycloak K1.1)
|
||||
|
||||
### Implémentation
|
||||
|
||||
```typescript
|
||||
// module de prod
|
||||
let active = productionDefault;
|
||||
|
||||
export function __setXForTests(v: typeof productionDefault): void { active = v; }
|
||||
export function __resetXForTests(): void { active = productionDefault; }
|
||||
```
|
||||
|
||||
```typescript
|
||||
// setupFile.ts — reset AVANT chaque fichier
|
||||
beforeEach(() => {
|
||||
__resetXForTests();
|
||||
__resetAllRateLimitersForTests();
|
||||
});
|
||||
```
|
||||
|
||||
Cas vécus : `__resetAllRateLimitersForTests` (`rateLimiter.ts`), puis `__setKeycloakKeyGetterForTests` / `__setSubResolverForTests` (K1.1).
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-e2e-db-based-nestjs-prisma-v7"></a>
|
||||
## Pattern : e2e DB-based NestJS + Prisma v7 (Jest + @swc/jest)
|
||||
|
||||
- Objectif : exécuter des e2e Jest contre une vraie base Postgres (sans mocker `PrismaClient`) dans un projet NestJS utilisant Prisma v7.
|
||||
- Contexte : Prisma v7 charge dynamiquement un runtime WASM (`await import('....mjs')`, `import.meta.url`) → incompatible avec la config Jest historique `ts-jest` + `moduleNameMapper` qui mock le client.
|
||||
- Quand l'utiliser : projet NestJS + Prisma v7 voulant des e2e avec persistance réelle (validation de contracts Zod réels, fixtures partagées avec le seed, intégrité cross-modules).
|
||||
- Quand l'éviter : suites qui n'ont pas besoin d'une vraie DB → garder le pipeline mocké (plus rapide, plus isolé).
|
||||
- Avantage :
|
||||
- exploite des builders typés contre une vraie base
|
||||
- pattern inter-projet : tout NestJS + Prisma v7 rencontre le même problème
|
||||
- Limites / vigilance :
|
||||
- garde-fou anti-truncate destructeur obligatoire (refuser si `DATABASE_URL` ne contient pas "test")
|
||||
- **PAS** de mock global `argon2` (sinon le hash est stub et les tests d'auth ne valident rien)
|
||||
- Validé le : 27-05-2026
|
||||
- Contexte technique : NestJS / Jest / @swc/jest / Prisma v7 / Postgres — app-alexandrie
|
||||
|
||||
### Config Jest e2e DB-based minimale
|
||||
|
||||
```json
|
||||
{
|
||||
"moduleFileExtensions": ["js", "mjs", "json", "ts"],
|
||||
"testRegex": ".e2e-db-spec.ts$",
|
||||
"setupFiles": ["<rootDir>/test-env-db.ts"],
|
||||
"transformIgnorePatterns": ["/node_modules/(?!(@prisma)/)"],
|
||||
"transform": {
|
||||
"^.+\\.(t|j|mj)s$": ["@swc/jest", {
|
||||
"jsc": {
|
||||
"target": "es2023",
|
||||
"parser": { "syntax": "typescript", "decorators": true, "dynamicImport": true },
|
||||
"transform": { "legacyDecorator": true, "decoratorMetadata": true },
|
||||
"keepClassNames": true
|
||||
},
|
||||
"module": { "type": "commonjs" }
|
||||
}]
|
||||
},
|
||||
"moduleNameMapper": { "^(\\.{1,2}/.*)\\.js$": "$1" }
|
||||
}
|
||||
```
|
||||
|
||||
Points-clés :
|
||||
|
||||
- **`@swc/jest`** au lieu de `ts-jest` : gère `import.meta.url`, `await import()` dynamique, et respecte `decoratorMetadata` (DI Nest).
|
||||
- **`transformIgnorePatterns`** ouvert pour `/node_modules/@prisma/` : les `.mjs` du runtime WASM doivent passer dans le transformer.
|
||||
- **`moduleNameMapper`** strip les extensions `.js` : Prisma v7 écrit ses imports en ESM strict (`./internal/class.js`), Jest CJS résout `.ts`.
|
||||
- **PAS** de `moduleNameMapper` mappant `PrismaClient` vers un mock (sinon on tape le mock, pas la vraie DB).
|
||||
|
||||
### Helper e2e avec garde-fou anti-truncate
|
||||
|
||||
```typescript
|
||||
// _helpers/e2e-db.ts
|
||||
export async function truncateE2EDb(prisma: PrismaClient): Promise<void> {
|
||||
const url = process.env.DATABASE_URL ?? '';
|
||||
if (!url.includes('test')) {
|
||||
throw new Error('[e2e-db] truncate refusé : DATABASE_URL ne contient pas "test"');
|
||||
}
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} RESTART IDENTITY CASCADE;`);
|
||||
}
|
||||
```
|
||||
|
||||
Infra (à provisionner une fois) : DB dédiée avec "test" dans le nom + `DATABASE_URL=...test pnpm exec prisma migrate deploy`.
|
||||
|
||||
### Cohabitation avec les e2e mockés historiques
|
||||
|
||||
- Garder le pipeline mocké pour les suites sans besoin de vraie DB.
|
||||
- Migrer progressivement vers le DB-based les e2e qui bénéficient d'une vraie persistance.
|
||||
- Convention de nommage : `*.e2e-spec.ts` (mockés) vs `*.e2e-db-spec.ts` (DB-based) — extension distinctive captée par les `testRegex` respectifs.
|
||||
|
||||
---
|
||||
|
||||
<a id="pattern-test-atomicite-transaction"></a>
|
||||
|
||||
@@ -8,12 +8,12 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
|
||||
|
||||
| Fichier | Domaine | Entrées clés |
|
||||
|---------|---------|--------------|
|
||||
| `auth.md` | Auth, sessions, guards, accès | AuthN/AuthZ dispersée, guard global manquant, null-check request.user, AdminRoleGuard sans @RequireAdminRole, GET sans contrôle accès, cookie après révocation, mock session sans expiresAt, buildApp partagé e2e, champ absent JWT, email login vs contact, disclosure comptes soft-deleted dans login() |
|
||||
| `contracts.md` | Contrats, validation, codes erreur | Contrats implicites, erreurs non standardisées, duplication constantes, schema orphelin, code erreur générique 409, ForbiddenException pour validation, process.env direct, statut métier non propagé |
|
||||
| `prisma.md` | Prisma, DB, transactions, migrations | @unique nullable, TOCTOU transaction, OR tenantId null, nextOrder race condition, tenantId sans FK, schema divergence spec, getter manquant, init module build, clearAllMocks imbriqué, cursor non validé, enum-like String, migration manuelle hors git, relation 1:1 sans unique, index partial soft-delete (perf) |
|
||||
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end, list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing |
|
||||
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète, guard multi-statut READ_METHODS, bootstrap OK mais injection cassée (tsx watch), guard écriture mode dégradé bloque le support |
|
||||
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h, compensation incrBy non-atomique (quota fantôme) |
|
||||
| `auth.md` | Auth, sessions, guards, accès | AuthN/AuthZ dispersée, guard global manquant, null-check request.user, AdminRoleGuard sans @RequireAdminRole, GET sans contrôle accès, cookie après révocation, mock session sans expiresAt, buildApp partagé e2e, champ absent JWT, email login vs contact, disclosure comptes soft-deleted dans login(), guard d'abonnement global vs droits acquis permanents, validité du jeton d'octroi ≠ durée de l'accès, cohérence des filtres d'authz entre chemins, rotation refresh token IdP en BFF (cookie non réécrit) |
|
||||
| `contracts.md` | Contrats, validation, codes erreur | Contrats implicites, erreurs non standardisées, duplication constantes, schema orphelin, code erreur générique 409, ForbiddenException pour validation, process.env direct, statut métier non propagé, AC d'affichage vert mais champ absent du contract, schéma par audience pas par entité |
|
||||
| `prisma.md` | Prisma, DB, transactions, migrations | @unique nullable, TOCTOU transaction, OR tenantId null, nextOrder race condition, tenantId sans FK (relation des deux côtés), schema divergence spec, getter manquant, init module build, clearAllMocks imbriqué, cursor non validé (champs typés), enum-like String, migration manuelle hors git, relation 1:1 sans unique, index partial soft-delete (perf), index partiels littéraux text bloquent ALTER enum, colonnes Prisma jamais écrites, read-then-write/transition one-shot race, @@unique + @@index redondant, suppression champ DB invisible via .map(), DELETE row en transaction d'anonymisation, slug User.id (id auto-généré + validation Zod), template DB de test à droper après migration |
|
||||
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end (+ SDK v20 par item), list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing, remboursement lié à la transaction (PaymentIntent), refund éligibilité mesurée sur visionnage réel |
|
||||
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète, guard multi-statut READ_METHODS, bootstrap OK mais injection cassée (tsx watch → fix swc/.swcrc), guard écriture mode dégradé bloque le support |
|
||||
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h, compensation incrBy non-atomique (quota fantôme + échec transaction DB), rate-limit à compteur partagé entre endpoints jumeaux |
|
||||
| `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags, dossiers `_*` exclus du routing App Router |
|
||||
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch, valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level, dérive DTO liste vs détail, notification linkUrl rôle-aware, matrice documentée vs code, format `User.id` mixte, Web Push topic > 32 chars, lib npm types non embarqués, form HTML POST dans un mail, env vars frontend-facing fail-fast |
|
||||
| `tests.md` | Isolation des tests d'intégration | `vi.stubEnv` sans restauration, `maxWorkers: 1` masque l'isolation, flakiness inter-fichiers DB partagée |
|
||||
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch (+ RBAC-before-parse), valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level, dérive DTO liste vs détail, notification linkUrl rôle-aware, matrice documentée vs code, format `User.id` mixte, Web Push topic > 32 chars, lib npm types non embarqués, form HTML POST dans un mail, env vars frontend-facing fail-fast, `deleteOlderThan` sans cron caller, couplage framework dans shared/utils, cache in-process stale en test, AuditLog.userId NOT NULL vs action publique, keepAliveTimeout=0 ne désactive pas, upsert + filtre liste (pollution/désync), suppression champ DB via .map(), gate de seuil sur valeur entrante, flag capacité global non réconcilié, garde-fou seed TRUNCATE, migration flag → dérivé, bypass authz sur liste (lookup batché), epoch secondes vs ms, wrapper fail-safe catch-all, retrait asymétrique front/back, Keycloak start --optimized vs theme/provider monté, comparaison de dates vs NaN, entité active via status, anti-énumération codes différenciés rate-limitée |
|
||||
| `tests.md` | Isolation des tests d'intégration | `vi.stubEnv` sans restauration, `maxWorkers: 1` masque l'isolation, flakiness inter-fichiers DB partagée, test RBAC qui ré-encode la table de rôles au lieu d'invoquer les guards réels, préfixe de fixture partagé entre fichiers, test qui écrit/supprime un fichier versionné, singleton module-level dépendant de l'env, rate-limit qui hardcode le rang exact |
|
||||
|
||||
@@ -307,6 +307,21 @@ it('retourne 403 si subscription inactive', async () => {
|
||||
|
||||
- Contexte technique : auth / refresh token — RL799_V2 08-04-2026
|
||||
|
||||
### Complément — rotation du refresh token IdP en BFF : cookie rafraîchi non réécrit → déconnexions erratiques
|
||||
|
||||
Angle distinct mais lié à la rotation : en archi BFF, si le cookie de session rafraîchi (`sessionCookieToApply`) n'est réécrit que par UN seul handler (typiquement `/me`), la rotation du refresh token côté IdP déconnecte les utilisateurs de façon erratique.
|
||||
|
||||
- Les N autres call-sites d'auth déclenchent bien le refresh (access token rafraîchi en mémoire → requête autorisée → 200) mais JETTENT le nouveau cookie.
|
||||
- Tant que l'IdP n'a PAS la rotation activée, c'est inoffensif (l'ancien refresh token reste valide). MAIS si le realm a `Revoke Refresh Token` / rotation activée (durcissement prod COURANT chez Keycloak/Auth0/etc.), chaque refresh INVALIDE l'ancien refresh token côté IdP : le cookie non réécrit garde un refresh token révoqué → la requête suivante échoue → `SESSION_EXPIRED` → re-login forcé.
|
||||
- **Piège** : INVISIBLE en dev (rotation souvent off par défaut), il n'apparaît qu'au déploiement quand un ops active la rotation pour durcir.
|
||||
|
||||
Règles :
|
||||
1. Si la réécriture du cookie n'est pas généralisée à TOUS les handlers (via un wrapper qui attache `sessionCookieToApply` systématiquement), alors NE PAS activer la rotation du refresh token côté realm — et le documenter comme garde-fou de déploiement explicite.
|
||||
2. Inversement, si on veut la rotation (recommandé en sécurité), généraliser la réécriture du cookie AVANT.
|
||||
3. Ne jamais traiter « le refresh marche en dev » comme preuve que la rotation marchera en prod — tester avec la rotation activée.
|
||||
|
||||
- Cas vécu : RL799 K1.5, seul `/me` consomme `sessionCookieToApply`, ~202 autres call-sites l'ignorent ; garde-fou « pas de rotation realm avant généralisation » renvoyé au Lot 6 déploiement — 15-06-2026.
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-drift-auth-copier-coller"></a>
|
||||
@@ -331,6 +346,16 @@ it('retourne 403 si subscription inactive', async () => {
|
||||
|
||||
- Contexte technique : auth / architecture — RL799_V2 08-04-2026
|
||||
|
||||
### Complément — cohérence des filtres d'autorisation entre TOUS les chemins ciblant la même population
|
||||
|
||||
Le drift ne touche pas que les codes d'erreur : il touche aussi les FILTRES appliqués sur la même population résolue à plusieurs endroits.
|
||||
|
||||
- Quand une même population (ex. « les membres actifs d'un grade ») est résolue à plusieurs endroits — un chemin de NOTIFICATION qui filtre `isActive: true` et un chemin d'AUTORISATION qui fait `getUserByEmail` sans filtre `isActive` — la divergence crée une faille : un compte désactivé/démissionnaire avec un JWT encore valide (fenêtre ≤ TTL) n'est pas notifié MAIS peut encore agir.
|
||||
- **Règle** : tout contrôle d'autorisation basé sur un fetch user doit re-vérifier `isActive` à chaque requête (le JWT ne reflète pas une désactivation survenue après émission).
|
||||
- **Audit** : grep des `getUserByEmail` / `findUser*` dans les services, vérifier que chaque usage en contexte d'autorisation filtre/contrôle `isActive`.
|
||||
- **Symptôme de l'incohérence** : « la liste des destinataires d'un effet et la liste des autorisés à le déclencher ne coïncident pas ».
|
||||
- Cas vécu : isolation de réponse aux instructions RL799 — le fetch DB avait été ajouté EXPRÈS pour capter les changements d'état à chaque requête, mais ignorait `isActive`, annulant le bénéfice.
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-auth-acl-unique-champ-sensible"></a>
|
||||
@@ -495,3 +520,53 @@ if (user.deletedAt !== null) {
|
||||
- **Nuance** : un code `ACCOUNT_DELETED` reste acceptable dans un flux `exchange()` OAuth, où le provider a déjà confirmé l'identité (pas d'énumération possible côté attaquant).
|
||||
|
||||
- Contexte technique : auth / soft-delete / anti-énumération — app-alexandrie 13-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-guard-abonnement-vs-droit-acquis"></a>
|
||||
## Guard d'abonnement global vs droits acquis permanents
|
||||
|
||||
### Risques
|
||||
|
||||
- Un guard de gating « abonnement actif » (ex. `RequireSubscriptionActive` / `RequireAccessLevel(FULL)`) posé uniformément sur TOUTES les routes d'un domaine coupe l'accès à un contenu déjà payé en one-shot (« possession à vie ») dès que l'abonnement est résilié
|
||||
- Violation silencieuse d'un invariant métier : « je garde ce que j'ai payé même sans abo »
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Couper l'abonnement rend inaccessible un contenu acheté de façon permanente
|
||||
- Aucun test ne couvre le cas « droit permanent + abo coupé » → régression non détectée
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Avant d'appliquer un guard « abonnement actif » uniformément, distinguer deux natures de droit :
|
||||
- **droit RÉCURRENT** (lié à l'abo : feed, communauté, contenu inclus)
|
||||
- **droit ACQUIS/permanent** (achat one-shot, possession « à vie »)
|
||||
- **Règle** : gater la LECTURE d'un bien acquis par la POSSESSION (helper `canAccess…`), pas par l'abonnement. Réserver le guard abo aux routes d'écriture/progression et aux contenus récurrents.
|
||||
- TOUJOURS écrire un test « bien possédé + abo coupé → lisible » : c'est l'angle mort classique qui laisse passer ce type de régression.
|
||||
|
||||
- Contexte technique : auth / gating abonnement — app-alexandrie 02-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-validite-jeton-vs-duree-acces"></a>
|
||||
## Confondre la validité du JETON d'octroi avec la durée de l'ACCÈS octroyé
|
||||
|
||||
### Risques
|
||||
|
||||
- Un helper d'accès lit le `expiresAt` d'un jeton d'octroi (code de déblocage, lien/token d'invitation) comme SOURCE D'ACCÈS directe
|
||||
- Mais `expiresAt` borne la fenêtre d'ACTIVATION du jeton (ex. 72 h), pas la durée de l'accès octroyé (censé être permanent) → l'accès expire en même temps que le jeton
|
||||
|
||||
### Symptômes
|
||||
|
||||
- L'accès « à vie » expire 72 h après l'émission du code
|
||||
- Bug non détecté par les tests (qui valident le helper tel qu'écrit, pas l'intention)
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Ne JAMAIS faire dépendre la vérification d'accès du `expiresAt` du jeton.
|
||||
- À l'activation, **matérialiser l'accès dans son entité propre** (ex. `UserPack`/possession) et vérifier l'accès via CETTE entité — pas via le jeton.
|
||||
- **Règle** : « le jeton expire, le droit qu'il a créé persiste. »
|
||||
- Test obligatoire : « jeton activé puis expiré → l'accès reste valide ».
|
||||
- **Corollaire** : un helper d'accès ne doit pas « anticiper » un mécanisme pas encore implémenté en lisant un état intermédiaire — il introduit un modèle d'accès parallèle qui diverge du modèle cible (la branche aurait dû passer par `UserPack` dès le départ).
|
||||
|
||||
- Contexte technique : auth / activation vs possession — app-alexandrie 02-06-2026
|
||||
@@ -259,3 +259,55 @@ const emailSchema = z
|
||||
**À auditer projet-wide** : grep tous les schémas avec ce pattern (`.email().toLowerCase().trim()`) et migrer en `.pipe()`.
|
||||
|
||||
- Contexte technique : Zod 4 — RL799_V2 01-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-ac-affichage-champ-contract-zod"></a>
|
||||
## AC d'affichage livré « vert » mais champ absent du contract Zod
|
||||
|
||||
### Risques
|
||||
|
||||
- Un AC métier dit « afficher / truncate / preview de [champ] dans [carte/écran] » mais le champ n'est jamais ajouté au schéma Zod public correspondant → le user ne le voit jamais
|
||||
- Le service backend peut même charger le champ depuis la DB (`select: { bio: true }`) puis le jeter au mapping de réponse → invisible
|
||||
- Ni le typage ni les tests unit ne détectent l'absence : la code review est le seul filet
|
||||
|
||||
### Symptômes
|
||||
|
||||
- AC d'affichage livré « tout vert » (tests/typecheck/lint passent) mais l'écran ne montre rien
|
||||
- Variante : champ rendu côté UI mais jamais transmis par l'API → `undefined`/`null` silencieux à l'écran
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Quand un AC mentionne « afficher / truncate / preview de [champ] dans [carte/écran] », vérifier la chaîne complète :
|
||||
|
||||
1. **Le champ existe dans le schéma Zod public** (ex : AC « carte annuaire affiche bio truncate 80 char » → `DirectoryUserSchema` doit avoir `bio: z.string().nullable()`).
|
||||
2. **Le service backend l'expose dans le mapping de réponse** (pas seulement dans le `select` Prisma).
|
||||
3. **Le composant UI lit le champ.**
|
||||
|
||||
Le contrat est la barrière minimale : si le champ n'y est pas, l'AC ne peut pas être satisfait.
|
||||
|
||||
- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 28-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-schema-par-audience-pas-par-entite"></a>
|
||||
## Schéma par audience, pas par entité : vue admin réutilise le schéma public
|
||||
|
||||
### Risques
|
||||
|
||||
- Une vue ADMIN/back-office réutilise (ou `extend`) le schéma de la vue PUBLIQUE/utilisateur de la même entité → elle hérite d'un schéma qui masque délibérément les champs de gestion
|
||||
- L'admin ne peut pas distinguer les états (ex : leçons DRAFT vs PUBLISHED) car le contrat ampute l'info structurante (`status`, `body`, timestamps, flags internes)
|
||||
- Le service inclut correctement les données (pas de filtre status) mais le CONTRAT les supprime — bug invisible au typecheck et au test
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Un détail admin `extend`/réutilise un schéma existant qui est en réalité la variante de rendu front
|
||||
- Un test qui ne peut asserter que la **présence** d'un élément, jamais son **état** (le champ d'état n'existe pas dans le schéma)
|
||||
- AC « prévisualiser avant publication » / « back-office » non satisfait alors que tout est vert
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Une vue admin/back-office et une vue publique de la MÊME entité ne partagent PAS le schéma de réponse par défaut : l'admin a besoin des champs de gestion (status, body brut, timestamps, flags) que la vue publique masque.
|
||||
- **Réflexe de revue** : quand un détail admin réutilise un schéma, vérifier que c'est bien la variante ADMIN (porte le statut/les champs éditoriaux), pas la variante de rendu front.
|
||||
|
||||
- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 04-06-2026
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,7 +167,27 @@ if (user.sessionStatus === 'BLOCKED') throw new HttpException(...);
|
||||
- Tester immédiatement `GET /`, l'endpoint OpenAPI et une route publique métier, puis lire la stack runtime **après le premier hit** (pas seulement les logs de bootstrap).
|
||||
- Si un lanceur `tsx watch` est utilisé avec NestJS, vérifier explicitement la compatibilité avec l'injection runtime ; en cas de doute, expliciter les injections critiques avec `@Inject(...)` sur guards et services exposés dès le premier hit.
|
||||
|
||||
- Contexte technique : NestJS / tsx watch / injection — app-alexandrie 01-04-2026
|
||||
#### Cause racine : esbuild n'émet pas `emitDecoratorMetadata`
|
||||
|
||||
Tout runner basé sur esbuild (`tsx watch`, `tsup --watch`, `esbuild-node-runner`...) **n'implémente pas** `emitDecoratorMetadata`. Sans cette metadata, Nest ne sait plus quels types injecter dans les constructeurs `@Injectable()` et tombe dans un fallback **silencieux** : `new Service(undefined, undefined, …)`. Aucun crash au boot ; tous les services à >1 dépendance deviennent inopérants au runtime, le bug n'apparaît qu'au **premier appel** d'une méthode touchant une dépendance injectée.
|
||||
|
||||
Le piège est durable : la CI passe (elle utilise `nest build` = tsc), les e2e passent (AppModule allégé). Le bug ne se voit qu'au navigateur/mobile, sur les endpoints non couverts par e2e. Cas vécu : introduit par bascule `nest start --watch` → `tsx watch src/main.ts` (gain de boot / contournement conflit Prisma v7 ESM/CJS), non détecté 2 mois et demi.
|
||||
|
||||
**Détection en 30 s** : `console.log({ deps: typeof someDep })` dans le constructor du service qui crashe. Si `typeof === 'undefined'` au boot alors que le module l'importe → c'est le bug metadata.
|
||||
|
||||
#### Fix recommandé (Nest 11) : `nest start --watch --builder swc`
|
||||
|
||||
- `pnpm add -D @swc/cli @swc/core`
|
||||
- `.swcrc` avec `jsc.transform.legacyDecorator: true`, `jsc.transform.decoratorMetadata: true`, `module.type: "commonjs"` (préserve decorators + metadata)
|
||||
- `nest-cli.json` : `"compilerOptions": { "builder": "swc", "typeCheck": true }` (le `typeCheck: true` relance `tsc --noEmit` en parallèle pour garder la sécurité types)
|
||||
- `package.json` : conserver `"start:dev": "nest start --watch"` (le `nest-cli.json` prend le relais pour le builder)
|
||||
- Si pnpm bloque le binaire natif SWC : `onlyBuiltDependencies: ['@swc/core']` dans `pnpm-workspace.yaml`
|
||||
|
||||
Bénéfices : DI nominale, type-check préservé, build ~10× plus rapide que tsc seul (≈190 ms pour 153 fichiers), résout au passage le conflit Prisma v7 ESM/CJS.
|
||||
|
||||
**Anti-pattern à proscrire** : plugin esbuild qui prétend réimplémenter `emitDecoratorMetadata` (ex : `@anatine/esbuild-decorators`) — couverture partielle, casse silencieusement sur les types génériques. Sa présence est un signal qu'il faut passer à SWC.
|
||||
|
||||
- Contexte technique : NestJS / tsx watch / esbuild / SWC / injection — app-alexandrie 01-04-2026, complété 26-05-2026
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -177,7 +177,34 @@ Tout modèle tenant-scoped doit avoir les trois :
|
||||
|
||||
- **Checklist review** : vérifier systématiquement que les nouveaux modèles respectent ce guardrail
|
||||
|
||||
- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026
|
||||
### Règle générale — toute FK doit déclarer sa relation Prisma des DEUX côtés
|
||||
|
||||
Le piège n'est pas spécifique à `tenantId`. Tout `xxxId String @map(...)` sans `@relation` correspondante (côté table cible **et** côté table référencée) ne génère **aucune FK SQL**. Prisma ne le détecte pas — il faut un check humain à la review de schéma.
|
||||
|
||||
```prisma
|
||||
// ❌ pas de @relation → pas de FK générée → orphelins possibles
|
||||
model DmConversation {
|
||||
userAId String @map("user_a_id")
|
||||
}
|
||||
model User { /* pas de field DmConversation[] */ }
|
||||
|
||||
// ✅ relation déclarée des deux côtés → FK générée
|
||||
model User {
|
||||
dmConversationsAsA DmConversation[] @relation("DmConversationUserA")
|
||||
dmConversationsAsB DmConversation[] @relation("DmConversationUserB")
|
||||
}
|
||||
model DmConversation {
|
||||
userAId String @map("user_a_id")
|
||||
userBId String @map("user_b_id")
|
||||
userA User @relation("DmConversationUserA", fields: [userAId], references: [id], onDelete: Cascade)
|
||||
userB User @relation("DmConversationUserB", fields: [userBId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
- **Critère review** : tout `xxxId String @map(...)` (y compris les paires de tables de jointure `userAId`/`userBId`) DOIT avoir sa `@relation` paire des deux côtés.
|
||||
- **Bonus index** : Postgres n'indexe pas automatiquement la colonne porteuse de la FK. Dès qu'on filtre dessus (`updateMany({ where: { senderId } })`, purges admin, dashboards), ajouter un `@@index` dédié sur cette colonne — un index composite `(conversation_id, created_at, id)` ne couvre pas un filtre par `sender_id` seul (seq scan sinon).
|
||||
|
||||
- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026 ; app-alexandrie 13-05-2026
|
||||
|
||||
---
|
||||
|
||||
@@ -314,7 +341,29 @@ if (cursor) {
|
||||
|
||||
- **Règle** : ajouter un test unitaire "cursor invalide → 400" sur tout endpoint paginé par cursor
|
||||
|
||||
- Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026
|
||||
### Valider chaque champ typé du cursor décodé, pas seulement sa structure
|
||||
|
||||
Le décodage JSON valide la **structure** (présence des clés) mais pas le **format** des champs typés. Un attaquant peut forger `{"createdAt":"garbage","id":"x"}` : `JSON.parse` réussit → `new Date('garbage') = Invalid Date` → Prisma renvoie un 500 au lieu d'un 400 propre.
|
||||
|
||||
```ts
|
||||
let decoded: { createdAt: string; id: string } | null = null;
|
||||
if (cursor) {
|
||||
try {
|
||||
decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
|
||||
if (!decoded.createdAt || !decoded.id) throw new Error('Champs manquants');
|
||||
// ✅ valider la convertibilité de chaque champ consommé par Prisma
|
||||
if (Number.isNaN(new Date(decoded.createdAt).getTime()))
|
||||
throw new Error('createdAt invalide');
|
||||
// (id UUID : check regex si requis)
|
||||
} catch {
|
||||
throw new BadRequestException({ error: { code: 'INVALID_CURSOR', message: '…' } });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Règle** : pour chaque champ du cursor décodé consommé par Prisma (`new Date()`, `BigInt()`, etc.), valider explicitement la convertibilité avant la query, sinon l'erreur fuit en 500.
|
||||
|
||||
- Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026 ; app-alexandrie 28-05-2026
|
||||
|
||||
---
|
||||
|
||||
@@ -534,7 +583,17 @@ await prisma.X.updateMany(...);
|
||||
- Test d'invariant post-seed obligatoire (cf. pattern dédié)
|
||||
- Si migration 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`, etc.)
|
||||
|
||||
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
|
||||
### Sous-règle — seed Prisma + contracts Zod : `id` auto-généré + validation Zod sortante
|
||||
|
||||
Tout modèle Prisma référencé dans un schéma Zod par `z.string().uuid()` (ex: `DmConversation.id`, `User.id` exposé en `peerUserId`) doit avoir un `id` **auto-généré** par Prisma (`@id @default(uuid())`) dans le seed. **Jamais d'ID lisible** type `seed-user-alice` sur ces modèles : la validation Zod **sortante** les rejette.
|
||||
|
||||
Le piège est silencieux : `prisma.user.create({ data: { id: 'seed-alice' } })` ne plante pas (Postgres ne valide pas le format UUID sur une colonne `text`/`varchar`), mais l'endpoint de listing renvoie un HTTP 400 (`fieldErrors.items: ["Invalid UUID"]`) après `ZodValidationPipe` sur la **response**. Invisible si les e2e mockent `PrismaClient` (les UUID auto-générés sont remplacés par des stubs) — visible uniquement à l'usage réel (mobile/curl).
|
||||
|
||||
- Fixtures : référencer les entités par une **`key` logique stable** (`alice`, `alice-bob`), pas par `id` ; construire un `Map<key, uuid>` après insertion et le propager aux fixtures dépendantes.
|
||||
- Les modèles dont le contract n'exige pas un UUID (Thread, Comment, Mention) peuvent garder des IDs lisibles si utile à la lisibilité des fixtures.
|
||||
- **Test de non-régression** : tout endpoint de listing qui validate sa response via `ZodValidationPipe` + fixture seed doit être testé via un e2e **DB-based** (non mocké) qui hit l'endpoint réel.
|
||||
|
||||
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026 ; app-alexandrie 26-05-2026
|
||||
|
||||
---
|
||||
|
||||
@@ -618,7 +677,15 @@ const resolveDbUrl = (): string | undefined => {
|
||||
|
||||
**Règle générale** : toute stratégie template-based doit auditer le chemin du `DB_URL` à travers les sub-processes de bootstrap. Le bootstrap ouvre une connexion sur la template, mais le seed transitif exécuté via un sub-process peut être sujet à des transformations agressives du DSN qui le redirigent ailleurs.
|
||||
|
||||
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026
|
||||
### Après TOUTE nouvelle migration : droper le template DB de test avant de re-run
|
||||
|
||||
Un template DB construit une fois (`migrate + seed`) puis cloné par worker (`bootstrapTemplate.ensureTemplateReady` / `globalSetup`) est **réutilisé tel quel s'il existe** — il ne détecte PAS qu'une nouvelle migration est apparue. Symptôme trompeur : juste après avoir ajouté une migration `ADD COLUMN`, les tests échouent en `column "x" does not exist` (`PrismaClientKnownRequestError`) alors que `prisma migrate status` dit « up to date » sur la DB dev et que le schema est correct. Cause : le template de test est resté sur l'ancien schéma.
|
||||
|
||||
- **Fix** : droper le template avant la 1ʳᵉ exécution → le `globalSetup` le recrée from scratch (migrate deploy + seed) avec la colonne. Si `psql` indisponible, via client `pg` : `DROP DATABASE <template> WITH (FORCE)`.
|
||||
- **À automatiser idéalement** : faire dépendre la validité du template d'un **hash du dossier `migrations/`** (re-build si le hash change).
|
||||
- Note Prisma 7.x : garde-fou anti-IA sur les actions destructives (`prisma migrate reset` exige un consentement explicite). La cohabitation migration+seed se prouve via le rebuild du template de test, pas besoin de reset la DB dev.
|
||||
|
||||
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026 ; RL799_V2 14-06-2026
|
||||
|
||||
---
|
||||
|
||||
@@ -649,3 +716,185 @@ CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at")
|
||||
- **Règle** : pour une colonne soft-delete nullable à majorité `NULL`, préférer un index partial `WHERE deleted_at IS NOT NULL`.
|
||||
|
||||
- Contexte technique : Prisma / PostgreSQL / index partial — app-alexandrie 13-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-index-partiel-text-alter-enum"></a>
|
||||
## Index partiels avec littéraux text — rejettent `ALTER COLUMN String → enum`
|
||||
|
||||
### Risques
|
||||
|
||||
- Une migration de conversion `String → enum` plante au `ALTER TABLE ... ALTER COLUMN ... TYPE "<Enum>" USING ...` à cause d'un index partiel **historique** dont la clause `WHERE` contient un littéral text.
|
||||
- **Le pré-scan des valeurs DB ne détecte PAS ce piège** : une migration peut passer le pré-scan des orphelins et planter quand même. Coût d'oubli : rollback en urgence + reset du template DB de tests.
|
||||
|
||||
### Symptômes
|
||||
|
||||
```
|
||||
ERROR: operator does not exist: "<EnumName>" = text
|
||||
HINT: No operator matches the given name and argument types.
|
||||
```
|
||||
|
||||
Apparaît au moment de l'`ALTER COLUMN ... TYPE`. La migration est rollée back atomiquement par Postgres (pas d'état hybride), mais bloque le déploiement.
|
||||
|
||||
### Cause racine
|
||||
|
||||
Une migration historique a créé un index partiel avec un **littéral text** dans le `WHERE` :
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX my_index ON table(col) WHERE status = 'active';
|
||||
```
|
||||
|
||||
Quand `status` passe de `text` à `enum`, le littéral `'active'` reste **typé text** → Postgres refuse la conversion car l'opérateur `enum = text` n'est pas défini.
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Encadrer la conversion par DROP/CREATE de l'index (le `CREATE` post-conversion typera automatiquement le littéral en enum) :
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS "my_index";
|
||||
ALTER TABLE "table" ALTER COLUMN "status" TYPE "MyEnum" USING "status"::"MyEnum";
|
||||
CREATE UNIQUE INDEX "my_index" ON "table"("col") WHERE "status" = 'active';
|
||||
```
|
||||
|
||||
**Détection préventive** — avant toute migration enum, grep les index partiels littéraux :
|
||||
|
||||
```bash
|
||||
grep -rn "WHERE.*=.*'" prisma/migrations --include="*.sql" | grep -v "DELETE\|UPDATE"
|
||||
```
|
||||
|
||||
Tout `WHERE col = 'literal'` touchant une colonne candidate à conversion doit être ajouté au DROP/CREATE de la migration. Risque compagnon du pattern `pattern-migration-string-int-enum-sans-downtime` dans `patterns/prisma.md`.
|
||||
|
||||
- Contexte technique : Prisma / PostgreSQL — RL799_V2 05-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-colonne-prisma-jamais-ecrite"></a>
|
||||
## Colonnes Prisma jamais écrites (placeholder / i18n côté contracts)
|
||||
|
||||
### Risques
|
||||
|
||||
- Un champ ajouté à un modèle Prisma "au cas où" mais que le code projette systématiquement depuis une constante en mémoire (côté contracts/schemas) → colonne morte : dette schéma silencieuse, writes ralentis par un index inutile, confusion future ("à quoi sert-elle ?").
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Migration `ADD COLUMN "placeholder_label" TEXT;`
|
||||
- Service : `placeholderLabel: isAutoHidden ? AUTO_HIDE_PLACEHOLDER_LABEL : null` (constante de contracts)
|
||||
- Aucun `update({ data: { placeholderLabel: ... } })` dans le codebase ; colonne toujours NULL en pratique
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Avant d'ajouter une colonne destinée à porter un libellé ou un texte localisable :
|
||||
|
||||
1. Besoin réel `(global, immuable)` → **constante côté contracts**, pas de colonne.
|
||||
2. Besoin `(par-row, configurable plus tard)` → colonne **+** endpoint admin pour la peupler **dès la story** qui l'introduit.
|
||||
3. Jamais "j'ajoute la colonne au cas où une story future en aurait besoin" → YAGNI.
|
||||
|
||||
- **Signal review** : si une colonne du schema n'apparaît dans **aucun** `.create` / `.update` / `.upsert` du codebase, c'est probablement une colonne morte.
|
||||
|
||||
- Contexte technique : Prisma / schema — app-alexandrie 05-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-read-then-write-transition-one-shot"></a>
|
||||
## Read-then-write sur invariant d'unicité / transition one-shot — race condition
|
||||
|
||||
### Risques
|
||||
|
||||
- Vérifier une condition par un `findUnique`/`findFirst`/`SELECT` puis agir par un `update` séparé n'est **pas atomique** sous concurrence. Sous `READ COMMITTED` (défaut Prisma/Postgres), deux requêtes concurrentes passent toutes deux la garde en mémoire avant tout update → double consommation (usage-unique) ou double transition (machine à états "irréversible" devenue ré-écrasable).
|
||||
- Le check applicatif ne protège PAS contre la concurrence (double-clic, retry, multi-onglets).
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Deux ressources créées pour un seul code/ticket usage-unique (2 `UserPack` pour 1 code).
|
||||
- Une transition `open → settled` (ou `draft → published`, `pending → approved`) appliquée deux fois : test `Promise.all([settle(), settle()])` prouve `[200, 200]` au lieu de `[200, 409]`.
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Porter la garde **dans l'écriture** : `updateMany` conditionnel atomique + test de `count`. Le verrou de ligne sérialise les transactions concurrentes ; le perdant voit `count === 0`.
|
||||
|
||||
```typescript
|
||||
// ✅ Consommation usage-unique
|
||||
const { count } = await tx.code.updateMany({
|
||||
where: { id, consumedBy: null }, // garde DANS le WHERE
|
||||
data: { consumedBy: userId },
|
||||
});
|
||||
if (count === 0) throw new ConflictException('ALREADY_USED');
|
||||
|
||||
// ✅ Transition one-shot
|
||||
const { count } = await prisma.proposal.updateMany({
|
||||
where: { id, status: 'open' },
|
||||
data: { status: 'settled', ... },
|
||||
});
|
||||
if (count === 0) /* perdant de la course → 409 */;
|
||||
```
|
||||
|
||||
- La garde en mémoire reste utile en **fail-fast** (évite un round-trip si déjà transité à la lecture), mais ce n'est plus elle qui garantit l'unicité.
|
||||
- Alternative : `isolationLevel: 'Serializable'` + retry, mais l'`updateMany` gardé est plus simple.
|
||||
- **Test obligatoire** : deux opérations concurrentes (`Promise.all`) → exactement une réussit.
|
||||
|
||||
- Contexte technique : Prisma / Postgres / concurrence — app-alexandrie 02-06-2026 ; RL799_V2 (settle proposition d'instruction)
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-unique-plus-index-redondant"></a>
|
||||
## `@@unique` + `@@index` sur la même colonne — index redondant en Postgres
|
||||
|
||||
### Risques
|
||||
|
||||
- Déclarer `@@unique([col])` ET `@@index([col])` (ou `@unique` + `@@index`) sur la même colonne génère **deux** construits SQL : un `CREATE UNIQUE INDEX` et un `CREATE INDEX` normal. Postgres utilise l'index unique pour les lookups — le second ne sert à rien, consomme de l'espace et ralentit toutes les écritures (chaque write tient les deux index à jour).
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Migration générée avec un `CREATE UNIQUE INDEX` et un `CREATE INDEX` sur la même colonne (ex: `season_reports.season_id`).
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- N'ajouter `@@index` que sur des colonnes qui **ne sont pas déjà couvertes** par `@@unique`/`@unique`.
|
||||
- Correction : supprimer `@@index([col])` et générer une migration `DROP INDEX IF EXISTS`.
|
||||
|
||||
- Contexte technique : Prisma / PostgreSQL — RL799_V2 14-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-delete-row-fin-transaction-anonymisation"></a>
|
||||
## DELETE row à la fin d'une transaction d'anonymisation
|
||||
|
||||
### Risques
|
||||
|
||||
- Dans une transaction qui clôture un cycle métier sensible (admission, archivage, anonymisation RGPD) et DELETE une row "pivot" pour purger ses dépendances en cascade (`ON DELETE CASCADE`), placer le DELETE **en milieu** de transaction casse les opérations suivantes :
|
||||
- audit log, projection DTO de retour, side-effects référencent un id qui n'existe plus → `RecordNotFound` ou retour `null` ;
|
||||
- les requêtes post-DELETE peuvent retomber sur un état pré-CASCADE ou retourner des rows liées zombies.
|
||||
|
||||
### Symptômes
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.profane.findUnique({ where: { id } });
|
||||
await tx.profane.delete({ where: { id } }); // ← TROP TÔT
|
||||
await logActionSync(tx, 'enquete:admitted', 'Profane', id, { ... }); // référence un id supprimé
|
||||
return tx.user.create({ data: { ... } });
|
||||
});
|
||||
```
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
DELETE = **dernière opération** de la transaction. Tout ce qui doit lire ou auditer la row se fait avant. Les side-effects post-commit (notifs, `fs.rm`) utilisent des données **capturées avant** le DELETE.
|
||||
|
||||
```typescript
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.profane.findUnique({ where: { id }, include: { rapports: true } });
|
||||
// 1. Lectures, audits, créations dérivées
|
||||
await logActionSync(tx, 'enquete:admitted_purge', 'Profane', id, { profaneId: id, nbRapports: row.rapports.length });
|
||||
const newUser = await tx.user.create({ data: { email: row.email, ... } });
|
||||
// 2. DELETE EN DERNIER (CASCADE balaie Enquete + Rapports + …)
|
||||
await tx.profane.delete({ where: { id } });
|
||||
return newUser;
|
||||
});
|
||||
// Post-commit (hors tx) : fs.rm uploads/enquetes/{enqueteId} en best-effort, sur les paths capturés avant
|
||||
```
|
||||
|
||||
- **DELETE vs SET NULL** : DELETE si la row n'a plus aucune valeur métier post-cycle ; SET NULL/anonymize si la row doit rester pour des liens entrants (ex. `RapportEnquete.enqueteurId = null` quand un enquêteur est remplacé — le rapport reste consultable, le lien à l'auteur est anonymisé).
|
||||
- Toujours capturer les `fileUrl`/`path` **avant** le DELETE pour permettre un `fs.rm` post-commit.
|
||||
- Audit log **avant** DELETE — sinon le `targetId` référence une row inexistante.
|
||||
|
||||
- Contexte technique : Prisma / transactions — RL799_V2 05-05-2026
|
||||
|
||||
@@ -140,4 +140,48 @@ if (compensated === null) {
|
||||
- **Règle** : toujours vérifier le retour du décrément de compensation et loguer explicitement si `null`. Documenter ce choix dans les Dev Notes de la story (comportement intentionnel vs bug).
|
||||
- **Solution robuste** : encapsuler incrément + compensation conditionnelle dans le même pipeline `MULTI/EXEC` ou un script Lua (atomicité garantie), au prix d'une complexité plus élevée.
|
||||
|
||||
- Contexte technique : Redis / NestJS — app-alexandrie 01-04-2026
|
||||
#### Compenser AUSSI quand la transaction DB échoue (pas seulement au dépassement)
|
||||
|
||||
Le même compteur doit être compensé quand l'écriture DB qui suit le quota échoue. Pattern récurrent : `consumeDailyQuota` (incrément Redis) est appelé **avant** une transaction DB. Si la transaction throw, le compteur du user est consommé sans avoir produit le side-effect attendu → quota fantôme. Le piège : la compensation `incrBy(-1)` existante n'est souvent déclenchée **que** sur dépassement de quota, pas sur exception de la transaction.
|
||||
|
||||
```typescript
|
||||
// ❌ si $transaction throw, le compteur reste incrémenté → quota fantôme
|
||||
await consumeDailyQuota({ ..., action: 'dm-message' });
|
||||
const message = await prisma.$transaction(async (tx) => { /* INSERT, peut échouer */ });
|
||||
|
||||
// ✅ compensation systématique sur exception de la transaction
|
||||
await consumeDailyQuota({ ... });
|
||||
try {
|
||||
const message = await prisma.$transaction(...);
|
||||
} catch (err) {
|
||||
await redis.incrBy(quotaKey, -1).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
- **Trade-off** : garder l'ordre `quota → tx → compensation` (et non « tx puis quota ») garantit qu'on ne dépasse pas la limite sous charge concurrente (deux requêtes simultanées qui passeraient toutes deux le check). La compensation sur catch est donc **obligatoire**.
|
||||
- **Règle** : tout flow `consumeDailyQuota` puis écriture DB doit compenser sur exception. Vérifier aussi les autres actions partageant ce flow (`comment`, `post`, `support_ticket`).
|
||||
|
||||
- Contexte technique : Redis / NestJS / Prisma — app-alexandrie 01-04-2026, complété 13-05-2026 (story 10.2)
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-rate-limit-compteur-partage"></a>
|
||||
## Rate-limit à compteur partagé entre endpoints jumeaux
|
||||
|
||||
### Risques
|
||||
|
||||
- Un helper de rate-limit dont la clé Redis omet un discriminant d'endpoint (`quota:${action}:${userId}:${jour}`) fait partager **un unique compteur** à plusieurs endpoints réutilisant le même `action` et le même discriminant (ex : l'IP).
|
||||
- Les seuils respectifs des endpoints perdent tout sens : un excès sur l'un consomme le quota de l'autre. Ex : un excès de `login` bloque un `reset` de mot de passe légitime.
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Un endpoint renvoie 429 alors que son propre seuil n'est pas atteint, à cause du trafic sur un endpoint jumeau.
|
||||
- Le bug est invisible en test : chaque e2e exerce un seul endpoint à la fois, donc le compteur n'est jamais partagé pendant un test.
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- **Règle de clé** : `clé = quota:<action>:<endpoint>:<identité>:<fenêtre>`. La clé DOIT inclure un discriminant d'endpoint, pas seulement l'identité de l'appelant.
|
||||
- **Test de non-régression** : exercer DEUX endpoints jumeaux jusqu'au seuil dans le **même** test — un test mono-endpoint ne révèle jamais ce bug.
|
||||
|
||||
- Contexte technique : Redis / NestJS — app-alexandrie 22-05-2026
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
- Utiliser `subscription.current_period_end` (timestamp) pour la fin de période courante
|
||||
- Ajouter un test sur un événement webhook/Subscription qui vérifie la date persistée
|
||||
|
||||
### Nuance SDK v20 (API 2025-03-31.basil+) : `current_period_end` est par ITEM, plus à la racine
|
||||
|
||||
Depuis le SDK Stripe v20, `Subscription.current_period_end` n'existe **plus** au niveau racine : lire `subscription.items.data[i].current_period_end` (prendre le **max** des items pour borner au plus tard). Symptôme : `currentPeriodEnd` revient systématiquement `null` → un abo « ACTIVE » sans borne de période reste ouvert indéfiniment (abo « zombie ») si un event de renouvellement est manqué. Garde-fou complémentaire : `isActive = status === 'ACTIVE' && currentPeriodEnd != null && currentPeriodEnd > now`. Valider sur un event Stripe réel (l'API effective dépend de la clé/compte).
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-stripe-list-has-more"></a>
|
||||
@@ -114,3 +118,50 @@
|
||||
- Si `processing` détecté (concurrent) : attendre brièvement la transition `processed`, sinon répondre **non-2xx** (force retry provider)
|
||||
- Ne jamais passer à `processed` sans preuve d'un traitement effectif
|
||||
- Contexte technique : Stripe / NestJS — 09-03-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-refund-lie-user-produit"></a>
|
||||
## Remboursement lié à (user, produit) au lieu de la TRANSACTION (PaymentIntent)
|
||||
|
||||
### Risques
|
||||
|
||||
- Un garde-fou « ne pas ré-accorder l'accès si déjà remboursé » identifié par `(userId, productId)` casse le cas « rembourser puis racheter » : l'ancien refund contamine le nouvel achat
|
||||
- Un RACHAT légitime (nouveau paiement, nouveau PaymentIntent) du même produit par le même user est bloqué → client qui re-paie sans accès (perte de revenu + incident)
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Garde-fou d'idempotence/révocation indexé sur `(userId, packId)` plutôt que sur le `paymentIntentId`
|
||||
- Un `RefundRecord` orphelin (refund arrivé avant `completed`) qui bloque tout achat futur du même produit
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Un remboursement concerne UNE transaction précise, pas une relation `(user, produit)` durable. La clé d'un refund et de son garde-fou d'idempotence/ordre = le `paymentIntentId` (ou l'id de charge), **jamais** `(user, produit)`.
|
||||
- Propager le `paymentIntentId` depuis `checkout.session.completed` (`session.payment_intent`) jusqu'au garde-fou.
|
||||
- Corollaire (arrivée désordonnée refund-avant-completed via un `RefundRecord`) : matcher ce record par `paymentIntentId` à la création du `UserPack`, sinon il bloque tout achat futur du même produit.
|
||||
- **Test obligatoire** : achat → refund → rachat (nouveau PI) → accès accordé.
|
||||
|
||||
- Contexte technique : Stripe / refund / webhooks — app-alexandrie 02-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-refund-consommation-visionnage-reel"></a>
|
||||
## Éligibilité « refund si peu consommé » mesurée sur la validation, pas le visionnage réel
|
||||
|
||||
### Risques
|
||||
|
||||
- Une politique de remboursement bornée par la consommation (ex : « < 20 % consommé ») qui mesure un drapeau de VALIDATION EXPLICITE (clic « terminer » → state `COMPLETED`) est contournable
|
||||
- L'utilisateur regarde 100 % du contenu sans cliquer « valider » → `completionPct = 0` → remboursable malgré tout le contenu consommé (open-bar)
|
||||
|
||||
### Symptômes
|
||||
|
||||
- AC qui cite `maxWatchedPct` mais implémentation qui compte `state === 'COMPLETED'`
|
||||
- Divergence d'oracle entre le badge UI « remboursable » et ce que le serveur accepte réellement
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Mesurer la consommation EFFECTIVE (progression vidéo `maxWatchedPct >= seuil`, lecture réelle), JAMAIS un drapeau de validation cliqué par l'utilisateur (`COMPLETED`).
|
||||
- Règle de seuil : leçon vidéo consommée dès `maxWatchedPct >= 90` (seuil de complétion vidéo), leçon texte dès `COMPLETED`.
|
||||
- Garder UNE seule source de mesure partagée par la décision serveur ET le badge UI « remboursable » (sinon divergence d'oracle).
|
||||
|
||||
- Contexte technique : Stripe / refund / consommation contenu — app-alexandrie 04-06-2026
|
||||
|
||||
@@ -121,3 +121,123 @@ pnpm -C apps/api test && pnpm -C apps/api test
|
||||
- Fails aléatoires différents à chaque run : urgent (state corruption)
|
||||
|
||||
- Contexte technique : Vitest / Prisma — RL799_V2 25-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-test-non-regression-rbac-guards-reels"></a>
|
||||
## Test de non-régression d'accès (RBAC) qui ré-encode la table de rôles au lieu d'invoquer les guards réels
|
||||
|
||||
### Risques
|
||||
|
||||
- Un test "filet anti-régression" donne un faux sentiment de sécurité s'il teste une projection inerte de la règle au lieu de la règle appliquée
|
||||
- Cas vécu RL799 : un test "snapshot d'accès" conçu comme "pour chaque rôle, quels sets de rôles le couvrent" ne dépendait QUE du fichier de définition des sets — il restait vert même si un guard ou un call-site changeait (le vrai risque d'une refonte RBAC). La revue adversariale l'a qualifié de "faux filet"
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Le test ne peut PAS échouer si la régression qu'il prétend protéger se produit
|
||||
- Question de détection : "ce test peut-il échouer si la régression que je crains arrive ?" — si non, c'est un faux filet
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Un test censé protéger contre un changement X doit exercer le chemin où X s'applique RÉELLEMENT, pas une reformulation parallèle de la règle
|
||||
- Correctif type : matrice (rôle représentatif × handler représentatif), token forgé par rôle, **appel du handler/guard réel**, assertion `200`/`403`
|
||||
- Ne jamais ré-encoder la table de rôles dans le test : invoquer les guards/handlers de prod
|
||||
|
||||
- Contexte technique : RBAC / API HTTP — RL799_V2
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-prefixe-fixture-unique-par-fichier"></a>
|
||||
## Préfixe de fixture de test partagé entre fichiers — cleanup qui efface la fixture d'un voisin
|
||||
|
||||
### Risques
|
||||
|
||||
- Deux fichiers de tests écrivant dans la même table avec le MÊME préfixe (`RI-TEST-`), dont l'un fait un filet `deleteMany({ ref: { startsWith: 'RI-TEST-' } })` en `afterEach`
|
||||
- Vitest pouvant entrelacer deux fichiers sur un même worker, ce filet peut effacer la fixture VIVANTE de l'autre fichier → flakiness intermittente non déterministe
|
||||
- Bug LATENT de profil "iceberg" : les deux fichiers passent en isolation et même ensemble la plupart du temps, fail sporadique en CI (ex. 404 sur le merge) difficile à diagnostiquer
|
||||
|
||||
### Symptômes
|
||||
|
||||
- 404 / "introuvable" sporadique sur une fixture que le test croyait avoir créée
|
||||
- Deux fichiers `grep`-ables sur le même littéral de préfixe
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- **Préfixe de fixture = UNIQUE par fichier, jamais par domaine/table** : `RI-CRUD-` vs `RI-MERGE-`, pas `RI-TEST-` partagé
|
||||
- Règle générale : un cleanup de test ne doit JAMAIS supprimer au-delà de ce que CE fichier a créé — ni `rm` une racine disque partagée, ni `deleteMany` un pattern qu'un autre fichier peut matcher, ni réutiliser un id du seed
|
||||
- Détection : `grep -rln "<PREFIX>" <test-dir>` doit retourner UN seul fichier par préfixe
|
||||
|
||||
- Contexte technique : Vitest / Prisma — RL799_V2 22-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-test-collision-fichier-versionne"></a>
|
||||
## Test qui écrit/supprime un fichier collisionnant avec un artefact versionné
|
||||
|
||||
### Risques
|
||||
|
||||
- Un test pose une fixture via `writeFile` puis la supprime dans son `finally`, mais en ciblant le nom d'un fichier SEED VERSIONNÉ dans git (ex. `seed-planches-architecture-apprenti.pdf`)
|
||||
- À chaque run de la suite, le fichier seed disparaît du working tree (`git status` → `D`), polluant tous les diffs et risquant d'être commité par erreur
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Un fichier suivi par git réapparaît en `D` (supprimé) dans `git status` après l'exécution d'une suite de tests, sans qu'aucun code applicatif ne le touche
|
||||
- Déroutant : le coupable est un test, pas le code en cours de dev
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Les fixtures posées sur disque portent un nom JETABLE non versionné (préfixe `_fixture-`, `tmp-`, ou sous-dossier `__fixtures__/` git-ignoré), JAMAIS le nom d'un fichier seed/asset commité
|
||||
- Vérifier : `git ls-files <dir>` liste les fichiers versionnés — aucun nom de fixture de test ne doit y figurer
|
||||
- Détection rapide : si `git status` montre une suppression `D` inattendue d'un seed/asset après un run de tests, `grep` le nom exact dans `__tests__/` → le test qui le référence avec un `rm`/`removeFixture` en `finally` est le coupable
|
||||
|
||||
- Contexte technique : Vitest / filesystem — RL799_V2 23-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-test-singleton-module-level-env"></a>
|
||||
## Test consommant un singleton module-level dépendant de l'env — passe "par accident" selon l'ordre des fichiers
|
||||
|
||||
### Risques
|
||||
|
||||
- Un test qui consomme un singleton module-level configuré par l'environnement (transport SMTP, client API, pool DB mémoïsé) sans stubber son propre env ET reset le singleton passe "par accident" selon l'ordre d'exécution des fichiers
|
||||
- Le singleton (`let transport = null` mémoïsé au 1er `getTransport()`) est partagé entre fichiers d'un même worker : un fichier A pose `SMTP_HOST` + construit le transport, un fichier B qui ne configure RIEN réutilise le transport de A → B "marche" tant que A tourne avant lui
|
||||
- Corollaire : un reset de singleton (`__resetXxxForTests`) placé dans le SETUP GLOBAL via import statique court-circuite les `vi.mock` des autres fichiers — l'`import` charge le module (et sa chaîne, ex. `smtpTransport → nodemailer`) AVANT que les `vi.mock` propres à chaque fichier soient hoistés → module figé sur la vraie dépendance
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Test vert en local, rouge en CI (ou l'inverse) sans changement de code ; `sentCount === 0` au lieu de N, ou appel réseau réel malgré un mock
|
||||
- Ajouter un reset "utile" au setup global casse des dizaines de tests sans rapport (`'failed' !== 'sent'`)
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Tout fichier qui exerce le singleton doit être AUTO-SUFFISANT : stubber l'env requis dans `beforeAll` (`vi.stubEnv('SMTP_HOST', …)` + `vi.unstubAllEnvs()` en `afterAll`) ET reset le singleton dans `beforeEach` (`__resetXxxForTests()`), au POINT D'USAGE (où son propre `vi.mock` est actif), jamais dans le setup global
|
||||
- Ne jamais s'appuyer sur l'état laissé par un fichier voisin
|
||||
- Un reset de singleton va dans le `beforeEach` du/des fichier(s) qui en ont besoin, PAS dans le setup global ; si vraiment transverse, l'importer en différé (`await import('@/lib/...')`) au point d'usage
|
||||
- Le `dryRun`/mode test d'un service ne dérive PAS forcément de `NODE_ENV === 'test'` — vérifier la VRAIE condition (souvent une var dédiée, ex. `MAIL_DRY_RUN === 'true'`)
|
||||
- Après tout changement touchant le fichier de setup, rejouer la suite COMPLÈTE (effet iceberg)
|
||||
|
||||
- Contexte technique : Vitest — RL799_V2 23-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-test-rate-limit-rang-hardcode"></a>
|
||||
## Test de rate-limit qui hardcode le rang exact de la requête bloquée
|
||||
|
||||
### Risques
|
||||
|
||||
- Un test qui hardcode "la Ne requête déclenche le 429" casse silencieusement quand un flag d'environnement (E2E) relève la limite
|
||||
- La limite effective dépend d'un flag (`process.env.E2E === '1' ? 1000 : 20`), mais le test fige le nombre d'itérations
|
||||
|
||||
### Symptômes
|
||||
|
||||
- `for (i=0;i<21;i++) ...; expect(last.status).toBe(429)` passe en local (limite=20) mais casse sous E2E=1 (limite relevée à 1000) — jamais de 429 atteint, rouge en CI E2E
|
||||
- À l'inverse, un test calibré sur la limite E2E serait trop lent/inutile en local
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Boucler jusqu'au PREMIER 429 avec un plafond de sécurité couvrant le régime le plus permissif (`SAFETY_CAP > limite E2E`)
|
||||
- Asserter (a) qu'un 429 a bien été atteint avant le plafond, (b) qu'au moins une requête est passée avant le blocage
|
||||
- Le test reste correct quelle que soit la limite effective et survit à un ajustement de capacité
|
||||
- Alternative : exposer la limite (getter) et dériver le nombre d'itérations — mais boucler-jusqu'au-429 évite de changer l'API de prod pour un test
|
||||
|
||||
- Contexte technique : rate-limit / API HTTP — RL799_V2 23-06-2026
|
||||
|
||||
Reference in New Issue
Block a user