feat(gating): socle — LicenseProvider + matrice d'entitlements + useEntitlement #303
9 changed files with 349 additions and 131 deletions
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useLicense } from "../../hooks/useLicense";
|
import { useLicenseContext } from "../../contexts/LicenseContext";
|
||||||
import {
|
import {
|
||||||
MachineInfo,
|
MachineInfo,
|
||||||
ActivationStatus,
|
ActivationStatus,
|
||||||
|
|
@ -16,7 +16,7 @@ const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
|
||||||
|
|
||||||
export default function LicenseCard() {
|
export default function LicenseCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, submitKey } = useLicense();
|
const { status: licenseStatus, edition, info, error, 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);
|
||||||
|
|
@ -26,7 +26,7 @@ export default function LicenseCard() {
|
||||||
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
||||||
const [machineError, setMachineError] = useState<string | null>(null);
|
const [machineError, setMachineError] = useState<string | null>(null);
|
||||||
|
|
||||||
const hasLicense = state.edition !== "free";
|
const hasLicense = edition !== "free";
|
||||||
|
|
||||||
const loadActivation = useCallback(async () => {
|
const loadActivation = useCallback(async () => {
|
||||||
if (!hasLicense) return;
|
if (!hasLicense) return;
|
||||||
|
|
@ -108,7 +108,7 @@ export default function LicenseCard() {
|
||||||
return new Date(timestamp * 1000).toLocaleDateString();
|
return new Date(timestamp * 1000).toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const editionLabel = t(`license.editions.${state.edition}`);
|
const editionLabel = t(`license.editions.${edition}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
<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>
|
||||||
<p className="text-base font-medium">
|
<p className="text-base font-medium">
|
||||||
{editionLabel}
|
{editionLabel}
|
||||||
{state.edition !== "free" && (
|
{edition !== "free" && (
|
||||||
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
|
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{state.info && state.info.expires_at > 0 && (
|
{info && info.expires_at > 0 && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
{t("license.expiresAt")}
|
{t("license.expiresAt")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">{formatExpiry(state.info.expires_at)}</p>
|
<p className="text-sm">{formatExpiry(info.expires_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.status === "error" && state.error && (
|
{licenseStatus === "error" && error && (
|
||||||
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
||||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
<p>{state.error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -155,7 +155,7 @@ export default function LicenseCard() {
|
||||||
>
|
>
|
||||||
{t("license.enterKey")}
|
{t("license.enterKey")}
|
||||||
</button>
|
</button>
|
||||||
{state.edition === "free" && (
|
{edition === "free" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePurchase}
|
onClick={handlePurchase}
|
||||||
|
|
@ -181,10 +181,10 @@ export default function LicenseCard() {
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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")}
|
{t("license.activate")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
161
src/contexts/LicenseContext.tsx
Normal file
161
src/contexts/LicenseContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
19
src/hooks/useEntitlement.ts
Normal file
19
src/hooks/useEntitlement.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,41 +1,51 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { useIsPremium } from "./useIsPremium";
|
import { useIsPremium } from "./useIsPremium";
|
||||||
|
|
||||||
vi.mock("./useLicense", () => ({
|
// useIsPremium now reads LicenseContext (was: useLicense). Mock the context hook.
|
||||||
useLicense: vi.fn(),
|
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", () => {
|
describe("useIsPremium", () => {
|
||||||
it('returns true when edition is "premium"', () => {
|
it('returns true when edition is "premium"', () => {
|
||||||
mockUseLicense.mockReturnValue({
|
mockUseLicenseContext.mockReturnValue({
|
||||||
state: { status: "ready", edition: "premium", info: null, error: null },
|
status: "ready",
|
||||||
|
edition: "premium",
|
||||||
|
features: [],
|
||||||
|
info: null,
|
||||||
|
error: null,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
checkEntitlement: vi.fn(),
|
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(true);
|
expect(useIsPremium()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when edition is "base"', () => {
|
it('returns false when edition is "base"', () => {
|
||||||
mockUseLicense.mockReturnValue({
|
mockUseLicenseContext.mockReturnValue({
|
||||||
state: { status: "ready", edition: "base", info: null, error: null },
|
status: "ready",
|
||||||
|
edition: "base",
|
||||||
|
features: [],
|
||||||
|
info: null,
|
||||||
|
error: null,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
checkEntitlement: vi.fn(),
|
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(false);
|
expect(useIsPremium()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when edition is "free"', () => {
|
it('returns false when edition is "free"', () => {
|
||||||
mockUseLicense.mockReturnValue({
|
mockUseLicenseContext.mockReturnValue({
|
||||||
state: { status: "ready", edition: "free", info: null, error: null },
|
status: "ready",
|
||||||
|
edition: "free",
|
||||||
|
features: [],
|
||||||
|
info: null,
|
||||||
|
error: null,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
submitKey: vi.fn(),
|
submitKey: vi.fn(),
|
||||||
checkEntitlement: vi.fn(),
|
|
||||||
});
|
});
|
||||||
expect(useIsPremium()).toBe(false);
|
expect(useIsPremium()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useLicense } from "./useLicense";
|
import { useLicenseContext } from "../contexts/LicenseContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the active license edition is "premium".
|
* Returns true if the active license edition is "premium".
|
||||||
* Ergonomic helper only — the server enforces entitlements independently (cf. ADR 0011 §UX).
|
* 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 {
|
export function useIsPremium(): boolean {
|
||||||
const { state } = useLicense();
|
const { edition } = useLicenseContext();
|
||||||
return state.edition === "premium";
|
return edition === "premium";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
13
src/main.tsx
13
src/main.tsx
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { LicenseProvider } from "./contexts/LicenseContext";
|
||||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||||
import { initLogCapture } from "./services/logService";
|
import { initLogCapture } from "./services/logService";
|
||||||
|
|
@ -11,10 +12,12 @@ initLogCapture();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ProfileProvider>
|
<LicenseProvider>
|
||||||
<ErrorBoundary>
|
<ProfileProvider>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<App />
|
||||||
</ProfileProvider>
|
</ErrorBoundary>
|
||||||
|
</ProfileProvider>
|
||||||
|
</LicenseProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
72
src/services/entitlements.test.ts
Normal file
72
src/services/entitlements.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/shared/entitlements.ts
Normal file
50
src/shared/entitlements.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue