A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.
Rust
- license.check, next to the key, holds what the license server last said
about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
missing, unreadable or invalid file, or a state about another key, reads
as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
the key only. Nothing is sent without a stored key that validates, nor
within 24 h of the last attempt, which is written before the request.
Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
premium_required. It records nothing: the price route judges the license
of the activation token, which the client cannot tie to the stored key,
and 0.15.0 left the token of a previous key next to a new one (#367).
Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
session flag and markRevoked are gone. The check starts once per app
start, writes its outcome to the in-app log, and reloads the context only
when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.
Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.
- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
characters, pinned by two reference vectors. An empty OS id is refused,
never hashed.
- Activation sends the derived identifier and requires the issued token to
carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
identifier (D3). `ActivationStatus` no longer carries an identifier and
the unused `get_machine_id` command is removed: no command hands the OS
id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
freed slot: any row of this machine takes it, except the old raw row
while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
removing the raw-id compatibility.
Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.
No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.
Refs #350
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Key entry and machine activation can no longer overlap. An activation
in flight writes the token of the key it read: a key stored meanwhile
ended up next to the previous key's token, the dead end 3f90635
closes. "Enter a key" and the form's submit are disabled while an
activation runs (LicenseKeyForm gains a busy prop), and "Activate"
while a key is being stored.
- The "Activated" badge is hidden for a revoked key: the server
deactivated every machine of the license.
- ADR 0017: the #347 amendment also points at section 1, which still
mentions the machine-binding downgrade.
Refs #347
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- store_license_in drops activation.token when the stored key changes.
The token names the previous license to the price server (its sub is
the license id, the key carries the email), so the card showed the
machine as activated while prices were refused for the old license.
The same key entered again keeps its token; a refused key changes
nothing.
- get_activation_status goes through activation_status_in(dir,
machine_id, key), tested in a temporary directory: a missing,
expired, foreign-machine, foreign-key or unreadable token reads as
not activated. It alone decides "Not activated" and "Activate".
- LicenseCard is keyed by licenseIdentity(info): another key remounts
the body, which re-reads the activation and drops the previous key's
machine list.
- invalidSignature asks to check the copy first. jsonwebtoken checks
the signature before decoding the payload, so a damaged copy often
reads as a signature failure (characterization test). User guide and
changelogs aligned.
- Render tests: the other errors stay visible under Machines, and the
edition's green check gives way to an alert for a revoked key.
Refs #347
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
UnknownEdition. validate_with_key returns it (ExpiredSignature maps
to LicenseExpired), and store_license, read_license and
validate_license_key reject with the same JSON as the network
commands. store_license_in and read_license_in take a directory, so
the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
resolve_license_entitlements no longer takes the activation token,
and current_entitlements reads only license.key
(entitlements_from_dir). A token that had expired (2-year server
lifetime) or came from another machine (restored data) used to drop
a valid key to free, while a key copied without its token kept its
edition. get_activation_status and persist_activation_token keep the
strict token validation, and the key keeps its strict validation
(CWE-613).
Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
static render reaches it) names a refused key, through
parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
whole session, because the card unmounts when the user leaves
Settings > Users. Only a different key clears it. Card-level
message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
beyond 50 years (licenseExpiry.ts, for the 100-year keys of
maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
the price authFailed message points to activating the machine.
Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).
Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.
Fixes#347Fixes#360
Refs #363
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Render tests for the upsell CTAs (UpsellCtas.render.test.tsx): the
reviewer's probe, which needs no jsdom (react-dom/server in the node
environment, plus the FilterPanel.test.tsx pattern of calling a
component as a function). It pins the Base/Premium CTA on UpsellGate,
the router guard of HaveKeyButton, the localized product URL, the
Premium note and the onNavigate forwarding, none of which the suite
guarded before.
- A failure to open the browser (openUrl) is now logged through
logError in PurchaseButton, DownloadLatestButton and LicenseCard,
instead of vanishing as an unhandled rejection that never reaches the
in-app logs. Tested for both shared buttons.
- upsellCtaFor: comment the trap in making Premium purchasable, since
PurchaseButton sells Base only.
- ADR 0017 amendment: the Account card text holds in practice only;
current_entitlements still grants Premium to an active subscription,
a dormant path until a subscription is sold.
- User guide and in-app docs: the purchase step now comes before the
key entry, and the Balance button is named "Get Premium".
- CHANGELOG: "Maximus Account" in English, "every screen that requires
it" in both files (the Balance screen sells nothing), and the French
elision before a guillemet.
Refs #346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The idempotent-deactivation test now pins the route's other 404:
license_not_found stays an error. The previous negative case was a
403 that the deactivate route never returns, so swallowing
license_not_found (an unknown key shown as deactivated, the local
token deleted) went unnoticed.
- A test deactivates this machine and checks that the re-read activation
status brings the Activate button back. Removing that re-read used to
leave the suite green.
- The Activate button stays disabled while the list loads
(selectCanActivate), the counterpart of selectCanDeactivate: a verify
answer landing after a successful activation can no longer mark as
fresh a list that misses this machine.
- CHANGELOG: the old /simpl-resultat address is redirected by the site
since la-compagnie-maximus#153, so the "Buy" bullet now says the
button opens the product page directly, in the app's language. The
test comment is moved to the past tense.
Proven red on the fixed tree: idempotence swallowing license_not_found
1, no status re-read after deactivation 1, Activate live during a list
load 1.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A 404 without a known code (missing route, proxy error page) is now
pinned as a server error by two table rows, so "key not found" can
never be shown for it.
- The message-key test spells out the expected key of every code
instead of comparing the map with itself, and checks the keys are
distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
AppHandle, tested against a mock server (404 no_active_activation ->
done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
(selectCanDeactivate), so a machine deactivated during a reload
cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
activation. The edition does not change with or without an
activation token, so nothing is observable; refresh() stays.
Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Activation, deactivation and verify POST to /v1/licenses/* with
"product":"simpl-resultat" instead of the legacy /licenses/* paths,
which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
read from error.code of the /v1 envelope. A 200 {valid:false} from
verify is now a coded refusal instead of an empty machine list, and an
HTML error page on a non-2xx maps to server instead of
invalid_response. Deactivation is idempotent: no_active_activation
counts as done.
- The three calls live in *_inner functions without AppHandle, tested
against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
survives the list load it triggers (machine_limit_reached opens the
list), a deactivation removes the machine locally without re-running
verify (4 requests on the machine-limit path, under the server's
5/min/IP limit), and refresh() follows every successful activation or
deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
/{fr|en}/produits/simpl-resultat.
test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.
Fixes#270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A rejected submitKey dispatched the same ERROR action as a failed boot
load, so the CWE-703 retry backoff armed on it and the auto refresh
(LOAD_START) cleared the "invalid key" message ~1s after submit —
LicenseCard has no local error state, the context is the only source.
Split the state: load lifecycle (status/error, retried) vs validation
(validating/validationError, never retried). VALIDATE_ERROR leaves
status untouched, so a ready license stays ready on a typo'd key (no
`ready` regression for gating consumers) and a boot-error retry loop
keeps running through a failed validation. Reducer + initial state
exported for tests, covered by LicenseContext.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the user-facing layer on top of the Rust license commands shipped
in #46.
- `licenseService.ts` thin wrapper around the new Tauri commands
- `useLicense` hook follows the project's useReducer pattern (idle,
loading, ready, validating, error) and exposes `submitKey`,
`refresh`, and `checkEntitlement` for cross-component use
- `LicenseCard` shows the current edition, the expiry date when set,
accepts a license key with inline validation feedback, and links to
the purchase page via `openUrl` from `@tauri-apps/plugin-opener`
- Card is inserted at the top of `SettingsPage` so the edition is the
first thing users see when looking for license-related actions
- i18n: new `license.*` keys in both `fr.json` and `en.json`
- Bilingual CHANGELOG entries