feat(license): online revocation check — a revoked key falls back to Free (#365) #368
25 changed files with 1587 additions and 176 deletions
|
|
@ -6,13 +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).
|
||||
|
||||
### 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).
|
||||
- La carte Licence signale une clé révoquée jusqu'à la fermeture de l'application, n'affiche plus sa machine comme activée et ne propose plus « Activer » pour elle ; le message disparaissait auparavant avec la liste des machines (#347).
|
||||
- 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).
|
||||
- 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).
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
- Une clé de licence refusée est désormais expliquée dans votre langue (préfixe incorrect, clé illisible ou qui n'a pas pu être vérifiée, le plus souvent parce qu'elle a été copiée en partie, clé expirée, édition inconnue de cette version) au lieu d'un message anglais brut, et un fichier de licence illisible est nommé lui aussi (#347).
|
||||
- Les dates de la carte Licence suivent la langue de l'application plutôt que celle du système (#347).
|
||||
- Enregistrer une autre clé de licence efface désormais l'activation de la clé précédente : la carte Licence propose « Activer » pour la nouvelle clé. Elle affichait auparavant la machine comme activée alors que les cours du marché étaient refusés au nom de l'ancienne licence, par exemple après un rachat suivant un remboursement, ou lors du passage de Base à Premium. La saisie d'une clé et l'activation d'une machine ne peuvent plus se chevaucher, donc plus se croiser (#347).
|
||||
- Les cours du marché refusés parce que l'activation de cette machine est liée à une licence révoquée le disent désormais, au lieu de « Cette fonction nécessite un abonnement premium » (#365).
|
||||
|
||||
### Sécurité
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +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).
|
||||
|
||||
### 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).
|
||||
- The License card keeps a revoked key flagged until the app is closed, no longer shows its machine as activated, and no longer offers "Activate" for it; the message used to disappear with the machine list (#347).
|
||||
- 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).
|
||||
- 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).
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
- A refused license key is now explained in your language (wrong prefix, a key that cannot be read or verified, most often because it was not copied in full, an expired key, an edition this version does not know) instead of a raw English message, and a license file that cannot be read is named too (#347).
|
||||
- Dates on the License card follow the app's language instead of the system's (#347).
|
||||
- Entering a different license key now clears the previous key's machine activation, so the License card offers "Activate" for the new key. It used to show the machine as activated while market prices were refused for the previous license, for instance after a new purchase following a refund, or when moving from Base to Premium. Entering a key and activating a machine can no longer overlap, so they cannot cross either (#347).
|
||||
- Market prices refused because this machine's activation belongs to a revoked license now say so, instead of "This feature requires a premium subscription" (#365).
|
||||
|
||||
### Security
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@
|
|||
|
||||
> **Amendement du 2026-09-29 (#347)** — l'édition locale ne dépend plus du jeton d'activation (décision D12, qui ferme #360). La contrainte 2 du Contexte affirmait qu'une `license.key` copiée sur une autre machine était déclassée en `free` : c'était faux pour une clé copiée **seule**, qui gardait son édition (état « pré-activation » toléré). Seule la copie clé + jeton retombait en Gratuite, c'est-à-dire le cas d'un utilisateur légitime qui restaure ses données après une réinstallation ou un changement de poste ; et une machine activée perdait son édition à l'expiration de son jeton, signé pour 2 ans alors que la clé en dure 100 depuis maximus-api#63. La règle ne protégeait donc rien et piégeait des acheteurs. Désormais `current_entitlements` résout l'édition et les `features[]` depuis la **seule clé** : une clé valide et non expirée donne son édition, quel que soit le jeton ; une clé invalide ou expirée donne `("free", [])`, et le court-circuit fail-closed en Free (CWE-863) ne vise plus que ce cas. La licence n'est liée à la machine que **côté serveur** : activation, limite de machines, et cours du Bilan (`/v1/prices` exige un jeton valide, [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Le jeton garde une validation stricte là où il décide de quelque chose : l'état « Activée » de la carte Licence (`get_activation_status`) et l'écriture d'un jeton reçu du serveur. Enregistrer une autre clé supprime le jeton de la précédente : il désignerait l'ancienne licence auprès du serveur des cours. La clé reste validée strictement (`exp` et `iat` exigés, CWE-613). Les mentions de liaison à la machine plus bas (Contexte, §1, §5, Liens) se lisent à la lumière de cet amendement.
|
||||
|
||||
> **Amendement du 2026-10-02 (#365)** — une clé révoquée par le serveur de licences retombe en Gratuite, et l'app envoie pour cela sa première requête non sollicitée. Constat du go-live (2026-09-30) : une licence remboursée, révoquée côté serveur, restait pleinement fonctionnelle, mises à jour automatiques comprises. L'édition se résout hors ligne depuis la clé, dont l'échéance est à 100 ans depuis maximus-api#63, et le signalement de #347 (D13, « rien n'est persisté ») ne vivait que le temps de la session, sans toucher l'édition. Les conditions de vente disent pourtant que le remboursement entraîne la révocation de la licence.
|
||||
>
|
||||
> **Ce qui change.** D13 est remplacée pour le cas révoqué : l'état vit dans `license.check`, à côté de la clé, sous la forme `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. `entitlements_from_dir` rend `("free", [])` quand cet état dit `revoked` pour la clé enregistrée : `get_edition`, `check_entitlement` (`auto-update`) et la matrice du front suivent sans autre changement. D12 est inchangée, le jeton d'activation ne pèse toujours pas sur l'édition. Un fichier absent, illisible, au JSON invalide, ou qui parle d'une autre clé se lit « pas d'état » : la clé n'est pas tenue pour révoquée et un contrôle est dû. Un fichier corrompu ne rétrograde donc jamais un client légitime, et une autre clé enregistrée reprend son édition.
|
||||
>
|
||||
> **La requête au démarrage, et ses bornes.** Jusqu'ici aucune requête ne partait au lancement : les mises à jour, les cours et la rétroaction partent sur un clic, et le contrôle d'un compte Maximus connecté à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction. `check_license_revocation` est la première exception, bornée ainsi : (1) rien ne part sans une clé enregistrée qui se valide localement ; (2) au plus une tentative par 24 h, quel qu'en soit le résultat, parce que `attempted_at` est écrit **avant** l'appel ; (3) la requête est `POST /v1/licenses/verify` avec le produit et la clé, sans identifiant machine, nom d'ordinateur ni donnée d'usage ; (4) chaque contrôle écrit son issue dans le Journal de l'app, y compris « rien envoyé » quand la dernière tentative a moins de 24 h ; (5) le contrôle ne part qu'au démarrage, sans minuteur en cours de session, et une clé saisie en cours de session est contrôlée au démarrage suivant. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app) et un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition).
|
||||
>
|
||||
> **Fail-open.** Seule une réponse « révoquée » pose l'état, seule une réponse `valid:true` le lève. Erreur réseau, délai dépassé, 429, 5xx, réponse illisible, `not_found` et `expired` le laissent tel quel : un utilisateur hors ligne n'est jamais rétrogradé, et une clé connue comme révoquée le reste. Aucun chemin serveur ne réactive une licence aujourd'hui ; la levée ne sert qu'à une correction manuelle de la base.
|
||||
>
|
||||
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis », et le jeton périmé est suivi en #367.
|
||||
>
|
||||
> **Côté interface.** `LicenseContext` lit l'état depuis Rust (`info.revoked`) et ne décide plus rien ; la carte Licence et chaque écran verrouillé (`UpsellGate`) nomment la révocation, sans bandeau global. Une clé révoquée ressaisie reste en Gratuite.
|
||||
>
|
||||
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage), et une app laissée ouverte n'est pas recontrôlée avant son redémarrage. La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site est suivie par maximus/la-compagnie-maximus#184.
|
||||
|
||||
## Contexte
|
||||
|
||||
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 (liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête ; ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
|
||||
|
|
@ -71,7 +85,7 @@ Les modules non inclus dans l'édition restent **visibles et cliquables** : cade
|
|||
|
||||
### 5. Durcissement sec, non destructif
|
||||
|
||||
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée ; un changement de machine ne la fait plus baisser depuis #347, voir l'amendement en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
|
||||
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
|
||||
|
||||
### 6. Dev-override compilé hors des builds normaux
|
||||
|
||||
|
|
@ -128,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), `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), `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)
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -540,12 +540,14 @@ Retry-After: 42
|
|||
| `missing_param` | `errors.missingParam` | « Paramètre manquant » | "Missing parameter" |
|
||||
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs » | "Authentication failed — activate this machine in Settings → Users" |
|
||||
| `premium_required` | `errors.premiumRequired` | « Abonnement premium requis » | "Premium subscription required" |
|
||||
| `license_revoked` | `errors.licenseRevoked` | « Licence révoquée — contactez le support » | "License revoked — contact support" |
|
||||
| `license_revoked` | `errors.licenseRevoked` | « L'activation de cette machine est liée à une licence révoquée » | "This machine's activation belongs to a revoked license" |
|
||||
| `symbol_not_found` | `errors.symbolNotFound` | « Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
|
||||
| `rate_limit_exceeded` | `errors.rateLimit` | « Trop de requêtes — réessayez dans {{seconds}}s » | "Too many requests — retry in {{seconds}}s" |
|
||||
| `provider_unavailable` / `service_degraded` / `internal_error` | `errors.serverUnavailable` | « Service indisponible — saisissez le prix manuellement » | "Service unavailable — enter price manually" |
|
||||
| `service_degraded` (circuit breaker Yahoo) | `errors.bestEffortDegraded` | « Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement » | "Price source temporarily unavailable — retry in {{minutes}} min or enter manually" |
|
||||
|
||||
**Depuis #365**, le client distingue `license_revoked` de `premium_required` : il lisait auparavant tout 403 comme `premium_required`. Il ne retente pas la requête, et n'enregistre pas ce refus comme une révocation de la clé : le serveur juge ici la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md), #367).
|
||||
|
||||
**Règle générale côté UI** : sur n'importe quelle erreur, le champ de saisie manuelle reste actif. Jamais bloquer la saisie d'un snapshot.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
|||
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
|
||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `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) |
|
||||
|
||||
|
|
@ -227,7 +227,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
|
||||
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
|
||||
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
|
||||
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels est consigné dans le `LicenseContext` (`markRevoked`, #347) ; le hook en relit `revoked` et `selectCanActivate` bloque alors « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition |
|
||||
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition |
|
||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||
|
||||
### Hook transverse — `useCollapsibleGroups`
|
||||
|
|
@ -292,11 +292,11 @@ 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 ou expiré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` (#347) : un `license_revoked` du serveur, consigné pour toute la session parce que la carte Licence se démonte en quittant Paramètres → Utilisateurs ; jamais persisté, sans effet sur l'édition, et effacé seulement quand une autre clé est enregistrée. `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 |
|
||||
| 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 |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| 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) |
|
||||
|
|
@ -305,7 +305,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
|
|||
|
||||
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
|
||||
|
||||
## Commandes Tauri (35)
|
||||
## Commandes Tauri (36)
|
||||
|
||||
### `fs_commands.rs` — Système de fichiers (6)
|
||||
|
||||
|
|
@ -334,21 +334,24 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
|
|||
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
|
||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||
|
||||
### `license_commands.rs` — Licence et activation machine (9)
|
||||
### `license_commands.rs` — Licence et activation machine (10)
|
||||
|
||||
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
|
||||
- `store_license` — Stockage de la clé dans le répertoire app data
|
||||
- `store_activation_token` — Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machine
|
||||
- `read_license` — Lecture de la licence stockée
|
||||
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347)
|
||||
- `read_license` — Lecture de la licence stockée, avec `revoked` : l'état de révocation enregistré pour cette clé (#365)
|
||||
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365)
|
||||
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270). Envoie l'identifiant machine **dérivé**, jamais celui du système (#350)
|
||||
- `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
|
||||
|
||||
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`. 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é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).
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -380,9 +383,9 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
|
|||
|
||||
### `entitlements.rs` — Entitlements (1)
|
||||
|
||||
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé résolue en `free`, invalide ou expirée, ne récupère jamais ses features signées)
|
||||
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé résolue en `free`, invalide, expirée ou révoquée, ne récupère jamais ses features signées)
|
||||
- Source de vérité Rust : `FEATURE_TIERS` dans `entitlements.rs`, réduit depuis #301 à `auto-update` → `[base, premium]` (absorbe l'issue #271). Les gates UI (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`) vivent dans la matrice TS `src/shared/entitlements.ts` — voir la section « Gating par édition » et l'[ADR 0017](adr/0017-feature-gating-par-tier.md)
|
||||
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347), tout échec de validation retourne `("free", [])`
|
||||
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347) et son état de révocation (#365), tout échec de validation retourne `("free", [])`
|
||||
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
|
||||
|
||||
### `balance_commands.rs` — Bilan (1)
|
||||
|
|
|
|||
|
|
@ -511,7 +511,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
|
||||
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
|
||||
- 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
|
||||
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
|
||||
|
||||
---
|
||||
|
|
@ -562,11 +562,12 @@ 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, la carte l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »
|
||||
- **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)
|
||||
|
||||
### Astuces
|
||||
|
||||
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée ou invalide), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
|
||||
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide ou révoquée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
|
||||
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
|
||||
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
|
||||
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ pub struct PriceResponse {
|
|||
pub enum FetchPriceError {
|
||||
Auth,
|
||||
PremiumRequired,
|
||||
/// 403 `license_revoked`: the license the activation token stands for was revoked.
|
||||
/// Named for the user, never recorded as the state of the stored key: the token names
|
||||
/// a license the client cannot tie to that key, and a data directory written by 0.15.0
|
||||
/// or earlier can hold a valid key next to the token of a previous license (#365).
|
||||
LicenseRevoked,
|
||||
SymbolNotFound,
|
||||
RateLimit { retry_after_s: u64 },
|
||||
ProviderUnavailable,
|
||||
|
|
@ -66,6 +71,7 @@ impl std::fmt::Display for FetchPriceError {
|
|||
match self {
|
||||
FetchPriceError::Auth => write!(f, "auth"),
|
||||
FetchPriceError::PremiumRequired => write!(f, "premium_required"),
|
||||
FetchPriceError::LicenseRevoked => write!(f, "license_revoked"),
|
||||
FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"),
|
||||
FetchPriceError::RateLimit { retry_after_s } => {
|
||||
write!(f, "rate_limit (retry after {}s)", retry_after_s)
|
||||
|
|
@ -162,7 +168,19 @@ async fn fetch_price_inner(
|
|||
Ok(price_resp)
|
||||
}
|
||||
401 => Err(FetchPriceError::Auth),
|
||||
403 => Err(FetchPriceError::PremiumRequired),
|
||||
403 => {
|
||||
// The price route refuses for two reasons (api-contract-prices.md §5): a
|
||||
// revoked license, tested first by the server, or an edition below Premium.
|
||||
// Only `error.code` tells them apart; an unreadable body keeps the old reading.
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
match body.pointer("/error/code").and_then(|v| v.as_str()) {
|
||||
Some("license_revoked") => Err(FetchPriceError::LicenseRevoked),
|
||||
_ => Err(FetchPriceError::PremiumRequired),
|
||||
}
|
||||
}
|
||||
404 => Err(FetchPriceError::SymbolNotFound),
|
||||
429 => {
|
||||
// Parse `error.retry_after` from the JSON body.
|
||||
|
|
@ -428,6 +446,49 @@ mod tests {
|
|||
assert_eq!(parsed["code"], "premium_required");
|
||||
}
|
||||
|
||||
/// #365: a revoked license used to read as "premium required", because every 403 did.
|
||||
#[tokio::test]
|
||||
async fn it_names_a_revoked_license_on_403() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_inner("revoked-token", "AAPL", "2026-04-25", &server.url()).await;
|
||||
let err_str = price_error_to_string(&result.unwrap_err());
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||
assert_eq!(parsed, serde_json::json!({ "code": "license_revoked" }));
|
||||
}
|
||||
|
||||
/// A 403 whose body cannot be read, or whose code is not known, keeps the old reading.
|
||||
#[tokio::test]
|
||||
async fn it_keeps_premium_required_for_any_other_403() {
|
||||
let bodies = [
|
||||
("text/html", "<html>Forbidden</html>"),
|
||||
("application/json", r#"{"error":{"code":"something_else","message":"x"}}"#),
|
||||
("application/json", r#"{"code":"license_revoked"}"#),
|
||||
];
|
||||
for (content_type, body) in bodies {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||
.with_status(403)
|
||||
.with_header("content-type", content_type)
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
|
||||
let err_str = price_error_to_string(&result.unwrap_err());
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||
assert_eq!(parsed["code"], "premium_required", "body {}", body);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_returns_symbol_not_found_on_404() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -461,6 +461,7 @@ pub fn run() {
|
|||
commands::deactivate_machine,
|
||||
commands::list_activated_machines,
|
||||
commands::get_activation_status,
|
||||
commands::check_license_revocation,
|
||||
commands::start_oauth,
|
||||
commands::refresh_auth_token,
|
||||
commands::get_account_info,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ beforeEach(() => {
|
|||
machine_limit: 3,
|
||||
issued_at: nowS(),
|
||||
expires_at: TEN_YEAR_EXPIRY_S,
|
||||
revoked: false,
|
||||
},
|
||||
error: null,
|
||||
validating: false,
|
||||
|
|
@ -98,7 +99,6 @@ beforeEach(() => {
|
|||
revoked: false,
|
||||
refresh: vi.fn(),
|
||||
submitKey: vi.fn(),
|
||||
markRevoked: vi.fn(),
|
||||
};
|
||||
machines = hookView({});
|
||||
});
|
||||
|
|
@ -119,7 +119,68 @@ describe("LicenseCard load error", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("LicenseCard revoked key", () => {
|
||||
// The main case since #365: Rust persists the revocation and resolves the edition of a
|
||||
// revoked key to Free. The card says why, and offers a way out.
|
||||
describe("LicenseCard revoked key (Free edition)", () => {
|
||||
beforeEach(() => {
|
||||
context = {
|
||||
...context,
|
||||
edition: "free",
|
||||
revoked: true,
|
||||
info: { ...(context.info as LicenseInfo), revoked: true },
|
||||
};
|
||||
machines = hookView({}, true);
|
||||
});
|
||||
|
||||
it("shows the Free edition, names the revocation once, and offers to buy or enter a key", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("license.editions.free");
|
||||
expect(html.split("license.errors.licenseRevoked")).toHaveLength(2);
|
||||
expect(html).toContain("license.purchase");
|
||||
expect(html).toContain("license.enterKey");
|
||||
});
|
||||
|
||||
it("shows no expiry date: it would read as a date until which the key works", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain("license.expiresAt");
|
||||
expect(html).not.toContain("license.noExpiry");
|
||||
expect(html).not.toContain("2036-09-26");
|
||||
});
|
||||
|
||||
it("shows neither the Machines section nor the edition's green check", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain("license.machines.title");
|
||||
expect(html).not.toContain("license.activate");
|
||||
expect(html).not.toContain("lucide-circle-check-big");
|
||||
});
|
||||
|
||||
it("keeps the expiry date of a key that is not revoked", () => {
|
||||
context = {
|
||||
...context,
|
||||
edition: "base",
|
||||
revoked: false,
|
||||
info: { ...(context.info as LicenseInfo), revoked: false },
|
||||
};
|
||||
machines = hookView({});
|
||||
const html = render();
|
||||
expect(html).toContain("license.expiresAt");
|
||||
expect(html).toContain("2036-09-26");
|
||||
expect(html).not.toContain("license.errors.licenseRevoked");
|
||||
});
|
||||
});
|
||||
|
||||
// The corner that keeps a revoked key under a paid edition: Premium coming from the
|
||||
// Maximus account while a revoked key is stored. The Machines section is then still shown.
|
||||
describe("LicenseCard revoked key under an account Premium", () => {
|
||||
beforeEach(() => {
|
||||
context = {
|
||||
...context,
|
||||
edition: "premium",
|
||||
revoked: true,
|
||||
info: { ...(context.info as LicenseInfo), revoked: true },
|
||||
};
|
||||
});
|
||||
|
||||
it("names the revocation at card level with the list closed, and blocks Activate", () => {
|
||||
machines = hookView({ activation: notActivated }, true);
|
||||
const html = render();
|
||||
|
|
|
|||
|
|
@ -60,15 +60,19 @@ function LicenseCardBody() {
|
|||
|
||||
const errorMessage = (err: LicenseError) => t(err.i18nKey, err.params);
|
||||
|
||||
// A revoked key is named once, at card level (#347): not repeated under Machines.
|
||||
// A revoked key is named once, at card level (#347): not repeated under Machines. Since
|
||||
// #365 the revocation is persisted and the edition of a revoked key is Free, so the
|
||||
// Machines section is gone with it; these branches only still show under a paid edition
|
||||
// that comes from the Maximus account while a revoked key is stored.
|
||||
const shownUnderMachines = (err: LicenseError | null) =>
|
||||
err && err.code !== "license_revoked" ? err : null;
|
||||
const activationError = shownUnderMachines(machines.activationError);
|
||||
const listError = shownUnderMachines(machines.listError);
|
||||
|
||||
const editionLabel = t(`license.editions.${edition}`);
|
||||
// No expiry date for a revoked key: it would read as a date until which it works.
|
||||
const expiry =
|
||||
info && info.expires_at > 0
|
||||
info && !revoked && info.expires_at > 0
|
||||
? licenseExpiryDisplay(info.expires_at, Math.floor(Date.now() / 1000), dateLocale)
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ vi.mock("react-i18next", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// The locked screen reads one thing from the license context: whether the stored key
|
||||
// was revoked (#365).
|
||||
let licenseRevoked = false;
|
||||
vi.mock("../../contexts/LicenseContext", () => ({
|
||||
useLicenseContext: () => ({ revoked: licenseRevoked }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useUpdater", () => ({
|
||||
useUpdater: () => ({
|
||||
state: { status: "notEntitled", version: null, body: null, progress: 0, contentLength: null, error: null },
|
||||
|
|
@ -50,6 +57,32 @@ beforeEach(() => {
|
|||
openUrl.mockClear();
|
||||
logError.mockClear();
|
||||
language = "fr";
|
||||
licenseRevoked = false;
|
||||
});
|
||||
|
||||
describe("UpsellGate with a revoked key (#365)", () => {
|
||||
const gate = () =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<UpsellGate feature="budget" requiredTier="base" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
it("says nothing about a revocation for a key that is not revoked", () => {
|
||||
expect(gate()).not.toContain("upsell.revoked");
|
||||
});
|
||||
|
||||
it("names the revocation between the feature description and the CTAs", () => {
|
||||
licenseRevoked = true;
|
||||
const html = gate();
|
||||
const line = html.indexOf("upsell.revoked");
|
||||
expect(line).toBeGreaterThan(html.indexOf("upsell.features.budget"));
|
||||
expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
|
||||
expect(html.split("upsell.revoked")).toHaveLength(2);
|
||||
// The way out stays: buying, or entering another key.
|
||||
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
|
||||
expect(html).toContain("upsell.ctaHaveKey");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpsellGate wiring", () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Lock, ShoppingCart } from "lucide-react";
|
|||
import type { Edition } from "../../services/licenseService";
|
||||
import type { FeatureKey } from "../../shared/entitlements";
|
||||
import { upsellCtaFor } from "../../shared/upsellCta";
|
||||
import { useLicenseContext } from "../../contexts/LicenseContext";
|
||||
import PurchaseButton from "./PurchaseButton";
|
||||
import HaveKeyButton from "./HaveKeyButton";
|
||||
|
||||
|
|
@ -33,9 +34,13 @@ interface UpsellGateProps {
|
|||
*
|
||||
* The tier label reuses the existing `license.editions.*` keys; the feature
|
||||
* description comes from `upsell.features.<FeatureKey>` (FR/EN).
|
||||
*
|
||||
* When the stored key was revoked by the license server (#365), one line says so above
|
||||
* the CTAs: the lock would otherwise appear with no reason given.
|
||||
*/
|
||||
export default function UpsellGate({ feature, requiredTier, onNavigate }: UpsellGateProps) {
|
||||
const { t } = useTranslation();
|
||||
const { revoked } = useLicenseContext();
|
||||
const tier = t(`license.editions.${requiredTier}`);
|
||||
const cta = upsellCtaFor(requiredTier);
|
||||
|
||||
|
|
@ -53,6 +58,12 @@ export default function UpsellGate({ feature, requiredTier, onNavigate }: Upsell
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{revoked && (
|
||||
<p role="alert" className="text-sm text-[var(--negative)]">
|
||||
{t("upsell.revoked")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{cta.purchasable ? (
|
||||
<PurchaseButton />
|
||||
|
|
|
|||
|
|
@ -1,14 +1,30 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { licenseReducer, initialLicenseState } from "./LicenseContext";
|
||||
import type { Edition, LicenseInfo } from "../services/licenseService";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
licenseReducer,
|
||||
initialLicenseState,
|
||||
performSubmitKey,
|
||||
createRevocationCheckStarter,
|
||||
performRevocationCheck,
|
||||
REVOCATION_LOG,
|
||||
type LicenseAction,
|
||||
type RevocationCheckDeps,
|
||||
type SubmitKeyDeps,
|
||||
} from "./LicenseContext";
|
||||
import type {
|
||||
Edition,
|
||||
LicenseInfo,
|
||||
RevocationCheck,
|
||||
RevocationOutcome,
|
||||
} from "../services/licenseService";
|
||||
|
||||
const makeInfo = (edition: Exclude<Edition, "free">): LicenseInfo => ({
|
||||
const makeInfo = (edition: Exclude<Edition, "free">, revoked = false): LicenseInfo => ({
|
||||
edition,
|
||||
email: "max@example.com",
|
||||
features: [],
|
||||
machine_limit: 3,
|
||||
issued_at: 1,
|
||||
expires_at: 0,
|
||||
revoked,
|
||||
});
|
||||
|
||||
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
|
||||
|
|
@ -88,7 +104,7 @@ describe("licenseReducer — key validation is orthogonal to the load lifecycle"
|
|||
);
|
||||
const done = licenseReducer(
|
||||
licenseReducer(bootError, { type: "VALIDATE_START" }),
|
||||
{ type: "VALIDATE_DONE", info: makeInfo("base") },
|
||||
{ type: "VALIDATE_DONE", info: makeInfo("base"), edition: "base" },
|
||||
);
|
||||
expect(done).toEqual({
|
||||
status: "ready",
|
||||
|
|
@ -97,58 +113,278 @@ describe("licenseReducer — key validation is orthogonal to the load lifecycle"
|
|||
error: null,
|
||||
validating: false,
|
||||
validationError: null,
|
||||
revoked: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// A `license_revoked` answer stays known for the whole session (#347, D13): the card
|
||||
// that receives it unmounts when the user leaves Settings → Users or switches profile,
|
||||
// this state does not. Only a different key clears it.
|
||||
describe("licenseReducer — revocation lasts the session", () => {
|
||||
const revoked = licenseReducer(premiumReady, { type: "LICENSE_REVOKED" });
|
||||
|
||||
it("LICENSE_REVOKED flags the stored key without touching the edition", () => {
|
||||
expect(revoked.revoked).toBe(true);
|
||||
expect(revoked.edition).toBe("premium");
|
||||
expect(revoked.status).toBe("ready");
|
||||
});
|
||||
|
||||
it("survives a reload of the license and a load error", () => {
|
||||
const reloaded = licenseReducer(
|
||||
licenseReducer(revoked, { type: "LOAD_START" }),
|
||||
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
|
||||
// The revoked state is persisted by Rust and reaches the context inside the license
|
||||
// info (#365): nothing in the reducer decides it, so nothing in the session can lose it.
|
||||
describe("licenseReducer — the revoked state comes from Rust", () => {
|
||||
const revokedReady = licenseReducer(
|
||||
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
|
||||
{ type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) },
|
||||
);
|
||||
expect(reloaded.revoked).toBe(true);
|
||||
const failed = licenseReducer(licenseReducer(revoked, { type: "LOAD_START" }), {
|
||||
|
||||
it("a loaded revoked key is Free, and its info says why", () => {
|
||||
expect(revokedReady.edition).toBe("free");
|
||||
expect(revokedReady.info?.revoked).toBe(true);
|
||||
expect(revokedReady.info?.edition).toBe("base");
|
||||
});
|
||||
|
||||
it("survives a reload, a load error and a refused key", () => {
|
||||
const reloaded = licenseReducer(
|
||||
licenseReducer(revokedReady, { type: "LOAD_START" }),
|
||||
{ type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) },
|
||||
);
|
||||
expect(reloaded.info?.revoked).toBe(true);
|
||||
|
||||
const failed = licenseReducer(licenseReducer(revokedReady, { type: "LOAD_START" }), {
|
||||
type: "LOAD_ERROR",
|
||||
error: "boot fail",
|
||||
});
|
||||
expect(failed.revoked).toBe(true);
|
||||
});
|
||||
expect(failed.info?.revoked).toBe(true);
|
||||
expect(failed.edition).toBe("free");
|
||||
|
||||
it("survives a refused key", () => {
|
||||
const refused = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
|
||||
const refused = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
|
||||
type: "VALIDATE_ERROR",
|
||||
error: '{"code":"invalid_prefix"}',
|
||||
});
|
||||
expect(refused.revoked).toBe(true);
|
||||
expect(refused.info?.revoked).toBe(true);
|
||||
});
|
||||
|
||||
it("stays when the same key is entered again", () => {
|
||||
const same = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
|
||||
it("VALIDATE_DONE takes the edition it is given, not the one the key claims", () => {
|
||||
const same = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
|
||||
type: "VALIDATE_DONE",
|
||||
info: makeInfo("premium"),
|
||||
info: makeInfo("base", true),
|
||||
edition: "free",
|
||||
});
|
||||
expect(same.revoked).toBe(true);
|
||||
expect(same.edition).toBe("free");
|
||||
expect(same.info?.revoked).toBe(true);
|
||||
});
|
||||
|
||||
it("clears when a different key is stored", () => {
|
||||
it("a different, valid key starts clean", () => {
|
||||
const other: LicenseInfo = { ...makeInfo("premium"), issued_at: 2 };
|
||||
const replaced = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
|
||||
const replaced = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
|
||||
type: "VALIDATE_DONE",
|
||||
info: other,
|
||||
edition: "premium",
|
||||
});
|
||||
expect(replaced.revoked).toBe(false);
|
||||
expect(replaced.edition).toBe("premium");
|
||||
expect(replaced.info?.revoked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
describe("performSubmitKey", () => {
|
||||
function submitHarness(overrides: Partial<SubmitKeyDeps> = {}) {
|
||||
let state = premiumReady;
|
||||
const actions: LicenseAction[] = [];
|
||||
const deps: SubmitKeyDeps = {
|
||||
storeLicense: vi.fn(async () => makeInfo("base")),
|
||||
getEdition: vi.fn(async (): Promise<Edition> => "base"),
|
||||
dispatch: (action) => {
|
||||
actions.push(action);
|
||||
state = licenseReducer(state, action);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { deps, actions, getState: () => state };
|
||||
}
|
||||
|
||||
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)),
|
||||
getEdition: vi.fn(async (): Promise<Edition> => "free"),
|
||||
});
|
||||
const result = await performSubmitKey(deps, "SR-BASE-k");
|
||||
expect(result).toEqual({ ok: true, info: makeInfo("base", true) });
|
||||
expect(getState().edition).toBe("free");
|
||||
expect(getState().info?.revoked).toBe(true);
|
||||
expect(deps.storeLicense).toHaveBeenCalledWith("SR-BASE-k");
|
||||
});
|
||||
|
||||
it("keeps an account Premium over the stored key's own edition", async () => {
|
||||
const { deps, getState } = submitHarness({
|
||||
getEdition: vi.fn(async (): Promise<Edition> => "premium"),
|
||||
});
|
||||
await performSubmitKey(deps, "SR-BASE-k");
|
||||
expect(getState().edition).toBe("premium");
|
||||
expect(getState().info?.edition).toBe("base");
|
||||
});
|
||||
|
||||
it("falls back to Free when the edition cannot be read and the key came back revoked", async () => {
|
||||
const { deps, getState } = submitHarness({
|
||||
storeLicense: vi.fn(async () => makeInfo("base", true)),
|
||||
getEdition: vi.fn(async (): Promise<Edition> => {
|
||||
throw new Error("ipc down");
|
||||
}),
|
||||
});
|
||||
const result = await performSubmitKey(deps, "SR-BASE-k");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(getState().edition).toBe("free");
|
||||
expect(getState().status).toBe("ready");
|
||||
});
|
||||
|
||||
it("falls back to the key's edition when the edition cannot be read and the key is clean", async () => {
|
||||
const { deps, getState } = submitHarness({
|
||||
getEdition: vi.fn(async (): Promise<Edition> => {
|
||||
throw new Error("ipc down");
|
||||
}),
|
||||
});
|
||||
await performSubmitKey(deps, "SR-BASE-k");
|
||||
expect(getState().edition).toBe("base");
|
||||
});
|
||||
|
||||
it("reports a refused key without reading the edition", async () => {
|
||||
const { deps, actions, getState } = submitHarness({
|
||||
storeLicense: vi.fn(async () => {
|
||||
throw '{"code":"invalid_prefix"}';
|
||||
}),
|
||||
});
|
||||
const result = await performSubmitKey(deps, "nonsense");
|
||||
expect(result).toEqual({ ok: false, error: '{"code":"invalid_prefix"}' });
|
||||
expect(deps.getEdition).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"}');
|
||||
});
|
||||
});
|
||||
|
||||
// D1: the check is a startup check. The starter is what makes "once" true: effects do
|
||||
// not run under renderToStaticMarkup, so the decision lives here, outside the provider.
|
||||
describe("createRevocationCheckStarter", () => {
|
||||
const clean = makeInfo("base");
|
||||
|
||||
it("does nothing until the license is ready", () => {
|
||||
const run = vi.fn();
|
||||
const start = createRevocationCheckStarter(run);
|
||||
start("idle", null);
|
||||
start("loading", null);
|
||||
start("error", clean);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs once at the first ready state with a stored key", () => {
|
||||
const run = vi.fn();
|
||||
const start = createRevocationCheckStarter(run);
|
||||
start("loading", null);
|
||||
start("ready", clean);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("never runs again: not after a reload, nor for another key", () => {
|
||||
const run = vi.fn();
|
||||
const start = createRevocationCheckStarter(run);
|
||||
start("ready", clean);
|
||||
start("loading", clean);
|
||||
start("ready", clean);
|
||||
start("ready", { ...makeInfo("premium"), issued_at: 2 });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the revoked state the key was loaded with", () => {
|
||||
const run = vi.fn();
|
||||
createRevocationCheckStarter(run)("ready", makeInfo("base", true));
|
||||
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();
|
||||
});
|
||||
|
||||
it("is consumed by the first ready state after a boot error too", () => {
|
||||
const run = vi.fn();
|
||||
const start = createRevocationCheckStarter(run);
|
||||
start("error", null);
|
||||
start("loading", null);
|
||||
start("ready", clean);
|
||||
start("ready", clean);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performRevocationCheck", () => {
|
||||
function checkHarness(result: RevocationCheck | Error) {
|
||||
const deps: RevocationCheckDeps = {
|
||||
checkLicenseRevocation: vi.fn(async () => {
|
||||
if (result instanceof Error) throw result;
|
||||
return result;
|
||||
}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
logInfo: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
};
|
||||
return deps;
|
||||
}
|
||||
|
||||
// Each message written out: comparing against REVOCATION_LOG itself would pass with
|
||||
// two messages swapped.
|
||||
const EXPECTED_LOG: [RevocationOutcome, string][] = [
|
||||
["no_license", "License check: no valid key stored, nothing sent"],
|
||||
["not_due", "License check: last attempt less than 24 h ago, nothing sent"],
|
||||
["valid", "License check: the license server confirms the key"],
|
||||
["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"],
|
||||
];
|
||||
|
||||
it("has a log message for every outcome, and no other", () => {
|
||||
expect(Object.keys(REVOCATION_LOG).sort()).toEqual(EXPECTED_LOG.map(([o]) => o).sort());
|
||||
});
|
||||
|
||||
it.each(EXPECTED_LOG)("writes one log line for %s", async (outcome, message) => {
|
||||
const deps = checkHarness({ outcome, revoked: false });
|
||||
await performRevocationCheck(deps, false);
|
||||
expect(deps.logInfo).toHaveBeenCalledTimes(1);
|
||||
expect(deps.logInfo).toHaveBeenCalledWith(message);
|
||||
expect(deps.logWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads the license when the key turns out revoked", async () => {
|
||||
const deps = checkHarness({ outcome: "revoked", revoked: true });
|
||||
await performRevocationCheck(deps, false);
|
||||
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reloads the license when a revocation is lifted", async () => {
|
||||
const deps = checkHarness({ outcome: "valid", revoked: false });
|
||||
await performRevocationCheck(deps, true);
|
||||
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reload when nothing changed", async () => {
|
||||
const unchanged: [RevocationCheck, boolean][] = [
|
||||
[{ outcome: "valid", revoked: false }, false],
|
||||
[{ outcome: "revoked", revoked: true }, true],
|
||||
[{ outcome: "not_due", revoked: true }, true],
|
||||
[{ outcome: "unreachable", revoked: false }, false],
|
||||
[{ outcome: "unreachable", revoked: true }, true],
|
||||
[{ outcome: "no_license", revoked: false }, false],
|
||||
];
|
||||
for (const [result, wasRevoked] of unchanged) {
|
||||
const deps = checkHarness(result);
|
||||
await performRevocationCheck(deps, wasRevoked);
|
||||
expect(deps.refresh, `${result.outcome} from ${wasRevoked}`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs a command that could not run and changes nothing", async () => {
|
||||
const failure = new Error("ipc down");
|
||||
const deps = checkHarness(failure);
|
||||
await expect(performRevocationCheck(deps, false)).resolves.toBeUndefined();
|
||||
expect(deps.logWarn).toHaveBeenCalledWith(
|
||||
"License check: the check could not run, nothing changed",
|
||||
failure,
|
||||
);
|
||||
expect(deps.logInfo).not.toHaveBeenCalled();
|
||||
expect(deps.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,15 +5,20 @@ import {
|
|||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
checkLicenseRevocation,
|
||||
getEdition,
|
||||
readLicense,
|
||||
storeLicense,
|
||||
type Edition,
|
||||
type LicenseInfo,
|
||||
type RevocationCheck,
|
||||
type RevocationOutcome,
|
||||
} from "../services/licenseService";
|
||||
import { logInfo, logWarn } from "../services/logService";
|
||||
|
||||
/**
|
||||
* Machine-level license context — loaded ONCE at boot, above ProfileProvider.
|
||||
|
|
@ -35,11 +40,12 @@ import {
|
|||
* it can never arm on a bad key and auto-clear the message the user is reading,
|
||||
* and `ready` consumers never regress on a typo'd key.
|
||||
*
|
||||
* Revocation (#347, D13): a `license_revoked` answer from the license server is
|
||||
* recorded here for the rest of the session. The License card that receives it
|
||||
* unmounts when the user leaves Settings → Users or switches profile; this
|
||||
* provider does not. Session only — never persisted — and it never touches the
|
||||
* edition, which keeps following the local validation of the key (ADR 0017).
|
||||
* Revocation (#365, which replaces D13 of #347): the revoked state lives in Rust,
|
||||
* 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.
|
||||
*/
|
||||
|
||||
type LicenseStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
|
@ -53,18 +59,16 @@ interface LicenseState {
|
|||
validating: boolean;
|
||||
/** Key-submission error — never retried, cleared on the next submit. */
|
||||
validationError: string | null;
|
||||
/** The server answered `license_revoked` for the stored key during this session. */
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
type LicenseAction =
|
||||
/** Exported for the functions below and their tests — not part of the context API. */
|
||||
export type LicenseAction =
|
||||
| { type: "LOAD_START" }
|
||||
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
|
||||
| { type: "LOAD_ERROR"; error: string }
|
||||
| { type: "VALIDATE_START" }
|
||||
| { type: "VALIDATE_DONE"; info: LicenseInfo }
|
||||
| { type: "VALIDATE_ERROR"; error: string }
|
||||
| { type: "LICENSE_REVOKED" };
|
||||
| { type: "VALIDATE_DONE"; info: LicenseInfo; edition: Edition }
|
||||
| { type: "VALIDATE_ERROR"; error: string };
|
||||
|
||||
/** Exported for unit tests only — not part of the context API. */
|
||||
export const initialLicenseState: LicenseState = {
|
||||
|
|
@ -74,7 +78,6 @@ export const initialLicenseState: LicenseState = {
|
|||
error: null,
|
||||
validating: false,
|
||||
validationError: null,
|
||||
revoked: false,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -87,11 +90,6 @@ export function licenseIdentity(info: LicenseInfo | null): string | null {
|
|||
: `${info.email}|${info.edition}|${info.issued_at}|${info.expires_at}`;
|
||||
}
|
||||
|
||||
/** Same license: a re-entered key, not a replacement. */
|
||||
function isSameLicense(a: LicenseInfo | null, b: LicenseInfo): boolean {
|
||||
return a !== null && licenseIdentity(a) === licenseIdentity(b);
|
||||
}
|
||||
|
||||
/** Exported for unit tests only — not part of the context API. */
|
||||
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
|
||||
switch (action.type) {
|
||||
|
|
@ -106,22 +104,20 @@ export function licenseReducer(state: LicenseState, action: LicenseAction): Lice
|
|||
case "VALIDATE_START":
|
||||
return { ...state, validating: true, validationError: null };
|
||||
case "VALIDATE_DONE":
|
||||
// The edition is the one Rust resolved for the stored key, not the one the
|
||||
// key claims: a revoked key entered again stays in the Free edition (#365).
|
||||
return {
|
||||
status: "ready",
|
||||
edition: action.info.edition,
|
||||
edition: action.edition,
|
||||
info: action.info,
|
||||
error: null,
|
||||
validating: false,
|
||||
validationError: null,
|
||||
// A replacement key starts clean; the same key re-entered stays revoked.
|
||||
revoked: state.revoked && isSameLicense(state.info, action.info),
|
||||
};
|
||||
case "VALIDATE_ERROR":
|
||||
// Status untouched: "ready" stays ready (no gating flash on a typo'd
|
||||
// key), and a boot-error retry loop keeps running through the failure.
|
||||
return { ...state, validating: false, validationError: action.error };
|
||||
case "LICENSE_REVOKED":
|
||||
return { ...state, revoked: true };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +125,91 @@ type SubmitKeyResult =
|
|||
| { ok: true; info: LicenseInfo }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface SubmitKeyDeps {
|
||||
storeLicense: (key: string) => Promise<LicenseInfo>;
|
||||
getEdition: () => Promise<Edition>;
|
||||
dispatch: (action: LicenseAction) => 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.
|
||||
*/
|
||||
export async function performSubmitKey(deps: SubmitKeyDeps, key: string): Promise<SubmitKeyResult> {
|
||||
deps.dispatch({ type: "VALIDATE_START" });
|
||||
let info: LicenseInfo;
|
||||
try {
|
||||
info = await deps.storeLicense(key);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
deps.dispatch({ type: "VALIDATE_ERROR", error: message });
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
let edition: Edition;
|
||||
try {
|
||||
edition = await deps.getEdition();
|
||||
} catch {
|
||||
edition = info.revoked ? "free" : info.edition;
|
||||
}
|
||||
deps.dispatch({ type: "VALIDATE_DONE", info, edition });
|
||||
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.
|
||||
*/
|
||||
export function createRevocationCheckStarter(run: (wasRevoked: boolean) => void) {
|
||||
let consumed = false;
|
||||
return (status: LicenseStatus, info: LicenseInfo | null): void => {
|
||||
if (consumed || status !== "ready") return;
|
||||
consumed = true;
|
||||
if (info !== null) run(info.revoked);
|
||||
};
|
||||
}
|
||||
|
||||
/** One line of the in-app log per outcome: what was sent, and what it changed. */
|
||||
export const REVOCATION_LOG: Record<RevocationOutcome, string> = {
|
||||
no_license: "License check: no valid key stored, nothing sent",
|
||||
not_due: "License check: last attempt less than 24 h ago, nothing sent",
|
||||
valid: "License check: the license server confirms the key",
|
||||
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",
|
||||
};
|
||||
|
||||
export interface RevocationCheckDeps {
|
||||
checkLicenseRevocation: () => Promise<RevocationCheck>;
|
||||
refresh: () => Promise<void>;
|
||||
logInfo: (...args: unknown[]) => void;
|
||||
logWarn: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the revocation check and write its outcome to the in-app log (Settings →
|
||||
* System → Logs): it is a request the user did not ask for, so it must be visible.
|
||||
* The context is reloaded only when the revoked state changed. A command that fails
|
||||
* is logged and changes nothing.
|
||||
*/
|
||||
export async function performRevocationCheck(
|
||||
deps: RevocationCheckDeps,
|
||||
wasRevoked: boolean,
|
||||
): Promise<void> {
|
||||
let result: RevocationCheck;
|
||||
try {
|
||||
result = await deps.checkLicenseRevocation();
|
||||
} catch (e) {
|
||||
deps.logWarn("License check: the check could not run, nothing changed", e);
|
||||
return;
|
||||
}
|
||||
deps.logInfo(REVOCATION_LOG[result.outcome]);
|
||||
if (result.revoked !== wasRevoked) await deps.refresh();
|
||||
}
|
||||
|
||||
interface LicenseContextValue {
|
||||
status: LicenseStatus;
|
||||
edition: Edition;
|
||||
|
|
@ -137,12 +218,10 @@ interface LicenseContextValue {
|
|||
error: string | null;
|
||||
validating: boolean;
|
||||
validationError: string | null;
|
||||
/** The server answered `license_revoked` during this session (#347). */
|
||||
/** The license server revoked the stored key (#365). Persisted by Rust. */
|
||||
revoked: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
submitKey: (key: string) => Promise<SubmitKeyResult>;
|
||||
/** Records a `license_revoked` answer for the rest of the session. Stable across renders. */
|
||||
markRevoked: () => void;
|
||||
}
|
||||
|
||||
const LicenseContext = createContext<LicenseContextValue | null>(null);
|
||||
|
|
@ -168,28 +247,29 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const submitKey = useCallback(async (key: string): Promise<SubmitKeyResult> => {
|
||||
dispatch({ type: "VALIDATE_START" });
|
||||
try {
|
||||
const info = await storeLicense(key);
|
||||
dispatch({ type: "VALIDATE_DONE", info });
|
||||
return { ok: true, info };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
dispatch({ type: "VALIDATE_ERROR", error: message });
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markRevoked = useCallback(() => {
|
||||
dispatch({ type: "LICENSE_REVOKED" });
|
||||
}, []);
|
||||
const submitKey = useCallback(
|
||||
(key: string) => performSubmitKey({ storeLicense, getEdition, dispatch }, key),
|
||||
[],
|
||||
);
|
||||
|
||||
// Load once at boot.
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Revocation check: started once per app start (see createRevocationCheckStarter).
|
||||
const [startRevocationCheck] = useState(() =>
|
||||
createRevocationCheckStarter((wasRevoked) => {
|
||||
void performRevocationCheck(
|
||||
{ checkLicenseRevocation, refresh, logInfo, logWarn },
|
||||
wasRevoked,
|
||||
);
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
startRevocationCheck(state.status, state.info);
|
||||
}, [startRevocationCheck, state.status, state.info]);
|
||||
|
||||
// Error recovery: retry with capped exponential backoff (CWE-703). Load
|
||||
// errors only by construction — validation failures never set status.
|
||||
useEffect(() => {
|
||||
|
|
@ -215,10 +295,9 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
|||
error: state.error,
|
||||
validating: state.validating,
|
||||
validationError: state.validationError,
|
||||
revoked: state.revoked,
|
||||
revoked: state.info?.revoked ?? false,
|
||||
refresh,
|
||||
submitKey,
|
||||
markRevoked,
|
||||
};
|
||||
|
||||
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ describe("useIsPremium", () => {
|
|||
revoked: false,
|
||||
refresh: vi.fn(),
|
||||
submitKey: vi.fn(),
|
||||
markRevoked: vi.fn(),
|
||||
});
|
||||
expect(useIsPremium()).toBe(true);
|
||||
});
|
||||
|
|
@ -40,7 +39,6 @@ describe("useIsPremium", () => {
|
|||
revoked: false,
|
||||
refresh: vi.fn(),
|
||||
submitKey: vi.fn(),
|
||||
markRevoked: vi.fn(),
|
||||
});
|
||||
expect(useIsPremium()).toBe(false);
|
||||
});
|
||||
|
|
@ -57,7 +55,6 @@ describe("useIsPremium", () => {
|
|||
revoked: false,
|
||||
refresh: vi.fn(),
|
||||
submitKey: vi.fn(),
|
||||
markRevoked: vi.fn(),
|
||||
});
|
||||
expect(useIsPremium()).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
} from "../services/licenseService";
|
||||
|
||||
// The hook itself reads the LicenseContext; the pure functions below never do.
|
||||
const context = { refresh: vi.fn(async () => {}), revoked: false, markRevoked: vi.fn() };
|
||||
const context = { refresh: vi.fn(async () => {}), revoked: false };
|
||||
vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: () => context }));
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
|
|
@ -303,7 +303,6 @@ function harness(overrides: Partial<LicenseMachinesDeps> = {}) {
|
|||
),
|
||||
getActivationStatus: vi.fn(async () => ({ is_activated: true })),
|
||||
refresh: vi.fn(async () => {}),
|
||||
markRevoked: vi.fn(),
|
||||
dispatch: (action) => {
|
||||
state = licenseMachinesReducer(state, action);
|
||||
},
|
||||
|
|
@ -377,10 +376,10 @@ describe("performActivate", () => {
|
|||
|
||||
it("neither loads the list nor refreshes on other refusals", async () => {
|
||||
const { deps, getState } = harness({
|
||||
activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_revoked") })),
|
||||
activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })),
|
||||
});
|
||||
await performActivate(deps);
|
||||
expect(getState().activationError?.code).toBe("license_revoked");
|
||||
expect(getState().activationError?.code).toBe("license_expired");
|
||||
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
||||
expect(deps.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -438,39 +437,42 @@ describe("performDeactivate", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// A revoked key stays flagged for the session through the LicenseContext (#347), and
|
||||
// the three server calls can each be the one that learns it.
|
||||
describe("revocation is recorded for the session", () => {
|
||||
// The Rust command that receives `license_revoked` persists it (#365): each of the three
|
||||
// server calls reloads the LicenseContext, so the edition (Free) and the card follow.
|
||||
describe("a revocation reloads the license context", () => {
|
||||
const revokedResult = { ok: false as const, error: err("license_revoked") };
|
||||
|
||||
it("from an activation", async () => {
|
||||
const { deps } = harness({ activateMachine: vi.fn(async () => revokedResult) });
|
||||
it("from an activation, without loading the list", async () => {
|
||||
const { deps, getState } = harness({ activateMachine: vi.fn(async () => revokedResult) });
|
||||
await performActivate(deps);
|
||||
expect(deps.markRevoked).toHaveBeenCalledTimes(1);
|
||||
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
|
||||
expect(getState().activationError?.code).toBe("license_revoked");
|
||||
});
|
||||
|
||||
it("from the machine list", async () => {
|
||||
const { deps, getState } = harness({ listActivatedMachines: vi.fn(async () => revokedResult) });
|
||||
await loadMachines(deps);
|
||||
expect(deps.markRevoked).toHaveBeenCalledTimes(1);
|
||||
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(getState().listError?.code).toBe("license_revoked");
|
||||
});
|
||||
|
||||
it("from a deactivation", async () => {
|
||||
const { deps } = harness({ deactivateMachine: vi.fn(async () => revokedResult) });
|
||||
await performDeactivate(deps, "a");
|
||||
expect(deps.markRevoked).toHaveBeenCalledTimes(1);
|
||||
expect(deps.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never from another refusal or a success", async () => {
|
||||
it("never from another refusal", async () => {
|
||||
const { deps } = harness({
|
||||
activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })),
|
||||
listActivatedMachines: vi.fn(async () => ({ ok: false as const, error: err("network") })),
|
||||
deactivateMachine: vi.fn(async () => ({ ok: false as const, error: err("server", { status: 502 }) })),
|
||||
});
|
||||
await performActivate(deps);
|
||||
await loadMachines(deps);
|
||||
await performDeactivate(deps, "a");
|
||||
expect(deps.markRevoked).not.toHaveBeenCalled();
|
||||
expect(deps.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -490,11 +492,11 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
context.revoked = false;
|
||||
context.markRevoked.mockClear();
|
||||
context.refresh.mockClear();
|
||||
vi.mocked(invoke).mockReset();
|
||||
});
|
||||
|
||||
it("reads the session revocation and blocks Activate", () => {
|
||||
it("reads the revocation from the context and blocks Activate", () => {
|
||||
context.revoked = true;
|
||||
const view = renderHook();
|
||||
expect(view.revoked).toBe(true);
|
||||
|
|
@ -507,10 +509,10 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
|
|||
expect(view.canActivate).toBe(true);
|
||||
});
|
||||
|
||||
it("hands the context's markRevoked to the server calls", async () => {
|
||||
it("hands the context's refresh to the server calls", async () => {
|
||||
vi.mocked(invoke).mockRejectedValue('{"code":"license_revoked"}');
|
||||
const view = renderHook();
|
||||
await view.activate();
|
||||
expect(context.markRevoked).toHaveBeenCalledTimes(1);
|
||||
expect(context.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ import { useLicenseContext } from "../contexts/LicenseContext";
|
|||
// verify, and nothing loads the list from an effect (StrictMode would double
|
||||
// it). List loads come only from explicit handlers.
|
||||
//
|
||||
// A `license_revoked` answer, from any of the three calls, is recorded in the
|
||||
// LicenseContext for the rest of the session (#347): this hook's own state dies
|
||||
// with the card when the user leaves Settings → Users or switches profile.
|
||||
// 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.
|
||||
|
||||
export interface LicenseMachinesState {
|
||||
activation: ActivationStatus | null;
|
||||
|
|
@ -162,14 +163,15 @@ export interface LicenseMachinesDeps {
|
|||
listActivatedMachines: () => Promise<LicenseResult<MachineInfo[]>>;
|
||||
getActivationStatus: () => Promise<ActivationStatus>;
|
||||
refresh: () => Promise<void>;
|
||||
/** Records a `license_revoked` answer in the LicenseContext for the session. */
|
||||
markRevoked: () => void;
|
||||
dispatch: (action: LicenseMachinesAction) => void;
|
||||
}
|
||||
|
||||
/** A revoked key stays flagged for the session, whichever call learned it. */
|
||||
function noteRevocation(deps: LicenseMachinesDeps, error: LicenseError): void {
|
||||
if (error.code === "license_revoked") deps.markRevoked();
|
||||
/**
|
||||
* Rust has just persisted the revocation, whichever call learned it: reload the license
|
||||
* so the edition and the revoked state follow.
|
||||
*/
|
||||
async function noteRevocation(deps: LicenseMachinesDeps, error: LicenseError): Promise<void> {
|
||||
if (error.code === "license_revoked") await deps.refresh();
|
||||
}
|
||||
|
||||
/** Local read (no network). Best effort: a failure keeps the last known status. */
|
||||
|
|
@ -187,7 +189,7 @@ export async function loadMachines(deps: LicenseMachinesDeps): Promise<void> {
|
|||
const result = await deps.listActivatedMachines();
|
||||
if (!result.ok) {
|
||||
deps.dispatch({ type: "LIST_LOAD_ERROR", error: result.error });
|
||||
noteRevocation(deps, result.error);
|
||||
await noteRevocation(deps, result.error);
|
||||
return;
|
||||
}
|
||||
deps.dispatch({ type: "LIST_LOAD_DONE", machines: result.value });
|
||||
|
|
@ -198,7 +200,7 @@ export async function performActivate(deps: LicenseMachinesDeps): Promise<void>
|
|||
const result = await deps.activateMachine();
|
||||
if (!result.ok) {
|
||||
deps.dispatch({ type: "ACTIVATE_ERROR", error: result.error });
|
||||
noteRevocation(deps, result.error);
|
||||
await noteRevocation(deps, result.error);
|
||||
if (result.error.code === "machine_limit_reached") {
|
||||
await loadMachines(deps);
|
||||
}
|
||||
|
|
@ -216,7 +218,7 @@ export async function performDeactivate(deps: LicenseMachinesDeps, machineId: st
|
|||
const result = await deps.deactivateMachine(machineId);
|
||||
if (!result.ok) {
|
||||
deps.dispatch({ type: "DEACTIVATE_ERROR", error: result.error });
|
||||
noteRevocation(deps, result.error);
|
||||
await noteRevocation(deps, result.error);
|
||||
return;
|
||||
}
|
||||
// Removed locally, no verify call: see the rate budget note at the top.
|
||||
|
|
@ -231,7 +233,7 @@ export async function performDeactivate(deps: LicenseMachinesDeps, machineId: st
|
|||
* free edition, where the card shows no machine section.
|
||||
*/
|
||||
export function useLicenseMachines(enabled: boolean) {
|
||||
const { refresh, revoked, markRevoked } = useLicenseContext();
|
||||
const { refresh, revoked } = useLicenseContext();
|
||||
const [state, dispatch] = useReducer(licenseMachinesReducer, initialLicenseMachinesState);
|
||||
|
||||
const deps = useMemo<LicenseMachinesDeps>(
|
||||
|
|
@ -241,10 +243,9 @@ export function useLicenseMachines(enabled: boolean) {
|
|||
listActivatedMachines,
|
||||
getActivationStatus,
|
||||
refresh,
|
||||
markRevoked,
|
||||
dispatch,
|
||||
}),
|
||||
[refresh, markRevoked],
|
||||
[refresh],
|
||||
);
|
||||
|
||||
// Local activation status only (get_activation_status reads the token on
|
||||
|
|
@ -263,7 +264,7 @@ export function useLicenseMachines(enabled: boolean) {
|
|||
|
||||
return {
|
||||
state,
|
||||
/** The key was revoked during this session (LicenseContext, #347). */
|
||||
/** The license server revoked the stored key (LicenseContext, #365). */
|
||||
revoked,
|
||||
activate,
|
||||
deactivate,
|
||||
|
|
|
|||
|
|
@ -1098,7 +1098,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",
|
||||
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"editions": {
|
||||
|
|
@ -1118,7 +1118,7 @@
|
|||
"Modules unlock immediately — no reinstall or restart needed"
|
||||
],
|
||||
"tips": [
|
||||
"Locking is never destructive: if your edition goes down (expired or invalid key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
|
||||
"Locking is never destructive: if your edition goes down (expired, invalid or revoked key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
|
||||
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
|
||||
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
|
||||
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
|
||||
|
|
@ -1126,7 +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, the License card says so until the app is closed and no longer offers \"Activate\""
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -1243,7 +1244,8 @@
|
|||
},
|
||||
"ctaGet": "Get {{tier}}",
|
||||
"premiumNotForSale": "Premium isn't sold online yet.",
|
||||
"ctaHaveKey": "I already have a key"
|
||||
"ctaHaveKey": "I already have a key",
|
||||
"revoked": "Your license key was revoked: the Free edition applies."
|
||||
},
|
||||
"account": {
|
||||
"title": "Maximus Account",
|
||||
|
|
@ -2097,7 +2099,7 @@
|
|||
"missingParam": "Missing parameter",
|
||||
"authFailed": "Authentication failed — activate this machine in Settings → Users",
|
||||
"premiumRequired": "This feature requires a premium subscription",
|
||||
"licenseRevoked": "License revoked",
|
||||
"licenseRevoked": "This machine's activation belongs to a revoked license",
|
||||
"symbolNotFound": "Symbol not found",
|
||||
"rateLimit": "Too many requests — retry in {{seconds}}s",
|
||||
"serverUnavailable": "Server unavailable — try again later",
|
||||
|
|
|
|||
|
|
@ -1098,7 +1098,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",
|
||||
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"editions": {
|
||||
|
|
@ -1118,7 +1118,7 @@
|
|||
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire"
|
||||
],
|
||||
"tips": [
|
||||
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée ou invalide), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
|
||||
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide ou révoquée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
|
||||
"En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés",
|
||||
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
|
||||
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
|
||||
|
|
@ -1126,7 +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, la carte Licence l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »"
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -1243,7 +1244,8 @@
|
|||
},
|
||||
"ctaGet": "Obtenir {{tier}}",
|
||||
"premiumNotForSale": "Premium n'est pas encore offert en ligne.",
|
||||
"ctaHaveKey": "J'ai déjà une clé"
|
||||
"ctaHaveKey": "J'ai déjà une clé",
|
||||
"revoked": "Votre clé de licence a été révoquée : l'édition Gratuite s'applique."
|
||||
},
|
||||
"account": {
|
||||
"title": "Compte Maximus",
|
||||
|
|
@ -2097,7 +2099,7 @@
|
|||
"missingParam": "Paramètre manquant",
|
||||
"authFailed": "Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs",
|
||||
"premiumRequired": "Cette fonction nécessite un abonnement premium",
|
||||
"licenseRevoked": "Licence révoquée",
|
||||
"licenseRevoked": "L'activation de cette machine est liée à une licence révoquée",
|
||||
"symbolNotFound": "Symbole introuvable",
|
||||
"rateLimit": "Trop de requêtes — réessaie dans {{seconds}} s",
|
||||
"serverUnavailable": "Serveur indisponible — réessaie plus tard",
|
||||
|
|
|
|||
|
|
@ -2104,6 +2104,25 @@ describe("balance.service.prices", () => {
|
|||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 3b. 403 license_revoked — named as a revocation, no retry (#365). Before, the Rust
|
||||
// command mapped every 403 to premium_required.
|
||||
it("fetchPrice license_revoked is named as a revocation, without retry", async () => {
|
||||
vi.mocked(invoke).mockRejectedValueOnce('{"code":"license_revoked"}');
|
||||
|
||||
const promise = prices.fetchPrice("AAPL", "2026-04-25");
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("license_revoked");
|
||||
expect(result.error.i18nKey).toBe(
|
||||
"balance.priceFetching.errors.licenseRevoked"
|
||||
);
|
||||
}
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 4. 404 symbol_not_found — no retry
|
||||
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
|
||||
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
|
||||
|
|
|
|||
|
|
@ -2614,6 +2614,7 @@ export function isLinkedTransactionFkError(error: unknown): boolean {
|
|||
// Annexe B i18n mapping (keys live in the i18n PR #160):
|
||||
// auth → balance.priceFetching.errors.authFailed
|
||||
// premium_required → balance.priceFetching.errors.premiumRequired
|
||||
// license_revoked → balance.priceFetching.errors.licenseRevoked
|
||||
// symbol_not_found → balance.priceFetching.errors.symbolNotFound
|
||||
// rate_limit → balance.priceFetching.errors.rateLimit
|
||||
// provider_unavailable → balance.priceFetching.errors.serverUnavailable
|
||||
|
|
@ -2624,6 +2625,7 @@ export function isLinkedTransactionFkError(error: unknown): boolean {
|
|||
export type PriceErrorCode =
|
||||
| "auth"
|
||||
| "premium_required"
|
||||
| "license_revoked"
|
||||
| "symbol_not_found"
|
||||
| "rate_limit"
|
||||
| "provider_unavailable"
|
||||
|
|
@ -2667,6 +2669,9 @@ interface RawPriceResponse {
|
|||
const PRICE_ERROR_I18N_MAP: Record<Exclude<PriceErrorCode, "rate_limit">, string> = {
|
||||
auth: "balance.priceFetching.errors.authFailed",
|
||||
premium_required: "balance.priceFetching.errors.premiumRequired",
|
||||
// The license of this machine's activation was revoked (#365). Named, never taken
|
||||
// as the state of the stored key: Rust cannot tie the activation token to that key.
|
||||
license_revoked: "balance.priceFetching.errors.licenseRevoked",
|
||||
symbol_not_found: "balance.priceFetching.errors.symbolNotFound",
|
||||
provider_unavailable: "balance.priceFetching.errors.serverUnavailable",
|
||||
network: "balance.priceFetching.errors.serverUnavailable",
|
||||
|
|
@ -2678,6 +2683,7 @@ const PRICE_ERROR_I18N_MAP: Record<Exclude<PriceErrorCode, "rate_limit">, string
|
|||
const NO_RETRY_CODES = new Set<PriceErrorCode>([
|
||||
"auth",
|
||||
"premium_required",
|
||||
"license_revoked",
|
||||
"symbol_not_found",
|
||||
"rate_limit",
|
||||
"session_cap_reached",
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ describe("upsell i18n coverage (fr + en)", () => {
|
|||
ctaGet: string;
|
||||
premiumNotForSale: string;
|
||||
ctaHaveKey: string;
|
||||
revoked: string;
|
||||
};
|
||||
}
|
||||
const locales: Record<string, UpsellMessages> = { fr, en };
|
||||
|
|
@ -144,6 +145,7 @@ describe("upsell i18n coverage (fr + en)", () => {
|
|||
expect(messages.upsell.ctaGet).toBeTruthy();
|
||||
expect(messages.upsell.premiumNotForSale).toBeTruthy();
|
||||
expect(messages.upsell.ctaHaveKey).toBeTruthy();
|
||||
expect(messages.upsell.revoked).toBeTruthy();
|
||||
expect(messages.nav.locked).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -157,6 +159,7 @@ describe("upsell i18n coverage (fr + en)", () => {
|
|||
"ctaHaveKey",
|
||||
"features",
|
||||
"premiumNotForSale",
|
||||
"revoked",
|
||||
"title",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
parseLicenseError,
|
||||
LICENSE_ERROR_I18N_MAP,
|
||||
activateMachine,
|
||||
checkLicenseRevocation,
|
||||
deactivateMachine,
|
||||
listActivatedMachines,
|
||||
type LicenseErrorCode,
|
||||
|
|
@ -131,6 +132,13 @@ describe("license command wrappers", () => {
|
|||
await expect(deactivateMachine("m2")).resolves.toEqual({ ok: true, value: undefined });
|
||||
expect(mockInvoke).toHaveBeenCalledWith("deactivate_machine", { machineId: "m2" });
|
||||
});
|
||||
|
||||
it("runs the revocation check without any argument", async () => {
|
||||
// The webview sends nothing of its own: Rust reads the stored key (#365).
|
||||
mockInvoke.mockResolvedValueOnce({ outcome: "revoked", revoked: true });
|
||||
await expect(checkLicenseRevocation()).resolves.toEqual({ outcome: "revoked", revoked: true });
|
||||
expect(mockInvoke).toHaveBeenCalledWith("check_license_revocation");
|
||||
});
|
||||
});
|
||||
|
||||
// The render tests echo i18n keys instead of translating them: a key missing
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ export interface LicenseInfo {
|
|||
machine_limit: number;
|
||||
issued_at: number;
|
||||
expires_at: number;
|
||||
/**
|
||||
* The license server answered "revoked" for this key and nothing lifted it since
|
||||
* (#365). Rust keeps that state next to the key: the edition it resolves is then
|
||||
* "free", whatever `edition` says here about the key itself.
|
||||
*/
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
export async function validateLicenseKey(key: string): Promise<LicenseInfo> {
|
||||
|
|
@ -31,6 +37,34 @@ export async function checkEntitlement(feature: string): Promise<boolean> {
|
|||
return invoke<boolean>("check_entitlement", { feature });
|
||||
}
|
||||
|
||||
/**
|
||||
* How a revocation check ended (#365), as the Rust `RevocationOutcome` enum serializes.
|
||||
* `no_license`, `not_due` and `storage` mean nothing was sent.
|
||||
*/
|
||||
export type RevocationOutcome =
|
||||
| "no_license"
|
||||
| "not_due"
|
||||
| "valid"
|
||||
| "revoked"
|
||||
| "unreachable"
|
||||
| "unknown"
|
||||
| "storage";
|
||||
|
||||
export interface RevocationCheck {
|
||||
outcome: RevocationOutcome;
|
||||
/** Whether the stored key is known as revoked after the check. */
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the license server whether the stored key was revoked. Rust sends nothing without
|
||||
* a stored key, nor when the last attempt is less than 24 h old, and an unreachable
|
||||
* server changes nothing. The request carries the product and the key only.
|
||||
*/
|
||||
export async function checkLicenseRevocation(): Promise<RevocationCheck> {
|
||||
return invoke<RevocationCheck>("check_license_revocation");
|
||||
}
|
||||
|
||||
export interface MachineInfo {
|
||||
/**
|
||||
* Identifier of the row on the license server: derived (64 hex characters) for an
|
||||
|
|
|
|||
Loading…
Reference in a new issue