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>
46 KiB
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.mdpour l'index complet.
Pattern : Helper d'authentification centralisé enrichissable
- Objectif : éviter la duplication de logique RBAC dans chaque service en centralisant un seul
requireRoleAccessdanslib/authHelpers.ts. - Contexte : chaque nouveau service réimplémentait
requireRoleAccesslocalement 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
requireRoleAccessdanslib/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
verifyTokendirectement 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/*consommepackages/<lib>via"workspace:*", et où lepackage.jsondu 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 installpuis directementpnpm run test:api(ou équivalent) sans étape de build intermédiaire, alors que le package workspace consommé pointe sur undist/compilé. - Bug type :
Cannot find module '<scope>/<package>'ouCannot 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>.
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)
- Parsing du body (400 VALIDATION_ERROR si malformé)
- Validation du schéma (400 VALIDATION_ERROR si payload invalide)
- Auth (401 si non authentifié)
- Autz (403 si rôle insuffisant)
- Existence ressource (404 si l'id n'existe pas)
- Gates métier (400/403 si règle business violée)
- Mutation
Anti-pattern
// ❌ 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 hocFORBIDDEN_OUT_OF_DELEGATION_SCOPE
- les codes d'erreur restent standards (
- Validé le : 27-04-2026
- Contexte technique : Next.js / API HTTP — RL799_V2
Le pattern
- 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. - Guard centrale au niveau du parent :
requireAccessForParent(request, parentId, { roleSet })retourne{ userId, role, viaDelegation, delegatedParentId? }ouResponse 403/404. - 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.
- 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
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'existencefindUnique+ checkuserId === auth.userId+ 403 : fuit l'existence (la 403 vs 404 vs 204 distingue)prisma.delete({ where: { endpoint } })sans filtreuserId: 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() setXn'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
__resetForTestsdoit être gardé parNODE_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
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èteensureInit()retourne booléen, no-op silencieux si non activé__resetForTestsgardé parNODE_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 constraintviolation possible si race) - choisir entre
revoked(soft delete) etdeleteselon les besoins audit - le cap doit être au-dessus de l'usage légitime max (10 pour push = laptop + perso + pro + mobile + tablette + marges)
- faire le check + révocation avant l'insert, pas après (sinon
- Validé le : 28-04-2026
- Contexte technique : Prisma — RL799_V2
Implémentation
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
lastSeenAtbumpé à 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_CATALOGou é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
- Vérifier qu'il n'y a plus de call site (
grep -rn 'document:delete') - Retirer l'entrée du
AUDIT_ACTION_CATALOG - 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)
- test "PATCH multi-champs valides → tous présents dans
- Validé le : 27-04-2026
- Contexte technique : audit / observabilité — RL799_V2
Implémentation
// 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
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);
});
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
CHECKSQL 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
- le
- Limites / vigilance :
@default("singleton")seul ne suffit pas — un dev peut créer une row avecid: '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
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) :
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
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 + auditoù 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é à chaqueinvalidate()) 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
- garde-fou
- 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 :
- GET parallèle pendant la transaction hit le cache (ancienne valeur) → return cachedValue
- Si la transaction commit, le cache contient l'ancienne valeur jusqu'à TTL
- Le client a vu une valeur déjà obsolète après ACK serveur → race condition
Si on invalide avant mutation :
- Cache vide pendant la transaction
- GET parallèle fait un fetch DB (peut renvoyer l'ancienne ou la nouvelle selon timing)
- 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_dispatchonly sur le job deploy pnpm run <script>dans le servicemigratepeut casser si le script dépend descripts/non embarqué dans l'image — appeler les binaires directement
- plan GitHub gratuit pour repos privés : pas de gating manuel possible — compenser par
- 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
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
-
pnpm run <script>dans le servicemigrate: si le script appelle un wrapper (pnpm run env:node) qui exécutescripts/check-node-version.mjs, et quescripts/n'est pas embarqué dans l'image API → 500 au boot du job migrate. Toujours appeler les binaires directement dans les services Docker. -
Image GHCR privée +
docker logincôté VPS : credentials par-user (~/.docker/config.json). Fairedocker loginen tant quedeployvia SSH, pas viasudo -u deploy. -
Hostname réel vs alias
~/.ssh/config:VPS_HOSTdoit être l'hostname résolu (ssh -G <alias> | grep ^hostname), pas l'alias. -
Permissions dossier
/srv/sites/<app>/: si owner historique = autre user,deployne peut passcp. Solution propre = groupe partagé avecsetgid. -
Healthcheck timeout : si l'API met longtemps à boot (Chromium/Puppeteer lazy load, migrations longues), augmenter au-delà du défaut 60 s.
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
casedans leswitchshared - testable en pure logic (matrice de tests sans Prisma ni mocks)
- allow-list strict : contexte non implémenté =
falsepar défaut (pas de fuite d'autorisation)
- Limites / vigilance :
- défense en profondeur obligatoire : gater AUSSI le listing (
GET /documents?type=...), pas seulement leview, sinon la ressource leak via la liste usageContext String?simple en V1 ; migrer versString[]seulement si un match multiple devient nécessaire (anticiper enString[]est de la sur-ingénierie)- n'envisager un service générique paramétré (
canUserViewContextualResource) qu'à partir du 3ᵉ usage
- défense en profondeur obligatoire : gater AUSSI le listing (
- 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).
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.
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 (canInvestigaten'est pas un rôle). switchéparpillé dans plusieurs handlers : centraliser dans un seul service shared.- Gate uniquement sur
viewsans gater le listing : fuite par la liste.
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 non404: un path hors racine est un bug applicatif/compromission, pas une erreur user — à surveiller via les logs prod - utiliser
path.relative, jamaisString.startsWith(ROOT)(casse avec les liens symboliques) - un blocage Zod en amont est insuffisant seul : Zod valide à l'écriture, pas à la lecture
- retourner
- Validé le : 06-05-2026
- Contexte technique : Node.js / Prisma — RL799_V2
Implémentation
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)puisfs.readFilesans normalisation :'../../../etc/passwd'passe.resolved.startsWith(ROOT): casse avec les symlinks —path.relativeest la bonne API.- "Trust DB" ("c'est moi qui écris") : ignore le bug de migration et l'attaquant à accès SQL direct.
Pattern : Alias d'import @/* — convention et migration en masse
- Objectif : remplacer les imports relatifs profonds (
../../X) par un alias@/Xrésolu depuis la racinesrc/, 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/Xdit où on va,../../../services/Xdit combien on remonte) - linter-friendly (
import/no-relative-parent-imports)
- refactor-friendly : déplacer un fichier dans
- Limites / vigilance :
tsconfig.jsonET 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
// tsconfig.json
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }
// 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) :
- Voisins directs (
./sibling,../siblingun seul niveau). - Fichiers hors
src/(ex.next.config.tsà la racine de l'app) — laisser en relatif + commentaire JSDoc justifiant l'exception. __dirname-relatif dans les.mjs(resolve(here, '../../../src/...')) : c'est un chemin filesystem, pas un import.- 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)
# 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 aussiimport.meta.urlou 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.
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
requireRoleAccessdans 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.6keycloakGuestInvariant.test.ts)
Règles
- 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. - 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. - 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). - 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 unerror.codequi peut être un homonyme métier (ex.INVALID_TOKENdomaine présence ≠INVALID_TOKENKeycloak). NE PASthrowdans le spy (uncatchdu handler pourrait l'avaler et masquer la violation). - 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.
- 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).
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)
- au-delà des invariants classiques (idempotent :
- Validé le : 15-06-2026
- Contexte technique : script ops Node + service externe — RL799_V2 (K1.7
keycloak-migrate)
Règles
- 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). - RAPPORT EXPLOITABLE : la raison d'un
failedne doit JAMAIS êtreerr.nameseul (sur une pannefetch, ça donne "Error", inactionnable). Remontername: messagenettoyé/borné (sans secret) + le statut HTTP des erreurs typées. - 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).
- ACTION SENSIBLE = AUDIT : toute action destructive/sensible (révocation de token, ré-émission d'invitation) trace un audit persistant (
logAction), pas unconsole.logvolatil — 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.
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
// 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.