feat(gating): auto-update Base+ + features[] override + dev-override (Rust) #307

Closed
maximus wants to merge 1 commit from issue-301-rust-entitlements into issue-300-multi-profile-gate
Owner

4e maillon de la pile gating (base = issue-300-multi-profile-gate, pas main). 100% Rust — le front ne bouge pas d'un octet.

Ce que ça fait (en termes JS/TS)

1. Auto-update passe Base+ (absorbe #271). FEATURE_TIERS (l'équivalent Rust de notre ENTITLEMENTS de src/shared/entitlements.ts) passe auto-update de [free, base, premium] à [base, premium]. Le commentaire « temporarily open until the license server is live » saute — le serveur est live depuis spec-monetisation. Le test free_allows_auto_update_temporarily devient free_denied_auto_update. Côté UI rien à faire : useUpdater.ts appelle déjà check_entitlement("auto-update") et gère l'état notEntitled.

2. Purge des entrées mortes. web-sync, cloud-backup, advanced-reports n'avaient aucun call-site, et advanced-reports → Premium contredisait la matrice TS (reports-advanced → Base+). Il ne reste que auto-update côté Rust — la matrice UI vit uniquement en TS, plus de double source de vérité.

3. Override features[] fail-closed en Free (CWE-863). Le JWT de licence porte un tableau signé features[] (namespace kebab-case partagé avec le TS). Nouveau résolveur current_entitlements() : il retourne (edition, features) en suivant exactement le même chemin machine-binding que l'édition — chaque chemin qui downgrade à free (clé copiée, activation.token d'une autre machine, JWT invalide/expiré) retourne ("free", []). Une licence copiée perd donc ses features signées en même temps que son édition. check_entitlement combine ensuite via la nouvelle fonction pure is_entitled() : is_feature_allowed(feature, edition) || features.includes(feature), avec le même court-circuit edition === "free" → pas d'override que notre isEntitled TS (défense en profondeur). current_edition() délègue désormais à current_entitlements().0 — une seule implémentation du chemin, aucune dérive possible entre les deux.

