feat(gating): socle — LicenseProvider + matrice d'entitlements + useEntitlement #303

Merged
maximus merged 2 commits from issue-297-license-provider into main 2026-07-21 01:53:01 +00:00
Owner

Socle du gating par tier (soft-paywall UI-only).

  • LicenseContext : provider machine-level (modelé sur ProfileContext), monté au-dessus de ProfileProvider — 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) + matrice ENTITLEMENTS + isEntitled() pur fail-closed en Free (override features[] ignoré avant le short-circuit free — CWE-863).
  • useEntitlement(f){ allowed, ready } (synchrone).
  • useIsPremium + son test migrés sur le context ; LicenseCard consomme le context ; useLicense.ts retiré (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 du gating par tier (soft-paywall UI-only). - `LicenseContext` : provider machine-level (modelé sur `ProfileContext`), monté au-dessus de `ProfileProvider` — 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) + matrice `ENTITLEMENTS` + `isEntitled()` pur **fail-closed en Free** (override `features[]` ignoré avant le short-circuit free — CWE-863). - `useEntitlement(f)` → `{ allowed, ready }` (synchrone). - `useIsPremium` + son test migrés sur le context ; `LicenseCard` consomme le context ; `useLicense.ts` retiré (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
maximus added 1 commit 2026-07-20 01:05:34 +00:00
feat(gating): license provider + entitlements matrix + useEntitlement (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m38s
PR Check / frontend (pull_request) Successful in 2m34s
fd7e053239
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>
maximus added the
status:review
type:feature
source:human
autopilot:pending-human
labels 2026-07-20 01:06:19 +00:00
Author
Owner

Review adversariale — /pr-review

Verdict : REQUEST_CHANGES

Résumé : Le socle est solide — matrice conforme aux décisions de spec, isEntitled fail-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, useEntitlement anti-flash. Un défaut réel : le retry backoff (conçu pour les erreurs de boot, CWE-703) s'arme aussi sur les échecs de submitKey, 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

  1. src/contexts/LicenseContext.tsx:129-142 — le retry auto efface l'erreur de validation de clé après ~1 s.
    Un submitKey échoué dispatche ERROR (LicenseContext.tsx:118), le même status que l'erreur de boot ; le retry effect ne distingue pas les deux. Séquence : VALIDATE_START reset retryAttemptERROR → timer 1 s → refresh()LOAD_START met error: null / status: "loading" → le bloc d'erreur de LicenseCard (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 ignore result.error — le context est la seule source du message. Sur main, useLicense n'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 sur status === "error" && errorSource === "load". Effet secondaire du même défaut, corrigé au passage : chaque clé invalide déclenche aujourd'hui un refresh() IPC superflu.

Suggestions (non bloquantes)

  • Tester le reducer : exporter reducer et 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 partie isEntitled est bien couverte).
  • LicenseCard.tsx:158 : le bouton d'achat (edition === "free") s'affiche brièvement au boot pour un utilisateur payant (initialState free pendant loading). Pré-existant sur main, mais le context expose maintenant status — un guard ready alignerait la carte sur le contrat anti-flash du socle. Peut attendre les PRs de gating UI (#298+).
  • services/licenseService.ts:34 : checkEntitlement n'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

  • CWE-863 : short-circuit Free AVANT l'override features[] + test dédié (clé copiée → downgrade free → features signées ignorées)
  • CWE-703 : état neutre + backoff cappé 1 s→30 s (modulo l'issue 1)
  • Provider monté au-dessus de ProfileProvider (main.tsx) → survit à BrowserRouter key={refreshKey} (App.tsx:108) ; un switch de profil ne recharge pas la licence
  • useEntitlement{ allowed, ready }, pas un boolean nu
  • useIsPremium behavior-preserving ; PriceFetchControl/PriceFetchConsentToggle mockent useIsPremium directement → gate cours non régressé
  • Matrice : budget/adjustments/reports-advanced/multi-profile = Base+, balance = Premium, hub reports non gaté (aucune clé — conforme à la correction 🔴 du /review-spec)
  • Plus aucun consommateur de useLicense (grep vérifié) ; mock useIsPremium.test.ts migré proprement vers le context
  • Aucune migration DB, pas de secrets, pas de nouvelles strings i18n (rien de user-facing dans ce socle)

Review générée par /pr-review (passe adversariale, read-only).

## Review adversariale — /pr-review **Verdict : REQUEST_CHANGES** **Résumé** : Le socle est solide — matrice conforme aux décisions de spec, `isEntitled` fail-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, `useEntitlement` anti-flash. Un défaut réel : le retry backoff (conçu pour les erreurs de **boot**, CWE-703) s'arme aussi sur les échecs de `submitKey`, 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 1. **`src/contexts/LicenseContext.tsx:129-142` — le retry auto efface l'erreur de validation de clé après ~1 s.** Un `submitKey` échoué dispatche `ERROR` (`LicenseContext.tsx:118`), le même status que l'erreur de boot ; le retry effect ne distingue pas les deux. Séquence : `VALIDATE_START` reset `retryAttempt` → `ERROR` → timer 1 s → `refresh()` → `LOAD_START` met `error: null` / `status: "loading"` → le bloc d'erreur de `LicenseCard` (`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 ignore `result.error` — le context est la seule source du message. Sur `main`, `useLicense` n'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 sur `status === "error" && errorSource === "load"`. Effet secondaire du même défaut, corrigé au passage : chaque clé invalide déclenche aujourd'hui un `refresh()` IPC superflu. ### Suggestions (non bloquantes) - **Tester le reducer** : exporter `reducer` et 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 partie `isEntitled` est bien couverte). - **`LicenseCard.tsx:158`** : le bouton d'achat (`edition === "free"`) s'affiche brièvement au boot pour un utilisateur payant (initialState free pendant `loading`). Pré-existant sur `main`, mais le context expose maintenant `status` — un guard `ready` alignerait la carte sur le contrat anti-flash du socle. Peut attendre les PRs de gating UI (#298+). - **`services/licenseService.ts:34`** : `checkEntitlement` n'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 - CWE-863 : short-circuit Free AVANT l'override `features[]` + test dédié (clé copiée → downgrade free → features signées ignorées) - CWE-703 : état neutre + backoff cappé 1 s→30 s (modulo l'issue 1) - Provider monté au-dessus de `ProfileProvider` (`main.tsx`) → survit à `BrowserRouter key={refreshKey}` (`App.tsx:108`) ; un switch de profil ne recharge pas la licence - `useEntitlement` → `{ allowed, ready }`, pas un boolean nu - `useIsPremium` behavior-preserving ; `PriceFetchControl`/`PriceFetchConsentToggle` mockent `useIsPremium` directement → gate cours non régressé - Matrice : budget/adjustments/reports-advanced/multi-profile = Base+, balance = Premium, hub reports non gaté (aucune clé — conforme à la correction 🔴 du `/review-spec`) - Plus aucun consommateur de `useLicense` (grep vérifié) ; mock `useIsPremium.test.ts` migré proprement vers le context - Aucune migration DB, pas de secrets, pas de nouvelles strings i18n (rien de user-facing dans ce socle) *Review générée par /pr-review (passe adversariale, read-only).*
maximus added 1 commit 2026-07-21 01:21:20 +00:00
fix(gating): keep key-validation errors out of the load lifecycle (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m21s
PR Check / frontend (pull_request) Successful in 2m32s
b9e13b5bca
A rejected submitKey dispatched the same ERROR action as a failed boot
load, so the CWE-703 retry backoff armed on it and the auto refresh
(LOAD_START) cleared the "invalid key" message ~1s after submit —
LicenseCard has no local error state, the context is the only source.

Split the state: load lifecycle (status/error, retried) vs validation
(validating/validationError, never retried). VALIDATE_ERROR leaves
status untouched, so a ready license stays ready on a typo'd key (no
`ready` regression for gating consumers) and a boot-error retry loop
keeps running through a failed validation. Reducer + initial state
exported for tests, covered by LicenseContext.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

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 par b9e13b5 : la validation de clé est sortie du cycle de load. VALIDATE_START/VALIDATE_ERROR ne touchent plus status, le retry ne s'arme que sur status === "error" (load uniquement), et l'erreur vit dans validationError, affichée inline dans le formulaire. CI verte sur le head (rust + frontend).

Vérification du fix

  • Licence ready + clé rejetée : status inchangé → l'effet retry (deps [state.status]) ne se ré-exécute pas, aucun timer, le message persiste (test « retry never arms »).
  • Boot en erreur + clé rejetée : la boucle de retry du load continue (voulu), et LOAD_START/LOAD_DONE préservent validationError — un load en arrière-plan ne peut plus effacer le message (tests dédiés).
  • Clé valide pendant un boot en erreur : VALIDATE_DONE reset complet → sortie propre (testé).
  • LicenseCard : spinner/disabled sur validating, statut "validating" retiré de l'union, erreur rendue dans le formulaire (showInput reste ouvert sur échec).

Reste du diff re-validé

Matrice conforme aux décisions (adjustments Base, reports-advanced, modules Free sans clé → jamais gatés) ; isEntitled fail-closed Free avant l'override (CWE-863, scénario clé copiée testé) ; provider au-dessus de ProfileProvider, remount BrowserRouter key={refreshKey} confiné dans App → la licence survit au switch de profil ; LOAD_ERROR préserve édition+info (pas d'upsell sur erreur transitoire) ; useEntitlement{allowed, ready} ; useIsPremium behavior-preserving (PriceFetchControl/PriceFetchConsentToggle intacts) ; useLicense.ts supprimé sans importeur restant ; 17 nouveaux tests ; aucune migration DB, aucun secret. Les 6 critères d'acceptation de #297 sont remplis.

Suggestions (non bloquantes)

  1. validationError périmé à la réouverture du formulaire (échec → Annuler → rouvrir ré-affiche l'ancienne erreur sur champ vide). Optionnel : nettoyer à l'annulation.
  2. checkEntitlement (licenseService.ts:34) n'a plus d'appelant TS — à trancher au tip de la pile (#301) : consommé ou balayé.
  3. docs/architecture.md:221 liste encore useLicense — pour le sweep docs de #302.
  4. Pas d'entrée CHANGELOG — acceptable pour le socle ; plier les effets visibles (erreur de clé inline, auto-retry au boot) dans les entrées de #298/#299.

Ce verdict supersède le REQUEST_CHANGES du commentaire précédent (état fd7e053), conservé comme trace de l'origine du fix.

## 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 par `b9e13b5` : la validation de clé est sortie du cycle de load. `VALIDATE_START`/`VALIDATE_ERROR` ne touchent plus `status`, le retry ne s'arme que sur `status === "error"` (load uniquement), et l'erreur vit dans `validationError`, affichée inline dans le formulaire. CI verte sur le head (rust + frontend). ### Vérification du fix - Licence `ready` + clé rejetée : `status` inchangé → l'effet retry (deps `[state.status]`) ne se ré-exécute pas, aucun timer, le message persiste (test « retry never arms »). - Boot en erreur + clé rejetée : la boucle de retry du load continue (voulu), et `LOAD_START`/`LOAD_DONE` préservent `validationError` — un load en arrière-plan ne peut plus effacer le message (tests dédiés). - Clé valide pendant un boot en erreur : `VALIDATE_DONE` reset complet → sortie propre (testé). - `LicenseCard` : spinner/disabled sur `validating`, statut `"validating"` retiré de l'union, erreur rendue dans le formulaire (`showInput` reste ouvert sur échec). ### Reste du diff re-validé Matrice conforme aux décisions (`adjustments` Base, `reports-advanced`, modules Free sans clé → jamais gatés) ; `isEntitled` fail-closed Free **avant** l'override (CWE-863, scénario clé copiée testé) ; provider au-dessus de `ProfileProvider`, remount `BrowserRouter key={refreshKey}` confiné dans `App` → la licence survit au switch de profil ; `LOAD_ERROR` préserve édition+info (pas d'upsell sur erreur transitoire) ; `useEntitlement` → `{allowed, ready}` ; `useIsPremium` behavior-preserving (`PriceFetchControl`/`PriceFetchConsentToggle` intacts) ; `useLicense.ts` supprimé sans importeur restant ; 17 nouveaux tests ; aucune migration DB, aucun secret. Les 6 critères d'acceptation de #297 sont remplis. ### Suggestions (non bloquantes) 1. `validationError` périmé à la réouverture du formulaire (échec → Annuler → rouvrir ré-affiche l'ancienne erreur sur champ vide). Optionnel : nettoyer à l'annulation. 2. `checkEntitlement` (`licenseService.ts:34`) n'a plus d'appelant TS — à trancher au tip de la pile (#301) : consommé ou balayé. 3. `docs/architecture.md:221` liste encore `useLicense` — pour le sweep docs de #302. 4. Pas d'entrée CHANGELOG — acceptable pour le socle ; plier les effets visibles (erreur de clé inline, auto-retry au boot) dans les entrées de #298/#299. *Ce verdict supersède le REQUEST_CHANGES du commentaire précédent (état `fd7e053`), conservé comme trace de l'origine du fix.*
maximus merged commit b9e13b5bca into main 2026-07-21 01:53:01 +00:00
maximus deleted branch issue-297-license-provider 2026-07-21 01:53:01 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#303
No description provided.