From 8f4ac2b033a74520d2e881ff21e8d6ebd1b67a18 Mon Sep 17 00:00:00 2001 From: MaksTinyWorkshop Date: Mon, 23 Mar 2026 20:42:50 +0100 Subject: [PATCH] =?UTF-8?q?capitalisation:=20TOCTOU=20Prisma=20(fusion=20+?= =?UTF-8?q?=20g=C3=A9n=C3=A9ralisation),=20Contracts=20schema=20orphelin,?= =?UTF-8?q?=20Zustand=20optimistic=20update=20sous-listes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fusion entrée TOCTOU : étend l'entrée multi-tenant existante avec le cas général "idempotence / plafond" (check métier hors transaction) — app-alexandrie story 4.6 - Nouvelle entrée : Contracts schema orphelin / type de retour désynchronisé (RequestSchema non importé, type inline au lieu du type contracts) - Nouvelle entrée : Zustand optimistic update sur item absent de la liste principale (fallback sur pinnedThreads / showcasedThreads) Co-Authored-By: Claude Sonnet 4.6 --- 10_backend_risques_et_vigilance.md | 77 ++++++++++++++++++++++++++--- 10_frontend_risques_et_vigilance.md | 35 +++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/10_backend_risques_et_vigilance.md b/10_backend_risques_et_vigilance.md index e9b13db..775ea15 100644 --- a/10_backend_risques_et_vigilance.md +++ b/10_backend_risques_et_vigilance.md @@ -57,7 +57,8 @@ Dernière mise à jour : 23-03-2026 - [TTL Redis quota calculé en heure locale (dérive jusqu'à ±12h)](#risque-ttl-redis-heure-locale) - [Story "completed" avec tâches ❌ auto-déclarées](#risque-story-completed-taches-echec) - [Story "done" sans aucun fichier source dans la File List](#risque-story-done-file-list-vide) -- [Prisma `$transaction` multi-tenant : écriture sans `tenantId` dans le WHERE (TOCTOU)](#risque-prisma-transaction-toctou-tenantid) +- [Prisma `$transaction` : fenêtres TOCTOU (check hors transaction)](#risque-prisma-transaction-toctou-tenantid) +- [Contracts : schema orphelin / type de retour désynchronisé](#risque-contracts-schema-orphelin) - [Prisma OR multi-tenant : `tenantId: null` manquant sur la branche système](#risque-prisma-or-tenantid-null) - [Calcul de `nextOrder` hors transaction (race condition `sortOrder`)](#risque-nextorder-hors-transaction) - [Redirect vers la page désactivée elle-même (boucle infinie feature flags)](#risque-redirect-boucle-infinie) @@ -853,20 +854,23 @@ endOfDay.setHours(23, 59, 59, 999); // dérive selon TZ serveur --- -## Prisma `$transaction` multi-tenant : écriture sans `tenantId` dans le WHERE (TOCTOU) +## Prisma `$transaction` : fenêtres TOCTOU (check hors transaction) ### Risques -- Un pre-check d'appartenance tenant + une `$transaction` avec `update({ where: { id } })` sans `tenantId` crée une fenêtre TOCTOU -- Un bug upstream qui laisse passer un id cross-tenant peut contourner l'isolation +- Un pre-check + une `$transaction` avec un `update` non sécurisé crée une fenêtre TOCTOU +- Deux appels concurrents peuvent tous deux passer le check et agir simultanément +- En multi-tenant : un bug upstream peut permettre une écriture cross-tenant malgré le guard applicatif ### Symptômes -- Vérification préalable OK mais écriture sur une ressource d'un autre tenant possible en race condition -- Le guard applicatif est passé mais la DB n'enforce pas au niveau de l'écriture +- Double action sur un état booléen (ex : double mise en vitrine) si le check n'est pas dans la transaction +- Écriture sur une ressource d'un autre tenant possible en race condition ### Bonnes pratiques / mitigations +**Cas 1 — Multi-tenant : inclure `tenantId` dans chaque écriture** + ```typescript // ❌ Anti-pattern — check OK mais écriture sans tenantId const existing = await prisma.item.findMany({ where: { id: { in: ids }, tenantId } }); @@ -881,9 +885,31 @@ await prisma.$transaction( ``` - Règle : toute écriture Prisma sur une ressource tenant-aware doit inclure `tenantId` dans le WHERE, même dans une transaction précédée d'un check -- Utiliser `updateMany`/`deleteMany` (pas `update`/`delete`) pour inclure `tenantId` sans exception si 0 lignes +- Utiliser `updateMany`/`deleteMany` pour inclure `tenantId` sans exception si 0 lignes -- Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026 +**Cas 2 — Idempotence / plafond : re-check d'état à l'intérieur de la transaction** + +```typescript +// ❌ Anti-pattern : check d'état hors transaction +if (resource.isActive) throw ...; +await prisma.$transaction(async (tx) => { + // resource.isActive a pu changer entre-temps + return tx.resource.update(...); +}); + +// ✅ Pattern correct : check ET update dans la transaction +await prisma.$transaction(async (tx) => { + const current = await tx.resource.findUnique({ where: { id } }); + if (current?.isActive) throw ...; // re-check atomique + const count = await tx.resource.count(...); + if (count >= LIMIT) throw ...; + return tx.resource.update(...); +}); +``` + +- Règle : tout guard métier de type "déjà fait / plafond atteint" doit être vérifié à l'intérieur de la transaction, pas avant + +- Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026 ; NestJS / Prisma — app-alexandrie 23-03-2026 --- @@ -1032,3 +1058,38 @@ async createRessource(...) {} - **Checklist review** : vérifier systématiquement les endpoints admin que `@RequireAdminRole()` est présent - Contexte technique : NestJS / guards metadata — app-alexandrie 23-03-2026 + +--- + + +## Contracts : schema orphelin / type de retour désynchronisé + +### Risques + +- Un `RequestSchema` défini dans `packages/contracts` mais jamais importé dans le controller ni le service mobile → dead code silencieux qui crée une fausse confiance +- Un type de retour inline (`string` brut) à la place du type contracts → désynchronisation silencieuse entre contrat et implémentation + +### Symptômes + +- `grep` du nom du schema ne trouve aucun `import` en dehors de sa définition +- Service retourne `Promise<{ status: string }>` au lieu de `Promise` — le `status` n'est pas validé comme `CurationStatus` +- Endpoints `POST /action` sans body ayant un schema `{ pathParam: string }` — le param vient du path, pas du body + +### Bonnes pratiques / mitigations + +À chaque story qui ajoute des schemas dans `packages/contracts`, vérifier en review : + +1. Chaque `RequestSchema` est utilisé dans un `ZodValidationPipe` (API) ou importé dans le service mobile. +2. Les `ResponseSchema` correspondent au type de retour typé du service (`Promise`, pas un type inline). +3. Les endpoints sans body (`POST /action`) définissent `z.object({})` ou omettent le body schema — ne jamais placer les path params dans le body schema. + +```typescript +// ❌ Anti-pattern — type inline, status non typé +async showcaseThread(...): Promise<{ threadId: string; status: string }> { ... } + +// ✅ Pattern correct — type contracts importé +import type { CurationResponse } from '@app-alexandrie/contracts'; +async showcaseThread(...): Promise { ... } +``` + +- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 23-03-2026 diff --git a/10_frontend_risques_et_vigilance.md b/10_frontend_risques_et_vigilance.md index 37ea2cd..27d9872 100644 --- a/10_frontend_risques_et_vigilance.md +++ b/10_frontend_risques_et_vigilance.md @@ -59,6 +59,7 @@ Dernière mise à jour : 23-03-2026 - [`useTransition` global pour des listes d'items interactifs](#risque-usetransition-global-liste-items) - [`useCallback` inutile quand le callback est wrappé en inline au render](#risque-usecallback-inutile-inline) - [Formulaire React avec `defaultValue` sans `key` prop](#risque-formulaire-defaultvalue-sans-key) +- [Zustand : optimistic update sur item absent de la liste principale](#risque-zustand-optimistic-update-sous-listes) --- @@ -1010,3 +1011,37 @@ const handleToggle = useCallback((id: string) => { ... }, []); // stable ✓ - **Règle** : tout formulaire d'édition réutilisé pour plusieurs entités doit avoir une `key` distincte par entité - Contexte technique : React / Next.js — app-template-resto 21-03-2026 + +--- + + +## Zustand : optimistic update sur item absent de la liste principale + +### Risques + +- Une action admin qui cherche l'item uniquement dans `state.threads` (liste paginée principale) manque les items présents exclusivement dans `state.pinnedThreads` ou `state.showcasedThreads` +- L'optimistic update ne se reflète pas visuellement même si l'appel API a réussi + +### Symptômes + +- L'item mis à jour par une action admin n'apparaît pas dans la nouvelle sous-liste après l'action +- Bug reproductible uniquement quand l'item est épinglé / en vitrine mais pas dans la page courante du flux principal + +### Bonnes pratiques / mitigations + +```typescript +// ❌ Anti-pattern : cherche uniquement dans la liste principale paginée +const target = state.threads.find((t) => t.id === threadId); +// → manque les items présents uniquement dans pinnedThreads / showcasedThreads + +// ✅ Pattern correct : fallback sur toutes les sous-listes du store +const target = + state.threads.find((t) => t.id === threadId) ?? + state.pinnedThreads.find((t) => t.id === threadId) ?? + state.showcasedThreads.find((t) => t.id === threadId); +``` + +- **Règle** : toute action qui opère sur un item pouvant être présent dans plusieurs sous-listes doit chercher dans l'ensemble de ces listes +- Règle complémentaire : ne pas mettre à jour une sous-liste (ex: `pinnedThreads`) lors d'une action qui n'y a pas de rapport (ex: mise en vitrine ne touche pas `pinnedThreads`) + +- Contexte technique : React Native / Zustand — app-alexandrie 23-03-2026