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>
349 lines
13 KiB
TypeScript
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;
|
|
}
|