mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 10:03:40 +02:00
f1b783407a
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>
875 lines
46 KiB
Markdown
875 lines
46 KiB
Markdown
---
|
|
title: Backend — Patterns : Général
|
|
domain: backend
|
|
bucket: patterns
|
|
tags: [auth, helpers, architecture, rbac]
|
|
applies_to: [implementation, review, architecture]
|
|
severity: medium
|
|
validated_on: 2026-04-07
|
|
source_projects: [RL799_V2]
|
|
---
|
|
|
|
# Backend — Patterns : Général
|
|
|
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
|
|
|
|
---
|
|
|
|
<a id="pattern-helper-auth-centralise-enrichissable"></a>
|
|
## Pattern : Helper d'authentification centralisé enrichissable
|
|
|
|
- Objectif : éviter la duplication de logique RBAC dans chaque service en centralisant un seul `requireRoleAccess` dans `lib/authHelpers.ts`.
|
|
- Contexte : chaque nouveau service réimplémentait `requireRoleAccess` localement avec des variations subtiles (certains retournaient `{ email }`, d'autres `{ email, role }`).
|
|
- Quand l'utiliser : dès qu'un endpoint nécessite une vérification de rôle ou d'authentification.
|
|
- Validé le : 07-04-2026
|
|
- Contexte technique : backend / RBAC — RL799_V2 epics 7-8-9
|
|
|
|
### Règle
|
|
|
|
- Un seul `requireRoleAccess` dans `lib/authHelpers.ts`, retournant toutes les infos du token utiles (email, role, sub).
|
|
- Quand un service a besoin d'une info supplémentaire : enrichir le helper centralisé, pas le copier.
|
|
- Le retour riche est rétrocompatible : les consommateurs existants utilisent ce dont ils ont besoin via destructuring.
|
|
|
|
### Signal review
|
|
|
|
- Tout service qui importe `verifyToken` directement ET fait son propre check RBAC est suspect de duplication.
|
|
|
|
---
|
|
|
|
<a id="pattern-extension-service-par-type-rbac"></a>
|
|
## Pattern : Extension d'un service existant par type avec RBAC granulaire
|
|
|
|
- Objectif : éviter la duplication de service quand un nouveau besoin est fonctionnellement identique à un service existant mais avec des restrictions d'accès différentes.
|
|
- Contexte : ajout d'un nouveau type de communication (ex: `vm`) avec restrictions RBAC spécifiques dans un service communications existant.
|
|
- Quand l'utiliser : quand le nouveau besoin utilise le même modèle de données et le même CRUD que le service existant, seules les restrictions d'accès changent.
|
|
- Quand l'éviter : quand le modèle de données diverge (champs spécifiques au nouveau type) — dans ce cas, un service dédié est préférable.
|
|
- Validé le : 08-04-2026
|
|
- Contexte technique : backend / architecture — RL799_V2 story 18-7
|
|
|
|
### Règle
|
|
|
|
Préférer étendre le service avec un nouveau type (enum/Set) et ajuster les restrictions RBAC par type, plutôt que dupliquer le service.
|
|
|
|
### Avantages
|
|
|
|
- Un seul repository, un seul endpoint, même format de réponse
|
|
- Tests existants couvrent la non-régression
|
|
- Moins de code à maintenir
|
|
|
|
### Signal review
|
|
|
|
- Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type
|
|
|
|
---
|
|
|
|
<a id="pattern-build-packages-workspace-amont-tests-ci"></a>
|
|
## Pattern : Builder les packages workspace en amont des tests CI (monorepo pnpm)
|
|
|
|
- Objectif : garantir qu'un package workspace compilé (TypeScript → `dist/`) est construit avant que les apps consommatrices lancent leurs tests dans le CI.
|
|
- Contexte : monorepo pnpm où `apps/*` consomme `packages/<lib>` via `"workspace:*"`, et où le `package.json` du package pointe sur un build artifact (`"main": "dist/index.js"`, `"types": "dist/index.d.ts"`).
|
|
- Quand l'utiliser : dans tout pipeline CI/CD qui lance des tests sur les apps consommatrices d'un package compilé.
|
|
- Quand l'éviter : si tous les packages workspace sont consommés en source directement (TS sans build, ex. via `tsx`, `vite-node`, `@swc-node`).
|
|
- Validé le : 30-04-2026
|
|
- Contexte technique : pnpm monorepo / GitHub Actions / Node 22 / TypeScript — RL799_V2
|
|
|
|
### Règle
|
|
|
|
Ajouter une étape `Build workspace packages` entre `pnpm install` et le premier `pnpm run test:*` du pipeline. Préférer un build complet du workspace plutôt qu'un build ciblé : `pnpm -r --filter '<scope>/*' build` (ou `pnpm run build` si un script racine équivalent existe). `pnpm -r` respecte le graphe de dépendances et bâtit les libs avant les apps.
|
|
|
|
### Anti-pattern à éviter
|
|
|
|
Ne pas placer le `build` du package partagé **dans** la commande de tests de ce package (`"test:shared": "pnpm -C packages/shared build && pnpm -C packages/shared test"`). Si le script `test:shared` tourne après `test:api` dans la séquence CI, le build arrive trop tard pour les tests qui consomment `dist/`. Garder le build comme étape CI explicite et amont, et garder `test:<package>` minimaliste.
|
|
|
|
### Signal review
|
|
|
|
- Pipeline CI qui enchaîne `pnpm install` puis directement `pnpm run test:api` (ou équivalent) sans étape de build intermédiaire, alors que le package workspace consommé pointe sur un `dist/` compilé.
|
|
- Bug type : `Cannot find module '<scope>/<package>'` ou `Cannot find module '<scope>/<package>/dist/...'` au démarrage des tests CI, alors que les tests passent en local.
|
|
|
|
### Pourquoi ce bug est silencieux en local
|
|
|
|
En local, `dist/` existe presque toujours (build précédent) — donc les tests passent. Le pipeline CI part d'un environnement vierge et casse. Test de fumée local avant un push CI sensible : `rm -rf packages/<lib>/dist && pnpm run build && pnpm run test:<app>`.
|
|
|
|
---
|
|
|
|
<a id="pattern-ordre-canonique-gates-http"></a>
|
|
## Pattern : Ordre canonique des gates dans un handler HTTP
|
|
|
|
- Objectif : éviter les fuites d'information via les codes HTTP — un user non authentifié ne doit jamais distinguer "ressource n'existe pas" de "ressource existe mais non autorisée".
|
|
- Contexte : tout handler HTTP qui combine validation de payload, authentification, autorisation, vérification d'existence de ressource et gates métier.
|
|
- Quand l'utiliser : systématique sur tout handler HTTP exposé.
|
|
- Quand l'éviter : jamais.
|
|
- Avantage :
|
|
- la sémantique HTTP reste cohérente entre endpoints
|
|
- aucune énumération possible via les codes 4xx
|
|
- Limites / vigilance :
|
|
- test "non auth + payload problématique → 401, pas 400" obligatoire pour verrouiller l'ordre
|
|
- Validé le : 27-04-2026
|
|
- Contexte technique : Next.js / NestJS / API HTTP — RL799_V2
|
|
|
|
### Ordre canonique (du plus permissif au plus restrictif)
|
|
|
|
1. **Parsing du body** (400 VALIDATION_ERROR si malformé)
|
|
2. **Validation du schéma** (400 VALIDATION_ERROR si payload invalide)
|
|
3. **Auth** (401 si non authentifié)
|
|
4. **Autz** (403 si rôle insuffisant)
|
|
5. **Existence ressource** (404 si l'id n'existe pas)
|
|
6. **Gates métier** (400/403 si règle business violée)
|
|
7. **Mutation**
|
|
|
|
### Anti-pattern
|
|
|
|
```typescript
|
|
// ❌ Gate métier AVANT auth — leak l'existence de la route + de la règle
|
|
if (payload.contains_locked_field) return 400 LOCKED;
|
|
const auth = requireAuth(...); // jamais atteint si payload contient le champ
|
|
|
|
// ✅ Auth AVANT gate métier
|
|
const auth = requireAuth(...);
|
|
if (auth instanceof Response) return auth;
|
|
if (payload.contains_locked_field) return 400 LOCKED;
|
|
```
|
|
|
|
### Test associé
|
|
|
|
Ajouter systématiquement un test `non auth + payload problématique → 401, pas 400`. Sans ce test, la régression passe.
|
|
|
|
---
|
|
|
|
<a id="pattern-delegation-endpoint-agrege"></a>
|
|
## Pattern : Délégation au niveau d'un agrégat → endpoint agrégé serveur
|
|
|
|
- Objectif : éviter les cascades de 403 silencieux côté client quand un user "délégué" doit accéder à des entités liées hors de l'agrégat parent.
|
|
- Contexte : user avec un rôle "délégué" rattaché à un agrégat parent (`Soiree.secretaireDeSeanceId`), qui doit pouvoir lire/écrire sur des entités liées au-delà de l'agrégat (tenues précédentes du même grade).
|
|
- Quand l'utiliser : la délégation ouvre l'accès à plusieurs entités liées qui sont rendues ensemble dans une vue unique.
|
|
- Quand l'éviter : si le frontend a vraiment besoin des entités séparément (rare).
|
|
- Avantage :
|
|
- une seule réponse hydrate toute la vue → pas de cascade de 403
|
|
- guard centrale au niveau du parent réutilisée en lecture ET en écriture
|
|
- source de vérité unique de la "fenêtre légitime" (un repo helper)
|
|
- Limites / vigilance :
|
|
- les codes d'erreur restent standards (`403 FORBIDDEN`), pas de codes ad hoc `FORBIDDEN_OUT_OF_DELEGATION_SCOPE`
|
|
- Validé le : 27-04-2026
|
|
- Contexte technique : Next.js / API HTTP — RL799_V2
|
|
|
|
### Le pattern
|
|
|
|
1. **Endpoint agrégé côté serveur** : `GET /api/<parent>/[id]/<vue-agrégée>` qui hydrate en une seule réponse toutes les entités liées dont la vue a besoin.
|
|
2. **Guard centrale au niveau du parent** : `requireAccessForParent(request, parentId, { roleSet })` retourne `{ userId, role, viaDelegation, delegatedParentId? }` ou `Response 403/404`.
|
|
3. **Si la guard sur l'entité enfant doit aussi reconnaître la délégation** (cas d'écriture) : étendre avec un slow-path qui appelle la résolution serveur — *« cet enfant fait-il partie de la fenêtre légitime ouverte par la délégation ? »*. Pas de re-vérification dispersée dans chaque service.
|
|
4. **Single source of truth de la "fenêtre légitime"** : la même fonction de résolution est utilisée par l'endpoint agrégé (lecture) ET par la guard d'écriture.
|
|
|
|
### Ce qu'on évite
|
|
|
|
- Cascade de N requêtes côté client → autant de chances de 403/erreurs silencieuses
|
|
- Logique métier dupliquée dans la guard et dans le service de lecture (dérive garantie)
|
|
|
|
---
|
|
|
|
<a id="pattern-anti-enumeration-delete-204"></a>
|
|
## Pattern : Anti-énumération sur DELETE — 204 systématique
|
|
|
|
- Objectif : empêcher un user authentifié d'énumérer les ressources d'autres users via les codes HTTP du DELETE (204 vs 404 fuite l'existence).
|
|
- Contexte : endpoint DELETE qui révoque/supprime une ressource identifiée par un identifiant connu uniquement de son propriétaire (push endpoint, refresh token, magic link, OAuth state).
|
|
- Quand l'utiliser : la ressource est identifiée par un secret/identifiant non énumérable.
|
|
- Quand l'éviter : ressource identifiée par un ID séquentiel public — fixer d'abord l'autorisation.
|
|
- Avantage :
|
|
- aucune information ne fuit (inconnue / à un autre / déjà révoquée → indistinguables)
|
|
- simplifie le code : pas de branchement 404 vs 204
|
|
- idempotence naturelle (rejouer un DELETE n'a aucun effet observable)
|
|
- Limites / vigilance :
|
|
- garder une validation de format en amont (400 si body malformé) — c'est le format qui ne fuit rien, pas l'existence
|
|
- le filtre SQL DOIT inclure `userId = currentUser` — sinon on révoque la ressource d'un autre user
|
|
- Validé le : 28-04-2026
|
|
- Contexte technique : API HTTP / Prisma — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
export const handleDelete = async (request: Request): Promise<Response> => {
|
|
const auth = requireAuthenticatedUser(request, ROLES);
|
|
if ('status' in auth) return auth;
|
|
|
|
const parsed = bodySchema.safeParse(await request.json());
|
|
if (!parsed.success) {
|
|
return errorResponse(400, 'VALIDATION_ERROR', 'Body invalide');
|
|
}
|
|
|
|
try {
|
|
await revokeByOwner({ userId: auth.userId, ...parsed.data });
|
|
} catch {
|
|
/* logger côté serveur, retourner 204 quand même */
|
|
}
|
|
|
|
// 204 systématique : inconnu / autre user / déjà révoqué → indistinguables
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
|
|
// Repository
|
|
export const revokeByOwner = async (input: {
|
|
userId: string;
|
|
endpoint: string;
|
|
}): Promise<void> => {
|
|
await prisma.subscription.updateMany({
|
|
where: {
|
|
userId: input.userId, // filtre propriétaire OBLIGATOIRE
|
|
endpoint: input.endpoint,
|
|
revokedAt: null, // idempotent
|
|
},
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
};
|
|
```
|
|
|
|
### Anti-patterns
|
|
|
|
- `findUnique` + `if (!sub) return 404` : fuit l'existence
|
|
- `findUnique` + check `userId === auth.userId` + 403 : fuit l'existence (la 403 vs 404 vs 204 distingue)
|
|
- `prisma.delete({ where: { endpoint } })` sans filtre `userId` : un user peut révoquer la sub d'un autre
|
|
|
|
### Tests minimaux
|
|
|
|
DELETE inconnu / DELETE autre user / DELETE déjà révoqué → tous 204, état inchangé pour les autres users.
|
|
|
|
---
|
|
|
|
<a id="pattern-lazy-init-memoize-libs-config-globale"></a>
|
|
## Pattern : Lazy init memoizé pour libs avec config globale
|
|
|
|
- Objectif : initialiser une lib qui exige une config globale (clés API, credentials) seulement au premier appel utile pour permettre feature flag, tests isolés et démarrage gracieux sans env complet.
|
|
- Contexte : libs externes type `web-push.setVapidDetails`, `Stripe(secret)`, `S3Client({ region })`, `Resend(apiKey)`.
|
|
- Quand l'utiliser : lib avec init globale + feature flag possible + tests qui doivent reset l'état.
|
|
- Quand l'éviter : lib qui réclame son init au boot (ex : connexion persistante TCP), libs trivialement instanciables par appel.
|
|
- Avantage :
|
|
- le service ne crash pas au boot si l'env n'est pas encore configuré
|
|
- feature flag respecté de bout en bout (`isEnabled()` court-circuite avant init)
|
|
- tests parallèles peuvent reset l'état via `__resetForTests()`
|
|
- `setX` n'est appelé qu'une fois par process (memoization)
|
|
- Limites / vigilance :
|
|
- state module-level partagé entre tous les imports — bien isoler la lib derrière un service
|
|
- `__resetForTests` doit être gardé par `NODE_ENV === 'test'` pour éviter l'usage en prod
|
|
- Validé le : 28-04-2026
|
|
- Contexte technique : Node.js / SDK avec config globale — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
let initialized = false;
|
|
|
|
const isEnabled = (): boolean =>
|
|
process.env.FEATURE_FLAG === 'true' &&
|
|
Boolean(process.env.API_KEY && process.env.SECRET);
|
|
|
|
const ensureInit = (): boolean => {
|
|
if (initialized) return true;
|
|
if (!isEnabled()) return false;
|
|
externalLib.configure({
|
|
apiKey: process.env.API_KEY!,
|
|
secret: process.env.SECRET!,
|
|
});
|
|
initialized = true;
|
|
return true;
|
|
};
|
|
|
|
export const callExternal = async (input: Input): Promise<void> => {
|
|
if (!ensureInit()) return; // no-op silencieux si flag off ou env manquant
|
|
await externalLib.send(input);
|
|
};
|
|
|
|
/** Reset interne — usage tests uniquement. */
|
|
export const __resetInitForTests = (): void => {
|
|
if (process.env.NODE_ENV !== 'test') return;
|
|
initialized = false;
|
|
};
|
|
```
|
|
|
|
### Checklist
|
|
|
|
- `isEnabled()` vérifie flag ET présence env complète
|
|
- `ensureInit()` retourne booléen, no-op silencieux si non activé
|
|
- `__resetForTests` gardé par `NODE_ENV === 'test'`
|
|
- Pas de log "skipped" en cas de flag off (silencieux par design — c'est le contrat du flag)
|
|
|
|
---
|
|
|
|
<a id="pattern-cap-lru-ressources-par-user"></a>
|
|
## Pattern : Cap LRU sur ressources par-user avec contrainte d'unicité externe
|
|
|
|
- Objectif : empêcher un user (malveillant ou bugué) de générer un nombre illimité de ressources par-user en exploitant l'unicité d'un identifiant externe.
|
|
- Contexte : ressources où chaque insert produit une row unique côté DB (push subscription endpoint, refresh token, device fingerprint, OAuth state).
|
|
- Quand l'utiliser : ressource sans limite naturelle côté usage, où chaque action utilisateur peut créer une nouvelle row.
|
|
- Quand l'éviter : ressource intrinsèquement bornée (1 par user — utiliser une PK composite), ou ressource où l'historique compte (audit, logs).
|
|
- Avantage :
|
|
- cap dur prévisible (10 actives max, p.ex.) — borne supérieure de coût stockage connue
|
|
- LRU eviction naturelle : les anciennes subs (devices oubliés, browsers réinstallés) sont nettoyées automatiquement
|
|
- pas besoin de TTL global, le user peut garder ses N appareils légitimes
|
|
- Limites / vigilance :
|
|
- faire le check + révocation **avant** l'insert, pas après (sinon `unique constraint` violation possible si race)
|
|
- choisir entre `revoked` (soft delete) et `delete` selon les besoins audit
|
|
- le cap doit être au-dessus de l'usage légitime max (10 pour push = laptop + perso + pro + mobile + tablette + marges)
|
|
- Validé le : 28-04-2026
|
|
- Contexte technique : Prisma — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
const MAX_ACTIVE_PER_USER = 10;
|
|
|
|
export const handleCreate = async (userId: string, input: CreateInput) => {
|
|
const active = await prisma.resource.count({
|
|
where: { userId, revokedAt: null },
|
|
});
|
|
|
|
if (active >= MAX_ACTIVE_PER_USER) {
|
|
const oldest = await prisma.resource.findFirst({
|
|
where: { userId, revokedAt: null },
|
|
orderBy: { lastSeenAt: 'asc' },
|
|
select: { id: true },
|
|
});
|
|
if (oldest) {
|
|
await prisma.resource.update({
|
|
where: { id: oldest.id },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
}
|
|
}
|
|
|
|
return prisma.resource.upsert({
|
|
where: { externalKey: input.externalKey },
|
|
create: { userId, ...input },
|
|
update: { userId, revokedAt: null, lastSeenAt: new Date() },
|
|
});
|
|
};
|
|
```
|
|
|
|
### Checklist
|
|
|
|
- [ ] Cap (constante) défini + commenté avec justification du chiffre
|
|
- [ ] Eviction LRU faite **avant** l'insert
|
|
- [ ] `lastSeenAt` bumpé à chaque usage légitime, pas juste à la création
|
|
- [ ] Test : seed cap-1 actives + 1 nouvel insert → cap respecté, plus ancienne révoquée
|
|
|
|
---
|
|
|
|
<a id="pattern-convention-dot-notation-audit"></a>
|
|
## Pattern : Convention dot-notation pour audit events
|
|
|
|
- Objectif : aligner le nommage des audit events avec la convention des outils observables (segment.io, datadog, posthog) qui utilisent tous la dot notation.
|
|
- Contexte : projet avec un `AUDIT_ACTION_CATALOG` ou équivalent listant les actions auditées.
|
|
- Quand l'utiliser : tout nouvel audit event, et migration progressive des events legacy en colon (`document:delete`).
|
|
- Quand l'éviter : projets dont l'outil d'observabilité impose un autre séparateur.
|
|
- Avantage :
|
|
- cohérence inter-outils (segment.io, datadog, posthog)
|
|
- lecture humaine plus fluide (`document.soft_delete` > `document:soft-delete`)
|
|
- multi-niveaux possible (`planche.tronc.admin_override`) sans confusion avec un séparateur de namespace JS
|
|
- Limites / vigilance :
|
|
- migration en PR atomique (call sites + catalog ensemble) pour éviter les events orphelins en filtrage UI
|
|
- Validé le : 20-04-2026
|
|
- Contexte technique : audit / observabilité — RL799_V2
|
|
|
|
### Convention
|
|
|
|
- **Préférer la dot notation** : `<entity>.<action_detail>` (ex : `document.update_metadata`, `document.soft_delete`, `cotisation_payment.created`)
|
|
- **Legacy colon** (`document:delete`) toléré pour rétrocompatibilité — migration encouragée lors du prochain touchement du module concerné
|
|
|
|
### Comment migrer
|
|
|
|
1. Vérifier qu'il n'y a plus de call site (`grep -rn 'document:delete'`)
|
|
2. Retirer l'entrée du `AUDIT_ACTION_CATALOG`
|
|
3. Si des call sites existent, les migrer en même temps que le retrait (PR atomique)
|
|
|
|
---
|
|
|
|
<a id="pattern-whitelist-explicite-audit-fields"></a>
|
|
## Pattern : Whitelist explicite pour audit metadata fields
|
|
|
|
- Objectif : empêcher qu'un futur dev ajoutant un champ secret au schema (`backupPassword`, `apiToken`) ne le voie automatiquement loggé dans le journal d'audit.
|
|
- Contexte : PATCH d'admin qui logge `metadata: { fields: Object.keys(payload) }` pour tracer ce qui a changé.
|
|
- Quand l'utiliser : tout audit qui veut tracer "quels champs ont été modifiés".
|
|
- Quand l'éviter : audit qui ne logge que l'action et l'id cible (pas les champs).
|
|
- Avantage :
|
|
- pas de fuite silencieuse dans le journal d'audit
|
|
- `satisfies readonly (keyof typeof baseShape)[]` garantit que la whitelist ne peut pas contenir de champ inexistant (typo-safe)
|
|
- Limites / vigilance :
|
|
- test "PATCH multi-champs valides → tous présents dans `metadata.fields`" pour vérifier que la whitelist couvre 100 % des champs PATCH-ables légitimes (silence par omission, pas de fuite)
|
|
- Validé le : 27-04-2026
|
|
- Contexte technique : audit / observabilité — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
// packages/shared/src/validation/<entity>Schemas.ts
|
|
export const AUDITABLE_LODGE_SETTINGS_FIELDS = [
|
|
'nameLong',
|
|
'nameShort',
|
|
// … uniquement les champs SAFE à logger
|
|
] as const satisfies readonly (keyof typeof baseShape)[];
|
|
|
|
// Côté service
|
|
const fields = AUDITABLE_LODGE_SETTINGS_FIELDS.filter((k) => k in payload);
|
|
await logActionSync(tx, userId, '<entity>.update', '<entity>', id, { fields });
|
|
```
|
|
|
|
### Test obligatoire
|
|
|
|
```typescript
|
|
test('AUDITABLE_<X>_FIELDS couvre tous les champs PATCH-ables légitimes', () => {
|
|
const allPatchableFields = Object.keys(updateXxxSchema.shape);
|
|
const sensitiveFields = ['secretToken', 'backupPassword'];
|
|
const expected = allPatchableFields.filter((k) => !sensitiveFields.includes(k));
|
|
expect([...AUDITABLE_X_FIELDS]).toEqual(expected);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
<a id="pattern-singleton-db-config-globale"></a>
|
|
## Pattern : Singleton DB pour config globale d'instance
|
|
|
|
- Objectif : stocker une configuration applicative qui doit être éditable runtime, unique pour l'instance, et protégée contre toute création accidentelle de doublon.
|
|
- Contexte : application mono-tenant déployable où chaque instance a sa propre DB et expose une config globale (paramètres de l'instance, branding, identité).
|
|
- Quand l'utiliser : config (a) en DB pour être éditable runtime sans rebuild, (b) unique pour l'instance, (c) protégée par contrainte DB.
|
|
- Quand l'éviter : SaaS multi-tenant — chaque tenant a sa propre row.
|
|
- Avantage :
|
|
- le `CHECK` SQL est la dernière ligne de défense même si un dev contourne le repo
|
|
- le repo centralise le `where: { id: 'singleton' }` — première ligne d'abstraction
|
|
- Limites / vigilance :
|
|
- `@default("singleton")` seul ne suffit pas — un dev peut créer une row avec `id: 'other'`
|
|
- cache mémoire à invalider AVANT mutation (cf. pattern `pattern-invalidation-cache-avant-mutation`)
|
|
- Validé le : 27-04-2026
|
|
- Contexte technique : Prisma / Postgres — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```prisma
|
|
model LodgeSettings {
|
|
id String @id @default("singleton")
|
|
// …champs
|
|
@@map("lodge_settings")
|
|
}
|
|
```
|
|
|
|
Garde-fou SQL au niveau migration (édition manuelle du `.sql` après `prisma migrate dev --create-only`) :
|
|
|
|
```sql
|
|
ALTER TABLE "lodge_settings"
|
|
ADD CONSTRAINT "lodge_settings_singleton_check"
|
|
CHECK (id = 'singleton');
|
|
```
|
|
|
|
Repository centralisé : tout accès passe par `getXxx()` / `updateXxx()` qui prennent un client transactionnel optionnel pour permettre l'atomicité mutation + audit. Jamais de `prisma.xxx.create()` direct depuis ailleurs.
|
|
|
|
### Tests d'intégration
|
|
|
|
```typescript
|
|
test('rejette la création d\'une 2e row', async () => {
|
|
await expect(
|
|
prisma.lodgeSettings.create({ data: { id: 'other', ... } }),
|
|
).rejects.toThrow('lodge_settings_singleton_check');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
<a id="pattern-invalidation-cache-avant-mutation"></a>
|
|
## Pattern : Invalidation cache mémoire AVANT mutation atomique
|
|
|
|
- Objectif : éviter qu'un GET parallèle pendant la transaction re-cache l'ancienne valeur jusqu'à expiration TTL.
|
|
- Contexte : config en cache mémoire (settings, feature flags, catalog) modifiée par une mutation atomique.
|
|
- Quand l'utiliser : tout flow `cache + mutation + audit` où le cache vit dans le même process que la mutation.
|
|
- Quand l'éviter : cache distribué Redis avec `SETEX` — la TTL gère naturellement.
|
|
- Avantage :
|
|
- cohérence forte (au pire, GET parallèle bloque sur fetch DB → trade-off latence acceptable)
|
|
- pas de fenêtre où le client a vu une valeur déjà obsolète après ACK serveur
|
|
- Limites / vigilance :
|
|
- garde-fou `cacheVersion` (compteur incrémenté à chaque `invalidate()`) recommandé : tout fetch en vol capture la version au démarrage et n'écrit le cache que si la version est inchangée au retour
|
|
- Validé le : 27-04-2026
|
|
- Contexte technique : Node.js / cache mémoire — RL799_V2
|
|
|
|
### Séquence sûre
|
|
|
|
```
|
|
1. invalidateCache() // AVANT toute mutation
|
|
2. transaction { // Prisma $transaction
|
|
update(...)
|
|
logActionSync(tx, ...)
|
|
}
|
|
3. return // Le prochain GET re-fetch fresh DB
|
|
```
|
|
|
|
### Pourquoi avant et pas après
|
|
|
|
Si on invalide après mutation :
|
|
1. GET parallèle pendant la transaction hit le cache (ancienne valeur) → return cachedValue
|
|
2. Si la transaction commit, le cache contient l'ancienne valeur jusqu'à TTL
|
|
3. Le client a vu une valeur déjà obsolète après ACK serveur → race condition
|
|
|
|
Si on invalide avant mutation :
|
|
1. Cache vide pendant la transaction
|
|
2. GET parallèle fait un fetch DB (peut renvoyer l'ancienne ou la nouvelle selon timing)
|
|
3. Au pire, GET parallèle bloque sur fetch DB → cohérence forte
|
|
|
|
### Anti-pattern à éviter
|
|
|
|
- Auto-chaînage entre invalidations couplées de caches dont les timings doivent diverger (ex : cache settings DTO à invalider AVANT la mutation, cache logo filesystem à invalider APRÈS pour éviter un re-cache d'un fichier supprimé). Chaque cache expose son `invalidate()` propre, le caller décide explicitement du moment.
|
|
|
|
### TTL recommandé
|
|
|
|
TTL court (60 s) plutôt que long (5 min) : fenêtre de stale plus courte si plusieurs admins éditent.
|
|
|
|
---
|
|
|
|
<a id="pattern-pipeline-cicd-github-actions-vps"></a>
|
|
## Pattern : Pipeline CI/CD GitHub Actions → VPS (compose externe + GHCR + SSH)
|
|
|
|
- Objectif : déployer automatiquement un monorepo Node + Postgres + Docker à chaque merge sur main vers un VPS hébergeant déjà des stacks globales (Traefik + Postgres).
|
|
- Contexte : VPS multi-apps avec Traefik global + Postgres global sur réseaux Docker externes (`traefik`, `stack`). Repo GitHub privé, image GHCR privée, user SSH dédié `deploy` (pas superuser).
|
|
- Quand l'utiliser : projet ≥ 1 app + ≥ 1 service partagé sur un VPS, sans Kubernetes ni service externe (pas de Vercel/Render/Railway).
|
|
- Quand l'éviter : déploiement sur un seul app/VPS dédié (compose simple suffit), ou besoin de blue/green strict (k8s ou Nomad plus adaptés).
|
|
- Avantage :
|
|
- réutilisation des stacks Traefik + Postgres existantes via réseaux externes
|
|
- pas de superuser, juste membre du groupe `docker`
|
|
- migrations exécutées AVANT le redémarrage applicatif (séquence `pull → migrate → up -d`)
|
|
- Limites / vigilance :
|
|
- plan GitHub gratuit pour repos privés : pas de gating manuel possible — compenser par `workflow_dispatch` only sur le job deploy
|
|
- `pnpm run <script>` dans le service `migrate` peut casser si le script dépend de `scripts/` non embarqué dans l'image — appeler les binaires directement
|
|
- Validé le : 02-05-2026
|
|
- Contexte technique : pnpm monorepo / Next.js / Vue / Prisma / Postgres / Docker — RL799_V2
|
|
|
|
### `compose.vps.yml` — compose dédié CI/CD
|
|
|
|
```yaml
|
|
services:
|
|
api:
|
|
image: ${API_IMAGE:?API_IMAGE is required}
|
|
env_file: [.env]
|
|
networks: [app_net, stack_net, traefik_net]
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api`)
|
|
|
|
frontend:
|
|
image: ${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}
|
|
networks: [app_net, traefik_net]
|
|
|
|
migrate:
|
|
image: ${API_IMAGE:?API_IMAGE is required}
|
|
profiles: [ops]
|
|
# Appel DIRECT du binaire Prisma — éviter `pnpm run prisma:migrate` qui
|
|
# exécute scripts/check-node-version.mjs absent de l'image API
|
|
command: ['pnpm', '-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy']
|
|
networks: [stack_net]
|
|
|
|
networks:
|
|
traefik_net:
|
|
external: true
|
|
name: traefik
|
|
stack_net:
|
|
external: true
|
|
name: stack
|
|
```
|
|
|
|
### Workflow CI/CD en 2 jobs
|
|
|
|
- `build-and-push` : `docker buildx` → push image sur GHCR (tag = SHA court)
|
|
- `deploy` : SSH au VPS, `scp compose.vps.yml`, `pull` + `migrate` + `up -d` + healthcheck retries
|
|
|
|
Trigger : `workflow_run` du workflow `Tests` quand vert sur main, OU `workflow_dispatch` manuel.
|
|
|
|
### Secrets GitHub à configurer
|
|
|
|
| Secret | Rôle | Exemple |
|
|
|---|---|---|
|
|
| `VPS_HOST` | hostname réel (pas alias `~/.ssh/config`) | `82.x.x.x` |
|
|
| `VPS_USER` | `deploy` |
|
|
| `VPS_SSH_PORT` | port custom éventuel | `2287` |
|
|
| `VPS_SSH_PRIVATE_KEY` | clé privée multi-lignes | `-----BEGIN OPENSSH...` |
|
|
| `VPS_SSH_KNOWN_HOSTS` | `ssh-keyscan -p <port> <host>` | 3 lignes |
|
|
| `VPS_APP_DIR` | chemin app sur VPS | `/srv/sites/<app>` |
|
|
|
|
### Pièges anticipés
|
|
|
|
1. **`pnpm run <script>` dans le service `migrate`** : si le script appelle un wrapper (`pnpm run env:node`) qui exécute `scripts/check-node-version.mjs`, et que `scripts/` n'est pas embarqué dans l'image API → 500 au boot du job migrate. **Toujours appeler les binaires directement** dans les services Docker.
|
|
|
|
2. **Image GHCR privée + `docker login` côté VPS** : credentials par-user (`~/.docker/config.json`). Faire `docker login` **en tant que `deploy`** via SSH, pas via `sudo -u deploy`.
|
|
|
|
3. **Hostname réel vs alias `~/.ssh/config`** : `VPS_HOST` doit être l'hostname résolu (`ssh -G <alias> | grep ^hostname`), pas l'alias.
|
|
|
|
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.
|