feat(gating): socle — LicenseProvider + matrice d'entitlements + useEntitlement #303

Merged
maximus merged 2 commits from issue-297-license-provider into main 2026-07-21 01:53:01 +00:00
4 changed files with 164 additions and 18 deletions
Showing only changes of commit b9e13b5bca - Show all commits

View file

@ -16,7 +16,8 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
export default function LicenseCard() {
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 [showInput, setShowInput] = 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)]"
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">
<button
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"
>
{licenseStatus === "validating" && <Loader2 size={14} className="animate-spin" />}
{validating && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")}
</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
* 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.
*/
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
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;
}
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: "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",
edition: "free",
info: 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) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return { status: "ready", edition: action.edition, info: action.info, error: null };
case "VALIDATE_START":
return { ...state, status: "validating", error: null };
case "VALIDATE_DONE":
return { status: "ready", edition: action.info.edition, info: action.info, error: null };
case "ERROR":
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":
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[];
info: LicenseInfo | null;
error: string | null;
validating: boolean;
validationError: string | null;
refresh: () => Promise<void>;
submitKey: (key: string) => Promise<SubmitKeyResult>;
}
@ -91,7 +119,7 @@ const RETRY_BASE_MS = 1000;
const RETRY_MAX_MS = 30_000;
export function LicenseProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
const [state, dispatch] = useReducer(licenseReducer, initialLicenseState);
const retryAttempt = useRef(0);
const refresh = useCallback(async () => {
@ -101,7 +129,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
dispatch({ type: "LOAD_DONE", edition, info });
} catch (e) {
dispatch({
type: "ERROR",
type: "LOAD_ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
@ -115,7 +143,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
return { ok: true, info };
} catch (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 };
}
}, []);
@ -125,9 +153,10 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
void 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(() => {
if (state.status === "ready" || state.status === "validating") {
if (state.status === "ready") {
retryAttempt.current = 0;
return;
}
@ -147,6 +176,8 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
features: state.info?.features ?? [],
info: state.info,
error: state.error,
validating: state.validating,
validationError: state.validationError,
refresh,
submitKey,
};

View file

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