Simpl-Resultat/docs/adr/0017-feature-gating-par-tier.md
le king fu e24c30fe6a feat(license): online revocation check — a revoked key falls back to Free (#365)
A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.

Rust
- license.check, next to the key, holds what the license server last said
  about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
  missing, unreadable or invalid file, or a state about another key, reads
  as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
  locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
  the key only. Nothing is sent without a stored key that validates, nor
  within 24 h of the last attempt, which is written before the request.
  Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
  under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
  premium_required. It records nothing: the price route judges the license
  of the activation token, which the client cannot tie to the stored key,
  and 0.15.0 left the token of a previous key next to a new one (#367).

Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
  session flag and markRevoked are gone. The check starts once per app
  start, writes its outcome to the in-app log, and reloads the context only
  when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.

Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:45:34 -04:00

147 lines
22 KiB
Markdown

# ADR 0017 — Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé
- Status: **Accepted**
- Date: 2026-07-20
- Issues: #297 (socle LicenseContext + matrice + `useEntitlement`), #298 (garde `RequireFeature` + `UpsellGate`), #299 (routes gatées + cadenas Sidebar/tuiles), #300 (multi-profils non destructif), #301 (Rust : `auto-update` Base+, `features[]`, dev-override), #302 (cette doc)
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md)
- S'appuie sur la licence JWT Ed25519 (Phase 3b monétisation ; liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
> **Amendement du 2026-09-27 (#346)** — le CTA d'achat n'est plus inerte. « Obtenir Base » est actif sur tous les écrans verrouillés Base : il ouvre la page produit dans la langue de l'app (`purchaseUrl`, livré par #270, via le composant `PurchaseButton`). Premium reste désactivé, avec la note « Premium n'est pas encore offert en ligne. », tant qu'aucune offre Premium n'est vendable ; la règle vit dans une fonction pure testée, `upsellCtaFor` (`src/shared/upsellCta.ts`). En édition Gratuite, la carte Mises à jour et la page d'erreur proposent aussi « Obtenir Base » et « Télécharger la dernière version » (page produit), sans vérification de mise à jour ni appel réseau. `ProfileFormModal` réutilise désormais `UpsellGate` : le « mode upsell compact » a disparu, et « J'ai déjà une clé » ne s'affiche que sous un routeur (`HaveKeyButton`). Réserve sur la carte Compte Maximus : son texte dit que les éditions viennent de la clé de licence, pas du compte, ce qui n'est exact qu'en pratique. `current_entitlements` accorde encore Premium à un compte dont le cache signé porte un abonnement actif (`check_account_edition`, `license_commands.rs`), comme le rappelle le Contexte ci-dessous. Ce chemin reste dormant tant qu'aucun abonnement n'est vendu ; le jour où un abonnement existera, il faudra revoir ce texte ou retirer le chemin. Le reste de la décision est inchangé.
> **Amendement du 2026-09-29 (#347)** — l'édition locale ne dépend plus du jeton d'activation (décision D12, qui ferme #360). La contrainte 2 du Contexte affirmait qu'une `license.key` copiée sur une autre machine était déclassée en `free` : c'était faux pour une clé copiée **seule**, qui gardait son édition (état « pré-activation » toléré). Seule la copie clé + jeton retombait en Gratuite, c'est-à-dire le cas d'un utilisateur légitime qui restaure ses données après une réinstallation ou un changement de poste ; et une machine activée perdait son édition à l'expiration de son jeton, signé pour 2 ans alors que la clé en dure 100 depuis maximus-api#63. La règle ne protégeait donc rien et piégeait des acheteurs. Désormais `current_entitlements` résout l'édition et les `features[]` depuis la **seule clé** : une clé valide et non expirée donne son édition, quel que soit le jeton ; une clé invalide ou expirée donne `("free", [])`, et le court-circuit fail-closed en Free (CWE-863) ne vise plus que ce cas. La licence n'est liée à la machine que **côté serveur** : activation, limite de machines, et cours du Bilan (`/v1/prices` exige un jeton valide, [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Le jeton garde une validation stricte là où il décide de quelque chose : l'état « Activée » de la carte Licence (`get_activation_status`) et l'écriture d'un jeton reçu du serveur. Enregistrer une autre clé supprime le jeton de la précédente : il désignerait l'ancienne licence auprès du serveur des cours. La clé reste validée strictement (`exp` et `iat` exigés, CWE-613). Les mentions de liaison à la machine plus bas (Contexte, §1, §5, Liens) se lisent à la lumière de cet amendement.
> **Amendement du 2026-10-02 (#365)** — une clé révoquée par le serveur de licences retombe en Gratuite, et l'app envoie pour cela sa première requête non sollicitée. Constat du go-live (2026-09-30) : une licence remboursée, révoquée côté serveur, restait pleinement fonctionnelle, mises à jour automatiques comprises. L'édition se résout hors ligne depuis la clé, dont l'échéance est à 100 ans depuis maximus-api#63, et le signalement de #347 (D13, « rien n'est persisté ») ne vivait que le temps de la session, sans toucher l'édition. Les conditions de vente disent pourtant que le remboursement entraîne la révocation de la licence.
>
> **Ce qui change.** D13 est remplacée pour le cas révoqué : l'état vit dans `license.check`, à côté de la clé, sous la forme `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. `entitlements_from_dir` rend `("free", [])` quand cet état dit `revoked` pour la clé enregistrée : `get_edition`, `check_entitlement` (`auto-update`) et la matrice du front suivent sans autre changement. D12 est inchangée, le jeton d'activation ne pèse toujours pas sur l'édition. Un fichier absent, illisible, au JSON invalide, ou qui parle d'une autre clé se lit « pas d'état » : la clé n'est pas tenue pour révoquée et un contrôle est dû. Un fichier corrompu ne rétrograde donc jamais un client légitime, et une autre clé enregistrée reprend son édition.
>
> **La requête au démarrage, et ses bornes.** Jusqu'ici aucune requête ne partait au lancement : les mises à jour, les cours et la rétroaction partent sur un clic, et le contrôle d'un compte Maximus connecté à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction. `check_license_revocation` est la première exception, bornée ainsi : (1) rien ne part sans une clé enregistrée qui se valide localement ; (2) au plus une tentative par 24 h, quel qu'en soit le résultat, parce que `attempted_at` est écrit **avant** l'appel ; (3) la requête est `POST /v1/licenses/verify` avec le produit et la clé, sans identifiant machine, nom d'ordinateur ni donnée d'usage ; (4) chaque contrôle écrit son issue dans le Journal de l'app, y compris « rien envoyé » quand la dernière tentative a moins de 24 h ; (5) le contrôle ne part qu'au démarrage, sans minuteur en cours de session, et une clé saisie en cours de session est contrôlée au démarrage suivant. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app) et un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition).
>
> **Fail-open.** Seule une réponse « révoquée » pose l'état, seule une réponse `valid:true` le lève. Erreur réseau, délai dépassé, 429, 5xx, réponse illisible, `not_found` et `expired` le laissent tel quel : un utilisateur hors ligne n'est jamais rétrogradé, et une clé connue comme révoquée le reste. Aucun chemin serveur ne réactive une licence aujourd'hui ; la levée ne sert qu'à une correction manuelle de la base.
>
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis », et le jeton périmé est suivi en #367.
>
> **Côté interface.** `LicenseContext` lit l'état depuis Rust (`info.revoked`) et ne décide plus rien ; la carte Licence et chaque écran verrouillé (`UpsellGate`) nomment la révocation, sans bandeau global. Une clé révoquée ressaisie reste en Gratuite.
>
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage), et une app laissée ouverte n'est pas recontrôlée avant son redémarrage. La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site est suivie par maximus/la-compagnie-maximus#184.
## Contexte
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 (liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête ; ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
| Module | Gratuite | Base | Premium |
|---|---|---|---|
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ |
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
| Profil (un seul) | ✓ | ✓ | ✓ |
| Budget | — | ✓ | ✓ |
| Ajustements | — | ✓ | ✓ |
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
| Profils multiples | — | ✓ | ✓ |
| Mises à jour automatiques | — | ✓ | ✓ |
| Bilan complet (patrimoine, détail par titre, cours) | — | — | ✓ |
Pas de quatrième édition « admin » : Max s'auto-émet une licence Premium.
Trois contraintes structurent la solution :
1. **L'app est GPL-3.0.** Tout enforcement embarqué dans le client est contournable par recompilation — un « durcissement » côté Rust n'apporterait aucune garantie réelle, seulement de la complexité (un aller-retour IPC par gate, gestion d'erreur, latence au boot d'une app offline-first).
2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863). *Inexact avant même #347 : une clé copiée sans son jeton gardait son édition ; depuis #347 le jeton ne déclasse plus rien, seule une clé invalide ou expirée retombe en `free` (voir l'amendement en tête).*
3. **Le contexte licence n'existait pas côté React.** `useLicense` invoquait `get_edition` à chaque appel (asynchrone, par composant) — inutilisable pour gater des routes et des items de navigation sans flash ni cascade d'IPC.
## Décision
### 1. Matrice statique côté UI + override signé `features[]`, fail-closed en Free
La source de vérité front est `src/shared/entitlements.ts` : un type `FeatureKey` fermé (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance` — kebab-case car le namespace est partagé avec le `features[]` du JWT et avec Rust) et une table `ENTITLEMENTS: Record<FeatureKey, Edition[]>`. Les modules Free n'ont **pas** de clé : ce qui n'est pas dans la matrice n'est jamais gaté.
`isEntitled(feature, edition, licenseFeatures)` combine la matrice et l'override signé par-licence, avec un court-circuit **fail-closed en Free** (CWE-863) :
```ts
if (edition === "free") return false; // l'override ne ressuscite JAMAIS une clé déclassée
return tiers.includes(edition) || licenseFeatures.includes(feature);
```
L'override `features[]` sert à débloquer une fonctionnalité au-dessus du tier d'une licence payante (ex. une licence Base portant `balance`), jamais à secourir une édition `free` — qu'elle soit native ou issue d'un déclassement machine-binding. Le même court-circuit existe côté Rust (`entitlements::is_entitled`), en défense en profondeur : `current_entitlements` retourne déjà `("free", [])` sur tout échec de validation, donc les features signées sont perdues dès le déclassement, et le court-circuit les refuse même si un chemin futur les laissait passer. `requiredTierFor(feature)` (dérivé de l'appartenance à la matrice, pas de l'ordre du tableau) alimente le libellé d'upsell « Obtenir Base / Premium ».
### 2. Enforcement UI-only — soft-paywall GPL assumé
Le gating est appliqué **uniquement dans l'interface** (routes, navigation, points d'entrée). C'est un choix explicite, pas un oubli : sous GPL, un utilisateur qui recompile l'app sans les gardes est un cas assumé — le paywall s'adresse à l'utilisateur des binaires officiels, pas à un adversaire. La seule fonctionnalité réellement enforced l'est **côté serveur** : la récupération de cours (Premium) passe par maximus-api qui vérifie la licence à chaque requête ([ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Côté Rust, `FEATURE_TIERS` ne conserve que `auto-update` → Base+ (absorbe l'issue #271) — la matrice UI vit uniquement en TS, les deux tables ont des rôles disjoints.
### 3. `LicenseProvider` machine-level + `useEntitlement` synchrone anti-flash
`LicenseContext` est monté **au-dessus** de `ProfileProvider` : la licence est une propriété de la machine, pas du profil actif, et le provider survit au remount `BrowserRouter key={refreshKey}` déclenché par un changement de profil. Il charge édition + licence **une fois au boot**, puis :
- **Récupération d'erreur (CWE-703)** : le provider est un point unique de défaillance ; si l'invoke de boot échoue, l'état passe à `status: "error"` (édition précédente préservée) et un retry à backoff exponentiel plafonné (1 s → 30 s) relance le chargement. Les consommateurs rendent un placeholder neutre tant que `status !== "ready"` — jamais l'upsell — pour qu'une panne IPC transitoire ne verrouille pas un client payant.
- **Validation de clé orthogonale** : un `submitKey` rejeté (clé mal saisie) alimente `validationError` sans toucher `status` — pas de flash de verrouillage sur une typo, et la boucle de retry ne peut pas s'armer sur une erreur de validation.
`useEntitlement(feature)` retourne `{ allowed, ready }` (pas un booléen nu) : `allowed` est fail-closed pendant le boot (édition par défaut `free`), `ready` permet aux surfaces de supprimer le cadenas/upsell tant que la licence n'est pas résolue.
### 4. Upsell verrouillé visible, jamais masqué
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
### 5. Durcissement sec, non destructif
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
### 6. Dev-override compilé hors des builds normaux
Pour tester les trois éditions sans forger de licences, `SR_DEV_EDITION` force l'édition résolue — mais **uniquement** dans un build compilé avec la Cargo feature `dev-override` (off par défaut, `cargo test --features dev-override`). Le choix d'une feature explicite plutôt que `debug_assertions` évite qu'un artefact debug distribué par erreur embarque la porte dérobée (CWE-489) : l'activation est un acte opt-in, jamais un effet de profil de build.
## Alternatives considérées
### A. Enforcement dur côté Rust pour tous les modules — rejeté
Faire passer chaque gate par `check_entitlement` (IPC) et refuser les données côté commandes. Rejeté : sous GPL le client reste recompilable, donc la garantie est illusoire ; le coût est réel (latence, gestion d'erreur par gate, couplage des services SQL — qui n'appellent aucune commande Rust par convention — au module licence). Le seul enforcement qui vaut quelque chose est côté serveur, et il existe déjà pour les cours.
### B. Masquer les fonctionnalités non licenciées — rejeté
Retirer de la Sidebar et du hub ce que l'édition ne couvre pas. Rejeté : l'utilisateur Gratuite doit **voir** ce que Base et Premium offrent (découvrabilité = le canal de vente d'une app sans télémétrie) ; un module invisible ne se vend pas. D'où l'upsell verrouillé : cadenas + écran explicite.
### C. Downgrade destructif ou données en lecture seule exportable — rejeté
Purger ou geler les données des modules perdus au déclassement. Rejeté sans débat : contraire au principe privacy-first « vos données vous appartiennent », et transforme toute expiration de licence en incident. Le blocage d'accès réversible donne le même incitatif d'upgrade sans risque de perte.
### D. Édition admin dédiée — rejetée
Une quatrième édition pour l'usage interne de Max. Rejetée : une licence Premium auto-émise donne le même résultat sans quatrième branche dans la matrice, les tests et l'UI.
### E. `features[]` seul, sans matrice statique — rejeté
Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : chaque licence devrait énumérer toutes ses fonctionnalités (fragile à l'ajout d'un module — les licences déjà émises ne le porteraient pas), et le serveur d'émission deviendrait la seule source de vérité d'un comportement client. La matrice donne le défaut par édition ; l'override signé reste l'exception par-licence.
## Conséquences
### Positives
- **Un point de vérité par couche** : `ENTITLEMENTS` (TS) pour l'UI, `FEATURE_TIERS` (Rust) réduit à `auto-update` — rôles disjoints, namespace kebab-case partagé (`features[]` JWT lisible par les deux).
- **Fail-closed partout** : édition par défaut `free` au boot, override refusé en Free (CWE-863) des deux côtés, échec de résolution Rust → `("free", [])`.
- **Pas de flash de verrouillage** : `{ allowed, ready }` + loader neutre dans `RequireFeature` + retry backoff dans le provider — un client payant ne voit jamais l'upsell sur une erreur transitoire.
- **Zéro migration, zéro perte** : aucune table, aucun changement de schéma ; le déclassement est purement un état d'affichage réversible.
- **#271 absorbé** : `auto-update` passe Base+ par une ligne de `FEATURE_TIERS`, sans code nouveau.
### Négatives / risques actés
- **Contournable par build local** : assumé (GPL, soft-paywall). Ne jamais présenter ce gating comme une protection — la seule barrière réelle est serveur (cours).
- **CTA d'achat inerte** : « Obtenir \<tier\> » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle (voir l'amendement en tête).
- **Deux tables à ne pas confondre** : un futur gate ajouté côté Rust dans `FEATURE_TIERS` ne gaterait rien dans l'UI, et réciproquement. La règle est documentaire (cet ADR + commentaires des deux modules).
- **`dev-override` à surveiller en release** : la feature Cargo ne doit jamais apparaître dans un build publié ; le choix opt-in la rend improbable, pas impossible.
### Neutre
- Le chemin abonnement Compte Maximus (Premium) ne porte pas de `features[]` — l'override est propre aux licences JWT ; c'est cohérent, Premium débloque déjà toute la matrice.
- `useIsPremium` subsiste comme raccourci d'affichage (badge licence) au-dessus de `LicenseContext` ; `useLicense` (invoke par appel) est supprimé.
## Liens
- `src/shared/entitlements.ts` — matrice `ENTITLEMENTS`, `isEntitled` (court-circuit Free), `requiredTierFor`
- `src/contexts/LicenseContext.tsx` — provider machine-level, retry backoff (CWE-703), `validationError` orthogonal
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage), `dev_override_edition` (CWE-489)
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #367 (jeton d'activation périmé laissé par la v0.15.0)