feat(gating): socle — LicenseProvider + matrice d'entitlements + useEntitlement #303
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#303
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-297-license-provider"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Socle du gating par tier (soft-paywall UI-only).
LicenseContext: provider machine-level (modelé surProfileContext), monté au-dessus deProfileProvider— un changement de profil ne recharge pas la licence. Charge édition + info 1×, récupération d'erreur au boot (état neutre + retry backoff, jamais l'upsell — CWE-703).shared/entitlements.ts:FeatureKey(kebab-case) + matriceENTITLEMENTS+isEntitled()pur fail-closed en Free (overridefeatures[]ignoré avant le short-circuit free — CWE-863).useEntitlement(f)→{ allowed, ready }(synchrone).useIsPremium+ son test migrés sur le context ;LicenseCardconsomme le context ;useLicense.tsretiré (remplacé, plus aucun consommateur).services/entitlements.test.ts: matrice, override, override ignoré en Free, feature inconnue.846 vitest verts, build tsc+vite OK, aucune migration DB.
Resolves #297
Generated autonomously by /autopilot run of 2026-07-19
Socle for tier-based feature gating (UI-only soft-paywall). - LicenseContext: machine-level provider (createContext<T|null>, useReducer, throwing consumer hook), mounted above ProfileProvider in main.tsx so a profile switch (BrowserRouter key remount) does not reload the license. Loads edition + info once; exposes { status, edition, features, info, error, refresh, submitKey }. Boot-error recovery (CWE-703): neutral state + capped exponential-backoff retry, never the upsell. - shared/entitlements.ts: FeatureKey (kebab-case), ENTITLEMENTS matrix, pure isEntitled() fail-closed in Free (CWE-863) — the features[] override is ignored before edition==="free" is checked. - useEntitlement(f): { allowed, ready } (ready = status==="ready"), synchronous. - useIsPremium + its test migrated onto the context (drops the per-call double invoke); LicenseCard consumes the context. useLicense.ts removed (fully replaced, no remaining consumers). - services/entitlements.test.ts: matrix, features[] override, override ignored in Free, unknown-feature deny-all. Resolves #297 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Review adversariale — /pr-review
Verdict : REQUEST_CHANGES
Résumé : Le socle est solide — matrice conforme aux décisions de spec,
isEntitledfail-closed en Free (CWE-863) correctement ordonné et testé (10 tests), provider machine-level qui survit au remount profil, édition préservée sur erreur,useEntitlementanti-flash. Un défaut réel : le retry backoff (conçu pour les erreurs de boot, CWE-703) s'arme aussi sur les échecs desubmitKey, ce qui efface le message « clé invalide » après ~1 s — régression UX sur le flux d'activation de licence, c'est-à-dire le flux de conversion payante.Issue bloquante
src/contexts/LicenseContext.tsx:129-142— le retry auto efface l'erreur de validation de clé après ~1 s.Un
submitKeyéchoué dispatcheERROR(LicenseContext.tsx:118), le même status que l'erreur de boot ; le retry effect ne distingue pas les deux. Séquence :VALIDATE_STARTresetretryAttempt→ERROR→ timer 1 s →refresh()→LOAD_STARTmeterror: null/status: "loading"→ le bloc d'erreur deLicenseCard(licenseStatus === "error" && error, LicenseCard.tsx:142) disparaît.handleSubmit(LicenseCard.tsx:92-101, non modifié par la PR) n'a aucun état local d'erreur et ignoreresult.error— le context est la seule source du message. Surmain,useLicensen'a pas de retry : le message restait affiché jusqu'à la prochaine action. Post-PR, l'utilisateur qui colle une clé invalide (cas d'erreur le plus courant du flux d'achat) voit l'erreur flasher ~1 s puis disparaître sans autre feedback. L'issue #297 scope le retry aux erreurs de boot (« Sur erreur de boot (invoke throw)… état neutre + refresh »).Fix suggéré (chirurgical) : distinguer la source d'erreur —
{ type: "ERROR"; error: string; source: "load" | "validate" }stockée dans le state — et n'armer le retry effect que surstatus === "error" && errorSource === "load". Effet secondaire du même défaut, corrigé au passage : chaque clé invalide déclenche aujourd'hui unrefresh()IPC superflu.Suggestions (non bloquantes)
reduceret couvrir en tests purs « ERROR préserve l'édition » et « VALIDATE_DONE met à jour l'édition » — c'est la garantie « un payant n'est jamais downgradé visuellement sur erreur transitoire » du socle, aujourd'hui non testée (la partieisEntitledest bien couverte).LicenseCard.tsx:158: le bouton d'achat (edition === "free") s'affiche brièvement au boot pour un utilisateur payant (initialState free pendantloading). Pré-existant surmain, mais le context expose maintenantstatus— un guardreadyalignerait la carte sur le contrat anti-flash du socle. Peut attendre les PRs de gating UI (#298+).services/licenseService.ts:34:checkEntitlementn'a plus aucun consommateur TS après la suppression du hook — attendu (gate dur cours à venir dans la pile), à ne pas laisser mort en fin de milestone.Conformité vérifiée
features[]+ test dédié (clé copiée → downgrade free → features signées ignorées)ProfileProvider(main.tsx) → survit àBrowserRouter key={refreshKey}(App.tsx:108) ; un switch de profil ne recharge pas la licenceuseEntitlement→{ allowed, ready }, pas un boolean nuuseIsPremiumbehavior-preserving ;PriceFetchControl/PriceFetchConsentTogglemockentuseIsPremiumdirectement → gate cours non régressé/review-spec)useLicense(grep vérifié) ; mockuseIsPremium.test.tsmigré proprement vers le contextReview générée par /pr-review (passe adversariale, read-only).
Review adversariale — /pr-review (re-review post-fix)
Verdict : APPROVE
Résumé : Le blocage de la review précédente (retry backoff CWE-703 armé sur un échec de
submitKey, effaçant le message « clé invalide » après ~1 s) est corrigé exactement comme prescrit parb9e13b5: la validation de clé est sortie du cycle de load.VALIDATE_START/VALIDATE_ERRORne touchent plusstatus, le retry ne s'arme que surstatus === "error"(load uniquement), et l'erreur vit dansvalidationError, affichée inline dans le formulaire. CI verte sur le head (rust + frontend).Vérification du fix
ready+ clé rejetée :statusinchangé → l'effet retry (deps[state.status]) ne se ré-exécute pas, aucun timer, le message persiste (test « retry never arms »).LOAD_START/LOAD_DONEpréserventvalidationError— un load en arrière-plan ne peut plus effacer le message (tests dédiés).VALIDATE_DONEreset complet → sortie propre (testé).LicenseCard: spinner/disabled survalidating, statut"validating"retiré de l'union, erreur rendue dans le formulaire (showInputreste ouvert sur échec).Reste du diff re-validé
Matrice conforme aux décisions (
adjustmentsBase,reports-advanced, modules Free sans clé → jamais gatés) ;isEntitledfail-closed Free avant l'override (CWE-863, scénario clé copiée testé) ; provider au-dessus deProfileProvider, remountBrowserRouter key={refreshKey}confiné dansApp→ la licence survit au switch de profil ;LOAD_ERRORpréserve édition+info (pas d'upsell sur erreur transitoire) ;useEntitlement→{allowed, ready};useIsPremiumbehavior-preserving (PriceFetchControl/PriceFetchConsentToggleintacts) ;useLicense.tssupprimé sans importeur restant ; 17 nouveaux tests ; aucune migration DB, aucun secret. Les 6 critères d'acceptation de #297 sont remplis.Suggestions (non bloquantes)
validationErrorpérimé à la réouverture du formulaire (échec → Annuler → rouvrir ré-affiche l'ancienne erreur sur champ vide). Optionnel : nettoyer à l'annulation.checkEntitlement(licenseService.ts:34) n'a plus d'appelant TS — à trancher au tip de la pile (#301) : consommé ou balayé.docs/architecture.md:221liste encoreuseLicense— pour le sweep docs de #302.Ce verdict supersède le REQUEST_CHANGES du commentaire précédent (état
fd7e053), conservé comme trace de l'origine du fix.