diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index 682c0dd..609ffee 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -14,6 +14,10 @@
- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » une fois la liste masquée, et confirme qu'elle réapparaîtra sur le tableau de bord (#352).
- Le popover d'aide « ? » de la page Import renvoie désormais à la sous-section « Exporter un CSV depuis votre banque » du guide, comme l'état vide d'Import (#356).
+### Corrigé
+
+- Une clé de licence révoquée par le serveur de licences et saisie en cours de session fait désormais repasser l'application en édition Gratuite sans attendre : la clé est vérifiée dès son enregistrement, en arrière-plan, au lieu du démarrage suivant. Elle gardait auparavant son édition jusqu'au redémarrage de l'application, ou jusqu'à une tentative d'activation de la machine. La saisie d'une clé rend toujours la main tout de suite, en ligne comme hors ligne, et une clé révoquée affiche son édition une à deux secondes avant de basculer. La vérification part toujours au plus une fois par 24 heures pour une même clé, et chacune est inscrite dans les journaux (Paramètres → Système) (#373).
+
## [0.16.0] - 2026-10-03
### Ajouté
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb3752b..9b75935 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,10 @@
- Settings → Users offers "Show the getting-started checklist" once the checklist was hidden, and confirms that it will reappear on the dashboard (#352).
- The "?" help popover of the Import page now links to the guide's "Export a CSV from your bank" sub-section, as does the Import empty state (#356).
+### Fixed
+
+- A license key revoked by the license server and entered during a session now puts the app back in the Free edition right away: the key is checked as soon as it is stored, in the background, instead of at the next start. It used to keep its edition until the app restarted, or until you tried to activate the machine. Entering a key still answers at once, online or offline, and a revoked key shows its edition for a second or two before the switch. The check still goes out at most once every 24 hours for the same key, and each check is written to the logs (Settings → System) (#373).
+
## [0.16.0] - 2026-10-03
### Added
diff --git a/docs/adr/0017-feature-gating-par-tier.md b/docs/adr/0017-feature-gating-par-tier.md
index bc71be9..b0adfdc 100644
--- a/docs/adr/0017-feature-gating-par-tier.md
+++ b/docs/adr/0017-feature-gating-par-tier.md
@@ -14,7 +14,7 @@
>
> **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).
+> **La requête au démarrage et à la saisie d'une clé, 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 part au démarrage et à la saisie d'une clé, sans minuteur en cours de session, toujours au plus une tentative par 24 h et par clé (`license.check` ne retient que la dernière clé contrôlée : revenir à une clé précédente en rend un nouveau dû). Le contrôle à la saisie a été ajouté par #373 (2026-10-04) : jusque-là, une clé saisie en cours de session n'était contrôlée qu'au démarrage suivant, et une clé révoquée jamais contrôlée sur ce poste gardait son édition d'ici là, puisqu'aucune activation n'est requise depuis #347. Il part en arrière-plan, après l'enregistrement de la clé : la saisie rend la main sans attendre le serveur, hors ligne comme en ligne, et une clé révoquée affiche son édition une à deux secondes avant de passer en Gratuite. Rust ne laisse passer qu'un contrôle à la fois, verrou tenu jusqu'au verdict (`check_revocation_in_turn`) : une clé saisie avant la fin du chargement initial, contrôlée par la saisie et par le démarrage, n'envoie qu'une requête, le second contrôle lisant la tentative du premier ; et le contrôle d'une clé saisie pendant celui d'une autre attend ce verdict au lieu de l'écraser. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app), un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition) et, pour #373, attendre la réponse dans la saisie (l'indicateur de validation tournerait jusqu'à 15 s hors ligne).
>
> **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.
>
@@ -22,7 +22,7 @@
>
> **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, y compris sa poursuite après une révocation, 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 ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). 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
@@ -142,6 +142,6 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
- `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)
+- `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 et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `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)
+- 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) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #367 (jeton d'activation périmé laissé par la v0.15.0)
diff --git a/docs/architecture.md b/docs/architecture.md
index d6e199a..02b50fd 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -186,7 +186,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `userPreferenceService.ts` | Stockage préférences utilisateur |
| `onboardingService.ts` | Liste de démarrage (#351) : `getOnboardingCounters` lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import et la préférence `onboarding_checklist_hidden` ; `hideChecklist` / `showChecklist` posent et retirent la préférence. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en six lignes — voir la section « Onboarding » |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
-| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle du démarrage, qui rend `{ outcome, revoked }` (#365) |
+| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365) |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
@@ -328,7 +328,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
| Brique | Fichier | Rôle |
|---|---|---|
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé résolue en `free` (invalide, expirée ou révoquée) ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
-| `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 |
+| `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 au démarrage et à la saisie d'une clé (#373), par un seul lanceur (`revocationCheckLauncher`), qui rend la main aussitôt : `createRevocationCheckStarter` est consommé par le premier état `ready` et ne lance le contrôle que si une clé est enregistrée à ce moment ; `performSubmitKey` le lance après `VALIDATE_DONE` pour la clé qu'il vient d'enregistrer, sans l'attendre, et jamais après un refus ; `performRevocationCheck` écrit l'issue dans le Journal (`REVOCATION_LOG`) et ne recharge le contexte que si l'état a changé. Une clé saisie avant le premier état `ready` reçoit les deux lancements : Rust ne laisse passer qu'un contrôle à la fois, et le second lit la tentative du premier. `performSubmitKey` relit aussi 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 (``) 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). La carte Mises à jour reprend la même ligne dans sa branche Gratuite |
@@ -381,13 +381,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` 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é
+- `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. 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. 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).
+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. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. 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.
diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md
index 793f38c..0a36554 100644
--- a/docs/guide-utilisateur.md
+++ b/docs/guide-utilisateur.md
@@ -585,7 +585,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Exportez régulièrement pour garder une sauvegarde de vos données
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
-- L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Au démarrage, une seule requête part d'elle-même : la vérification de la licence, au plus une fois par 24 heures et seulement si une clé est enregistrée. Aucune télémétrie
+- L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Une seule requête part d'elle-même, au démarrage et à la saisie d'une clé : la vérification de la licence, au plus une fois par 24 heures pour une même clé et seulement si une clé est enregistrée. Aucune télémétrie
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
---
@@ -637,7 +637,7 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
- **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, 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
+- **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 et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. 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
diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs
index 19e7044..b786e6b 100644
--- a/src-tauri/src/commands/license_commands.rs
+++ b/src-tauri/src/commands/license_commands.rs
@@ -18,9 +18,10 @@
// ADR 0020). A token or a server row from before the derivation carries the raw id: both
// forms are recognized locally, only the derived one is ever sent by an activation.
// - A key the license server revoked resolves to free (#365). The server is asked at app
-// start, at most once per 24 h and only when a key is stored (`check_license_revocation`);
-// its answer is kept in license.check, next to the key, as a digest of the key, the time of
-// the last attempt and the verdict. Fail-open: an unreachable server changes nothing.
+// start and after a key is entered (#373), one check at a time, at most once per 24 h and
+// only when a key is stored (`check_license_revocation`); its answer is kept in
+// license.check, next to the key, as a digest of the key, the time of the last attempt and
+// the verdict. Fail-open: an unreachable server changes nothing.
//
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
@@ -557,7 +558,8 @@ fn revocation_check_due(state: Option<&LicenseCheck>, now: i64) -> bool {
}
/// The revocation check, without `AppHandle` so tests run it in a temporary directory and
-/// against a mock server (#365).
+/// against a mock server (#365). The command runs it through [`check_revocation_in_turn`],
+/// one check at a time (#373).
///
/// - Nothing is sent without a stored key that validates locally, nor when the last attempt
/// is less than 24 h old (D1).
@@ -628,6 +630,32 @@ async fn check_revocation_in(
}
}
+/// One revocation check at a time (#373). Since a key entered in session is checked too, two
+/// checks can overlap: the one at start and the one of a key entered before it ran, or the
+/// checks of two keys entered in a row. The 24 h guard reads the state then writes the
+/// attempt, and a verdict rewrites `license.check`, which holds one key: side by side, both
+/// checks could send, or one could overwrite what the other recorded. The command takes this
+/// turn over the whole check, request included, so a check that waited reads the state the
+/// previous one left.
+static REVOCATION_CHECK_TURN: tauri::async_runtime::Mutex<()> =
+ tauri::async_runtime::Mutex::const_new(());
+
+/// [`check_revocation_in`] once `turn` is free, held until the check is over (#373): the
+/// command passes [`REVOCATION_CHECK_TURN`]. The time is read when the turn comes, not when the
+/// check is called: read before the wait, it could date the attempt of a check that ran
+/// meanwhile in the future, and a check is due then. Kept apart from `AppHandle`, and from the
+/// static, so tests run checks side by side against a mock server, each test with its own turn.
+async fn check_revocation_in_turn(
+ turn: &tauri::async_runtime::Mutex<()>,
+ dir: &Path,
+ api_base: &str,
+ decoding_key: &DecodingKey,
+ now: impl FnOnce() -> i64,
+) -> RevocationCheck {
+ let _turn = turn.lock().await;
+ check_revocation_in(dir, api_base, decoding_key, now()).await
+}
+
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -888,7 +916,8 @@ async fn deactivate_inner(
}
/// Verify call, raw answer. The request carries the product and the key, nothing else: it
-/// is also what the revocation check sends on its own at app start (#365).
+/// is also what the revocation check sends on its own, at app start (#365) and after a key
+/// is entered (#373).
async fn verify_body(api_base: &str, license_key: &str) -> Result {
let body = serde_json::json!({
"product": PRODUCT,
@@ -1161,8 +1190,9 @@ fn activation_status_in(dir: &Path, ids: &MachineIds, decoding_key: &DecodingKey
validate_activation_with_key(&token, ids, decoding_key).is_ok()
}
-/// Ask the license server whether the stored key was revoked (#365). The front calls it
-/// once at app start; the 24 h guard and the fail-open rules live in
+/// Ask the license server whether the stored key was revoked (#365). The front calls it at
+/// app start and after a key is entered (#373); the checks run one at a time
+/// ([`check_revocation_in_turn`]), and the 24 h guard and the fail-open rules live in
/// [`check_revocation_in`]. It is the one request the app makes on its own: nothing leaves
/// without a stored key, and the request carries the product and the key only. Never an
/// error for the user: the outcome says what happened, for the in-app log.
@@ -1170,7 +1200,14 @@ fn activation_status_in(dir: &Path, ids: &MachineIds, decoding_key: &DecodingKey
pub async fn check_license_revocation(app: tauri::AppHandle) -> Result {
let dir = app_data_dir(&app)?;
let decoding_key = embedded_decoding_key().map_err(|e| license_error_to_string(&e))?;
- Ok(check_revocation_in(&dir, &api_base_url(), &decoding_key, unix_now()).await)
+ Ok(check_revocation_in_turn(
+ &REVOCATION_CHECK_TURN,
+ &dir,
+ &api_base_url(),
+ &decoding_key,
+ unix_now,
+ )
+ .await)
}
// === Tests ====================================================================================
@@ -2584,6 +2621,140 @@ mod tests {
assert!(!raw_state.contains(&key), "the key must not be in the state file");
}
+ // === One check at a time (#373) ===========================================================
+ //
+ // Each test has its own turn, standing for `REVOCATION_CHECK_TURN`: tests run on parallel
+ // threads, and a turn shared between them would decide which check goes first. `join!` is
+ // `biased`: its first branch is polled first, every time, so it is the check that starts
+ // first.
+
+ /// A turn of its own for one test.
+ fn new_turn() -> tauri::async_runtime::Mutex<()> {
+ tauri::async_runtime::Mutex::new(())
+ }
+
+ #[tokio::test]
+ async fn two_checks_side_by_side_send_one_request() {
+ // A key entered before the first ready state starts the check twice, from the entry
+ // and from the start. The second waits for the first one's verdict and sends nothing.
+ // Without the turn it would run during the first one's request and read its attempt
+ // only: `revoked: false`. That is what this test sees of a missing turn on one thread;
+ // the double send itself needs two threads between the read and the write of the
+ // state, a window without an await point that no test here can hold open.
+ let (enc, dec) = default_keys();
+ let key = base_license_with_features(&enc, vec![]);
+ let dir = TestDir::new("check_side_by_side");
+ dir.write(LICENSE_FILE, &key);
+ let mut server = mockito::Server::new_async().await;
+ let route = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await;
+ let url = server.url();
+ let turn = new_turn();
+
+ let (first, second) = tokio::join!(
+ biased;
+ check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW),
+ check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW),
+ );
+
+ assert_eq!(first, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true });
+ assert_eq!(second, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: true });
+ route.assert_async().await;
+ }
+
+ #[tokio::test]
+ async fn the_check_of_a_key_entered_meanwhile_waits_for_the_verdict_in_flight() {
+ // The check of the stored key is in flight when another key is entered, and that
+ // key's check starts at once. `license.check` holds one key: side by side, the first
+ // verdict would land on the second key's attempt, and the second key would be checked
+ // again at the next start, within 24 h.
+ let (enc, dec) = default_keys();
+ let refunded_key = base_license_with_features(&enc, vec![]);
+ let new_key = base_license_with_features(&enc, vec!["balance".to_string()]);
+ let dir = TestDir::new("check_key_entered_meanwhile");
+ dir.write(LICENSE_FILE, &refunded_key);
+ let revoked_key = refunded_key.clone();
+ let mut server = mockito::Server::new_async().await;
+ let route = server
+ .mock("POST", "/v1/licenses/verify")
+ .with_status(200)
+ .with_header("content-type", "application/json")
+ .with_body_from_request(move |request| {
+ let sent = request
+ .body()
+ .ok()
+ .and_then(|raw| serde_json::from_slice::(raw).ok())
+ .and_then(|body| body["license_key"].as_str().map(str::to_string));
+ if sent.as_deref() == Some(revoked_key.as_str()) {
+ br#"{"valid":false,"reason":"revoked"}"#.to_vec()
+ } else {
+ br#"{"valid":true,"machines":[]}"#.to_vec()
+ }
+ })
+ .expect(2)
+ .create_async()
+ .await;
+ let url = server.url();
+ let turn = new_turn();
+
+ let (first, second) = tokio::join!(
+ biased;
+ check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW),
+ async {
+ store_license_in(&dir.0, &new_key, &dec).unwrap();
+ check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW).await
+ },
+ );
+
+ assert_eq!(first, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true });
+ assert_eq!(second, RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false });
+ assert_eq!(
+ stored_state(&dir),
+ Some(serde_json::json!({
+ "key_sha256": key_digest(&new_key),
+ "attempted_at": NOW,
+ "revoked": false,
+ }))
+ );
+ // The next start, the same day: the new key's attempt counts, nothing leaves.
+ let next_start = check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW + 60).await;
+ assert_eq!(next_start, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: false });
+ route.assert_async().await;
+ }
+
+ #[tokio::test]
+ async fn a_check_reads_the_time_once_its_turn_has_come() {
+ // A check that waited for the one running finds that one's attempt on disk, written a
+ // second after this check was called. Read before the wait, the time would date that
+ // attempt in the future, a check would be due, and a second request would leave.
+ let (enc, dec) = default_keys();
+ let key = base_license_with_features(&enc, vec![]);
+ let dir = TestDir::new("check_time_after_turn");
+ dir.write(LICENSE_FILE, &key);
+ let clock = std::sync::atomic::AtomicI64::new(NOW);
+ let mut server = mockito::Server::new_async().await;
+ let any_request = server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await;
+ let url = server.url();
+ let turn = new_turn();
+
+ let running = turn.lock().await;
+ let (dir_ref, clock_ref, key_ref) = (&dir, &clock, &key);
+ let (result, ()) = tokio::join!(
+ biased;
+ check_revocation_in_turn(&turn, &dir.0, &url, &dec, || {
+ clock.load(std::sync::atomic::Ordering::SeqCst)
+ }),
+ async move {
+ // What the running check leaves before it ends: its attempt, a second later.
+ dir_ref.write(CHECK_FILE, check_state(key_ref, NOW + 1, false));
+ clock_ref.store(NOW + 1, std::sync::atomic::Ordering::SeqCst);
+ drop(running);
+ },
+ );
+
+ assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: false });
+ any_request.assert_async().await;
+ }
+
#[tokio::test]
async fn a_revoked_answer_to_any_license_command_is_recorded_for_the_key_it_sent() {
// D4: activation, list and deactivation all learn it the same way the check does.
diff --git a/src/contexts/LicenseContext.test.ts b/src/contexts/LicenseContext.test.ts
index 011d28c..0b13455 100644
--- a/src/contexts/LicenseContext.test.ts
+++ b/src/contexts/LicenseContext.test.ts
@@ -6,6 +6,7 @@ import {
performSubmitKey,
createRevocationCheckStarter,
performRevocationCheck,
+ revocationCheckLauncher,
REVOCATION_LOG,
type LicenseAction,
type RevocationCheckDeps,
@@ -177,23 +178,47 @@ describe("licenseReducer — the revoked state comes from Rust", () => {
// Storing a key: the edition shown right after is the one Rust resolves (#365). Before,
// VALIDATE_DONE took the key's own edition, so a revoked key entered again read as Base
-// until the next reload.
+// until the next reload. A stored key is then checked online at once (#373).
describe("performSubmitKey", () => {
function submitHarness(overrides: Partial = {}) {
let state = premiumReady;
const actions: LicenseAction[] = [];
+ // Dispatched actions and launched checks, in the order they happen.
+ const events: string[] = [];
const deps: SubmitKeyDeps = {
storeLicense: vi.fn(async () => makeInfo("base")),
getEdition: vi.fn(async (): Promise => "base"),
dispatch: (action) => {
actions.push(action);
+ events.push(action.type);
state = licenseReducer(state, action);
},
+ startRevocationCheck: vi.fn((wasRevoked: boolean) => {
+ events.push(`check(${wasRevoked})`);
+ }),
...overrides,
};
- return { deps, actions, getState: () => state };
+ return { deps, actions, events, getState: () => state };
}
+ it("launches the revocation check once, after VALIDATE_DONE, for the key just stored", async () => {
+ const { deps, events } = submitHarness();
+ await performSubmitKey(deps, "SR-BASE-k");
+ expect(events).toEqual(["VALIDATE_START", "VALIDATE_DONE", "check(false)"]);
+ expect(deps.startRevocationCheck).toHaveBeenCalledTimes(1);
+ });
+
+ it("launches the check with the revoked state Rust gave the key", async () => {
+ // A key already known here as revoked: the check compares its answer with that state,
+ // so a "revoked" answer reloads nothing.
+ const { deps, events } = submitHarness({
+ storeLicense: vi.fn(async () => makeInfo("base", true)),
+ getEdition: vi.fn(async (): Promise => "free"),
+ });
+ await performSubmitKey(deps, "SR-BASE-k");
+ expect(events).toEqual(["VALIDATE_START", "VALIDATE_DONE", "check(true)"]);
+ });
+
it("takes the edition Rust resolves: Free for a revoked key entered again", async () => {
const { deps, getState } = submitHarness({
storeLicense: vi.fn(async () => makeInfo("base", true)),
@@ -226,6 +251,8 @@ describe("performSubmitKey", () => {
expect(result.ok).toBe(true);
expect(getState().edition).toBe("free");
expect(getState().status).toBe("ready");
+ // The key is stored: it is checked, whatever became of the edition read.
+ expect(deps.startRevocationCheck).toHaveBeenCalledWith(true);
});
it("falls back to the key's edition when the edition cannot be read and the key is clean", async () => {
@@ -238,7 +265,7 @@ describe("performSubmitKey", () => {
expect(getState().edition).toBe("base");
});
- it("reports a refused key without reading the edition", async () => {
+ it("reports a refused key without reading the edition nor launching the check", async () => {
const { deps, actions, getState } = submitHarness({
storeLicense: vi.fn(async () => {
throw '{"code":"invalid_prefix"}';
@@ -247,12 +274,117 @@ describe("performSubmitKey", () => {
const result = await performSubmitKey(deps, "nonsense");
expect(result).toEqual({ ok: false, error: '{"code":"invalid_prefix"}' });
expect(deps.getEdition).not.toHaveBeenCalled();
+ expect(deps.startRevocationCheck).not.toHaveBeenCalled();
expect(actions.map((a) => a.type)).toEqual(["VALIDATE_START", "VALIDATE_ERROR"]);
expect(getState().edition).toBe("premium");
expect(getState().validationError).toBe('{"code":"invalid_prefix"}');
});
});
+// The entry and its check together, wired as the provider wires them: the launcher is the
+// real one, only the Tauri commands and the log are stubs (#373).
+describe("a key entered in session is checked right away", () => {
+ /** Lets the check launched in the background run to its end. */
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+ const freeReady = licenseReducer(initialLicenseState, {
+ type: "LOAD_DONE",
+ edition: "free",
+ info: null,
+ });
+
+ function entryHarness(answer: () => Promise) {
+ let state = freeReady;
+ const dispatch = (action: LicenseAction) => {
+ state = licenseReducer(state, action);
+ };
+ const check: RevocationCheckDeps = {
+ checkLicenseRevocation: vi.fn(answer),
+ // A reload reads what Rust resolves once the revocation is recorded.
+ refresh: vi.fn(async () => {
+ dispatch({ type: "LOAD_START" });
+ dispatch({ type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) });
+ }),
+ logInfo: vi.fn(),
+ logWarn: vi.fn(),
+ };
+ const deps: SubmitKeyDeps = {
+ storeLicense: vi.fn(async () => makeInfo("base")),
+ getEdition: vi.fn(async (): Promise => "base"),
+ dispatch,
+ startRevocationCheck: revocationCheckLauncher(check),
+ };
+ return { deps, check, getState: () => state };
+ }
+
+ it("answers the entry first, then takes a revoked key to Free without a restart", async () => {
+ let answer: (result: RevocationCheck) => void = () => {};
+ const { deps, check, getState } = entryHarness(
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+
+ const entry = performSubmitKey(deps, "SR-BASE-k");
+
+ // The entry is over while the license server has not answered yet.
+ const first = await Promise.race([
+ entry.then(() => "entry"),
+ settle().then(() => "nothing yet"),
+ ]);
+ expect(first).toBe("entry");
+ expect(await entry).toEqual({ ok: true, info: makeInfo("base") });
+ expect(getState().edition).toBe("base");
+ expect(check.checkLicenseRevocation).toHaveBeenCalledTimes(1);
+ expect(check.logInfo).not.toHaveBeenCalled();
+
+ answer({ outcome: "revoked", revoked: true });
+ await settle();
+
+ expect(check.logInfo).toHaveBeenCalledWith(
+ "License check: the license server says the key was revoked",
+ );
+ expect(check.refresh).toHaveBeenCalledTimes(1);
+ expect(getState().edition).toBe("free");
+ expect(getState().info?.revoked).toBe(true);
+ });
+
+ it("keeps the key's edition when the check cannot run, and logs it", async () => {
+ const failure = new Error("ipc down");
+ const { deps, check, getState } = entryHarness(async () => {
+ throw failure;
+ });
+
+ await performSubmitKey(deps, "SR-BASE-k");
+ await settle();
+
+ expect(check.logWarn).toHaveBeenCalledTimes(1);
+ expect(check.logWarn).toHaveBeenCalledWith(
+ "License check: the check could not run, nothing changed",
+ failure,
+ );
+ expect(check.refresh).not.toHaveBeenCalled();
+ expect(getState().edition).toBe("base");
+ });
+
+ it("keeps the key's edition when the server is unreachable, and logs it", async () => {
+ const { deps, check, getState } = entryHarness(async () => ({
+ outcome: "unreachable",
+ revoked: false,
+ }));
+
+ await performSubmitKey(deps, "SR-BASE-k");
+ await settle();
+
+ expect(check.logInfo).toHaveBeenCalledTimes(1);
+ expect(check.logInfo).toHaveBeenCalledWith(
+ "License check: the license server could not be reached or answered with an error, nothing changed",
+ );
+ expect(check.refresh).not.toHaveBeenCalled();
+ expect(getState().edition).toBe("base");
+ });
+});
+
// The provider's value is derived by a plain function: its state never loads under
// renderToStaticMarkup, so this is where "the context says revoked" is decided.
describe("licenseContextValue", () => {
@@ -329,12 +461,55 @@ describe("createRevocationCheckStarter", () => {
expect(run).toHaveBeenCalledWith(true);
});
- it("sends nothing without a stored key, and a key entered later waits for the next start", () => {
- const run = vi.fn();
- const start = createRevocationCheckStarter(run);
- start("ready", null);
- start("ready", clean);
- expect(run).not.toHaveBeenCalled();
+ /** Entry deps whose dispatch also runs the starter, as the provider's effect does. */
+ function entryWithStarter(
+ initial: ReturnType,
+ start: ReturnType,
+ launch: (wasRevoked: boolean) => void,
+ ): SubmitKeyDeps {
+ let state = initial;
+ return {
+ storeLicense: async () => clean,
+ getEdition: async (): Promise => "base",
+ dispatch: (action) => {
+ state = licenseReducer(state, action);
+ start(state.status, state.info);
+ },
+ startRevocationCheck: launch,
+ };
+ }
+
+ it("sends nothing without a stored key, and leaves a key entered later to the entry", async () => {
+ // Before #373 a key entered later waited for the next start. The starter is still
+ // consumed at boot; the entry checks the key itself, once.
+ const launch = vi.fn();
+ const start = createRevocationCheckStarter(launch);
+ const booted = licenseReducer(initialLicenseState, {
+ type: "LOAD_DONE",
+ edition: "free",
+ info: null,
+ });
+ start(booted.status, booted.info);
+ expect(launch).not.toHaveBeenCalled();
+
+ await performSubmitKey(entryWithStarter(booted, start, launch), "SR-BASE-k");
+
+ expect(launch).toHaveBeenCalledTimes(1);
+ expect(launch).toHaveBeenCalledWith(false);
+ });
+
+ it("launches twice for a key entered before the first ready state, which Rust sends once", async () => {
+ // The entry ends in the first ready state, so the starter launches too. Rust runs the
+ // checks one at a time and the second reads the first one's attempt
+ // (`two_checks_side_by_side_send_one_request` in license_commands.rs).
+ const launch = vi.fn();
+ const start = createRevocationCheckStarter(launch);
+ const loading = licenseReducer(initialLicenseState, { type: "LOAD_START" });
+ start(loading.status, loading.info);
+
+ await performSubmitKey(entryWithStarter(loading, start, launch), "SR-BASE-k");
+
+ expect(launch).toHaveBeenCalledTimes(2);
});
it("is consumed by the first ready state after a boot error too", () => {
diff --git a/src/contexts/LicenseContext.tsx b/src/contexts/LicenseContext.tsx
index d0bd264..453c9fb 100644
--- a/src/contexts/LicenseContext.tsx
+++ b/src/contexts/LicenseContext.tsx
@@ -44,8 +44,9 @@ import { logInfo, logWarn } from "../services/logService";
* next to the key, and survives the session. It reaches this context as
* `info.revoked`, and Rust resolves the edition of a revoked key to "free". Nothing
* here decides it. One request goes out on the app's own initiative: the revocation
- * check, started once at boot when a key is stored. Rust sends it at most once per
- * 24 h, and an unreachable server changes nothing.
+ * check, started at boot when a key is stored, and after a key is entered (#373),
+ * in the background. Rust runs the checks one at a time and sends at most once per
+ * 24 h for a key, and an unreachable server changes nothing.
*/
type LicenseStatus = "idle" | "loading" | "ready" | "error";
@@ -129,12 +130,22 @@ export interface SubmitKeyDeps {
storeLicense: (key: string) => Promise;
getEdition: () => Promise;
dispatch: (action: LicenseAction) => void;
+ /**
+ * The provider's one launcher of the revocation check (#373): it returns at once and
+ * the check runs in the background (see `revocationCheckLauncher`).
+ */
+ startRevocationCheck: (wasRevoked: boolean) => void;
}
/**
* Store a key, then take the edition Rust resolves for it: one resolution point, which
* knows a revoked key (Free) and an active account Premium. If that read fails, the
* key's own edition stands in, or "free" when the key came back revoked.
+ *
+ * A stored key is then checked online right away (#373), not at the next start: the
+ * check is launched after VALIDATE_DONE and not awaited, so the entry answers without
+ * waiting for the license server. A revoked key shows the key's edition until the
+ * check answers, then the reload brings Free. A refused key sends nothing.
*/
export async function performSubmitKey(deps: SubmitKeyDeps, key: string): Promise {
deps.dispatch({ type: "VALIDATE_START" });
@@ -153,14 +164,17 @@ export async function performSubmitKey(deps: SubmitKeyDeps, key: string): Promis
edition = info.revoked ? "free" : info.edition;
}
deps.dispatch({ type: "VALIDATE_DONE", info, edition });
+ deps.startRevocationCheck(info.revoked);
return { ok: true, info };
}
/**
- * Starter of the revocation check (#365, D1): a startup check, nothing more. It is
- * consumed by the FIRST "ready" state, whatever the key state, and runs the check only
- * if a key is stored then. A key entered later in the session is first checked at the
- * next start; a reload of the context never starts it again.
+ * Starter of the revocation check at app start (#365, D1). It is consumed by the FIRST
+ * "ready" state, whatever the key state, and runs the check only if a key is stored then;
+ * a reload of the context never starts it again. A key entered later is checked by the
+ * entry itself (`performSubmitKey`, #373). A key entered before the first ready state gets
+ * two checks from here, the entry's and this starter's: Rust runs the checks one at a
+ * time, and the second one reads the first one's attempt and sends nothing.
*/
export function createRevocationCheckStarter(run: (wasRevoked: boolean) => void) {
let consumed = false;
@@ -210,6 +224,17 @@ export async function performRevocationCheck(
if (result.revoked !== wasRevoked) await deps.refresh();
}
+/**
+ * The one launcher of the revocation check, shared by the start (`createRevocationCheckStarter`)
+ * and the entry of a key (`performSubmitKey`, #373). It returns at once: the check runs in the
+ * background, so nothing that launches it waits for the license server.
+ */
+export function revocationCheckLauncher(deps: RevocationCheckDeps): (wasRevoked: boolean) => void {
+ return (wasRevoked) => {
+ void performRevocationCheck(deps, wasRevoked);
+ };
+}
+
interface LicenseContextValue {
status: LicenseStatus;
edition: Edition;
@@ -270,9 +295,19 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
}
}, []);
+ // Revocation check: launched at app start (see createRevocationCheckStarter) and after a
+ // key is entered (see performSubmitKey), by the same launcher.
+ const [launchRevocationCheck] = useState(() =>
+ revocationCheckLauncher({ checkLicenseRevocation, refresh, logInfo, logWarn }),
+ );
+
const submitKey = useCallback(
- (key: string) => performSubmitKey({ storeLicense, getEdition, dispatch }, key),
- [],
+ (key: string) =>
+ performSubmitKey(
+ { storeLicense, getEdition, dispatch, startRevocationCheck: launchRevocationCheck },
+ key,
+ ),
+ [launchRevocationCheck],
);
// Load once at boot.
@@ -280,18 +315,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
void refresh();
}, [refresh]);
- // Revocation check: started once per app start (see createRevocationCheckStarter).
- const [startRevocationCheck] = useState(() =>
- createRevocationCheckStarter((wasRevoked) => {
- void performRevocationCheck(
- { checkLicenseRevocation, refresh, logInfo, logWarn },
- wasRevoked,
- );
- }),
- );
+ const [checkAtStart] = useState(() => createRevocationCheckStarter(launchRevocationCheck));
useEffect(() => {
- startRevocationCheck(state.status, state.info);
- }, [startRevocationCheck, state.status, state.info]);
+ checkAtStart(state.status, state.info);
+ }, [checkAtStart, state.status, state.info]);
// Error recovery: retry with capped exponential backoff (CWE-703). Load
// errors only by construction — validation failures never set status.
diff --git a/src/hooks/useLicenseMachines.ts b/src/hooks/useLicenseMachines.ts
index d198db9..4600bf4 100644
--- a/src/hooks/useLicenseMachines.ts
+++ b/src/hooks/useLicenseMachines.ts
@@ -22,8 +22,9 @@ import { useLicenseContext } from "../contexts/LicenseContext";
//
// A `license_revoked` answer, from any of the three calls, is persisted by the Rust
// command that received it (#365): this hook only reloads the LicenseContext, so the
-// edition (Free) and the card follow. The check made at app start shares that budget:
-// with it, the machine-limit path reaches the 5 requests of the first minute.
+// edition (Free) and the card follow. The revocation check, made at app start and after
+// a key is entered (#373), shares that budget: with it, the machine-limit path reaches
+// the 5 requests of the first minute.
export interface LicenseMachinesState {
activation: ActivationStatus | null;
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index d4a91a5..9082ee0 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1231,7 +1231,7 @@
"The user guide can be printed or exported to PDF via the Print button",
"Logs persist for the session — they survive a page refresh",
"If you encounter an issue, copy the logs and attach them to your report",
- "The app only contacts a server for updates, Maximus sign-in, feedback, the license (activation and check) and market prices. At startup, one request goes out on its own: the license check, at most once every 24 hours and only when a key is stored. No telemetry"
+ "The app only contacts a server for updates, Maximus sign-in, feedback, the license (activation and check) and market prices. One request goes out on its own, at startup and when a key is entered: the license check, at most once every 24 hours for the same key and only when a key is stored. No telemetry"
]
},
"editions": {
@@ -1260,7 +1260,7 @@
"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, 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"
+ "When a key is stored, the app asks the license server whether it was revoked: at startup and as soon as you enter a key, at most once every 24 hours for the same key, sending the product and the key, nothing else. The check of an entered key runs in the background: the License card answers right away, and a revoked key shows its edition for a second or two before the app goes back to Free. 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"
]
}
},
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index a6a00e0..b5701a5 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -1231,7 +1231,7 @@
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer",
"Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page",
"En cas de problème, copiez les journaux et joignez-les à votre signalement",
- "L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Au démarrage, une seule requête part d'elle-même : la vérification de la licence, au plus une fois par 24 heures et seulement si une clé est enregistrée. Aucune télémétrie"
+ "L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Une seule requête part d'elle-même, au démarrage et à la saisie d'une clé : la vérification de la licence, au plus une fois par 24 heures pour une même clé et seulement si une clé est enregistrée. Aucune télémétrie"
]
},
"editions": {
@@ -1260,7 +1260,7 @@
"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, 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"
+ "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 et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. 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"
]
}
},