--- 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. --- ## 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. --- ## 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 --- ## 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/` 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 '/*' 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:` 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 '/'` ou `Cannot find module '//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//dist && pnpm run build && pnpm run test:`. --- ## 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. --- ## 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//[id]/` 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) --- ## 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 => { 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 => { 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. --- ## 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 => { 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) --- ## 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 --- ## 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** : `.` (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) --- ## 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/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, '.update', '', id, { fields }); ``` ### Test obligatoire ```typescript test('AUDITABLE__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); }); ``` --- ## 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'); }); ``` --- ## 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. --- ## 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