docs(spec): apply /review-spec corrections to feature-gating plan (2 critical + 6 improvements)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
99ba147906
commit
a100ee287b
1 changed files with 109 additions and 77 deletions
|
|
@ -2,57 +2,62 @@
|
|||
|
||||
> Date: 2026-07-19
|
||||
> Projet: simpl-resultat
|
||||
> Statut: Draft
|
||||
> Statut: Draft (revu — voir Révision — Synthèse)
|
||||
> Slug: feature-gating
|
||||
> Decisions: [spec-decisions-feature-gating.md](./spec-decisions-feature-gating.md)
|
||||
|
||||
## Ancrage code (vérifié 2026-07-19)
|
||||
|
||||
- **Rapports = routes distinctes** (`App.tsx:117-122`) : `/reports` (hub), `/reports/highlights|trends|compare|category|cartes`. Le gating « par onglet » = **gating par route**.
|
||||
- **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`) ne déclare que 4 features ; `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.
|
||||
- **`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`) → l'override par-licence est **gratuit côté TS**.
|
||||
- **Sidebar** lit `NAV_ITEMS` (`constants/index.ts:6-61`), liste de config `{key, path, icon, labelKey}`. **`useIsPremium`** (`useIsPremium.ts`) remonte `useLicense` (à rebrancher sur le Context). **`LicenseCard`** est monté à `/settings/users` (`UsersSettingsPage.tsx:34`) → cible du lien « J'ai déjà une clé ».
|
||||
- **`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). Nouveau : une **couche d'entitlements côté TS** pour un gating UI synchrone.
|
||||
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)
|
||||
├── edition, features[], info, refresh, submitKey
|
||||
LicenseProvider (context, chargé 1× au boot, monté AU-DESSUS de ProfileProvider dans main.tsx)
|
||||
├── status, edition, features[], info, refresh, submitKey
|
||||
└── consommé par :
|
||||
useEntitlement(featureKey) → sync, lit ENTITLEMENTS + override features[]
|
||||
useIsPremium() → refactoré pour lire le context (au lieu d'un useLicense par-appel)
|
||||
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 :
|
||||
```ts
|
||||
export type FeatureKey = "budget" | "reports.advanced" | "multi-profile" | "balance";
|
||||
export type FeatureKey = "budget" | "reports-advanced" | "multi-profile" | "balance";
|
||||
export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
|
||||
"budget": ["base", "premium"],
|
||||
"reports.advanced":["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);
|
||||
}
|
||||
```
|
||||
`auto-update` n'est **pas** ici : il reste géré côté Rust (`FEATURE_TIERS`), car c'est le seul gate consulté par du code Rust. Les modules Free (dashboard, import, transactions, catégories, `reports/trends`, export, changelog, docs) n'ont **pas** de clé → jamais gatés.
|
||||
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`, **adjustments**, export, changelog, docs) = pas de clé → jamais gatés.
|
||||
|
||||
- **Enforcement UI-only** (soft-paywall assumé) : aucune commande Tauri ne refuse (sauf l'existant : cours server-enforced). Le Rust ne change que pour `auto-update` (#271) + le câblage de l'override dans `check_entitlement`.
|
||||
- **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
|
||||
|
||||
- **`<UpsellGate feature requiredTier>`** (nouveau, `src/components/shared/`) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée par feature, deux CTA : « Obtenir <tier> » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → `navigate("/settings/users")`. i18n FR/EN.
|
||||
- **`<RequireFeature feature>`** (nouveau) : `useEntitlement(feature) ? children : <UpsellGate…>`. Wrappe les routes gatées.
|
||||
- **Sidebar** : `NavItem` gagne `feature?: FeatureKey`. Un item non autorisé reste **visible avec un cadenas** (icône `Lock`) ; le clic mène à la route → `RequireFeature` affiche l'upsell (décision « verrouillé + upsell », pas masqué).
|
||||
- **Hub `/reports`** (Free) : accessible, mais 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 `!useEntitlement("multi-profile")` ; « créer un profil » est verrouillé (upsell) pour un Free ayant déjà ≥ 1 profil. **Données conservées** : rien n'est supprimé de `profiles.json`, seul l'accès est bloqué.
|
||||
- **`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 <tier> » (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 et balance uniquement**. `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
|
||||
|
||||
|
|
@ -62,50 +67,53 @@ Aucune nouvelle table, **aucune migration DB** — le gating lit la licence (fic
|
|||
|
||||
### Issue 1 — Socle : LicenseProvider + matrice d'entitlements + useEntitlement [type:feature]
|
||||
Dependances : aucune
|
||||
- [ ] `src/contexts/LicenseContext.tsx` : provider encapsulant la logique de `useLicense` (charge édition + info 1×), expose `{ status, edition, features, info, refresh, submitKey }`. Monter dans `main.tsx` au niveau racine (licence = machine-level, au-dessus de `ProfileProvider`).
|
||||
- [ ] `src/shared/entitlements.ts` : type `FeatureKey`, `ENTITLEMENTS`, `isEntitled()` (pur).
|
||||
- [ ] `src/hooks/useEntitlement.ts` : `useEntitlement(f: FeatureKey): boolean` (sync, lit le context).
|
||||
- [ ] Refactorer `src/hooks/useIsPremium.ts` pour lire `LicenseContext` (behavior-preserving) → supprime le double-invoke sur `PriceFetchControl`/`PriceFetchConsentToggle`.
|
||||
- [ ] Refactorer `LicenseCard` pour consommer le context (au lieu de son `useLicense` local).
|
||||
- [ ] Tests : `entitlements.test.ts` (matrice, override `features[]`, édition inconnue).
|
||||
- [ ] `src/contexts/LicenseContext.tsx` : provider (modelé sur `ProfileContext` — `createContext<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é (feature, tier requis), 2 CTA.
|
||||
- [ ] `src/components/shared/RequireFeature.tsx` : wrapper de route.
|
||||
- [ ] i18n `src/i18n/locales/{fr,en}.json` : clés `upsell.*` (titres/descriptions par feature, CTA) + `nav.locked` (aria cadenas).
|
||||
- [ ] `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` : wrapper `<RequireFeature feature="budget">` sur `/budget` ; `"reports.advanced"` sur `/reports/highlights|compare|category|cartes` (PAS `/reports/trends`, PAS `/reports` hub) ; `"balance"` sur `/balance`, `/balance/accounts`, `/balance/snapshot`.
|
||||
- [ ] `src/shared/types` : `NavItem` + `feature?: FeatureKey`. `constants/index.ts` : renseigner `feature` sur budget/reports/balance.
|
||||
- [ ] `Sidebar.tsx` : cadenas sur item non autorisé (via `useEntitlement`).
|
||||
- [ ] `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`.
|
||||
- [ ] `NavItem` (`src/shared/types`) + `feature?: FeatureKey` ; renseigner dans `constants/index.ts` sur **budget et balance uniquement** — **PAS `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 [type:feature]
|
||||
Dependances : Issue 1 (+ Issue 2 pour l'upsell)
|
||||
- [ ] `ProfileSwitcher` : cadenas + upsell sur profils au-delà de l'actif si `!multi-profile`.
|
||||
- [ ] Bouton « créer un profil » (ProfileSwitcher / ProfileSelectionPage) : verrouillé pour Free ayant ≥ 1 profil.
|
||||
- [ ] Garde non-destructive : ne rien retirer de `profiles.json`.
|
||||
### 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_temporarily` → `free_denied_auto_update`.
|
||||
- [ ] Câbler l'**override `features[]`** dans `check_entitlement` : `is_feature_allowed(feature, edition) || license_features.contains(feature)` (charger les features de la licence courante).
|
||||
- [ ] Dev override (confort de test) : en `#[cfg(debug_assertions)]`, `current_edition` lit `SR_DEV_EDITION` (jamais en build release) pour tester les 3 tiers sans jongler avec des clés.
|
||||
- [ ] Ferme/rescope l'issue Forgejo #271.
|
||||
- [ ] **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` : modèle (matrice tier→features + override par-licence, enforcement UI-only, soft-paywall GPL assumé).
|
||||
- [ ] 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 : liste des modules désormais Base/Premium).
|
||||
- [ ] 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 1 → Issue 4
|
||||
→ Issue 4 (Issue 4 dépend de 1 ET 2)
|
||||
Issue 5 (indépendant, parallèle)
|
||||
Issues 1-5 → Issue 6
|
||||
```
|
||||
|
|
@ -114,59 +122,83 @@ Issues 1-5 → Issue 6
|
|||
|
||||
| Fichier | Action | Raison |
|
||||
|---|---|---|
|
||||
| `src/contexts/LicenseContext.tsx` | Créer | Provider licence partagé (1 chargement) |
|
||||
| `src/shared/entitlements.ts` | Créer | Matrice `ENTITLEMENTS` + `isEntitled` (pur, testable) |
|
||||
| `src/hooks/useEntitlement.ts` | Créer | Hook sync de check |
|
||||
| `src/hooks/useIsPremium.ts` | Modifier | Lire le context (dé-duplique l'invoke) |
|
||||
| `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` | Créer | Écran verrouillé + CTA |
|
||||
| `src/components/shared/RequireFeature.tsx` | Créer | Wrapper de route |
|
||||
| `src/App.tsx` | Modifier | Wrapper les routes budget / reports avancés / balance |
|
||||
| `src/shared/types` (NavItem) | Modifier | Champ `feature?` |
|
||||
| `src/shared/constants/index.ts` | Modifier | Renseigner `feature` sur les items gatés |
|
||||
| `src/components/layout/Sidebar.tsx` | Modifier | Cadenas sur item non autorisé |
|
||||
| `src/components/shared/UpsellGate.tsx`, `RequireFeature.tsx` | Créer | Écran verrouillé + wrapper (layout-route) |
|
||||
| `src/App.tsx` | Modifier | Layout-routes budget / reports-advanced / balance |
|
||||
| `src/shared/types` (NavItem), `src/shared/constants/index.ts` | Modifier | `feature?` sur budget + balance uniquement |
|
||||
| `src/components/layout/Sidebar.tsx` | Modifier | Cadenas (si `ready`) |
|
||||
| `src/pages/ReportsPage.tsx` | Modifier | Cadenas sur tuiles avancées |
|
||||
| `src/components/profile/ProfileSwitcher.tsx` | Modifier | Cadenas profils + gate création |
|
||||
| `src-tauri/src/commands/entitlements.rs` | Modifier | auto-update Base+ (#271) + override `features[]` |
|
||||
| `src-tauri/src/commands/license_commands.rs` | Modifier | Dev override `SR_DEV_EDITION` (debug only) |
|
||||
| `src/i18n/locales/{fr,en}.json` | Modifier | Clés `upsell.*`, `nav.locked` |
|
||||
| `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[]` (une licence Free avec `features:["budget"]` débloque budget), feature absente.
|
||||
- Rust `entitlements.rs` : auto-update denied Free / allowed Base+Premium ; override (`is_feature_allowed` OR `license.features`).
|
||||
- `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 (le harness de test de `license_commands.rs` signe déjà des JWT) portant `features:[…]`.
|
||||
- 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) — figer via un test de `isEntitled`/edition avant de toucher `PriceFetchControl`.
|
||||
- Suite existante verte (le gate cours via `useIsPremium` ne doit pas régresser).
|
||||
- `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 → les composants React (`RequireFeature`, `UpsellGate`, `Sidebar`, `ProfileSwitcher`) ne sont pas rendables en test. La logique testable vit dans `isEntitled` (TS pur) et les checks Rust ; le rendu est délégué à la vérif runtime / `/pr-review`.
|
||||
**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, les 4 rapports avancés et le Bilan **verrouillés** (cadenas + upsell), garde Dashboard/Import/Transactions/Catégories/Tendance/Export/Changelog.
|
||||
- [ ] Un Free voit Budget, les 4 rapports avancés et le Bilan **verrouillés** (cadenas + upsell), garde Dashboard/Import/Transactions/Catégories/Tendance/Adjustments/Export/Changelog + le hub `/reports`.
|
||||
- [ ] Un Base débloque Budget, tous les rapports, multi-profils, auto-update ; Bilan reste verrouillé.
|
||||
- [ ] Un Premium a tout.
|
||||
- [ ] Le durcissement est **non destructif** : les budgets/Bilan/profils déjà créés réapparaissent intacts après upgrade.
|
||||
- [ ] Un Free avec 2 profils garde l'actif ; les autres sont verrouillés (conservés) ; « créer profil » est verrouillé.
|
||||
- [ ] 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 portant `features:["<clé>"]` débloque cette feature quel que soit le tier (override).
|
||||
- [ ] `useEntitlement` est synchrone (pas de flash « verrouillé » au chargement une fois la licence lue).
|
||||
- [ ] 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` non classé dans la matrice | **À trancher** — défaut proposé : Free (proche des Transactions), pas de gate. Confirmer avec Max. |
|
||||
| Max se bloque de son propre Bilan en dev (dev = édition `free`) | Dev override `SR_DEV_EDITION=premium` (`#[cfg(debug_assertions)]`) — Issue 5. Sinon, poser une vraie clé Premium locale. |
|
||||
| Flash « verrouillé » pendant le chargement de la licence au boot | `useEntitlement` renvoie un état pendant `status !== "ready"` : rendre un loader (pas l'upsell) tant que la licence n'est pas résolue. |
|
||||
| Désync matrice TS ↔ Rust | Non applicable : la matrice UI vit **uniquement** en TS ; Rust ne gère qu'`auto-update`. Pas de double source pour les features UI. |
|
||||
| Rollout : gating partiel sur `main` entre les issues | Sans effet en prod — le durcissement ne devient visible qu'à la prochaine **release** taggée. Ordre des issues garde `main` cohérent (socle avant gardes). |
|
||||
| Contournement (fork retire le `if`) | Assumé (GPL, soft-paywall). Hors périmètre — seul le gate serveur (cours) est dur. |
|
||||
| Override `features[]` sur une Premium-via-compte (pas de license.key) | L'override vient du JWT `license.key` ; un Premium-via-abonnement n'a pas de `features[]` custom → comportement normal (tier seul). |
|
||||
| `/adjustments` (écritures manuelles + split de transactions) | **Tranché : Free** (adossé aux Transactions). Pas de clé, pas de gate. |
|
||||
| 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 (sauf `/adjustments`, tranché Free par défaut).
|
||||
|
||||
### 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` = Free. ✓
|
||||
|
|
|
|||
Loading…
Reference in a new issue