fix(license): drop an activation token that no longer stands for this machine (#367) #388
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#388
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-367-stale-activation-token"
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 #367
Generated autonomously by /autopilot run of 2026-10-04
Ce qui change pour l'utilisateur
license_revoked), ou dès l'ouverture de la liste des machines quand la clé A n'a aucune activation de ce poste.Technique
Rust
balance_commands.rs:fetch_price_in(dir, symbol, date, api_base), sansAppHandle, lit le jeton parread_stored_activation_token_in(dir)(constanteACTIVATION_FILE, plus de nom en dur) et, surFetchPriceError::LicenseRevoked, appelleremove_activation_token_if_unchanged_inen best effort.fetch_price_with_token, sans appelant, est retiré.license_commands.rs:remove_activation_token_if_unchanged_in(dir, token): supprime le fichier seulement s'il vaut encore le jeton dont parle la réponse (deux côtés rognés), viaremove_activation_token_inde #370.Ok(false)fichier absent ou autre jeton,Storagefichier illisible.machines_from_verifyrendListedMachines { machines, skipped }: l'affichage de la liste reste tolérant (pas d'invalid_response), seule la réconciliation lit le compteur.drop_token_of_unlisted_machine_in(modèle :drop_token_of_deactivated_machine_in), appelée parlist_machines_inaprès une réponseOk. Gardes : identifiant du système lisible ; liste complète ; jeton qui active ce poste (signature, expiration, identifiant) ; aucune ligne qui nomme ce poste (names_this_machine, formes dérivée et brute) ; fichier inchangé depuis la requête. Toute erreur (license_revoked, 429, réseau, réponse illisible…) sort avant.list_activated_machinesrendMachineList { machines, token_dropped: "not_listed" | null }.Front
licenseService.ts:MachineList,TokenDropped, etACTIVATION_TOKEN_LOG(codesnot_listed_for_this_machine,price_refused_revoked, sur le modèle deREVOCATION_LOG).useLicenseMachines.ts:loadMachinesrelit l'état d'activation après chaqueLIST_LOAD_DONE(lecture locale, aucune requête ajoutée au budget de 5/min/IP) et écrit la ligne quandtoken_droppedvautnot_listed;logInfoinjecté dans les dépendances.balance.service.ts:_doFetchOnceécrit la ligneprice_refused_revokedsur le codelicense_revoked(sans relance, donc une ligne par requête refusée).balance.priceFetching.errors.licenseRevoked,docs.editions.tips.Docs :
docs/architecture.md(ligneslicenseService.ts,useLicenseMachines,list_activated_machines, contrat d'erreur, Révocation, Identifiant machine), ADR 0017 (amendement #365, « Les autres sources »), ADR 0020 (règle 4, en-tête), guide (puce « Machines »), CHANGELOG FR/EN sous Fixed/Corrigé.Écarts au corps de l'issue
list_machines_inlit le jeton avant la requête et la réconciliation passe par le même helper conditionnel. L'écart ne fait que réduire les suppressions (testa_token_written_while_the_list_was_in_flight_stays).authinchangé. Il dit déjà « activez cette machine dans Paramètres → Utilisateurs » (EN : « activate this machine in Settings → Users ») ; un test l'épingle dans les deux langues aveclicenseRevoked.list_activated_machineslit la clé publique embarquée (validation locale du jeton) :embedded_decoding_key()?, commedeactivate_machine_impl.Tests
Rust : 209 → 223 (+14)
balance_commands:a_revoked_license_drops_the_token_it_refused(fichierACTIVATION_FILEavec retour à la ligne,Bearerrogné, rien d'écrit à la place),a_token_written_while_the_refused_request_was_in_flight_stays(fermeturematch_requestqui écrit un autre jeton pendant la requête),no_other_answer_drops_the_token(200, 401, 403premium_requiredet HTML, 404, 429, 502, hors ligne),without_a_token_nothing_is_sent_and_the_answer_is_auth.license_commands:a_v0_15_0_folder_gets_activate_back_once_the_price_route_refuses_its_token(dossier v0.15.0 reproduit : « Activée » avant, jeton parti,license.checknon écrit, « non activée » après, clé A garde son édition),the_conditional_removal_drops_only_the_token_it_is_given,a_complete_list_without_this_machine_drops_its_token,a_row_of_this_machine_keeps_the_token_under_either_identifier,a_raw_token_stays_next_to_its_row_rehashed_as_adr_0020_derives_it(ligne au format dérivé exact de l'ADR 0020, premier vecteur écrit en toutes lettres : hypothèse de maximus-api#82 figée côté client),the_token_stays_unless_the_list_can_speak_for_it(ligne illisible, identifiant illisible, jeton expiré, d'une autre machine, d'une autre clé, illisible, absent),a_list_that_fails_drops_nothing(license_revokeddes deux formes, 429,not_found,valid:truesansmachines, 503, hors ligne),a_token_written_while_the_list_was_in_flight_stays,the_webview_learns_whether_the_list_dropped_the_token,a_row_that_does_not_read_is_left_out_and_counted.the_machine_list_flags_this_machine_under_either_identifier, les trois tests d'enregistrement de révocation qui appellentlist_machines_in,verify_answers_map_to_machines_or_codes,verify_posts_product_to_v1_and_lists_machines,all_three_calls_work_against_v1_only_routes.vitest : 1487 → 1495 (+8)
useLicenseMachines.test.ts: relecture de l'état après la liste, ordre des événements et ligne au Journal ; relecture après toute liste, ligne seulement surtoken_dropped; rien après une liste en échec ; câblage du hook (logInfodu service de journal, chemin 409). Adapté : « re-reads the local status… » lit maintenant l'état trois fois (montage, après la liste, après la désactivation).balance.service.test.ts: ligneprice_refused_revokedune fois surlicense_revoked, aucune sur les autres réponses ; invitation à activer danslicenseRevokedetauthFailed, FR et EN.licenseService.test.ts: contratMachineList; codes deACTIVATION_TOKEN_LOG.Mutations (une à la fois, restauration par
git checkout --depuis l'index : toutes rouges)the_conditional_removal_…a_revoked_license_drops_the_token_it_refused,the_conditional_removal_…a_revoked_license_…,a_v0_15_0_folder_…no_other_answer_drops_the_tokenno_other_answer_drops_the_tokenlicense.checka_revoked_license_…,a_v0_15_0_folder_…ACTIVATION_FILEa_v0_15_0_folder_…the_token_stays_…(« a row that does not read »)the_token_stays_…(« an unreadable OS id »)the_token_stays_…(« an expired token of this machine »)the_token_stays_…(« a token of another machine »)the_token_stays_…(« a token signed by another key »)a_row_of_this_machine_…(jeton brut, ligne brute)a_raw_token_stays_next_to_its_row_rehashed_…,a_row_of_this_machine_…a_list_that_fails_drops_nothinga_token_written_while_the_list_was_in_flight_staysnot_listedrendu sans suppressiona_token_written_while_the_list_was_in_flight_staysa_complete_list_without_this_machine_drops_its_tokenmachines_from_verifyne compte riena_row_that_does_not_read_…,the_token_stays_…list_machines_innerperd le compteurthe_token_stays_…notListedthe_webview_learns_…without_a_token_nothing_is_sent_…logWarnau lieu delogInfolicenseRevokedsans l'invitationprice_refused_revokedrenommétoken_droppedValidation
npm run build(tsc + vite) : vertnpm test: 1495 vertscargo test: 223 verts ;cargo checksans avertissement ;Cargo.lockinchangé (cargo metadata --lockedOK)Pour reviewer
drop_token_of_unlisted_machine_inetlist_machines_in(license_commands.rs), puisfetch_price_in(balance_commands.rs) etremove_activation_token_if_unchanged_in.AppHandle(fetch_price,list_activated_machines_impl:embedded_decoding_key,local_machine_ids().ok(),unix_now) ; le chargement de la liste depuistoggleList(fire-and-forget) : le câblage du hook est prouvé par le chemin 409 deperformActivate, seul chargement qu'un test peut attendre sousrenderToStaticMarkup.🤖 Generated with Claude Code
A data directory written by v0.15.0 or earlier can hold a valid key next to the activation token of another license, typically a refunded hence revoked one. The License card read "Activated", market prices were refused, and "Activate" was never offered: the folder could not recover without a manual file removal. - Prices: a 403 license_revoked drops the refused token, and only while the file still holds it, so an activation that lands while the refused request is in flight keeps its new token (CWE-367). The key's revoked state is never written from this route. The token is read through ACTIVATION_FILE by the directory-based read_stored_activation_token_in, in fetch_price_in, testable without AppHandle. - Machine list: list_activated_machines returns { machines, token_dropped }. A complete list that names this machine under neither identifier drops a token that activates this machine, when the OS id is readable and the token is the one on disk when the list was asked for. An error, a revoked key or a rate limit drops nothing. machines_from_verify counts the rows it skips, which the list still tolerates. This also gives "Activate" back to a machine deactivated from another computer. - Front: the activation status is read again after every list load; a fixed log line (ACTIVATION_TOKEN_LOG) is written on token_dropped and on a price refused as revoked; the revoked-license price message invites to activate the machine again. - Docs: architecture, ADR 0017 (#365 amendment), ADR 0020 (rule 4), user guide and in-app tips, changelogs. Resolves #367 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Auto-revue (worker autopilot) : APPROVE
Relecture du diff complet de la PR (base
issue-370-remove-license-key, têtefd90de5) sous quatre angles : sécurité, justesse, qualité, données. Ce n'est pas une revue indépendante : l'issue reste enstatus:review.Résumé
license.check.maximus-api(verifyLicense) : la liste ne porte que les activationsisActive = true, toujours avec leurs dates, sans limite. Une machine désactivée ailleurs en est donc absente, comme le suppose le deuxième critère.cargo check.fetch_price_with_token, qui n'avait plus d'appelant, est retiré.Cargo.lockinchangé.Points bloquants
Aucun.
Points non bloquants
src-tauri/src/commands/license_commands.rs:277:remove_activation_token_if_unchanged_inlit, compare, puis supprime. Une écriture du jeton qui tomberait entre la comparaison etremove_fileserait perdue. La fenêtre dure quelques microsecondes contre un aller-retour réseau, et c'est la règle de l'issue. La fermer demanderait un renommage atomique, hors de proportion.src/services/licenseService.ts:124: la ligneprice_refused_revokeddit « that token is removed ». C'est vrai aussi quand une activation a remplacé le jeton pendant la requête, puisqu'il n'est plus sur le disque. Ce n'est faux que si la suppression échoue sur une erreur d'E/S (best effort) : la requête suivante présente alors le même jeton, reçoit le même refus et retente la suppression. Le contrat{code}deFetchPriceErrorreste tel quel, comme le demande l'issue.src/hooks/useLicenseMachines.ts:433: le chargement lancé partoggleListpart sans être attendu, et ne s'atteint pas sousrenderToStaticMarkup. Le câblage du hook est prouvé par le chemin 409, oùperformActivateattendloadMachines. À vérifier à la main : ouvrir la liste sur un poste désactivé ailleurs.src-tauri/src/commands/balance_commands.rs:225etlicense_commands.rs:1282: aucun test ne garde les commandes avecAppHandle(fetch_price,list_activated_machines_impl), comme pour les autres commandes du module.Suggestions (hors périmètre)
/v1/pricesne vérifie pas qu'une machine est encore active. Le suivi maximus-api est à ouvrir, comme le note l'issue. D'ici là, une machine désactivée ailleurs garde les cours tant que personne n'ouvre la liste sur ce poste.a_raw_token_stays_next_to_its_row_rehashed_as_adr_0020_derives_itfige cette hypothèse côté client.Revue adversariale (/pr-review) : APPROVE
Verdict : APPROVE. Les deux retraits du jeton font ce que l'issue demande, sous les gardes qu'elle fixe, et chaque garde centrale que j'ai mutée tombe en rouge. Une épingle de test ne départage pas sa branche (suggestion 1), sans effet sur le comportement livré.
Ce qui a tourné
Copie hors dépôt à
fd90de5(clone dans/tmp,npm ci, dossiertargetséparé) :cargo test --all-targets: 223 verts ;vitest run: 1495 verts (79 fichiers).cargo checksans avertissement, garde des advisories à 2 vérifications, 223 tests), run 485 (tsc && vite build, 1495 tests).mainest ancêtre defd90de5, donc ces suites valent l'état d'après merge de toute la pile #386 → #387 → #388.Mutations du relecteur, une à la fois, restaurées par
git checkout --dans la copie :fetch_price_insupprime sans condition (remove_activation_token_in)a_token_written_while_the_refused_request_was_in_flight_stayslist_machines_inlit le jeton après la réponsea_token_written_while_the_list_was_in_flight_stayslisted.skipped > 0retiréethe_token_stays_unless_the_list_can_speak_for_itremove_activation_token_if_unchanged_in: une lecture en échec supprime quand même (Err(_) => {})token_droppedvautnot_listeduseLicenseMachines.test.ts)en.json:licenseRevokedsans l'invitation à activerbalance.service.test.ts)Hypothèses serveur vérifiées dans maximus-api (lecture seule)
verifyLicense(main) ne liste que les activationsisActive = true, sans limite, avec lemachine_idcomplet ;machine_id,activated_atetlast_seen_atsontNOT NULLdans le schéma.skippedvaut donc 0 en pratique, et une machine désactivée ailleurs disparaît de la liste, comme le suppose le deuxième critère d'acceptation.activateLicenserafraîchit le jeton d'une machine déjà active : un jeton retiré à tort se répare par « Activer », sans prendre une seconde place.licenseAuthne répond 403license_revokedque pour la licence dusubdu jeton : la route des cours condamne le jeton, pas la clé.issue-82-derived-machine-ids(non mergée) dérive parHMAC-SHA256souscom.simpl.resultat/license-machine-id/v1, avec un rognage aligné surstr::trim, et ses tests portent les deux vecteurs de l'ADR 0020, recalculés ici hors du code. L'hypothèse que figea_raw_token_stays_next_to_its_row_rehashed_as_adr_0020_derives_ittient contre l'implémentation en cours.Contrôlé, sans constat
activation_token_machine_idrogne le jeton : un fichier à retour à la ligne se valide aussi côté liste, etremove_activation_token_if_unchanged_inrogne les deux côtés.ACTIVATION_STATUSne touche pasactivationError: la relecture ajoutée après la liste n'efface pas le message 409 du chemin D2.loadActivationStatusavale ses erreurs, donc le chargement lancé partoggleListne crée pas de rejet non géré.store_license_inetremove_license_insuppriment le jeton, et la suppression conditionnelle rendOk(false).list_activated_machinesni defetch_price; pas d'import circulaire entrebalance.service.tsetlicenseService.ts.pull_refcloses, depuis #388 ; corps et commits (Resolves #367) ne ferment que #367, conformément à la décision de planification.127.0.0.1:1des tests hors ligne).Suggestions (non bloquantes)
license_commands.rs,the_conditional_removal_drops_only_the_token_it_is_given: le cas « un dossier à la place du jeton » ne départage pas la brancheErr(_) => return Err(LicenseError::Storage). Sous M4,remove_fileéchoue aussi sur un dossier et rendStoragequand même, d'où 223 verts. Un jeton non UTF-8 départage : sonde ajoutée dans la copie, verte sur la PR, rouge sous M4 (left: Ok(true), fichier supprimé) : Portée pratique faible : les deux appelants ont déjà lu le jeton avant la requête, et la branche ne joue que si le fichier devient illisible pendant celle-ci. Épingle manquante sur un code correct : en tête de pile avant le merge, ou en suivi.Fixes #367ferme l'issue au merge, et ces deux contrôles n'auront plus d'autre trace que ce corps. Les reporter au merge dans la liste des vérifications de release deSTATE.md, comme pour #346, #350 et #365.Actions faites
/tmp/pr388-src, dossier/tmp/pr388-target, scripts de mutation et logs CI téléchargés : supprimés en fin de revue. Arbre de travail du dépôt jamais touché.status:review→status:approved, posé avec ce commentaire.Mergée le 2026-10-05 en avance rapide par l'API, avec toute la pile : #386 → #387 → #388, chacune épinglée sur sa tête,
main=2cbc886.2cbc886a été ajouté après la revue et n'a pas été relu. C'est un commit unique en tête de pile, qui applique les suggestions non bloquantes des trois/pr-review:revocationCheckLauncher. Le commentaire deuseLicenseMachines.tsdit aussi que deux contrôles dans la même minute portent le parcours « limite de machines » à 6 requêtes.tdes tests du retrait et de la carte Licence marque sa sortie,t(clé[options]).Storagedestore_license_inquand l'ancien jeton ne peut pas être supprimé ; l'ancienne clé reste.falseau lieu de « déjà révoquée »tttstore_license_inignore un jeton non supprimableValidation du tip cumulé : worktree jetable,
npm cipropre ; une sonde volontairement rouge sort bien en code 1. Résultats : build vert, 1498 vitest, 224 tests Rust,cargo checksans avertissement,cargo metadata --lockedaccepté. Rien ne change par rapport àmaindans.cargo,.forgejo,Cargo.lock,Cargo.toml,package.jsonnipackage-lock.json. La CI de2cbc886est verte : run 489 (1498 tests) et run 490 (224 tests, cache partagé restauré enfull match).Suite : les huit autres fichiers de tests dont le faux
trend la clé brute partent dans une issue de suivi. Les vérifications manuelles (1) et (2) de cette PR sont reportées dansSTATE.md.maximus referenced this pull request2026-10-05 23:24:06 +00:00