diff --git a/40_decisions_et_archi.md b/40_decisions_et_archi.md
index ce3ec6a..9a851fb 100644
--- a/40_decisions_et_archi.md
+++ b/40_decisions_et_archi.md
@@ -30,6 +30,7 @@ Dernière mise à jour : 2026-03-12
- [Le front-end est un logiciel en production](#decision-frontend-production)
- [Single source of truth des contrats — schémas runtime partagés (Zod) + z.infer (No-DTO)](#decision-contrats-sso-zod)
- [User views — User public par défaut + MeUser explicite](#decision-user-views)
+- [Mono-tenant déployable vs SaaS multi-tenant](#decision-mono-tenant-deployable)
### 2. Infra
@@ -293,6 +294,48 @@ Règles associées :
---
+
+
+## Mono-tenant déployable vs SaaS multi-tenant
+
+- Date : 2026-04-27
+- Statut : Accepted
+- Périmètre : global
+
+### Contexte
+
+Pour une app vendable à plusieurs clients qui fonctionnent chacun individuellement (ex : loges, cabinets, écoles), le choix d'architecture multi-tenant a un impact majeur sur la complexité, la sécurité et le coût opérationnel. Trois architectures possibles, à trancher tôt.
+
+### Options envisagées
+
+| Approche | Description | Pour | Contre |
+| -------- | ----------- | ---- | ------ |
+| **Mono-tenant déployable** | Chaque client = son instance app + DB + uploads + domaine | Pas d'isolation cross-tenant à coder, complexité ÷10, sécurité plus simple | Coût opérationnel par déploiement, pas de cross-tenant report |
+| **SaaS multi-tenant logique** | Une app, une DB, `tenantId` partout | Coût opérationnel ÷N, cross-tenant analytics | Isolation à coder partout (RLS, scoping, tests cross-tenant), risque leak data |
+| **SaaS multi-tenant physique** | Une app, plusieurs DBs (1 par tenant) | Isolation physique, scaling fin | Routing complexe, ops par tenant, migration cross-DB |
+
+### Décision
+
+Pour un projet dont la cible est constituée de clients indépendants qui veulent leur autonomie, **préférer le mono-tenant déployable** (Option 1) tant que :
+
+- pas de feature cross-tenant attendue (pas de "voir les statistiques tous clients")
+- l'admin technique de chaque instance peut être différent
+- la simplicité de la sécurité (pas de scoping `tenantId` partout, pas de RLS Postgres, pas de tests "leak cross-tenant") prime sur l'économie d'opérations
+
+### Justification
+
+- Population cible = clients indépendants qui veulent leur autonomie
+- Sécurité plus simple : pas de scoping multi-tenant à valider partout
+- Conséquence : modélisation des configurations en singleton (cf. `pattern-singleton-db-config-globale` dans `knowledge/backend/patterns/general.md`)
+
+### Conséquences
+
+- Chaque déploiement a sa propre DB, ses propres uploads, son propre domaine
+- Pipeline CI/CD par instance ou via template (cf. `pattern-pipeline-cicd-github-actions-vps` dans `knowledge/backend/patterns/general.md`)
+- Migration vers Option 2 si la cible évolue (back-office central pour toutes les instances) → **réécriture explicite** plutôt que rétro-fit (ajouter `tenantId` partout est non-trivial)
+
+---
+
## 2. Infra
diff --git a/90_debug_et_postmortem.md b/90_debug_et_postmortem.md
index ac68a9c..3e37f10 100644
--- a/90_debug_et_postmortem.md
+++ b/90_debug_et_postmortem.md
@@ -220,3 +220,113 @@ npm install -g @latest
- `npm root -g`
- `npm ls -g --depth=0 ` | npm list -g @openai/codex --depth=0
- --version
+
+---
+
+## Sub-agents Claude Code — `Write` indisponible dans la sandbox `Explore`
+
+### Contexte
+
+Workflow BMAD `testarch-test-review` sur RL799_V2 (24-04-2026) utilisant 4 sub-agents `subagent_type=Explore` pour évaluer 4 dimensions qualité en parallèle. Chaque sub-agent devait écrire un fichier JSON dans `/tmp/`.
+
+### Symptômes
+
+- Les 4 sub-agents ont terminé leur analyse avec succès mais **aucun n'a réussi à écrire son fichier JSON**
+- Messages de retour : *"Je rencontre une limitation d'outillage… je suis en mode READ-ONLY… je génère le rapport directement en texte."*
+
+### Cause
+
+Le sub-agent type `Explore` n'a pas accès à l'outil `Write` dans sa sandbox (spec : "Tools: All tools except Agent, ExitPlanMode, Edit, Write, NotebookEdit"). Non documenté clairement dans les workflows TEA qui demandent pourtant d'écrire en JSON.
+
+### Correctif / règle à retenir
+
+1. **Ne pas demander aux sub-agents `Explore` d'utiliser `Write`** — briefer explicitement "retourne le JSON en bloc dans ta réponse finale"
+2. **L'orchestrateur matérialise** les fichiers de sortie pour le compte des sub-agents
+3. **Alternative** : utiliser `subagent_type=general-purpose` qui a accès à tous les tools (mais plus cher en tokens et moins spécialisé pour l'exploration)
+
+Extrait de brief corrigé pour futur usage :
+
+```
+Ta mission : analyse X dans les fichiers Y.
+Format de sortie : JSON structuré selon le schéma ci-dessous.
+IMPORTANT : retourne le JSON directement dans ta réponse finale, entre blocs ```json```.
+Ne tente pas d'écrire de fichier (Write indisponible dans ta sandbox).
+L'orchestrateur matérialisera le fichier à partir de ton retour.
+```
+
+---
+
+## Effet iceberg en CI — patcher en cascade jusqu'au fond du puits
+
+### Contexte
+
+Quand un fix CI structurant rétablit un pipeline qui foirait depuis longtemps, **plusieurs bugs latents en aval peuvent apparaître en cascade** : ils étaient tous présents avant, juste invisibles parce que le runner s'arrêtait à l'échec amont. Vécu sur RL799_V2 le 30-04 / 01-05-2026, 8 étages d'iceberg fixés en cascade.
+
+### Symptômes
+
+| # | Phase | Symptôme | Cause | Fix |
+|---|---|---|---|---|
+| 1 | CI tests | `Cannot find module '@org/shared'` | `dist/lib` non bâti avant `test:api` | Build workspace en amont |
+| 2 | CI tests | `Module '@prisma/client' has no exported member 'X'` | Client Prisma non généré | Inverser `prisma generate` → `pnpm build` |
+| 3 | CI tests | `Seed incomplet : 0 users / N attendus` | Étape seed manquante | Ajouter `prisma db seed` après `prisma migrate deploy` |
+| 4 | CI tests | ` non configuré (requis hors dev)` | Variable d'env applicative manquante en CI | Définir au bloc `env:` du job |
+| 5 | CI tests | 14×500 sur endpoints qui chiffrent | `ENCRYPTION_KEY` manquante | Idem |
+| 6 | CI tests (PDF) | `Could not find Chrome` | Puppeteer cherche son cache local absent du runner | `PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable` |
+| 7 | CD prod (migrate) | `Cannot find module '/app/scripts/check-node-version.mjs'` | `pnpm run prisma:migrate` appelle un script absent de l'image API | Appel direct du binaire Prisma |
+| 8 | CI tests | Test attend `50,00 €` reçoit `1,19 €` | `waitForNotification` mal scopé (filtre par `type` mais pas par `recipientId`) — masquée par les étages 1-7 | Re-run OU patch chirurgical du `where:` |
+
+Chaque étage masquait le suivant. Aucun n'était nouveau — tous présents avant la session, mais invisibles à cause des étages amont.
+
+### Cause
+
+- **Local ≠ CI** : en local, `dist/` traîne, le client Prisma est généré, la DB est seedée d'une session précédente, le `.env` est complet. Le bug est invisible
+- **Pipeline early-exit** : un échec à l'étape N ne laisse rien tourner aux étapes N+1, N+2, …
+- **Effet additif des sessions** : plus le pipeline est cassé depuis longtemps, plus le code applicatif a évolué sans validation CI
+
+### Correctif / règle à retenir
+
+1. **Validation locale stricte avant push CI structurant** : simuler les conditions CI vierges (`rm -rf node_modules/.prisma packages/*/dist apps/*/.next` + relancer la chaîne complète)
+2. **Lecture honnête des nouveaux failures** : après un fix CI structurant, ne pas présumer que les nouveaux failures sont des régressions du fix. Probablement des bugs latents
+3. **Tableau iceberg** : noter au fil de la session le tableau (étage / symptôme / cause / fix). Ne pas se laisser submerger par "ça casse encore"
+4. **Push après chaque étage** : ne pas attendre d'avoir tout fixé. Chaque fix structurant mérite son commit thématique
+5. **Ne pas stopper trop tôt** : un seul push ne révèle qu'un étage. Tant qu'il y a des bugs latents, le pipeline cassera
+
+### Signal pour repérer un effet iceberg
+
+- Le pipeline était cassé depuis ≥ 1 semaine
+- Le fix d'aujourd'hui touche une étape **précoce** du workflow (install, build, generate, migrate)
+- Les commits récents ont ajouté des features sans valider en CI
+- Sentiment vague de "ça pourrait casser plein d'autres trucs" — c'est probablement vrai
+
+---
+
+## Prisma migrate inclut les diffs cosmétiques (RenameIndex)
+
+### Contexte
+
+`prisma migrate dev --create-only --name add_lodge_settings` peut générer une migration qui contient (1) le changement attendu mais aussi (2) un side-effect cosmétique pré-existant entre le schema Prisma et la DB qui n'avait jamais été nettoyé. RL799_V2 — migration `20260427120920_add_lodge_settings` qui ramassait un `ALTER INDEX … RENAME TO …` orphelin.
+
+### Symptômes
+
+- Migration thématique qui contient un rename d'index sans rapport avec le scope de la story
+- Un dev qui regarde la migration ne comprend pas pourquoi cet `ALTER INDEX` est là
+
+### Options et décision
+
+| Option | Pro | Con |
+|---|---|---|
+| Garder le rename dans la migration thématique avec commentaire | la prochaine `prisma migrate dev` ne re-générera pas ce rename | le commit "thématique" contient un side-effect cosmétique |
+| Retirer le rename | commit propre | la prochaine migration thématique l'inclura à nouveau → piège pour le prochain dev |
+| Migration de cleanup séparée | plus propre | nécessite 2 migrations + 2 PRs |
+
+**Décision recommandée** : option 1 avec commentaire explicite dans le `.sql` :
+
+```sql
+-- RenameIndex (réalignement DB ↔ schema, dérive cosmétique pré-existante)
+ALTER INDEX "tronc_entries_tenue_idx" RENAME TO "tronc_entries_tenue_id_idx";
+```
+
+### Correctif / règle à retenir
+
+- **Préventif** : `prisma migrate diff` régulièrement (CI/CD ou pré-commit) pour détecter la dérive AVANT qu'elle ne pollue une migration thématique
+- **Curatif** : inspecter manuellement le SQL généré par `--create-only` avant de l'appliquer en migration thématique
diff --git a/95_a_capitaliser.md b/95_a_capitaliser.md
index 0995975..0389eb4 100644
--- a/95_a_capitaliser.md
+++ b/95_a_capitaliser.md
@@ -20,6 +20,7 @@ vers les fichiers appropriés :
- `knowledge/n8n/risques/general.md`
- `knowledge/product/patterns/general.md`
- `knowledge/product/risques/
.md`
+- `knowledge/workflow/patterns/general.md`
- `knowledge/workflow/risques/story-tracking.md`
- `10_conventions_redaction.md`
- `40_decisions_et_archi.md`
@@ -37,7 +38,7 @@ Chaque proposition doit suivre ce format :
DATE — PROJET
FILE_UPDATE_PROPOSAL
-Fichier cible : .md | knowledge/backend/risques/
.md | knowledge/workflow/patterns/general.md | knowledge/workflow/risques/story-tracking.md | 10_conventions_redaction.md | 40_decisions_et_archi.md | 90_debug_et_postmortem.md>
Pourquoi :
@@ -75,7 +76,7 @@ Description courte, factuelle, orientée réutilisation.
3. La validation et l'intégration finale dans `Lead_tech`
sont faites **manuellement**.
4. Une fois intégrée, la proposition doit être **supprimée de ce fichier**.
-5. La structure de ce fichier est **restaurée à son état initial** (voir `70_templates/template_a_capitaliser.md`).
+5. La structure de ce fichier est **restaurée à son état initial** (voir `70_templates/tempate_a_capitaliser.md`).
---
diff --git a/knowledge/backend/patterns/README.md b/knowledge/backend/patterns/README.md
index 73c13e2..3d30c72 100644
--- a/knowledge/backend/patterns/README.md
+++ b/knowledge/backend/patterns/README.md
@@ -15,5 +15,6 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique |
| `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement |
| `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe |
-| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents |
-| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable |
+| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL |
+| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable, ordre canonique des gates HTTP, délégation agrégat → endpoint agrégé, anti-énumération DELETE 204, lazy init memoizé, cap LRU par-user, convention dot-notation audit, whitelist explicite audit, singleton DB config, invalidation cache avant mutation, pipeline CI/CD GitHub Actions → VPS |
+| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné, test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe |
diff --git a/knowledge/backend/patterns/async.md b/knowledge/backend/patterns/async.md
index fff315b..98568bd 100644
--- a/knowledge/backend/patterns/async.md
+++ b/knowledge/backend/patterns/async.md
@@ -77,3 +77,177 @@
- Dead-letter ou statut FAILED visible
- Idempotence documentée
- Logs corrélés (requestId/traceId)
+
+---
+
+
+## Pattern : Hooks fire-and-forget après création DB critique
+
+- Objectif : déclencher des hooks secondaires (mail accusé réception, notification, invalidation cache) après une création DB sans bloquer la réponse HTTP au client.
+- Contexte : endpoint POST qui crée une ressource en DB et déclenche en cascade des hooks impliquant des appels réseau (Resend, FCM, Redis cache).
+- Quand l'utiliser : hooks **rapides** (< 1-2 s) qui peuvent vivre dans le même process que la requête HTTP.
+- Quand l'éviter : tâches lourdes (génération PDF, batch envoi sur 100 destinataires) — utiliser un vrai job queue (BullMQ, pg-boss).
+- Avantage :
+ - la 201 part dès la création DB (l'AC critique de la route)
+ - chaque hook logge ses propres échecs sans bloquer le caller
+ - `Promise.allSettled` détaché → robustesse même si un hook futur ajoute un comportement async
+- Limites / vigilance :
+ - dans Next.js 15+, préférer `after()` (cf. `knowledge/backend/patterns/nextjs.md`) qui garantit l'exécution post-réponse même en serverless
+ - `Promise.all` reject au premier échec — `allSettled` attend toutes les promesses
+ - tests : poll DB borné (`waitForX`) plutôt que `setTimeout(50)` (cf. `knowledge/backend/patterns/tests.md`)
+- Validé le : 30-04-2026
+- Contexte technique : Node.js — RL799_V2
+
+### Implémentation
+
+```typescript
+// ✅ La 201 part dès la création DB ; les hooks tournent en parallèle
+const created = await prisma.registration.create({ data });
+
+// Promise.allSettled détaché : ne reject jamais, on capture quand même
+// au cas où le service de log lui-même bug
+void Promise.allSettled([
+ sendAcknowledgmentMail(data.email),
+ notifyObservers(created.id),
+ invalidateCache(`stats:${data.scope}`),
+]).catch((err) => {
+ logger.error({ type: 'hooks', event: 'unexpected_error', err: String(err) });
+});
+
+return jsonResponse(201, { data: created });
+```
+
+### Règles d'utilisation
+
+1. **L'AC critique doit être atteint avant** : la création DB doit réussir (await) — c'est le seul résultat que le client attend.
+2. **Chaque hook doit logger ses propres échecs** : le service mail doit avoir son propre `logger.error` sur status=failed. Le `.catch()` du `Promise.allSettled` est un filet, pas le canal d'audit primaire.
+3. **`Promise.allSettled` (pas `Promise.all`)** : robuste si un hook futur ajoute un comportement asynchrone derrière.
+4. **Côté tests** : helper `waitForX` polling-borné plutôt que `setTimeout(N)` arbitraire.
+
+---
+
+
+## Pattern : Notification fanout fire-and-forget avec filtre grade plancher
+
+- Objectif : notifier N destinataires éligibles (filtrage par grade plancher) après une mutation, sans bloquer la réponse HTTP et sans rollback de la création principale si la notif échoue.
+- Contexte : action métier qui crée une ressource + doit notifier les membres dont le grade ≥ grade plancher de la ressource (`SOIREE_CANCELLED` à tous les membres, `COMMUNICATION_PUBLISHED` aux membres de grade ≥ X, etc.).
+- Quand l'utiliser : fanout multi-rôles avec filtrage métier sur le profil destinataire.
+- Quand l'éviter : si la notif est critique (la ressource ne doit pas exister sans notif) — utiliser une transaction.
+- Avantage :
+ - seuil monotone `gradeRank(member) >= gradeRank(resource)` aligné sur les filtres `list*` consommateurs
+ - exclusion du créateur via `id: { not: userId }` pour éviter de se notifier soi-même
+ - log explicite sur `catch` du fire-and-forget — pas de perte silencieuse
+- Limites / vigilance :
+ - pas de transaction avec la création principale : best-effort, dégradation acceptable
+ - le `linkUrl` doit être rôle-aware (cf. `knowledge/backend/risques/general.md` risque-notif-linkurl-non-role-aware)
+- Validé le : 23-04-2026
+- Contexte technique : Prisma — RL799_V2
+
+### Implémentation
+
+```typescript
+const createResourceNotifications = async (input: {
+ resourceId: string;
+ grade: string; // plancher (seuil monotone)
+ excludeUserId?: string;
+}): Promise => {
+ const thresholdRank = gradeRank(input.grade);
+
+ const recipients = await prisma.user.findMany({
+ where: {
+ isActive: true,
+ role: { in: [...ROLES_ALL_ACTIVE] },
+ id: input.excludeUserId ? { not: input.excludeUserId } : undefined,
+ profile: { is: {} },
+ },
+ select: {
+ id: true,
+ role: true, // pour linkUrl rôle-aware si multi-rôles
+ profile: { select: { grade: true } },
+ },
+ });
+
+ const eligibleIds = recipients
+ .filter((r) => {
+ const g = r.profile?.grade;
+ if (!g) return false;
+ return gradeRank(g) >= thresholdRank;
+ })
+ .map((r) => r.id);
+
+ if (eligibleIds.length === 0) return;
+
+ await prisma.notification.createMany({
+ data: eligibleIds.map((recipientId) => ({
+ type: NotificationType.RESOURCE_CREATED,
+ recipientId,
+ // …
+ linkUrl: ..., // rôle-aware si nécessaire
+ })),
+ });
+};
+
+// Côté handler
+try {
+ const resource = await createResource({ ... });
+ logAction(userId, 'resource:create', ...);
+
+ // Fire-and-forget
+ void createResourceNotifications({
+ resourceId: resource.id,
+ ...minimumDataForNotif,
+ }).catch((err) => {
+ console.error('[resource:create] notification fanout failed:', err);
+ });
+
+ return jsonResponse(201, { data: serialize(resource) });
+} catch {
+ return errorResponse(500, ...);
+}
+```
+
+### Pourquoi un seuil monotone
+
+`gradeRank(member) >= gradeRank(resource)` = "à partir du grade X", aligné sur les filtres `list*` consommateurs. Évite les sélections non-contiguës (A+M sans C) qui sont pénibles à représenter.
+
+---
+
+
+## Pattern : Auto-purge côté vue via fenêtre temporelle SQL
+
+- Objectif : faire porter la rétention courte par le filtre de lecture plutôt que par un cron de purge réelle, quand une donnée a deux publics avec des besoins de rétention différents.
+- Contexte : donnée consultée à long terme côté admin/historique mais utile uniquement sur fenêtre courte côté consommateur final (membre lambda).
+- Quand l'utiliser : 2 publics, rétention courte côté consommateur, rétention longue côté admin, volumétrie raisonnable.
+- Quand l'éviter :
+ - volumétrie très élevée (millions de rows) — finir par un vrai archivage si le volume explose
+ - RGPD / obligations légales de suppression — il faut **vraiment** supprimer la donnée, pas la masquer
+ - données avec coût de stockage significatif (PDF, blobs, logs verbeux) — purge réelle + archivage externe
+- Avantage :
+ - pas de cron à écrire, déployer, monitorer
+ - zéro risque de purge destructive : la donnée reste en DB
+ - rétention courte est **déclarative** (paramètre de query), pas cachée dans un job planifié
+ - l'admin conserve l'accès complet via un autre endpoint
+- Limites / vigilance :
+ - index sur `createdAt` indispensable dès que la table grossit
+- Validé le : 23-04-2026
+- Contexte technique : Prisma / Postgres — RL799_V2
+
+### Implémentation
+
+```typescript
+export const listRecentXxxForMember = async (
+ ...filters,
+ sinceDays = 30,
+) => {
+ const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
+ return prisma.xxx.findMany({
+ where: {
+ ...filters,
+ createdAt: { gte: since },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+};
+```
+
+L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet.
diff --git a/knowledge/backend/patterns/auth.md b/knowledge/backend/patterns/auth.md
index 1c1b767..46edce7 100644
--- a/knowledge/backend/patterns/auth.md
+++ b/knowledge/backend/patterns/auth.md
@@ -342,3 +342,338 @@ Le helper `requireRoleAccess` doit retourner :
- Contexte technique : auth / audit — RL799_V2
---
+
+
+## Pattern : Consume single-use = pas de session implicite, force re-login
+
+- Objectif : éviter qu'un endpoint qui consume un magic link (invitation, reset password) émette une session implicite — défense en profondeur contre l'interception d'email.
+- Contexte : flows magic-link qui aboutissent à un `consume` (set du nouveau mot de passe ou activation du compte).
+- Quand l'utiliser : tous les flows single-use qui touchent au cycle d'authentification.
+- Quand l'éviter : magic-link "passwordless" pur (sans étape de saisie de mot de passe) où le link EST le facteur d'auth.
+- Avantage :
+ - un attaquant qui intercepte le mail obtient un consume, pas une session
+ - cohérence cross-flow : un seul pattern post-consume, surface d'audit réduite
+ - factorisation page consume facilitée (les modes diffèrent par wording, pas par flow)
+- Limites / vigilance :
+ - friction perçue d'un re-login → minime, l'utilisateur vient de saisir son mot de passe
+- Validé le : 28-04-2026
+- Contexte technique : auth / magic-link — RL799_V2
+
+### Règle
+
+Réponse minimale post-consume : `{ data: { ok: true, email } }`. Pas de cookie JWT, pas d'access token. Le frontend redirige vers `/login?email=…` avec bouton "Se connecter" bien placé sur la page de succès.
+
+### Anti-pattern
+
+- "Auto-login pour invitation, re-login pour reset" sous prétexte UX — divergence asymétrique = anti-pattern de sécurité
+- Cookie JWT émis avant que l'utilisateur ait validé qu'il connaît son nouveau mot de passe
+
+---
+
+
+## Pattern : Magic-link consume — mécanique partagée sans fusion table
+
+- Objectif : factoriser la mécanique sécu d'un consume single-use (hash SHA-256, transaction atomique, révocation refresh tokens) sans fusionner les modèles DB des deux domaines.
+- Contexte : projet avec deux flows partageant la même mécanique (invitation magic-link + reset password) mais des objets métier différents (invitation = audit riche, reset = purement technique).
+- Quand l'utiliser : factorisation au niveau **service**, pas au niveau table.
+- Quand l'éviter : un seul flow magic-link dans le projet — pas de factorisation prématurée.
+- Avantage :
+ - audit historique riche pour les invitations (statut traçable)
+ - transaction atomique partagée (rotation/consume)
+ - rate limiter dédié par endpoint sans pollution cross-domaine
+- Limites / vigilance :
+ - duplication contrôlée des modèles DB (acceptable — chaque domaine a son cycle de vie)
+- Validé le : 28-04-2026
+- Contexte technique : auth / magic-link / Prisma — RL799_V2
+
+### Règles d'or
+
+1. **Tokens stockés en hash SHA-256 uniquement** (`tokenHash @unique`). Le raw token n'est transmis que dans l'URL email, jamais persisté. Helper `hashXxxToken(raw: string): string` réutilisable côté service ET tests.
+
+2. **PK technique `id String @id @default(uuid())`**, pas le token comme PK. Évite le couplage PK/sécurité, permet la rotation d'un token sans créer une nouvelle row.
+
+3. **Transaction atomique consume** :
+ - Lock implicite via `findUnique({ where: { tokenHash }, include: { user } })`
+ - Check `status === 'active'`, `consumedAt === null`, `revokedAt === null`, `expiresAt > now`, `user.isActive === true`
+ - UPDATE token `status='consumed', consumedAt=now()`
+ - UPDATE user (password si applicable)
+ - UPDATE refresh_tokens `revokedAt=now()` (force re-login propre)
+ - UPDATE autres tokens actifs `status='revoked'` (anti-réutilisation)
+ - INSERT audit dans la transaction (atomicité stricte)
+
+4. **Retour discriminé** :
+
+```typescript
+export type ConsumeResult =
+ | { ok: true; userId: string; email: string }
+ | { ok: false; reason: 'NOT_FOUND' | 'ALREADY_USED' | 'EXPIRED' | 'REVOKED' | 'USER_INACTIVE' };
+```
+
+Le frontend ne connaît qu'un code générique côté UX (`INVALID_X_TOKEN`) pour ne pas exposer d'oracle.
+
+5. **Rate limiter dédié par endpoint** :
+ - `validate` : 30 req / 15 min / IP (oracle d'énumération)
+ - `consume` : 10 req / 15 min / IP (modifie le mot de passe)
+ - `resend` (admin) : 20 req / heure / admin
+
+6. **Helpers transverses** : `revokeAndIssueXxx(input)` en transaction unique (couplé à un index unique partiel `WHERE status='active'`), `revokeXxxForUser(userId)` (appelé par `setUserActive(false)`), `getLatestXxxByUserIds(userIds[])` batch (anti N+1).
+
+---
+
+
+## Pattern : Sentinelle non-hashable pour user en attente de mot de passe
+
+- Objectif : éviter à la fois un `password: String?` nullable qui casse les chemins login/test/audit qui font tous des `select: { password }`, et un appel scrypt inutile (~100 ms par user invité).
+- Contexte : user créé via invitation qui n'a pas encore défini son mot de passe.
+- Quand l'utiliser : tout flow d'invitation où le user existe en base avant le set du password.
+- Quand l'éviter : projet où le user n'est créé qu'au consume du magic link (pas de placeholder nécessaire).
+- Avantage :
+ - le champ `password` reste `NOT NULL` en DB → aucun chemin code ne casse
+ - `verifyPassword` détecte le préfixe en early-return → 0 ms scrypt
+ - garantie cryptographique (pas conventionnelle) : le préfixe `!` est absent d'une sortie hex
+- Limites / vigilance :
+ - la sentinelle est lisible en clair par un admin DB — acceptable car c'est un placeholder identifié, pas un secret
+- Validé le : 28-04-2026
+- Contexte technique : auth / scrypt — RL799_V2
+
+### Implémentation
+
+```typescript
+// services/.../inviteService.ts
+const buildInvitedPlaceholderPassword = (): string =>
+ `!INVITED_PENDING_${crypto.randomUUID()}`;
+
+await prisma.user.create({
+ data: { email, password: buildInvitedPlaceholderPassword(), ... },
+});
+
+// lib/passwords.ts
+export const verifyPassword = (password: string, stored: StoredPassword): boolean => {
+ if (typeof stored !== 'string' || typeof password !== 'string') return false;
+ if (stored.startsWith('!')) return false; // placeholder réservé, jamais matchable
+ // …logique scrypt habituelle
+};
+```
+
+### Checklist
+
+- [ ] Préfixe `!` (caractère **garanti** absent de l'alphabet hex `0-9a-f`)
+- [ ] UUID embarqué pour l'unicité par user (utile pour debug audit, pas un secret)
+- [ ] Test : `verifyPassword('anything', '!INVITED_PENDING_xxx') === false`
+- [ ] AC dédié : `verifyPassword` rejette en 0 ms observable (pas d'appel scrypt)
+
+---
+
+
+## Pattern : TTL court + bouton resend admin > TTL longue
+
+- Objectif : minimiser la fenêtre d'exposition d'un token sensible sans dégrader l'UX dans les cas marginaux (user en vacances).
+- Contexte : tokens d'authentification sensibles (invitation, reset password) où la tentation est d'allonger la TTL pour couvrir les cas de longue absence.
+- Quand l'utiliser : tout token sensible avec un canal admin disponible pour relancer.
+- Quand l'éviter : tokens où le resend est impossible (signed URLs publiques sans admin).
+- Avantage :
+ - surface d'attaque réduite (token intercepté n'est utilisable que pendant la TTL courte)
+ - granularité opérationnelle : l'admin trace dans l'audit qui demande un resend
+ - invariant "1 active par user" reste applicable (le resend révoque l'ancien et émet un nouveau)
+- Limites / vigilance :
+ - rate limiter sur le bouton resend (20/h/admin) pour éviter le spam involontaire
+ - pas d'extension d'`expiresAt` au resend — émettre un nouveau token, pas patcher l'ancien
+- Validé le : 28-04-2026
+- Contexte technique : auth / magic-link — RL799_V2
+
+### Règles d'or
+
+- TTL en constante explicite côté service (`7 * 24 * 60 * 60 * 1000`), pas en magic number éparpillé
+- Fenêtres recommandées : invitation ≤ 7 jours, reset password ≤ 24 h
+- Audit `xxx.resend` sur le bouton admin + audit `xxx.revoke` (cohérence avec resend qui révoque l'ancien)
+- AC dédié : `expiresAt - createdAt ≈ N * 24h ± 1s` (tolérance fixture)
+
+---
+
+
+## Pattern : Hook `setUserActive(false)` → revoke side-tokens
+
+- Objectif : éviter qu'un user désactivé puisse continuer à consommer un magic link reçu juste avant la désactivation et reprendre la main.
+- Contexte : opération admin de désactivation user dans un projet avec tokens secondaires actifs (refresh tokens, invitations, futurs reset password tokens).
+- Quand l'utiliser : tout endpoint qui transitionne `user.isActive` de `true` à `false`.
+- Quand l'éviter : si la désactivation est purement métier (suspension UI) sans portée auth.
+- Avantage :
+ - défense en profondeur (le checker du consume vérifie aussi `user.isActive` — ceinture + bretelles)
+ - les tokens orphelins ne survivent pas à la désactivation
+- Limites / vigilance :
+ - best-effort tracé : les revokes ne doivent pas bloquer la désactivation (l'admin attend un retour immédiat)
+- Validé le : 28-04-2026
+- Contexte technique : auth / lifecycle user — RL799_V2
+
+### Implémentation
+
+```typescript
+if (!body.isActive) {
+ try {
+ await revokeAllRefreshTokensForUser(userId);
+ } catch (err) {
+ console.error('[admin.users] refresh token revoke failed', err);
+ refreshTokenWarning = '...';
+ }
+ try {
+ await revokeInvitationsForUser(userId);
+ } catch (err) {
+ console.error('[admin.users] invitation revoke failed', err);
+ }
+ // À ajouter quand pertinent : reset password tokens, OAuth states, etc.
+}
+```
+
+### Règles d'or
+
+- **Best-effort tracé**, pas silencieux : `try { ... } catch { console.error(...) }`
+- Warning UX en cas d'échec partiel : la réponse JSON peut contenir un champ optionnel `warning` que le frontend affiche
+- **Double protection consume** : le checker du consume vérifie également `user.isActive === true` — même si le revoke échoue, l'user désactivé ne peut pas consommer
+- AC dédié : "user actif avec invitation pending → admin désactive → consume échoue avec INVALID_TOKEN, pas de session créée"
+
+---
+
+
+## Pattern : Magic link "URL clean" — token signé HMAC + `history.replaceState`
+
+- Objectif : ouvrir une page web qui pré-charge un contexte serveur (soireeId, eventId, inviteId) sans que l'identifiant ne reste visible dans l'URL navigable.
+- Contexte : mail (convocation, invitation, RSVP) avec un bouton qui mène vers une landing page PWA. On veut éviter forward, capture, indexation involontaire.
+- Quand l'utiliser : magic links publics vers une page qui pré-charge un contexte sensible.
+- Quand l'éviter : magic links d'authentification membre — utiliser les patterns auth classiques (refresh + access httpOnly).
+- Avantage :
+ - URL visible côté utilisateur ne contient pas l'identifiant
+ - HMAC garantit l'intégrité (custom claim `purpose` pour rejeter un token signé pour un autre usage)
+ - `sessionStorage` plutôt que `localStorage` → contexte meurt avec l'onglet
+- Limites / vigilance :
+ - `algorithms: ['HS256']` imposé côté `jwt.verify` pour bloquer les "alg: none" attacks
+ - sécurité dépendante de l'isolation cryptographique du secret (cf. pattern dérivation HMAC)
+- Validé le : 30-04-2026
+- Contexte technique : Node.js crypto / Vue / Vite PWA — RL799_V2
+
+### Architecture
+
+```
+Mail HTML
+ ↓ Bouton (GET https://app/visit?t=)
+Landing PWA /visit
+ ↓ JS lit le token, POST /api/.../redeem { token }
+ ↓ Backend valide HMAC + extrait contextId, retourne metadata
+ ↓ JS stocke contextId en sessionStorage
+ ↓ history.replaceState(null, '', '/inscription')
+ ↓ router.replace() pour synchroniser le router SPA
+Page d'inscription (URL clean)
+```
+
+### Backend — signature
+
+```typescript
+import jwt from 'jsonwebtoken';
+
+export function signAccessToken(contextId: string, expiresAt: Date): string {
+ const expSeconds = Math.floor(expiresAt.getTime() / 1000);
+ return jwt.sign(
+ { sub: contextId, purpose: 'rsvp-v1', exp: expSeconds },
+ getDerivedSecret(),
+ { algorithm: 'HS256' },
+ );
+}
+
+export function redeemAccessToken(token: string):
+ | { ok: true; contextId: string }
+ | { ok: false; reason: 'expired' | 'invalid' } {
+ try {
+ const decoded = jwt.verify(token, getDerivedSecret(), {
+ algorithms: ['HS256'],
+ }) as { sub?: string; purpose?: string };
+ if (decoded.purpose !== 'rsvp-v1') return { ok: false, reason: 'invalid' };
+ if (typeof decoded.sub !== 'string') return { ok: false, reason: 'invalid' };
+ return { ok: true, contextId: decoded.sub };
+ } catch (err) {
+ if (err instanceof jwt.TokenExpiredError) return { ok: false, reason: 'expired' };
+ return { ok: false, reason: 'invalid' };
+ }
+}
+```
+
+### Frontend — landing minimaliste
+
+```typescript
+onMounted(async () => {
+ const token = route.query.t as string;
+ if (!token) return showError('Lien incomplet');
+
+ const result = await redeemToken(token);
+ saveSessionContext(result); // sessionStorage
+
+ if (typeof window !== 'undefined' && window.history?.replaceState) {
+ window.history.replaceState(null, '', '/inscription');
+ }
+ await router.replace({ name: 'inscription' });
+});
+```
+
+### Choix d'expiration
+
+- Magic link auth : court (15 min – 24 h), token consommable une fois
+- RSVP événement : long (jusqu'à la date)
+- Invitation one-shot : moyen (7-30 jours), invalidable à l'usage
+
+---
+
+
+## Pattern : Isolation cryptographique — secret dérivé via HMAC
+
+- Objectif : signer plusieurs types de tokens isolés cryptographiquement sans ajouter une nouvelle env var par usage.
+- Contexte : projet avec un `JWT_SECRET` racine (auth membre) qui doit signer un autre type de token (magic link, RSVP, webhook) sans cross-domain attack possible.
+- Quand l'utiliser : besoin d'un secret de signature isolé sans nouvelle clé à provisionner et à tourner.
+- Quand l'éviter : si le projet supporte déjà un système de gestion de clés multiples (KMS, Vault) — utiliser le mécanisme natif.
+- Avantage :
+ - une seule env var racine à provisionner et tourner
+ - HMAC est one-way : un attaquant qui obtient le secret dérivé ne peut pas remonter au racine
+ - reproductible : `(JWT_SECRET, purpose)` → même secret, pas d'état à persister
+ - versionnable via le purpose (`-v1`, `-v2`) : rotation possible en bumpant le purpose
+- Limites / vigilance :
+ - n'est PAS un substitut à la rotation de clés : si `JWT_SECRET` est compromis, tous les secrets dérivés le sont aussi
+ - HKDF est plus rigoureux pour la dérivation formelle ; pour un simple isolement d'usage, `HMAC(secret, purpose)` suffit
+- Validé le : 30-04-2026
+- Contexte technique : Node.js crypto — RL799_V2
+
+### Implémentation
+
+```typescript
+import { createHmac } from 'node:crypto';
+
+const TOKEN_PURPOSE = 'magic-link-v1';
+let cachedSecret: Buffer | null = null;
+
+function getDerivedSecret(): Buffer {
+ if (cachedSecret) return cachedSecret;
+ const root = process.env.JWT_SECRET;
+ if (!root) throw new Error('JWT_SECRET requis');
+ cachedSecret = createHmac('sha256', root).update(TOKEN_PURPOSE).digest();
+ return cachedSecret;
+}
+```
+
+### Test d'isolation
+
+```typescript
+test('rejette un token signé avec un autre purpose', async () => {
+ const tokenAsMember = jwt.sign(
+ { sub: 'x' },
+ process.env.JWT_SECRET!,
+ { algorithm: 'HS256', expiresIn: '15m' },
+ );
+ const result = redeemToken(tokenAsMember);
+ expect(result.ok).toBe(false);
+});
+```
+
+### Applications
+
+- Tokens magic link / RSVP / invitation
+- Signature de webhooks sortants
+- Tokens d'unsubscribe email (lien direct sans login)
+- Tokens d'accès aux ressources publiques limitées dans le temps
+
+---
diff --git a/knowledge/backend/patterns/contracts.md b/knowledge/backend/patterns/contracts.md
index 4c2294e..df08f10 100644
--- a/knowledge/backend/patterns/contracts.md
+++ b/knowledge/backend/patterns/contracts.md
@@ -187,3 +187,268 @@ Quand une fonction crypto travaille en base64 pour la sérialisation, prévoir u
### Signal review
- `buffer.toString('base64')` suivi immédiatement de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile
+
+---
+
+
+## Pattern : Zod `.strict()` systématique sur les schémas de mutation
+
+- Objectif : bloquer la pollution de champs internes via PATCH/POST/PUT en rejetant tout champ supplémentaire non listé dans le schéma.
+- Contexte : tout schéma Zod qui valide un payload de mutation côté API.
+- Quand l'utiliser : systématiquement sur tous les schémas de mutation.
+- Quand l'éviter : schémas de réponse (où l'API est l'émetteur) ou schémas d'enrichissement intentionnel.
+- Avantage :
+ - première ligne de défense contre la pollution de payload (`uploadedBy`, `createdAt`, `isAdmin` injectés par un client malveillant)
+ - rejet à 400 avant d'atteindre Prisma → pas de risque de spread accidentel dans `data: parsed.data`
+- Limites / vigilance :
+ - ne dispense pas de la deuxième ligne de défense : ne JAMAIS spread `parsed.data` directement dans `prisma.update`, construire `data` au champ près
+- Validé le : 20-04-2026
+- Contexte technique : TypeScript / Zod — RL799_V2
+
+### Implémentation
+
+```typescript
+export const updateXxxSchema = z.object({
+ name: z.string().min(1).optional(),
+ status: z.enum(['active', 'inactive']).optional(),
+}).strict();
+```
+
+### Combiné avec le repo
+
+```typescript
+const data: Partial = {};
+if (parsed.data.name !== undefined) data.name = parsed.data.name;
+if (parsed.data.status !== undefined) data.status = parsed.data.status;
+// …jamais `data: parsed.data` brut
+```
+
+### Test à ajouter
+
+```typescript
+test('PATCH .strict() rejette les champs hors-whitelist', async () => {
+ const r = await PATCH(makeReq({ name: 'OK', uploadedBy: 'attacker' }));
+ expect(r.status).toBe(400);
+});
+```
+
+---
+
+
+## Pattern : Rigidification Zod en 2 phases (données d'abord, schémas ensuite)
+
+- Objectif : rigidifier un schéma Zod artificiellement laxiste sans casser la suite de tests en cascade.
+- Contexte : schéma qui accepte une forme large (`z.string().min(1).max(128)`) pour compenser une donnée hétérogène en base (slugs + UUIDs cohabitent), avant d'avoir uniformisé la donnée.
+- Quand l'utiliser : tout chantier de rigidification (`.uuid()`, `.email()`, `.enum()`) sur un champ dont la base contient encore l'ancien format.
+- Quand l'éviter : si la donnée est déjà uniforme — rigidifier directement.
+- Avantage :
+ - diagnostic séparé : si le commit 2 casse un test, on sait que c'est la rigidification, pas la migration
+ - rollback granulaire : on peut rollback la rigidification sans reperdre la migration
+ - revue plus lisible : un reviewer valide indépendamment "migration correcte" puis "rigidification sûre"
+- Limites / vigilance :
+ - tentation de tout faire d'un coup → écarter
+- Validé le : 24-04-2026
+- Contexte technique : Zod / Prisma — RL799_V2
+
+### Séquence obligatoire (2 commits séparés)
+
+**Phase 1 — Normalisation des données** :
+- Migrer la base (seed, fixtures, lignes legacy via `prisma migrate`)
+- Adapter tous les consommateurs qui référencent l'ancien format (tests, helpers E2E, scripts admin)
+- Le schéma Zod reste laxiste à ce stade — il accepte les deux formats pendant la transition
+- Ajouter un test d'invariant qui valide que la base ne contient plus que le format cible
+- Commit : `feat(): migration + adaptation tests`
+
+**Phase 2 — Rigidification du schéma** :
+- Remplacer `z.string()` par `z.uuid()` / `z.email()` / `z.enum()` sur les champs concernés
+- Adapter les quelques tests qui reposaient sur l'ancienne sémantique laxiste
+- Vérifier par grep final qu'aucun autre schéma n'a le même pattern laxiste oublié
+- Commit : `feat(): rigidification Zod sur `
+
+### Signaux de dérive
+
+- Schéma avec un commentaire "accepte toute chaîne pour compatibilité avec X" → dette à rigidifier dès que X est migré
+- `.min(1).max(128)` sur un champ conceptuellement UUID/email/enum → forme laxiste en attente de rigidification
+
+---
+
+
+## Pattern : Enum canonique + sous-ensembles nommés (vs flags par usage)
+
+- Objectif : factoriser les règles métier sur une enum partagée par plusieurs domaines fonctionnels sans alourdir l'enum elle-même de flags.
+- Contexte : enum (rôles, statuts, types) qui sert plusieurs usages avec des règles différentes (annuaire, pointage rituel, mandats administratifs).
+- Quand l'utiliser : dès qu'un même `enum.filter(r => …)` apparaît à plusieurs endroits avec une règle métier explicite.
+- Quand l'éviter : si le filtre n'apparaît qu'une fois — laisser inline, l'extraction est prématurée.
+- Avantage :
+ - chaque sous-ensemble a un nom métier explicite — le lecteur comprend sans chercher
+ - les règles sont localisées au point de définition, pas éparpillées en flags
+ - ajouter un usage = ajouter un sous-ensemble, pas modifier la structure de l'enum
+- Limites / vigilance :
+ - les sous-ensembles doivent être typés `readonly Role[]` pour bénéficier du narrowing
+ - propagation côté front ET côté Zod backend (defense-in-depth)
+- Validé le : 21-04-2026
+- Contexte technique : TypeScript / Zod — RL799_V2
+
+### Anti-pattern
+
+```typescript
+// ❌ Flag par usage, multipliable, illisible
+export const OFFICER_ROLES = [
+ { code: 'venerable', label: '...', isRitual: true, isAdmin: true },
+ { code: 'archiviste', label: '...', isRitual: false, isAdmin: true },
+ // … 12 rôles × 3-4 flags
+];
+```
+
+### Pattern correct
+
+```typescript
+export const OFFICER_ROLES = [
+ 'venerable', 'premier-surveillant', /* … */ 'archiviste',
+] as const;
+type OfficerRole = (typeof OFFICER_ROLES)[number];
+
+/** Officiers avec fonction rituelle pendant la tenue (pointage). */
+export const RITUAL_OFFICER_ROLES: readonly OfficerRole[] =
+ OFFICER_ROLES.filter((role) => role !== 'archiviste');
+
+/** Officiers éligibles à un mandat administratif. */
+export const MANDATABLE_OFFICER_ROLES = OFFICER_ROLES;
+```
+
+### Propagation Zod backend
+
+```typescript
+// Le sous-ensemble est utilisé côté front ET côté Zod
+export const tenueOfficerAssignmentSchema = z.object({
+ role: z.enum(RITUAL_OFFICER_ROLES as readonly [OfficerRole, ...OfficerRole[]]),
+});
+// → POST avec role: 'archiviste' = 400, sans duplication de la règle
+```
+
+---
+
+
+## Pattern : Constantes par variant figé + sélecteur enum strict
+
+- Objectif : figer dans le code des règles ou textes versionnés via Git tout en sélectionnant l'implémentation à l'exécution via un champ DB (tenant, pays, juridiction).
+- Contexte : règles métier figées (CGV par juridiction, formats de facture par pays, libellés réglementaires par régulateur) qui doivent rester typées strictement et versionnées via Git, mais sélectionnées au runtime.
+- Quand l'utiliser : préparation multi-variant **avant** d'avoir réellement plusieurs implémentations, OU cas où on veut des diffs visibles dans la PR à chaque modification (texte à autorité).
+- Quand l'éviter : règles métier admin-éditables runtime — ces données appartiennent à la DB, pas au code.
+- Avantage :
+ - une seule source de vérité par variant, typée strictement
+ - étendre l'union à `'A' | 'B'` propage automatiquement la nouvelle option (Zod, UI, tests)
+ - diff visible dans la PR à chaque modification — review éclate sur un mot changé
+- Limites / vigilance :
+ - throw explicite dans le sélecteur (pas de fallback silencieux) — un drift DB doit échouer fort
+ - pour du texte à autorité, préférer `expect(X).toBe(...)` à `toMatchSnapshot` — diff visible vs snapshot file rarement lu
+- Validé le : 28-04-2026
+- Contexte technique : TypeScript / Zod — RL799_V2
+
+### Structure type
+
+```
+packages/shared/src//
+ types.ts ← SupportedXCode union fermée + SUPPORTED_X_CODES tuple runtime
+ .ts ← Constantes du variant A (typées )
+ index.ts ← getXConstants(code) + isSupportedXCode + UnsupportedXError
+```
+
+### Source de vérité unique pour le code
+
+```typescript
+// types.ts
+export type SupportedRiteCode = 'REAA';
+export const SUPPORTED_RITE_CODES = ['REAA'] as const
+ satisfies readonly SupportedRiteCode[];
+```
+
+`SUPPORTED_RITE_CODES` est consommé partout :
+- `z.enum([...SUPPORTED_RITE_CODES] as [...])` côté validation
+- `