fix(gating): keep key-validation errors out of the load lifecycle (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m21s
PR Check / frontend (pull_request) Successful in 2m32s

A rejected submitKey dispatched the same ERROR action as a failed boot
load, so the CWE-703 retry backoff armed on it and the auto refresh
(LOAD_START) cleared the "invalid key" message ~1s after submit —
LicenseCard has no local error state, the context is the only source.

Split the state: load lifecycle (status/error, retried) vs validation
(validating/validationError, never retried). VALIDATE_ERROR leaves
status untouched, so a ready license stays ready on a typo'd key (no
`ready` regression for gating consumers) and a boot-error retry loop
keeps running through a failed validation. Reducer + initial state
exported for tests, covered by LicenseContext.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-20 21:21:18 -04:00
parent fd7e053239
commit b9e13b5bca
4 changed files with 164 additions and 18 deletions

View file

@ -16,7 +16,8 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
export default function LicenseCard() { export default function LicenseCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { status: licenseStatus, edition, info, error, submitKey } = useLicenseContext(); const { status: licenseStatus, edition, info, error, validating, validationError, submitKey } =
useLicenseContext();
const [keyInput, setKeyInput] = useState(""); const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false); const [showInput, setShowInput] = useState(false);
const [showMachines, setShowMachines] = useState(false); const [showMachines, setShowMachines] = useState(false);
@ -178,13 +179,19 @@ export default function LicenseCard() {
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]" className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
autoFocus autoFocus
/> />
{validationError && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{validationError}</p>
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={licenseStatus === "validating" || !keyInput.trim()} disabled={validating || !keyInput.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
> >
{licenseStatus === "validating" && <Loader2 size={14} className="animate-spin" />} {validating && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")} {t("license.activate")}
</button> </button>
<button <button

View file

@ -0,0 +1,102 @@
import { describe, it, expect } from "vitest";
import { licenseReducer, initialLicenseState } from "./LicenseContext";
import type { Edition, LicenseInfo } from "../services/licenseService";
const makeInfo = (edition: Exclude<Edition, "free">): LicenseInfo => ({
edition,
email: "max@example.com",
features: [],
machine_limit: 3,
issued_at: 1,
expires_at: 0,
});
// The CWE-703 retry loop arms exclusively on `status === "error"`, and its
// LOAD_START clears `error`. A rejected key must therefore never re-enter the
// load lifecycle: it would arm the auto refresh, which would wipe the very
// message the user is reading (~1s after submitting an invalid key).
const premiumReady = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_DONE", edition: "premium", info: makeInfo("premium") },
);
describe("licenseReducer — load lifecycle (CWE-703)", () => {
it("LOAD_ERROR preserves the last-known edition and info", () => {
const errored = licenseReducer(premiumReady, { type: "LOAD_ERROR", error: "ipc down" });
expect(errored.status).toBe("error");
expect(errored.error).toBe("ipc down");
expect(errored.edition).toBe("premium");
expect(errored.info).toEqual(makeInfo("premium"));
});
it("LOAD_START clears the load error but not a pending validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const refreshed = licenseReducer(rejected, { type: "LOAD_START" });
expect(refreshed.status).toBe("loading");
expect(refreshed.error).toBeNull();
expect(refreshed.validationError).toBe("invalid key");
});
});
describe("licenseReducer — key validation is orthogonal to the load lifecycle", () => {
it("VALIDATE_ERROR on a ready license keeps status ready (retry never arms)", () => {
const validating = licenseReducer(premiumReady, { type: "VALIDATE_START" });
expect(validating.status).toBe("ready");
expect(validating.validating).toBe(true);
const rejected = licenseReducer(validating, { type: "VALIDATE_ERROR", error: "invalid key" });
expect(rejected.status).toBe("ready");
expect(rejected.validating).toBe(false);
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("premium");
expect(rejected.info).toEqual(makeInfo("premium"));
});
it("VALIDATE_ERROR during a boot error keeps status error (load retry keeps running)", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const rejected = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
expect(rejected.status).toBe("error");
expect(rejected.error).toBe("boot fail");
expect(rejected.validationError).toBe("invalid key");
expect(rejected.edition).toBe("free");
});
it("VALIDATE_START clears the previous validation error", () => {
const rejected = licenseReducer(
licenseReducer(premiumReady, { type: "VALIDATE_START" }),
{ type: "VALIDATE_ERROR", error: "invalid key" },
);
const resubmit = licenseReducer(rejected, { type: "VALIDATE_START" });
expect(resubmit.validating).toBe(true);
expect(resubmit.validationError).toBeNull();
});
it("VALIDATE_DONE resets the full state, recovering from a boot error", () => {
const bootError = licenseReducer(
licenseReducer(initialLicenseState, { type: "LOAD_START" }),
{ type: "LOAD_ERROR", error: "boot fail" },
);
const done = licenseReducer(
licenseReducer(bootError, { type: "VALIDATE_START" }),
{ type: "VALIDATE_DONE", info: makeInfo("base") },
);
expect(done).toEqual({
status: "ready",
edition: "base",
info: makeInfo("base"),
error: null,
validating: false,
validationError: null,
});
});
});

View file

