- A state file that cannot be written now has a test on both of its paths:
the attempt (nothing is sent, or a read-only data directory would send a
request at every start) and the verdict (the key is not reported revoked
when nothing on disk says so). The first path was correct but unguarded:
the review's mutation left all 185 tests green.
- The storage outcome no longer claims that nothing was sent: it also covers
a verdict that could not be written. Reworded in licenseService.ts, the
in-app log line and docs/architecture.md.
- The Updates card names a revoked key in its Free branch, between the
message and the CTAs: a refunded Base buyer first meets the loss of
automatic updates there (Max, 2026-10-03). The error page stays as it is.
- A revoked key keeps being checked once per 24 h while it stays stored, so
that a correction made on the license server is picked up (Max,
2026-10-03). Declared in the user guide, the in-app docs, the changelogs
and the ADR 0017 amendment.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five mutations survived the first pass, for four gaps:
- the revoked flag exposed by the provider: the value is now derived by
licenseContextValue, a plain function, and tested;
- the price message for a revoked license missing from either locale;
- the attempt written after the request instead of before: a test now reads
the state file from the server's side, while the request is in flight;
- the 24 h interval, which the boundary rows followed because they were
built from the constant: both sides of the boundary are written out.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>
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>
- Add automatic re-hashing of legacy SHA-256 PINs to Argon2id on
successful verification, returning new hash to frontend for persistence
- Use constant-time comparison (subtle::ConstantTimeEq) for both
Argon2id and legacy SHA-256 hash verification
- Add unit tests for hash_pin, verify_pin (Argon2id and legacy paths),
re-hashing flow, error cases, and hex encoding roundtrip
- Update frontend to handle VerifyPinResult struct and save rehashed
PIN hash via profile update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each profile gets its own SQLite database file for complete data isolation.
Profile selection screen at launch, sidebar switcher for quick switching,
and optional 4-6 digit PIN for privacy. Existing database becomes the
default profile with seamless upgrade.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>