feat(license): remove the stored license key from the License card (#370) #387

Merged
maximus merged 1 commit from issue-370-remove-license-key into main 2026-10-05 23:22:52 +00:00
Owner

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

  • « Retirer la clé » dans la carte Licence (Paramètres → Utilisateurs), visible dès qu'une clé est enregistrée, valide ou révoquée (info !== null, pas hasLicense, qui le cacherait justement pour la clé révoquée).
  • Confirmation selon l'état (D3) :
    • clé valide : les modules de l'édition se verrouillent, l'app repasse en Gratuite, les données restent ;
    • machine activée (clé valide) : même texte, plus « la place de cette machine est d'abord libérée sur le serveur de licences » ;
    • clé révoquée : l'app cesse de l'envoyer au serveur de licences, les données restent.
  • Désactivation préalable (D2) : sur une machine activée avec une clé valide, la place est libérée avant le retrait. Si le serveur ne répond pas (hors ligne, 5xx, 429, réponse illisible), rien n'est supprimé et une seconde confirmation nomme la raison puis demande s'il faut retirer la clé quand même ; la place reste alors occupée et se libère depuis un autre ordinateur qui a la clé.
  • Toute autre erreur est nommée sur la carte (« La clé n'a pas été retirée. … »).
  • Une ligne au Journal par retrait, par libération échouée et par retrait échoué.
  • Pendant un retrait, « Entrer une clé », « Activer » et « Désactiver » attendent (chacun lit ou écrit la clé ou le jeton supprimés).
  • Après le retrait : édition Gratuite, carte sans clé, et plus aucune requête de contrôle au démarrage. Une clé révoquée retirée puis ressaisie reste en Gratuite (license.check gardé, décision 1).

Technique

  • Rust (license_commands.rs) :
    • commande remove_license(release_slot) → remove_license_impl → remove_license_in(dir, api_base, release_slot, ids, decoding_key, now), sans AppHandle, enregistrée dans lib.rs ;
    • ordre : libération éventuelle, puis license.key, puis activation.token ; la première suppression qui échoue rend storage ; jamais « jeton parti, clé restée » ; license.check gardé ; 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_id filtré par MachineIds::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à rendu Ok par deactivate_idempotent), license_not_found, license_revoked (avec note_revocation sous la clé envoyée) → on continue ; toute autre erreur est rendue, rien n'est supprimé ;
    • helper partagé pub(crate) fn remove_activation_token_in(dir) -> Result<(), LicenseError> (tolère NotFound), adopté par store_license_in (propage Storage comme avant) et drop_token_of_deactivated_machine_in (ignore l'erreur comme avant). #367 le réutilisera.
  • Front :
    • removeLicense(releaseSlot) dans licenseService.ts (à côté de storeLicense), résultat typé LicenseResult<void> ;
    • performRemoveLicense(deps, state) dans useLicenseMachines.ts, à dépendances injectées (confirm, t, removeLicense, refresh, logInfo, logWarn, dispatch), avec ses fonctions pures removeLicenseConfirmKey, releasesSlot, selectRemoveLicenseState, RELEASE_RETRY_CODES, REMOVE_LOG ;
    • removing / removeError dans le reducer des machines (REMOVE_START, REMOVE_ERROR, REMOVE_END), selectCanRemoveKey, et !removing dans selectCanActivate / selectCanDeactivate ;
    • la carte passe window.confirm et t à removeKey (motif window.confirm(t(...)) du dépôt).
  • i18n FR/EN sous license.removeKey.* (bouton, libellé en cours, trois confirmations, seconde confirmation avec {{reason}}, erreur nommée avec {{reason}}).
  • Docs : guide (puce « Retirer la clé », fin de « Vérification de la licence », astuce « jamais destructif »), docs.editions.tips FR/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.md sous Added / Ajouté.

Écarts au corps de l'issue, et pourquoi

  1. La libération ne passe pas par deactivate_in (cité en exemple dans « Point de conception ») : deactivate_in supprime le jeton dès la place libérée (drop_token_of_deactivated_machine_in) ; si la suppression de license.key échouait ensuite, on aurait l'état interdit « jeton parti, clé restée ». Le retrait appelle deactivate_idempotent puis note_revocation (même enregistrement de la révocation) et ne supprime le jeton qu'après la clé. Un test le garde (mutation R17).
  2. Codes hors table (invalid_input, et license_expired / machine_limit_reached, que la route ne rend pas d'après deactivateLicense dans 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é.
  3. Clé présente mais illisible avec release_slot : storage, rien supprimé (la libération demandée ne peut pas être faite). Sans release_slot, la clé n'est pas lue et part.
  4. rate_limit_exceeded de la table est le code rate_limit du client (LicenseError::RateLimit).
  5. t injecté dans performRemoveLicense en plus des dépendances listées : la seconde confirmation nomme la raison (license.errors.* déjà traduit), et l'issue fait passer window.confirm et t(...) par la carte.
  6. performRemoveLicense dans useLicenseMachines.ts (l'issue autorise ce fichier) : is_activated, qui décide release_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.
  7. Clés i18n license.removeKey.*, pas license.removeLicense : licenseService.test.ts garde depuis #347 l'absence de license.removeLicense, laissé tel quel.
  8. Ajouts : un jeton orphelin (sans clé) est aussi supprimé par le retrait (post-condition unique, et la reprise d'un retrait dont le jeton n'a pas pu partir) ; une ligne logWarn pour 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

  • Rust (+14, dans license_commands.rs) : helper (absent, présent, dossier à la place) ; retrait (clé et jeton supprimés, license.check et un autre fichier intacts, aucune requête) ; après le retrait, entitlements_from_dir = ("free", []) et check_revocation_in = no_license avec un seul mock attrape-tout expect(0) ; clé révoquée retirée puis ressaisie → Gratuite ; retrait sans clé (succès, rien d'écrit, dossier absent non créé, avec et sans release_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 par match_request avec 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 (404 no_active_activation, 404 license_not_found, 403 license_revoked avec é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.
  • Front (+41) : reducer et sélecteurs du retrait ; 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 et refresh du 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)

Rust Rouge
R1 jeton supprimé avant la clé 3 tests
R2 jeton jamais supprimé 7
R3 license.check supprimé aussi 4
R4 release_slot ignoré 5
R5 clé connue révoquée quand même libérée 2
R6 jeton d'une autre machine libéré 1 (ligne « token of another machine »)
R6c identifiant du système illisible ignoré 1 (ligne « unreadable OS id »)
R7 network → on continue 1
R7b server / rate_limit / invalid_response / invalid_input → on continue 1
R8 license_not_found devient une erreur 1
R8b license_revoked devient une erreur 1
R9 pas de note_revocation 1
R10 clé absente + release_slot → storage 2
R11 clé illisible + release_slot → on continue 1
R12 suppression de la clé : NotFound → storage 2
R13 helper : NotFound → storage 7
R14 libération après les suppressions 5
R15 dérivé envoyé au lieu du claim du jeton 1 (jeton brut)
R16 create_dir_all au début du retrait 1
R17 libération par deactivate_in 1
Front Rouge
F1 confirmation refusée ignorée 2
F2 pas de refresh après le retrait 7
F3 seconde confirmation pour tout code 7
F4 seconde confirmation sans libération demandée 1
F5 second appel avec releaseSlot 5
F6 textes « activée » / « valide » échangés 18
F7 releasesSlot ignore la révocation 1
F8 pas de logWarn sur libération échouée 4
F9 ligne « place occupée » journalisée « place libérée » 4
F10 échec non nommé 9
F11 pas de REMOVE_END après « garder la clé » 4
F12 bouton sur hasLicense 2
F13 saisie d'une clé non verrouillée pendant un retrait 1
F14 retrait non verrouillé pendant une saisie 1
F15 échec du retrait non affiché 1
F16 selectCanActivate sans !removing 2
F17 selectCanDeactivate sans !removing 2
F18 selectCanRemoveKey ignore une activation 2
F19 isActivated toujours faux 1
F20 le hook ne passe pas la révocation du contexte 1
F21 argument release_slot au lieu de releaseSlot 2
F22 invalid_response retiré des codes de seconde confirmation 2
F23 raison absente de la seconde confirmation 4
F24 REMOVE_START avant la première confirmation 16

Validation

  • npm run build (tsc + vite) : vert
  • npm test : 1487 (1446 sur la base, +41)
  • cargo test : 209 (195 sur la base, +14)
  • cargo check : sans avertissement ; cargo metadata --locked : OK, Cargo.lock inchangé, aucune dépendance ajoutée
  • Aucune migration SQL touchée

Pour reviewer

  • Où regarder d'abord : release_this_machine_slot et remove_license_in (license_commands.rs), puis performRemoveLicense (useLicenseMachines.ts).
  • Ce que les tests ne gardent pas : la commande remove_license / remove_license_impl (dossier, clé embarquée, local_machine_ids().ok(), unix_now) et son enregistrement dans lib.rs, faute d'AppHandle ; le onClick de la carte (removeKey({ confirm: (message) => window.confirm(message), t })), faute de clic sous renderToStaticMarkup ; le passage de isActivated du hook au retrait n'est gardé que par le sélecteur (l'état d'activation n'est jamais chargé sous renderToStaticMarkup) ; la réponse réelle de /v1/licenses/deactivate.
  • Limite connue : release_slot suit 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, ou get_activation_status en échec), le retrait ne libère rien, et sa confirmation (texte « valide ») n'annonce aucune libération.
  • À vérifier à la main (Linux, puis Windows) :
    1. clé valide, machine activée : « Retirer la clé » → texte « activée » → OK ; la machine disparaît de la liste vue depuis un autre poste ; le Journal dit « …after this machine's slot was freed… » ; la carte repasse en Gratuite, sans clé ;
    2. même cas hors ligne : la seconde confirmation nomme « Impossible de joindre le serveur de licences… » ; Annuler ne change rien ; OK retire la clé et le Journal dit que la place reste occupée ;
    3. clé révoquée (celle du contrôle d'achat du 2026-09-30) : texte « révoquée », aucune requête de désactivation ; au redémarrage, le Journal dit « License check: no valid key stored, nothing sent » ;
    4. ressaisir cette clé révoquée : elle reste en Gratuite ;
    5. window.confirm affiche bien les sauts de ligne des textes (WebKitGTK et WebView2).

Après merge (Max) — texte pour maximus/la-compagnie-maximus#184

Complément de simpl-resultat#370 (prochaine version de l'application) : la carte Licence (Paramètres → Utilisateurs → Licence) offre maintenant « Retirer la clé ». Le retrait de la clé arrête le contrôle de révocation : aucune requête de vérification ne part tant qu'aucune clé n'est enregistrée sur l'ordinateur. Si la machine est activée, le retrait libère d'abord sa place auprès du serveur de licences par une requête de désactivation, qui porte le produit, la clé et l'identifiant de machine de l'activation, les mêmes données que la désactivation depuis la liste des machines. La politique peut donc dire que le contrôle s'arrête au retrait de la clé, et non plus seulement à l'enregistrement d'une autre clé.

🤖 Generated with Claude Code

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 - **« Retirer la clé »** dans la carte Licence (Paramètres → Utilisateurs), visible dès qu'une clé est enregistrée, **valide ou révoquée** (`info !== null`, pas `hasLicense`, qui le cacherait justement pour la clé révoquée). - **Confirmation selon l'état** (D3) : - clé valide : les modules de l'édition se verrouillent, l'app repasse en Gratuite, les données restent ; - machine activée (clé valide) : même texte, plus « la place de cette machine est d'abord libérée sur le serveur de licences » ; - clé révoquée : l'app cesse de l'envoyer au serveur de licences, les données restent. - **Désactivation préalable** (D2) : sur une machine activée avec une clé valide, la place est libérée avant le retrait. Si le serveur ne répond pas (hors ligne, 5xx, 429, réponse illisible), **rien n'est supprimé** et une seconde confirmation **nomme la raison** puis demande s'il faut retirer la clé quand même ; la place reste alors occupée et se libère depuis un autre ordinateur qui a la clé. - Toute autre erreur est nommée sur la carte (« La clé n'a pas été retirée. … »). - Une ligne au Journal par retrait, par libération échouée et par retrait échoué. - Pendant un retrait, « Entrer une clé », « Activer » et « Désactiver » attendent (chacun lit ou écrit la clé ou le jeton supprimés). - Après le retrait : édition Gratuite, carte sans clé, et **plus aucune requête de contrôle** au démarrage. Une clé révoquée retirée puis ressaisie **reste en Gratuite** (`license.check` gardé, décision 1). ### Technique - **Rust** (`license_commands.rs`) : - commande `remove_license(release_slot)` → `remove_license_impl` → `remove_license_in(dir, api_base, release_slot, ids, decoding_key, now)`, sans `AppHandle`, enregistrée dans `lib.rs` ; - ordre : libération éventuelle, puis `license.key`, puis `activation.token` ; la première suppression qui échoue rend `storage` ; jamais « jeton parti, clé restée » ; `license.check` gardé ; 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_id` filtré par `MachineIds::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à rendu `Ok` par `deactivate_idempotent`), `license_not_found`, `license_revoked` (avec `note_revocation` sous la clé envoyée) → on continue ; toute autre erreur est rendue, rien n'est supprimé ; - helper partagé `pub(crate) fn remove_activation_token_in(dir) -> Result<(), LicenseError>` (tolère `NotFound`), adopté par `store_license_in` (propage `Storage` comme avant) et `drop_token_of_deactivated_machine_in` (ignore l'erreur comme avant). #367 le réutilisera. - **Front** : - `removeLicense(releaseSlot)` dans `licenseService.ts` (à côté de `storeLicense`), résultat typé `LicenseResult<void>` ; - `performRemoveLicense(deps, state)` dans `useLicenseMachines.ts`, à dépendances injectées (`confirm`, `t`, `removeLicense`, `refresh`, `logInfo`, `logWarn`, `dispatch`), avec ses fonctions pures `removeLicenseConfirmKey`, `releasesSlot`, `selectRemoveLicenseState`, `RELEASE_RETRY_CODES`, `REMOVE_LOG` ; - `removing` / `removeError` dans le reducer des machines (`REMOVE_START`, `REMOVE_ERROR`, `REMOVE_END`), `selectCanRemoveKey`, et `!removing` dans `selectCanActivate` / `selectCanDeactivate` ; - la carte passe `window.confirm` et `t` à `removeKey` (motif `window.confirm(t(...))` du dépôt). - **i18n** FR/EN sous `license.removeKey.*` (bouton, libellé en cours, trois confirmations, seconde confirmation avec `{{reason}}`, erreur nommée avec `{{reason}}`). - **Docs** : guide (puce « Retirer la clé », fin de « Vérification de la licence », astuce « jamais destructif »), `docs.editions.tips` FR/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.md` sous Added / Ajouté. ## Écarts au corps de l'issue, et pourquoi 1. **La libération ne passe pas par `deactivate_in`** (cité en exemple dans « Point de conception ») : `deactivate_in` supprime le jeton dès la place libérée (`drop_token_of_deactivated_machine_in`) ; si la suppression de `license.key` échouait ensuite, on aurait l'état interdit « jeton parti, clé restée ». Le retrait appelle `deactivate_idempotent` puis `note_revocation` (même enregistrement de la révocation) et ne supprime le jeton qu'après la clé. Un test le garde (mutation R17). 2. **Codes hors table** (`invalid_input`, et `license_expired` / `machine_limit_reached`, que la route ne rend pas d'après `deactivateLicense` dans 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é. 3. **Clé présente mais illisible avec `release_slot`** : `storage`, rien supprimé (la libération demandée ne peut pas être faite). Sans `release_slot`, la clé n'est pas lue et part. 4. **`rate_limit_exceeded`** de la table est le code `rate_limit` du client (`LicenseError::RateLimit`). 5. **`t` injecté** dans `performRemoveLicense` en plus des dépendances listées : la seconde confirmation nomme la raison (`license.errors.*` déjà traduit), et l'issue fait passer `window.confirm` et `t(...)` par la carte. 6. **`performRemoveLicense` dans `useLicenseMachines.ts`** (l'issue autorise ce fichier) : `is_activated`, qui décide `release_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. 7. **Clés i18n `license.removeKey.*`**, pas `license.removeLicense` : `licenseService.test.ts` garde depuis #347 l'absence de `license.removeLicense`, laissé tel quel. 8. **Ajouts** : un jeton orphelin (sans clé) est aussi supprimé par le retrait (post-condition unique, et la reprise d'un retrait dont le jeton n'a pas pu partir) ; une ligne `logWarn` pour 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 - **Rust** (+14, dans `license_commands.rs`) : helper (absent, présent, dossier à la place) ; retrait (clé et jeton supprimés, `license.check` et un autre fichier intacts, aucune requête) ; après le retrait, `entitlements_from_dir` = `("free", [])` et `check_revocation_in` = `no_license` avec un seul mock attrape-tout `expect(0)` ; clé révoquée retirée puis ressaisie → Gratuite ; retrait sans clé (succès, rien d'écrit, dossier absent non créé, avec et sans `release_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 par `match_request` avec 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 (404 `no_active_activation`, 404 `license_not_found`, 403 `license_revoked` avec é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. - **Front** (+41) : reducer et sélecteurs du retrait ; `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 et `refresh` du 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) | Rust | Rouge | |---|---| | R1 jeton supprimé avant la clé | 3 tests | | R2 jeton jamais supprimé | 7 | | R3 `license.check` supprimé aussi | 4 | | R4 `release_slot` ignoré | 5 | | R5 clé connue révoquée quand même libérée | 2 | | R6 jeton d'une autre machine libéré | 1 (ligne « token of another machine ») | | R6c identifiant du système illisible ignoré | 1 (ligne « unreadable OS id ») | | R7 `network` → on continue | 1 | | R7b `server` / `rate_limit` / `invalid_response` / `invalid_input` → on continue | 1 | | R8 `license_not_found` devient une erreur | 1 | | R8b `license_revoked` devient une erreur | 1 | | R9 pas de `note_revocation` | 1 | | R10 clé absente + `release_slot` → `storage` | 2 | | R11 clé illisible + `release_slot` → on continue | 1 | | R12 suppression de la clé : `NotFound` → `storage` | 2 | | R13 helper : `NotFound` → `storage` | 7 | | R14 libération après les suppressions | 5 | | R15 dérivé envoyé au lieu du claim du jeton | 1 (jeton brut) | | R16 `create_dir_all` au début du retrait | 1 | | R17 libération par `deactivate_in` | 1 | | Front | Rouge | |---|---| | F1 confirmation refusée ignorée | 2 | | F2 pas de `refresh` après le retrait | 7 | | F3 seconde confirmation pour tout code | 7 | | F4 seconde confirmation sans libération demandée | 1 | | F5 second appel avec `releaseSlot` | 5 | | F6 textes « activée » / « valide » échangés | 18 | | F7 `releasesSlot` ignore la révocation | 1 | | F8 pas de `logWarn` sur libération échouée | 4 | | F9 ligne « place occupée » journalisée « place libérée » | 4 | | F10 échec non nommé | 9 | | F11 pas de `REMOVE_END` après « garder la clé » | 4 | | F12 bouton sur `hasLicense` | 2 | | F13 saisie d'une clé non verrouillée pendant un retrait | 1 | | F14 retrait non verrouillé pendant une saisie | 1 | | F15 échec du retrait non affiché | 1 | | F16 `selectCanActivate` sans `!removing` | 2 | | F17 `selectCanDeactivate` sans `!removing` | 2 | | F18 `selectCanRemoveKey` ignore une activation | 2 | | F19 `isActivated` toujours faux | 1 | | F20 le hook ne passe pas la révocation du contexte | 1 | | F21 argument `release_slot` au lieu de `releaseSlot` | 2 | | F22 `invalid_response` retiré des codes de seconde confirmation | 2 | | F23 raison absente de la seconde confirmation | 4 | | F24 `REMOVE_START` avant la première confirmation | 16 | ## Validation - `npm run build` (tsc + vite) : vert - `npm test` : **1487** (1446 sur la base, +41) - `cargo test` : **209** (195 sur la base, +14) - `cargo check` : sans avertissement ; `cargo metadata --locked` : OK, `Cargo.lock` inchangé, aucune dépendance ajoutée - Aucune migration SQL touchée ## Pour reviewer - **Où regarder d'abord** : `release_this_machine_slot` et `remove_license_in` (`license_commands.rs`), puis `performRemoveLicense` (`useLicenseMachines.ts`). - **Ce que les tests ne gardent pas** : la commande `remove_license` / `remove_license_impl` (dossier, clé embarquée, `local_machine_ids().ok()`, `unix_now`) et son enregistrement dans `lib.rs`, faute d'`AppHandle` ; le `onClick` de la carte (`removeKey({ confirm: (message) => window.confirm(message), t })`), faute de clic sous `renderToStaticMarkup` ; le passage de `isActivated` du hook au retrait n'est gardé que par le sélecteur (l'état d'activation n'est jamais chargé sous `renderToStaticMarkup`) ; la réponse réelle de `/v1/licenses/deactivate`. - **Limite connue** : `release_slot` suit 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, ou `get_activation_status` en échec), le retrait ne libère rien, et sa confirmation (texte « valide ») n'annonce aucune libération. - **À vérifier à la main** (Linux, puis Windows) : 1. clé valide, machine activée : « Retirer la clé » → texte « activée » → OK ; la machine disparaît de la liste vue depuis un autre poste ; le Journal dit « …after this machine's slot was freed… » ; la carte repasse en Gratuite, sans clé ; 2. même cas hors ligne : la seconde confirmation nomme « Impossible de joindre le serveur de licences… » ; Annuler ne change rien ; OK retire la clé et le Journal dit que la place reste occupée ; 3. clé révoquée (celle du contrôle d'achat du 2026-09-30) : texte « révoquée », aucune requête de désactivation ; au redémarrage, le Journal dit « License check: no valid key stored, nothing sent » ; 4. ressaisir cette clé révoquée : elle reste en Gratuite ; 5. `window.confirm` affiche bien les sauts de ligne des textes (WebKitGTK et WebView2). ## Après merge (Max) — texte pour maximus/la-compagnie-maximus#184 > Complément de simpl-resultat#370 (prochaine version de l'application) : la carte Licence (Paramètres → Utilisateurs → Licence) offre maintenant « Retirer la clé ». Le retrait de la clé arrête le contrôle de révocation : aucune requête de vérification ne part tant qu'aucune clé n'est enregistrée sur l'ordinateur. Si la machine est activée, le retrait libère d'abord sa place auprès du serveur de licences par une requête de désactivation, qui porte le produit, la clé et l'identifiant de machine de l'activation, les mêmes données que la désactivation depuis la liste des machines. La politique peut donc dire que le contrôle s'arrête au retrait de la clé, et non plus seulement à l'enregistrement d'une autre clé. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-10-04 21:15:57 +00:00
feat(license): remove the stored license key from the License card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m26s
e2f0fb86da
"Remove the key" on the License card removes license.key, then the
activation token, and keeps license.check, so a revoked key removed then
entered again stays Free. Removing the key stops the revocation check,
which until now only storing another key could stop.

When this machine is activated with a valid key, its slot is freed on
the license server first (remove_license with release_slot). A release
that fails removes nothing; on network, server, rate_limit or
invalid_response the card asks whether to remove the key anyway, the
slot then staying occupied. no_active_activation, license_not_found and
license_revoked (recorded for the key sent) mean the slot is free.

- Rust: remove_license / remove_license_in, release_this_machine_slot,
  this_machine_token_id, and the shared remove_activation_token_in
  helper, now also used by store_license_in and the deactivation.
- Front: removeLicense service; performRemoveLicense in
  useLicenseMachines (confirmations by state, error table, in-app log
  lines); removing and removeError in the machines reducer, which lock
  key entry, Activate and Deactivate; the button for any stored key,
  valid or revoked.
- Docs: user guide, in-app tips, architecture, ADR 0017 amendment,
  changelogs.

Resolves #370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-04 21:16:02 +00:00
Author
Owner

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:approved n'est pas posé, l'issue est en status:review.

Résumé. Le retrait suit les décisions de l'issue : license.key d'abord, puis le jeton, license.check gardé, 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 sur info !== null ; lignes au Journal. 20 mutations Rust et 24 front rouges, une à la fois.

Sécurité

  • Aucun secret ni donnée personnelle dans le diff (lignes ajoutées scannées : adresses, clés SR-*, JWT, noms).
  • La requête de désactivation porte product, license_key et le claim du jeton, rien d'autre (corps exact vérifié par match_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).
  • États partiels : jamais « jeton parti, clé restée » (ordre gardé par trois tests, dont la clé rendue non supprimable pendant la requête de libération).

Correction

  • Aucun point bloquant.

Qualité

  • Pas de code mort ni de console.* ; tout le texte visible passe par license.removeKey.* (FR et EN) ; le garde de #347 sur license.removeLicense est intact.
  • Aucune migration SQL touchée, Cargo.lock inchangé.

Suggestions non bloquantes

  1. 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é de remove_license (place libérée / déjà libre / pas demandée) rendrait la ligne exacte.
  2. src-tauri/src/commands/license_commands.rs:1218 — remove_license_impl exige la clé publique embarquée même sans release_slot. Elle est épinglée par embedded_public_key_pem_parses, donc sans conséquence pratique.
  3. src/hooks/useLicenseMachines.ts:287 — release_slot suit l'état d'activation que la carte a chargé ; avant ce chargement (quelques millisecondes) ou si get_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 dans lib.rs, faute d'AppHandle ;
  • src/components/settings/LicenseCard.tsx:161, le onClick qui passe window.confirm et t, faute de clic sous renderToStaticMarkup.
## 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:approved` n'est pas posé, l'issue est en `status:review`. **Résumé.** Le retrait suit les décisions de l'issue : `license.key` d'abord, puis le jeton, `license.check` gardé, 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 sur `info !== null` ; lignes au Journal. 20 mutations Rust et 24 front rouges, une à la fois. ### Sécurité - Aucun secret ni donnée personnelle dans le diff (lignes ajoutées scannées : adresses, clés `SR-*`, JWT, noms). - La requête de désactivation porte `product`, `license_key` et le claim du jeton, rien d'autre (corps exact vérifié par `match_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). - États partiels : jamais « jeton parti, clé restée » (ordre gardé par trois tests, dont la clé rendue non supprimable pendant la requête de libération). ### Correction - Aucun point bloquant. ### Qualité - Pas de code mort ni de `console.*` ; tout le texte visible passe par `license.removeKey.*` (FR et EN) ; le garde de #347 sur `license.removeLicense` est intact. - Aucune migration SQL touchée, `Cargo.lock` inchangé. ### Suggestions non bloquantes 1. `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é de `remove_license` (place libérée / déjà libre / pas demandée) rendrait la ligne exacte. 2. `src-tauri/src/commands/license_commands.rs:1218` — `remove_license_impl` exige la clé publique embarquée même sans `release_slot`. Elle est épinglée par `embedded_public_key_pem_parses`, donc sans conséquence pratique. 3. `src/hooks/useLicenseMachines.ts:287` — `release_slot` suit l'état d'activation que la carte a chargé ; avant ce chargement (quelques millisecondes) ou si `get_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 dans `lib.rs`, faute d'`AppHandle` ; - `src/components/settings/LicenseCard.tsx:161`, le `onClick` qui passe `window.confirm` et `t`, faute de clic sous `renderToStaticMarkup`.
Author
Owner

Revue adversariale : PR #387 (#370), tête e2f0fb8

Verdict : 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é

  • Copie hors dépôt de e2f0fb8 (git archive, npm ci, cible Cargo séparée) : tsc sans erreur, vitest 1487/1487, cargo test 209/209, compilation sans avertissement.
  • CI de la tête lue dans les logs : run 481 (tsc && vite build, 1487 tests) et run 482 (garde des suppressions 2 checks, exit 0, 209 tests), tous deux sur e2f0fb8.
  • Mutations du relecteur, une à la fois, copie restaurée et vérifiée propre après chacune :
Mutation Rouge
Rust : libération par deactivate_inner, sans l'idempotence 1 (a_slot_already_free_lets_the_removal_go_on)
Rust : this_machine_token_id n'accepte que le dérivé (jeton d'avant #350 ignoré) 1 (the_release_carries_the_token_s_identifier_before_anything_is_removed)
Rust : échec de suppression du jeton avalé dans remove_license_in 1 (a_token_that_cannot_be_deleted_is_storage_once_the_key_is_gone)
Rust : R17 de l'auteur rejouée (libération par deactivate_in) 1, conforme
Rust : store_license_in ignore un jeton précédent non supprimable (? → let _ =) 0 (voir S2)
Front : F3 de l'auteur rejouée (seconde confirmation pour tout code) 7, conforme
Front : raison de la seconde confirmation passée sans t 1 (ligne rate_limit seule)
Front : première confirmation passée sans t 0 (voir S1)
Front : raison de l'échec affiché sur la carte passée sans t 0 (voir S1)
  • Côté serveur (maximus-api d312ea6) : deactivateLicense ne rend que license_not_found (404), no_active_activation (404) et invalid_input (400, schéma), plus le 429 du limiteur. Il ne rend jamais license_revoked : revokeLicense désactive toutes les activations. Le saut de la libération pour une clé révoquée est donc juste, et la ligne license_revoked de la table est défensive. L'impasse de l'écart 2 (erreur nommée, sans « retirer quand même ») ne s'atteint que par invalid_input. Or activation et désactivation partagent machineIdSchema, 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 t ne distingue pas un texte traduit d'une clé brute. echo (useLicenseMachines.test.ts) et le mock de LicenseCard.render.test.tsx rendent la clé telle quelle quand l'appel n'a pas d'options, donc un appel à t oublié ne se voit pas. Mesuré : deps.confirm(removeLicenseConfirmKey(state)) sans t laisse 1487/1487 verts, alors que l'utilisateur lirait license.removeKey.confirmValid dans la boîte de dialogue ; même chose pour reason: machines.removeError.i18nKey sur la carte. La raison de la seconde confirmation n'est gardée que par la ligne rate_limit, la seule qui porte des options. Pour les tests de cette PR, un echo qui marque sa sortie (par exemple t(<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 storage par store_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 de activation.token rend Err(Storage), et l'ancienne clé reste. Sa place naturelle est #388.

S3 : pas de refresh() après un échec. Quand license.key est parti et que seule la suppression du jeton échoue, Rust rend storage. 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_impl et son enregistrement, le onClick de la carte, le passage de isActivated du hook au retrait, la réponse réelle de /v1/licenses/deactivate et les sauts de ligne de window.confirm sous WebKitGTK et WebView2 restent à la vérification manuelle décrite dans la PR.

Divers

  • Commit conventionnel (feat(license): …), Resolves #370 dans le message et Fixes #370 dans le corps : la timeline de #370 porte bien un pull_ref closes depuis #387.
  • Aucun .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.rs sans manifeste d'app).
  • docs/architecture.md : la section license_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 dans lib.rs), hors du périmètre de la PR.
  • Copie hors dépôt et fichiers /tmp/pr387-* supprimés en fin de revue.
## Revue adversariale : PR #387 (#370), tête `e2f0fb8` **Verdict : 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é - Copie hors dépôt de `e2f0fb8` (`git archive`, `npm ci`, cible Cargo séparée) : `tsc` sans erreur, **vitest 1487/1487**, **cargo test 209/209**, compilation sans avertissement. - CI de la tête lue dans les logs : run 481 (`tsc && vite build`, 1487 tests) et run 482 (garde des suppressions `2 checks, exit 0`, 209 tests), tous deux sur `e2f0fb8`. - Mutations du relecteur, une à la fois, copie restaurée et vérifiée propre après chacune : | Mutation | Rouge | |---|---| | Rust : libération par `deactivate_inner`, sans l'idempotence | 1 (`a_slot_already_free_lets_the_removal_go_on`) | | Rust : `this_machine_token_id` n'accepte que le dérivé (jeton d'avant #350 ignoré) | 1 (`the_release_carries_the_token_s_identifier_before_anything_is_removed`) | | Rust : échec de suppression du jeton avalé dans `remove_license_in` | 1 (`a_token_that_cannot_be_deleted_is_storage_once_the_key_is_gone`) | | Rust : R17 de l'auteur rejouée (libération par `deactivate_in`) | 1, conforme | | Rust : `store_license_in` ignore un jeton précédent non supprimable (`?` → `let _ =`) | **0** (voir S2) | | Front : F3 de l'auteur rejouée (seconde confirmation pour tout code) | 7, conforme | | Front : raison de la seconde confirmation passée sans `t` | 1 (ligne `rate_limit` seule) | | Front : première confirmation passée sans `t` | **0** (voir S1) | | Front : raison de l'échec affiché sur la carte passée sans `t` | **0** (voir S1) | - Côté serveur (maximus-api `d312ea6`) : `deactivateLicense` ne rend que `license_not_found` (404), `no_active_activation` (404) et `invalid_input` (400, schéma), plus le 429 du limiteur. Il ne rend jamais `license_revoked` : `revokeLicense` désactive toutes les activations. Le saut de la libération pour une clé révoquée est donc juste, et la ligne `license_revoked` de la table est défensive. L'impasse de l'écart 2 (erreur nommée, sans « retirer quand même ») ne s'atteint que par `invalid_input`. Or activation et désactivation partagent `machineIdSchema`, 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 `t` ne distingue pas un texte traduit d'une clé brute.** `echo` (`useLicenseMachines.test.ts`) et le mock de `LicenseCard.render.test.tsx` rendent la clé telle quelle quand l'appel n'a pas d'options, donc un appel à `t` oublié ne se voit pas. Mesuré : `deps.confirm(removeLicenseConfirmKey(state))` sans `t` laisse 1487/1487 verts, alors que l'utilisateur lirait `license.removeKey.confirmValid` dans la boîte de dialogue ; même chose pour `reason: machines.removeError.i18nKey` sur la carte. La raison de la seconde confirmation n'est gardée que par la ligne `rate_limit`, la seule qui porte des options. Pour les tests de cette PR, un `echo` qui marque sa sortie (par exemple `t(<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 `storage` par `store_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 de `activation.token` rend `Err(Storage)`, et l'ancienne clé reste. Sa place naturelle est #388. **S3 : pas de `refresh()` après un échec.** Quand `license.key` est parti et que seule la suppression du jeton échoue, Rust rend `storage`. 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_impl` et son enregistrement, le `onClick` de la carte, le passage de `isActivated` du hook au retrait, la réponse réelle de `/v1/licenses/deactivate` et les sauts de ligne de `window.confirm` sous WebKitGTK et WebView2 restent à la vérification manuelle décrite dans la PR. ### Divers - Commit conventionnel (`feat(license): …`), `Resolves #370` dans le message et `Fixes #370` dans le corps : la timeline de #370 porte bien un `pull_ref` `closes` depuis #387. - Aucun `.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.rs` sans manifeste d'app). - `docs/architecture.md` : la section `license_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 dans `lib.rs`), hors du périmètre de la PR. - Copie hors dépôt et fichiers `/tmp/pr387-*` supprimés en fin de revue.
maximus changed target branch from issue-373-revocation-check-on-entry to main 2026-10-05 23:22:50 +00:00
maximus merged commit e2f0fb86da into main 2026-10-05 23:22:52 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#387
No description provided.