@ -28,45 +28,71 @@ import {
* retry with capped exponential backoff. Consumers must render a neutral * retry with capped exponential backoff. Consumers must render a neutral
* placeholder while `status !== "ready"` never the upsell so a transient IPC * placeholder while `status !== "ready"` never the upsell so a transient IPC
* failure never locks a paying user out. * 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.
*/ */
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error"; type LicenseStatus = "idle" | "loading" | "ready" | "error";
interface LicenseState { interface LicenseState {
status: LicenseStatus; status: LicenseStatus;
edition: Edition; edition: Edition;
info: LicenseInfo | null; info: LicenseInfo | null;
/** Load-lifecycle error — target of the CWE-703 retry loop. */
error: string | null; error: string | null;
validating: boolean;
/** Key-submission error — never retried, cleared on the next submit. */
validationError: string | null;
} }
type LicenseAction = type LicenseAction =
| { type: "LOAD_START" } | { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null } | { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "LOAD_ERROR"; error: string }
| { type: "VALIDATE_START" } | { type: "VALIDATE_START" }
| { type: "VALIDATE_DONE"; info: LicenseInfo } | { type: "VALIDATE_DONE"; info: LicenseInfo }
| { type: "ERROR"; error: string }; | { type: "VALIDATE_ERROR"; error: string };
const initialState: LicenseState = { /** Exported for unit tests only — not part of the context API. */
export const initialLicenseState: LicenseState = {
status: "idle", status: "idle",
edition: "free", edition: "free",
info: null, info: null,
error: null, error: null,
validating: false,
validationError: null,
}; };
function reducer(state: LicenseState, action: LicenseAction): LicenseState { /** Exported for unit tests only — not part of the context API. */
export function licenseReducer(state: LicenseState, action: LicenseAction): LicenseState {
switch (action.type) { switch (action.type) {
case "LOAD_START": case "LOAD_START":
return { ...state, status: "loading", error: null }; return { ...state, status: "loading", error: null };
case "LOAD_DONE": case "LOAD_DONE":
return { status: "ready", edition: action.edition, info: action.info, error: null }; return { ...state, status: "ready", edition: action.edition, info: action.info, error: null };
case "VALIDATE_START": case "LOAD_ERROR":
return { ...state, status: "validating", error: null };
case "VALIDATE_DONE":
return { status: "ready", edition: action.info.edition, info: action.info, error: null };
case "ERROR":
// Preserve the last-known edition: an error must not downgrade a paying // Preserve the last-known edition: an error must not downgrade a paying
// user to the upsell (that is what fail-closed + `ready` guard protect). // user to the upsell (that is what fail-closed + `ready` guard protect).
return { ...state, status: "error", error: action.error }; return { ...state, status: "error", error: action.error };
case "VALIDATE_START":
return { ...state, validating: true, validationError: null };
case "VALIDATE_DONE":
return {
status: "ready",
edition: action.info.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 };
} }
} }
@ -80,6 +106,8 @@ interface LicenseContextValue {
features: string[]; features: string[];
info: LicenseInfo | null; info: LicenseInfo | null;
error: string | null; error: string | null;
validating: boolean;
validationError: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
submitKey: (key: string) => Promise<SubmitKeyResult>; submitKey: (key: string) => Promise<SubmitKeyResult>;
} }
@ -91,7 +119,7 @@ const RETRY_BASE_MS = 1000;
const RETRY_MAX_MS = 30_000; const RETRY_MAX_MS = 30_000;
export function LicenseProvider({ children }: { children: ReactNode }) { export function LicenseProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(licenseReducer, initialLicenseState);
const retryAttempt = useRef(0); const retryAttempt = useRef(0);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@ -101,7 +129,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
dispatch({ type: "LOAD_DONE", edition, info }); dispatch({ type: "LOAD_DONE", edition, info });
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "ERROR", type: "LOAD_ERROR",
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
}); });
} }
@ -115,7 +143,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
return { ok: true, info }; return { ok: true, info };
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : String(e); const message = e instanceof Error ? e.message : String(e);
dispatch({ type: "ERROR", error: message }); dispatch({ type: "VALIDATE_ERROR", error: message });
return { ok: false, error: message }; return { ok: false, error: message };
} }
}, []); }, []);
@ -125,9 +153,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
// Error recovery: retry with capped exponential backoff (CWE-703). // Error recovery: retry with capped exponential backoff (CWE-703). Load
// errors only by construction — validation failures never set status.
useEffect(() => { useEffect(() => {
if (state.status === "ready" || state.status === "validating") { if (state.status === "ready") {
retryAttempt.current = 0; retryAttempt.current = 0;
return; return;
} }
@ -147,6 +176,8 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
features: state.info?.features ?? [], features: state.info?.features ?? [],
info: state.info, info: state.info,
error: state.error, error: state.error,
validating: state.validating,
validationError: state.validationError,
refresh, refresh,
submitKey, submitKey,
}; };

View file

@ -18,6 +18,8 @@ describe("useIsPremium", () => {
features: [], features: [],
info: null, info: null,
error: null, error: null,
validating: false,
validationError: null,
refresh: vi.fn(), refresh: vi.fn(),
submitKey: vi.fn(), submitKey: vi.fn(),
}); });
@ -31,6 +33,8 @@ describe("useIsPremium", () => {
features: [], features: [],
info: null, info: null,
error: null, error: null,
validating: false,
validationError: null,
refresh: vi.fn(), refresh: vi.fn(),
submitKey: vi.fn(), submitKey: vi.fn(),
}); });
@ -44,6 +48,8 @@ describe("useIsPremium", () => {
features: [], features: [],
info: null, info: null,
error: null, error: null,
validating: false,
validationError: null,
refresh: vi.fn(), refresh: vi.fn(),
submitKey: vi.fn(), submitKey: vi.fn(),
}); });