--- title: Backend — Risques & vigilance : Auth domain: backend bucket: risques tags: [auth, guards, request-user, sessions, admin, enumeration, soft-delete] applies_to: [implementation, review, debug] severity: high validated_on: 2026-06-25 source_projects: [app-alexandrie, RL799_V2] --- # Backend — Risques & vigilance : Auth > Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet. --- ## AuthN/AuthZ dispersée (contrôles d'accès au fil de l'eau) ### Risques - Règles de permissions incohérentes selon endpoints - Failles "oubliées" sur un endpoint secondaire - Audit impossible ### Symptômes - Utilisateurs qui accèdent à des ressources non prévues - Correctifs en urgence "on ajoute un if ici" - Bugs qui réapparaissent après refactor ### Bonnes pratiques / mitigations - Centraliser authn/authz (middleware/policies) - Tests sur règles critiques - Logs/audit des décisions d'accès --- ## Guard global manquant (request.user jamais peuplé) ### Risques - Chaîne auth bâtie sur une fondation inopérante (tout "a l'air OK" en dev/tests, mais casse en prod) - Guards aval qui dépendent de `request.user` en erreur (ou contournements involontaires) - Découvert tard (souvent uniquement en code review ou en prod) ### Symptômes - `request.user` vaut `undefined` dans un guard supposé "après auth" - Endpoints qui passent alors qu'ils devraient être refusés (si les guards aval se désactivent/retournent true par défaut) - Tests "verts" car trop mockés (pas de test e2e qui valide le pipeline complet) ### Bonnes pratiques / mitigations - Poser explicitement le guard global dès les foundations (au moins `AuthGuard`) - Vérifier l'ordre des `APP_GUARD` (AuthGuard avant tout guard qui lit `request.user`) - Ajouter au minimum 1 test d'intégration/e2e qui prouve que `request.user` est bien peuplé sur un endpoint protégé --- ## Guard NestJS route-level — null-check manquant sur `request.user` ### Risques - Un guard route-level qui lit `request.user.userId` sans null-check lève une `TypeError` (500) si `request.user` est absent - Mauvaise registration de module, test d'intégration mal configuré, ou middleware custom peuvent produire cet état ### Symptômes - `TypeError: Cannot read properties of undefined (reading 'userId')` en prod - Tests "verts" car `request.user` mocké globalement, mais pas le guard isolé ### Bonnes pratiques / mitigations ```typescript const user = (request as any).user as { userId: string } | undefined; if (!user?.userId) { throw new UnauthorizedException({ error: { code: 'UNAUTHENTICATED', message: '...' } }); } ``` - **Règle** : les guards route-level ne font pas confiance aux guards globaux pour leurs invariants — ils se défendent eux-mêmes. - Contexte technique : NestJS v10+ — 09-03-2026 --- ## Suppression du cookie après révocation DB sur logout ### Risques - Si la révocation DB échoue avant la suppression du cookie, l'utilisateur garde un cookie local devenu incohérent - L'utilisateur peut rester bloqué dans un état où il ne peut plus se déconnecter proprement - Le comportement diffère selon la disponibilité de la base ### Symptômes - Logout qui échoue par intermittence quand la DB est instable - Cookie de session toujours présent côté navigateur après erreur serveur - Réessais de logout qui produisent des états difficiles à diagnostiquer ### Bonnes pratiques / mitigations - Toujours supprimer le cookie en premier, même si la révocation DB échoue ensuite - Traiter la suppression côté DB en best-effort ou avec gestion d'idempotence adaptée - Vérifier en test qu'un échec DB ne laisse pas l'accès browser actif - Contexte technique : Next.js / auth par cookie / session persistée — 16-03-2026 --- ## Endpoints GET sans contrôle d'accès sur ressource protégée ### Risques - Un endpoint de lecture expose des données premium/protégées à tout utilisateur authentifié - La règle "seuls les writes vérifient les droits" est un anti-pattern qui cause des fuites silencieuses ### Symptômes - `getCategories`, `getThreads` ou équivalent accessible sans vérification d'entitlements - Endpoint write protégé par `assertForumAccess` mais GET correspondant non protégé ### Bonnes pratiques / mitigations - Tout endpoint retournant des données liées à une ressource protégée (forum pack, contenu premium) doit appeler `assertForumAccess` ou équivalent, même pour les GET - **Checklist review** : pour chaque nouveau GET, vérifier qu'il passe par le guard/helper d'accès si la ressource appartient à un scope protégé - Contexte technique : NestJS / app-alexandrie — 23-03-2026 --- ## NestJS `@UseGuards(AdminRoleGuard)` sans `@RequireAdminRole()` — silencieusement ouvert ### Risques - `AdminRoleGuard.canActivate()` lit la metadata `REQUIRE_ADMIN_ROLE_KEY` posée par `@RequireAdminRole()` - Si le décorateur est absent, `requiresAdmin = false/undefined` → le guard retourne `true` et laisse passer sans vérification ### Symptômes - Endpoint admin accessible à tout utilisateur authentifié - Zéro erreur de compilation ou de démarrage — le bug est silencieux ### Bonnes pratiques / mitigations ```typescript // ✅ Correct — les deux décorateurs ensemble @Post('admin/ressource') @UseGuards(AdminRoleGuard) @RequireAdminRole() async createRessource(...) {} // ❌ Silencieusement non protégé — @RequireAdminRole() manquant @Post('admin/ressource') @UseGuards(AdminRoleGuard) async createRessource(...) {} ``` - Règle : s'applique à tout guard NestJS qui délègue la décision à une metadata de décorateur - **Checklist review** : vérifier systématiquement les endpoints admin que `@RequireAdminRole()` est présent - Contexte technique : NestJS / guards metadata — app-alexandrie 23-03-2026 --- ## Mock Prisma session sans filtre `expiresAt` — divergence test/prod ### Risques - Le mock `session.findFirst` omet de filtrer `expiresAt` → des sessions expirées passent en test alors qu'elles seraient rejetées en prod - Masque des régressions sur la logique d'expiration de session ### Symptômes - Tests e2e verts avec un token de session expiré - Bug découvert uniquement en prod quand la TTL est dépassée ### Bonnes pratiques / mitigations Le mock doit répliquer **tous** les critères de `getUserByToken()` en prod : `revokedAt === null` ET `expiresAt > now` : ```typescript // ✅ Mock complet fidèle à la prod findFirst: jest.fn().mockImplementation(({ where }) => { const session = store[where.accessToken]; if (!session) return null; if (where.revokedAt === null && session.revokedAt !== null) return null; if (where.expiresAt?.gt && session.expiresAt <= where.expiresAt.gt) return null; return session; }) ``` - **Règle** : `seedSession()` doit initialiser `expiresAt` à +30j par défaut. Ajouter un helper `seedExpiredSession()` si des tests de session expirée sont nécessaires. - Contexte technique : NestJS / Prisma mock / e2e — app-alexandrie 24-03-2026 --- ## Tests e2e autorisation : scénarios non-abonné avec `buildApp` partagé ### Risques - Un `describe` e2e avec `buildApp` partagé en `beforeAll` (entitlements actifs) rend impossible le test de scénarios non-abonné sans pollution entre tests - Tenter de surcharger le mock partagé (`jest.fn().mockResolvedValueOnce(...)`) dans un `it` intermédiaire est fragile et crée des effets de bord ### Symptômes - Scénario "non-abonné → 403" n'est jamais testé, ou pollue les autres tests si le mock est modifié en cours de describe ### Bonnes pratiques / mitigations Créer une instance `buildApp` isolée pour les scénarios d'autorisation alternatifs : ```typescript it('retourne 403 si subscription inactive', async () => { const isolatedApp = await buildApp({ getEntitlementsForUser: jest.fn().mockResolvedValue({ subscription: { isActive: false, plan: 'free' } }) }); // ... tests await isolatedApp.close(); }); ``` - **Règle** : ne jamais tenter de surcharger un mock partagé dans un `it` — créer un `buildApp` isolé avec `app.close()` en fin de test - Contexte technique : NestJS / Jest e2e — app-alexandrie 24-03-2026 --- ## Champ métier absent du JWT — découplage silencieux frontend/backend ### Risques - Le frontend lit un champ dans `decodeJwtPayload(token)` qui n'est jamais émis par le service d'authentification - Le comportement est silencieux — `undefined` est traité comme `''`, aucune erreur visible, l'UI se dégrade sans signal d'alerte ### Symptômes - `decodeJwtPayload(token).field` retourne `undefined` pour tous les utilisateurs réels - Filtres de grade/rôle côté UI entièrement inopérants (rank=0, aucune tab affichée) ### Bonnes pratiques / mitigations - Toute donnée lue via `decodeJwtPayload` côté frontend doit être explicitement émise dans le payload JWT côté backend - Lors de l'ajout d'un champ à `JwtPayload` (type TypeScript), vérifier immédiatement que le service d'authentification inclut ce champ à l'émission - Ajouter un test d'intégration login → decode qui vérifie la présence des champs critiques dans le token retourné - **Signal review** : un champ apparaît dans le type `JwtPayload` côté frontend sans modification correspondante dans `authService.ts` - Contexte technique : JWT / auth — RL799_V2 02-04-2026 --- ## Confusion email login / email de contact dans un endpoint annuaire ### Risques - Le mapping de l'endpoint annuaire utilise `email: user.email` (email de login, toujours présent) alors que l'intention est d'exposer un email de contact optionnel - Même un utilisateur à bas privilège peut récupérer les emails de login de tous les membres ### Symptômes - `email: user.email` dans le mapping d'un endpoint de type "annuaire" ou "liste membres" - Emails de connexion exposés à tous les utilisateurs authentifiés ### Bonnes pratiques / mitigations - Dans tout endpoint annuaire ou profil public, distinguer explicitement : - **email de login** : identifiant de compte, JAMAIS exposé à un tiers dans un endpoint annuaire - **email de contact** : champ optionnel dans le profil ou la table directory, exposé uniquement s'il est renseigné - Si le modèle ne dispose pas encore d'un champ email de contact distinct, renvoyer `undefined` pour le champ email dans l'annuaire - **Signal review** : `email: user.email` dans le mapping d'un endpoint annuaire - Contexte technique : auth / annuaire — RL799_V2 02-04-2026 --- ## TOCTOU sur rotation de refresh token ### Risques - Un pattern `findUnique` + `update` séparés sur la rotation de refresh token crée une fenêtre TOCTOU - Deux requêtes concurrentes avec le même refresh token passent toutes les deux la vérification avant que l'une ne révoque → deux sessions valides émises, le vol de token passe inaperçu ### Symptômes - Deux sessions actives issues du même refresh token - Détection de vol impossible car les deux tokens sont valides ### Bonnes pratiques / mitigations - Toujours utiliser un `updateMany` atomique avec condition `WHERE revokedAt IS NULL AND expiresAt > NOW()` et vérifier `count === 1` - Si `count === 0`, le token a déjà été utilisé → révoquer **tous** les tokens du user (token family detection, RFC 6819) - **Signal review** : `findUnique` suivi de `update` séparés dans un flux de rotation de refresh token - Contexte technique : auth / refresh token — RL799_V2 08-04-2026 ### Complément — rotation du refresh token IdP en BFF : cookie rafraîchi non réécrit → déconnexions erratiques Angle distinct mais lié à la rotation : en archi BFF, si le cookie de session rafraîchi (`sessionCookieToApply`) n'est réécrit que par UN seul handler (typiquement `/me`), la rotation du refresh token côté IdP déconnecte les utilisateurs de façon erratique. - Les N autres call-sites d'auth déclenchent bien le refresh (access token rafraîchi en mémoire → requête autorisée → 200) mais JETTENT le nouveau cookie. - Tant que l'IdP n'a PAS la rotation activée, c'est inoffensif (l'ancien refresh token reste valide). MAIS si le realm a `Revoke Refresh Token` / rotation activée (durcissement prod COURANT chez Keycloak/Auth0/etc.), chaque refresh INVALIDE l'ancien refresh token côté IdP : le cookie non réécrit garde un refresh token révoqué → la requête suivante échoue → `SESSION_EXPIRED` → re-login forcé. - **Piège** : INVISIBLE en dev (rotation souvent off par défaut), il n'apparaît qu'au déploiement quand un ops active la rotation pour durcir. Règles : 1. Si la réécriture du cookie n'est pas généralisée à TOUS les handlers (via un wrapper qui attache `sessionCookieToApply` systématiquement), alors NE PAS activer la rotation du refresh token côté realm — et le documenter comme garde-fou de déploiement explicite. 2. Inversement, si on veut la rotation (recommandé en sécurité), généraliser la réécriture du cookie AVANT. 3. Ne jamais traiter « le refresh marche en dev » comme preuve que la rotation marchera en prod — tester avec la rotation activée. - Cas vécu : RL799 K1.5, seul `/me` consomme `sessionCookieToApply`, ~202 autres call-sites l'ignorent ; garde-fou « pas de rotation realm avant généralisation » renvoyé au Lot 6 déploiement — 15-06-2026. --- ## Drift d'authentification par copier-coller de pattern auth ### Risques - Quand un helper d'auth centralisé existe (`requireRoleAccess`), mais que de nouveaux services réimplémentent le même pattern manuellement (`extractAccessToken` + `verifyToken` + vérification locale), chaque service développe ses propres variantes (codes d'erreur différents, 401 vs 403, requestId ou non) - La surface d'auth devient incohérente et indéfendable en audit ### Symptômes - Un audit RBAC révèle qu'une part significative des routes ont un pattern d'auth "fait maison" au lieu du helper standard - Codes d'erreur divergents entre services pour la même situation (token absent) ### Bonnes pratiques / mitigations - Tout nouveau handler HTTP DOIT utiliser le helper centralisé pour l'authentification et l'autorisation - Ne JAMAIS importer `extractAccessToken` + `verifyToken` directement dans un service métier - Si le helper ne couvre pas un besoin (ex: besoin de `userId` en plus de `email`), étendre le helper plutôt que contourner - **Signal review** : import de `verifyToken` dans un fichier service (hors `authHelpers.ts`) - Contexte technique : auth / architecture — RL799_V2 08-04-2026 ### Complément — cohérence des filtres d'autorisation entre TOUS les chemins ciblant la même population Le drift ne touche pas que les codes d'erreur : il touche aussi les FILTRES appliqués sur la même population résolue à plusieurs endroits. - Quand une même population (ex. « les membres actifs d'un grade ») est résolue à plusieurs endroits — un chemin de NOTIFICATION qui filtre `isActive: true` et un chemin d'AUTORISATION qui fait `getUserByEmail` sans filtre `isActive` — la divergence crée une faille : un compte désactivé/démissionnaire avec un JWT encore valide (fenêtre ≤ TTL) n'est pas notifié MAIS peut encore agir. - **Règle** : tout contrôle d'autorisation basé sur un fetch user doit re-vérifier `isActive` à chaque requête (le JWT ne reflète pas une désactivation survenue après émission). - **Audit** : grep des `getUserByEmail` / `findUser*` dans les services, vérifier que chaque usage en contexte d'autorisation filtre/contrôle `isActive`. - **Symptôme de l'incohérence** : « la liste des destinataires d'un effet et la liste des autorisés à le déclencher ne coïncident pas ». - Cas vécu : isolation de réponse aux instructions RL799 — le fetch DB avait été ajouté EXPRÈS pour capter les changements d'état à chaque requête, mais ignorait `isActive`, annulant le bénéfice. --- ## ACL unique pour ressource globale et sous-champ sensible ### Risques - Champs sensibles exposés à des rôles qui ne devraient accéder qu'à la vue agrégée. ### Symptômes - Endpoint fonctionnellement "autorisé" mais fuite de notes/valeurs sensibles en clair. ### Bonnes pratiques / mitigations - Séparer explicitement les règles d'accès : liste globale vs détails sensibles. - Appliquer des guards dédiés au niveau du champ ou du sous-endpoint. - Contexte technique : auth / ACL granulaire — RL799_V2 13-04-2026 --- ## JWT valide mais utilisateur introuvable en base ### Risques - Retour `403` trompeur (authz) au lieu d'un `401` (auth invalide côté sujet). ### Symptômes - Frontend affiche "accès refusé" au lieu de forcer une ré-authentification. ### Bonnes pratiques / mitigations - Si le sujet JWT ne résout plus un user actif : répondre `401`. - Déclencher invalidation de session côté client. - Contexte technique : auth / cycle de vie compte — RL799_V2 17-04-2026 --- ## Helpers "X actif" qui dérivent silencieusement ### Risques - Plusieurs helpers répondent à la même question — *"l'entité X est-elle active / opérante ?"* — avec des filtres légèrement différents - Un user passe la guard A mais pas la guard B sur la même ressource (ou inversement). Bugs silencieux, pas d'erreur, juste une asymétrie de comportement ### Symptômes - Délégation `secretaireDeSeance` "active" filtrée sur `status: 'published', closedAt: null, cancelledAt: null` dans un helper, juste `cancelledAt: null` dans l'autre - Un ex-délégué d'une soirée clôturée garde l'autorité cross-soirée indéfiniment ### Bonnes pratiques / mitigations 1. **Un seul helper canonique** par notion d'activité (ex : `isDelegationActive`, `isSoireeOpenForRappel`). Les autres l'appellent 2. Si la centralisation n'est pas faisable immédiatement (ex : helper appelé en N+1 query, perf), au moins un test qui compare leur output sur des fixtures partagées et casse à la moindre divergence 3. Au minimum : un commentaire en tête du helper "secondaire" qui pointe vers le canonique et liste explicitement les filtres à maintenir synchronisés - Contexte technique : auth / RBAC — RL799_V2 27-04-2026 --- ## Guard d'autorisation qui charge des objets riches ### Risques - Une guard d'autorisation s'exécute à CHAQUE requête sur une route protégée - Si la guard a besoin de "trouver une candidate" (ex : "cette tenue est-elle dans les 'dernières rappelables' du grade pour une de mes délégations ?"), le repo helper utilisé doit avoir un select **minimal**, PAS le select complet utilisé par les services métier - Pour un user avec N délégations actives, on charge N agrégats volumineux à chaque requête ### Symptômes - Même fonction repo appelée par (1) un service qui a besoin de toutes les relations (rendu UI) et (2) une guard qui n'a besoin que de l'id - La guard paie le coût du fetch riche inutilement - Latence guard qui croît avec le nombre de relations chargées ### Bonnes pratiques / mitigations Exposer **deux variantes** du repo helper : - `findX(...)` — select riche, utilisé par les services métier - `findXIdOnly(...)` — select `{ id: true }`, utilisé par les guards ```typescript // Guard export const requireXAccess = async (request, id, { roleSet }) => { // utilise findXIdOnly (select minimal) — pas findX const candidate = await repo.findXIdOnly({ ... }); if (!candidate || candidate.id !== id) return forbidden(); }; // Service métier export const getXFullDetails = async (id) => { return repo.findX({ ... }); // include riche }; ``` Coût : duplication de la clause `where` (acceptable, factorisable en constante). Bénéfice : la guard reste O(1) en payload même quand les relations grossissent. - Contexte technique : auth / performance — RL799_V2 27-04-2026 --- ## Suppression d'un flag auth global (DB + DTO + tests) — cleanup atomique obligatoire ### Risques - Un flag profondément câblé dans Prisma (ex : `mustChangePassword`, `isVerified`) ne peut pas être supprimé incrémentalement : chaque cleanup partiel produit un état non-compilable - Les fixtures de tests qui posent `mustChangePassword: false` cassent à la compilation TS au moment du drop — bloque tout commit séparé - Les helpers `helpers/db.ts` et les DTO partagés (`packages/shared`) sont prioritaires, sinon les imports cross-package échouent en cascade ### Symptômes - `Property 'mustChangePassword' does not exist on type 'User'` après un drop partiel - Tentative de découpage en sous-lots qui échoue au typecheck ### Bonnes pratiques / mitigations Quand on prévoit de supprimer un flag auth profondément câblé : 1. **Le cleanup ne peut pas être incrémental** — soit on supprime tout dans un chantier, soit on garde le flag avec un nullable de transition 2. **Les fixtures de tests doivent être nettoyées dans le même PR** — grep systématique avant de démarrer (`grep -rn "mustChangePassword" apps/`) pour estimer l'ampleur 3. **Les helpers `helpers/db.ts`** sont prioritaires — un seul fichier touché casse tous les tests qui l'importent 4. **Les DTO partagés (`packages/shared`)** doivent être alignés en premier 5. Considérer un sous-lot dédié au cleanup si le flag est transverse — éviter de l'inclure dans un sous-lot fonctionnel **Anti-pattern** : déprécier en douceur en gardant le flag avec un commentaire `// @deprecated` sans supprimer les usages. Le code mort s'accumule, les futurs devs hésitent à le nettoyer ("pourquoi c'est encore là ?"), la dépréciation ne se finit jamais. - Contexte technique : auth / refactor schema — RL799_V2 28-04-2026 --- ## Information disclosure sur comptes soft-deleted dans `login()` ### Risques - Retourner un code d'erreur distinct (ex : `ACCOUNT_DELETED`) pour les comptes supprimés dans `login()` permet l'**énumération** : un attaquant distingue « compte supprimé » de « identifiants invalides » par email. - Complément du pattern anti-énumération (cf. `pattern-...anti-énumération` dans `patterns/auth.md`) appliqué au cas soft-delete. ### Symptômes - `login()` lève `ACCOUNT_DELETED` au lieu de `INVALID_CREDENTIALS` pour un compte soft-deleted - L'existence (et le statut) d'un compte fuite via la réponse d'authentification ### Bonnes pratiques / mitigations ```typescript // ❌ DANGEREUX — révèle l'existence d'un compte supprimé if (user.deletedAt !== null) { throw new UnauthorizedException({ error: { code: 'ACCOUNT_DELETED' } }); } // ✅ CORRECT — même code que des identifiants invalides if (user.deletedAt !== null) { throw new UnauthorizedException({ error: { code: 'INVALID_CREDENTIALS', message: 'Email ou mot de passe invalide.' }, }); } ``` - **Règle** : dans `login()`, toujours répondre `INVALID_CREDENTIALS` pour un compte soft-deleted — jamais un code spécifique. - **Nuance** : un code `ACCOUNT_DELETED` reste acceptable dans un flux `exchange()` OAuth, où le provider a déjà confirmé l'identité (pas d'énumération possible côté attaquant). - Contexte technique : auth / soft-delete / anti-énumération — app-alexandrie 13-04-2026 --- ## Guard d'abonnement global vs droits acquis permanents ### Risques - Un guard de gating « abonnement actif » (ex. `RequireSubscriptionActive` / `RequireAccessLevel(FULL)`) posé uniformément sur TOUTES les routes d'un domaine coupe l'accès à un contenu déjà payé en one-shot (« possession à vie ») dès que l'abonnement est résilié - Violation silencieuse d'un invariant métier : « je garde ce que j'ai payé même sans abo » ### Symptômes - Couper l'abonnement rend inaccessible un contenu acheté de façon permanente - Aucun test ne couvre le cas « droit permanent + abo coupé » → régression non détectée ### Bonnes pratiques / mitigations - Avant d'appliquer un guard « abonnement actif » uniformément, distinguer deux natures de droit : - **droit RÉCURRENT** (lié à l'abo : feed, communauté, contenu inclus) - **droit ACQUIS/permanent** (achat one-shot, possession « à vie ») - **Règle** : gater la LECTURE d'un bien acquis par la POSSESSION (helper `canAccess…`), pas par l'abonnement. Réserver le guard abo aux routes d'écriture/progression et aux contenus récurrents. - TOUJOURS écrire un test « bien possédé + abo coupé → lisible » : c'est l'angle mort classique qui laisse passer ce type de régression. - Contexte technique : auth / gating abonnement — app-alexandrie 02-06-2026 --- ## Confondre la validité du JETON d'octroi avec la durée de l'ACCÈS octroyé ### Risques - Un helper d'accès lit le `expiresAt` d'un jeton d'octroi (code de déblocage, lien/token d'invitation) comme SOURCE D'ACCÈS directe - Mais `expiresAt` borne la fenêtre d'ACTIVATION du jeton (ex. 72 h), pas la durée de l'accès octroyé (censé être permanent) → l'accès expire en même temps que le jeton ### Symptômes - L'accès « à vie » expire 72 h après l'émission du code - Bug non détecté par les tests (qui valident le helper tel qu'écrit, pas l'intention) ### Bonnes pratiques / mitigations - Ne JAMAIS faire dépendre la vérification d'accès du `expiresAt` du jeton. - À l'activation, **matérialiser l'accès dans son entité propre** (ex. `UserPack`/possession) et vérifier l'accès via CETTE entité — pas via le jeton. - **Règle** : « le jeton expire, le droit qu'il a créé persiste. » - Test obligatoire : « jeton activé puis expiré → l'accès reste valide ». - **Corollaire** : un helper d'accès ne doit pas « anticiper » un mécanisme pas encore implémenté en lisant un état intermédiaire — il introduit un modèle d'accès parallèle qui diverge du modèle cible (la branche aurait dû passer par `UserPack` dès le départ). - Contexte technique : auth / activation vs possession — app-alexandrie 02-06-2026