feat(license): remove the stored license key from the License card (#370) #387
No reviewers
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#387
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-370-remove-license-key"
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?
Fixes #370
Generated autonomously by /autopilot run of 2026-10-04
PR empilée sur #386 (
issue-373-revocation-check-on-entry, #373) : la base de cette PR est cette branche, et le diff ne montre que le travail de #370. À merger après #386, dans l'ordre de la pile #373 → #370 → #367.Ce qui change
Pour l'utilisateur
info !== null, pashasLicense, qui le cacherait justement pour la clé révoquée).license.checkgardé, décision 1).Technique
license_commands.rs) :remove_license(release_slot)→remove_license_impl→remove_license_in(dir, api_base, release_slot, ids, decoding_key, now), sansAppHandle, enregistrée danslib.rs;license.key, puisactivation.token; la première suppression qui échoue rendstorage; jamais « jeton parti, clé restée » ;license.checkgardé ; idempotente sans clé (rien n'est écrit, le dossier n'est pas créé) ;release_this_machine_slot: rien n'est envoyé sans clé, pour une clé connue comme révoquée, ni sans jeton qui active ce poste (this_machine_token_id=stored_token_machine_idfiltré parMachineIds::names_this_machine, la règle d'activation_status_in). La désactivation porte le claim du jeton (brut pour une activation d'avant #350) et la clé enregistrée. Table d'erreurs :no_active_activation(déjà renduOkpardeactivate_idempotent),license_not_found,license_revoked(avecnote_revocationsous la clé envoyée) → on continue ; toute autre erreur est rendue, rien n'est supprimé ;pub(crate) fn remove_activation_token_in(dir) -> Result<(), LicenseError>(tolèreNotFound), adopté parstore_license_in(propageStoragecomme avant) etdrop_token_of_deactivated_machine_in(ignore l'erreur comme avant). #367 le réutilisera.removeLicense(releaseSlot)danslicenseService.ts(à côté destoreLicense), résultat typéLicenseResult<void>;performRemoveLicense(deps, state)dansuseLicenseMachines.ts, à dépendances injectées (confirm,t,removeLicense,refresh,logInfo,logWarn,dispatch), avec ses fonctions puresremoveLicenseConfirmKey,releasesSlot,selectRemoveLicenseState,RELEASE_RETRY_CODES,REMOVE_LOG;removing/removeErrordans le reducer des machines (REMOVE_START,REMOVE_ERROR,REMOVE_END),selectCanRemoveKey, et!removingdansselectCanActivate/selectCanDeactivate;window.confirmettàremoveKey(motifwindow.confirm(t(...))du dépôt).license.removeKey.*(bouton, libellé en cours, trois confirmations, seconde confirmation avec{{reason}}, erreur nommée avec{{reason}}).docs.editions.tipsFR/EN,docs/architecture.md(commande, compteurs 37 → 38 et 10 → 11,licenseService.ts,useLicenseMachines, paragraphes « Contrat d'erreur » et « Révocation »), ADR 0017 (amendement #365 : « aucune interface ne retire une clé enregistrée » remplacé par un paragraphe sur le retrait ; liens),CHANGELOG.md/CHANGELOG.fr.mdsous Added / Ajouté.Écarts au corps de l'issue, et pourquoi
deactivate_in(cité en exemple dans « Point de conception ») :deactivate_insupprime le jeton dès la place libérée (drop_token_of_deactivated_machine_in) ; si la suppression delicense.keyéchouait ensuite, on aurait l'état interdit « jeton parti, clé restée ». Le retrait appelledeactivate_idempotentpuisnote_revocation(même enregistrement de la révocation) et ne supprime le jeton qu'après la clé. Un test le garde (mutation R17).invalid_input, etlicense_expired/machine_limit_reached, que la route ne rend pas d'aprèsdeactivateLicensedans maximus-api) : rendus tels quels, rien n'est supprimé, et le front les nomme sans seconde confirmation, puisque la surface de test la réserve aux quatre codes. Choix conservateur : ne rien supprimer que l'utilisateur n'a pas accepté.release_slot:storage, rien supprimé (la libération demandée ne peut pas être faite). Sansrelease_slot, la clé n'est pas lue et part.rate_limit_exceededde la table est le coderate_limitdu client (LicenseError::RateLimit).tinjecté dansperformRemoveLicenseen plus des dépendances listées : la seconde confirmation nomme la raison (license.errors.*déjà traduit), et l'issue fait passerwindow.confirmett(...)par la carte.performRemoveLicensedansuseLicenseMachines.ts(l'issue autorise ce fichier) :is_activated, qui déciderelease_slot, y vit déjà, ainsi que le motif « un appel serveur en vol verrouille la carte » ; et les tests de rendu simulent ce hook, donc l'état « en cours » et l'erreur nommée sont épinglés.license.removeKey.*, paslicense.removeLicense:licenseService.test.tsgarde depuis #347 l'absence delicense.removeLicense, laissé tel quel.logWarnpour un retrait échoué ; verrouillage d'« Activer » et « Désactiver » pendant un retrait ; l'astuce « verrouillage jamais destructif » mentionne une clé retirée.Détail des décisions :
reports/run-2026-10-04-suites-licence/decisions-370.md(local, ignoré par git).Tests
license_commands.rs) : helper (absent, présent, dossier à la place) ; retrait (clé et jeton supprimés,license.checket un autre fichier intacts, aucune requête) ; après le retrait,entitlements_from_dir=("free", [])etcheck_revocation_in=no_licenseavec un seul mock attrape-toutexpect(0); clé révoquée retirée puis ressaisie → Gratuite ; retrait sans clé (succès, rien d'écrit, dossier absent non créé, avec et sansrelease_slot) ; jeton orphelin supprimé ; clé non supprimable →storage, jeton intact ; jeton non supprimable →storage, clé partie ; libération : corps exact{product, license_key, machine_id}lu parmatch_requestavec le claim du jeton (dérivé et brut), clé et jeton encore sur disque pendant la requête ; clé non supprimable après une libération réussie → jeton intact ; place déjà libre (404no_active_activation, 404license_not_found, 403license_revokedavec état révoqué enregistré) ; libération échouée (502, 429, 200 illisible, 400, hors ligne) → rien ne bouge ; table « rien envoyé » (libération non demandée, pas de jeton, jeton expiré, d'une autre machine, signé par une autre clé, identifiant du système illisible, clé connue révoquée) ; clé illisible.selectRemoveLicenseState; table état → confirmation / libération ;performRemoveLicense: confirmation refusée (rien n'est appelé, état intact), retrait d'une clé valide, d'une machine activée, d'une clé révoquée (ordre confirmation →REMOVE_START→ appel →refresh→REMOVE_END), les quatre codes de seconde confirmation (acceptée puis refusée), sept autres codes nommés sans seconde confirmation, pas de seconde confirmation sans libération demandée, échec du second appel ; câblage du hook (révocation etrefreshdu contexte,invoke("remove_license", { releaseSlot })) ; service ; couverture i18n FR/EN et{{reason}}; rendu de la carte (bouton pour une clé valide et pour une clé révoquée en Gratuite, absent sans clé ni pour un Premium de compte, désactivé pendant une saisie, une activation, une désactivation, état « en cours » qui verrouille saisie / Activer / Désactiver, erreur nommée).Mutations (une à la fois, rouges, restauration depuis l'index)
license.checksupprimé aussirelease_slotignorénetwork→ on continueserver/rate_limit/invalid_response/invalid_input→ on continuelicense_not_founddevient une erreurlicense_revokeddevient une erreurnote_revocationrelease_slot→storagerelease_slot→ on continueNotFound→storageNotFound→storagecreate_dir_allau début du retraitdeactivate_inrefreshaprès le retraitreleaseSlotreleasesSlotignore la révocationlogWarnsur libération échouéeREMOVE_ENDaprès « garder la clé »hasLicenseselectCanActivatesans!removingselectCanDeactivatesans!removingselectCanRemoveKeyignore une activationisActivatedtoujours fauxrelease_slotau lieu dereleaseSlotinvalid_responseretiré des codes de seconde confirmationREMOVE_STARTavant la première confirmationValidation
npm run build(tsc + vite) : vertnpm test: 1487 (1446 sur la base, +41)cargo test: 209 (195 sur la base, +14)cargo check: sans avertissement ;cargo metadata --locked: OK,Cargo.lockinchangé, aucune dépendance ajoutéePour reviewer
release_this_machine_slotetremove_license_in(license_commands.rs), puisperformRemoveLicense(useLicenseMachines.ts).remove_license/remove_license_impl(dossier, clé embarquée,local_machine_ids().ok(),unix_now) et son enregistrement danslib.rs, faute d'AppHandle; leonClickde la carte (removeKey({ confirm: (message) => window.confirm(message), t })), faute de clic sousrenderToStaticMarkup; le passage deisActivateddu hook au retrait n'est gardé que par le sélecteur (l'état d'activation n'est jamais chargé sousrenderToStaticMarkup) ; la réponse réelle de/v1/licenses/deactivate.release_slotsuit l'état d'activation que la carte a chargé ; tant qu'il n'est pas chargé (quelques millisecondes après l'ouverture de la carte, ouget_activation_statusen échec), le retrait ne libère rien, et sa confirmation (texte « valide ») n'annonce aucune libération.window.confirmaffiche bien les sauts de ligne des textes (WebKitGTK et WebView2).Après merge (Max) — texte pour maximus/la-compagnie-maximus#184
🤖 Generated with Claude Code
Auto-revue autopilot — verdict : APPROVE
Auto-revue du worker sur le diff de la PR contre sa base
issue-373-revocation-check-on-entry(15 fichiers). Ce n'est pas une revue indépendante :status:approvedn'est pas posé, l'issue est enstatus:review.Résumé. Le retrait suit les décisions de l'issue :
license.keyd'abord, puis le jeton,license.checkgardé, idempotent sans clé ; désactivation préalable seulement pour un jeton qui active ce poste et une clé non révoquée, sous l'identifiant du jeton ; table d'erreurs appliquée ligne à ligne (place libre → on continue ;network/server/rate_limit/invalid_response→ rien supprimé, seconde confirmation ; tout autre code → rendu et nommé, sans seconde confirmation) ; confirmations par état ; bouton surinfo !== null; lignes au Journal. 20 mutations Rust et 24 front rouges, une à la fois.Sécurité
SR-*, JWT, noms).product,license_keyet le claim du jeton, rien d'autre (corps exact vérifié parmatch_request). Pour une activation d'avant #350, ce claim est l'identifiant brut que le serveur détient déjà pour cette ligne : même règle que la désactivation depuis la liste (ADR 0020).Correction
Qualité
console.*; tout le texte visible passe parlicense.removeKey.*(FR et EN) ; le garde de #347 surlicense.removeLicenseest intact.Cargo.lockinchangé.Suggestions non bloquantes
src/hooks/useLicenseMachines.ts:368— la ligne « after this machine's slot was freed » dit ce que le front a demandé. Rust peut sauter une libération qu'il juge inutile (jeton changé, ou clé marquée révoquée par un contrôle en arrière-plan entre le rendu et le clic) : cas rare, et la place est alors libre de toute façon. Un retour typé deremove_license(place libérée / déjà libre / pas demandée) rendrait la ligne exacte.src-tauri/src/commands/license_commands.rs:1218—remove_license_implexige la clé publique embarquée même sansrelease_slot. Elle est épinglée parembedded_public_key_pem_parses, donc sans conséquence pratique.src/hooks/useLicenseMachines.ts:287—release_slotsuit l'état d'activation que la carte a chargé ; avant ce chargement (quelques millisecondes) ou siget_activation_statuséchoue, le retrait ne libère rien, et sa confirmation n'annonce aucune libération. Conforme au « Point de conception » de l'issue.Non gardé par les tests (à vérifier à la main, voir la PR)
src-tauri/src/commands/license_commands.rs:1212-1235(commande et_impl) et l'enregistrement danslib.rs, faute d'AppHandle;src/components/settings/LicenseCard.tsx:161, leonClickqui passewindow.confirmett, faute de clic sousrenderToStaticMarkup.Revue adversariale : PR #387 (#370), tête
e2f0fb8Verdict : APPROVE
Résumé : le retrait suit la table d'erreurs de l'issue et ne laisse jamais le jeton parti à côté d'une clé restée. Les deux écarts déclarés (libération hors de
deactivate_in, codes hors table nommés sans seconde confirmation) sont justifiés et gardés par des tests. Trois épingles manquent sur du code correct, aucune ne bloque.Ce qui a tourné
e2f0fb8(git archive,npm ci, cible Cargo séparée) :tscsans erreur, vitest 1487/1487, cargo test 209/209, compilation sans avertissement.tsc && vite build, 1487 tests) et run 482 (garde des suppressions2 checks, exit 0, 209 tests), tous deux sure2f0fb8.deactivate_inner, sans l'idempotencea_slot_already_free_lets_the_removal_go_on)this_machine_token_idn'accepte que le dérivé (jeton d'avant #350 ignoré)the_release_carries_the_token_s_identifier_before_anything_is_removed)remove_license_ina_token_that_cannot_be_deleted_is_storage_once_the_key_is_gone)deactivate_in)store_license_inignore un jeton précédent non supprimable (?→let _ =)trate_limitseule)ttd312ea6) :deactivateLicensene rend quelicense_not_found(404),no_active_activation(404) etinvalid_input(400, schéma), plus le 429 du limiteur. Il ne rend jamaislicense_revoked:revokeLicensedésactive toutes les activations. Le saut de la libération pour une clé révoquée est donc juste, et la lignelicense_revokedde la table est défensive. L'impasse de l'écart 2 (erreur nommée, sans « retirer quand même ») ne s'atteint que parinvalid_input. Or activation et désactivation partagentmachineIdSchema, et la clé enregistrée a été validée à l'enregistrement : rien de ce que le client envoie ne la déclenche aujourd'hui.Suggestions (non bloquantes)
S1 : le faux
tne distingue pas un texte traduit d'une clé brute.echo(useLicenseMachines.test.ts) et le mock deLicenseCard.render.test.tsxrendent la clé telle quelle quand l'appel n'a pas d'options, donc un appel àtoublié ne se voit pas. Mesuré :deps.confirm(removeLicenseConfirmKey(state))sanstlaisse 1487/1487 verts, alors que l'utilisateur liraitlicense.removeKey.confirmValiddans la boîte de dialogue ; même chose pourreason: machines.removeError.i18nKeysur la carte. La raison de la seconde confirmation n'est gardée que par la lignerate_limit, la seule qui porte des options. Pour les tests de cette PR, unechoqui marque sa sortie (par exemplet(<clé>)) suffit, attentes ajustées. Le mock de la carte suit une convention plus large du dépôt : une issue de suivi plutôt qu'un tour de plus.S2 : aucun test n'épingle la propagation de
storageparstore_license_in.remove_activation_token_in(dir)?→let _ = remove_activation_token_in(dir);laisse 209/209 verts. Cette mutation remet l'état corrigé à la revue de #347 : une nouvelle clé à côté du jeton de la précédente (carte « Activée », cours refusés au nom de l'ancienne licence), la famille de #367. Le trou précède la PR (le code en ligne avait la même exposition), mais la PR touche cette ligne et #367 réutilise le helper ; #388 n'ajoute pas ce test non plus. Un seul test suffit : une autre clé enregistrée alors qu'un dossier occupe la place deactivation.tokenrendErr(Storage), et l'ancienne clé reste. Sa place naturelle est #388.S3 : pas de
refresh()après un échec. Quandlicense.keyest parti et que seule la suppression du jeton échoue, Rust rendstorage. La carte garde alors la clé et l'édition payante jusqu'au redémarrage, sous « La clé n'a pas été retirée », qui est faux dans ce cas. Un second clic termine le retrait (clé absente, le jeton orphelin part), et le cas demande un jeton non supprimable à côté d'une clé qui l'est. Recharger le contexte aussi après un échec (lectures locales seulement) alignerait la carte sur le disque.Hors de portée des tests
La liste « Ce que les tests ne gardent pas » de la PR est juste : la commande
remove_license/remove_license_implet son enregistrement, leonClickde la carte, le passage deisActivateddu hook au retrait, la réponse réelle de/v1/licenses/deactivateet les sauts de ligne dewindow.confirmsous WebKitGTK et WebView2 restent à la vérification manuelle décrite dans la PR.Divers
feat(license): …),Resolves #370dans le message etFixes #370dans le corps : la timeline de #370 porte bien unpull_refclosesdepuis #387..only/.skip, aucun artefact de débogage, aucune adresse ni clé réelle dans les lignes ajoutées ; aucune migration touchée ; aucune permission Tauri à déclarer (build.rssans manifeste d'app).docs/architecture.md: la sectionlicense_commands.rs(11) correspond au code. Le total « 38 » applique le +1 de la PR à un compte qui dérivait déjà (44 commandes enregistrées danslib.rs), hors du périmètre de la PR./tmp/pr387-*supprimés en fin de revue.