- 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>
- upsellCtaFor(tier) decides what a locked screen offers: Base is
purchasable, Premium stays disabled with "Premium isn't sold online
yet". Pure and tested on the whole entitlements matrix.
- Shared CTAs, one component per file: PurchaseButton ("Get Base") and
DownloadLatestButton open the localized product page (purchaseUrl) and
need no router; HaveKeyButton renders only inside BrowserRouter.
- UpsellGate uses them; the purchase link never calls onNavigate, so
modal hosts stay open. ProfileFormModal reuses UpsellGate instead of
its compact panel, keeps its Cancel button, and closes after "I
already have a key" (onNavigate={onClose}).
- In Free, the Updates card offers Get Base, I already have a key and
Download the latest version; the error page (outside the router)
offers Get Base and the download link. No update check, no network
call.
- i18n FR/EN: Account card text, Premium note, Updates notEntitled text,
download label, editions guide step and tip; the "coming soon" note
key is gone. Tests pin the upsell key set and the decided texts.
- Docs: ADR 0017 amendment, architecture (UpsellGate row, shared CTAs,
upsellCtaFor, multi-profile row, counts), user guide section 12;
CHANGELOG FR/EN.
Proven red: Premium made purchasable 2, balance sold as Base in the
matrix 4, the old note key back in a locale 1, Account text altered 1,
download label altered 1.
Fixes#346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Document the edition-gating work (#297-#301):
- ADR 0017 (accepted): tier->features matrix, signed features[] override
fail-closed in Free (CWE-863), UI-only enforcement as an assumed GPL
soft-paywall (server-enforced price fetching stays the only hard gate),
non-destructive downgrade, dev-override behind an explicit Cargo
feature (CWE-489), rejected alternatives.
- architecture.md: new 'Gating par edition' section (entitlements matrix,
LicenseContext, useEntitlement, RequireFeature/UpsellGate, NavLock,
profileGate, Rust side), rewritten entitlements.rs section (auto-update
now Base+, stale 'open to free' note removed), gated routes listed in
the routing section, hooks table updated (useLicense removed in #297 ->
useEntitlement/useIsPremium), ADR index + header refreshed.
- guide-utilisateur.md + docs.editions.* i18n keys (FR/EN) wired into
DocsContent: new 'Editions' section with the Free/Base/Premium table,
unlock flow and non-destructive locking tips.
- CHANGELOG.md + CHANGELOG.fr.md: one global [Unreleased] entry listing
the modules now gated Base (Budget, Adjustments, advanced reports,
multi-profile, auto-update) and Premium (Balance), the visible-but-
locked upsell with disabled 'coming soon' purchase CTA, and the
data-preserving behaviour.
Resolves#302
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>