feat(gating): license provider + entitlements matrix + useEntitlement (#297)
All checks were successful
PR Check / rust (pull_request) Successful in 22m38s
PR Check / frontend (pull_request) Successful in 2m34s

Socle for tier-based feature gating (UI-only soft-paywall).

- LicenseContext: machine-level provider (createContext<T|null>, useReducer,
  throwing consumer hook), mounted above ProfileProvider in main.tsx so a
  profile switch (BrowserRouter key remount) does not reload the license.
  Loads edition + info once; exposes { status, edition, features, info, error,
  refresh, submitKey }. Boot-error recovery (CWE-703): neutral state + capped
  exponential-backoff retry, never the upsell.
- shared/entitlements.ts: FeatureKey (kebab-case), ENTITLEMENTS matrix, pure
  isEntitled() fail-closed in Free (CWE-863) — the features[] override is
  ignored before edition==="free" is checked.
- useEntitlement(f): { allowed, ready } (ready = status==="ready"), synchronous.
- useIsPremium + its test migrated onto the context (drops the per-call double
  invoke); LicenseCard consumes the context. useLicense.ts removed (fully
  replaced, no remaining consumers).
- services/entitlements.test.ts: matrix, features[] override, override ignored
  in Free, unknown-feature deny-all.

Resolves #297
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-19 21:05:10 -04:00
parent 0b408a8014
commit fd7e053239
9 changed files with 349 additions and 131 deletions

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { useLicense } from "../../hooks/useLicense";
import { useLicenseContext } from "../../contexts/LicenseContext";
import {
MachineInfo,
ActivationStatus,
@ -16,7 +16,7 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
export default function LicenseCard() {
const { t } = useTranslation();
const { state, submitKey } = useLicense();
const { status: licenseStatus, edition, info, error, submitKey } = useLicenseContext();
const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false);
const [showMachines, setShowMachines] = useState(false);
@ -26,7 +26,7 @@ export default function LicenseCard() {
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [machineError, setMachineError] = useState<string | null>(null);
const hasLicense = state.edition !== "free";
const hasLicense = edition !== "free";
const loadActivation = useCallback(async () => {
if (!hasLicense) return;
@ -108,7 +108,7 @@ export default function LicenseCard() {
return new Date(timestamp * 1000).toLocaleDateString();
};
const editionLabel = t(`license.editions.${state.edition}`);
const editionLabel = t(`license.editions.${edition}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
@ -124,25 +124,25 @@ export default function LicenseCard() {
</p>
<p className="text-base font-medium">
{editionLabel}
{state.edition !== "free" && (
{edition !== "free" && (
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
)}
</p>
</div>
{state.info && state.info.expires_at > 0 && (
{info && info.expires_at > 0 && (
<div className="text-right">
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.expiresAt")}
</p>
<p className="text-sm">{formatExpiry(state.info.expires_at)}</p>
<p className="text-sm">{formatExpiry(info.expires_at)}</p>
</div>
)}
</div>
{state.status === "error" && state.error && (
{licenseStatus === "error" && error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
<p>{error}</p>
</div>
)}
@ -155,7 +155,7 @@ export default function LicenseCard() {
>
{t("license.enterKey")}
</button>
{state.edition === "free" && (
{edition === "free" && (
<button
type="button"
onClick={handlePurchase}
@ -181,10 +181,10 @@ export default function LicenseCard() {
<div className="flex gap-2">
<button
type="submit"
disabled={state.status === "validating" || !keyInput.trim()}
disabled={licenseStatus === "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"
>
{state.status === "validating" && <Loader2 size={14} className="animate-spin" />}
{licenseStatus === "validating" && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")}
</button>
<button

View file

@ -0,0 +1,161 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
useRef,
type ReactNode,
} from "react";
import {
getEdition,
readLicense,
storeLicense,
type Edition,
type LicenseInfo,
} from "../services/licenseService";
/**
* 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.
*/
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
interface LicenseState {
status: LicenseStatus;
edition: Edition;
info: LicenseInfo | null;
error: string | null;
}
type LicenseAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "VALIDATE_START" }
| { type: "VALIDATE_DONE"; info: LicenseInfo }
| { type: "ERROR"; error: string };
const initialState: LicenseState = {
status: "idle",
edition: "free",
info: null,
error: null,
};
function reducer(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":
// 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 };
}
}
type SubmitKeyResult =
| { ok: true; info: LicenseInfo }
| { ok: false; error: string };
interface LicenseContextValue {
status: LicenseStatus;
edition: Edition;
features: string[];
info: LicenseInfo | null;
error: string | null;
refresh: () => Promise<void>;
submitKey: (key: string) => Promise<SubmitKeyResult>;
}
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(reducer, initialState);
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: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const submitKey = useCallback(async (key: string): Promise<SubmitKeyResult> => {
dispatch({ type: "VALIDATE_START" });
try {
const info = await storeLicense(key);
dispatch({ type: "VALIDATE_DONE", info });
return { ok: true, info };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
dispatch({ type: "ERROR", error: message });
return { ok: false, error: message };
}
}, []);
// Load once at boot.
useEffect(() => {
void refresh();
}, [refresh]);
// Error recovery: retry with capped exponential backoff (CWE-703).
useEffect(() => {
if (state.status === "ready" || state.status === "validating") {
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 = {
status: state.status,
edition: state.edition,
features: state.info?.features ?? [],
info: state.info,
error: state.error,
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;
}

View file

@ -0,0 +1,19 @@
import { useLicenseContext } from "../contexts/LicenseContext";
import { isEntitled, type FeatureKey } from "../shared/entitlements";
/**
* Synchronous feature-gating hook.
*
* Returns `{ allowed, ready }` (NOT a bare boolean) so consumers can suppress
* the lock/upsell while the license is still loading or errored (`ready` is
* false) instead of flashing "locked" to a paying user. `allowed` is fail-closed
* in Free and during boot (edition defaults to "free"), so it is safe to read
* even before `ready`.
*/
export function useEntitlement(feature: FeatureKey): { allowed: boolean; ready: boolean } {
const { status, edition, features } = useLicenseContext();
return {
allowed: isEntitled(feature, edition, features),
ready: status === "ready",
};
}

View file

@ -1,41 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { useIsPremium } from "./useIsPremium";
vi.mock("./useLicense", () => ({
useLicense: vi.fn(),
// useIsPremium now reads LicenseContext (was: useLicense). Mock the context hook.
vi.mock("../contexts/LicenseContext", () => ({
useLicenseContext: vi.fn(),
}));
import { useLicense } from "./useLicense";
import { useLicenseContext } from "../contexts/LicenseContext";
const mockUseLicense = vi.mocked(useLicense);
const mockUseLicenseContext = vi.mocked(useLicenseContext);
describe("useIsPremium", () => {
it('returns true when edition is "premium"', () => {
mockUseLicense.mockReturnValue({
state: { status: "ready", edition: "premium", info: null, error: null },
mockUseLicenseContext.mockReturnValue({
status: "ready",
edition: "premium",
features: [],
info: null,
error: null,
refresh: vi.fn(),
submitKey: vi.fn(),
checkEntitlement: vi.fn(),
});
expect(useIsPremium()).toBe(true);
});
it('returns false when edition is "base"', () => {
mockUseLicense.mockReturnValue({
state: { status: "ready", edition: "base", info: null, error: null },
mockUseLicenseContext.mockReturnValue({
status: "ready",
edition: "base",
features: [],
info: null,
error: null,
refresh: vi.fn(),
submitKey: vi.fn(),
checkEntitlement: vi.fn(),
});
expect(useIsPremium()).toBe(false);
});
it('returns false when edition is "free"', () => {
mockUseLicense.mockReturnValue({
state: { status: "ready", edition: "free", info: null, error: null },
mockUseLicenseContext.mockReturnValue({
status: "ready",
edition: "free",
features: [],
info: null,
error: null,
refresh: vi.fn(),
submitKey: vi.fn(),
checkEntitlement: vi.fn(),
});
expect(useIsPremium()).toBe(false);
});

View file

@ -1,10 +1,11 @@
import { useLicense } from "./useLicense";
import { useLicenseContext } from "../contexts/LicenseContext";
/**
* Returns true if the active license edition is "premium".
* Ergonomic helper only the server enforces entitlements independently (cf. ADR 0011 §UX).
* Reads the shared LicenseContext (single boot load) no per-call invoke.
*/
export function useIsPremium(): boolean {
const { state } = useLicense();
return state.edition === "premium";
const { edition } = useLicenseContext();
return edition === "premium";
}

View file

@ -1,98 +0,0 @@
import { useCallback, useEffect, useReducer } from "react";
import {
Edition,
LicenseInfo,
checkEntitlement as checkEntitlementCmd,
getEdition,
readLicense,
storeLicense,
} from "../services/licenseService";
type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
interface LicenseState {
status: LicenseStatus;
edition: Edition;
info: LicenseInfo | null;
error: string | null;
}
type LicenseAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
| { type: "VALIDATE_START" }
| { type: "VALIDATE_DONE"; info: LicenseInfo }
| { type: "ERROR"; error: string };
const initialState: LicenseState = {
status: "idle",
edition: "free",
info: null,
error: null,
};
function reducer(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: "error", error: action.error };
}
}
export function useLicense() {
const [state, dispatch] = useReducer(reducer, initialState);
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: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const submitKey = useCallback(async (key: string) => {
dispatch({ type: "VALIDATE_START" });
try {
const info = await storeLicense(key);
dispatch({ type: "VALIDATE_DONE", info });
return { ok: true as const, info };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
dispatch({ type: "ERROR", error: message });
return { ok: false as const, error: message };
}
}, []);
const checkEntitlement = useCallback(
(feature: string) => checkEntitlementCmd(feature),
[],
);
useEffect(() => {
void refresh();
}, [refresh]);
return { state, refresh, submitKey, checkEntitlement };
}

View file

@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { LicenseProvider } from "./contexts/LicenseContext";
import { ProfileProvider } from "./contexts/ProfileContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import { initLogCapture } from "./services/logService";
@ -11,10 +12,12 @@ initLogCapture();
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<ProfileProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</ProfileProvider>
<LicenseProvider>
<ProfileProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</ProfileProvider>
</LicenseProvider>
</React.StrictMode>,
);

View file

@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
// Subject lives in src/shared/entitlements.ts; test co-located here per the
// issue's file plan (services/).
import { isEntitled, ENTITLEMENTS, type FeatureKey } from "../shared/entitlements";
import type { Edition } from "./licenseService";
const EDITIONS: Edition[] = ["free", "base", "premium"];
const FEATURES = Object.keys(ENTITLEMENTS) as FeatureKey[];
describe("isEntitled — matrix", () => {
it("denies every feature in Free (fail-closed)", () => {
FEATURES.forEach((f) => {
expect(isEntitled(f, "free", [])).toBe(false);
});
});
it("Base unlocks budget/adjustments/reports-advanced/multi-profile, not balance", () => {
expect(isEntitled("budget", "base", [])).toBe(true);
expect(isEntitled("adjustments", "base", [])).toBe(true);
expect(isEntitled("reports-advanced", "base", [])).toBe(true);
expect(isEntitled("multi-profile", "base", [])).toBe(true);
expect(isEntitled("balance", "base", [])).toBe(false);
});
it("Premium unlocks everything including balance", () => {
FEATURES.forEach((f) => {
expect(isEntitled(f, "premium", [])).toBe(true);
});
});
it("matches the ENTITLEMENTS table for every feature × edition", () => {
FEATURES.forEach((f) => {
EDITIONS.forEach((ed) => {
const expected = ed !== "free" && ENTITLEMENTS[f].includes(ed);
expect(isEntitled(f, ed, [])).toBe(expected);
});
});
});
});
describe("isEntitled — features[] override", () => {
it("grants a higher-tier feature when the signed override lists it", () => {
// balance is Premium-only; a Base license carrying balance in features[] gets it.
expect(isEntitled("balance", "base", ["balance"])).toBe(true);
});
it("IGNORES the override when edition is free (CWE-863)", () => {
// A copied key downgrades to free (machine-binding) but still carries its
// signed features[]. Those must not be re-granted.
expect(isEntitled("balance", "free", ["balance"])).toBe(false);
expect(isEntitled("budget", "free", ["budget"])).toBe(false);
});
it("does not grant unrelated features via the override", () => {
expect(isEntitled("balance", "base", ["budget"])).toBe(false);
});
});
describe("isEntitled — unknown feature", () => {
it("denies an unknown feature key (deny-all)", () => {
expect(isEntitled("web-sync" as FeatureKey, "premium", [])).toBe(false);
expect(isEntitled("web-sync" as FeatureKey, "base", [])).toBe(false);
});
it("still honours a signed override for an unknown key when non-free", () => {
expect(isEntitled("web-sync" as FeatureKey, "premium", ["web-sync"])).toBe(true);
});
it("keeps an unknown feature denied in Free even with an override", () => {
expect(isEntitled("web-sync" as FeatureKey, "free", ["web-sync"])).toBe(false);
});
});

View file

@ -0,0 +1,50 @@
import type { Edition } from "../services/licenseService";
/**
* UI entitlements matrix single front-end source of truth for feature gating.
*
* Keys are kebab-case because the JWT `features[]` array is a namespace shared
* between the Rust override and this TS layer (cf. `auto-update` string on the
* Rust side). Enforcement is UI-only (soft-paywall, GPL assumed) the edition
* itself is still resolved by the machine-bound Rust `current_edition()`.
*
* Modules that stay Free (dashboard, import, transactions, categories,
* reports/trends, export, changelog, docs) have NO key here never gated.
*/
export type FeatureKey =
| "budget"
| "adjustments"
| "reports-advanced"
| "multi-profile"
| "balance";
export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
budget: ["base", "premium"],
adjustments: ["base", "premium"],
"reports-advanced": ["base", "premium"],
"multi-profile": ["base", "premium"],
balance: ["premium"],
};
/**
* Pure entitlement check.
*
* Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
* downgrades `edition` "free" (machine-binding), but still carries its signed
* `features[]`. We must NOT re-grant those features to a downgraded license, so
* the Free short-circuit runs BEFORE the `features[]` override.
*
* `licenseFeatures` is the JWT override namespace. An unknown feature (not in
* the matrix the namespace is shared with Rust and may drift) is deny-all
* unless explicitly present in the signed override.
*/
export function isEntitled(
f: FeatureKey,
edition: Edition,
licenseFeatures: string[],
): boolean {
if (edition === "free") return false;
const tiers = ENTITLEMENTS[f];
if (!tiers) return licenseFeatures.includes(f);
return tiers.includes(edition) || licenseFeatures.includes(f);
}