18 KiB
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) chargeedition+info.features[]au boot (getEdition+readLicense). ⇒useEntitlementpeut être synchrone. MAISuseLicenseest un état par-appel (chaque montage refait 2 invokes) → il faut un Context partagé. FEATURE_TIERSRust (entitlements.rs:14-21) déclare 4 features dont 3 mortes (web-sync,cloud-backup,advanced-reports— aucun call-site) ;is_feature_allowedne regarde que l'édition (entitlements.rs:24-30) — l'overridefeatures[]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'étatnotEntitled. ⇒ #271 = 1 ligne dansFEATURE_TIERS. LicenseInfo.features[]est peuplé depuis le JWT (license_commands.rs:132) et exposé au front (licenseService.ts:8). ⚠️readLicense/read_licensene 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) remonteuseLicense(à rebrancher).LicenseCardest monté à/settings/users(UsersSettingsPage.tsx:34). Création de profil =ProfileFormModal(createProfile), ouvert depuisProfileSwitcherETProfileSelectionPage.
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 JWTfeatures[]est un namespace partagé entre l'override Rust et TS.auto-updaten'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'overridecheck_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>}>(conventionSettingsLayout/AppShell), pour ne pas répéter le wrapper.- Sidebar :
NavItemgagnefeature?: FeatureKey, renseigné sur budget, adjustments et balance.reportsreste non gaté (pointe vers le hub Free). Un item non autorisé (etready) affiche un cadenas ; le clic mène à la route →RequireFeatureaffiche l'upsell. - Hub
/reports(Free) : accessible ; les tuiles vers les rapports avancés portent un cadenas. - Multi-profils :
ProfileSwitchermarque d'un cadenas les profils au-delà de l'actif quand!allowed; la création est verrouillée au point uniqueProfileFormModalpour un Free ayant déjà ≥ 1 profil. Données conservées : rien n'est supprimé deprofiles.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é surProfileContext—createContext<T|null>,useReducer, hook consommateur qui throw), charge édition + info 1×, expose{ status, edition, features, info, refresh, submitKey }. Monter dansmain.tsxAU-DESSUS deProfileProvider(licence = machine-level → survit au remountBrowserRouter key={refreshKey}sur changement de profil).- Récupération d'erreur : sur échec de boot (
getEdition/readLicensethrow →status:"error"), retry avec backoff +refreshmanuel. Les consommateurs rendent un état neutre pendantstatus==="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.tspour lireLicenseContext(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/PriceFetchConsentTogglemockentuseIsPremiumdirectement → non affectés.) - Refactor
LicenseCardpour consommer le context. - Tests :
entitlements.test.ts(matrice, overridefeatures[], 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 dansconstants/index.tssur budget, adjustments et balance — PASreports(hub Free, non route-wrappé).Sidebar.tsx: cadenas sur item non autorisé etready, 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(etready).ProfileFormModal(point unique de création, ouvert depuisProfileSwitcherETProfileSelectionPage) : 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 testfree_allows_auto_update_temporarily→free_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 TSreports-advanced→Base+). Ne laisser queauto-update. - Câbler l'override
features[]danscheck_entitlement, fail-closed en Free : résoudre l'édition ET les features via le même chemin machine-binding (features ignorées sicurrent_editiondowngrade àfree), puisis_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), PASdebug_assertions(activable sur un build release → backdoor Premium, CWE-489).current_editionlitSR_DEV_EDITIONuniquement sous cette feature ; test queSR_DEV_EDITIONn'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+ i18ndocs.*: 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, overridefeatures[], 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_EDITIONsans effet quand la featuredev-overrideest off.
Tests d'integration
- Rust :
current_edition+check_entitlementbout-en-bout avec une licence signée de test portantfeatures:[…]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 (PriceFetchControlviauseIsPremium) 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 viafeatures[]. - Aucun flash « verrouillé » au boot (Sidebar, tuiles, profils, routes) —
readysupprime 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)
- 🔴 Nav
reportsnon gaté —featuresur budget + balance uniquement ; hub/reportsreste Free. ✓ - 🔴 Purger
advanced-reports/web-sync/cloud-backupdu RustFEATURE_TIERS(Issue 5) ; mitigation « double source » corrigée. ✓ - 🟡
useEntitlement→{ allowed, ready }; tous les consommateurs suppriment le cadenas tant que!ready. ✓ - 🟡 Override
features[]fail-closed en Free (TSisEntitled+ Rustcheck_entitlement) — CWE-863. ✓ - 🟡
SR_DEV_EDITIONderrière une Cargo featuredev-override, pasdebug_assertions— CWE-489. ✓ - 🟡 Récupération d'erreur du LicenseProvider (retry + état neutre, jamais upsell) — CWE-703. ✓
- 🟡 Migrer
useIsPremium.test.ts(mock du context) — ajouté à Issue 1. ✓ - 🟡 Gate création profil dans
ProfileFormModal(point unique) — fichiers ajoutés à Issue 4. ✓ - 🟢 Kebab-case
reports-advanced; layout-route groupant les routes de même feature ;/adjustments= Base (tranché par Max). ✓