Commit graph

12 commits

Author SHA1 Message Date
le king fu
cb7f8479e4 feat(gating): reserve transaction splits for the Base edition
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m16s
The Répartition (split) button of the Transactions page now belongs to
the `adjustments` feature, as the unlock screen already promised
(upsell.features.adjustments). On the Free edition the button keeps its
place and its icon, carries a lock and opens the locked screen in a
dialog instead of the split window, for every row, split or not. While
the license is not ready it shows no lock and does nothing. Existing
splits stay in the data, counted by the reports and flagged by the
orange icon.

- splitGate.ts: pure splitAccessFor(gate) -> open | locked | pending,
  stricter than profileGate: `allowed` does not short-circuit `ready`.
- SplitButton: stateless button that decides what a click opens.
- UpsellDialog: UpsellGate in a named modal dialog, closed by its X and
  by "I already have a key".
- TransactionTable: required splitAccess prop. The split window is not
  rendered once the edition resolves to Free, and stays mounted through
  a license refresh; the upsell is not rendered once it resolves to Base.
- TransactionsPage reads useEntitlement("adjustments").
- ADR 0017 amendment and matrix, user guide, in-app docs (FR, EN),
  architecture, entitlements header comment, both changelogs.

Tests: the predicate, the button's markup and click, the dialog, the
table's wiring through the shared hook harness (#409), the page under
the three gate states, and the new key in both locales.

Fixes #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:02:47 -04:00
le king fu
ed35570121 fix(license): drop an activation token that no longer stands for this machine
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m21s
A data directory written by v0.15.0 or earlier can hold a valid key next
to the activation token of another license, typically a refunded hence
revoked one. The License card read "Activated", market prices were
refused, and "Activate" was never offered: the folder could not recover
without a manual file removal.

- Prices: a 403 license_revoked drops the refused token, and only while
  the file still holds it, so an activation that lands while the refused
  request is in flight keeps its new token (CWE-367). The key's revoked
  state is never written from this route. The token is read through
  ACTIVATION_FILE by the directory-based read_stored_activation_token_in,
  in fetch_price_in, testable without AppHandle.
- Machine list: list_activated_machines returns { machines,
  token_dropped }. A complete list that names this machine under neither
  identifier drops a token that activates this machine, when the OS id is
  readable and the token is the one on disk when the list was asked for.
  An error, a revoked key or a rate limit drops nothing.
  machines_from_verify counts the rows it skips, which the list still
  tolerates. This also gives "Activate" back to a machine deactivated from
  another computer.
- Front: the activation status is read again after every list load; a
  fixed log line (ACTIVATION_TOKEN_LOG) is written on token_dropped and on
  a price refused as revoked; the revoked-license price message invites to
  activate the machine again.
- Docs: architecture, ADR 0017 (#365 amendment), ADR 0020 (rule 4), user
  guide and in-app tips, changelogs.

Resolves #367

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:58:25 -04:00
le king fu
e2f0fb86da feat(license): remove the stored license key from the License card
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m49s
PR Check — Rust / rust (pull_request) Successful in 3m26s
"Remove the key" on the License card removes license.key, then the
activation token, and keeps license.check, so a revoked key removed then
entered again stays Free. Removing the key stops the revocation check,
which until now only storing another key could stop.

When this machine is activated with a valid key, its slot is freed on
the license server first (remove_license with release_slot). A release
that fails removes nothing; on network, server, rate_limit or
invalid_response the card asks whether to remove the key anyway, the
slot then staying occupied. no_active_activation, license_not_found and
license_revoked (recorded for the key sent) mean the slot is free.

- Rust: remove_license / remove_license_in, release_this_machine_slot,
  this_machine_token_id, and the shared remove_activation_token_in
  helper, now also used by store_license_in and the deactivation.
- Front: removeLicense service; performRemoveLicense in
  useLicenseMachines (confirmations by state, error table, in-app log
  lines); removing and removeError in the machines reducer, which lock
  key entry, Activate and Deactivate; the button for any stored key,
  valid or revoked.
- Docs: user guide, in-app tips, architecture, ADR 0017 amendment,
  changelogs.

Resolves #370

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:14:36 -04:00
le king fu
ab78aa9ddc 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
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>
2026-10-04 16:26:10 -04:00
le king fu
b107cf42ce fix(license): apply the review of #368
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m45s
PR Check — Rust / rust (pull_request) Successful in 8m54s
- 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>
2026-10-03 09:29:47 -04:00
le king fu
e24c30fe6a feat(license): online revocation check — a revoked key falls back to Free (#365)
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>
2026-10-02 14:45:34 -04:00
le king fu
cdf4df85f7 fix(license): apply the second review of #364
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m15s
- 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>
2026-09-30 20:44:51 -04:00
le king fu
3f90635298 fix(license): apply the review of #364
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
PR Check — Rust / rust (pull_request) Successful in 10m35s
- 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>
2026-09-30 19:58:22 -04:00
le king fu
d2ded515fd fix(license): name key-entry errors, keep the edition off the activation token, flag revoked keys
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m43s
PR Check — Rust / rust (pull_request) Successful in 9m11s
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 #347
Fixes #360
Refs #363

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:39:01 -04:00
le king fu
170b22d356 fix(upsell): apply the review of #362
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m39s
- 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>
2026-09-27 20:47:04 -04:00
le king fu
57a0fbc2f7 feat(upsell): activate "Get Base", honest Premium note, purchase path in Free, Account card text
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m42s
- 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>
2026-09-27 15:50:45 -04:00
le king fu
01da65c215 docs(gating): ADR 0017 + architecture + user guide + CHANGELOG
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>
2026-07-20 22:48:55 -04:00