Simpl-Resultat/spec-plan-feature-gating.md
le king fu 4f39fa3434 spec(gating): adjustments -> Base tier + STATE review sync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:44:22 -04:00

18 KiB
Raw Blame History

Spec Plan — Gating des fonctionnalités par édition de licence

Date: 2026-07-19 Projet: simpl-resultat Statut: Draft (revu — voir Révision — Synthèse) Slug: feature-gating Decisions: spec-decisions-feature-gating.md

Ancrage code (vérifié 2026-07-19)

  • Rapports = routes distinctes (App.tsx:117-122) : /reports (hub Free), /reports/highlights|trends|compare|category|cartes. Le gating « par onglet » = gating par route.
  • Édition + features déjà en mémoire : useLicense (useLicense.ts:59) charge edition + info.features[] au boot (getEdition + readLicense). ⇒ useEntitlement peut être synchrone. MAIS useLicense est un état par-appel (chaque montage refait 2 invokes) → il faut un Context partagé.
  • FEATURE_TIERS Rust (entitlements.rs:14-21) déclare 4 features dont 3 mortes (web-sync, cloud-backup, advanced-reports — aucun call-site) ; is_feature_allowed ne regarde que l'édition (entitlements.rs:24-30) — l'override features[] n'est pas câblé. Une feature absente = deny-all.
  • Auto-update déjà branché sur check_entitlement("auto-update") (useUpdater.ts:79), UI gère l'état notEntitled. ⇒ #271 = 1 ligne dans FEATURE_TIERS.
  • LicenseInfo.features[] est peuplé depuis le JWT (license_commands.rs:132) et exposé au front (licenseService.ts:8). ⚠️ readLicense/read_license ne vérifie pas le machine-binding (contrairement à current_edition) → l'override doit être fail-closed en Free (voir Sécurité).
  • Sidebar lit NAV_ITEMS (constants/index.ts:6-61), liste {key, path, icon, labelKey}. useIsPremium (useIsPremium.ts) remonte useLicense (à rebrancher). LicenseCard est monté à /settings/users (UsersSettingsPage.tsx:34). Création de profil = ProfileFormModal (createProfile), ouvert depuis ProfileSwitcher ET ProfileSelectionPage.

Design

Architecture

Source de vérité de l'édition = inchangée (current_edition() Rust, fail-closed, machine-binding). Nouveau : une couche d'entitlements côté TS pour un gating UI synchrone.

