fix: licence — chemins /v1 + product, erreurs serveur nommées FR/EN, URL d'achat localisée #270
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#270
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Les trois commandes réseau de licence appellent encore les chemins historiques
/licenses/*, ce qui coûte un 308 à chaque appel et laisse le serveur deviner le produit d'après le préfixeSR-. Elles lisent aussi mal l'enveloppe d'erreur/v1: tout refus s'affiche « Activation failed », et une clé révoquée apparaît comme « Aucune machine activée ». Enfin, le bouton « Acheter » mène à un 404.Cette issue corrige le protocole et établit le contrat des codes d'erreur de licence, que #347 étendra aux erreurs locales. Elle rend aussi testable l'état de la liste des machines et localise l'URL d'achat.
Supersedes #53 (volet client). Le fallback serveur maximus/maximus-api#48 (308
/licenses/*→/v1/licenses/*) reste en place pour les binaires déjà distribués.Contexte vérifié (2026-09-24)
Contrat serveur maximus-api, déployé en prod. Vérifié par des sondes réelles et par la lecture de
src/routes/licenses.ts,src/services/licenseService.tsetsrc/middleware/rateLimit.ts.POST /v1/licenses/activate:{activation_token}license_not_foundlicense_revoked, 403license_expiredmachine_limit_reached, avec un champmachinesà la racine (ignoré, décision D2)invalid_input(+details)rate_limit_exceededPOST /v1/licenses/deactivate: 200{ok:true}· 404license_not_found· 404no_active_activation· 400 · 429.POST /v1/licenses/verify: toujours 200, hors 400/429/5xx. Réponse{valid:false, reason:"not_found"|"revoked"|"expired"}ou{valid:true, edition, email, machine_limit, active_machines, machines:[{machine_id, machine_name, activated_at, last_seen_at}], expires_at}. Sonde du 2026-09-24 avec une clé bidon :200 {"valid":false,"reason":"not_found"}.{ "error": { "code", "message" } }. Pour 429,retry_after(en secondes) est danserror.license_not_found,no_active_activation), pas une route absente. Ne jamais décider sur le statut HTTP seul : lireerror.code.productest optionnel côté serveur (maximus-api/src/types/index.ts:86-108), seule valeur admisesimpl-resultat.Client (
src-tauri/src/commands/license_commands.rs) :activate_machine(l.401),deactivate_machine(l.455) etlist_activated_machines(l.500) : chemins sans/v1, payloads sansproduct.resp_body["error"].as_str()(l.426-431, l.469-475), alors que/v1renvoie un objet. Résultat : toujours « Activation failed » ou « Deactivation failed ».list_activated_machinesrenvoie « Cannot verify license » sur un non-2xx (l.512-514). Mais un 200{valid:false}passe en succès :machinesest absent, doncOk([])(l.521-529), et la carte affiche « Aucune machine activée » pour une clé révoquée.FetchPriceError(src-tauri/src/commands/balance_commands.rs:52-84), avec#[serde(tag = "code", rename_all = "snake_case")]et une sérialisation JSON dans leErrparprice_error_to_string. Côté TS :parseRustError+PRICE_ERROR_I18N_MAP(src/services/balance.service.ts:2612-2680), et le wrapper renvoie{ ok: false, error: parseRustError(e) }(l.2745-2751).fetch_price_inner(balance_commands.rs:122) et ses testsmockito::Server::new_async()/#[tokio::test](balance_commands.rs:366+). mockito 1.6 est en dev-dependency (Cargo.toml:68) ; une requête non appariée reçoit un 501.Front :
src/components/settings/LicenseCard.tsxporte 8useState.loadMachines()commence parsetMachineError(null)(l.44).handleActivate(l.59-70) ethandleDeactivate(l.72-83, qui relanceloadMachines()donc verify) n'appellent pasrefresh()duLicenseContext.LicenseCard.tsx:15:PURCHASE_URL = https://lacompagniemaximus.com/simpl-resultat, qui mène à un 307 vers/fr/simpl-resultat, puis à un 404 (sonde du 2026-09-24)./fr/produits/simpl-resultatet/en/produits/simpl-resultatrépondent 200. Le bouton n'est visible qu'en Gratuite (l.159-168).i18n.languagevautfrouen(src/i18n/config.ts:lng: "fr",fallbackLng: "en").licenseReducer(src/contexts/LicenseContext.tsx:71) etfeedbackReducer(src/hooks/useFeedback.ts:27). Le projet n'a ni jsdom ni Testing Library : les tests vitest portent sur des fonctions pures.Tâches
license_commands.rs: chemins/v1/licenses/activate,/v1/licenses/deactivateet/v1/licenses/verify;"product": "simpl-resultat"dans les trois payloads.Enum
LicenseErrordanslicense_commands.rs, avec#[derive(Debug, Serialize, Clone)]et#[serde(tag = "code", rename_all = "snake_case")], pluslicense_error_to_string(JSON dans leErr), sur le modèle deFetchPriceError. Une variante par sens pour l'utilisateur :LicenseNotFound,LicenseRevoked,LicenseExpired,MachineLimitReached,NoActiveActivation,InvalidInput: lus danserror.codede l'enveloppe. Pour verify,valid:false+reason(not_found,revoked,expired) donneLicenseNotFound,LicenseRevokedouLicenseExpired.RateLimit { retry_after_s: u64 }: 429 (error.retry_after), sérialisérate_limitcomme pour les cours.Network: envoi impossible ou délai dépassé.InvalidResponse: corps illisible,activation_tokenabsent, ou jeton refusé parstore_activation_token.Server { status: u16 }: tout autre non-2xx, ou un code inconnu.NoLicenseStored: aucune clé enregistrée.Storage: lecture ou écriture du fichier de licence.Internal:machine_idillisible, ou clé publique embarquée invalide.Les commandes Tauri gardent leur signature (
Result<_, String>).Extraire, sans
AppHandleni accès disque et sur le modèle defetch_price_inner:activate_inner(api_base, license_key, machine_id, machine_name) -> Result<String, LicenseError>(renvoie le jeton) ;deactivate_inner(api_base, license_key, machine_id) -> Result<(), LicenseError>;verify_inner(api_base, license_key) -> Result<Vec<MachineInfo>, LicenseError>.Tests Rust (mockito) :
/v1/licenses/activateet le corps contient"product":"simpl-resultat"(Matcher::PartialJsonString) ;license_not_found→{"code":"license_not_found"};license_revoked;machine_limit_reached, avec un corps qui portemachinesà la racine ;"retry_after": 42→{"code":"rate_limit","retry_after_s":42};server;valid:false, reason:"revoked"→license_revoked;valid:true→ machines lues ;no_active_activation./v1/.... Toute requête legacy y reçoit le 501 de mockito et fait échouer le test.src/services/licenseService.ts:LicenseErrorCode(union des codes) et typeLicenseError;parseLicenseError(e: unknown): LicenseError, calqué surparseRustError: une chaîne non JSON ou un code inconnu donneunknown;LICENSE_ERROR_I18N_MAPverslicense.errors.<camelCase>;activateMachine,deactivateMachineetlistActivatedMachinesrenvoient un résultat typé{ ok: true, ... } | { ok: false, error: LicenseError }(précédentbalance.service.ts:2745-2751) ;src/services/licenseService.test.ts.i18n FR/EN, clés
license.errors.*, au vouvoiement (commelicense.*etsettings.updates.*) :licenseNotFound,licenseRevoked,licenseExpired,machineLimitReached,noActiveActivation,invalidInput,rateLimit(avec{{seconds}}),network,invalidResponse,server,noLicenseStored,storage,internal,unknown.license.errors.unknownest le seul repli générique.Nouveau hook
src/hooks/useLicenseMachines.ts, avec un reducer exporté (licenseMachinesReducer). Il porte l'état de la liste : machines, statut d'activation, chargements, liste ouverte, et deux champs distincts, erreur d'activation et erreur de liste. Transitions :machine_limit_reachedsur activate : l'erreur d'activation est posée, puis la liste s'ouvre et se charge (verify). Le chargement de la liste n'efface jamais l'erreur d'activation (D2).refresh()duLicenseContext, pour que l'édition se mette à jour sans redémarrage.Tests vitest du reducer : ces transitions, plus « liste vide sans erreur → état vide » et « erreur de liste → pas d'état vide ».
LicenseCard.tsxconsomme ce hook. Les erreurs s'affichent viat(LICENSE_ERROR_I18N_MAP[...], params). La lignelicense.machines.noMachinesne s'affiche que si la liste est chargée, vide et sans erreur.Nouveau helper
src/shared/purchaseUrl.ts, fonction pure voisine deprofileGate.ts.purchaseUrl(language)renvoiehttps://lacompagniemaximus.com/{fr|en}/produits/simpl-resultat, aveclanguage.toLowerCase().startsWith("en") ? "en" : "fr". Test danssrc/shared/purchaseUrl.test.ts.LicenseCardappellepurchaseUrl(i18n.language)à la place dePURCHASE_URL.CHANGELOG.mdetCHANGELOG.fr.mdsous[Unreleased]/[Non publié](Fixed / Corrigé).docs/architecture.md: contrat d'erreur JSON{code}dans le §license_commands.rs(l.334-344), etuseLicenseMachinesdans le tableau des hooks.Fichiers concernés
src-tauri/src/commands/license_commands.rssrc/services/licenseService.tssrc/services/licenseService.test.ts(nouveau)src/hooks/useLicenseMachines.ts(nouveau)src/hooks/useLicenseMachines.test.ts(nouveau)src/shared/purchaseUrl.ts(nouveau)src/shared/purchaseUrl.test.ts(nouveau)src/components/settings/LicenseCard.tsxsrc/i18n/locales/fr.jsonsrc/i18n/locales/en.jsonCHANGELOG.mdCHANGELOG.fr.mddocs/architecture.mdDepends on
Aucune.
Critères d'acceptation
/v1/licenses/*avec"product":"simpl-resultat", prouvé par un test mockito sans route legacy.rate_limit+retry_after_s) et verifyvalid:false.license.errors.*, jamais avec la chaîne Rust « Activation failed » ni avec un message anglais du serveur. Une clé révoquée n'affiche plus « Aucune machine activée ».machine_limit_reached, la liste des machines s'ouvre d'elle-même avec ses boutons Désactiver, et le message reste visible (prouvé par le test du reducer)./fr/produits/simpl-resultaten français et/en/produits/simpl-resultaten anglais (test vitest du helper).cargo test,npm test(vitest) etnpm run buildverts ; CHANGELOG FR/EN.Review caveats
loadMachines()effaçait le message 409, et « Aucune machine activée » s'affichait même en cas d'erreur. Il faut deux champs d'erreur distincts, et l'état vide ne doit s'afficher qu'en l'absence d'erreur.license.errors.rateLimit, avec le délai.useReducer». Sans jsdom, c'est la seule preuve automatisée de D2.machine_uid::get()). Son hachage fait l'objet de #350, à traiter avant le go-live. Ne pas le changer ici : toute modification de la dérivation invalide les activations existantes.Décisions prises en planification
machine_limit_reached: message nommé et ouverture automatique de la liste (verify). Le champmachinesdu 409 est ignoré, pour garder une seule source à la liste.src/shared/(fonction pure), pas danssrc/services/.FetchPriceError:rate_limit(et nonrate_limited),Storagepour le fichier de licence,Internalpourmachine_id.Hors périmètre
/simpl-resultatcôté site → maximus/la-compagnie-maximus#153.Spec source
spec-plan-paiements.md(séance/plan-rundu 2026-09-24). C'est un fichier de travail ignoré par git : ce corps se suffit à lui-même.fix: activation /v1 + product explicite + URL achat localiseeto fix: licence — chemins /v1 + product, erreurs serveur nommées FR/EN, URL d'achat localiséemaximus referenced this issue2026-09-30 23:53:53 +00:00