diff --git a/src/components/shared/UpsellCtas.render.test.tsx b/src/components/shared/UpsellCtas.render.test.tsx
index c9e358f..ddf95d5 100644
--- a/src/components/shared/UpsellCtas.render.test.tsx
+++ b/src/components/shared/UpsellCtas.render.test.tsx
@@ -24,6 +24,13 @@ vi.mock("react-i18next", () => ({
}),
}));
+// The locked screen and the Updates card read one thing from the license context:
+// whether the stored key was revoked (#365).
+let licenseRevoked = false;
+vi.mock("../../contexts/LicenseContext", () => ({
+ useLicenseContext: () => ({ revoked: licenseRevoked }),
+}));
+
vi.mock("../../hooks/useUpdater", () => ({
useUpdater: () => ({
state: { status: "notEntitled", version: null, body: null, progress: 0, contentLength: null, error: null },
@@ -50,6 +57,32 @@ beforeEach(() => {
openUrl.mockClear();
logError.mockClear();
language = "fr";
+ licenseRevoked = false;
+});
+
+describe("UpsellGate with a revoked key (#365)", () => {
+ const gate = () =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+ it("says nothing about a revocation for a key that is not revoked", () => {
+ expect(gate()).not.toContain("upsell.revoked");
+ });
+
+ it("names the revocation between the feature description and the CTAs", () => {
+ licenseRevoked = true;
+ const html = gate();
+ const line = html.indexOf("upsell.revoked");
+ expect(line).toBeGreaterThan(html.indexOf("upsell.features.budget"));
+ expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
+ expect(html.split("upsell.revoked")).toHaveLength(2);
+ // The way out stays: buying, or entering another key.
+ expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
+ expect(html).toContain("upsell.ctaHaveKey");
+ });
});
describe("UpsellGate wiring", () => {
@@ -128,5 +161,19 @@ describe("UpdateCard in the free edition", () => {
expect(html).toContain("upsell.ctaGet[tier=license.editions.base]");
expect(html).toContain("upsell.ctaHaveKey");
expect(html).toContain("settings.updates.downloadLatest");
+ expect(html).not.toContain("upsell.revoked");
+ });
+
+ it("names a revoked key between the message and the CTAs (#365)", () => {
+ licenseRevoked = true;
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ const line = html.indexOf("upsell.revoked");
+ expect(line).toBeGreaterThan(html.indexOf("settings.updates.notEntitled"));
+ expect(line).toBeLessThan(html.indexOf("upsell.ctaGet"));
+ expect(html.split("upsell.revoked")).toHaveLength(2);
});
});
diff --git a/src/components/shared/UpsellGate.tsx b/src/components/shared/UpsellGate.tsx
index 7c6694d..fb3d3df 100644
--- a/src/components/shared/UpsellGate.tsx
+++ b/src/components/shared/UpsellGate.tsx
@@ -3,6 +3,7 @@ import { Lock, ShoppingCart } from "lucide-react";
import type { Edition } from "../../services/licenseService";
import type { FeatureKey } from "../../shared/entitlements";
import { upsellCtaFor } from "../../shared/upsellCta";
+import { useLicenseContext } from "../../contexts/LicenseContext";
import PurchaseButton from "./PurchaseButton";
import HaveKeyButton from "./HaveKeyButton";
@@ -33,9 +34,13 @@ interface UpsellGateProps {
*
* The tier label reuses the existing `license.editions.*` keys; the feature
* description comes from `upsell.features.
` (FR/EN).
+ *
+ * When the stored key was revoked by the license server (#365), one line says so above
+ * the CTAs: the lock would otherwise appear with no reason given.
*/
export default function UpsellGate({ feature, requiredTier, onNavigate }: UpsellGateProps) {
const { t } = useTranslation();
+ const { revoked } = useLicenseContext();
const tier = t(`license.editions.${requiredTier}`);
const cta = upsellCtaFor(requiredTier);
@@ -53,6 +58,12 @@ export default function UpsellGate({ feature, requiredTier, onNavigate }: Upsell
{cta.purchasable ? (
diff --git a/src/contexts/LicenseContext.test.ts b/src/contexts/LicenseContext.test.ts
index 6459598..011d28c 100644
--- a/src/contexts/LicenseContext.test.ts
+++ b/src/contexts/LicenseContext.test.ts
@@ -1,14 +1,31 @@
-import { describe, it, expect } from "vitest";
-import { licenseReducer, initialLicenseState } from "./LicenseContext";
-import type { Edition, LicenseInfo } from "../services/licenseService";
+import { describe, it, expect, vi } from "vitest";
+import {
+ licenseReducer,
+ initialLicenseState,
+ licenseContextValue,
+ performSubmitKey,
+ createRevocationCheckStarter,
+ performRevocationCheck,
+ REVOCATION_LOG,
+ type LicenseAction,
+ type RevocationCheckDeps,
+ type SubmitKeyDeps,
+} from "./LicenseContext";
+import type {
+ Edition,
+ LicenseInfo,
+ RevocationCheck,
+ RevocationOutcome,
+} from "../services/licenseService";
-const makeInfo = (edition: Exclude
): LicenseInfo => ({
+const makeInfo = (edition: Exclude, revoked = false): LicenseInfo => ({
edition,
email: "max@example.com",
features: [],
machine_limit: 3,
issued_at: 1,
expires_at: 0,
+ revoked,
});
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
@@ -88,7 +105,7 @@ describe("licenseReducer — key validation is orthogonal to the load lifecycle"
);
const done = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
- { type: "VALIDATE_DONE", info: makeInfo("base") },
+ { type: "VALIDATE_DONE", info: makeInfo("base"), edition: "base" },
);
expect(done).toEqual({
status: "ready",
@@ -97,58 +114,315 @@ describe("licenseReducer — key validation is orthogonal to the load lifecycle"
error: null,
validating: false,
validationError: null,
- revoked: false,
});
});
});
-// A `license_revoked` answer stays known for the whole session (#347, D13): the card
-// that receives it unmounts when the user leaves Settings → Users or switches profile,
-// this state does not. Only a different key clears it.
-describe("licenseReducer — revocation lasts the session", () => {
- const revoked = licenseReducer(premiumReady, { type: "LICENSE_REVOKED" });
+// The revoked state is persisted by Rust and reaches the context inside the license
+// info (#365): nothing in the reducer decides it, so nothing in the session can lose it.
+describe("licenseReducer — the revoked state comes from Rust", () => {
+ const revokedReady = licenseReducer(
+ licenseReducer(initialLicenseState, { type: "LOAD_START" }),
+ { type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) },
+ );
- it("LICENSE_REVOKED flags the stored key without touching the edition", () => {
- expect(revoked.revoked).toBe(true);
- expect(revoked.edition).toBe("premium");
- expect(revoked.status).toBe("ready");
+ it("a loaded revoked key is Free, and its info says why", () => {
+ expect(revokedReady.edition).toBe("free");
+ expect(revokedReady.info?.revoked).toBe(true);
+ expect(revokedReady.info?.edition).toBe("base");
});
- it("survives a reload of the license and a load error", () => {
+ it("survives a reload, a load error and a refused key", () => {
const reloaded = licenseReducer(
- licenseReducer(revoked, { type: "LOAD_START" }),
- { type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
+ licenseReducer(revokedReady, { type: "LOAD_START" }),
+ { type: "LOAD_DONE", edition: "free", info: makeInfo("base", true) },
);
- expect(reloaded.revoked).toBe(true);
- const failed = licenseReducer(licenseReducer(revoked, { type: "LOAD_START" }), {
+ expect(reloaded.info?.revoked).toBe(true);
+
+ const failed = licenseReducer(licenseReducer(revokedReady, { type: "LOAD_START" }), {
type: "LOAD_ERROR",
error: "boot fail",
});
- expect(failed.revoked).toBe(true);
- });
+ expect(failed.info?.revoked).toBe(true);
+ expect(failed.edition).toBe("free");
- it("survives a refused key", () => {
- const refused = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
+ const refused = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
type: "VALIDATE_ERROR",
error: '{"code":"invalid_prefix"}',
});
- expect(refused.revoked).toBe(true);
+ expect(refused.info?.revoked).toBe(true);
});
- it("stays when the same key is entered again", () => {
- const same = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
+ it("VALIDATE_DONE takes the edition it is given, not the one the key claims", () => {
+ const same = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
type: "VALIDATE_DONE",
- info: makeInfo("premium"),
+ info: makeInfo("base", true),
+ edition: "free",
});
- expect(same.revoked).toBe(true);
+ expect(same.edition).toBe("free");
+ expect(same.info?.revoked).toBe(true);
});
- it("clears when a different key is stored", () => {
+ it("a different, valid key starts clean", () => {
const other: LicenseInfo = { ...makeInfo("premium"), issued_at: 2 };
- const replaced = licenseReducer(licenseReducer(revoked, { type: "VALIDATE_START" }), {
+ const replaced = licenseReducer(licenseReducer(revokedReady, { type: "VALIDATE_START" }), {
type: "VALIDATE_DONE",
info: other,
+ edition: "premium",
});
- expect(replaced.revoked).toBe(false);
+ expect(replaced.edition).toBe("premium");
+ expect(replaced.info?.revoked).toBe(false);
+ });
+});
+
+// Storing a key: the edition shown right after is the one Rust resolves (#365). Before,
+// VALIDATE_DONE took the key's own edition, so a revoked key entered again read as Base
+// until the next reload.
+describe("performSubmitKey", () => {
+ function submitHarness(overrides: Partial = {}) {
+ let state = premiumReady;
+ const actions: LicenseAction[] = [];
+ const deps: SubmitKeyDeps = {
+ storeLicense: vi.fn(async () => makeInfo("base")),
+ getEdition: vi.fn(async (): Promise => "base"),
+ dispatch: (action) => {
+ actions.push(action);
+ state = licenseReducer(state, action);
+ },
+ ...overrides,
+ };
+ return { deps, actions, getState: () => state };
+ }
+
+ it("takes the edition Rust resolves: Free for a revoked key entered again", async () => {
+ const { deps, getState } = submitHarness({
+ storeLicense: vi.fn(async () => makeInfo("base", true)),
+ getEdition: vi.fn(async (): Promise => "free"),
+ });
+ const result = await performSubmitKey(deps, "SR-BASE-k");
+ expect(result).toEqual({ ok: true, info: makeInfo("base", true) });
+ expect(getState().edition).toBe("free");
+ expect(getState().info?.revoked).toBe(true);
+ expect(deps.storeLicense).toHaveBeenCalledWith("SR-BASE-k");
+ });
+
+ it("keeps an account Premium over the stored key's own edition", async () => {
+ const { deps, getState } = submitHarness({
+ getEdition: vi.fn(async (): Promise => "premium"),
+ });
+ await performSubmitKey(deps, "SR-BASE-k");
+ expect(getState().edition).toBe("premium");
+ expect(getState().info?.edition).toBe("base");
+ });
+
+ it("falls back to Free when the edition cannot be read and the key came back revoked", async () => {
+ const { deps, getState } = submitHarness({
+ storeLicense: vi.fn(async () => makeInfo("base", true)),
+ getEdition: vi.fn(async (): Promise => {
+ throw new Error("ipc down");
+ }),
+ });
+ const result = await performSubmitKey(deps, "SR-BASE-k");
+ expect(result.ok).toBe(true);
+ expect(getState().edition).toBe("free");
+ expect(getState().status).toBe("ready");
+ });
+
+ it("falls back to the key's edition when the edition cannot be read and the key is clean", async () => {
+ const { deps, getState } = submitHarness({
+ getEdition: vi.fn(async (): Promise => {
+ throw new Error("ipc down");
+ }),
+ });
+ await performSubmitKey(deps, "SR-BASE-k");
+ expect(getState().edition).toBe("base");
+ });
+
+ it("reports a refused key without reading the edition", async () => {
+ const { deps, actions, getState } = submitHarness({
+ storeLicense: vi.fn(async () => {
+ throw '{"code":"invalid_prefix"}';
+ }),
+ });
+ const result = await performSubmitKey(deps, "nonsense");
+ expect(result).toEqual({ ok: false, error: '{"code":"invalid_prefix"}' });
+ expect(deps.getEdition).not.toHaveBeenCalled();
+ expect(actions.map((a) => a.type)).toEqual(["VALIDATE_START", "VALIDATE_ERROR"]);
+ expect(getState().edition).toBe("premium");
+ expect(getState().validationError).toBe('{"code":"invalid_prefix"}');
+ });
+});
+
+// The provider's value is derived by a plain function: its state never loads under
+// renderToStaticMarkup, so this is where "the context says revoked" is decided.
+describe("licenseContextValue", () => {
+ const refresh = async () => {};
+ const submitKey = async () => ({ ok: false as const, error: "unused" });
+ const loaded = (info: LicenseInfo | null, edition: Edition) =>
+ licenseContextValue(
+ licenseReducer(initialLicenseState, { type: "LOAD_DONE", edition, info }),
+ refresh,
+ submitKey,
+ );
+
+ it("says revoked for a revoked key, and only then", () => {
+ expect(loaded(makeInfo("base", true), "free").revoked).toBe(true);
+ expect(loaded(makeInfo("base"), "base").revoked).toBe(false);
+ expect(loaded(null, "free").revoked).toBe(false);
+ expect(licenseContextValue(initialLicenseState, refresh, submitKey).revoked).toBe(false);
+ });
+
+ it("exposes the state, the signed features and the two actions", () => {
+ const info: LicenseInfo = { ...makeInfo("base"), features: ["balance"] };
+ expect(loaded(info, "base")).toEqual({
+ status: "ready",
+ edition: "base",
+ features: ["balance"],
+ info,
+ error: null,
+ validating: false,
+ validationError: null,
+ revoked: false,
+ refresh,
+ submitKey,
+ });
+ expect(loaded(null, "free").features).toEqual([]);
+ });
+});
+
+// D1: the check is a startup check. The starter is what makes "once" true: effects do
+// not run under renderToStaticMarkup, so the decision lives here, outside the provider.
+describe("createRevocationCheckStarter", () => {
+ const clean = makeInfo("base");
+
+ it("does nothing until the license is ready", () => {
+ const run = vi.fn();
+ const start = createRevocationCheckStarter(run);
+ start("idle", null);
+ start("loading", null);
+ start("error", clean);
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("runs once at the first ready state with a stored key", () => {
+ const run = vi.fn();
+ const start = createRevocationCheckStarter(run);
+ start("loading", null);
+ start("ready", clean);
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(run).toHaveBeenCalledWith(false);
+ });
+
+ it("never runs again: not after a reload, nor for another key", () => {
+ const run = vi.fn();
+ const start = createRevocationCheckStarter(run);
+ start("ready", clean);
+ start("loading", clean);
+ start("ready", clean);
+ start("ready", { ...makeInfo("premium"), issued_at: 2 });
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+
+ it("passes the revoked state the key was loaded with", () => {
+ const run = vi.fn();
+ createRevocationCheckStarter(run)("ready", makeInfo("base", true));
+ expect(run).toHaveBeenCalledWith(true);
+ });
+
+ it("sends nothing without a stored key, and a key entered later waits for the next start", () => {
+ const run = vi.fn();
+ const start = createRevocationCheckStarter(run);
+ start("ready", null);
+ start("ready", clean);
+ expect(run).not.toHaveBeenCalled();
+ });
+
+ it("is consumed by the first ready state after a boot error too", () => {
+ const run = vi.fn();
+ const start = createRevocationCheckStarter(run);
+ start("error", null);
+ start("loading", null);
+ start("ready", clean);
+ start("ready", clean);
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("performRevocationCheck", () => {
+ function checkHarness(result: RevocationCheck | Error) {
+ const deps: RevocationCheckDeps = {
+ checkLicenseRevocation: vi.fn(async () => {
+ if (result instanceof Error) throw result;
+ return result;
+ }),
+ refresh: vi.fn(async () => {}),
+ logInfo: vi.fn(),
+ logWarn: vi.fn(),
+ };
+ return deps;
+ }
+
+ // Each message written out: comparing against REVOCATION_LOG itself would pass with
+ // two messages swapped.
+ const EXPECTED_LOG: [RevocationOutcome, string][] = [
+ ["no_license", "License check: no valid key stored, nothing sent"],
+ ["not_due", "License check: last attempt less than 24 h ago, nothing sent"],
+ ["valid", "License check: the license server confirms the key"],
+ ["revoked", "License check: the license server says the key was revoked"],
+ ["unreachable", "License check: the license server could not be reached or answered with an error, nothing changed"],
+ ["unknown", "License check: the license server gave no usable answer, nothing changed"],
+ ["storage", "License check: the attempt or the server's answer could not be recorded, nothing changed"],
+ ];
+
+ it("has a log message for every outcome, and no other", () => {
+ expect(Object.keys(REVOCATION_LOG).sort()).toEqual(EXPECTED_LOG.map(([o]) => o).sort());
+ });
+
+ it.each(EXPECTED_LOG)("writes one log line for %s", async (outcome, message) => {
+ const deps = checkHarness({ outcome, revoked: false });
+ await performRevocationCheck(deps, false);
+ expect(deps.logInfo).toHaveBeenCalledTimes(1);
+ expect(deps.logInfo).toHaveBeenCalledWith(message);
+ expect(deps.logWarn).not.toHaveBeenCalled();
+ });
+
+ it("reloads the license when the key turns out revoked", async () => {
+ const deps = checkHarness({ outcome: "revoked", revoked: true });
+ await performRevocationCheck(deps, false);
+ expect(deps.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("reloads the license when a revocation is lifted", async () => {
+ const deps = checkHarness({ outcome: "valid", revoked: false });
+ await performRevocationCheck(deps, true);
+ expect(deps.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not reload when nothing changed", async () => {
+ const unchanged: [RevocationCheck, boolean][] = [
+ [{ outcome: "valid", revoked: false }, false],
+ [{ outcome: "revoked", revoked: true }, true],
+ [{ outcome: "not_due", revoked: true }, true],
+ [{ outcome: "unreachable", revoked: false }, false],
+ [{ outcome: "unreachable", revoked: true }, true],
+ [{ outcome: "no_license", revoked: false }, false],
+ ];
+ for (const [result, wasRevoked] of unchanged) {
+ const deps = checkHarness(result);
+ await performRevocationCheck(deps, wasRevoked);
+ expect(deps.refresh, `${result.outcome} from ${wasRevoked}`).not.toHaveBeenCalled();
+ }
+ });
+
+ it("logs a command that could not run and changes nothing", async () => {
+ const failure = new Error("ipc down");
+ const deps = checkHarness(failure);
+ await expect(performRevocationCheck(deps, false)).resolves.toBeUndefined();
+ expect(deps.logWarn).toHaveBeenCalledWith(
+ "License check: the check could not run, nothing changed",
+ failure,
+ );
+ expect(deps.logInfo).not.toHaveBeenCalled();
+ expect(deps.refresh).not.toHaveBeenCalled();
});
});
diff --git a/src/contexts/LicenseContext.tsx b/src/contexts/LicenseContext.tsx
index 4b9a622..d0bd264 100644
--- a/src/contexts/LicenseContext.tsx
+++ b/src/contexts/LicenseContext.tsx
@@ -5,15 +5,20 @@ import {
useEffect,
useReducer,
useRef,
+ useState,
type ReactNode,
} from "react";
import {
+ checkLicenseRevocation,
getEdition,
readLicense,
storeLicense,
type Edition,
type LicenseInfo,
+ type RevocationCheck,
+ type RevocationOutcome,
} from "../services/licenseService";
+import { logInfo, logWarn } from "../services/logService";
/**
* Machine-level license context — loaded ONCE at boot, above ProfileProvider.
@@ -35,11 +40,12 @@ import {
* it can never arm on a bad key and auto-clear the message the user is reading,
* and `ready` consumers never regress on a typo'd key.
*
- * Revocation (#347, D13): a `license_revoked` answer from the license server is
- * recorded here for the rest of the session. The License card that receives it
- * unmounts when the user leaves Settings → Users or switches profile; this
- * provider does not. Session only — never persisted — and it never touches the
- * edition, which keeps following the local validation of the key (ADR 0017).
+ * Revocation (#365, which replaces D13 of #347): the revoked state lives in Rust,
+ * next to the key, and survives the session. It reaches this context as
+ * `info.revoked`, and Rust resolves the edition of a revoked key to "free". Nothing
+ * here decides it. One request goes out on the app's own initiative: the revocation
+ * check, started once at boot when a key is stored. Rust sends it at most once per
+ * 24 h, and an unreachable server changes nothing.
*/
type LicenseStatus = "idle" | "loading" | "ready" | "error";
@@ -53,18 +59,16 @@ interface LicenseState {
validating: boolean;
/** Key-submission error — never retried, cleared on the next submit. */
validationError: string | null;
- /** The server answered `license_revoked` for the stored key during this session. */
- revoked: boolean;
}
-type LicenseAction =
+/** Exported for the functions below and their tests — not part of the context API. */
+export type LicenseAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "LOAD_ERROR"; error: string }
| { type: "VALIDATE_START" }
- | { type: "VALIDATE_DONE"; info: LicenseInfo }
- | { type: "VALIDATE_ERROR"; error: string }
- | { type: "LICENSE_REVOKED" };
+ | { type: "VALIDATE_DONE"; info: LicenseInfo; edition: Edition }
+ | { type: "VALIDATE_ERROR"; error: string };
/** Exported for unit tests only — not part of the context API. */
export const initialLicenseState: LicenseState = {
@@ -74,7 +78,6 @@ export const initialLicenseState: LicenseState = {
error: null,
validating: false,
validationError: null,
- revoked: false,
};
/**
@@ -87,11 +90,6 @@ export function licenseIdentity(info: LicenseInfo | null): string | null {
: `${info.email}|${info.edition}|${info.issued_at}|${info.expires_at}`;
}
-/** Same license: a re-entered key, not a replacement. */
-function isSameLicense(a: LicenseInfo | null, b: LicenseInfo): boolean {
- return a !== null && licenseIdentity(a) === licenseIdentity(b);
-}
-
/** Exported for unit tests only — not part of the context API. */
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
switch (action.type) {
@@ -106,22 +104,20 @@ export function licenseReducer(state: LicenseState, action: LicenseAction): Lice
case "VALIDATE_START":
return { ...state, validating: true, validationError: null };
case "VALIDATE_DONE":
+ // The edition is the one Rust resolved for the stored key, not the one the
+ // key claims: a revoked key entered again stays in the Free edition (#365).
return {
status: "ready",
- edition: action.info.edition,
+ edition: action.edition,
info: action.info,
error: null,
validating: false,
validationError: null,
- // A replacement key starts clean; the same key re-entered stays revoked.
- revoked: state.revoked && isSameLicense(state.info, action.info),
};
case "VALIDATE_ERROR":
// Status untouched: "ready" stays ready (no gating flash on a typo'd
// key), and a boot-error retry loop keeps running through the failure.
return { ...state, validating: false, validationError: action.error };
- case "LICENSE_REVOKED":
- return { ...state, revoked: true };
}
}
@@ -129,6 +125,91 @@ type SubmitKeyResult =
| { ok: true; info: LicenseInfo }
| { ok: false; error: string };
+export interface SubmitKeyDeps {
+ storeLicense: (key: string) => Promise;
+ getEdition: () => Promise;
+ dispatch: (action: LicenseAction) => void;
+}
+
+/**
+ * Store a key, then take the edition Rust resolves for it: one resolution point, which
+ * knows a revoked key (Free) and an active account Premium. If that read fails, the
+ * key's own edition stands in, or "free" when the key came back revoked.
+ */
+export async function performSubmitKey(deps: SubmitKeyDeps, key: string): Promise {
+ deps.dispatch({ type: "VALIDATE_START" });
+ let info: LicenseInfo;
+ try {
+ info = await deps.storeLicense(key);
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ deps.dispatch({ type: "VALIDATE_ERROR", error: message });
+ return { ok: false, error: message };
+ }
+ let edition: Edition;
+ try {
+ edition = await deps.getEdition();
+ } catch {
+ edition = info.revoked ? "free" : info.edition;
+ }
+ deps.dispatch({ type: "VALIDATE_DONE", info, edition });
+ return { ok: true, info };
+}
+
+/**
+ * Starter of the revocation check (#365, D1): a startup check, nothing more. It is
+ * consumed by the FIRST "ready" state, whatever the key state, and runs the check only
+ * if a key is stored then. A key entered later in the session is first checked at the
+ * next start; a reload of the context never starts it again.
+ */
+export function createRevocationCheckStarter(run: (wasRevoked: boolean) => void) {
+ let consumed = false;
+ return (status: LicenseStatus, info: LicenseInfo | null): void => {
+ if (consumed || status !== "ready") return;
+ consumed = true;
+ if (info !== null) run(info.revoked);
+ };
+}
+
+/** One line of the in-app log per outcome: what was sent, and what it changed. */
+export const REVOCATION_LOG: Record = {
+ no_license: "License check: no valid key stored, nothing sent",
+ not_due: "License check: last attempt less than 24 h ago, nothing sent",
+ valid: "License check: the license server confirms the key",
+ revoked: "License check: the license server says the key was revoked",
+ unreachable: "License check: the license server could not be reached or answered with an error, nothing changed",
+ unknown: "License check: the license server gave no usable answer, nothing changed",
+ storage: "License check: the attempt or the server's answer could not be recorded, nothing changed",
+};
+
+export interface RevocationCheckDeps {
+ checkLicenseRevocation: () => Promise;
+ refresh: () => Promise;
+ logInfo: (...args: unknown[]) => void;
+ logWarn: (...args: unknown[]) => void;
+}
+
+/**
+ * Run the revocation check and write its outcome to the in-app log (Settings →
+ * System → Logs): it is a request the user did not ask for, so it must be visible.
+ * The context is reloaded only when the revoked state changed. A command that fails
+ * is logged and changes nothing.
+ */
+export async function performRevocationCheck(
+ deps: RevocationCheckDeps,
+ wasRevoked: boolean,
+): Promise {
+ let result: RevocationCheck;
+ try {
+ result = await deps.checkLicenseRevocation();
+ } catch (e) {
+ deps.logWarn("License check: the check could not run, nothing changed", e);
+ return;
+ }
+ deps.logInfo(REVOCATION_LOG[result.outcome]);
+ if (result.revoked !== wasRevoked) await deps.refresh();
+}
+
interface LicenseContextValue {
status: LicenseStatus;
edition: Edition;
@@ -137,12 +218,33 @@ interface LicenseContextValue {
error: string | null;
validating: boolean;
validationError: string | null;
- /** The server answered `license_revoked` during this session (#347). */
+ /** The license server revoked the stored key (#365). Persisted by Rust. */
revoked: boolean;
refresh: () => Promise;
submitKey: (key: string) => Promise;
- /** Records a `license_revoked` answer for the rest of the session. Stable across renders. */
- markRevoked: () => void;
+}
+
+/**
+ * What the context exposes of a state. A plain function so the derivation is tested: the
+ * provider's own state never loads under renderToStaticMarkup, where effects do not run.
+ */
+export function licenseContextValue(
+ state: LicenseState,
+ refresh: () => Promise,
+ submitKey: (key: string) => Promise,
+): LicenseContextValue {
+ return {
+ status: state.status,
+ edition: state.edition,
+ features: state.info?.features ?? [],
+ info: state.info,
+ error: state.error,
+ validating: state.validating,
+ validationError: state.validationError,
+ revoked: state.info?.revoked ?? false,
+ refresh,
+ submitKey,
+ };
}
const LicenseContext = createContext(null);
@@ -168,28 +270,29 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
}
}, []);
- const submitKey = useCallback(async (key: string): Promise => {
- dispatch({ type: "VALIDATE_START" });
- try {
- const info = await storeLicense(key);
- dispatch({ type: "VALIDATE_DONE", info });
- return { ok: true, info };
- } catch (e) {
- const message = e instanceof Error ? e.message : String(e);
- dispatch({ type: "VALIDATE_ERROR", error: message });
- return { ok: false, error: message };
- }
- }, []);
-
- const markRevoked = useCallback(() => {
- dispatch({ type: "LICENSE_REVOKED" });
- }, []);
+ const submitKey = useCallback(
+ (key: string) => performSubmitKey({ storeLicense, getEdition, dispatch }, key),
+ [],
+ );
// Load once at boot.
useEffect(() => {
void refresh();
}, [refresh]);
+ // Revocation check: started once per app start (see createRevocationCheckStarter).
+ const [startRevocationCheck] = useState(() =>
+ createRevocationCheckStarter((wasRevoked) => {
+ void performRevocationCheck(
+ { checkLicenseRevocation, refresh, logInfo, logWarn },
+ wasRevoked,
+ );
+ }),
+ );
+ useEffect(() => {
+ startRevocationCheck(state.status, state.info);
+ }, [startRevocationCheck, state.status, state.info]);
+
// Error recovery: retry with capped exponential backoff (CWE-703). Load
// errors only by construction — validation failures never set status.
useEffect(() => {
@@ -207,19 +310,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
return () => window.clearTimeout(timer);
}, [state.status, refresh]);
- const value: LicenseContextValue = {
- status: state.status,
- edition: state.edition,
- features: state.info?.features ?? [],
- info: state.info,
- error: state.error,
- validating: state.validating,
- validationError: state.validationError,
- revoked: state.revoked,
- refresh,
- submitKey,
- markRevoked,
- };
+ const value = licenseContextValue(state, refresh, submitKey);
return {children};
}
diff --git a/src/hooks/useIsPremium.test.ts b/src/hooks/useIsPremium.test.ts
index fa63826..9496ed8 100644
--- a/src/hooks/useIsPremium.test.ts
+++ b/src/hooks/useIsPremium.test.ts
@@ -23,7 +23,6 @@ describe("useIsPremium", () => {
revoked: false,
refresh: vi.fn(),
submitKey: vi.fn(),
- markRevoked: vi.fn(),
});
expect(useIsPremium()).toBe(true);
});
@@ -40,7 +39,6 @@ describe("useIsPremium", () => {
revoked: false,
refresh: vi.fn(),
submitKey: vi.fn(),
- markRevoked: vi.fn(),
});
expect(useIsPremium()).toBe(false);
});
@@ -57,7 +55,6 @@ describe("useIsPremium", () => {
revoked: false,
refresh: vi.fn(),
submitKey: vi.fn(),
- markRevoked: vi.fn(),
});
expect(useIsPremium()).toBe(false);
});
diff --git a/src/hooks/useLicenseMachines.test.ts b/src/hooks/useLicenseMachines.test.ts
index a2047cf..acd0c27 100644
--- a/src/hooks/useLicenseMachines.test.ts
+++ b/src/hooks/useLicenseMachines.test.ts
@@ -28,7 +28,7 @@ import {
} from "../services/licenseService";
// The hook itself reads the LicenseContext; the pure functions below never do.
-const context = { refresh: vi.fn(async () => {}), revoked: false, markRevoked: vi.fn() };
+const context = { refresh: vi.fn(async () => {}), revoked: false };
vi.mock("../contexts/LicenseContext", () => ({ useLicenseContext: () => context }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
@@ -303,7 +303,6 @@ function harness(overrides: Partial = {}) {
),
getActivationStatus: vi.fn(async () => ({ is_activated: true })),
refresh: vi.fn(async () => {}),
- markRevoked: vi.fn(),
dispatch: (action) => {
state = licenseMachinesReducer(state, action);
},
@@ -377,10 +376,10 @@ describe("performActivate", () => {
it("neither loads the list nor refreshes on other refusals", async () => {
const { deps, getState } = harness({
- activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_revoked") })),
+ activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })),
});
await performActivate(deps);
- expect(getState().activationError?.code).toBe("license_revoked");
+ expect(getState().activationError?.code).toBe("license_expired");
expect(deps.listActivatedMachines).not.toHaveBeenCalled();
expect(deps.refresh).not.toHaveBeenCalled();
});
@@ -438,39 +437,42 @@ describe("performDeactivate", () => {
});
});
-// A revoked key stays flagged for the session through the LicenseContext (#347), and
-// the three server calls can each be the one that learns it.
-describe("revocation is recorded for the session", () => {
+// The Rust command that receives `license_revoked` persists it (#365): each of the three
+// server calls reloads the LicenseContext, so the edition (Free) and the card follow.
+describe("a revocation reloads the license context", () => {
const revokedResult = { ok: false as const, error: err("license_revoked") };
- it("from an activation", async () => {
- const { deps } = harness({ activateMachine: vi.fn(async () => revokedResult) });
+ it("from an activation, without loading the list", async () => {
+ const { deps, getState } = harness({ activateMachine: vi.fn(async () => revokedResult) });
await performActivate(deps);
- expect(deps.markRevoked).toHaveBeenCalledTimes(1);
+ expect(deps.refresh).toHaveBeenCalledTimes(1);
+ expect(deps.listActivatedMachines).not.toHaveBeenCalled();
+ expect(getState().activationError?.code).toBe("license_revoked");
});
it("from the machine list", async () => {
const { deps, getState } = harness({ listActivatedMachines: vi.fn(async () => revokedResult) });
await loadMachines(deps);
- expect(deps.markRevoked).toHaveBeenCalledTimes(1);
+ expect(deps.refresh).toHaveBeenCalledTimes(1);
expect(getState().listError?.code).toBe("license_revoked");
});
it("from a deactivation", async () => {
const { deps } = harness({ deactivateMachine: vi.fn(async () => revokedResult) });
await performDeactivate(deps, "a");
- expect(deps.markRevoked).toHaveBeenCalledTimes(1);
+ expect(deps.refresh).toHaveBeenCalledTimes(1);
});
- it("never from another refusal or a success", async () => {
+ it("never from another refusal", async () => {
const { deps } = harness({
activateMachine: vi.fn(async () => ({ ok: false as const, error: err("license_expired") })),
listActivatedMachines: vi.fn(async () => ({ ok: false as const, error: err("network") })),
+ deactivateMachine: vi.fn(async () => ({ ok: false as const, error: err("server", { status: 502 }) })),
});
await performActivate(deps);
await loadMachines(deps);
await performDeactivate(deps, "a");
- expect(deps.markRevoked).not.toHaveBeenCalled();
+ expect(deps.refresh).not.toHaveBeenCalled();
});
});
@@ -490,11 +492,11 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
beforeEach(() => {
context.revoked = false;
- context.markRevoked.mockClear();
+ context.refresh.mockClear();
vi.mocked(invoke).mockReset();
});
- it("reads the session revocation and blocks Activate", () => {
+ it("reads the revocation from the context and blocks Activate", () => {
context.revoked = true;
const view = renderHook();
expect(view.revoked).toBe(true);
@@ -507,10 +509,10 @@ describe("useLicenseMachines wiring to the LicenseContext", () => {
expect(view.canActivate).toBe(true);
});
- it("hands the context's markRevoked to the server calls", async () => {
+ it("hands the context's refresh to the server calls", async () => {
vi.mocked(invoke).mockRejectedValue('{"code":"license_revoked"}');
const view = renderHook();
await view.activate();
- expect(context.markRevoked).toHaveBeenCalledTimes(1);
+ expect(context.refresh).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/hooks/useLicenseMachines.ts b/src/hooks/useLicenseMachines.ts
index 466f979..d198db9 100644
--- a/src/hooks/useLicenseMachines.ts
+++ b/src/hooks/useLicenseMachines.ts
@@ -20,9 +20,10 @@ import { useLicenseContext } from "../contexts/LicenseContext";
// verify, and nothing loads the list from an effect (StrictMode would double
// it). List loads come only from explicit handlers.
//
-// A `license_revoked` answer, from any of the three calls, is recorded in the
-// LicenseContext for the rest of the session (#347): this hook's own state dies
-// with the card when the user leaves Settings → Users or switches profile.
+// A `license_revoked` answer, from any of the three calls, is persisted by the Rust
+// command that received it (#365): this hook only reloads the LicenseContext, so the
+// edition (Free) and the card follow. The check made at app start shares that budget:
+// with it, the machine-limit path reaches the 5 requests of the first minute.
export interface LicenseMachinesState {
activation: ActivationStatus | null;
@@ -162,14 +163,15 @@ export interface LicenseMachinesDeps {
listActivatedMachines: () => Promise>;
getActivationStatus: () => Promise;
refresh: () => Promise;
- /** Records a `license_revoked` answer in the LicenseContext for the session. */
- markRevoked: () => void;
dispatch: (action: LicenseMachinesAction) => void;
}
-/** A revoked key stays flagged for the session, whichever call learned it. */
-function noteRevocation(deps: LicenseMachinesDeps, error: LicenseError): void {
- if (error.code === "license_revoked") deps.markRevoked();
+/**
+ * Rust has just persisted the revocation, whichever call learned it: reload the license
+ * so the edition and the revoked state follow.
+ */
+async function noteRevocation(deps: LicenseMachinesDeps, error: LicenseError): Promise {
+ if (error.code === "license_revoked") await deps.refresh();
}
/** Local read (no network). Best effort: a failure keeps the last known status. */
@@ -187,7 +189,7 @@ export async function loadMachines(deps: LicenseMachinesDeps): Promise {
const result = await deps.listActivatedMachines();
if (!result.ok) {
deps.dispatch({ type: "LIST_LOAD_ERROR", error: result.error });
- noteRevocation(deps, result.error);
+ await noteRevocation(deps, result.error);
return;
}
deps.dispatch({ type: "LIST_LOAD_DONE", machines: result.value });
@@ -198,7 +200,7 @@ export async function performActivate(deps: LicenseMachinesDeps): Promise
const result = await deps.activateMachine();
if (!result.ok) {
deps.dispatch({ type: "ACTIVATE_ERROR", error: result.error });
- noteRevocation(deps, result.error);
+ await noteRevocation(deps, result.error);
if (result.error.code === "machine_limit_reached") {
await loadMachines(deps);
}
@@ -216,7 +218,7 @@ export async function performDeactivate(deps: LicenseMachinesDeps, machineId: st
const result = await deps.deactivateMachine(machineId);
if (!result.ok) {
deps.dispatch({ type: "DEACTIVATE_ERROR", error: result.error });
- noteRevocation(deps, result.error);
+ await noteRevocation(deps, result.error);
return;
}
// Removed locally, no verify call: see the rate budget note at the top.
@@ -231,7 +233,7 @@ export async function performDeactivate(deps: LicenseMachinesDeps, machineId: st
* free edition, where the card shows no machine section.
*/
export function useLicenseMachines(enabled: boolean) {
- const { refresh, revoked, markRevoked } = useLicenseContext();
+ const { refresh, revoked } = useLicenseContext();
const [state, dispatch] = useReducer(licenseMachinesReducer, initialLicenseMachinesState);
const deps = useMemo(
@@ -241,10 +243,9 @@ export function useLicenseMachines(enabled: boolean) {
listActivatedMachines,
getActivationStatus,
refresh,
- markRevoked,
dispatch,
}),
- [refresh, markRevoked],
+ [refresh],
);
// Local activation status only (get_activation_status reads the token on
@@ -263,7 +264,7 @@ export function useLicenseMachines(enabled: boolean) {
return {
state,
- /** The key was revoked during this session (LicenseContext, #347). */
+ /** The license server revoked the stored key (LicenseContext, #365). */
revoked,
activate,
deactivate,
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 66dd8ae..c568519 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1098,7 +1098,7 @@
"The user guide can be printed or exported to PDF via the Print button",
"Logs persist for the session — they survive a page refresh",
"If you encounter an issue, copy the logs and attach them to your report",
- "Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
+ "The app only contacts a server for updates, Maximus sign-in, feedback, the license (activation and check) and market prices. At startup, one request goes out on its own: the license check, at most once every 24 hours and only when a key is stored. No telemetry"
]
},
"editions": {
@@ -1118,7 +1118,7 @@
"Modules unlock immediately — no reinstall or restart needed"
],
"tips": [
- "Locking is never destructive: if your edition goes down (expired or invalid key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
+ "Locking is never destructive: if your edition goes down (expired, invalid or revoked key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
@@ -1126,7 +1126,8 @@
"Activating a machine binds it to your key on the license server; it only serves the market prices of the Balance (Premium), and your edition never depends on it. After a reinstall, a new computer, a different key or an expired activation, the card shows \"Not activated\": click \"Activate\"",
"Activation sends the license server your key, this computer's name and a derived identifier, specific to Simpl'Résultat, in place of the system's machine identifier",
"The machine list shows how many machines are activated out of the number your key allows; deactivate one to free a slot",
- "If the license server reports that your key was revoked, the License card says so until the app is closed and no longer offers \"Activate\""
+ "If the license server reports that your key was revoked, for instance after a refund, the app goes back to the Free edition and stays there from one session to the next: the License card, the locked screens and the Updates card say so. Your data is kept, and another valid key gives its edition back",
+ "When a key is stored, the app asks the license server whether it was revoked: at startup, at most once every 24 hours, sending the product and the key, nothing else. Without a stored key no request is sent; if the server does not answer, nothing changes. Each check is written to the logs (Settings → System). The check goes on after a revocation, as long as the key stays stored: a correction made by the license server is thus picked up at the next start. Only storing another key stops the previous one from being sent"
]
}
},
@@ -1243,7 +1244,8 @@
},
"ctaGet": "Get {{tier}}",
"premiumNotForSale": "Premium isn't sold online yet.",
- "ctaHaveKey": "I already have a key"
+ "ctaHaveKey": "I already have a key",
+ "revoked": "Your license key was revoked: the Free edition applies."
},
"account": {
"title": "Maximus Account",
@@ -2097,7 +2099,7 @@
"missingParam": "Missing parameter",
"authFailed": "Authentication failed — activate this machine in Settings → Users",
"premiumRequired": "This feature requires a premium subscription",
- "licenseRevoked": "License revoked",
+ "licenseRevoked": "This machine's activation belongs to a revoked license",
"symbolNotFound": "Symbol not found",
"rateLimit": "Too many requests — retry in {{seconds}}s",
"serverUnavailable": "Server unavailable — try again later",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 956600c..3fc77c4 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -1098,7 +1098,7 @@
"Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer",
"Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page",
"En cas de problème, copiez les journaux et joignez-les à votre signalement",
- "Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
+ "L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Au démarrage, une seule requête part d'elle-même : la vérification de la licence, au plus une fois par 24 heures et seulement si une clé est enregistrée. Aucune télémétrie"
]
},
"editions": {
@@ -1118,7 +1118,7 @@
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire"
],
"tips": [
- "Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée ou invalide), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
+ "Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide ou révoquée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
"En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés",
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
@@ -1126,7 +1126,8 @@
"Activer une machine la lie à votre clé auprès du serveur de licences ; cela ne sert qu'aux cours du marché du Bilan (Premium), votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » : cliquez sur « Activer »",
"L'activation envoie au serveur de licences votre clé, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système",
"La liste des machines indique combien sont activées sur le nombre permis par votre clé ; désactivez-en une pour libérer une place",
- "Si le serveur de licences signale que votre clé a été révoquée, la carte Licence l'indique jusqu'à la fermeture de l'application et ne propose plus « Activer »"
+ "Si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition",
+ "Quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage, au plus une fois par 24 heures, en envoyant le produit et la clé, rien d'autre. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Seul l'enregistrement d'une autre clé arrête l'envoi de la précédente"
]
}
},
@@ -1243,7 +1244,8 @@
},
"ctaGet": "Obtenir {{tier}}",
"premiumNotForSale": "Premium n'est pas encore offert en ligne.",
- "ctaHaveKey": "J'ai déjà une clé"
+ "ctaHaveKey": "J'ai déjà une clé",
+ "revoked": "Votre clé de licence a été révoquée : l'édition Gratuite s'applique."
},
"account": {
"title": "Compte Maximus",
@@ -2097,7 +2099,7 @@
"missingParam": "Paramètre manquant",
"authFailed": "Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs",
"premiumRequired": "Cette fonction nécessite un abonnement premium",
- "licenseRevoked": "Licence révoquée",
+ "licenseRevoked": "L'activation de cette machine est liée à une licence révoquée",
"symbolNotFound": "Symbole introuvable",
"rateLimit": "Trop de requêtes — réessaie dans {{seconds}} s",
"serverUnavailable": "Serveur indisponible — réessaie plus tard",
diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts
index 8d48398..7276f9b 100644
--- a/src/services/balance.service.test.ts
+++ b/src/services/balance.service.test.ts
@@ -23,6 +23,8 @@ vi.mock("./profileService", () => ({
import { getDb } from "./db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "./profileService";
+import fr from "../i18n/locales/fr.json";
+import en from "../i18n/locales/en.json";
import {
listBalanceCategories,
createBalanceCategory,
@@ -2104,6 +2106,36 @@ describe("balance.service.prices", () => {
expect(invoke).toHaveBeenCalledTimes(1);
});
+ // 3b. 403 license_revoked — named as a revocation, no retry (#365). Before, the Rust
+ // command mapped every 403 to premium_required.
+ it("fetchPrice license_revoked is named as a revocation, without retry", async () => {
+ vi.mocked(invoke).mockRejectedValueOnce('{"code":"license_revoked"}');
+
+ const promise = prices.fetchPrice("AAPL", "2026-04-25");
+ await vi.runAllTimersAsync();
+ const result = await promise;
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe("license_revoked");
+ expect(result.error.i18nKey).toBe(
+ "balance.priceFetching.errors.licenseRevoked"
+ );
+ }
+ expect(invoke).toHaveBeenCalledTimes(1);
+ });
+
+ // The message exists in both locales, and names the activation rather than the key:
+ // the price route judges the license of the activation token, which may not be the
+ // stored key's (#365, #367).
+ it("has the revoked-license price message in both locales", () => {
+ for (const [lng, messages] of Object.entries({ fr, en })) {
+ const text = messages.balance.priceFetching.errors.licenseRevoked;
+ expect(text, lng).toBeTruthy();
+ expect(text, lng).toMatch(/activation/i);
+ }
+ });
+
// 4. 404 symbol_not_found — no retry
it("fetchPrice symbol_not_found returns immediately without retry", async () => {
vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}');
diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts
index f060968..3aa6461 100644
--- a/src/services/balance.service.ts
+++ b/src/services/balance.service.ts
@@ -2614,6 +2614,7 @@ export function isLinkedTransactionFkError(error: unknown): boolean {
// Annexe B i18n mapping (keys live in the i18n PR #160):
// auth → balance.priceFetching.errors.authFailed
// premium_required → balance.priceFetching.errors.premiumRequired
+// license_revoked → balance.priceFetching.errors.licenseRevoked
// symbol_not_found → balance.priceFetching.errors.symbolNotFound
// rate_limit → balance.priceFetching.errors.rateLimit
// provider_unavailable → balance.priceFetching.errors.serverUnavailable
@@ -2624,6 +2625,7 @@ export function isLinkedTransactionFkError(error: unknown): boolean {
export type PriceErrorCode =
| "auth"
| "premium_required"
+ | "license_revoked"
| "symbol_not_found"
| "rate_limit"
| "provider_unavailable"
@@ -2667,6 +2669,9 @@ interface RawPriceResponse {
const PRICE_ERROR_I18N_MAP: Record, string> = {
auth: "balance.priceFetching.errors.authFailed",
premium_required: "balance.priceFetching.errors.premiumRequired",
+ // The license of this machine's activation was revoked (#365). Named, never taken
+ // as the state of the stored key: Rust cannot tie the activation token to that key.
+ license_revoked: "balance.priceFetching.errors.licenseRevoked",
symbol_not_found: "balance.priceFetching.errors.symbolNotFound",
provider_unavailable: "balance.priceFetching.errors.serverUnavailable",
network: "balance.priceFetching.errors.serverUnavailable",
@@ -2678,6 +2683,7 @@ const PRICE_ERROR_I18N_MAP: Record, string
const NO_RETRY_CODES = new Set([
"auth",
"premium_required",
+ "license_revoked",
"symbol_not_found",
"rate_limit",
"session_cap_reached",
diff --git a/src/services/entitlements.test.ts b/src/services/entitlements.test.ts
index a50f2e2..621762f 100644
--- a/src/services/entitlements.test.ts
+++ b/src/services/entitlements.test.ts
@@ -115,6 +115,7 @@ describe("upsell i18n coverage (fr + en)", () => {
ctaGet: string;
premiumNotForSale: string;
ctaHaveKey: string;
+ revoked: string;
};
}
const locales: Record = { fr, en };
@@ -144,6 +145,7 @@ describe("upsell i18n coverage (fr + en)", () => {
expect(messages.upsell.ctaGet).toBeTruthy();
expect(messages.upsell.premiumNotForSale).toBeTruthy();
expect(messages.upsell.ctaHaveKey).toBeTruthy();
+ expect(messages.upsell.revoked).toBeTruthy();
expect(messages.nav.locked).toBeTruthy();
});
});
@@ -157,6 +159,7 @@ describe("upsell i18n coverage (fr + en)", () => {
"ctaHaveKey",
"features",
"premiumNotForSale",
+ "revoked",
"title",
]);
});
diff --git a/src/services/licenseService.test.ts b/src/services/licenseService.test.ts
index afce3bf..b0484c9 100644
--- a/src/services/licenseService.test.ts
+++ b/src/services/licenseService.test.ts
@@ -4,6 +4,7 @@ import {
parseLicenseError,
LICENSE_ERROR_I18N_MAP,
activateMachine,
+ checkLicenseRevocation,
deactivateMachine,
listActivatedMachines,
type LicenseErrorCode,
@@ -131,6 +132,13 @@ describe("license command wrappers", () => {
await expect(deactivateMachine("m2")).resolves.toEqual({ ok: true, value: undefined });
expect(mockInvoke).toHaveBeenCalledWith("deactivate_machine", { machineId: "m2" });
});
+
+ it("runs the revocation check without any argument", async () => {
+ // The webview sends nothing of its own: Rust reads the stored key (#365).
+ mockInvoke.mockResolvedValueOnce({ outcome: "revoked", revoked: true });
+ await expect(checkLicenseRevocation()).resolves.toEqual({ outcome: "revoked", revoked: true });
+ expect(mockInvoke).toHaveBeenCalledWith("check_license_revocation");
+ });
});
// The render tests echo i18n keys instead of translating them: a key missing
diff --git a/src/services/licenseService.ts b/src/services/licenseService.ts
index 5918e26..f340c6f 100644
--- a/src/services/licenseService.ts
+++ b/src/services/licenseService.ts
@@ -9,6 +9,12 @@ export interface LicenseInfo {
machine_limit: number;
issued_at: number;
expires_at: number;
+ /**
+ * The license server answered "revoked" for this key and nothing lifted it since
+ * (#365). Rust keeps that state next to the key: the edition it resolves is then
+ * "free", whatever `edition` says here about the key itself.
+ */
+ revoked: boolean;
}
export async function validateLicenseKey(key: string): Promise {
@@ -31,6 +37,35 @@ export async function checkEntitlement(feature: string): Promise {
return invoke("check_entitlement", { feature });
}
+/**
+ * How a revocation check ended (#365), as the Rust `RevocationOutcome` enum serializes.
+ * `no_license` and `not_due`: nothing was sent. `storage`: the attempt could not be
+ * written, so nothing was sent, or the server's verdict could not be, so nothing changed.
+ */
+export type RevocationOutcome =
+ | "no_license"
+ | "not_due"
+ | "valid"
+ | "revoked"
+ | "unreachable"
+ | "unknown"
+ | "storage";
+
+export interface RevocationCheck {
+ outcome: RevocationOutcome;
+ /** Whether the stored key is known as revoked after the check. */
+ revoked: boolean;
+}
+
+/**
+ * Ask the license server whether the stored key was revoked. Rust sends nothing without
+ * a stored key, nor when the last attempt is less than 24 h old, and an unreachable
+ * server changes nothing. The request carries the product and the key only.
+ */
+export async function checkLicenseRevocation(): Promise {
+ return invoke("check_license_revocation");
+}
+
export interface MachineInfo {
/**
* Identifier of the row on the license server: derived (64 hex characters) for an