fix(license): check a revoked key as soon as it is entered #386

Merged
maximus merged 2 commits from issue-373-revocation-check-on-entry into main 2026-10-05 23:22:41 +00:00
Owner

Fixes #373

Generated autonomously by /autopilot run of 2026-10-04

Ce qui change

Pour l'utilisateur. Une clé révoquée saisie en cours de session repasse en Gratuite pendant la session, sans redémarrage ni activation. La clé est vérifiée dès son enregistrement, en arrière-plan : la saisie rend la main tout de suite (hors ligne comme en ligne), puis une clé révoquée affiche son édition une à deux secondes avant de passer en Gratuite, comme décidé à l'/analyze. La vérification part toujours au plus une fois par 24 h pour une même clé, et chaque vérification écrit sa ligne dans le Journal. Une clé déjà connue comme révoquée sur ce poste passait déjà en Gratuite à la saisie (#365) ; rien ne change pour elle.

Front (src/contexts/LicenseContext.tsx).

  • SubmitKeyDeps reçoit un seul lanceur, startRevocationCheck(wasRevoked). performSubmitKey l'appelle après VALIDATE_DONE avec info.revoked, sans l'attendre. Un enregistrement refusé ne lance rien, et un échec de getEdition n'empêche pas le contrôle (la clé est enregistrée).
  • Nouvelle fabrique exportée revocationCheckLauncher(deps) : elle rend un lanceur qui appelle performRevocationCheck et rend la main aussitôt. Le provider la construit une fois avec checkLicenseRevocation, refresh, logInfo et logWarn, puis la passe à la saisie et à createRevocationCheckStarter, dont le corps ne change pas.
  • Docstrings de l'en-tête, de performSubmitKey et du starter réécrits.

Rust (src-tauri/src/commands/license_commands.rs).

  • static REVOCATION_CHECK_TURN: tauri::async_runtime::Mutex<()> : réexport de tokio::sync::Mutex par Tauri 2.10.2, donc ni Cargo.toml ni Cargo.lock ne bougent (cargo metadata --locked passe).
  • check_revocation_in_turn(turn, dir, api_base, decoding_key, now) prend le tour avant de lire l'état et le garde jusqu'au verdict, requête comprise, puis appelle check_revocation_in, qui ne change pas. La commande check_license_revocation passe &REVOCATION_CHECK_TURN et unix_now.
  • L'heure est lue une fois le tour obtenu (now: impl FnOnce() -> i64). Lue avant l'attente, elle pouvait dater dans le futur la tentative d'un contrôle passé entre-temps, et revocation_check_due tient une telle date pour due : seconde requête.

Docs. Amendement #365 de l'ADR 0017 (point (5), la phrase « une app laissée ouverte n'est pas recontrôlée », la ligne des Liens et la liste des issues) ; guide (Astuces de Paramètres, « Vérification de la licence ») ; docs.settings.tips et docs.editions.tips en FR et en EN ; docs/architecture.md (licenseService.ts, LicenseContext, la commande, le paragraphe Révocation) ; commentaire du budget de requêtes de useLicenseMachines.ts ; CHANGELOG.md / CHANGELOG.fr.md sous [Unreleased], section Fixed / Corrigé ajoutée après Changed / Modifié.

Écarts et précisions par rapport au corps de l'issue

  1. Le tour se passe en paramètre. L'issue décrit un verrou statique qui enveloppe le contrôle dans une fonction testable. Le static existe, mais check_revocation_in_turn le reçoit en paramètre : la commande passe le static, et chaque test crée son propre tour. Dans une première version, la fonction prenait le static elle-même. Les tests partageaient alors le verrou avec ceux d'autres fils, et le test « clé saisie pendant le contrôle » a échoué une fois sur deux lancé avec ses voisins. Contrepartie : la ligne de la commande qui passe le static n'est gardée par aucun test, comme l'enregistrement de la commande dans lib.rs.
  2. Ce que le test Rust demandé prouve. L'issue demande « deux appels concurrents, un seul mock expect(1), une seule requête, le second rend not_due, prouvé rouge sans le verrou ». Le test fait tout cela, mais sans verrou il rougit sur revoked (le second lit false au lieu de true), pas sur le nombre de requêtes. Le double envoi exige deux fils entre la lecture de l'état et l'écriture de la tentative, une fenêtre sans await. Sur le fil unique d'un #[tokio::test], le second contrôle lit déjà la tentative du premier, écrite avant l'appel depuis #365. Le reproduire demanderait un yield sous #[cfg(test)] dans check_revocation_in, que l'issue veut inchangée. Le test prouve donc que le tour couvre tout le contrôle, verdict compris. Comme ce tour est pris avant la lecture de l'état, aucun autre fil ne peut entrer dans la fenêtre pendant un contrôle.
  3. Deux tests Rust de plus. Le premier couvre la course clé A / clé B des Review caveats : le contrôle d'une clé remboursée est en vol quand la nouvelle clé est saisie. Sans le tour, le verdict de la première écrase la tentative de la seconde, qui serait recontrôlée dans les 24 h. Ce test rougit sans aucun point de suspension ajouté. Le second couvre l'heure lue après l'attente (voir plus haut).
  4. Docs touchées au-delà de la liste. docs.settings.tips est la jumelle intégrée de la ligne L514 (aujourd'hui L588) du guide, que l'issue cite. Il faut aussi corriger la ligne LicenseContext d'architecture.md et le commentaire de useLicenseMachines.ts, qui disaient « une fois par démarrage ». Les numéros de ligne de l'issue datent de 0e4fef5 : la correspondance a été vérifiée avec git show 0e4fef5:<fichier>.
  5. Le test du starter L332 (« a key entered later waits for the next start ») devient deux tests. Le premier vérifie qu'une clé saisie après le premier état ready est contrôlée une fois, par la saisie. Le second vérifie qu'une clé saisie avant ce premier état ready reçoit deux lancements, le démarrage et la saisie, et renvoie au test Rust qui les ramène à une requête.

Tests

Rust (3, license_commands.rs) : two_checks_side_by_side_send_one_request, the_check_of_a_key_entered_meanwhile_waits_for_the_verdict_in_flight (mock unique, réponse choisie selon la clé envoyée, expect(2), puis un « démarrage suivant » qui n'envoie rien), a_check_reads_the_time_once_its_turn_has_come. Chacun lance deux branches avec tokio::join!(biased; …). La première branche est donc interrogée la première à chaque tour, et c'est elle qui démarre en premier ; sans biased, join! alterne son point de départ après le premier tour (second commit, 0b94ccf). Tout license_commands a été passé 25 fois avant puis 25 fois après ce commit, sans aucun échec.

Front (LicenseContext.test.ts) : dans performSubmitKey, deux tests (le lancement suit VALIDATE_DONE, une seule fois ; le lancement porte l'état revoked venu de Rust) et deux assertions ajoutées (aucun lancement après un refus ; lancement même si getEdition échoue). Le nouveau describe « a key entered in session is checked right away » câble l'entrée au vrai lanceur. La saisie répond avant le contrôle (course explicite contre setTimeout(0)), puis une clé révoquée passe en Gratuite après rechargement. Une commande rejetée ou un serveur injoignable gardent l'édition de la clé et écrivent une ligne au Journal. Enfin, le test du starter est réécrit (point 5).

Mutations, une à la fois, restauration par git checkout -- depuis l'index :

# Mutation Résultat
R1 verrou retiré (let _ = turn;) 3 tests Rust rouges : second NotDue/false ; état final sur la clé remboursée ; Unreachable (une requête partie)
R2 heure lue avant le verrou a_check_reads_the_time_once_its_turn_has_come rouge (Unreachable) ; les deux autres verts, attendu
R3 garde relâchée aussitôt (let _ = turn.lock().await;) two_checks_side_by_side… et the_check_of_a_key_entered_meanwhile… rouges ; le test de l'heure vert, attendu
F1 lancement retiré de performSubmitKey 8 tests rouges (les 3 qui lancent dans performSubmitKey, les 3 de l'entrée câblée, les 2 du starter composés avec l'entrée)
F2 lancement avant VALIDATE_DONE 2 rouges (ordre des événements)
F3 lancement aussi après un refus 1 rouge (« …nor launching the check »)
F4 startRevocationCheck(false) au lieu de info.revoked 2 rouges
F5 la saisie attend le contrôle (await et un lanceur qui rend la promesse) 1 rouge, en 13 ms : Expected "entry", Received "nothing yet". Une première version ne rougissait que par le délai de 5 s de vitest, d'où la course explicite
F6 le lanceur inverse wasRevoked 2 rouges (pas de rechargement quand il en faut un ; rechargement indu)

Validation (worktree, tête 0b94ccf)

  • npm run build (tsc + vite) : vert.
  • npm test : 1446 verts (1440 avant, 6 de plus).
  • cargo test : 195 verts (192 avant, 3 de plus).
  • cargo check sans avertissement, cargo metadata --locked accepté, Cargo.toml et Cargo.lock intacts.

Pour reviewer

  • À lire d'abord : check_revocation_in_turn et REVOCATION_CHECK_TURN (license_commands.rs), puis les trois tests qui suivent the_check_sends_the_product_and_the_key_and_nothing_else. Côté front : performSubmitKey, revocationCheckLauncher et le câblage du provider.
  • Non gardé par les tests :
    • le câblage du provider : submitKey qui passe launchRevocationCheck, et le starter construit sur le même lanceur, car aucun effet ne tourne sous renderToStaticMarkup ;
    • la commande check_license_revocation, qui passe &REVOCATION_CHECK_TURN et unix_now, faute d'AppHandle ;
    • le double envoi réel sur deux fils (voir l'écart 2) ;
    • la réponse réelle du serveur.
  • Vérification manuelle suggérée : saisir la clé révoquée du contrôle d'achat du 2026-09-30 dans une app lancée. Attendu : Base une à deux secondes, puis Gratuite, et dans le Journal « License check: the license server says the key was revoked ». Ressaisie dans les 24 h : « last attempt less than 24 h ago, nothing sent ». Hors ligne : la saisie répond tout de suite, et le Journal dit « could not be reached ».
  • Effets connus, non bloquants :
    • une clé saisie avant le premier état ready déclenche deux rechargements du contexte, car le second contrôle rend not_due avec revoked: true ; c'est sans gravité ;
    • le contrôle à la saisie consomme une des 5 requêtes par minute et par IP du serveur de licences : la saisie suivie du parcours « limite de machines » fait 5 requêtes. Avec, dans la même minute, le contrôle au démarrage d'une autre clé, la dernière peut recevoir un 429, que l'UI nomme.

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

Précision pour la politique de confidentialité (simpl-resultat#373, prochaine version de l'app) : la vérification de la licence ne part plus seulement au démarrage. Elle part au démarrage et à la saisie d'une clé, au plus une fois par 24 h et par clé, et n'envoie toujours que le produit et la clé (POST /v1/licenses/verify), sans identifiant machine, nom d'ordinateur ni donnée d'usage. Rien ne part sans clé enregistrée, et la vérification continue après une révocation tant que la clé reste enregistrée.

🤖 Generated with Claude Code

Fixes #373 Generated autonomously by /autopilot run of 2026-10-04 ## Ce qui change **Pour l'utilisateur.** Une clé révoquée saisie en cours de session repasse en Gratuite pendant la session, sans redémarrage ni activation. La clé est vérifiée dès son enregistrement, en arrière-plan : la saisie rend la main tout de suite (hors ligne comme en ligne), puis une clé révoquée affiche son édition une à deux secondes avant de passer en Gratuite, comme décidé à l'`/analyze`. La vérification part toujours au plus une fois par 24 h pour une même clé, et chaque vérification écrit sa ligne dans le Journal. Une clé déjà connue comme révoquée sur ce poste passait déjà en Gratuite à la saisie (#365) ; rien ne change pour elle. **Front (`src/contexts/LicenseContext.tsx`).** - `SubmitKeyDeps` reçoit un seul lanceur, `startRevocationCheck(wasRevoked)`. `performSubmitKey` l'appelle après `VALIDATE_DONE` avec `info.revoked`, sans l'attendre. Un enregistrement refusé ne lance rien, et un échec de `getEdition` n'empêche pas le contrôle (la clé est enregistrée). - Nouvelle fabrique exportée `revocationCheckLauncher(deps)` : elle rend un lanceur qui appelle `performRevocationCheck` et rend la main aussitôt. Le provider la construit une fois avec `checkLicenseRevocation`, `refresh`, `logInfo` et `logWarn`, puis la passe à la saisie et à `createRevocationCheckStarter`, dont le corps ne change pas. - Docstrings de l'en-tête, de `performSubmitKey` et du starter réécrits. **Rust (`src-tauri/src/commands/license_commands.rs`).** - `static REVOCATION_CHECK_TURN: tauri::async_runtime::Mutex<()>` : réexport de `tokio::sync::Mutex` par Tauri 2.10.2, donc ni `Cargo.toml` ni `Cargo.lock` ne bougent (`cargo metadata --locked` passe). - `check_revocation_in_turn(turn, dir, api_base, decoding_key, now)` prend le tour avant de lire l'état et le garde jusqu'au verdict, requête comprise, puis appelle `check_revocation_in`, qui ne change pas. La commande `check_license_revocation` passe `&REVOCATION_CHECK_TURN` et `unix_now`. - L'heure est lue une fois le tour obtenu (`now: impl FnOnce() -> i64`). Lue avant l'attente, elle pouvait dater dans le futur la tentative d'un contrôle passé entre-temps, et `revocation_check_due` tient une telle date pour due : seconde requête. **Docs.** Amendement #365 de l'ADR 0017 (point (5), la phrase « une app laissée ouverte n'est pas recontrôlée », la ligne des Liens et la liste des issues) ; guide (Astuces de Paramètres, « Vérification de la licence ») ; `docs.settings.tips` et `docs.editions.tips` en FR et en EN ; `docs/architecture.md` (`licenseService.ts`, `LicenseContext`, la commande, le paragraphe Révocation) ; commentaire du budget de requêtes de `useLicenseMachines.ts` ; `CHANGELOG.md` / `CHANGELOG.fr.md` sous `[Unreleased]`, section Fixed / Corrigé ajoutée après Changed / Modifié. ## Écarts et précisions par rapport au corps de l'issue 1. **Le tour se passe en paramètre.** L'issue décrit un verrou statique qui enveloppe le contrôle dans une fonction testable. Le `static` existe, mais `check_revocation_in_turn` le reçoit en paramètre : la commande passe le `static`, et chaque test crée son propre tour. Dans une première version, la fonction prenait le `static` elle-même. Les tests partageaient alors le verrou avec ceux d'autres fils, et le test « clé saisie pendant le contrôle » a échoué une fois sur deux lancé avec ses voisins. Contrepartie : la ligne de la commande qui passe le `static` n'est gardée par aucun test, comme l'enregistrement de la commande dans `lib.rs`. 2. **Ce que le test Rust demandé prouve.** L'issue demande « deux appels concurrents, un seul mock `expect(1)`, une seule requête, le second rend `not_due`, prouvé rouge sans le verrou ». Le test fait tout cela, mais sans verrou il rougit sur `revoked` (le second lit `false` au lieu de `true`), pas sur le nombre de requêtes. Le double envoi exige deux fils entre la lecture de l'état et l'écriture de la tentative, une fenêtre sans `await`. Sur le fil unique d'un `#[tokio::test]`, le second contrôle lit déjà la tentative du premier, écrite avant l'appel depuis #365. Le reproduire demanderait un `yield` sous `#[cfg(test)]` dans `check_revocation_in`, que l'issue veut inchangée. Le test prouve donc que le tour couvre tout le contrôle, verdict compris. Comme ce tour est pris avant la lecture de l'état, aucun autre fil ne peut entrer dans la fenêtre pendant un contrôle. 3. **Deux tests Rust de plus.** Le premier couvre la course clé A / clé B des Review caveats : le contrôle d'une clé remboursée est en vol quand la nouvelle clé est saisie. Sans le tour, le verdict de la première écrase la tentative de la seconde, qui serait recontrôlée dans les 24 h. Ce test rougit sans aucun point de suspension ajouté. Le second couvre l'heure lue après l'attente (voir plus haut). 4. **Docs touchées au-delà de la liste.** `docs.settings.tips` est la jumelle intégrée de la ligne L514 (aujourd'hui L588) du guide, que l'issue cite. Il faut aussi corriger la ligne `LicenseContext` d'`architecture.md` et le commentaire de `useLicenseMachines.ts`, qui disaient « une fois par démarrage ». Les numéros de ligne de l'issue datent de `0e4fef5` : la correspondance a été vérifiée avec `git show 0e4fef5:<fichier>`. 5. **Le test du starter L332 (« a key entered later waits for the next start ») devient deux tests.** Le premier vérifie qu'une clé saisie après le premier état `ready` est contrôlée une fois, par la saisie. Le second vérifie qu'une clé saisie avant ce premier état `ready` reçoit deux lancements, le démarrage et la saisie, et renvoie au test Rust qui les ramène à une requête. ## Tests **Rust (3, `license_commands.rs`)** : `two_checks_side_by_side_send_one_request`, `the_check_of_a_key_entered_meanwhile_waits_for_the_verdict_in_flight` (mock unique, réponse choisie selon la clé envoyée, `expect(2)`, puis un « démarrage suivant » qui n'envoie rien), `a_check_reads_the_time_once_its_turn_has_come`. Chacun lance deux branches avec `tokio::join!(biased; …)`. La première branche est donc interrogée la première à chaque tour, et c'est elle qui démarre en premier ; sans `biased`, `join!` alterne son point de départ après le premier tour (second commit, `0b94ccf`). Tout `license_commands` a été passé 25 fois avant puis 25 fois après ce commit, sans aucun échec. **Front (`LicenseContext.test.ts`)** : dans `performSubmitKey`, deux tests (le lancement suit `VALIDATE_DONE`, une seule fois ; le lancement porte l'état `revoked` venu de Rust) et deux assertions ajoutées (aucun lancement après un refus ; lancement même si `getEdition` échoue). Le nouveau `describe` « a key entered in session is checked right away » câble l'entrée au vrai lanceur. La saisie répond avant le contrôle (course explicite contre `setTimeout(0)`), puis une clé révoquée passe en Gratuite après rechargement. Une commande rejetée ou un serveur injoignable gardent l'édition de la clé et écrivent une ligne au Journal. Enfin, le test du starter est réécrit (point 5). **Mutations**, une à la fois, restauration par `git checkout --` depuis l'index : | # | Mutation | Résultat | |---|---|---| | R1 | verrou retiré (`let _ = turn;`) | 3 tests Rust rouges : second `NotDue/false` ; état final sur la clé remboursée ; `Unreachable` (une requête partie) | | R2 | heure lue avant le verrou | `a_check_reads_the_time_once_its_turn_has_come` rouge (`Unreachable`) ; les deux autres verts, attendu | | R3 | garde relâchée aussitôt (`let _ = turn.lock().await;`) | `two_checks_side_by_side…` et `the_check_of_a_key_entered_meanwhile…` rouges ; le test de l'heure vert, attendu | | F1 | lancement retiré de `performSubmitKey` | 8 tests rouges (les 3 qui lancent dans `performSubmitKey`, les 3 de l'entrée câblée, les 2 du starter composés avec l'entrée) | | F2 | lancement avant `VALIDATE_DONE` | 2 rouges (ordre des événements) | | F3 | lancement aussi après un refus | 1 rouge (« …nor launching the check ») | | F4 | `startRevocationCheck(false)` au lieu de `info.revoked` | 2 rouges | | F5 | la saisie attend le contrôle (`await` et un lanceur qui rend la promesse) | 1 rouge, en 13 ms : `Expected "entry"`, `Received "nothing yet"`. Une première version ne rougissait que par le délai de 5 s de vitest, d'où la course explicite | | F6 | le lanceur inverse `wasRevoked` | 2 rouges (pas de rechargement quand il en faut un ; rechargement indu) | ## Validation (worktree, tête `0b94ccf`) - `npm run build` (tsc + vite) : vert. - `npm test` : **1446** verts (1440 avant, 6 de plus). - `cargo test` : **195** verts (192 avant, 3 de plus). - `cargo check` sans avertissement, `cargo metadata --locked` accepté, `Cargo.toml` et `Cargo.lock` intacts. ## Pour reviewer - **À lire d'abord** : `check_revocation_in_turn` et `REVOCATION_CHECK_TURN` (`license_commands.rs`), puis les trois tests qui suivent `the_check_sends_the_product_and_the_key_and_nothing_else`. Côté front : `performSubmitKey`, `revocationCheckLauncher` et le câblage du provider. - **Non gardé par les tests** : - le câblage du provider : `submitKey` qui passe `launchRevocationCheck`, et le starter construit sur le même lanceur, car aucun effet ne tourne sous `renderToStaticMarkup` ; - la commande `check_license_revocation`, qui passe `&REVOCATION_CHECK_TURN` et `unix_now`, faute d'`AppHandle` ; - le double envoi réel sur deux fils (voir l'écart 2) ; - la réponse réelle du serveur. - **Vérification manuelle suggérée** : saisir la clé révoquée du contrôle d'achat du 2026-09-30 dans une app lancée. Attendu : Base une à deux secondes, puis Gratuite, et dans le Journal « License check: the license server says the key was revoked ». Ressaisie dans les 24 h : « last attempt less than 24 h ago, nothing sent ». Hors ligne : la saisie répond tout de suite, et le Journal dit « could not be reached ». - **Effets connus, non bloquants** : - une clé saisie avant le premier état `ready` déclenche deux rechargements du contexte, car le second contrôle rend `not_due` avec `revoked: true` ; c'est sans gravité ; - le contrôle à la saisie consomme une des 5 requêtes par minute et par IP du serveur de licences : la saisie suivie du parcours « limite de machines » fait 5 requêtes. Avec, dans la même minute, le contrôle au démarrage d'une autre clé, la dernière peut recevoir un 429, que l'UI nomme. ## Après merge (Max) : texte pour maximus/la-compagnie-maximus#184 > Précision pour la politique de confidentialité (simpl-resultat#373, prochaine version de l'app) : la vérification de la licence ne part plus seulement au démarrage. Elle part au démarrage et à la saisie d'une clé, au plus une fois par 24 h et par clé, et n'envoie toujours que le produit et la clé (`POST /v1/licenses/verify`), sans identifiant machine, nom d'ordinateur ni donnée d'usage. Rien ne part sans clé enregistrée, et la vérification continue après une révocation tant que la clé reste enregistrée. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-10-04 20:27:22 +00:00
fix(license): check a revoked key as soon as it is entered
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
PR Check — Rust / rust (pull_request) Successful in 3m25s
ab78aa9ddc
A license key entered during a session was only checked against the
license server at the next start: a revoked key kept its edition until
the app restarted, or until a machine activation was refused (#365
checked at start only).

Front: performSubmitKey launches the revocation check after
VALIDATE_DONE, without waiting for it, and never after a refused key.
SubmitKeyDeps gets a single launcher, startRevocationCheck(wasRevoked),
built once by the provider with revocationCheckLauncher and shared with
the start check, whose starter is unchanged.

Rust: the command runs the checks one at a time. A static
tauri::async_runtime::Mutex (REVOCATION_CHECK_TURN) is held by
check_revocation_in_turn over the whole check, request and verdict
included, and the time is read once the turn has come. A key entered
before the first ready state, checked by the entry and by the start,
sends one request; the check of a key entered while another one is in
flight waits for its verdict instead of overwriting license.check.
check_revocation_in is unchanged, and so are Cargo.toml and Cargo.lock.

Docs: ADR 0017 (amendment #365, point 5), user guide, in-app docs
(docs.settings.tips, docs.editions.tips), architecture, and both
changelogs under [Unreleased] (Fixed / Corrigé).

Resolves #373

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maximus added the
autopilot:pending-human
label 2026-10-04 20:27:25 +00:00
maximus added 1 commit 2026-10-04 20:33:08 +00:00
test(license): poll the one-check-at-a-time tests in a fixed order
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 3m19s
0b94ccf006
The three tests of #373 run two branches with tokio::join! and rely on
the first branch being polled first: the check that starts first, or the
check that waits for a held turn before the time moves. tokio's join!
already starts with the first branch on its first poll, but rotates on
the next ones; `biased;` keeps the declared order on every poll and says
so in the code. The three mutations (no turn, time read before the turn,
guard dropped at once) were run again: same red tests.

Resolves #373

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Auto-revue du worker (autopilot) : APPROVE

Ce verdict vient du worker qui a écrit la PR : ce n'est pas une revue indépendante, et le label status:approved n'est pas posé. La revue porte sur le diff complet de la PR (11 fichiers, têtes ab78aa9 puis 0b94ccf), relu sous quatre angles : sécurité, exactitude, qualité et données.

Résumé. La saisie d'une clé lance le contrôle de révocation après VALIDATE_DONE, sans l'attendre, et jamais après un refus. Un seul lanceur, revocationCheckLauncher, sert au démarrage et à la saisie. Côté Rust, un tour statique (REVOCATION_CHECK_TURN) couvre tout le contrôle, de la lecture de l'état jusqu'au verdict, et l'heure n'est lue qu'une fois le tour obtenu. check_revocation_in ne change pas, ni Cargo.toml ni Cargo.lock. Les docs suivent toutes : ADR 0017, guide, docs intégrées FR/EN, architecture et les deux changelogs.

  • Sécurité : aucun secret ni aucune donnée personnelle dans le diff (lignes ajoutées scannées). La requête reste celle de #365 : le produit et la clé. Le nouveau moment d'envoi, à la saisie d'une clé, est déclaré dans le guide, l'ADR et le CHANGELOG, et le texte pour la-compagnie-maximus#184 figure dans la description de la PR.
  • Exactitude : les critères d'acceptation sont couverts par des tests, sauf le câblage du provider (voir 1). Le critère « même clé ressaisie dans les 24 h n'envoie rien » reste tenu par les tests existants : store_license_in ne touche pas license.check (reading_and_storing_a_key_report_its_revoked_state), et une tentative de moins de 24 h donne not_due (nothing_is_sent_without_a_valid_stored_key_nor_before_24_hours). Toutes les mutations rougissent comme annoncé (tableau de la description) : R1 à R3 ont été rejouées après 0b94ccf.
  • Qualité : aucun code mort ni reste de débogage. Le journal passe par logInfo et logWarn, aucun texte d'interface n'est écrit en dur, et les clés i18n sont modifiées dans les deux langues.
  • Données : aucune migration SQL touchée (v1→v17 inchangées).

Points relevés (aucun bloquant)

  1. src/contexts/LicenseContext.tsx:307 et :318 : le câblage du provider n'est gardé par aucun test. Il s'agit de submitKey, qui passe launchRevocationCheck, et du starter, construit sur le même lanceur. La cause : aucun effet ne tourne sous renderToStaticMarkup. À couvrir par la vérification manuelle décrite dans la PR, avec la clé révoquée du contrôle d'achat du 2026-09-30.
  2. src-tauri/src/commands/license_commands.rs:1204 : la commande passe &REVOCATION_CHECK_TURN et unix_now sans test, faute d'AppHandle. C'est la contrepartie du tour reçu en paramètre (écart 1 de la PR). Une mutation « un verrou neuf à chaque appel » à cet endroit passerait.
  3. src/contexts/LicenseContext.tsx:224 : une clé saisie avant le premier état ready reçoit deux contrôles. Le second rend not_due avec revoked: true, lu après le verdict du premier, et recharge donc le contexte une seconde fois. C'est sans gravité : la même lecture est faite deux fois, avec un bref passage en loading.
  4. src/hooks/useLicenseMachines.ts:25 : le contrôle à la saisie consomme une requête du budget de 5 par minute et par IP. Saisie puis parcours « limite de machines » font 5 requêtes. Avec, dans la même minute, le contrôle au démarrage d'une autre clé, la dernière requête peut recevoir un 429, que l'UI nomme.

Suggestions (facultatives)

  • Le nom du test front « launches twice …, which Rust sends once » annonce une propriété que seul le test Rust vérifie. Le commentaire du test y renvoie ; on pourrait aussi l'écrire entre parenthèses dans le nom.
  • RevocationCheck.revoked décrit la clé que le contrôle a envoyée, qui n'est plus forcément la clé enregistrée quand une autre clé est saisie pendant le contrôle. Au pire, le front recharge une fois de trop. Le comportement est antérieur à cette PR et ne demande rien ici.
## Auto-revue du worker (autopilot) : APPROVE Ce verdict vient du worker qui a écrit la PR : ce n'est pas une revue indépendante, et le label `status:approved` n'est pas posé. La revue porte sur le diff complet de la PR (11 fichiers, têtes `ab78aa9` puis `0b94ccf`), relu sous quatre angles : sécurité, exactitude, qualité et données. **Résumé.** La saisie d'une clé lance le contrôle de révocation après `VALIDATE_DONE`, sans l'attendre, et jamais après un refus. Un seul lanceur, `revocationCheckLauncher`, sert au démarrage et à la saisie. Côté Rust, un tour statique (`REVOCATION_CHECK_TURN`) couvre tout le contrôle, de la lecture de l'état jusqu'au verdict, et l'heure n'est lue qu'une fois le tour obtenu. `check_revocation_in` ne change pas, ni `Cargo.toml` ni `Cargo.lock`. Les docs suivent toutes : ADR 0017, guide, docs intégrées FR/EN, architecture et les deux changelogs. - **Sécurité** : aucun secret ni aucune donnée personnelle dans le diff (lignes ajoutées scannées). La requête reste celle de #365 : le produit et la clé. Le nouveau moment d'envoi, à la saisie d'une clé, est déclaré dans le guide, l'ADR et le CHANGELOG, et le texte pour la-compagnie-maximus#184 figure dans la description de la PR. - **Exactitude** : les critères d'acceptation sont couverts par des tests, sauf le câblage du provider (voir 1). Le critère « même clé ressaisie dans les 24 h n'envoie rien » reste tenu par les tests existants : `store_license_in` ne touche pas `license.check` (`reading_and_storing_a_key_report_its_revoked_state`), et une tentative de moins de 24 h donne `not_due` (`nothing_is_sent_without_a_valid_stored_key_nor_before_24_hours`). Toutes les mutations rougissent comme annoncé (tableau de la description) : R1 à R3 ont été rejouées après `0b94ccf`. - **Qualité** : aucun code mort ni reste de débogage. Le journal passe par `logInfo` et `logWarn`, aucun texte d'interface n'est écrit en dur, et les clés i18n sont modifiées dans les deux langues. - **Données** : aucune migration SQL touchée (v1→v17 inchangées). ### Points relevés (aucun bloquant) 1. `src/contexts/LicenseContext.tsx:307` et `:318` : le câblage du provider n'est gardé par aucun test. Il s'agit de `submitKey`, qui passe `launchRevocationCheck`, et du starter, construit sur le même lanceur. La cause : aucun effet ne tourne sous `renderToStaticMarkup`. À couvrir par la vérification manuelle décrite dans la PR, avec la clé révoquée du contrôle d'achat du 2026-09-30. 2. `src-tauri/src/commands/license_commands.rs:1204` : la commande passe `&REVOCATION_CHECK_TURN` et `unix_now` sans test, faute d'`AppHandle`. C'est la contrepartie du tour reçu en paramètre (écart 1 de la PR). Une mutation « un verrou neuf à chaque appel » à cet endroit passerait. 3. `src/contexts/LicenseContext.tsx:224` : une clé saisie avant le premier état `ready` reçoit deux contrôles. Le second rend `not_due` avec `revoked: true`, lu après le verdict du premier, et recharge donc le contexte une seconde fois. C'est sans gravité : la même lecture est faite deux fois, avec un bref passage en `loading`. 4. `src/hooks/useLicenseMachines.ts:25` : le contrôle à la saisie consomme une requête du budget de 5 par minute et par IP. Saisie puis parcours « limite de machines » font 5 requêtes. Avec, dans la même minute, le contrôle au démarrage d'une autre clé, la dernière requête peut recevoir un 429, que l'UI nomme. ### Suggestions (facultatives) - Le nom du test front « launches twice …, which Rust sends once » annonce une propriété que seul le test Rust vérifie. Le commentaire du test y renvoie ; on pourrait aussi l'écrire entre parenthèses dans le nom. - `RevocationCheck.revoked` décrit la clé que le contrôle a envoyée, qui n'est plus forcément la clé enregistrée quand une autre clé est saisie pendant le contrôle. Au pire, le front recharge une fois de trop. Le comportement est antérieur à cette PR et ne demande rien ici.
Author
Owner

Revue adversariale — PR #386 (#373)

Verdict : APPROVE

Résumé. Le correctif fait ce que demande l'issue : la saisie lance le contrôle après VALIDATE_DONE sans l'attendre, jamais après un refus, et Rust ne laisse passer qu'un contrôle à la fois, tour pris avant la lecture de l'état et tenu jusqu'au verdict, heure lue une fois le tour venu. Mes mutations confirment celles de l'auteur ; une seule survit, sur un maillon que la PR rend testable sans l'épingler (suggestion 1).

Ce que j'ai rejoué

Copie jetable hors dépôt à 0b94ccf (clone local, npm ci, cache cargo copié), supprimée en fin de revue.

  • npm run build vert ; npm test : 1446 verts ; cargo test : 195 verts ; cargo check sans avertissement ; cargo metadata --locked accepté, Cargo.toml et Cargo.lock intacts.
  • CI de la tête lue dans les logs : run 479 (front, HEAD is now at 0b94ccf, 1446 tests) et run 480 (Rust, 195 tests dont les trois nouveaux, cache restauré en full match).
  • license_commands lancé 15 fois de suite : aucun échec.

Mutations du relecteur, une à la fois, restauration par git checkout -- dans la copie, arbre propre vérifié après chacune :

# Mutation Résultat
RM-1 tour retiré (let _ = turn;) 3 rouges : second contrôle NotDue/false au lieu de NotDue/true ; état final sur la clé remboursée ; Unreachable au lieu de NotDue
RM-2 heure lue avant le tour 1 rouge (a_check_reads_the_time_once_its_turn_has_come)
RM-3 tour rendu avant la requête (drop(turn.lock().await)) 2 rouges (two_checks_side_by_side_send_one_request, the_check_of_a_key_entered_meanwhile_waits_for_the_verdict_in_flight)
FM-A le lanceur passe false au lieu de wasRevoked 0 rouge, survit (suggestion 1)
FM-B le lanceur passe true 2 rouges
FM-C la saisie saute le contrôle d'une clé déjà connue révoquée 2 rouges
FM-D lancement seulement si getEdition a réussi 1 rouge
FM-E lanceur vide 3 rouges
FM-F double lancement à la saisie 7 rouges

Sonde à deux fils (test jetable, runtime multi_thread à 4 workers, deux contrôles lâchés par une barrière, 200 rondes) : aucun double envoi, avec ou sans tour ; dans cette sonde, la fenêtre entre la lecture de l'état et l'écriture de la tentative ne s'est pas ouverte une seule fois. Cela confirme l'écart 2 : les tests ne peuvent garder que le verdict vu par le second contrôle et l'écrasement clé A / clé B, et RM-1 et RM-3 montrent que ces deux effets sont bien tenus.

Vérifié par lecture

  • check_revocation_in_turn prend le tour avant toute lecture de license.key et de license.check, et le garde jusqu'au retour de check_revocation_in, requête et verdict compris ; check_revocation_in est inchangée. La commande passe &REVOCATION_CHECK_TURN et unix_now.
  • performSubmitKey : lancement après VALIDATE_DONE, non attendu, avec info.revoked ; rien après un refus ; lancement maintenu quand getEdition échoue.
  • Provider : un seul lanceur, construit une fois (useState), partagé par la saisie et le starter ; refresh (useCallback sans dépendance) et submitKey gardent leur identité.
  • Fermeture : la timeline de #373 porte un pull_ref closes venant de #386, et le corps de la PR ne contient que Fixes #373 comme mot-clé de fermeture.
  • Docs : ADR 0017, guide, docs.settings.tips et docs.editions.tips en FR et en EN, architecture.md et CHANGELOG FR/EN sous [Unreleased] disent « au démarrage et à la saisie d'une clé ». Plus aucune mention d'un contrôle au seul démarrage, hors de l'entrée 0.16.0 du CHANGELOG, qui est historique.
  • Aucune migration, aucune dépendance ajoutée, git diff --check propre, commits conventionnels, ni skip ni only.

Les trous déclarés par la PR sont exacts : câblage du provider, static passé par la commande, double envoi sur deux fils, réponse réelle du serveur. La vérification manuelle proposée couvre le premier et le dernier.

Suggestions (non bloquantes)

  1. Épingler le relais de wasRevoked par revocationCheckLauncher. FM-A passe toute la suite : chaque test du lanceur part d'une clé non révoquée. Effet en production : une révocation levée côté serveur, le chemin que la décision du 2026-10-03 garde ouvert, ne rechargerait plus le contexte avant le démarrage suivant, et une clé connue révoquée rechargerait le contexte à chaque contrôle. Le maillon vivait avant dans le provider, sans test : le trou n'est pas nouveau, mais revocationCheckLauncher le rend testable. Ce test, essayé dans la copie, est vert sur la tête et rouge sous FM-A :
it("reloads when a key known as revoked is confirmed valid again", async () => {
  const deps: RevocationCheckDeps = {
    checkLicenseRevocation: vi.fn(
      async (): Promise<RevocationCheck> => ({ outcome: "valid", revoked: false }),
    ),
    refresh: vi.fn(async () => {}),
    logInfo: vi.fn(),
    logWarn: vi.fn(),
  };
  revocationCheckLauncher(deps)(true);
  await new Promise<void>((resolve) => setTimeout(resolve, 0));
  expect(deps.refresh).toHaveBeenCalledTimes(1);
});
  1. Commentaire du budget dans useLicenseMachines.ts. « with it, the machine-limit path reaches the 5 requests of the first minute » vaut pour un seul contrôle. Le corps de la PR nomme le cas de deux contrôles dans la même minute (démarrage pour une clé précédente, puis saisie d'une nouvelle) : le parcours atteint alors 6 requêtes et la dernière peut recevoir un 429. C'est dans ce commentaire que le prochain lecteur du budget cherchera cette limite.

Actions faites

  • Commentaire posté ; label de #373 passé de status:review à status:approved.
  • Copie jetable /tmp/pr386-review, scripts de mutation /tmp/pr386-mut et logs CI /tmp/pr386-run479.log, /tmp/pr386-run480.log supprimés.
## Revue adversariale — PR #386 (#373) **Verdict : APPROVE** **Résumé.** Le correctif fait ce que demande l'issue : la saisie lance le contrôle après `VALIDATE_DONE` sans l'attendre, jamais après un refus, et Rust ne laisse passer qu'un contrôle à la fois, tour pris avant la lecture de l'état et tenu jusqu'au verdict, heure lue une fois le tour venu. Mes mutations confirment celles de l'auteur ; une seule survit, sur un maillon que la PR rend testable sans l'épingler (suggestion 1). ### Ce que j'ai rejoué Copie jetable hors dépôt à `0b94ccf` (clone local, `npm ci`, cache cargo copié), supprimée en fin de revue. - `npm run build` vert ; `npm test` : 1446 verts ; `cargo test` : 195 verts ; `cargo check` sans avertissement ; `cargo metadata --locked` accepté, `Cargo.toml` et `Cargo.lock` intacts. - CI de la tête lue dans les logs : run 479 (front, `HEAD is now at 0b94ccf`, 1446 tests) et run 480 (Rust, 195 tests dont les trois nouveaux, cache restauré en `full match`). - `license_commands` lancé 15 fois de suite : aucun échec. **Mutations du relecteur**, une à la fois, restauration par `git checkout --` dans la copie, arbre propre vérifié après chacune : | # | Mutation | Résultat | |---|---|---| | RM-1 | tour retiré (`let _ = turn;`) | 3 rouges : second contrôle `NotDue/false` au lieu de `NotDue/true` ; état final sur la clé remboursée ; `Unreachable` au lieu de `NotDue` | | RM-2 | heure lue avant le tour | 1 rouge (`a_check_reads_the_time_once_its_turn_has_come`) | | RM-3 | tour rendu avant la requête (`drop(turn.lock().await)`) | 2 rouges (`two_checks_side_by_side_send_one_request`, `the_check_of_a_key_entered_meanwhile_waits_for_the_verdict_in_flight`) | | FM-A | le lanceur passe `false` au lieu de `wasRevoked` | **0 rouge, survit** (suggestion 1) | | FM-B | le lanceur passe `true` | 2 rouges | | FM-C | la saisie saute le contrôle d'une clé déjà connue révoquée | 2 rouges | | FM-D | lancement seulement si `getEdition` a réussi | 1 rouge | | FM-E | lanceur vide | 3 rouges | | FM-F | double lancement à la saisie | 7 rouges | **Sonde à deux fils** (test jetable, runtime `multi_thread` à 4 workers, deux contrôles lâchés par une barrière, 200 rondes) : aucun double envoi, avec ou sans tour ; dans cette sonde, la fenêtre entre la lecture de l'état et l'écriture de la tentative ne s'est pas ouverte une seule fois. Cela confirme l'écart 2 : les tests ne peuvent garder que le verdict vu par le second contrôle et l'écrasement clé A / clé B, et RM-1 et RM-3 montrent que ces deux effets sont bien tenus. ### Vérifié par lecture - `check_revocation_in_turn` prend le tour avant toute lecture de `license.key` et de `license.check`, et le garde jusqu'au retour de `check_revocation_in`, requête et verdict compris ; `check_revocation_in` est inchangée. La commande passe `&REVOCATION_CHECK_TURN` et `unix_now`. - `performSubmitKey` : lancement après `VALIDATE_DONE`, non attendu, avec `info.revoked` ; rien après un refus ; lancement maintenu quand `getEdition` échoue. - Provider : un seul lanceur, construit une fois (`useState`), partagé par la saisie et le starter ; `refresh` (`useCallback` sans dépendance) et `submitKey` gardent leur identité. - Fermeture : la timeline de #373 porte un `pull_ref` `closes` venant de #386, et le corps de la PR ne contient que `Fixes #373` comme mot-clé de fermeture. - Docs : ADR 0017, guide, `docs.settings.tips` et `docs.editions.tips` en FR et en EN, `architecture.md` et CHANGELOG FR/EN sous `[Unreleased]` disent « au démarrage et à la saisie d'une clé ». Plus aucune mention d'un contrôle au seul démarrage, hors de l'entrée 0.16.0 du CHANGELOG, qui est historique. - Aucune migration, aucune dépendance ajoutée, `git diff --check` propre, commits conventionnels, ni `skip` ni `only`. Les trous déclarés par la PR sont exacts : câblage du provider, `static` passé par la commande, double envoi sur deux fils, réponse réelle du serveur. La vérification manuelle proposée couvre le premier et le dernier. ### Suggestions (non bloquantes) 1. **Épingler le relais de `wasRevoked` par `revocationCheckLauncher`.** FM-A passe toute la suite : chaque test du lanceur part d'une clé non révoquée. Effet en production : une révocation levée côté serveur, le chemin que la décision du 2026-10-03 garde ouvert, ne rechargerait plus le contexte avant le démarrage suivant, et une clé connue révoquée rechargerait le contexte à chaque contrôle. Le maillon vivait avant dans le provider, sans test : le trou n'est pas nouveau, mais `revocationCheckLauncher` le rend testable. Ce test, essayé dans la copie, est vert sur la tête et rouge sous FM-A : ```ts it("reloads when a key known as revoked is confirmed valid again", async () => { const deps: RevocationCheckDeps = { checkLicenseRevocation: vi.fn( async (): Promise<RevocationCheck> => ({ outcome: "valid", revoked: false }), ), refresh: vi.fn(async () => {}), logInfo: vi.fn(), logWarn: vi.fn(), }; revocationCheckLauncher(deps)(true); await new Promise<void>((resolve) => setTimeout(resolve, 0)); expect(deps.refresh).toHaveBeenCalledTimes(1); }); ``` 2. **Commentaire du budget dans `useLicenseMachines.ts`.** « with it, the machine-limit path reaches the 5 requests of the first minute » vaut pour un seul contrôle. Le corps de la PR nomme le cas de deux contrôles dans la même minute (démarrage pour une clé précédente, puis saisie d'une nouvelle) : le parcours atteint alors 6 requêtes et la dernière peut recevoir un 429. C'est dans ce commentaire que le prochain lecteur du budget cherchera cette limite. ### Actions faites - Commentaire posté ; label de #373 passé de `status:review` à `status:approved`. - Copie jetable `/tmp/pr386-review`, scripts de mutation `/tmp/pr386-mut` et logs CI `/tmp/pr386-run479.log`, `/tmp/pr386-run480.log` supprimés.
maximus merged commit 0b94ccf006 into main 2026-10-05 23:22:41 +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#386
No description provided.