Simpl-Resultat/src/contexts/LicenseContext.tsx
le king fu ab78aa9ddc
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m44s
PR Check — Rust / rust (pull_request) Successful in 3m25s
fix(license): check a revoked key as soon as it is entered
A license key entered during a session was only checked against the
license server at the next start: a revoked key kept its edition until
the app restarted, or until a machine activation was refused (#365
checked at start only).

Front: performSubmitKey launches the revocation check after
VALIDATE_DONE, without waiting for it, and never after a refused key.
SubmitKeyDeps gets a single launcher, startRevocationCheck(wasRevoked),
built once by the provider with revocationCheckLauncher and shared with
the start check, whose starter is unchanged.

Rust: the command runs the checks one at a time. A static
tauri::async_runtime::Mutex (REVOCATION_CHECK_TURN) is held by
check_revocation_in_turn over the whole check, request and verdict
included, and the time is read once the turn has come. A key entered
before the first ready state, checked by the entry and by the start,
sends one request; the check of a key entered while another one is in
flight waits for its verdict instead of overwriting license.check.
check_revocation_in is unchanged, and so are Cargo.toml and Cargo.lock.

Docs: ADR 0017 (amendment #365, point 5), user guide, in-app docs
(docs.settings.tips, docs.editions.tips), architecture, and both
changelogs under [Unreleased] (Fixed / Corrigé).

Resolves #373

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:26:10 -04:00

349 lines
13 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
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.
*
* Modeled on ProfileContext (createContext<T | null>, useReducer, consumer hook
* that throws). Mounting above the profile layer means it survives the
* `BrowserRouter key={refreshKey}` remount that a profile switch triggers — the
* license is a property of the machine, not of the active profile.
*
* Error recovery (CWE-703): the provider is a SPOF. If the boot invoke throws,
* we expose a NEUTRAL state (previous edition preserved, `status: "error"`) and
* retry with capped exponential backoff. Consumers must render a neutral
* placeholder while `status !== "ready"` — never the upsell — so a transient IPC
* failure never locks a paying user out.
*
* Key validation is orthogonal to that load lifecycle: a rejected `submitKey`
* leaves the stored license — and therefore `status` — untouched, and surfaces
* `validationError` instead. The retry loop keys off `status === "error"`, so
* 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 (#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 at boot when a key is stored, and after a key is entered (#373),
* in the background. Rust runs the checks one at a time and sends at most once per
* 24 h for a key, and an unreachable server changes nothing.
*/
type LicenseStatus = "idle" | "loading" | "ready" | "error";
interface LicenseState {
status: LicenseStatus;
edition: Edition;
info: LicenseInfo | null;
/** Load-lifecycle error — target of the CWE-703 retry loop. */
error: string | null;
validating: boolean;
/** Key-submission error — never retried, cleared on the next submit. */
validationError: string | null;
}
/** 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; edition: Edition }
| { type: "VALIDATE_ERROR"; error: string };
/** Exported for unit tests only — not part of the context API. */
export const initialLicenseState: LicenseState = {
status: "idle",
edition: "free",
info: null,
error: null,
validating: false,
validationError: null,
};
/**
* Identity of the stored license, as far as the client can tell (#347): the same email,
* edition, issue and expiry dates mean the same key, entered again. `null` without a key.
*/
export function licenseIdentity(info: LicenseInfo | null): string | null {
return info === null
? null
: `${info.email}|${info.edition}|${info.issued_at}|${info.expires_at}`;
}
/** Exported for unit tests only — not part of the context API. */
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return { ...state, status: "ready", edition: action.edition, info: action.info, error: null };
case "LOAD_ERROR":
// Preserve the last-known edition: an error must not downgrade a paying
// user to the upsell (that is what fail-closed + `ready` guard protect).
return { ...state, status: "error", error: action.error };
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.edition,
info: action.info,
error: null,
validating: false,
validationError: null,
};
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 };
}
}
type SubmitKeyResult =
| { ok: true; info: LicenseInfo }
| { ok: false; error: string };
export interface SubmitKeyDeps {
storeLicense: (key: string) => Promise<LicenseInfo>;
getEdition: () => Promise<Edition>;
dispatch: (action: LicenseAction) => void;
/**
* The provider's one launcher of the revocation check (#373): it returns at once and
* the check runs in the background (see `revocationCheckLauncher`).
*/
startRevocationCheck: (wasRevoked: boolean) => 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.
*
* A stored key is then checked online right away (#373), not at the next start: the
* check is launched after VALIDATE_DONE and not awaited, so the entry answers without
* waiting for the license server. A revoked key shows the key's edition until the
* check answers, then the reload brings Free. A refused key sends nothing.
*/
export async function performSubmitKey(deps: SubmitKeyDeps, key: string): Promise<SubmitKeyResult> {
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 });
deps.startRevocationCheck(info.revoked);
return { ok: true, info };
}
/**
* Starter of the revocation check at app start (#365, D1). It is consumed by the FIRST
* "ready" state, whatever the key state, and runs the check only if a key is stored then;
* a reload of the context never starts it again. A key entered later is checked by the
* entry itself (`performSubmitKey`, #373). A key entered before the first ready state gets
* two checks from here, the entry's and this starter's: Rust runs the checks one at a
* time, and the second one reads the first one's attempt and sends nothing.
*/
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<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",
};
export interface RevocationCheckDeps {
checkLicenseRevocation: () => Promise<RevocationCheck>;
refresh: () => Promise<void>;
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<void> {
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();
}
/**
* The one launcher of the revocation check, shared by the start (`createRevocationCheckStarter`)
* and the entry of a key (`performSubmitKey`, #373). It returns at once: the check runs in the
* background, so nothing that launches it waits for the license server.
*/
export function revocationCheckLauncher(deps: RevocationCheckDeps): (wasRevoked: boolean) => void {
return (wasRevoked) => {
void performRevocationCheck(deps, wasRevoked);
};
}
interface LicenseContextValue {
status: LicenseStatus;
edition: Edition;
features: string[];
info: LicenseInfo | null;
error: string | null;
validating: boolean;
validationError: string | null;
/** The license server revoked the stored key (#365). Persisted by Rust. */
revoked: boolean;
refresh: () => Promise<void>;
submitKey: (key: string) => Promise<SubmitKeyResult>;
}
/**
* 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<void>,
submitKey: (key: string) => Promise<SubmitKeyResult>,
): 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<LicenseContextValue | null>(null);
// Capped exponential backoff for boot-error retries: 1s, 2s, 4s, ... max 30s.
const RETRY_BASE_MS = 1000;
const RETRY_MAX_MS = 30_000;
export function LicenseProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(licenseReducer, initialLicenseState);
const retryAttempt = useRef(0);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
const [edition, info] = await Promise.all([getEdition(), readLicense()]);
dispatch({ type: "LOAD_DONE", edition, info });
} catch (e) {
dispatch({
type: "LOAD_ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
// Revocation check: launched at app start (see createRevocationCheckStarter) and after a
// key is entered (see performSubmitKey), by the same launcher.
const [launchRevocationCheck] = useState(() =>
revocationCheckLauncher({ checkLicenseRevocation, refresh, logInfo, logWarn }),
);
const submitKey = useCallback(
(key: string) =>
performSubmitKey(
{ storeLicense, getEdition, dispatch, startRevocationCheck: launchRevocationCheck },
key,
),
[launchRevocationCheck],
);
// Load once at boot.
useEffect(() => {
void refresh();
}, [refresh]);
const [checkAtStart] = useState(() => createRevocationCheckStarter(launchRevocationCheck));
useEffect(() => {
checkAtStart(state.status, state.info);
}, [checkAtStart, state.status, state.info]);
// Error recovery: retry with capped exponential backoff (CWE-703). Load
// errors only by construction — validation failures never set status.
useEffect(() => {
if (state.status === "ready") {
retryAttempt.current = 0;
return;
}
if (state.status !== "error") return;
const attempt = retryAttempt.current;
retryAttempt.current = attempt + 1;
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
const timer = window.setTimeout(() => {
void refresh();
}, delay);
return () => window.clearTimeout(timer);
}, [state.status, refresh]);
const value = licenseContextValue(state, refresh, submitKey);
return <LicenseContext.Provider value={value}>{children}</LicenseContext.Provider>;
}
export function useLicenseContext(): LicenseContextValue {
const ctx = useContext(LicenseContext);
if (!ctx) throw new Error("useLicenseContext must be used within LicenseProvider");
return ctx;
}