feat(licence): révocation vérifiée en ligne — une clé remboursée retombe en Gratuite #365
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#365
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Contexte
Constat de Max le 2026-09-30, pendant le go-live Stripe (maximus/la-compagnie-maximus#141) : achat réel d'une licence Base, remboursement complet, licence révoquée côté serveur, mais la clé reste pleinement fonctionnelle dans l'app.
La cause, vérifiée sur
main(a9fa34d) :current_entitlements→entitlements_from_dir→resolve_license_entitlements(signature Ed25519 etexp). Les clés durent 36525 jours depuis maximus-api#63 :expne joue aucun rôle d'anti-révocation./v1/licenses/verifyn'est appelé que pour lister les machines (verify_inner). Une réponse{valid:false, reason:"revoked"}pose un drapeau de session dansLicenseContext(#347, D13) : il affiche un message, ne touche pas l'édition et disparaît à la fermeture de l'app.auto-updateest gatée sur l'édition locale (FEATURE_TIERS,entitlements.rs) : une clé révoquée garde aussi les mises à jour automatiques.Les conditions de vente du site disent « le remboursement entraîne la révocation de la licence » (
sales.refund_desc). Seule la moitié serveur est tenue.Ce que cette issue change au principe « rien ne part sans action »
Ce contrôle est la première requête que l'app fait seule au démarrage. Aujourd'hui les mises à jour, les cours et la rétroaction partent sur un clic ; le contrôle du compte Maximus (
check_subscription_status) a une garde de 24 h mais ne part qu'à l'ouverture de Paramètres → Utilisateurs. Les bornes qui encadrent cette exception :Décisions (2026-10-01)
check_subscription_status(auth_commands.rs). É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 resterait en Base).UpsellGate) ajoute une ligne « votre clé a été révoquée » au-dessus de l'invite d'achat. Pas de bandeau global.Conception
État persistant
Un fichier
license.checkdans le dossier de données, en JSON :license.revokedet l'horodatage séparé du corps d'origine. Une autre clé enregistrée ne correspond plus au condensé : l'état est ignoré, donc un contrôle est dû au démarrage suivant.sha2est déjà une dépendance.Résolution de l'édition
entitlements_from_dirrend("free", [])quand l'état ditrevoked: truepour la clé courante.resolve_license_entitlementsreste pure.get_edition,check_entitlement(auto-update) et le gating du front suivent sans autre changement :isEntitledrefuse tout en Gratuite.Contrôle
Une commande, par exemple
check_license_revocation, dont la logique vit dans des fonctions sansAppHandle(dossier temporaire et serveur mockito) :attempted_atdate de plus de 24 h, ou qu'il est dans le futur (horloge reculée).attempted_atest écrit avant l'appel : deux appels concurrents n'en font qu'un, et « une tentative par 24 h » tient quel que soit le résultat.POST /v1/licenses/verifyavec{product, license_key}. Le serveur répond toujours 200.{valid:false, reason:"revoked"}:revoked: true.{valid:true}:revoked: false.not_found,expiredet toute erreur : état inchangé.Aucun chemin serveur ne réactive une licence révoquée aujourd'hui (
revokeLicensepasseis_revokedà vrai et désactive les machines, rien ne fait l'inverse) : la levée survalid:truene sert qu'à une correction manuelle de la base.Autres sources de la révocation
activate_machine,list_activated_machinesetdeactivate_machine: surLicenseError::LicenseRevoked, poser l'état avant de rendre l'erreur.fetch_price_inner(balance_commands.rs) traduit tout 403 enPremiumRequiredsans lireerror.code, alors quelicenseAuth.tsdistinguelicense_revokeddepremium_required. Lireerror.code, ajouterLicenseRevokedàFetchPriceErroret àPriceErrorCode, le relier à la clé existantebalance.priceFetching.errors.licenseRevoked, et poser l'état. Le jeton envoyé appartient à la clé enregistrée :store_license_inle supprime quand la clé change (#347).Front
LicenseContextlit l'état révoqué depuis Rust (par exemple un champrevokeddeLicenseInfo, rendu parread_licenseetstore_license). Le drapeau de sessionLICENSE_REVOKEDetmarkRevokeddisparaissent : après une réponse « révoquée »,useLicenseMachinesrecharge le contexte.VALIDATE_DONEprend l'édition de la clé).license.errors.licenseRevoked, boutons « Entrer une clé » et « Acheter ». La section Machines disparaît avec l'édition.UpsellGate: une ligne (nouvelle cléupsell.revoked, FR et EN) quand la clé enregistrée est révoquée.Tâches
license.check(lecture tolérante, écriture, condensé de la clé) et prise en compte dansentitlements_from_dir.attempted_atécrit avant l'appel, fail-open, issue du contrôle rendue au front.LicenseRevokedpar les trois commandes réseau, et par le 403license_revokeddes cours (FetchPriceError::LicenseRevoked).read_licenseetstore_licenserendent l'état révoqué de la clé ; enregistrement de la commande danslib.rs.LicenseContext(état lu depuis Rust, contrôle au démarrage, rechargement),useLicenseMachines(rechargement au lieu demarkRevoked),LicenseCard,UpsellGate,licenseService.ts,balance.service.ts.logInfo).upsell.revoked; relirelicense.errors.licenseRevokedetbalance.priceFetching.errors.licenseRevoked.docs/architecture.md,docs/guide-utilisateur.mdetdocs.editions.tips(clé révoquée, et ce que l'app envoie et quand),CHANGELOG.mdetCHANGELOG.fr.md.Tests
Les décisions se testent sans
AppHandle: dossier temporaire pour l'état, serveur mockito pourverify, valeurs attendues en littéraux.auto-updaterefusé après révocation (is_entitledsur l'édition résolue).revokedpose l'état ;valid:truele lève ;not_found,expired, erreur réseau, 429, 5xx, corps illisible le laissent tel quel.expect(0)) ; requête due sans état, après 24 h, avec une autre clé, avec unattempted_atdans le futur ;attempted_atécrit même quand l'appel échoue.productetlicense_key.license_revoked; un 403premium_requiredne le pose pas.UpsellGateetLicenseCardnomment la révocation ; le contrôle part une seule fois et recharge le contexte quand l'état change.LicenseContext.test.tsest réécrit : l'état vient de Rust et survit à la session.Tests existants touchés :
entitlements_from_diretverify_*(license_commands.rs),fetch_price_inner(balance_commands.rs),entitlements.rs,LicenseContext.test.ts,useLicenseMachines.test.ts,LicenseCard.render.test.tsx.Critères d'acceptation
auto-updatecompris, et l'état révoqué reste affiché d'une session à l'autre.license_revokeddes cours s'affiche comme une révocation, plus comme « abonnement premium requis ».cargo test,npm testet la CI verts.Limites assumées
not_foundetexpiredn'ont aucun effet.not_foundpeut venir d'une base serveur restaurée ou d'une licence émise en mode test.Dépendances
Complexité
Moyenne, plus large que #350 : un PR, Rust et front, sans migration de base ni changement serveur.
Trouvé par la revue de la PR #364 (#347) et vérifié dans le code de
main: côté cours du Bilan, une licence révoquée s'affiche comme une licence non Premium.fetch_price_inner(src-tauri/src/commands/balance_commands.rs,match status) traduit tout 403 enFetchPriceError::PremiumRequired, sans lireerror.code.licenseAuth.ts(maximus-api) distinguelicense_revokeddepremium_required, etdocs/api-contract-prices.mdprévoiterrors.licenseRevokedpour le premier.balance.priceFetching.errors.licenseRevokedexiste en FR et en EN, mais aucun code ne l'appelle.Conséquence : un acheteur Premium remboursé lit « Cette fonction nécessite un abonnement premium » au lieu d'un message de révocation. Le correctif est à faire là où cette issue reprend l'affichage de la révocation : lire
error.codesur un 403, ajouter une varianteLicenseRevokedàFetchPriceError, et la relier à la clé existante.PR #364 ne touche pas à ce chemin. Elle pose, côté carte Licence, un drapeau de session
revokeddansLicenseContext(markRevoked), alimenté par activate, verify et deactivate. Ce drapeau peut servir de point d'entrée pour un refus de révocation venu des cours.Analyse du 2026-10-01 (
/analyze) : le corps est réécrit, le texte d'origine du 2026-09-30 est conservé plus bas.Ce que l'analyse établit, vérifié sur
main(a9fa34d), sur maximus-api (ac91da4) et dans les textes du site :current_entitlements,entitlements_from_dir),/v1/licenses/verifyne sert qu'à lister les machines, et les conditions de vente disent « le remboursement entraîne la révocation de la licence ».LicenseContext, pas dansuseLicenseMachines. La lecture de l'état va dansentitlements_from_dir:resolve_license_entitlementsest pure et ne lit aucun fichier.license_revokeddes cours (commentaire précédent) entre dans le périmètre, comme toute réponse « révoquée » apprise par une autre commande.valid:truene sert qu'à une correction manuelle de la base.Décisions de Max (2026-10-01) : contrôle au démarrage, au plus une tentative par 24 h ; la révocation est nommée sur la carte Licence et sur chaque écran verrouillé, sans bandeau global.
Corps d'origine (2026-09-30)
Contexte
Constat de Max le 2026-09-30, pendant le go-live Stripe (maximus/la-compagnie-maximus#141) : achat reel d'une licence Base, remboursement complet, licence revoquee cote serveur (visible « revoquee » dans /compte), mais la cle reste pleinement fonctionnelle dans l'app.
Cause (code actuel,
src-tauri/src/commands/license_commands.rs) :exp(current_entitlements->resolve_license_entitlements). Depuis maximus-api#63 les cles durent 36525 jours :expne jouera jamais le role d'anti-revocation prevu a l'origine (decision #15 de spec-decisions-paiements)./v1/licenses/verifyn'est appele que pour lister les machines (verify_inner,useLicenseMachines). Un{valid:false, reason:"revoked"}affiche un message mais ne touche ni l'edition ni la cle./v1/pricesreste la seule barriere dure.Consequence : apres un remboursement, l'acheteur garde l'edition Base hors ligne indefiniment, y compris la feature
auto-update(FEATURE_TIERSdansentitlements.rs, gatee sur l'edition locale, #301). Les CGV du site (/conditions-de-vente, politique de remboursement) disent « le remboursement entraine la revocation de la licence » : seule la moitie serveur est tenue.Decision proposee (option A, choisie par Max le 2026-09-30)
Verification de revocation en ligne, opportuniste, qui retrograde l'edition quand le serveur repond
revoked; aucun changement hors ligne.POST /v1/licenses/verify({product, license_key}), qui repond toujours 200 :{valid:true,...}ou{valid:false, reason: "revoked" | "not_found" | "expired"}.reason == "revoked": persister un marqueur de revocation lie a cette cle (fichier dansapp_data_dir, par ex.license.revokedcontenant un condense SHA-256 de la cle, jamais la cle en clair).resolve_license_entitlementsretourne("free", [])tant que le marqueur correspond a la cle courante. Coller une autre cle (nouvel achat) ne correspond plus au marqueur -> edition normale.valid == trueet qu'un marqueur existe pour cette cle (licence reactivee cote serveur) : supprimer le marqueur.not_found/expired: hors perimetre, aucune action (a trancher separement ;not_foundpeut venir d'une base serveur restauree).license.errors.licenseRevoked, #270) avec l'invite d'achat (#346). Cela remplace D13 (« rien n'est persiste ») pour le seul cas revoque.Compatibilite :
/v1/licenses/*partage une limite de 5 req/min/IP (maximus-apisrc/routes/licenses.ts). Un appel par jour et par poste est negligeable ; un 429 = aucune action./activateet/verify; ajouter une mention « verification periodique de la licence » dans la politique de confidentialite du site si elle n'y est pas (maximus/la-compagnie-maximus).Taches
license.revoked(condense de la cle), lu parresolve_license_entitlements; pose surrevoked, retire survalid:true; lecture en erreur = absent.useLicenseMachinesreprend l'etat depuis Rust.valid:trueefface le marqueur ; erreur reseau / 429 / 5xx -> aucune ecriture.auto-updaterefuse apres revocation (viacheck_entitlement).docs/guide-utilisateur.md§12 (licence revoquee apres remboursement).Criteres d'acceptation
auto-updatecompris, et l'etat revoque reste affiche d'une session a l'autre.Dependances
revokeddeuseLicenseMachines), dont cette issue amende D13.Questions ouvertes (a trancher au /analyze)
feat(licence): révocation vérifiée en ligne — une clé remboursée retombe en Gratuite (option A)to feat(licence): révocation vérifiée en ligne — une clé remboursée retombe en GratuiteDécision du 2026-10-02, prise pendant
/fix-issue: la route des cours nomme le refuslicense_revokedsans l'enregistrer comme une révocation de la clé. Le corps demandait le contraire (« Lireerror.code[…] et poser l'état »).Ce que le plan checker a trouvé, et que le code confirme :
/v1/pricesjuge la licence du jeton d'activation. Le client ne sait pas relier ce jeton à la clé enregistrée : lesubdu jeton est l'identifiant de la licence, la clé porte le courriel.store_license_inle supprime quand la clé change (#347) » n'est vraie que pour un dossier écrit depuis #347. Dans la v0.15.0,store_licenseécrit la clé sans toucher au jeton (git show v0.15.0:src-tauri/src/commands/license_commands.rs). Un dossier de données peut donc contenir une clé valide à côté du jeton d'une licence révoquée, par exemple après un rachat suivant un remboursement.Trois variantes ont été examinées : enregistrer quand même, confirmer par un appel à
verifyavant d'enregistrer (une requête de plus, hors des bornes de D1), ou nommer sans enregistrer. Max a retenu la troisième. Conséquences :license_revokeddes cours s'affiche comme une révocation » est tenu, avec un message qui nomme l'activation plutôt que la clé ;license_revokeddes cours » et le test correspondant sont abandonnés ;L'amendement #365 de l'ADR 0017 consigne la décision.
Mergée le 2026-10-03 par la PR #368 (
mainb107cf4, fast-forward, CI verte sur la tête : runs 449 et 450). L'issue reste ouverte, enstatus:blocked, jusqu'à la vérification manuelle : la PR portaitRefs, comme #350.Ce qui reste à faire, par Max, avant le tag (détail dans la section « Vérification manuelle due » de la PR) :
Pour rejouer le contrôle avant 24 h, supprimer
license.checkdans le dossier de données. Une fois ces points vérifiés, fermer l'issue à la main.Décisions prises à la revue (2026-10-03) : une clé révoquée reste contrôlée une fois par 24 h tant qu'elle est enregistrée, et c'est déclaré dans le guide ; la carte Mises à jour nomme la révocation en Gratuite. Suites : #367 (jeton périmé laissé par la v0.15.0), maximus/la-compagnie-maximus#184 (politique de confidentialité).
Vérification manuelle faite par Max le 2026-10-03, sur la v0.16.0 publiée (Linux) : la clé réellement révoquée du contrôle d'achat du 2026-09-30 fait passer l'app en Gratuite au démarrage, et le Journal porte la ligne du contrôle. Le dernier critère d'acceptation est tenu ; l'issue est fermée.
Observé en route, suivi ailleurs : saisie en cours de session, cette clé gardait l'édition Base jusqu'au clic sur « Activer », dont le refus
license_revokedl'a fait passer en Gratuite. C'est le comportement livré (écart 2 de la PR #368 : contrôle au démarrage seulement) ; le contrôle dès la saisie est proposé en #373.À rouvrir si une app redémarrée avec une clé révoquée reste dans l'édition de la clé alors que le serveur est joignable : le contrôle du démarrage serait alors en défaut.