fix(license): apply the review of #368
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m45s
PR Check — Rust / rust (pull_request) Successful in 8m54s

- A state file that cannot be written now has a test on both of its paths:
  the attempt (nothing is sent, or a read-only data directory would send a
  request at every start) and the verdict (the key is not reported revoked
  when nothing on disk says so). The first path was correct but unguarded:
  the review's mutation left all 185 tests green.
- The storage outcome no longer claims that nothing was sent: it also covers
  a verdict that could not be written. Reworded in licenseService.ts, the
  in-app log line and docs/architecture.md.
- The Updates card names a revoked key in its Free branch, between the
  message and the CTAs: a refunded Base buyer first meets the loss of
  automatic updates there (Max, 2026-10-03). The error page stays as it is.
- A revoked key keeps being checked once per 24 h while it stays stored, so
  that a correction made on the license server is picked up (Max,
  2026-10-03). Declared in the user guide, the in-app docs, the changelogs
  and the ADR 0017 amendment.

Refs #365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-03 09:29:47 -04:00
parent 4225dadebe
commit b107cf42ce
13 changed files with 103 additions and 23 deletions

View file

@ -6,14 +6,14 @@
- Achat de l'édition Base depuis chaque écran qui la requiert : « Obtenir Base » est désormais actif sur Budget, Ajustements, les rapports avancés et les profils supplémentaires, et ouvre la page produit dans la langue de l'application (#346).
- En édition Gratuite, la carte Mises à jour propose « Obtenir Base », « J'ai déjà une clé » et « Télécharger la dernière version », et la page d'erreur « Obtenir Base » et « Télécharger la dernière version ». Le lien de téléchargement ouvre la page produit, qui sert les derniers installateurs ; aucune vérification de mise à jour n'a lieu (#346).
- Vérification de la licence au démarrage : quand une clé de licence est enregistrée, l'application demande désormais au serveur de licences si elle a été révoquée, au plus une fois par 24 heures. C'est la première requête que l'application envoie d'elle-même. Elle porte le produit et la clé, rien d'autre ; rien ne part sans clé enregistrée ; si le serveur est injoignable, rien ne change ; et chaque vérification est inscrite dans les journaux (Paramètres → Système) (#365).
- Vérification de la licence au démarrage : quand une clé de licence est enregistrée, l'application demande désormais au serveur de licences si elle a été révoquée, au plus une fois par 24 heures. C'est la première requête que l'application envoie d'elle-même. Elle porte le produit et la clé, rien d'autre ; rien ne part sans clé enregistrée ; si le serveur est injoignable, rien ne change ; et chaque vérification est inscrite dans les journaux (Paramètres → Système). Une clé révoquée reste vérifiée tant qu'elle est enregistrée, pour qu'une correction faite par le serveur de licences soit reprise au démarrage suivant (#365).
### Modifié
- La carte Compte Maximus n'affirme plus qu'un compte est requis pour Premium : le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions, et les éditions viennent de la clé de licence (#346).
- L'écran verrouillé du Bilan (Premium) indique désormais que Premium n'est pas encore offert en ligne, au lieu d'« Achat en ligne bientôt disponible » (#346).
- En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346).
- Une clé de licence révoquée par le serveur de licences, par exemple après un remboursement, fait désormais repasser l'application en édition Gratuite, mises à jour automatiques comprises, et elle y reste d'une session à l'autre. La carte Licence et les écrans verrouillés indiquent que la clé a été révoquée. Vos données sont conservées, et une autre clé valide redonne son édition. Une clé révoquée gardait auparavant son édition (#347, #365).
- Une clé de licence révoquée par le serveur de licences, par exemple après un remboursement, fait désormais repasser l'application en édition Gratuite, mises à jour automatiques comprises, et elle y reste d'une session à l'autre. La carte Licence, les écrans verrouillés et la carte Mises à jour indiquent que la clé a été révoquée. Vos données sont conservées, et une autre clé valide redonne son édition. Une clé révoquée gardait auparavant son édition (#347, #365).
- La liste des machines indique combien sont activées sur le nombre permis par votre clé (#347).
- Une clé de licence valide pour plus de 50 ans affiche « Sans échéance » au lieu d'une date (#347).
- L'erreur d'authentification des cours du marché indique désormais d'activer cette machine dans Paramètres → Utilisateurs (#347).

View file

@ -6,14 +6,14 @@
- Buy the Base edition from every screen that requires it: "Get Base" is now active on Budget, Adjustments, the advanced reports and extra profiles, and opens the product page in the app's language (#346).
- In the free edition, the Updates card offers "Get Base", "I already have a key" and "Download the latest version", and the error page offers "Get Base" and "Download the latest version". The download link opens the product page, which serves the latest installers; no update check runs (#346).
- License check at startup: when a license key is stored, the app now asks the license server whether the key was revoked, at most once every 24 hours. This is the first request the app sends on its own. It carries the product and the key, nothing else; nothing is sent without a stored key; if the server cannot be reached, nothing changes; and each check is written to the logs (Settings → System) (#365).
- License check at startup: when a license key is stored, the app now asks the license server whether the key was revoked, at most once every 24 hours. This is the first request the app sends on its own. It carries the product and the key, nothing else; nothing is sent without a stored key; if the server cannot be reached, nothing changes; and each check is written to the logs (Settings → System). A revoked key keeps being checked as long as it stays stored, so that a correction made on the license server is picked up at the next start (#365).
### Changed
- The Maximus Account card no longer says an account is required for Premium: the account lets you find your licenses on lacompagniemaximus.com and identifies you in your feedback, and editions come from the license key (#346).
- The Balance locked screen (Premium) now says Premium isn't sold online yet, instead of "Online purchase coming soon" (#346).
- In the free edition, the Updates card says automatic updates are included in the Base edition (#346).
- A license key revoked by the license server, for instance after a refund, now puts the app back in the Free edition, automatic updates included, and the app stays there from one session to the next. The License card and the locked screens say that the key was revoked. Your data is kept, and another valid key gives its edition back. A revoked key used to keep its edition (#347, #365).
- A license key revoked by the license server, for instance after a refund, now puts the app back in the Free edition, automatic updates included, and the app stays there from one session to the next. The License card, the locked screens and the Updates card say that the key was revoked. Your data is kept, and another valid key gives its edition back. A revoked key used to keep its edition (#347, #365).
- The machine list shows how many machines are activated out of the number your key allows (#347).
- A license key valid for more than 50 years shows "No expiry date" instead of a date (#347).
- The market price authentication error now tells you to activate this machine in Settings → Users (#347).

View file

@ -16,13 +16,13 @@
>
> **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.
> **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. Une clé connue comme révoquée reste donc contrôlée une fois par 24 h, tant qu'elle est enregistrée (décision de Max du 2026-10-03, à la revue de la PR #368) : c'est ce qui reprend une telle correction sans rien demander à l'utilisateur. Le coût est assumé et déclaré dans le guide : le serveur reçoit chaque jour la clé d'un acheteur remboursé qui continue d'utiliser l'app, et aucune interface ne retire une clé enregistrée, donc seul l'enregistrement d'une autre clé arrête cet envoi. Écarté : arrêter le contrôle à la révocation, ce qui aurait obligé à émettre une nouvelle clé pour réparer une révocation faite par erreur.
>
> **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.
> **Côté interface.** `LicenseContext` lit l'état depuis Rust (`info.revoked`) et ne décide plus rien ; la carte Licence, chaque écran verrouillé (`UpsellGate`) et la carte Mises à jour en Gratuite nomment la révocation, sans bandeau global ; la page d'erreur, page de repli après un plantage, reste sans cette ligne (revue de la PR #368). 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.
> **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, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
## Contexte

View file

@ -296,7 +296,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status`. Porte aussi `revoked` (#365, qui remplace le drapeau de session de #347) : l'état vient de Rust (`info.revoked`), persiste à côté de la clé, et l'édition résolue est alors `free` ; le contexte ne décide rien. Il lance le contrôle de révocation une fois par démarrage : `createRevocationCheckStarter` est consommé par le premier état `ready` et ne lance le contrôle que si une clé est enregistrée à ce moment, `performRevocationCheck` écrit l'issue dans le Journal (`REVOCATION_LOG`) et ne recharge le contexte que si l'état a changé. `performSubmitKey` relit l'édition depuis Rust après l'enregistrement d'une clé, pour qu'une clé révoquée ressaisie reste en Gratuite. `licenseIdentity(info)` (courriel, édition, dates) sert aussi de clé React à la carte Licence, qui se remonte pour une autre clé et relit ainsi son activation |
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas. Quand la clé enregistrée est révoquée, une ligne (`upsell.revoked`) le dit au-dessus des CTA : le verrou apparaîtrait sinon sans raison (#365) |
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas. Quand la clé enregistrée est révoquée, une ligne (`upsell.revoked`) le dit au-dessus des CTA : le verrou apparaîtrait sinon sans raison (#365). La carte Mises à jour reprend la même ligne dans sa branche Gratuite |
| CTA partagés | `src/components/shared/PurchaseButton.tsx`, `DownloadLatestButton.tsx`, `HaveKeyButton.tsx` | « Obtenir Base » et « Télécharger la dernière version » ouvrent `purchaseUrl` dans le navigateur, sans routeur ; ils servent aussi la carte Mises à jour et `ErrorPage` en édition Gratuite, sans aucune vérification de mise à jour. « J'ai déjà une clé » ne rend rien hors `BrowserRouter` (#346) |
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
@ -345,13 +345,13 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès. Supprime le jeton local quand il représentait la place libérée
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
- `check_license_revocation` — Contrôle de révocation du démarrage (`POST /v1/licenses/verify`, #365). Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license`, `not_due` et `storage` signifient qu'aucune requête n'est partie
- `check_license_revocation` — Contrôle de révocation du démarrage (`POST /v1/licenses/verify`, #365). Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ».
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. La requête porte `product` et `license_key`, rien d'autre. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, mais n'écrit rien, parce que le serveur y juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (#367). Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03). La requête porte `product` et `license_key`, rien d'autre. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, mais n'écrit rien, parce que le serveur y juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (#367). Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.

View file

@ -562,8 +562,8 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours
- **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence et les écrans verrouillés l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition
- **Vérification de la licence** — quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage, au plus une fois par 24 heures, en envoyant le produit et la clé, rien d'autre. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système)
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition
- **Vérification de la licence** — quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage, au plus une fois par 24 heures, en envoyant le produit et la clé, rien d'autre. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Seul l'enregistrement d'une autre clé arrête l'envoi de la précédente
### Astuces

View file

@ -512,7 +512,8 @@ pub enum RevocationOutcome {
Unreachable,
/// The server answered something the client does not act on (`not_found`, `expired`).
Unknown,
/// The attempt or its verdict could not be written: nothing changed.
/// The attempt could not be written, so nothing was sent; or the server's verdict could
/// not be, so nothing changed.
Storage,
}
@ -2442,6 +2443,61 @@ mod tests {
route.assert_async().await;
}
#[tokio::test]
async fn an_attempt_that_cannot_be_written_sends_nothing() {
// Bound (2) holds in a data directory where the state cannot be written: the attempt
// is not recorded, so nothing is sent, or every start would send a request (review
// of #368).
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_unwritable");
dir.write(LICENSE_FILE, &key);
// A directory in place of the state file: it reads as "no state" and cannot be written.
fs::create_dir(dir.0.join(CHECK_FILE)).unwrap();
let mut server = mockito::Server::new_async().await;
let any_request = server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Storage, revoked: false });
any_request.assert_async().await;
}
#[tokio::test]
async fn a_verdict_that_cannot_be_written_is_not_reported_as_applied() {
// The other `storage` outcome: the request left and the server answered "revoked",
// but the verdict could not be written. Nothing on disk says the key is revoked, so
// the check does not say it either.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec![]);
let dir = TestDir::new("check_verdict_unwritable");
dir.write(LICENSE_FILE, &key);
let state_path = dir.0.join(CHECK_FILE);
let mut server = mockito::Server::new_async().await;
let route = server
.mock("POST", "/v1/licenses/verify")
.match_request(move |_request| {
// The attempt is on disk by now: a directory in its place makes the verdict
// that follows unwritable.
let _ = fs::remove_file(&state_path);
let _ = fs::create_dir(&state_path);
true
})
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"valid":false,"reason":"revoked"}"#)
.expect(1)
.create_async()
.await;
let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await;
assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Storage, revoked: false });
route.assert_async().await;
assert!(!is_revoked_in(&dir.0, &key));
assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE);
}
#[tokio::test]
async fn the_attempt_is_on_disk_before_the_request_reaches_the_server() {
// D1: written before the request leaves, so a call started meanwhile sends nothing

View file

@ -10,6 +10,7 @@ import {
Loader2,
} from "lucide-react";
import { useUpdater } from "../../hooks/useUpdater";
import { useLicenseContext } from "../../contexts/LicenseContext";
import PurchaseButton from "../shared/PurchaseButton";
import HaveKeyButton from "../shared/HaveKeyButton";
import DownloadLatestButton from "../shared/DownloadLatestButton";
@ -18,6 +19,7 @@ export default function UpdateCard() {
const { t, i18n } = useTranslation();
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
useUpdater();
const { revoked } = useLicenseContext();
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
const fetchReleaseNotes = useCallback(
@ -77,13 +79,20 @@ export default function UpdateCard() {
{/* Free edition: automatic updates are locked and no update check runs
(no network call). The card offers to buy Base, to enter a key, or to
download the latest version from the product page (#346). */}
download the latest version from the product page (#346). A revoked key
is named here too (#365): a refunded Base buyer first meets the loss of
automatic updates on this card. */}
{state.status === "notEntitled" && (
<div className="space-y-3">
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.updates.notEntitled")}</p>
</div>
{revoked && (
<p role="alert" className="text-sm text-[var(--negative)]">
{t("upsell.revoked")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<PurchaseButton className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm" />
<HaveKeyButton className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm" />

View file

@ -24,8 +24,8 @@ vi.mock("react-i18next", () => ({
}),
}));
// The locked screen reads one thing from the license context: whether the stored key
// was revoked (#365).
// The locked screen and the Updates card read one thing from the license context:
// whether the stored key was revoked (#365).
let licenseRevoked = false;
vi.mock("../../contexts/LicenseContext", () => ({
useLicenseContext: () => ({ revoked: licenseRevoked }),
@ -161,5 +161,19 @@ describe("UpdateCard in the free edition", () => {
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
expect(html).toContain("upsell.ctaHaveKey");
expect(html).toContain("settings.updates.downloadLatest");
expect(html).not.toContain("upsell.revoked");
});
it("names a revoked key between the message and the CTAs (#365)", () => {
licenseRevoked = true;
const html = renderToStaticMarkup(
<MemoryRouter>
<UpdateCard />
</MemoryRouter>,
);
const line = html.indexOf("upsell.revoked");
expect(line).toBeGreaterThan(html.indexOf("settings.updates.notEntitled"));
expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
expect(html.split("upsell.revoked")).toHaveLength(2);
});
});

View file

@ -371,7 +371,7 @@ describe("performRevocationCheck", () => {
["revoked", "License check: the license server says the key was revoked"],
["unreachable", "License check: the license server could not be reached or answered with an error, nothing changed"],
["unknown", "License check: the license server gave no usable answer, nothing changed"],
["storage", "License check: the attempt could not be recorded, nothing changed"],
["storage", "License check: the attempt or the server's answer could not be recorded, nothing changed"],
];
it("has a log message for every outcome, and no other", () => {

View file

@ -179,7 +179,7 @@ export const REVOCATION_LOG: Record<RevocationOutcome, string> = {
revoked: "License check: the license server says the key was revoked",
unreachable: "License check: the license server could not be reached or answered with an error, nothing changed",
unknown: "License check: the license server gave no usable answer, nothing changed",
storage: "License check: the attempt could not be recorded, nothing changed",
storage: "License check: the attempt or the server's answer could not be recorded, nothing changed",
};
export interface RevocationCheckDeps {

View file

@ -1126,8 +1126,8 @@
"Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer, a different key or an expired activation, the card shows \"Not activated\": click \"Activate\"",
"Activation sends the license server your key, this computer's name and a derived identifier, specific to Simpl'Résultat, in place of the system's machine identifier",
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot",
"If the license server reports that your key was revoked, for instance after a refund, the app goes back to the Free edition and stays there from one session to the next: the License card and the locked screens say so. Your data is kept, and another valid key gives its edition back",
"When a key is stored, the app asks the license server whether it was revoked: at startup, at most once every 24 hours, sending the product and the key, nothing else. Without a stored key no request is sent; if the server does not answer, nothing changes. Each check is written to the logs (Settings → System)"
"If the license server reports that your key was revoked, for instance after a refund, the app goes back to the Free edition and stays there from one session to the next: the License card, the locked screens and the Updates card say so. Your data is kept, and another valid key gives its edition back",
"When a key is stored, the app asks the license server whether it was revoked: at startup, at most once every 24 hours, sending the product and the key, nothing else. Without a stored key no request is sent; if the server does not answer, nothing changes. Each check is written to the logs (Settings → System). The check goes on after a revocation, as long as the key stays stored: a correction made by the license server is thus picked up at the next start. Only storing another key stops the previous one from being sent"
]
}
},

View file

@ -1126,8 +1126,8 @@
"Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »",
"L'activation envoie au serveur de licences votre clé, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système",
"La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place",
"Si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence et les écrans verrouillés l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition",
"Quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage, au plus une fois par 24 heures, en envoyant le produit et la clé, rien d'autre. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système)"
"Si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition",
"Quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage, au plus une fois par 24 heures, en envoyant le produit et la clé, rien d'autre. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Seul l'enregistrement d'une autre clé arrête l'envoi de la précédente"
]
}
},

View file

@ -39,7 +39,8 @@ export async function checkEntitlement(feature: string): Promise<boolean> {
/**
* How a revocation check ended (#365), as the Rust `RevocationOutcome` enum serializes.
* `no_license`, `not_due` and `storage` mean nothing was sent.
* `no_license` and `not_due`: nothing was sent. `storage`: the attempt could not be
* written, so nothing was sent, or the server's verdict could not be, so nothing changed.
*/
export type RevocationOutcome =
| "no_license"