4. Dev-override pour tester les 3 tiers (CWE-489). Nouvelle Cargo feature dev-override — l'analogue d'un flag de compilation avec dead-code-elimination garanti : quand la feature est off (le défaut, et release ne l'active jamais), le code qui lit SR_DEV_EDITION n'existe pas dans le binaire (pas juste un if désactivé, le bloc est retiré à la compilation via #[cfg(feature = "dev-override")]). C'est pour ça qu'on n'utilise pas debug_assertions : ce dernier peut être réactivé sur un build release par config → backdoor Premium. Usage dev : cargo test --features dev-override, ou tauri dev -- --features dev-override puis SR_DEV_EDITION=premium. Valeur inconnue (typo) = ignorée, résolution normale.

Garanties de non-régression

  • Aucune signature de commande Tauri ne change : check_entitlement(feature: string): Promise<boolean> reste identique pour useUpdater.ts / ErrorPage.tsx / licenseService.ts ; get_edition inchangé.
  • Comportement de résolution d'édition inchangé au bit près (mêmes checks, mêmes priorités : Compte Maximus Premium > licence fichier > free) — seul ajout : les features voyagent avec.
  • Le chemin « compte Maximus Premium » retourne features: [] (il ne lit pas le JWT de licence, donc rien de signé à porter — et Premium a déjà tout).

Tests

  • cargo test (build normal, feature off) : 106 verts (98 baseline ��� 2 tests devenus sans objet après la purge + 10 nouveaux), dont sr_dev_edition_has_no_effect_when_feature_off qui prouve que la var d'env est inerte dans un build normal (sous feature off, aucun code ne lit la var → pas de course possible entre tests parallèles).
  • cargo test --features dev-override : 2 tests compagnons (force premium / valeur inconnue ignorée), accès env sérialisé par mutex (cargo test = threads parallèles, env = global process).
  • Machine-binding : licence Base signée portant features:["balance"] + activation.token machine-A vérifié depuis machine-B("free", []) (les features signées ne survivent pas au downgrade).
  • npm test : 871 vitest verts, npm run build (tsc + vite) vert — le front est intact.

#271 est déjà fermé superseded, rien à faire dessus.

Resolves #301
Generated autonomously by /autopilot run of 2026-07-20

4e maillon de la pile gating (base = `issue-300-multi-profile-gate`, pas `main`). 100% Rust — le front ne bouge pas d'un octet. ## Ce que ça fait (en termes JS/TS) **1. Auto-update passe Base+ (absorbe #271).** `FEATURE_TIERS` (l'équivalent Rust de notre `ENTITLEMENTS` de `src/shared/entitlements.ts`) passe `auto-update` de `[free, base, premium]` à `[base, premium]`. Le commentaire « temporarily open until the license server is live » saute — le serveur est live depuis spec-monetisation. Le test `free_allows_auto_update_temporarily` devient `free_denied_auto_update`. Côté UI rien à faire : `useUpdater.ts` appelle déjà `check_entitlement("auto-update")` et gère l'état `notEntitled`. **2. Purge des entrées mortes.** `web-sync`, `cloud-backup`, `advanced-reports` n'avaient aucun call-site, et `advanced-reports → Premium` contredisait la matrice TS (`reports-advanced → Base+`). Il ne reste que `auto-update` côté Rust — la matrice UI vit uniquement en TS, plus de double source de vérité. **3. Override `features[]` fail-closed en Free (CWE-863).** Le JWT de licence porte un tableau signé `features[]` (namespace kebab-case partagé avec le TS). Nouveau résolveur `current_entitlements()` : il retourne `(edition, features)` en suivant **exactement le même chemin machine-binding** que l'édition — chaque chemin qui downgrade à `free` (clé copiée, activation.token d'une autre machine, JWT invalide/expiré) retourne `("free", [])`. Une licence copiée perd donc ses features signées en même temps que son édition. `check_entitlement` combine ensuite via la nouvelle fonction pure `is_entitled()` : `is_feature_allowed(feature, edition) || features.includes(feature)`, avec le même court-circuit `edition === "free" → pas d'override` que notre `isEntitled` TS (défense en profondeur). `current_edition()` délègue désormais à `current_entitlements().0` — une seule implémentation du chemin, aucune dérive possible entre les deux. **4. Dev-override pour tester les 3 tiers (CWE-489).** Nouvelle **Cargo feature** `dev-override` — l'analogue d'un flag de compilation avec dead-code-elimination *garanti* : quand la feature est off (le défaut, et release ne l'active jamais), le code qui lit `SR_DEV_EDITION` **n'existe pas dans le binaire** (pas juste un `if` désactivé, le bloc est retiré à la compilation via `#[cfg(feature = "dev-override")]`). C'est pour ça qu'on n'utilise pas `debug_assertions` : ce dernier peut être réactivé sur un build release par config → backdoor Premium. Usage dev : `cargo test --features dev-override`, ou `tauri dev -- --features dev-override` puis `SR_DEV_EDITION=premium`. Valeur inconnue (typo) = ignorée, résolution normale. ## Garanties de non-régression - **Aucune signature de commande Tauri ne change** : `check_entitlement(feature: string): Promise<boolean>` reste identique pour `useUpdater.ts` / `ErrorPage.tsx` / `licenseService.ts` ; `get_edition` inchangé. - Comportement de résolution d'édition inchangé au bit près (mêmes checks, mêmes priorités : Compte Maximus Premium > licence fichier > free) — seul ajout : les features voyagent avec. - Le chemin « compte Maximus Premium » retourne `features: []` (il ne lit pas le JWT de licence, donc rien de signé à porter — et Premium a déjà tout). ## Tests - `cargo test` (build normal, feature off) : **106 verts** (98 baseline ��� 2 tests devenus sans objet après la purge + 10 nouveaux), dont `sr_dev_edition_has_no_effect_when_feature_off` qui prouve que la var d'env est inerte dans un build normal (sous feature off, aucun code ne lit la var → pas de course possible entre tests parallèles). - `cargo test --features dev-override` : 2 tests compagnons (force premium / valeur inconnue ignorée), accès env sérialisé par mutex (cargo test = threads parallèles, env = global process). - Machine-binding : licence Base signée portant `features:["balance"]` + activation.token `machine-A` vérifié depuis `machine-B` → `("free", [])` (les features signées ne survivent pas au downgrade). - `npm test` : **871 vitest** verts, `npm run build` (tsc + vite) vert — le front est intact. #271 est déjà fermé superseded, rien à faire dessus. Resolves #301 Generated autonomously by /autopilot run of 2026-07-20
maximus added 1 commit 2026-07-21 02:36:06 +00:00
Re-gate auto-update to Base+Premium now that paid activation works
end-to-end (absorbs #271), and align the Rust entitlement layer with the
TS matrix shipped in #297:

- FEATURE_TIERS: auto-update -> [base, premium]; the 'temporarily open'
  carve-out and its test are gone (free_allows_auto_update_temporarily
  -> free_denied_auto_update). Dead rows web-sync, cloud-backup and
  advanced-reports are purged (no call-site anywhere; advanced-reports
  -> Premium contradicted the TS reports-advanced -> Base+ matrix).
  Only auto-update remains on the Rust side.
- features[] override, fail-closed in Free (CWE-863): new
  current_entitlements() resolves the edition AND the signed features[]
  through the same machine-binding path — every downgrade path returns
  ('free', []) so a copied license.key can never keep its signed
  features. check_entitlement combines them via the new pure
  is_entitled(): is_feature_allowed(feature, edition) ||
  features.contains(feature), with a defense-in-depth free short-circuit
  mirroring the TS isEntitled. current_edition() now delegates to
  current_entitlements() — single resolution path, no drift possible.
- dev-override: new Cargo feature (off by default, never in a release
  feature set — CWE-489: debug_assertions could be flipped on a custom
  release build and become a Premium backdoor). Only when compiled in,
  SR_DEV_EDITION forces the edition (free|base|premium) to test tiers
  locally. A feature-off test proves the env var has zero effect in
  normal builds; feature-on companions (env access serialized by a
  mutex) cover cargo test --features dev-override.

No Tauri command signature changes: check_entitlement keeps its
(feature: String) -> Result<bool, String> contract for useUpdater.ts
and ErrorPage.tsx.

Validation: cargo check + cargo test (106 passed, feature off) +
cargo test --features dev-override + npm test (871) + npm run build.

Resolves #301

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
maximus added the
status:review
type:feature
source:human
autopilot:pending-human
labels 2026-07-21 02:36:15 +00:00
Author
Owner

Review adversariale — APPROVE

Résumé : Implémentation fidèle et complète de l'Issue 5 du plan gating (5/5 cases) : auto-update Base+ (#271 absorbé), purge des 3 entrées mortes, override features[] fail-closed en Free, dev-override sous Cargo feature. Les deux exigences sécurité du /review-spec sont correctement posées. Aucune signature de commande Tauri ne change, aucune migration touchée, 100% Rust.

Points vérifiés au-delà du diff

  • Rétrocompat licences existantes (risque n°1) : LicenseClaims.features porte #[serde(default)] (license_commands.rs:58, préexistant) — une licence Base déjà émise sans champ features désérialise en [] au lieu d'échouer la validation. Pas de downgrade des licences en circulation.
  • CWE-863 fail-closed à deux niveaux : chaque chemin de downgrade de resolve_license_entitlements/current_entitlements retourne ("free", []), et is_entitled court-circuite l'override quand edition == free (défense en profondeur, alignée sur le isEntitled TS). Testé des deux côtés (override_ignored_in_free, machine_mismatch_downgrades_to_free_and_drops_features).
  • CWE-489 : dev-override hors default et vérifié absent des builds release — release.yml lance npx tauri build sans --features. Sous feature off, le code lisant SR_DEV_EDITION n'existe pas dans le binaire ; le test d'inertie tourne dans la CI normale.
  • Purge sûre : aucun call-site réel de web-sync/cloud-backup/advanced-reports sur la branche (seuls hits : specs, STATE.md, et entitlements.test.ts"web-sync" sert de feature inconnue castée as FeatureKey).
  • Équivalence bit-près avec l'ancien current_edition : mêmes checks, mêmes priorités (Compte Premium > licence fichier > free), même posture stricte (token/machine-id illisible → free). Seule différence : l'ordre I/O (lecture du token d'activation avant la validation du JWT), sans effet observable. current_edition délègue à current_entitlements().0 — une seule implémentation, zéro dérive possible.
  • Édition Rust 2021std::env::set_var est une API safe, les tests env compilent ; les compagnons feature-on sérialisent l'accès env par mutex.
  • CHANGELOG absent : conforme au plan — l'Issue 6 (#302, dépendante des Issues 1-5) porte « une entrée globale ».
  • Cohérence TS/Rust : plus aucun chevauchement de namespace (auto-update uniquement côté Rust, matrice UI uniquement TS), kebab-case partagé, validate_with_key rejette toute édition hors {base, premium} donc is_entitled ne voit jamais d'édition exotique.

Suggestions (non bloquantes)

  1. #302 : s'assurer que l'entrée CHANGELOG globale mentionne explicitement l'auto-update désormais Base+ — c'est le changement le plus concret pour un utilisateur Free, et le libellé du plan (« modules désormais Base/Premium ») pourrait le faire oublier.
  2. Les tests compagnons dev_override_on ne tournent jamais en CI (check.yml ne passe pas la feature) — assumé dans le body ; si le dev-override devient un outil quotidien, un job ponctuel cargo test --features dev-override fermerait le trou.
  3. Rappel pile : PR ciblant issue-300-multi-profile-gate → aucune CI n'a tourné dessus. Valider le tip cumulé en local avant le merge de la pile (tsc + vite build, vitest, cargo check, cargo test).

Review /pr-review — vérifications read-only sur origin/issue-301-rust-entitlements (git show/grep, sans checkout).

## Review adversariale — APPROVE **Résumé** : Implémentation fidèle et complète de l'Issue 5 du plan gating (5/5 cases) : auto-update Base+ (#271 absorbé), purge des 3 entrées mortes, override `features[]` fail-closed en Free, dev-override sous Cargo feature. Les deux exigences sécurité du `/review-spec` sont correctement posées. Aucune signature de commande Tauri ne change, aucune migration touchée, 100% Rust. ### Points vérifiés au-delà du diff - **Rétrocompat licences existantes** (risque n°1) : `LicenseClaims.features` porte `#[serde(default)]` (`license_commands.rs:58`, préexistant) — une licence Base déjà émise sans champ `features` désérialise en `[]` au lieu d'échouer la validation. Pas de downgrade des licences en circulation. - **CWE-863 fail-closed à deux niveaux** : chaque chemin de downgrade de `resolve_license_entitlements`/`current_entitlements` retourne `("free", [])`, et `is_entitled` court-circuite l'override quand `edition == free` (défense en profondeur, alignée sur le `isEntitled` TS). Testé des deux côtés (`override_ignored_in_free`, `machine_mismatch_downgrades_to_free_and_drops_features`). - **CWE-489** : `dev-override` hors `default` et vérifié absent des builds release — `release.yml` lance `npx tauri build` sans `--features`. Sous feature off, le code lisant `SR_DEV_EDITION` n'existe pas dans le binaire ; le test d'inertie tourne dans la CI normale. - **Purge sûre** : aucun call-site réel de `web-sync`/`cloud-backup`/`advanced-reports` sur la branche (seuls hits : specs, STATE.md, et `entitlements.test.ts` où `"web-sync"` sert de feature *inconnue* castée `as FeatureKey`). - **Équivalence bit-près** avec l'ancien `current_edition` : mêmes checks, mêmes priorités (Compte Premium > licence fichier > free), même posture stricte (token/machine-id illisible → free). Seule différence : l'ordre I/O (lecture du token d'activation avant la validation du JWT), sans effet observable. `current_edition` délègue à `current_entitlements().0` — une seule implémentation, zéro dérive possible. - **Édition Rust 2021** → `std::env::set_var` est une API safe, les tests env compilent ; les compagnons feature-on sérialisent l'accès env par mutex. - **CHANGELOG absent : conforme au plan** — l'Issue 6 (#302, dépendante des Issues 1-5) porte « une entrée globale ». - **Cohérence TS/Rust** : plus aucun chevauchement de namespace (`auto-update` uniquement côté Rust, matrice UI uniquement TS), kebab-case partagé, `validate_with_key` rejette toute édition hors {base, premium} donc `is_entitled` ne voit jamais d'édition exotique. ### Suggestions (non bloquantes) 1. **#302** : s'assurer que l'entrée CHANGELOG globale mentionne explicitement l'auto-update désormais Base+ — c'est le changement le plus concret pour un utilisateur Free, et le libellé du plan (« modules désormais Base/Premium ») pourrait le faire oublier. 2. Les tests compagnons `dev_override_on` ne tournent jamais en CI (`check.yml` ne passe pas la feature) — assumé dans le body ; si le dev-override devient un outil quotidien, un job ponctuel `cargo test --features dev-override` fermerait le trou. 3. Rappel pile : PR ciblant `issue-300-multi-profile-gate` → aucune CI n'a tourné dessus. Valider le tip cumulé en local avant le merge de la pile (tsc + vite build, vitest, cargo check, cargo test). *Review /pr-review — vérifications read-only sur `origin/issue-301-rust-entitlements` (git show/grep, sans checkout).*
Author
Owner

Mergé en fast-forward sur main (pile #304→#308), voir commit 17833cf. Verdict /pr-review : APPROVE. Fermeture manuelle : le merge local ff n est pas détecté comme merged par Forgejo.

Mergé en fast-forward sur main (pile #304→#308), voir commit 17833cf. Verdict /pr-review : APPROVE. Fermeture manuelle : le merge local ff n est pas détecté comme *merged* par Forgejo.
maximus closed this pull request 2026-07-22 00:23:44 +00:00

Pull request closed

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#307
No description provided.