LicenseProvider (context, chargé 1× au boot, monté AU-DESSUS de ProfileProvider dans main.tsx)
  ├── status, edition, features[], info, refresh, submitKey
  └── consommé par :
        useEntitlement(featureKey) → { allowed, ready } — lit ENTITLEMENTS + override features[]
        useIsPremium()             → refactoré pour lire le context
  • Matrice UI (src/shared/entitlements.ts, nouveau) — source unique côté front :

    export type FeatureKey = "budget" | "adjustments" | "reports-advanced" | "multi-profile" | "balance";
    export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
      "budget":           ["base", "premium"],
      "adjustments":      ["base", "premium"],
      "reports-advanced": ["base", "premium"],
      "multi-profile":    ["base", "premium"],
      "balance":          ["premium"],
    };
    // Override par-licence via le JWT features[] — mais fail-closed en Free : une
    // license.key copiee sur une autre machine downgrade edition->free (machine-binding),
    // on ne re-grant PAS ses features[] signees (CWE-863).
    export function isEntitled(f: FeatureKey, edition: Edition, licenseFeatures: string[]): boolean {
      if (edition === "free") return false;
      return ENTITLEMENTS[f].includes(edition) || licenseFeatures.includes(f);
    }
    

    Clés en kebab-case (reports-advanced) pour rester cohérent avec les strings Rust existants (auto-update) — le JWT features[] est un namespace partagé entre l'override Rust et TS. auto-update n'est pas ici (géré côté Rust). Modules Free (dashboard, import, transactions, catégories, reports/trends, export, changelog, docs) = pas de clé → jamais gatés. Ajustements passe en Base (clé adjustments).

  • Enforcement UI-only (soft-paywall assumé). Le Rust ne change que pour auto-update (#271) + le câblage de l'override check_entitlement, fail-closed en Free comme côté TS.

UX / Interface

  • useEntitlement(f): { allowed: boolean; ready: boolean }ready = status === "ready". Tous les consommateurs (RequireFeature, Sidebar, tuiles ReportsPage, ProfileSwitcher) suppriment le cadenas/upsell tant que !ready (rendent un placeholder neutre), pour ne jamais flasher « verrouillé » à un utilisateur payant pendant le chargement.
  • <UpsellGate feature requiredTier> (nouveau, src/components/shared/) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée, 2 CTA : « Obtenir » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → navigate("/settings/users"). i18n FR/EN.
  • <RequireFeature feature> : ready ? (allowed ? children : <UpsellGate…>) : <Loader/>. Wrappe les routes gatées. Les routes de même feature (/balance*, les 4 /reports/* avancés) sont regroupées sous une layout-route <Route element={<RequireFeature feature=…><Outlet/></RequireFeature>}> (convention SettingsLayout/AppShell), pour ne pas répéter le wrapper.
  • Sidebar : NavItem gagne feature?: FeatureKey, renseigné sur budget, adjustments et balance. reports reste non gaté (pointe vers le hub Free). Un item non autorisé (et ready) affiche un cadenas ; le clic mène à la route → RequireFeature affiche l'upsell.
  • Hub /reports (Free) : accessible ; les tuiles vers les rapports avancés portent un cadenas.
  • Multi-profils : ProfileSwitcher marque d'un cadenas les profils au-delà de l'actif quand !allowed ; la création est verrouillée au point unique ProfileFormModal pour un Free ayant déjà ≥ 1 profil. Données conservées : rien n'est supprimé de profiles.json.

Données

Aucune nouvelle table, aucune migration DB — le gating lit la licence (fichier license.key), pas la base profil. NavItem (type TS) gagne un champ optionnel feature.

Plan de travail

Issue 1 — Socle : LicenseProvider + matrice d'entitlements + useEntitlement [type:feature]

Dependances : aucune

  • src/contexts/LicenseContext.tsx : provider (modelé sur ProfileContextcreateContext<T|null>, useReducer, hook consommateur qui throw), charge édition + info 1×, expose { status, edition, features, info, refresh, submitKey }. Monter dans main.tsx AU-DESSUS de ProfileProvider (licence = machine-level → survit au remount BrowserRouter key={refreshKey} sur changement de profil).
  • Récupération d'erreur : sur échec de boot (getEdition/readLicense throw → status:"error"), retry avec backoff + refresh manuel. Les consommateurs rendent un état neutre pendant status==="error" (PAS l'upsell) — sinon un échec transitoire bloque un payant en upsell jusqu'au restart (CWE-703).
  • src/shared/entitlements.ts : FeatureKey (kebab-case), ENTITLEMENTS, isEntitled() pur fail-closed en Free.
  • src/hooks/useEntitlement.ts : useEntitlement(f): { allowed, ready } (sync, lit le context).
  • Refactor useIsPremium.ts pour lire LicenseContext (behavior-preserving, supprime le double-invoke).
  • Migrer useIsPremium.test.ts : il mocke ./useLicense (vi.mock) — le refactor le casse ; le faire mocker le context (ou le module d'entitlements). (PriceFetchControl/PriceFetchConsentToggle mockent useIsPremium directement → non affectés.)
  • Refactor LicenseCard pour consommer le context.
  • Tests : entitlements.test.ts (matrice, override features[], override ignoré en Free, édition inconnue).

Issue 2 — Garde UI : RequireFeature + UpsellGate + i18n [type:feature]

Dependances : Issue 1

  • src/components/shared/UpsellGate.tsx : écran verrouillé paramétré, 2 CTA.
  • src/components/shared/RequireFeature.tsx : ready ? (allowed ? children : UpsellGate) : Loader. Rendre comme layout-route (<Outlet/>) pour regrouper les routes de même feature.
  • i18n {fr,en}.json : upsell.* + nav.locked.

Issue 3 — Gating des routes + Sidebar [type:feature]

Dependances : Issue 2

  • App.tsx : layout-route <RequireFeature feature="balance"><Outlet/></RequireFeature> groupant /balance, /balance/accounts, /balance/snapshot ; layout-route "reports-advanced" groupant /reports/highlights|compare|category|cartes (PAS /reports/trends, PAS le hub /reports) ; "budget" sur /budget ; "adjustments" sur /adjustments.
  • NavItem (src/shared/types) + feature?: FeatureKey ; renseigner dans constants/index.ts sur budget, adjustments et balancePAS reports (hub Free, non route-wrappé).
  • Sidebar.tsx : cadenas sur item non autorisé et ready, reste cliquable.
  • ReportsPage (hub) : cadenas sur les tuiles des rapports avancés.

Issue 4 — Gating multi-profils (Base+) non destructif [type:feature]

Dependances : Issue 1 ET Issue 2 (rend UpsellGate)

  • ProfileSwitcher : cadenas + upsell sur les profils au-delà de l'actif quand !allowed (et ready).
  • ProfileFormModal (point unique de création, ouvert depuis ProfileSwitcher ET ProfileSelectionPage) : verrouillé pour un Free ayant déjà ≥ 1 profil (ou désactiver les 2 boutons d'entrée).
  • Garde NON destructive : ne rien retirer de profiles.json.

Issue 5 — Absorber #271 : auto-update Base+ + override licence (Rust) [type:feature]

Dependances : aucune (Rust indépendant — parallélisable)

  • entitlements.rs:17 : ("auto-update", &[EDITION_BASE, EDITION_PREMIUM]), retirer le commentaire « temporarily open », ajuster le test free_allows_auto_update_temporarilyfree_denied_auto_update.
  • Purger les entrées mortes de FEATURE_TIERS : web-sync, cloud-backup, advanced-reports (aucun call-site ; advanced-reports→Premium contredit la matrice TS reports-advanced→Base+). Ne laisser que auto-update.
  • Câbler l'override features[] dans check_entitlement, fail-closed en Free : résoudre l'édition ET les features via le même chemin machine-binding (features ignorées si current_edition downgrade à free), puis is_feature_allowed(feature, edition) || features.contains(feature).
  • Dev override : gater derrière une Cargo feature dédiée dev-override (off par défaut, jamais dans le feature-set release), PAS debug_assertions (activable sur un build release → backdoor Premium, CWE-489). current_edition lit SR_DEV_EDITION uniquement sous cette feature ; test que SR_DEV_EDITION n'a aucun effet quand la feature est off.
  • Absorbe #271 (fermé superseded).

Issue 6 — Docs : ADR + architecture + CHANGELOG [type:feature]

Dependances : Issues 1-5

  • ADR docs/adr/00XX-feature-gating-par-tier.md : matrice tier→features + override par-licence (fail-closed Free), enforcement UI-only, soft-paywall GPL assumé.
  • docs/architecture.md : nouveau context/hook/module d'entitlements.
  • docs/guide-utilisateur.md + i18n docs.* : ce que débloque chaque tier.
  • CHANGELOG.md + CHANGELOG.fr.md : une entrée globale (durcissement — modules désormais Base/Premium).

Ordre d'execution

Issue 1 → Issue 2 → Issue 3
        → Issue 4   (Issue 4 dépend de 1 ET 2)
Issue 5 (indépendant, parallèle)
Issues 1-5 → Issue 6

Fichiers concernes

Fichier Action Raison
src/contexts/LicenseContext.tsx Créer Provider licence partagé (1 chargement) + récupération d'erreur
src/shared/entitlements.ts Créer Matrice ENTITLEMENTS + isEntitled (pur, fail-closed Free)
src/hooks/useEntitlement.ts Créer Hook { allowed, ready }
src/hooks/useIsPremium.ts + .test.ts Modifier Lire le context ; migrer le mock du test
src/components/settings/LicenseCard.tsx Modifier Consommer le context
src/components/shared/UpsellGate.tsx, RequireFeature.tsx Créer Écran verrouillé + wrapper (layout-route)
src/App.tsx Modifier Wrappers budget / adjustments / reports-advanced / balance
src/shared/types (NavItem), src/shared/constants/index.ts Modifier feature? sur budget + adjustments + balance
src/components/layout/Sidebar.tsx Modifier Cadenas (si ready)
src/pages/ReportsPage.tsx Modifier Cadenas sur tuiles avancées
src/components/profile/ProfileSwitcher.tsx, ProfileFormModal.tsx, src/pages/ProfileSelectionPage.tsx Modifier Cadenas profils + gate création au point unique
src-tauri/src/commands/entitlements.rs Modifier auto-update Base+ (#271), purge dead rows, override fail-closed
src-tauri/src/commands/license_commands.rs Modifier Override features via chemin machine-binding + dev-override (Cargo feature)
src-tauri/Cargo.toml Modifier Cargo feature dev-override (off par défaut)
src/i18n/locales/{fr,en}.json Modifier upsell.*, nav.locked
docs/adr/00XX-*.md, docs/architecture.md, CHANGELOG*.md Créer/Modifier Documentation

Plan de tests

Tests unitaires

  • entitlements.ts : isEntitled — chaque feature × chaque édition, override features[], override ignoré quand edition==="free" (régression CWE-863), feature absente.
  • Rust entitlements.rs : auto-update denied Free / allowed Base+Premium ; override fail-closed (features ignorées si édition downgrade free) ; SR_DEV_EDITION sans effet quand la feature dev-override est off.

Tests d'integration

  • Rust : current_edition + check_entitlement bout-en-bout avec une licence signée de test portant features:[…] ET un activation.token machine-mismatch → vérifier que les features ne sont PAS accordées.

Tests de regression

  • useIsPremium : le refactor vers le context ne change pas le résultat (Premium ⇢ true). Le gate cours (PriceFetchControl via useIsPremium) ne régresse pas.

Contrainte : ni jsdom ni @testing-library (confirmé — les *.test.tsx existants mockent les hooks, ne rendent pas les composants). La logique testable vit dans isEntitled (TS pur) et les checks Rust ; le rendu est délégué à la vérif runtime / /pr-review.

Criteres d'acceptation

  • Un Free voit Budget, Ajustements, les 4 rapports avancés et le Bilan verrouillés (cadenas + upsell), garde Dashboard/Import/Transactions/Catégories/Tendance/Export/Changelog + le hub /reports.
  • Un Base débloque Budget, Ajustements, tous les rapports, multi-profils, auto-update ; Bilan reste verrouillé.
  • Un Premium a tout.
  • Durcissement non destructif : budgets/Bilan/profils réapparaissent intacts après upgrade.
  • Un Free avec 2 profils garde l'actif ; les autres verrouillés (conservés) ; création verrouillée.
  • Auto-update refusé pour Free (#271 absorbé), autorisé Base+.
  • Une licence valide (machine-bound) portant features:["<clé>"] débloque cette feature ; une licence copiée (downgrade free) ne débloque rien via features[].
  • Aucun flash « verrouillé » au boot (Sidebar, tuiles, profils, routes) — ready supprime le cadenas pendant le chargement.
  • Aucune migration DB ; suite de tests verte.

Edge cases et risques

Cas Mitigation
/adjustments (écritures manuelles + split de transactions) Tranché : Base (outil de correction avancé). Clé adjustments — route /adjustments + item nav gatés.
Max se bloque de son Bilan en dev (dev = free) Cargo feature dev-override + SR_DEV_EDITION=premium (Issue 5). Sinon poser une clé Premium locale.
Flash « verrouillé » pendant le chargement licence useEntitlement renvoie ready ; tous les consommateurs suppriment le cadenas tant que !ready.
Échec de chargement licence (invoke throw) État neutre + retry/backoff, jamais l'upsell (CWE-703) — un payant n'est pas bloqué par un échec transitoire.
Override features[] sur une clé copiée (edition downgrade free) isEntitled/check_entitlement fail-closed en Free — features[] ignorées quand l'édition est free (CWE-863).
Désync matrice TS ↔ Rust Après purge des dead rows (Issue 5), FEATURE_TIERS ne garde qu'auto-update → matrice UI uniquement en TS.
Rollout : gating partiel sur main entre issues Sans effet en prod — visible seulement à la prochaine release taggée. Ordre socle→gardes garde main cohérent.
Contournement (fork retire le if) Assumé (GPL, soft-paywall). Seul le gate serveur (cours) est dur.

Revision — Synthese

Date: 2026-07-19 | Experts: Securite, Architecture, Technique | Corrections intégrées dans cette passe.

Verdict

🟡 AMELIORATIONS INTEGREES — 2 critiques + 6 améliorations trouvées et corrigées ci-dessus ; le plan est prêt à exécuter. Aucune décision produit en suspens (/adjustments tranché Base par Max le 2026-07-19).

Resume

Expert 🔴 🟡 🟢 Points clés
Securite 0 3 1 override features[] vs machine-binding (CWE-863) ; SR_DEV_EDITION sur debug-assertions (CWE-489) ; LicenseProvider SPOF sans récupération d'erreur (CWE-703)
Architecture 2 2 2 reports nav gaté à tort (hub Free) ; advanced-reports Rust contredit reports-advanced ; casing incohérent ; boolean sans état de chargement ; mount point validé sain
Technique 0 4 3 useIsPremium.test.ts cassé par le refactor ; gate création dans ProfileFormModal (pas Sidebar) ; /adjustments à trancher ; ordre 4→(1,2)

Actions requises (toutes intégrées)

  1. 🔴 Nav reports non gatéfeature sur budget + balance uniquement ; hub /reports reste Free. ✓
  2. 🔴 Purger advanced-reports/web-sync/cloud-backup du Rust FEATURE_TIERS (Issue 5) ; mitigation « double source » corrigée. ✓
  3. 🟡 useEntitlement{ allowed, ready } ; tous les consommateurs suppriment le cadenas tant que !ready. ✓
  4. 🟡 Override features[] fail-closed en Free (TS isEntitled + Rust check_entitlement) — CWE-863. ✓
  5. 🟡 SR_DEV_EDITION derrière une Cargo feature dev-override, pas debug_assertions — CWE-489. ✓
  6. 🟡 Récupération d'erreur du LicenseProvider (retry + état neutre, jamais upsell) — CWE-703. ✓
  7. 🟡 Migrer useIsPremium.test.ts (mock du context) — ajouté à Issue 1. ✓
  8. 🟡 Gate création profil dans ProfileFormModal (point unique) — fichiers ajoutés à Issue 4. ✓
  9. 🟢 Kebab-case reports-advanced ; layout-route groupant les routes de même feature ; /adjustments = Base (tranché par Max). ✓