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, 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; getEdition: () => Promise; 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 { 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 = { 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(); } /** * 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; submitKey: (key: string) => Promise; } /** * 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); // 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 {children}; } export function useLicenseContext(): LicenseContextValue { const ctx = useContext(LicenseContext); if (!ctx) throw new Error("useLicenseContext must be used within LicenseProvider"); return ctx; }