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 = { 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); } /** * Minimum paid tier that unlocks a feature — drives the upsell copy * ("Obtenir Base" vs "Obtenir Premium"). Derived from matrix MEMBERSHIP, not * array order, so reordering an ENTITLEMENTS row can never change the answer. */ export function requiredTierFor(f: FeatureKey): Edition { return ENTITLEMENTS[f].includes("base") ? "base" : "premium"; }