From the /pr-review of PR #394:
- the profile database opens only once prepare_profile_db has resolved
(deferred preparation; a load started early used to pass, RT1);
- a real VACUUM INTO failure, through a dangling symlink, names the full
path and comes out scrubbed (RV6); the test first checks the raw message
does name the folder;
- deleting a profile removes the -journal side file, the only one this
app produces (RV5);
- test folders are removed when each test ends, a failing one included
(TestDir guard), instead of 42 folders left in /tmp per run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The released-states loop stops at v0.10-v0.14 (state E). State F, the
consolidated schema of v0.15.0 to v0.17.0 without a history, only had
its floor checked; this test runs the whole preparation on it: history
recorded up to v17, nothing applied, no copy, flag repaired.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0021 records the catch-up rules and amends ADR 0003 and 0005, whose
claims about secondary profiles were false. architecture.md, CLAUDE.md
and the migration rule describe the two migration paths; the guide tells
where the pre-update copy lives and how to remove a database a deletion
on Linux left behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tauri-plugin-sql migrates only the database registered with add_migrations,
the default profile's. A secondary profile kept the consolidated schema of
the version that created it and never received a migration afterwards.
prepare_profile_db runs before every Database.load:
- secondary profiles catch up with the same migrations() list (pinned
SHA-384), history kept in _sqlx_migrations; a database without history
gets a floor inferred from its schema (the seven released creation
states are test fixtures), so nothing at or below it is ever replayed;
- a VACUUM INTO copy is taken before the first write, synced, then renamed;
- each version runs in its own transaction with foreign keys on; one that
fails on a foreign key is retried once without them and its orphans are
set to NULL where the key declares ON DELETE SET NULL;
- every profile gets its TEXT manual-categorization flags repaired;
- failures are logged with file names only and the profile still opens.
New profiles are born with their full history (get_new_profile_init_sql).
The plugin keeps databases under app_config_dir(), not app_data_dir():
the same folder on Windows and macOS, not on Linux. repair_migrations,
delete_profile_db and compute_account_return looked in the wrong folder
there. profile_db_path resolves the plugin's folder and validates the
file name; deleting a profile now also removes its SQLite side files and
catch-up copies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
updateTransactionCategory bound the JS boolean `true`. tauri-plugin-sql
passes a bool to sqlx as a JSON value, so the column received the TEXT
'true', which the getting-started checklist's `is_manually_categorized = 1`
never matches: step 5 never ticked.
toSqlFlag turns a boolean, a number or a backup's string into 0/1. It is
used when a category is set by hand and when a JSON or CSV backup is
restored, since a backup exported while the bug stood carries "true"
(the CSV path read it as 0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.17.0 is tagged on 07255e1 and published by release run 491: seven
artifacts and latest.json checked, the updater endpoint serves 0.17.0.
The update-cycle check was not due (nothing on the updater path changed
since v0.16.0) and the reason is written on the release. #234 is closed
on its fourth proof (actcache at 999 MB, one entry) with its milestone,
and the two suites-licence texts are posted on la-compagnie-maximus#184.
Rotate out the 2026-09-27 #270 decision to keep ten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The autopilot stack of 2026-10-04 is merged fast-forward through the API,
main at 2cbc886, milestone planned-2026-10-03-suites-licence closed 3/3.
The seven review suggestions were applied on the stack head (2cbc886),
each proven by a red mutation. Record the decision, the manual checks due
with v0.17.0 (ordered so the activation token is destroyed last), #234
points 2 and 3 done, and the #389 follow-up. Rotate out the 2026-09-24
decision to keep ten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups of the three /pr-review passes on #386, #387 and #388, applied
on the stack head before the fast-forward merge:
- #386: pin the relay of the revoked state by revocationCheckLauncher
(a launcher passing false instead of wasRevoked passed the whole suite),
and say in the rate budget comment that two checks in one minute bring
the machine-limit path to 6 requests.
- #387 S1: the fake `t` of the removal tests and of the License card tests
marks what it translates, so a string that skipped `t` fails its test.
- #387 S2: pin that storing another key stops on Storage while the
previous token cannot be dropped, the previous key staying.
- #387 S3: reload the license context after a failed removal too. Rust
deletes the key first, so a token that resisted left the key gone while
the card kept showing it under "the key was not removed".
- #388: pin that an unreadable token stays (the directory case could not
tell, its deletion fails anyway).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The English in-app tip now says, as the French one does, that the
revoked license is that of a refunded key left next to the current key,
not the key itself. The header of the price tests says that the tests of
the stored token call fetch_price_in with a temporary data directory,
instead of claiming that every test bypasses the token file.
Resolves#367
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
"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>
The three tests of #373 run two branches with tokio::join! and rely on
the first branch being polled first: the check that starts first, or the
check that waits for a held turn before the time moves. tokio's join!
already starts with the first branch on its first poll, but rotates on
the next ones; `biased;` keeps the declared order on every poll and says
so in the code. The three mutations (no turn, time read before the turn,
guard dropped at once) were run again: same red tests.
Resolves#373
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
#350: the #365 check had stored the revoked key on the Linux machine,
which deleted the v0.15.0 activation token; a read-only local script
confirmed it. Re-entering the tester key and activating produced a token
carrying the derived identifier, so step 3 holds in production. Steps 1-2
(raw token still accepted) can no longer be checked there; Max closed the
issue on step 3 and the two Rust tests that pin raw-token acceptance.
#346: checked in the Free edition given by the revoked key ("I already
have a key" closes the profile modal on the License card), so #370's PR
now uses Fixes and no longer waits on it.
Rotate out the 2026-09-21 decision to keep ten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 8c55e26 sync updated the header and decisions but left the Blockers
section at 2026-10-03. Bring it in line with Forgejo: onboarding phase 1
closed 7/7, #365 closed, #367/#370 ready in planned-2026-10-03-suites-licence,
maximus-api#62 and la-compagnie-maximus#150 closed, #317 absorbed by #358,
[Unreleased] carrying the onboarding entries.
Record that the #350 manual check is probably compromised on the Linux
machine: verifying #365 stored another key there, which deletes the
activation token, so the raw-ID token from v0.15.0 is likely gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Guide: drop the internal provenance (author, date, issue number) from the
BMO status line in the three copies (fr.json, en.json, guide-utilisateur.md).
The status now only says the procedure is reconstructed and unconfirmed.
- Dashboard: gate `showCompactChecklist` on `budgetReady` so a Base+ profile
with the five other items done no longer sees the compact list vanish (5/5
under the fail-closed entitlement) and reappear (5/6) once the licence
resolves.
- Test: pin TZ=America/Toronto around the `formatLocalDate` tests and assert
that `toISOString()` disagrees, so a UTC regression is caught on the UTC CI
runner too (measured: the mutation passed 7/7 under TZ=UTC before, fails now).
Follow-ups raised by the independent reviews of PRs #381, #378 and #382.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closes phase 1 of the onboarding wave (#351-#356) on the documentation side.
- CHANGELOG.md / CHANGELOG.fr.md: Added (guided empty states, getting-started
checklist, sample folder, bank CSV export guide) and Changed (re-show button
in Settings > Users, Import "?" popover linking to the guide) under
[Unreleased] / [Non publié], same entries in both languages.
- docs/architecture.md: create_sample_import_folders (fs_commands 6 -> 7,
total 36 -> 37) with its contract, onboardingService, useOnboardingChecklist,
the onboarding_checklist_hidden preference, the samples/ corpus next to csv/,
and an "Onboarding" section (four empty states and their signal, source of
truth of the six checklist rows, sample-folder flow). Tree counts measured
for the directories the wave touched.
- docs/guide-utilisateur.md chapters 3 and 11 + docs.dashboard.* and
docs.settings.* keys (FR/EN). Hand-off from #355: docs.gettingStarted.steps
aligned with the six-step quick start of the markdown, docs.import.steps
mentions the sample folder, so the three guide copies agree.
- docs/qa-onboarding.html: interactive manual-test checklist, French only,
static, no network dependency, state in localStorage, plain-text report to
the clipboard with a visible fallback, reset button.
Resolves#357
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The import page without a source no longer shows a bare "no source found"
card. `ImportEmptyState` (built on the shared `EmptyState`) takes the place
of the empty `SourceList` in both situations: without an import folder it
offers "Choose a folder" as the single primary action; with a folder and no
bank sub-folder it offers "Create a sample folder" (primary, disabled while
in flight) and "Refresh" (secondary). Both variants, and the "?" help
popover through the new optional `linkTo` prop of `PageHelp`, link to the
bank CSV export guide at `/settings/users#import-export`. `SourceList`
stays an untouched pure list.
`useImportWizard` gains `createSampleFolders`: it invokes the Rust command
`create_sample_import_folders` with the folder and the webview's LOCAL date
(built from the local getters, not `toISOString()`), rescans the folder so
the two cards appear without another click, then raises a closable success
banner from the new `sampleCreated` state, which `reset` and a folder change
clear. A refusal from Rust (a sample folder already exists) lands in the
existing error banner. The log line carries the folder NAMES only, never
the absolute path, since the logs travel with a feedback report.
"Browse" on the folder card drops to the secondary style while no folder is
configured, so a single primary button is on screen. The reducer and its
initial state are exported as `wizardReducer` / `initialWizardState` and
unit-tested; `ImportEmptyState` is tested by direct call.
Resolves#356
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add an optional `subsections` array to a user-guide section, rendered by
the new pure `DocsSubsections` component between the "How to" block and
the tips. Each entry has an id, a title, an optional italic status line,
an ordered list of steps and an optional trailing note. The ids are
registered in `sectionRefs` of `DocsContent`, so the existing hash
navigation (`/settings/users#import-export`) reaches the sub-sections
too. Only the Import section declares the key; the eleven other
sections render exactly as before.
The Import section gets six blocks in French and English: a generic
"export a CSV from your bank" block, then Desjardins, Tangerine, RBC,
National Bank and BMO with their verification status. The National Bank
and BMO blocks, which ask the user to send a header line through the
feedback form, carry the warning never to paste an account number, a
balance or a real transaction, in the JSON files and in the markdown
guide alike.
`docs/guide-utilisateur.md` receives the same six blocks in chapter 4,
a mention of the "Create a sample folder" button in its "How to", and a
"Quick start" in chapter 1 rewritten as the six steps of the
getting-started checklist, same order and wording as
`onboarding.checklist.items.*`.
`DocsSubsections.test.tsx` calls the component directly (no jsdom):
null for a section without sub-sections, one block per entry with its
id and ref registration, status line and note rendered only when
present, malformed entries dropped.
Resolves#355
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add a Tauri command that writes two synthetic bank statements (Desjardins
chequing account, Tangerine credit card) into the import folder, one
sub-folder each, so a new user has sources to import on the first run.
The command takes the import folder and the webview's local date only
(chrono is compiled without `clock`), renders the `{{M1}}` / `{{M2}}`
tokens as the two complete months before that date, names each file
`releve-YYYY-MM.csv` after the most recent one, and refuses to run when
the folder is missing or when either sample folder already exists -
checked before anything is written. It returns the two folder names.
The templates live under `src/__fixtures__/samples/` so the Rust command
(`include_str!`) and vitest read the same bytes. The integration test
proves detection recognises each file as its bank with a signed single
amount and reads 40/40 rows, that the production `mapRow` parses every
row and sums to +740.00 / -368.46, and that the 180 seed keywords
categorise exactly 33 / 36 lines while leaving the hand-classified ones
untouched. The frozen detection corpus (`src/__fixtures__/csv`) is not
modified.
Five Rust tests cover the missing folder, the existing folder, the
success path, an unparseable date and the year boundary, each in its own
sandbox so they run in parallel.
CHANGELOG and architecture docs are centralised in #357.
Resolves#354
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A profile without any transaction now gets an "import first" card with a
button to the import page on both pages, in place of the filter bar, the
table and the pagination (Transactions) and of the filter panel and the
grid (Budget). Title, page help and template actions stay.
The signal is a profile-wide `totalInDb` count loaded once at mount via
`getTransactionCount()`, not `rows.length`: the default Transactions
filter is "since January 1st", so an empty page cannot tell an empty
profile from a year without transactions. `null` (not loaded yet) keeps
the regular layout, and so does a loading Transactions page.
`transactionsReducer` / `initialTransactionsState` and `budgetReducer` /
`initialBudgetState` are exported with a pure `showsEmptyState` selector
per hook, so the reducer tests pin that the count never touches filters,
sort or page and that 0 then N > 0 brings the table back. The existing
"no transactions found" / "no categories found" rows are unchanged.
Resolves#353
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A profile without any transaction now opens a dashboard that says what to
do: a welcome card with the expanded getting-started checklist and one
primary action towards the import page, instead of widgets showing 0 $.
With transactions, the compact checklist sits between the title and the
filter panel until it is complete or dismissed.
- `EmptyState` (pure, dashed card from the import source list; a `to`
action renders a react-router `<Link>`, an `onClick` action a button)
- `OnboardingChecklist` (pure, expanded and compact modes; undone rows
link to their route, the locked Budget row shows a lock, the "Base and
up" label and links to the license card; neutral budget row and hidden
counter until the entitlement is known)
- `DashboardEmptyState` composes both
- `DashboardPage` branches on the hook `status`: loading renders the title,
help and banner only; error falls back to the v0.15.0 page without the
list and logs a warning; ready with zero transactions renders the empty
state outside the `isLoading` veil
- `useOnboardingChecklist` exposes `counters` and `error` for the page
- Users settings: "Show the getting-started checklist" button in the Guide
section, visible only when the preference is set, with an inline
confirmation after the click
- FR + EN keys `dashboard.empty.*`, `onboarding.checklist.*`,
`settings.users.onboarding.*`
Tests: `EmptyState.test.tsx` and `OnboardingChecklist.test.tsx` by direct
call (FilterPanel pattern). No DB migration. CHANGELOG left to #357.
Resolves#352
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Lay the data foundation of the getting-started checklist, without UI:
- transactionService.getTransactionCount(): unfiltered COUNT(*) of transactions
- onboardingService: getOnboardingCounters() reads the five aggregated counts,
the import folder and the onboarding_checklist_hidden preference in one
Promise.all; hideChecklist()/showChecklist() persist the preference and log
- utils/onboardingChecklist.computeChecklist(): pure computation of the six
rows (profile, importFolder, source, import, manualCategory, budget), with
the budget row locked and excluded from the total when the edition has no
Budget
- hooks/useOnboardingChecklist: exported reducer + initial state, actions
refresh/hide/show, composed with useEntitlement("budget"); a load error
sets status "error" and keeps the hidden flag
- types OnboardingCounters, ChecklistItem, ChecklistItemKey
Tests cover every row in isolation, the budget gating (total 5 vs 6, complete
at 5/5 locked and 6/6 open), the aggregated query and preference reads, and
every reducer action. No DB migration, no CHANGELOG entry (centralised in #357).
Resolves#351
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Applies the three suggestions of the review of #375:
- The runner's cleanup keeps one entry per key, whatever its scope: a PR
run that saves under the shared entry's key evicts it for every PR until
the next Monday, and two PRs on the same non-main lockfile evict each
other. Added to the list of cold runs.
- The cache key also hashes the job's CARGO*/RUST*/CC*/CFLAGS*/CXX*/CMAKE*
variables, and ignores the app's own version: changing env: means a cold
run until Monday, a release does not.
- A Monday restore that fails with a 404 after a quiet week is a known
runner race (forgejo/runner#1741), not #234 coming back.
Refs #234
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md and docs/architecture.md still said the Actions cache was
removed until #234 was fixed. They now describe rust-cache, the per-PR
isolation of what a pull_request run saves, the weekly run on main that
writes the shared cache, the expected cold runs, and why check-frontend
has no npm cache. The audit.yml paragraph no longer quotes the outdated
"1 PR in 40" figure.
Refs #234
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runner's cache server is reachable again since the VPS fix of
2026-10-03 (cache proxy pinned to port 28100, ufw rule scoped to the
Docker job networks; diagnosed in #374). Bring caching back to check-rust
through Swatinem/rust-cache, as #232 deferred.
forgejo-runner v12.7.0 keeps what a pull_request run saves private to
that PR, so PR runs alone would only warm later pushes to the same PR. A
weekly scheduled run on main (Mondays 06:30 UTC) and workflow_dispatch
write the shared entries that every PR falls back to.
rust-cache v2.9+ declares node24 and the runner starts JavaScript actions
with the container's node, so the job now installs Node.js 24. zstd is
installed for faster, smaller cache archives.
No npm cache in check-frontend, by decision: with per-PR isolation it
would only serve later pushes of a PR, and npm ci takes 9s there. audit.yml
loses two comments this change makes false.
No CHANGELOG entry: CI configuration only.
Refs #234
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
The review approved the previous head with five suggestions; this commit
applies the four that touch the repository (the fifth is the manual check
in the pull request description).
- Tests: a valid token issued for another machine goes with any row of
this machine. Two rows added to the deactivation table: the reviewer's
mutation that kept such a token stayed green before them.
- ADR 0020, removal condition: counted from the last raw-claim token the
server issued, not from a client version. A Free 0.15.0 no longer
updates and can still activate with the raw id months from now.
- ADR 0020: names what the transition leaves behind when a machine is
activated again while its raw row is still active (a second row, one
more slot), and what a public key does not cover (whoever already holds
the OS id can recompute the derived one).
- "No command hands the OS id to the webview" was wider than the code: the
server's list still carries it for a row from before the derivation.
Bounded in the ADR, architecture.md and the licenseService.ts comment.
No behaviour change. Rust 168, vitest 1314.
Refs #350
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>
The site checkout has been live since 2026-09-29 and the control
purchase of 2026-09-30 proved the chain down to revocation on refund,
so "no tag before the go-live" no longer has a reason to exist: the
desktop release is now a dependency of the go-live issue.
Also records that maximus-api#63 and maximus-api#70 are deployed and
that the terms of sale of la-compagnie-maximus#157 are published, adds
the spec-onboarding-phase1 milestone to the backlog, and moves the
latest decision entry from 2026-10-01 (UTC) to 2026-09-30, the local
merge date used by every other entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The seven issues of the spec-onboarding-phase1 milestone cite
docs/analyse-onboarding-2026-09.md as their source analysis, but the
file had stayed untracked since 2026-09-24 and only existed on one
machine. Phases 2 and 3 of the onboarding work are described nowhere
else.
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>