From c3eefb9742e7c13520016aeea1a95e9d49aed4e6 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Tue, 21 Jul 2026 13:24:15 -0400 Subject: [PATCH] feat(gating): free vs Pro feature gating + Anthropic migration Implements the free/Pro split (closes #9), stacked on the #8 entitlement layer. AI provider migrated OpenAI -> Anthropic as part of the same change. Free/Pro matrix (docs/free-vs-pro-gating.md): - SEO scoring + score breakdown: fully free, never gated. - AI recommendations: free via a capped, server-metered hosted proxy; Pro gets a higher proxy cap or unlimited with their own key. - Advanced options, multi-language output, schema generation, and BYO key: Pro only. Every gate reads an entitlement-store flag (canUse*/isPro), never an ad-hoc apiKey check. Three AI paths (app/src/lib/ai.ts + entitlement-store useAiStatus): - byok: Pro + own Anthropic key -> browser->Anthropic directly, unlimited. - pro: Pro, no key -> hosted proxy authenticated by X-Optia-Entitlement. - free: not Pro -> hosted proxy metered by install id. A stored key only unlocks the direct path when isPro, so a free user can never self-serve uncapped AI. The BYO key never transits Optia's backend. Provider migration: - openai.ts -> anthropic.ts using @anthropic-ai/sdk (claude-opus-4-8, dangerouslyAllowBrowser); new ai-proxy.ts for POST /ai/generate; ai.ts facade routes + records server-authoritative quota. Storage key openai_api_key -> anthropic_api_key; dev CORS proxy and all UI labels updated to Anthropic. Quota is server-authoritative: /ai/generate returns quota {limit,remaining, period} which the client caches; on a server quota_exceeded the client drives remaining to 0 so the gate converges to a friendly upsell (no dead ends). Free users generate on demand (auto-generate only runs on the uncapped byok/pro paths) to conserve the monthly allowance. Note: the shipped backend meters a MONTHLY free allowance (not the daily cap the issue proposed); the client follows the backend. Tests: 411 passing. New coverage for the proxy client, the Anthropic direct client, the AI router (all three paths + locked + quota-exhaustion lock + metered-subject), the quota model, and free/Pro rendering of every gated surface (schema, multi-language, BYO-key, advanced options, and the scoring-stays-free guarantee). tsc + vite build + eslint all green. Co-Authored-By: Claude Fable 5 --- app/package.json | 2 +- app/pnpm-lock.yaml | 225 ++------ .../EditableRecommendation.test.tsx | 6 +- app/src/components/EditableRecommendation.tsx | 9 +- app/src/components/H2SelectionList.test.tsx | 4 +- app/src/components/H2SelectionList.tsx | 15 +- app/src/components/ImageAltTextList.test.tsx | 6 +- app/src/components/ImageAltTextList.tsx | 9 +- app/src/components/RecommendationBox.test.tsx | 6 +- app/src/components/RecommendationBox.tsx | 4 +- app/src/lib/ai-error.ts | 14 + app/src/lib/ai-proxy.test.ts | 258 ++++++++++ app/src/lib/ai-proxy.ts | 125 +++++ app/src/lib/ai.test.ts | 316 ++++++++++++ app/src/lib/ai.ts | 120 +++++ app/src/lib/anthropic.test.ts | 220 ++++++++ app/src/lib/{openai.ts => anthropic.ts} | 122 ++--- app/src/lib/entitlement-store.test.ts | 291 ++++++++--- app/src/lib/entitlement-store.ts | 172 +++++-- app/src/lib/entitlement.test.ts | 135 ++++- app/src/lib/entitlement.ts | 71 ++- app/src/lib/openai.test.ts | 480 ------------------ app/src/lib/store.test.ts | 4 +- app/src/lib/store.ts | 4 +- app/src/options/Options.test.tsx | 224 ++++---- app/src/options/Options.tsx | 95 ++-- app/src/sidepanel/App.tsx | 33 +- app/src/sidepanel/pages/SetupPage.test.tsx | 38 +- app/src/sidepanel/pages/SetupPage.tsx | 76 ++- .../sidepanel/pages/SubscoresPage.test.tsx | 164 +++++- app/src/sidepanel/pages/SubscoresPage.tsx | 84 ++- app/vite.config.dev.ts | 45 +- docs/free-vs-pro-gating.md | 59 +++ 33 files changed, 2283 insertions(+), 1153 deletions(-) create mode 100644 app/src/lib/ai-error.ts create mode 100644 app/src/lib/ai-proxy.test.ts create mode 100644 app/src/lib/ai-proxy.ts create mode 100644 app/src/lib/ai.test.ts create mode 100644 app/src/lib/ai.ts create mode 100644 app/src/lib/anthropic.test.ts rename app/src/lib/{openai.ts => anthropic.ts} (70%) delete mode 100644 app/src/lib/openai.test.ts create mode 100644 docs/free-vs-pro-gating.md diff --git a/app/package.json b/app/package.json index 44ddb05..2e5cd36 100644 --- a/app/package.json +++ b/app/package.json @@ -20,11 +20,11 @@ "release:dry": "standard-version --dry-run" }, "dependencies": { + "@anthropic-ai/sdk": "^0.112.3", "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", "jose": "^6.2.3", "lucide-react": "^0.511.0", - "openai": "^4.97.0", "react": "^19.1.0", "react-dom": "^19.1.0", "tailwind-merge": "^3.3.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 2a8b850..c7eeac8 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.112.3 + version: 0.112.3(zod@4.3.6) canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -20,9 +23,6 @@ importers: lucide-react: specifier: ^0.511.0 version: 0.511.0(react@19.2.4) - openai: - specifier: ^4.97.0 - version: 4.104.0(ws@8.19.0)(zod@4.3.6) react: specifier: ^19.1.0 version: 19.2.4 @@ -128,6 +128,15 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@anthropic-ai/sdk@0.112.3': + resolution: {integrity: sha512-wjcozJlitVIuBEw9cj/xBuRznwkhcLmXmNzlFoeHbh4AvrDG3HGZrdvEOTTmobcbhjGkfOpKbmDTCQ4s9LQvCg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -817,6 +826,9 @@ packages: cpu: [x64] os: [win32] + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -891,9 +903,6 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -1018,10 +1027,6 @@ packages: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1043,10 +1048,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agentkeepalive@4.6.0: - resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} - engines: {node: '>= 8.0.0'} - ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -1140,9 +1141,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.4.27: resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} @@ -1280,10 +1278,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1469,10 +1463,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1679,10 +1669,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1700,6 +1686,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1755,17 +1744,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data-encoder@1.7.2: - resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formdata-node@4.4.1: - resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} - engines: {node: '>= 12.20'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1939,9 +1917,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - humanize-ms@1.2.1: - resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2177,6 +2152,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2309,14 +2288,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -2364,24 +2335,10 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -2434,18 +2391,6 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - openai@4.104.0: - resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.23.8 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2884,6 +2829,9 @@ packages: engines: {node: '>=10'} hasBin: true + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3040,9 +2988,6 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -3051,6 +2996,9 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -3215,13 +3163,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - web-streams-polyfill@4.0.0-beta.3: - resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} - engines: {node: '>= 14'} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -3239,9 +3180,6 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3366,6 +3304,13 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.112.3(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.3.6 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -3906,6 +3851,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@stablelib/base64@1.0.1': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -3991,14 +3938,10 @@ snapshots: '@types/minimist@1.2.5': {} - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 18.19.130 - form-data: 4.0.5 - '@types/node@18.19.130': dependencies: undici-types: 5.26.5 + optional: true '@types/normalize-package-data@2.4.4': {} @@ -4181,10 +4124,6 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -4199,10 +4138,6 @@ snapshots: agent-base@7.1.4: {} - agentkeepalive@4.6.0: - dependencies: - humanize-ms: 1.2.1 - ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -4314,8 +4249,6 @@ snapshots: async-function@1.0.0: {} - asynckit@0.4.0: {} - autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 @@ -4477,10 +4410,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@4.1.1: {} compare-func@2.0.0: @@ -4698,8 +4627,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - delayed-stream@1.0.0: {} - dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -5026,8 +4953,6 @@ snapshots: esutils@2.0.3: {} - event-target-shim@5.0.1: {} - expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -5044,6 +4969,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5098,21 +5025,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data-encoder@1.7.2: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formdata-node@4.4.1: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 4.0.0-beta.3 - fraction.js@5.3.4: {} fs-extra@10.1.0: @@ -5300,10 +5212,6 @@ snapshots: transitivePeerDependencies: - supports-color - humanize-ms@1.2.1: - dependencies: - ms: 2.1.3 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -5548,6 +5456,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -5680,12 +5593,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - min-indent@1.0.1: {} minimatch@10.2.4: @@ -5726,8 +5633,6 @@ snapshots: neo-async@2.6.2: {} - node-domexception@1.0.0: {} - node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -5735,10 +5640,6 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - node-releases@2.0.36: {} normalize-package-data@2.5.0: @@ -5801,21 +5702,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - openai@4.104.0(ws@8.19.0)(zod@4.3.6): - dependencies: - '@types/node': 18.19.130 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0 - optionalDependencies: - ws: 8.19.0 - zod: 4.3.6 - transitivePeerDependencies: - - encoding - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6320,6 +6206,11 @@ snapshots: stringify-package: 1.0.1 yargs: 16.2.0 + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -6521,14 +6412,14 @@ snapshots: dependencies: tldts: 6.1.86 - tr46@0.0.3: {} - tr46@5.1.1: dependencies: punycode: 2.3.1 trim-newlines@3.0.1: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -6594,7 +6485,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@5.26.5: {} + undici-types@5.26.5: + optional: true undici@7.22.0: {} @@ -6697,10 +6589,6 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - web-streams-polyfill@4.0.0-beta.3: {} - - webidl-conversions@3.0.1: {} - webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -6714,11 +6602,6 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/app/src/components/EditableRecommendation.test.tsx b/app/src/components/EditableRecommendation.test.tsx index 964929f..6070889 100644 --- a/app/src/components/EditableRecommendation.test.tsx +++ b/app/src/components/EditableRecommendation.test.tsx @@ -79,14 +79,14 @@ describe("EditableRecommendation", () => { describe("when aiDisabled is true", () => { it("disables the regenerate button", () => { render(); - const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options"); + const btn = screen.getByTitle("Activate Optia Pro or add your own Anthropic key in options"); expect(btn).toBeDisabled(); }); it("shows a message about setting up the API key", () => { render(); expect( - screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."), + screen.getByText("Activate Optia Pro or add your own Anthropic key in options to use AI suggestions."), ).toBeInTheDocument(); }); @@ -96,7 +96,7 @@ describe("EditableRecommendation", () => { render( , ); - const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options"); + const btn = screen.getByTitle("Activate Optia Pro or add your own Anthropic key in options"); await user.click(btn); expect(onRegenerate).not.toHaveBeenCalled(); }); diff --git a/app/src/components/EditableRecommendation.tsx b/app/src/components/EditableRecommendation.tsx index 21f08ee..7c8d17f 100644 --- a/app/src/components/EditableRecommendation.tsx +++ b/app/src/components/EditableRecommendation.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { Copy, RefreshCw, Check } from "lucide-react"; import { cn } from "@/lib/utils"; +import { aiErrorMessage } from "@/lib/ai-error"; interface EditableRecommendationProps { label: string; @@ -55,8 +56,8 @@ export function EditableRecommendation({ const newText = await onRegenerate(); setText(newText); onToast("Recommendation regenerated"); - } catch { - onToast("Failed to regenerate"); + } catch (err) { + onToast(aiErrorMessage(err, "Failed to regenerate")); } finally { setLoading(false); } @@ -89,7 +90,7 @@ export function EditableRecommendation({ onClick={handleRegenerate} disabled={loading || aiDisabled} className="rounded-full p-1.5 text-muted hover:bg-surface-2 hover:text-ink transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted" - title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : "Regenerate"} + title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : "Regenerate"} > {aiDisabled && (

- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions. + Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.

)} diff --git a/app/src/components/H2SelectionList.test.tsx b/app/src/components/H2SelectionList.test.tsx index 6a21a81..1baa202 100644 --- a/app/src/components/H2SelectionList.test.tsx +++ b/app/src/components/H2SelectionList.test.tsx @@ -104,7 +104,7 @@ describe("H2SelectionList", () => { it("disables all per-item regenerate buttons", () => { render(); - const regenerateButtons = screen.getAllByTitle("Add an OpenAI API key or activate Optia Pro in options"); + const regenerateButtons = screen.getAllByTitle("Activate Optia Pro or add your own Anthropic key in options"); for (const btn of regenerateButtons) { expect(btn).toBeDisabled(); } @@ -113,7 +113,7 @@ describe("H2SelectionList", () => { it("shows a message about setting up the API key", () => { render(); expect( - screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."), + screen.getByText("Activate Optia Pro or add your own Anthropic key in options to use AI suggestions."), ).toBeInTheDocument(); }); diff --git a/app/src/components/H2SelectionList.tsx b/app/src/components/H2SelectionList.tsx index b4021c1..92e8aad 100644 --- a/app/src/components/H2SelectionList.tsx +++ b/app/src/components/H2SelectionList.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { Copy, RefreshCw, Check, Sparkles } from "lucide-react"; import { Button } from "./ui/Button"; import { cn } from "@/lib/utils"; +import { aiErrorMessage } from "@/lib/ai-error"; interface H2Item { index: number; @@ -76,8 +77,8 @@ export function H2SelectionList({ const result = await onRegenerateOne(index, h2Text); setSuggestions((prev) => ({ ...prev, [index]: result })); onToast("H2 suggestion regenerated"); - } catch { - onToast("Failed to regenerate"); + } catch (err) { + onToast(aiErrorMessage(err, "Failed to regenerate")); } finally { setLoadingItems((prev) => { const next = new Set(prev); @@ -99,8 +100,8 @@ export function H2SelectionList({ }); setSuggestions(newSuggestions); onToast("All H2 suggestions generated"); - } catch { - onToast("Failed to generate suggestions"); + } catch (err) { + onToast(aiErrorMessage(err, "Failed to generate suggestions")); } finally { setLoadingAll(false); } @@ -136,7 +137,7 @@ export function H2SelectionList({ onClick={handleRegenerateAll} loading={loadingAll} disabled={aiDisabled} - title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : undefined} + title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : undefined} > Generate All @@ -145,7 +146,7 @@ export function H2SelectionList({ {aiDisabled && (

- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions. + Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.

)} @@ -196,7 +197,7 @@ export function H2SelectionList({ onClick={() => handleRegenerateOne(item.index, item.text)} disabled={isLoading || aiDisabled} className="rounded-full p-1.5 text-muted hover:bg-surface-2 hover:text-ink transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted" - title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : "Regenerate"} + title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : "Regenerate"} > { describe("when aiDisabled is true", () => { it("disables all generate buttons", () => { render(); - const generateButtons = screen.getAllByTitle("Add an OpenAI API key or activate Optia Pro in options"); + const generateButtons = screen.getAllByTitle("Activate Optia Pro or add your own Anthropic key in options"); for (const btn of generateButtons) { expect(btn).toBeDisabled(); } @@ -96,7 +96,7 @@ describe("ImageAltTextList", () => { it("shows a message about setting up the API key", () => { render(); expect( - screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."), + screen.getByText("Activate Optia Pro or add your own Anthropic key in options to use AI suggestions."), ).toBeInTheDocument(); }); @@ -106,7 +106,7 @@ describe("ImageAltTextList", () => { render( , ); - const generateButtons = screen.getAllByTitle("Add an OpenAI API key or activate Optia Pro in options"); + const generateButtons = screen.getAllByTitle("Activate Optia Pro or add your own Anthropic key in options"); await user.click(generateButtons[0]); expect(onGenerate).not.toHaveBeenCalled(); }); diff --git a/app/src/components/ImageAltTextList.tsx b/app/src/components/ImageAltTextList.tsx index 4ef9326..9f39576 100644 --- a/app/src/components/ImageAltTextList.tsx +++ b/app/src/components/ImageAltTextList.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from "react"; import { Copy, RefreshCw, Check, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; +import { aiErrorMessage } from "@/lib/ai-error"; import type { ImageData } from "@/types/seo"; interface ImageAltTextListProps { @@ -29,8 +30,8 @@ export function ImageAltTextList({ const result = await onGenerate(src); setAltTexts((prev) => ({ ...prev, [index]: result })); onToast("Alt text generated"); - } catch { - onToast("Failed to generate alt text"); + } catch (err) { + onToast(aiErrorMessage(err, "Failed to generate alt text")); } finally { setLoadingItems((prev) => { const next = new Set(prev); @@ -70,7 +71,7 @@ export function ImageAltTextList({ {aiDisabled && (

- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions. + Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.

)} @@ -124,7 +125,7 @@ export function ImageAltTextList({ onClick={() => handleGenerate(index, img.src)} disabled={isLoading || aiDisabled} className="rounded-full p-1.5 text-muted hover:bg-surface-2 hover:text-ink transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted" - title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : "Generate alt text"} + title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : "Generate alt text"} > {isLoading ? ( diff --git a/app/src/components/RecommendationBox.test.tsx b/app/src/components/RecommendationBox.test.tsx index e683dcf..b42d8bc 100644 --- a/app/src/components/RecommendationBox.test.tsx +++ b/app/src/components/RecommendationBox.test.tsx @@ -61,14 +61,14 @@ describe("RecommendationBox", () => { describe("when aiDisabled is true", () => { it("disables the regenerate button", () => { render(); - const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options"); + const btn = screen.getByTitle("Activate Optia Pro or add your own Anthropic key in options"); expect(btn).toBeDisabled(); }); it("shows a message about setting up the API key", () => { render(); expect( - screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."), + screen.getByText("Activate Optia Pro or add your own Anthropic key in options to use AI suggestions."), ).toBeInTheDocument(); }); @@ -78,7 +78,7 @@ describe("RecommendationBox", () => { render( , ); - const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options"); + const btn = screen.getByTitle("Activate Optia Pro or add your own Anthropic key in options"); await user.click(btn); expect(onRegenerate).not.toHaveBeenCalled(); }); diff --git a/app/src/components/RecommendationBox.tsx b/app/src/components/RecommendationBox.tsx index 185037d..dfb29bd 100644 --- a/app/src/components/RecommendationBox.tsx +++ b/app/src/components/RecommendationBox.tsx @@ -61,7 +61,7 @@ export function RecommendationBox({ onClick={handleRegenerate} disabled={loading || aiDisabled} className="rounded-full p-1.5 text-muted transition-colors hover:bg-surface-3 hover:text-ink disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted" - title={aiDisabled ? "Add an OpenAI API key or activate Optia Pro in options" : "Regenerate"} + title={aiDisabled ? "Activate Optia Pro or add your own Anthropic key in options" : "Regenerate"} aria-label="Regenerate" > @@ -71,7 +71,7 @@ export function RecommendationBox({

{text}

{aiDisabled && (

- Add your OpenAI API key or activate Optia Pro in options to use AI suggestions. + Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.

)} diff --git a/app/src/lib/ai-error.ts b/app/src/lib/ai-error.ts new file mode 100644 index 0000000..c8efa1d --- /dev/null +++ b/app/src/lib/ai-error.ts @@ -0,0 +1,14 @@ +// Maps AI errors to user-facing toast text. Proxy quota/unavailable errors +// carry friendly, actionable messages worth surfacing verbatim; anything else +// falls back to a generic message. Kept provider-agnostic (matches by name) so +// UI components don't import the AI modules. +export function aiErrorMessage(error: unknown, fallback: string): string { + if ( + error instanceof Error && + (error.name === "AiUnavailableError" || error.name === "AiProxyError") && + error.message + ) { + return error.message; + } + return fallback; +} diff --git a/app/src/lib/ai-proxy.test.ts b/app/src/lib/ai-proxy.test.ts new file mode 100644 index 0000000..e398180 --- /dev/null +++ b/app/src/lib/ai-proxy.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateViaProxy, AiProxyError, type ProxyRequest } from "@/lib/ai-proxy"; +import { BACKEND_BASE_URL } from "@/lib/entitlement-keys"; +import { getInstallId, getRawEntitlementToken } from "@/lib/entitlement"; + +// Mock only the two entitlement helpers ai-proxy depends on, so this suite drives +// the proxy request/response mapping without touching jose or storage. +vi.mock("@/lib/entitlement", () => ({ + getInstallId: vi.fn(), + getRawEntitlementToken: vi.fn(), +})); + +const getInstallIdMock = vi.mocked(getInstallId); +const getRawEntitlementTokenMock = vi.mocked(getRawEntitlementToken); + +const GENERATE_URL = `${BACKEND_BASE_URL}/ai/generate`; + +function jsonResponse(status: number, body: unknown, headers?: Record) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +/** A 200 whose body is not valid JSON, exercising the `.json()` failure path. */ +function nonJsonResponse(status: number) { + return new Response("<>", { + status, + headers: { "Content-Type": "text/html" }, + }); +} + +function request(overrides: Partial = {}): ProxyRequest { + return { + checkId: "check-1", + keyword: "seo keyword", + context: "the surrounding context", + authenticated: true, + ...overrides, + }; +} + +async function expectProxyError( + promise: Promise, + code: AiProxyError["code"], +): Promise { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(AiProxyError); + expect((error as AiProxyError).code).toBe(code); + return error as AiProxyError; +} + +const OK_BODY = { + recommendation: "Add the keyword to the H1.", + model: "claude-x", + quota: { limit: 100, remaining: 99, period: "2026-07" }, +}; + +beforeEach(() => { + getInstallIdMock.mockResolvedValue("install-xyz"); + getRawEntitlementTokenMock.mockResolvedValue("tok-123"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("generateViaProxy request shape", () => { + it("POSTs to /ai/generate with the check payload and the resolved install id", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY)); + vi.stubGlobal("fetch", fetchMock); + + await generateViaProxy( + request({ checkId: "c9", keyword: "kw", context: "ctx", authenticated: true }), + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(GENERATE_URL, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Optia-Entitlement": "tok-123" }, + body: JSON.stringify({ + checkId: "c9", + keyword: "kw", + context: "ctx", + installId: "install-xyz", + }), + }); + }); + + it("attaches X-Optia-Entitlement when authenticated and a token is available", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY)); + vi.stubGlobal("fetch", fetchMock); + + const result = await generateViaProxy(request({ authenticated: true })); + + const [, init] = fetchMock.mock.calls[0]; + expect((init.headers as Record)["X-Optia-Entitlement"]).toBe("tok-123"); + // The subject actually metered was the entitlement (Pro). + expect(result.authenticated).toBe(true); + }); + + it("reports authenticated:false when authenticated but no token is stored (install-metered)", async () => { + getRawEntitlementTokenMock.mockResolvedValue(null); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY)); + vi.stubGlobal("fetch", fetchMock); + + const result = await generateViaProxy(request({ authenticated: true })); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers).toEqual({ "Content-Type": "application/json" }); + expect(getRawEntitlementTokenMock).toHaveBeenCalledTimes(1); + // A Pro request whose token is missing was metered as free, and says so. + expect(result.authenticated).toBe(false); + }); + + it("omits X-Optia-Entitlement and never reads the token when not authenticated", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY)); + vi.stubGlobal("fetch", fetchMock); + + await generateViaProxy(request({ authenticated: false })); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers).toEqual({ "Content-Type": "application/json" }); + expect(getRawEntitlementTokenMock).not.toHaveBeenCalled(); + }); +}); + +describe("generateViaProxy success", () => { + it("returns the recommendation, model, and quota from a 200 body", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(200, OK_BODY))); + + const result = await generateViaProxy(request()); + + expect(result).toMatchObject(OK_BODY); + expect(result.quota).toEqual({ limit: 100, remaining: 99, period: "2026-07" }); + }); +}); + +describe("generateViaProxy error mapping", () => { + it("maps a 429 with a QUOTA_EXCEEDED code (nested error body) to quota_exceeded", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(429, { error: { code: "QUOTA_EXCEEDED", message: "You are out of AI credits." } }), + ), + ); + + const error = await expectProxyError(generateViaProxy(request()), "quota_exceeded"); + expect(error.message).toBe("You are out of AI credits."); + }); + + it("maps a 429 with a QUOTA_EXCEEDED code (bare code/message body) to quota_exceeded", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(429, { code: "QUOTA_EXCEEDED", message: "Monthly limit hit." }), + ), + ); + + const error = await expectProxyError(generateViaProxy(request()), "quota_exceeded"); + expect(error.message).toBe("Monthly limit hit."); + }); + + it("maps a 429 without a quota code to rate_limited", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse(429, { error: { code: "TOO_FAST" } })), + ); + + await expectProxyError(generateViaProxy(request()), "rate_limited"); + }); + + it("maps a 401 to unauthorized", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse(401, { error: { message: "Entitlement expired." } })), + ); + + const error = await expectProxyError(generateViaProxy(request()), "unauthorized"); + expect(error.message).toBe("Entitlement expired."); + }); + + it("maps a 400 to invalid using a bare message body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse(400, { code: "BAD_INPUT", message: "Keyword required." })), + ); + + const error = await expectProxyError(generateViaProxy(request()), "invalid"); + expect(error.message).toBe("Keyword required."); + }); + + it("maps any other non-ok status to upstream", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(500, {}))); + + const error = await expectProxyError(generateViaProxy(request()), "upstream"); + expect(error.message).toBe("The AI provider could not complete the request."); + }); + + it("falls back to a default message when the error body is not JSON", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(nonJsonResponse(503))); + + const error = await expectProxyError(generateViaProxy(request()), "upstream"); + expect(error.message).toBe("The AI provider could not complete the request."); + }); + + it("maps a fetch rejection to a network error", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + + const error = await expectProxyError(generateViaProxy(request()), "network"); + expect(error.message).toBe("Could not reach the AI service."); + }); +}); + +describe("generateViaProxy malformed success bodies", () => { + it("treats a 200 missing recommendation as upstream", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(200, { model: "claude-x", quota: OK_BODY.quota }), + ), + ); + + await expectProxyError(generateViaProxy(request()), "upstream"); + }); + + it("treats a 200 whose recommendation is not a string as upstream", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(200, { recommendation: 42, model: "claude-x", quota: OK_BODY.quota }), + ), + ); + + await expectProxyError(generateViaProxy(request()), "upstream"); + }); + + it("treats a 200 missing quota as upstream", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse(200, { recommendation: "ok", model: "claude-x" }), + ), + ); + + const error = await expectProxyError(generateViaProxy(request()), "upstream"); + expect(error.message).toBe("The AI service returned an unexpected response."); + }); + + it("treats a 200 with an unparseable body as upstream", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(nonJsonResponse(200))); + + await expectProxyError(generateViaProxy(request()), "upstream"); + }); +}); diff --git a/app/src/lib/ai-proxy.ts b/app/src/lib/ai-proxy.ts new file mode 100644 index 0000000..a33e47a --- /dev/null +++ b/app/src/lib/ai-proxy.ts @@ -0,0 +1,125 @@ +import { BACKEND_BASE_URL } from "@/lib/entitlement-keys"; +import { getInstallId, getRawEntitlementToken } from "@/lib/entitlement"; + +// Hosted, entitlement-gated proxy to Claude. Used for free-tier generation +// (metered by install id) and Pro-without-key generation (metered by the signed +// entitlement, sent as X-Optia-Entitlement). BYO keys never come here — those go +// browser→Anthropic directly (see anthropic.ts). + +export type AiProxyErrorCode = + | "quota_exceeded" + | "rate_limited" + | "unauthorized" + | "invalid" + | "upstream" + | "network"; + +export class AiProxyError extends Error { + constructor( + public code: AiProxyErrorCode, + message: string, + ) { + super(message); + this.name = "AiProxyError"; + } +} + +export interface ProxyQuota { + limit: number; + remaining: number; + period: string; +} + +export interface ProxyResult { + recommendation: string; + model: string; + quota: ProxyQuota; + /** Whether the entitlement was actually presented (drives which quota the caller records). */ + authenticated: boolean; +} + +export interface ProxyRequest { + checkId: string; + keyword: string; + context: string; + /** When true, authenticate as Pro via the stored entitlement; else free-tier by install id. */ + authenticated: boolean; +} + +interface ErrorBody { + error?: { code?: string; message?: string }; + code?: string; + message?: string; +} + +async function readError(response: Response): Promise<{ code: string; message: string }> { + try { + const body = (await response.json()) as ErrorBody; + return { + code: body.error?.code ?? body.code ?? "", + message: body.error?.message ?? body.message ?? "", + }; + } catch { + return { code: "", message: "" }; + } +} + +function mapError(status: number, code: string, message: string): AiProxyError { + if (status === 429) { + if (code === "QUOTA_EXCEEDED") { + return new AiProxyError("quota_exceeded", message || "AI quota reached."); + } + return new AiProxyError("rate_limited", message || "Too many AI requests. Please slow down."); + } + if (status === 401) { + return new AiProxyError("unauthorized", message || "Your Pro entitlement is no longer valid."); + } + if (status === 400) { + return new AiProxyError("invalid", message || "The AI request was rejected."); + } + return new AiProxyError("upstream", message || "The AI provider could not complete the request."); +} + +/** POSTs one generation to the hosted proxy. Throws AiProxyError on failure. */ +export async function generateViaProxy(request: ProxyRequest): Promise { + const { checkId, keyword, context, authenticated } = request; + const headers: Record = { "Content-Type": "application/json" }; + // Pro metering requires the token to actually be present; if it isn't, the + // request is install-metered (free) and the caller records it as such. + let didAuthenticate = false; + if (authenticated) { + const token = await getRawEntitlementToken(); + if (token) { + headers["X-Optia-Entitlement"] = token; + didAuthenticate = true; + } + } + const installId = await getInstallId(); + + let response: Response; + try { + response = await fetch(`${BACKEND_BASE_URL}/ai/generate`, { + method: "POST", + headers, + body: JSON.stringify({ checkId, keyword, context, installId }), + }); + } catch { + throw new AiProxyError("network", "Could not reach the AI service."); + } + + if (!response.ok) { + const { code, message } = await readError(response); + throw mapError(response.status, code, message); + } + + const body = (await response.json().catch(() => null)) as Partial | null; + if (!body || typeof body.recommendation !== "string" || !body.quota) { + throw new AiProxyError("upstream", "The AI service returned an unexpected response."); + } + return { + recommendation: body.recommendation, + model: body.model ?? "", + quota: body.quota, + authenticated: didAuthenticate, + }; +} diff --git a/app/src/lib/ai.test.ts b/app/src/lib/ai.test.ts new file mode 100644 index 0000000..56e6905 --- /dev/null +++ b/app/src/lib/ai.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + generateRecommendation, + generateH2Suggestion, + generateAllH2Suggestions, + generateAltText, + AiUnavailableError, + type AdvancedOptions, +} from "@/lib/ai"; +import { + generateRecommendationDirect, + generateH2SuggestionDirect, + generateAltTextDirect, +} from "@/lib/anthropic"; +import { generateViaProxy, AiProxyError } from "@/lib/ai-proxy"; +import { aiStatusNow, useEntitlementStore, type AiStatus } from "@/lib/entitlement-store"; +import { useStore } from "@/lib/store"; + +// The AI facade only routes; the three access paths (direct SDK, hosted proxy) +// and the entitlement gate are all mocked so these tests assert *dispatch* — +// which downstream fn is called, with what args — not the generation itself. +vi.mock("@/lib/anthropic", () => ({ + generateRecommendationDirect: vi.fn(), + generateH2SuggestionDirect: vi.fn(), + generateAltTextDirect: vi.fn(), +})); + +// Keep the real AiProxyError class (ai.ts does `err instanceof AiProxyError`); +// only the network call is mocked. +vi.mock("@/lib/ai-proxy", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, generateViaProxy: vi.fn() }; +}); + +// Keep the real store instance (so applyProxyQuota can be swapped via setState) +// but take control of the entitlement decision by mocking aiStatusNow. +vi.mock("@/lib/entitlement-store", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, aiStatusNow: vi.fn() }; +}); + +const generateRecommendationDirectMock = vi.mocked(generateRecommendationDirect); +const generateH2SuggestionDirectMock = vi.mocked(generateH2SuggestionDirect); +const generateAltTextDirectMock = vi.mocked(generateAltTextDirect); +const generateViaProxyMock = vi.mocked(generateViaProxy); +const aiStatusNowMock = vi.mocked(aiStatusNow); + +const TEST_KEY = "sk-ant-test-key"; +const testQuota = { period: "2026-07", remaining: 42, limit: 100 }; +const advancedOptions: AdvancedOptions = { + pageType: "product-page", + secondaryKeywords: "seo, ranking", + languageCode: "fr", +}; + +// applyProxyQuota is a store action; we swap it for a spy each test so we can +// assert the recorded (quota, subject) without touching real storage. +let applyProxyQuotaMock: ReturnType; + +function setMode(mode: AiStatus["mode"]): void { + aiStatusNowMock.mockReturnValue({ mode, remaining: null, limit: null }); +} + +beforeEach(() => { + applyProxyQuotaMock = vi.fn().mockResolvedValue(undefined); + useEntitlementStore.setState({ applyProxyQuota: applyProxyQuotaMock }); + useStore.setState({ apiKey: "" }); + + generateRecommendationDirectMock.mockResolvedValue("direct recommendation"); + generateH2SuggestionDirectMock.mockResolvedValue("direct h2"); + generateAltTextDirectMock.mockResolvedValue("direct alt"); + // The proxy reports the subject it actually metered against; mirror the + // request's `authenticated` flag (a Pro user in these tests has a token). + generateViaProxyMock.mockImplementation(async (req) => ({ + recommendation: "proxy recommendation", + model: "claude-opus-4-8", + quota: testQuota, + authenticated: req.authenticated, + })); +}); + +describe("generateRecommendation", () => { + it("byok → calls the Direct fn with the stored key + advancedOptions, never the proxy", async () => { + setMode("byok"); + useStore.setState({ apiKey: TEST_KEY }); + generateRecommendationDirectMock.mockResolvedValue("byok rec"); + + const result = await generateRecommendation("title-keyword", "kw", "current title", advancedOptions); + + expect(result).toBe("byok rec"); + expect(generateRecommendationDirectMock).toHaveBeenCalledWith( + TEST_KEY, + "title-keyword", + "kw", + "current title", + advancedOptions, + ); + expect(generateViaProxyMock).not.toHaveBeenCalled(); + expect(applyProxyQuotaMock).not.toHaveBeenCalled(); + }); + + it("pro → routes to the proxy authenticated, records the quota as 'pro', returns the recommendation", async () => { + setMode("pro"); + + const result = await generateRecommendation("title-keyword", "kw", "current title", advancedOptions); + + expect(result).toBe("proxy recommendation"); + expect(generateViaProxyMock).toHaveBeenCalledWith({ + checkId: "title-keyword", + keyword: "kw", + context: "current title", + authenticated: true, + }); + // advancedOptions are a BYO-key enhancement and must NOT reach the proxy. + expect(generateViaProxyMock).toHaveBeenCalledTimes(1); + expect(generateViaProxyMock.mock.calls[0][0]).not.toHaveProperty("advancedOptions"); + expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "pro"); + expect(generateRecommendationDirectMock).not.toHaveBeenCalled(); + }); + + it("free → routes to the proxy unauthenticated and records the quota as 'free'", async () => { + setMode("free"); + + const result = await generateRecommendation("meta-description-keyword", "kw", "desc"); + + expect(result).toBe("proxy recommendation"); + expect(generateViaProxyMock).toHaveBeenCalledWith({ + checkId: "meta-description-keyword", + keyword: "kw", + context: "desc", + authenticated: false, + }); + expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "free"); + expect(generateRecommendationDirectMock).not.toHaveBeenCalled(); + }); + + it("locked → throws AiUnavailableError and calls nothing downstream", async () => { + setMode("locked"); + + const error = await generateRecommendation("title-keyword", "kw", "ctx").then( + () => null, + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(AiUnavailableError); + expect((error as Error).name).toBe("AiUnavailableError"); + expect(generateRecommendationDirectMock).not.toHaveBeenCalled(); + expect(generateViaProxyMock).not.toHaveBeenCalled(); + expect(applyProxyQuotaMock).not.toHaveBeenCalled(); + }); + + it("free → a server quota_exceeded drives cached remaining to 0 and re-throws", async () => { + setMode("free"); + useEntitlementStore.setState({ freeAiLimit: 10 }); + generateViaProxyMock.mockRejectedValue(new AiProxyError("quota_exceeded", "quota reached")); + + const error = await generateRecommendation("title-keyword", "kw", "ctx").then( + () => null, + (e: unknown) => e, + ); + + // Re-thrown so the UI surfaces the friendly message... + expect(error).toBeInstanceOf(AiProxyError); + // ...and the cache is driven to 0 so the gate converges to locked/upsell. + expect(applyProxyQuotaMock).toHaveBeenCalledWith( + expect.objectContaining({ remaining: 0, limit: 10 }), + "free", + ); + }); + + it("pro → records against the subject the proxy actually metered (free if no token)", async () => { + setMode("pro"); + // A Pro request whose token was missing is install-metered by the server. + generateViaProxyMock.mockResolvedValue({ + recommendation: "r", + model: "m", + quota: testQuota, + authenticated: false, + }); + + await generateRecommendation("title-keyword", "kw", "ctx"); + + expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "free"); + }); +}); + +describe("generateH2Suggestion", () => { + it("byok → calls generateH2SuggestionDirect with the key, h2 text, keyword, and advancedOptions", async () => { + setMode("byok"); + useStore.setState({ apiKey: TEST_KEY }); + generateH2SuggestionDirectMock.mockResolvedValue("byok h2"); + + const result = await generateH2Suggestion("Current Heading", "kw", advancedOptions); + + expect(result).toBe("byok h2"); + expect(generateH2SuggestionDirectMock).toHaveBeenCalledWith( + TEST_KEY, + "Current Heading", + "kw", + advancedOptions, + ); + expect(generateViaProxyMock).not.toHaveBeenCalled(); + }); + + it("proxy path → maps to checkId 'h2-keyword' with the h2 text as context", async () => { + setMode("pro"); + + const result = await generateH2Suggestion("Current Heading", "kw", advancedOptions); + + expect(result).toBe("proxy recommendation"); + expect(generateViaProxyMock).toHaveBeenCalledWith({ + checkId: "h2-keyword", + keyword: "kw", + context: "Current Heading", + authenticated: true, + }); + expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "pro"); + }); + + it("locked → throws AiUnavailableError", async () => { + setMode("locked"); + + const error = await generateH2Suggestion("Heading", "kw").then( + () => null, + (e: unknown) => e, + ); + + expect((error as Error).name).toBe("AiUnavailableError"); + expect(generateH2SuggestionDirectMock).not.toHaveBeenCalled(); + expect(generateViaProxyMock).not.toHaveBeenCalled(); + }); +}); + +describe("generateAllH2Suggestions", () => { + it("byok → returns an array, one Direct-generated suggestion per input heading", async () => { + setMode("byok"); + useStore.setState({ apiKey: TEST_KEY }); + generateH2SuggestionDirectMock.mockImplementation(async (_key, h2Text) => `optimized: ${h2Text}`); + + const result = await generateAllH2Suggestions(["Intro", "Features", "Pricing"], "kw", advancedOptions); + + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(["optimized: Intro", "optimized: Features", "optimized: Pricing"]); + expect(generateH2SuggestionDirectMock).toHaveBeenCalledTimes(3); + expect(generateH2SuggestionDirectMock).toHaveBeenNthCalledWith(1, TEST_KEY, "Intro", "kw", advancedOptions); + }); + + it("proxy path → returns an array and records quota once per heading", async () => { + setMode("free"); + + const result = await generateAllH2Suggestions(["A", "B"], "kw"); + + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(["proxy recommendation", "proxy recommendation"]); + expect(generateViaProxyMock).toHaveBeenCalledTimes(2); + expect(applyProxyQuotaMock).toHaveBeenCalledTimes(2); + expect(applyProxyQuotaMock).toHaveBeenLastCalledWith(testQuota, "free"); + }); + + it("returns an empty array when given no headings (never routes)", async () => { + setMode("free"); + + const result = await generateAllH2Suggestions([], "kw"); + + expect(result).toEqual([]); + expect(generateViaProxyMock).not.toHaveBeenCalled(); + expect(generateH2SuggestionDirectMock).not.toHaveBeenCalled(); + }); +}); + +describe("generateAltText", () => { + it("byok → calls generateAltTextDirect with the key, image src, keyword, and advancedOptions", async () => { + setMode("byok"); + useStore.setState({ apiKey: TEST_KEY }); + generateAltTextDirectMock.mockResolvedValue("byok alt"); + + const result = await generateAltText("https://cdn.example.com/pic.png", "kw", advancedOptions); + + expect(result).toBe("byok alt"); + expect(generateAltTextDirectMock).toHaveBeenCalledWith( + TEST_KEY, + "https://cdn.example.com/pic.png", + "kw", + advancedOptions, + ); + expect(generateViaProxyMock).not.toHaveBeenCalled(); + }); + + it("proxy path → maps to checkId 'images-alt' with the image src as context", async () => { + setMode("free"); + + const result = await generateAltText("https://cdn.example.com/pic.png", "kw"); + + expect(result).toBe("proxy recommendation"); + expect(generateViaProxyMock).toHaveBeenCalledWith({ + checkId: "images-alt", + keyword: "kw", + context: "https://cdn.example.com/pic.png", + authenticated: false, + }); + expect(applyProxyQuotaMock).toHaveBeenCalledWith(testQuota, "free"); + }); + + it("locked → throws AiUnavailableError", async () => { + setMode("locked"); + + const error = await generateAltText("https://cdn.example.com/pic.png", "kw").then( + () => null, + (e: unknown) => e, + ); + + expect((error as Error).name).toBe("AiUnavailableError"); + expect(generateAltTextDirectMock).not.toHaveBeenCalled(); + expect(generateViaProxyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/lib/ai.ts b/app/src/lib/ai.ts new file mode 100644 index 0000000..57f199d --- /dev/null +++ b/app/src/lib/ai.ts @@ -0,0 +1,120 @@ +import { useStore } from "@/lib/store"; +import { aiStatusNow, useEntitlementStore } from "@/lib/entitlement-store"; +import { currentAiPeriod } from "@/lib/entitlement"; +import { + generateRecommendationDirect, + generateH2SuggestionDirect, + generateAltTextDirect, + type AdvancedOptions, +} from "@/lib/anthropic"; +import { generateViaProxy, AiProxyError } from "@/lib/ai-proxy"; + +export type { AdvancedOptions }; + +// AI facade: routes each generation to the right path based on entitlement. +// byok → direct browser→Anthropic call with the user's key (advanced context applies) +// pro → hosted proxy authenticated by entitlement (higher monthly cap) +// free → hosted proxy metered by install id (capped monthly allowance) +// locked → no path available → AiUnavailableError (surface a friendly upsell) +// +// The proxy accepts only { checkId, keyword, context }; advanced options are a +// BYO-key enhancement and are not forwarded to it. + +export class AiUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "AiUnavailableError"; + } +} + +/** Locked-state message tailored to the tier so it never dead-ends the user. */ +function lockedError(): AiUnavailableError { + const isPro = useEntitlementStore.getState().isPro; + return new AiUnavailableError( + isPro + ? "You've reached your monthly AI limit. Add your own Anthropic key in options for unlimited AI." + : "You've used your free AI recommendations for this month. Upgrade to Optia Pro for more.", + ); +} + +async function runProxy( + checkId: string, + keyword: string, + context: string, + authenticated: boolean, +): Promise { + try { + const result = await generateViaProxy({ checkId, keyword, context, authenticated }); + // Record against the subject the server actually metered (a Pro request with + // no token falls back to install metering). + await useEntitlementStore + .getState() + .applyProxyQuota(result.quota, result.authenticated ? "pro" : "free"); + return result.recommendation; + } catch (err) { + // The server is the quota authority: on a quota rejection, drive the cached + // remaining to 0 so the UI converges to the locked/upsell state instead of + // looping on error toasts with the controls still enabled. + if (err instanceof AiProxyError && err.code === "quota_exceeded") { + const state = useEntitlementStore.getState(); + const subject = authenticated ? "pro" : "free"; + const limit = subject === "pro" ? state.quotaLimit : (state.freeAiLimit ?? 0); + await state.applyProxyQuota({ period: currentAiPeriod(), remaining: 0, limit }, subject); + } + throw err; + } +} + +export async function generateRecommendation( + checkId: string, + keyword: string, + context: string, + advancedOptions?: AdvancedOptions, +): Promise { + const status = aiStatusNow(); + if (status.mode === "locked") throw lockedError(); + if (status.mode === "byok") { + return generateRecommendationDirect( + useStore.getState().apiKey, + checkId, + keyword, + context, + advancedOptions, + ); + } + return runProxy(checkId, keyword, context, status.mode === "pro"); +} + +export async function generateH2Suggestion( + h2Text: string, + keyword: string, + advancedOptions?: AdvancedOptions, +): Promise { + const status = aiStatusNow(); + if (status.mode === "locked") throw lockedError(); + if (status.mode === "byok") { + return generateH2SuggestionDirect(useStore.getState().apiKey, h2Text, keyword, advancedOptions); + } + return runProxy("h2-keyword", keyword, h2Text, status.mode === "pro"); +} + +export async function generateAllH2Suggestions( + h2Texts: string[], + keyword: string, + advancedOptions?: AdvancedOptions, +): Promise { + return Promise.all(h2Texts.map((text) => generateH2Suggestion(text, keyword, advancedOptions))); +} + +export async function generateAltText( + imageSrc: string, + keyword: string, + advancedOptions?: AdvancedOptions, +): Promise { + const status = aiStatusNow(); + if (status.mode === "locked") throw lockedError(); + if (status.mode === "byok") { + return generateAltTextDirect(useStore.getState().apiKey, imageSrc, keyword, advancedOptions); + } + return runProxy("images-alt", keyword, imageSrc, status.mode === "pro"); +} diff --git a/app/src/lib/anthropic.test.ts b/app/src/lib/anthropic.test.ts new file mode 100644 index 0000000..a5f2e6f --- /dev/null +++ b/app/src/lib/anthropic.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + AI_MODEL, + generateRecommendationDirect, + generateH2SuggestionDirect, + generateAltTextDirect, +} from "@/lib/anthropic"; + +// A module-scoped mock for `client.messages.create`, driven per-test. Hoisted so +// the vi.mock factory below (which is hoisted above imports) can close over it. +const createMock = vi.hoisted(() => vi.fn()); + +// Replace the SDK's default export with a constructor that hands back a client +// whose messages.create is our controllable mock. The constructor args +// (apiKey/dangerouslyAllowBrowser/baseURL) are irrelevant here since the network +// is never touched. +vi.mock("@anthropic-ai/sdk", () => ({ + default: vi.fn().mockImplementation(() => ({ + messages: { create: createMock }, + })), +})); + +/** Build a mock Anthropic Message whose text block carries `text`. */ +function textMessage(text: string) { + return { content: [{ type: "text", text }] }; +} + +/** Grab the single argument object passed to the most recent create() call. */ +function lastCreateArgs() { + return createMock.mock.calls[createMock.mock.calls.length - 1][0] as { + model: string; + max_tokens: number; + system: string; + messages: { role: string; content: string }[]; + }; +} + +beforeEach(() => { + createMock.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("generateRecommendationDirect", () => { + it("returns the model text for a copyable check and strips wrapping quotes", async () => { + // Model wraps its answer in quotes the way it sometimes does. + createMock.mockResolvedValue(textMessage('"Best Running Shoes for Trail Runners"')); + + const result = await generateRecommendationDirect( + "sk-test", + "title-keyword", + "running shoes", + "Old Title", + ); + + expect(result).toBe("Best Running Shoes for Trail Runners"); + + const args = lastCreateArgs(); + expect(createMock).toHaveBeenCalledTimes(1); + // The copyable branch uses the "ready-to-use content" system prompt. + expect(args.system).toContain("ready-to-use content"); + expect(args.messages[0].content).toContain('perfect SEO title for the keyphrase "running shoes"'); + }); + + it("passes the Opus 4.8 model and the 1024 max_tokens budget", async () => { + createMock.mockResolvedValue(textMessage("Something")); + + await generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx"); + + expect(AI_MODEL).toBe("claude-opus-4-8"); + const args = lastCreateArgs(); + expect(args.model).toBe("claude-opus-4-8"); + expect(args.max_tokens).toBe(1024); + expect(args.messages[0].role).toBe("user"); + }); + + it("uses the advisory system prompt for a non-listed checkId", async () => { + createMock.mockResolvedValue(textMessage("Add descriptive internal links throughout the page.")); + + const result = await generateRecommendationDirect( + "sk-test", + "some-unknown-check", + "running shoes", + "current status here", + ); + + expect(result).toBe("Add descriptive internal links throughout the page."); + + const args = lastCreateArgs(); + // Default branch => advisory ("actionable advice") system prompt, not copyable. + expect(args.system).toContain("actionable advice"); + expect(args.system).not.toContain("ready-to-use content"); + expect(args.messages[0].content).toContain('Fix this SEO issue: "some-unknown-check"'); + }); + + it("reads the first text block even when a non-text block precedes it", async () => { + createMock.mockResolvedValue({ + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "The Final Title" }, + ], + }); + + const result = await generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx"); + + expect(result).toBe("The Final Title"); + }); +}); + +describe("generateH2SuggestionDirect", () => { + it("returns the generated H2 heading text", async () => { + createMock.mockResolvedValue(textMessage("Choosing the Best Running Shoes")); + + const result = await generateH2SuggestionDirect( + "sk-test", + "Old Heading", + "running shoes", + ); + + expect(result).toBe("Choosing the Best Running Shoes"); + + const args = lastCreateArgs(); + expect(args.model).toBe("claude-opus-4-8"); + expect(args.system).toContain("H2 heading"); + expect(args.messages[0].content).toContain('perfect H2 heading for the keyphrase "running shoes"'); + expect(args.messages[0].content).toContain('Current H2: "Old Heading"'); + }); +}); + +describe("generateAltTextDirect", () => { + it("returns the alt text and derives the filename from the image URL", async () => { + createMock.mockResolvedValue(textMessage("Trail running shoes on a rocky path")); + + const result = await generateAltTextDirect( + "sk-test", + "https://cdn.example.com/assets/trail-shoes.jpg?v=2", + "running shoes", + ); + + expect(result).toBe("Trail running shoes on a rocky path"); + + const args = lastCreateArgs(); + expect(args.model).toBe("claude-opus-4-8"); + expect(args.system).toContain("accessibility expert"); + // Query string stripped, path removed: bare filename in the prompt. + expect(args.messages[0].content).toContain("Image filename: trail-shoes.jpg"); + expect(args.messages[0].content).toContain( + "https://cdn.example.com/assets/trail-shoes.jpg?v=2", + ); + }); +}); + +describe("advancedOptions shape the prompt", () => { + it("injects language, page type, and secondary keyword context", async () => { + createMock.mockResolvedValue(textMessage("Meilleures Chaussures de Course")); + + await generateRecommendationDirect( + "sk-test", + "title-keyword", + "chaussures de course", + "Ancien titre", + { + pageType: "product-page", + secondaryKeywords: "chaussures de trail, running", + languageCode: "fr", + }, + ); + + const args = lastCreateArgs(); + // French language instruction lives in the system prompt. + expect(args.system).toContain("Generate all content in French (Français)"); + // Advanced context and the humanized page type live in the user prompt. + expect(args.messages[0].content).toContain("Advanced Context:"); + expect(args.messages[0].content).toContain("Page Type: product-page"); + expect(args.messages[0].content).toContain("Secondary Keywords: chaussures de trail, running"); + expect(args.messages[0].content).toContain("for a product page"); + }); + + it("adds no language instruction for English (the default)", async () => { + createMock.mockResolvedValue(textMessage("A Title")); + + await generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx", { + languageCode: "en", + }); + + const args = lastCreateArgs(); + expect(args.system).not.toContain("Generate all content in"); + expect(args.messages[0].content).not.toContain("Advanced Context:"); + }); +}); + +describe("completeWithRetry behavior", () => { + it("throws a 401 immediately without retrying", async () => { + createMock.mockRejectedValue(Object.assign(new Error("Unauthorized"), { status: 401 })); + + await expect( + generateRecommendationDirect("sk-bad", "title-keyword", "kw", "ctx"), + ).rejects.toThrow("Unauthorized"); + + // Auth failures short-circuit: exactly one attempt. + expect(createMock).toHaveBeenCalledTimes(1); + }); + + it("retries a transient error and then succeeds", async () => { + vi.useFakeTimers(); + createMock + .mockRejectedValueOnce(Object.assign(new Error("Server Error"), { status: 500 })) + .mockResolvedValueOnce(textMessage("Recovered Title")); + + const promise = generateRecommendationDirect("sk-test", "title-keyword", "kw", "ctx"); + // Drain the backoff setTimeout so the retry fires without waiting real seconds. + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe("Recovered Title"); + expect(createMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/src/lib/openai.ts b/app/src/lib/anthropic.ts similarity index 70% rename from app/src/lib/openai.ts rename to app/src/lib/anthropic.ts index a9de48d..5e76dbd 100644 --- a/app/src/lib/openai.ts +++ b/app/src/lib/anthropic.ts @@ -1,18 +1,34 @@ -import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; import { getLanguageByCode } from "./languages"; +// Direct browser→Anthropic path for Pro users who bring their own key. The key +// never transits Optia's backend (that path is the hosted proxy in ai-proxy.ts). +// Free/entitlement-metered generation goes through the proxy instead. + +// SEO snippet generation is short and latency-sensitive; Opus 4.8 is the default +// per Anthropic guidance. Change AI_MODEL if a cheaper tier is preferred. +export const AI_MODEL = "claude-opus-4-8"; +const MAX_TOKENS = 1024; + const isDevMode = typeof window !== "undefined" && window.location?.hostname === "localhost"; -function createClient(apiKey: string): OpenAI { - return new OpenAI({ +function createClient(apiKey: string): Anthropic { + return new Anthropic({ apiKey, dangerouslyAllowBrowser: true, - ...(isDevMode ? { baseURL: `${window.location.origin}/api/openai` } : {}), + ...(isDevMode ? { baseURL: `${window.location.origin}/api/anthropic` } : {}), }); } -async function chatWithRetry( +function extractText(message: Anthropic.Message): string { + const block = message.content.find((b) => b.type === "text"); + const text = block && block.type === "text" ? block.text.trim() : ""; + // Strip wrapping quotes the model sometimes adds + return text.replace(/^["']|["']$/g, ""); +} + +async function completeWithRetry( apiKey: string, systemPrompt: string, userPrompt: string, @@ -23,20 +39,15 @@ async function chatWithRetry( while (retries <= maxRetries) { try { - const response = await client.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - max_tokens: 500, - temperature: 0.7, + const message = await client.messages.create({ + model: AI_MODEL, + max_tokens: MAX_TOKENS, + system: systemPrompt, + messages: [{ role: "user", content: userPrompt }], }); - const text = response.choices[0]?.message?.content?.trim() ?? ""; - // Strip wrapping quotes if present - return text.replace(/^["']|["']$/g, ""); + return extractText(message); } catch (error: unknown) { - // Don't retry auth or billing errors — they won't resolve + // Auth/permission errors won't resolve on retry const status = (error as { status?: number }).status; if (status === 401 || status === 403) throw error; if (retries === maxRetries) throw error; @@ -47,7 +58,7 @@ async function chatWithRetry( throw new Error("Max retries exceeded"); } -interface AdvancedOptions { +export interface AdvancedOptions { pageType?: string; secondaryKeywords?: string; languageCode?: string; @@ -71,7 +82,8 @@ function buildLanguageInstruction(langCode?: string): string { return `\n\nIMPORTANT: Generate all content in ${lang.name} (${lang.nativeName}). Provide recommendations entirely in this language.`; } -export async function generateRecommendation( +/** Direct Anthropic generation for a single SEO check (BYO-key path). */ +export async function generateRecommendationDirect( apiKey: string, checkId: string, keyword: string, @@ -94,7 +106,7 @@ Provide a concise recommendation for improving this SEO issue.${advCtx ? " Consi switch (checkId) { case "title-keyword": - return chatWithRetry(apiKey, copyableSystem, + return completeWithRetry(apiKey, copyableSystem, `Create a perfect SEO title for the keyphrase "${keyword}". Current title: ${context}${advCtx} Requirements: @@ -104,7 +116,7 @@ Requirements: Return ONLY the title text.`); case "meta-description-keyword": - return chatWithRetry(apiKey, copyableSystem, + return completeWithRetry(apiKey, copyableSystem, `Create a perfect meta description for the keyphrase "${keyword}". Current description: ${context}${advCtx} Requirements: @@ -114,7 +126,7 @@ Requirements: Return ONLY the description text.`); case "keyword-url": - return chatWithRetry(apiKey, copyableSystem, + return completeWithRetry(apiKey, copyableSystem, `Create an SEO-friendly URL slug for the keyphrase "${keyword}". Current URL: ${context}${advCtx} Requirements: @@ -127,7 +139,7 @@ Requirements: Return ONLY the page slug with no slashes, protocol, or domain.`); case "h1-keyword": - return chatWithRetry(apiKey, copyableSystem, + return completeWithRetry(apiKey, copyableSystem, `Create a perfect H1 heading for the keyphrase "${keyword}". Current H1: ${context}${advCtx} Requirements: @@ -137,7 +149,7 @@ Requirements: Return ONLY the H1 heading text.`); case "keyword-intro": - return chatWithRetry(apiKey, copyableSystem, + return completeWithRetry(apiKey, copyableSystem, `Rewrite this introduction to naturally include the keyphrase "${keyword}". Current introduction: ${context}${advCtx} Requirements: @@ -147,32 +159,16 @@ Requirements: - Make it engaging${pageTypeStr} Return ONLY the rewritten introduction.`); - case "keyword-density": - case "word-count": - case "heading-hierarchy": - case "internal-links": - case "outbound-links": - case "next-gen-images": - case "code-minification": - case "schema-markup": - case "image-file-size": - case "og-image": - case "og-tags": - case "canonical": - case "lang": - return chatWithRetry(apiKey, advisorySystem, + default: + return completeWithRetry(apiKey, advisorySystem, `Fix this SEO issue: "${checkId}" for keyphrase "${keyword}" if applicable. Current status: ${context}${advCtx} Provide concise but actionable advice in 2-3 sentences${pageTypeStr}.`); - - default: - return chatWithRetry(apiKey, advisorySystem, - `Provide SEO advice for: "${checkId}" regarding keyphrase "${keyword}". -Context: ${context}${advCtx}`); } } -export async function generateH2Suggestion( +/** Direct Anthropic H2 heading generation (BYO-key path). */ +export async function generateH2SuggestionDirect( apiKey: string, h2Text: string, keyword: string, @@ -184,7 +180,7 @@ export async function generateH2Suggestion( ? ` for a ${advancedOptions.pageType.replace("-", " ")}` : ""; - return chatWithRetry( + return completeWithRetry( apiKey, `You are an SEO expert providing ready-to-use content. Return ONLY the final H2 heading text with no explanation, quotes, or formatting.${langInst}`, @@ -200,19 +196,8 @@ Return ONLY the H2 heading text.`, ); } -export async function generateAllH2Suggestions( - apiKey: string, - h2Texts: string[], - keyword: string, - advancedOptions?: AdvancedOptions, -): Promise { - const results = await Promise.all( - h2Texts.map((text) => generateH2Suggestion(apiKey, text, keyword, advancedOptions)), - ); - return results; -} - -export async function generateAltText( +/** Direct Anthropic alt-text generation (BYO-key path). */ +export async function generateAltTextDirect( apiKey: string, imageSrc: string, keyword: string, @@ -222,7 +207,7 @@ export async function generateAltText( const langInst = buildLanguageInstruction(advancedOptions?.languageCode); const filename = imageSrc.split("/").pop()?.split("?")[0] ?? "unknown"; - return chatWithRetry( + return completeWithRetry( apiKey, `You are an SEO and accessibility expert. Return ONLY the alt text string with no explanations, quotes, or formatting.${langInst}`, `Create a concise, descriptive alt tag for this image that naturally incorporates the keyphrase "${keyword}". @@ -236,22 +221,3 @@ Requirements: Return ONLY the alt text.`, ); } - -// Legacy exports for backward compatibility in SubscoresPage -export async function generateTitle( - apiKey: string, - keyword: string, - context: string, - advancedOptions?: AdvancedOptions, -): Promise { - return generateRecommendation(apiKey, "title-keyword", keyword, context, advancedOptions); -} - -export async function generateMetaDescription( - apiKey: string, - keyword: string, - context: string, - advancedOptions?: AdvancedOptions, -): Promise { - return generateRecommendation(apiKey, "meta-description-keyword", keyword, context, advancedOptions); -} diff --git a/app/src/lib/entitlement-store.test.ts b/app/src/lib/entitlement-store.test.ts index f12a8ef..7f49c1f 100644 --- a/app/src/lib/entitlement-store.test.ts +++ b/app/src/lib/entitlement-store.test.ts @@ -1,42 +1,57 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; import { useEntitlementStore, - canUseAINow, - isMeteredAiCall, + useAiStatus, + useCanUseAI, + aiStatusNow, initEntitlementSync, } from "@/lib/entitlement-store"; import { useStore } from "@/lib/store"; import { - activate, - consumeAiQuota, - deactivate, - getAiQuotaRemaining, getValidEntitlement, + getFreeAiQuota, + getProAiRemaining, hasStoredLicenseKey, + activate, + deactivate, + recordProAiQuota, + recordFreeAiQuota, ENTITLEMENT_TOKEN_KEY, + LICENSE_KEY_KEY, + PRO_AI_QUOTA_KEY, + FREE_AI_QUOTA_KEY, type EntitlementClaims, + type QuotaSnapshot, } from "@/lib/entitlement"; import { LicenseError } from "@/lib/backend"; +// The store owns the feature flags; the entitlement layer underneath is mocked +// so these tests drive the flag/quota logic without jose, storage, or network. +// Constants (the watched storage keys) stay real via importOriginal. vi.mock("@/lib/entitlement", async (importOriginal) => { const original = await importOriginal(); return { ...original, getValidEntitlement: vi.fn(), - getAiQuotaRemaining: vi.fn(), + getFreeAiQuota: vi.fn(), + getProAiRemaining: vi.fn(), hasStoredLicenseKey: vi.fn(), activate: vi.fn(), deactivate: vi.fn(), - consumeAiQuota: vi.fn(), + recordProAiQuota: vi.fn(), + recordFreeAiQuota: vi.fn(), }; }); const getValidEntitlementMock = vi.mocked(getValidEntitlement); -const getAiQuotaRemainingMock = vi.mocked(getAiQuotaRemaining); +const getFreeAiQuotaMock = vi.mocked(getFreeAiQuota); +const getProAiRemainingMock = vi.mocked(getProAiRemaining); const hasStoredLicenseKeyMock = vi.mocked(hasStoredLicenseKey); const activateMock = vi.mocked(activate); const deactivateMock = vi.mocked(deactivate); -const consumeAiQuotaMock = vi.mocked(consumeAiQuota); +const recordProAiQuotaMock = vi.mocked(recordProAiQuota); +const recordFreeAiQuotaMock = vi.mocked(recordFreeAiQuota); const proClaims: EntitlementClaims = { sub: "lic_1", @@ -48,6 +63,9 @@ const proClaims: EntitlementClaims = { iat: Math.floor(Date.now() / 1000), }; +const freeSnapshot: QuotaSnapshot = { period: "2026-07", remaining: 4, limit: 10 }; + +// Full free/default shape — resets every store field so no test leaks state. function resetEntitlementState() { useEntitlementStore.setState({ entitlementLoaded: false, @@ -56,7 +74,12 @@ function resetEntitlementState() { expiresAt: null, quotaLimit: 0, aiQuotaRemaining: 0, + freeAiRemaining: null, + freeAiLimit: null, canUseAdvancedOptions: false, + canUseMultiLanguage: false, + canUseSchema: false, + canBringOwnKey: false, hasLicenseKey: false, activating: false, activationError: null, @@ -68,14 +91,16 @@ beforeEach(() => { useStore.setState({ apiKey: "" }); hasStoredLicenseKeyMock.mockResolvedValue(false); getValidEntitlementMock.mockResolvedValue(null); - getAiQuotaRemainingMock.mockResolvedValue(0); + getFreeAiQuotaMock.mockResolvedValue(null); + getProAiRemainingMock.mockResolvedValue(0); }); describe("hydrateEntitlement", () => { - it("maps a valid Pro entitlement to flags", async () => { + it("maps a valid Pro entitlement to all Pro flags", async () => { getValidEntitlementMock.mockResolvedValue(proClaims); - getAiQuotaRemainingMock.mockResolvedValue(42); + getProAiRemainingMock.mockResolvedValue(42); hasStoredLicenseKeyMock.mockResolvedValue(true); + getFreeAiQuotaMock.mockResolvedValue(freeSnapshot); await useEntitlementStore.getState().hydrateEntitlement(); @@ -86,56 +111,179 @@ describe("hydrateEntitlement", () => { expect(state.expiresAt).toBe(proClaims.exp * 1000); expect(state.quotaLimit).toBe(100); expect(state.aiQuotaRemaining).toBe(42); - expect(state.canUseAdvancedOptions).toBe(true); expect(state.hasLicenseKey).toBe(true); + expect(state.canUseAdvancedOptions).toBe(true); + expect(state.canUseMultiLanguage).toBe(true); + expect(state.canUseSchema).toBe(true); + expect(state.canBringOwnKey).toBe(true); + // Free-quota snapshot is populated even on the Pro branch. + expect(state.freeAiRemaining).toBe(4); + expect(state.freeAiLimit).toBe(10); + }); + + it("reads the Pro remaining quota from getProAiRemaining(claims)", async () => { + getValidEntitlementMock.mockResolvedValue(proClaims); + getProAiRemainingMock.mockResolvedValue(7); + + await useEntitlementStore.getState().hydrateEntitlement(); + + expect(getProAiRemainingMock).toHaveBeenCalledWith(proClaims); + expect(useEntitlementStore.getState().aiQuotaRemaining).toBe(7); }); - it("resolves missing or expired entitlements to the free tier", async () => { + it("resolves a missing or expired entitlement to free defaults", async () => { getValidEntitlementMock.mockResolvedValue(null); + hasStoredLicenseKeyMock.mockResolvedValue(true); await useEntitlementStore.getState().hydrateEntitlement(); const state = useEntitlementStore.getState(); expect(state.entitlementLoaded).toBe(true); expect(state.isPro).toBe(false); + expect(state.tier).toBe("free"); + expect(state.expiresAt).toBeNull(); + expect(state.quotaLimit).toBe(0); expect(state.aiQuotaRemaining).toBe(0); expect(state.canUseAdvancedOptions).toBe(false); + expect(state.canUseMultiLanguage).toBe(false); + expect(state.canUseSchema).toBe(false); + expect(state.canBringOwnKey).toBe(false); + // hasLicenseKey is independent of tier (a stored key with no valid token). + expect(state.hasLicenseKey).toBe(true); }); - it("never grants Pro for a non-pro tier claim", async () => { + it("never grants Pro for a non-'pro' tier claim", async () => { getValidEntitlementMock.mockResolvedValue({ ...proClaims, tier: "trial" }); await useEntitlementStore.getState().hydrateEntitlement(); - expect(useEntitlementStore.getState().isPro).toBe(false); + const state = useEntitlementStore.getState(); + expect(state.isPro).toBe(false); + expect(state.canUseAdvancedOptions).toBe(false); + expect(state.canBringOwnKey).toBe(false); }); -}); -describe("canUseAINow / isMeteredAiCall", () => { - it("follows the apiKey × isPro × quota truth table", () => { - // free, no key - expect(canUseAINow()).toBe(false); - expect(isMeteredAiCall()).toBe(false); + it("populates free quota from a snapshot on the free tier", async () => { + getValidEntitlementMock.mockResolvedValue(null); + getFreeAiQuotaMock.mockResolvedValue({ period: "2026-07", remaining: 3, limit: 5 }); + + await useEntitlementStore.getState().hydrateEntitlement(); + + const state = useEntitlementStore.getState(); + expect(state.freeAiRemaining).toBe(3); + expect(state.freeAiLimit).toBe(5); + }); + + it("leaves free quota null when no snapshot exists this period", async () => { + getValidEntitlementMock.mockResolvedValue(null); + getFreeAiQuotaMock.mockResolvedValue(null); + + await useEntitlementStore.getState().hydrateEntitlement(); + + const state = useEntitlementStore.getState(); + expect(state.freeAiRemaining).toBeNull(); + expect(state.freeAiLimit).toBeNull(); + }); +}); - // own key always wins, never metered +describe("aiStatusNow (computeAiStatus truth table)", () => { + it("Pro + own key → byok, unlimited", () => { useStore.setState({ apiKey: "sk-own" }); - expect(canUseAINow()).toBe(true); - expect(isMeteredAiCall()).toBe(false); + useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 0, quotaLimit: 100 }); + + expect(aiStatusNow()).toEqual({ mode: "byok", remaining: null, limit: null }); + }); + + it("Pro + no key + quota remaining → pro (metered)", () => { + useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5, quotaLimit: 100 }); + + expect(aiStatusNow()).toEqual({ mode: "pro", remaining: 5, limit: 100 }); + }); + + it("Pro + no key + quota exhausted → locked", () => { + useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 0, quotaLimit: 100 }); + + expect(aiStatusNow()).toEqual({ mode: "locked", remaining: 0, limit: 100 }); + }); + + it("free + own key → free (a stray key never grants byok)", () => { + useStore.setState({ apiKey: "sk-stray" }); + useEntitlementStore.setState({ isPro: false, freeAiRemaining: 3, freeAiLimit: 10 }); + + expect(aiStatusNow()).toEqual({ mode: "free", remaining: 3, limit: 10 }); + }); + + it("free + unknown remaining (null) → free (unknown is available)", () => { + useEntitlementStore.setState({ isPro: false, freeAiRemaining: null, freeAiLimit: null }); + + expect(aiStatusNow()).toEqual({ mode: "free", remaining: null, limit: null }); + }); + + it("free + remaining > 0 → free", () => { + useEntitlementStore.setState({ isPro: false, freeAiRemaining: 2, freeAiLimit: 5 }); - // pro with quota, no key: allowed and metered - useStore.setState({ apiKey: "" }); - useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5 }); - expect(canUseAINow()).toBe(true); - expect(isMeteredAiCall()).toBe(true); + expect(aiStatusNow()).toEqual({ mode: "free", remaining: 2, limit: 5 }); + }); + + it("free + remaining 0 → locked", () => { + useEntitlementStore.setState({ isPro: false, freeAiRemaining: 0, freeAiLimit: 5 }); - // pro with exhausted quota, no key: blocked - useEntitlementStore.setState({ aiQuotaRemaining: 0 }); - expect(canUseAINow()).toBe(false); + expect(aiStatusNow()).toEqual({ mode: "locked", remaining: 0, limit: 5 }); + }); +}); - // pro with exhausted quota but own key: allowed, not metered +describe("useAiStatus / useCanUseAI (reactive hooks)", () => { + it("useAiStatus mirrors the computed status", () => { useStore.setState({ apiKey: "sk-own" }); - expect(canUseAINow()).toBe(true); - expect(isMeteredAiCall()).toBe(false); + useEntitlementStore.setState({ isPro: true }); + + const { result } = renderHook(() => useAiStatus()); + expect(result.current).toEqual({ mode: "byok", remaining: null, limit: null }); + }); + + it("useCanUseAI is true while AI is available", () => { + useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5, quotaLimit: 100 }); + + const { result } = renderHook(() => useCanUseAI()); + expect(result.current).toBe(true); + }); + + it("useCanUseAI is false when the mode is locked", () => { + useEntitlementStore.setState({ isPro: false, freeAiRemaining: 0, freeAiLimit: 5 }); + + const { result } = renderHook(() => useCanUseAI()); + expect(result.current).toBe(false); + }); +}); + +describe("applyProxyQuota", () => { + it("records and reflects a Pro quota snapshot", async () => { + const quota: QuotaSnapshot = { period: "2026-07", remaining: 7, limit: 100 }; + + await useEntitlementStore.getState().applyProxyQuota(quota, "pro"); + + expect(recordProAiQuotaMock).toHaveBeenCalledWith(quota); + expect(recordFreeAiQuotaMock).not.toHaveBeenCalled(); + const state = useEntitlementStore.getState(); + expect(state.aiQuotaRemaining).toBe(7); + expect(state.quotaLimit).toBe(100); + // The free fields are untouched by a Pro-metered call. + expect(state.freeAiRemaining).toBeNull(); + expect(state.freeAiLimit).toBeNull(); + }); + + it("records and reflects a free quota snapshot", async () => { + const quota: QuotaSnapshot = { period: "2026-07", remaining: 2, limit: 5 }; + + await useEntitlementStore.getState().applyProxyQuota(quota, "free"); + + expect(recordFreeAiQuotaMock).toHaveBeenCalledWith(quota); + expect(recordProAiQuotaMock).not.toHaveBeenCalled(); + const state = useEntitlementStore.getState(); + expect(state.freeAiRemaining).toBe(2); + expect(state.freeAiLimit).toBe(5); + // The Pro remaining is untouched by a free-metered call. + expect(state.aiQuotaRemaining).toBe(0); }); }); @@ -143,7 +291,7 @@ describe("activateLicense", () => { it("activates, rehydrates, and reports success", async () => { activateMock.mockResolvedValue(proClaims); getValidEntitlementMock.mockResolvedValue(proClaims); - getAiQuotaRemainingMock.mockResolvedValue(100); + getProAiRemainingMock.mockResolvedValue(100); hasStoredLicenseKeyMock.mockResolvedValue(true); const ok = await useEntitlementStore.getState().activateLicense(" optia_live_key "); @@ -164,6 +312,7 @@ describe("activateLicense", () => { expect(ok).toBe(false); const state = useEntitlementStore.getState(); expect(state.isPro).toBe(false); + expect(state.activating).toBe(false); expect(state.activationError).toBe("This license key is not valid."); }); @@ -176,57 +325,85 @@ describe("activateLicense", () => { "Too many attempts. Try again in 30s.", ); }); + + it("omits the retry delay when the server gives no Retry-After", async () => { + activateMock.mockRejectedValue(new LicenseError("rate_limited", "slow down")); + + await useEntitlementStore.getState().activateLicense("key"); + + expect(useEntitlementStore.getState().activationError).toBe("Too many attempts."); + }); + + it("maps an unexpected (non-LicenseError) failure to a generic message", async () => { + activateMock.mockRejectedValue(new Error("boom")); + + const ok = await useEntitlementStore.getState().activateLicense("key"); + + expect(ok).toBe(false); + expect(useEntitlementStore.getState().activationError).toBe( + "Activation failed. Please try again.", + ); + }); }); describe("deactivateLicense", () => { - it("clears flags back to free", async () => { + it("clears all flags back to the free tier", async () => { useEntitlementStore.setState({ isPro: true, tier: "pro", aiQuotaRemaining: 10, + quotaLimit: 100, + expiresAt: Date.now(), canUseAdvancedOptions: true, + canUseMultiLanguage: true, + canUseSchema: true, + canBringOwnKey: true, hasLicenseKey: true, + activationError: "prior error", }); deactivateMock.mockResolvedValue(); + getValidEntitlementMock.mockResolvedValue(null); + hasStoredLicenseKeyMock.mockResolvedValue(false); await useEntitlementStore.getState().deactivateLicense(); - const state = useEntitlementStore.getState(); expect(deactivateMock).toHaveBeenCalled(); + const state = useEntitlementStore.getState(); expect(state.isPro).toBe(false); + expect(state.tier).toBe("free"); expect(state.hasLicenseKey).toBe(false); expect(state.aiQuotaRemaining).toBe(0); + expect(state.quotaLimit).toBe(0); + expect(state.expiresAt).toBeNull(); expect(state.canUseAdvancedOptions).toBe(false); - }); -}); - -describe("consumeAiQuota", () => { - it("updates the remaining quota flag", async () => { - useEntitlementStore.setState({ isPro: true, aiQuotaRemaining: 5 }); - consumeAiQuotaMock.mockResolvedValue(4); - - await useEntitlementStore.getState().consumeAiQuota(); - - expect(useEntitlementStore.getState().aiQuotaRemaining).toBe(4); + expect(state.canUseMultiLanguage).toBe(false); + expect(state.canUseSchema).toBe(false); + expect(state.canBringOwnKey).toBe(false); + expect(state.activationError).toBeNull(); }); }); describe("initEntitlementSync", () => { - it("rehydrates when entitlement storage keys change in another context", () => { + it("re-hydrates on watched local key changes, and is idempotent", () => { initEntitlementSync(); - initEntitlementSync(); // idempotent + initEntitlementSync(); // idempotent: only one listener is ever registered const addListener = vi.mocked(chrome.storage.onChanged.addListener); expect(addListener).toHaveBeenCalledTimes(1); const listener = addListener.mock.calls[0][0]; - getValidEntitlementMock.mockResolvedValue(proClaims); + getValidEntitlementMock.mockResolvedValue(null); - listener({ [ENTITLEMENT_TOKEN_KEY]: { newValue: "t" } }, "local"); - expect(getValidEntitlementMock).toHaveBeenCalled(); + // Each of the four watched keys re-triggers hydration. + for (const key of [ENTITLEMENT_TOKEN_KEY, LICENSE_KEY_KEY, PRO_AI_QUOTA_KEY, FREE_AI_QUOTA_KEY]) { + getValidEntitlementMock.mockClear(); + listener({ [key]: { newValue: "x" } }, "local"); + expect(getValidEntitlementMock, `${key} should re-hydrate`).toHaveBeenCalled(); + } + // Unrelated keys and non-local areas are ignored. getValidEntitlementMock.mockClear(); listener({ unrelated_key: { newValue: 1 } }, "local"); - listener({ [ENTITLEMENT_TOKEN_KEY]: { newValue: "t" } }, "session"); + listener({ [ENTITLEMENT_TOKEN_KEY]: { newValue: "x" } }, "session"); expect(getValidEntitlementMock).not.toHaveBeenCalled(); }); }); diff --git a/app/src/lib/entitlement-store.ts b/app/src/lib/entitlement-store.ts index acd4b4c..cc5fa62 100644 --- a/app/src/lib/entitlement-store.ts +++ b/app/src/lib/entitlement-store.ts @@ -1,38 +1,52 @@ import { create } from "zustand"; import { activate, - consumeAiQuota, deactivate, - getAiQuotaRemaining, + getFreeAiQuota, + getProAiRemaining, getValidEntitlement, hasStoredLicenseKey, - AI_USAGE_KEY, + recordFreeAiQuota, + recordProAiQuota, ENTITLEMENT_TOKEN_KEY, + FREE_AI_QUOTA_KEY, LICENSE_KEY_KEY, + PRO_AI_QUOTA_KEY, + type QuotaSnapshot, } from "@/lib/entitlement"; import { LicenseError } from "@/lib/backend"; import { useStore } from "@/lib/store"; // Feature-flag store over the entitlement layer. The rest of the app reads -// these flags (and useCanUseAI) — never raw license/token data. +// these flags (useCanUseAI / useAiStatus / the canUse* booleans) — never raw +// license/token data. interface EntitlementStore { entitlementLoaded: boolean; isPro: boolean; tier: "free" | "pro"; expiresAt: number | null; // ms epoch, for status display - quotaLimit: number; - aiQuotaRemaining: number; + quotaLimit: number; // Pro monthly limit from the token (0 for free) + aiQuotaRemaining: number; // Pro remaining, server-authoritative (0 for free) + aiQuotaPeriod: string | null; // period the Pro remaining belongs to (YYYY-MM) + freeAiRemaining: number | null; // free monthly remaining; null = unknown (no call yet this period) + freeAiLimit: number | null; + freeAiPeriod: string | null; // period the free remaining belongs to (YYYY-MM) canUseAdvancedOptions: boolean; + canUseMultiLanguage: boolean; + canUseSchema: boolean; + canBringOwnKey: boolean; hasLicenseKey: boolean; activating: boolean; activationError: string | null; hydrateEntitlement: () => Promise; activateLicense: (licenseKey: string) => Promise; deactivateLicense: () => Promise; - consumeAiQuota: () => Promise; + applyProxyQuota: (quota: QuotaSnapshot, subject: "pro" | "free") => Promise; } +// Reset shape for the free/expired tier. Free-quota fields are NOT reset here — +// they are install-scoped and hydrated separately. const freeFlags = { isPro: false, tier: "free" as const, @@ -40,6 +54,9 @@ const freeFlags = { quotaLimit: 0, aiQuotaRemaining: 0, canUseAdvancedOptions: false, + canUseMultiLanguage: false, + canUseSchema: false, + canBringOwnKey: false, }; function activationErrorMessage(error: unknown): string { @@ -56,6 +73,10 @@ function activationErrorMessage(error: unknown): string { export const useEntitlementStore = create((set) => ({ entitlementLoaded: false, ...freeFlags, + aiQuotaPeriod: null, + freeAiRemaining: null, + freeAiLimit: null, + freeAiPeriod: null, hasLicenseKey: false, activating: false, activationError: null, @@ -63,8 +84,21 @@ export const useEntitlementStore = create((set) => ({ hydrateEntitlement: async () => { const claims = await getValidEntitlement(); const hasLicenseKey = await hasStoredLicenseKey(); + const freeQuota = await getFreeAiQuota(); + const freeAiRemaining = freeQuota ? freeQuota.remaining : null; + const freeAiLimit = freeQuota ? freeQuota.limit : null; + const freeAiPeriod = freeQuota ? freeQuota.period : null; + if (!claims || claims.tier !== "pro") { - set({ entitlementLoaded: true, hasLicenseKey, ...freeFlags }); + set({ + entitlementLoaded: true, + hasLicenseKey, + freeAiRemaining, + freeAiLimit, + freeAiPeriod, + aiQuotaPeriod: null, + ...freeFlags, + }); return; } set({ @@ -74,8 +108,15 @@ export const useEntitlementStore = create((set) => ({ tier: "pro", expiresAt: claims.exp * 1000, quotaLimit: claims.quotaLimit, - aiQuotaRemaining: await getAiQuotaRemaining(claims), + aiQuotaRemaining: await getProAiRemaining(claims), + aiQuotaPeriod: claims.period, + freeAiRemaining, + freeAiLimit, + freeAiPeriod, canUseAdvancedOptions: true, + canUseMultiLanguage: true, + canUseSchema: true, + canBringOwnKey: true, }); }, @@ -94,51 +135,116 @@ export const useEntitlementStore = create((set) => ({ deactivateLicense: async () => { await deactivate(); - set({ hasLicenseKey: false, activationError: null, ...freeFlags }); + await useEntitlementStore.getState().hydrateEntitlement(); + set({ activationError: null }); }, - consumeAiQuota: async () => { - const remaining = await consumeAiQuota(); - set({ aiQuotaRemaining: remaining }); + applyProxyQuota: async (quota, subject) => { + // Concurrent generations (generate-all) resolve out of order; keep the most + // conservative (lowest) remaining for the SAME period so the cache never + // overstates the allowance. A different period is a rollover — take it as-is. + const state = useEntitlementStore.getState(); + if (subject === "pro") { + const known = state.quotaLimit > 0 ? state.aiQuotaRemaining : null; + const sameMonth = quota.period === state.aiQuotaPeriod; + const remaining = sameMonth && known !== null ? Math.min(known, quota.remaining) : quota.remaining; + const reconciled = { ...quota, remaining }; + await recordProAiQuota(reconciled); + set({ aiQuotaRemaining: remaining, quotaLimit: reconciled.limit, aiQuotaPeriod: quota.period }); + } else { + const sameMonth = quota.period === state.freeAiPeriod; + const remaining = + sameMonth && state.freeAiRemaining !== null + ? Math.min(state.freeAiRemaining, quota.remaining) + : quota.remaining; + const reconciled = { ...quota, remaining }; + await recordFreeAiQuota(reconciled); + set({ freeAiRemaining: remaining, freeAiLimit: reconciled.limit, freeAiPeriod: quota.period }); + } }, })); -/** - * The single place AI availability is decided: users with their own OpenAI key - * are never gated (and never metered); otherwise Pro with quota remaining. - */ -export function useCanUseAI(): boolean { - const apiKey = useStore((state) => state.apiKey); - const isPro = useEntitlementStore((state) => state.isPro); - const aiQuotaRemaining = useEntitlementStore((state) => state.aiQuotaRemaining); - return Boolean(apiKey) || (isPro && aiQuotaRemaining > 0); +// ── AI availability: the single place the three access paths are decided ── +// +// BYO key (Pro + own Anthropic key) → unlimited direct calls (never metered). +// Pro without key → hosted proxy, higher monthly cap (server-metered). +// Free → hosted proxy, capped monthly allowance (server-metered). +// A stored key only unlocks the direct path when the user is Pro — BYO key is a +// Pro feature, so free users can never self-serve AI with a key. + +export type AiMode = "byok" | "pro" | "free" | "locked"; + +export interface AiStatus { + mode: AiMode; + remaining: number | null; // null = unlimited (byok) or unknown (free, pre-call) + limit: number | null; +} + +interface AiInputs { + apiKey: string; + isPro: boolean; + aiQuotaRemaining: number; + quotaLimit: number; + freeAiRemaining: number | null; + freeAiLimit: number | null; } -/** Non-hook variant for imperative call sites (e.g. inside async handlers). */ -export function canUseAINow(): boolean { - const { isPro, aiQuotaRemaining } = useEntitlementStore.getState(); - return Boolean(useStore.getState().apiKey) || (isPro && aiQuotaRemaining > 0); +function computeAiStatus(i: AiInputs): AiStatus { + if (i.isPro && i.apiKey) return { mode: "byok", remaining: null, limit: null }; + if (i.isPro) { + return i.aiQuotaRemaining > 0 + ? { mode: "pro", remaining: i.aiQuotaRemaining, limit: i.quotaLimit } + : { mode: "locked", remaining: 0, limit: i.quotaLimit }; + } + // Free tier: unknown remaining (null) is treated as available. + if (i.freeAiRemaining === null || i.freeAiRemaining > 0) { + return { mode: "free", remaining: i.freeAiRemaining, limit: i.freeAiLimit }; + } + return { mode: "locked", remaining: 0, limit: i.freeAiLimit }; } -/** True when the current AI call runs on Pro quota (no personal key) and must be metered. */ -export function isMeteredAiCall(): boolean { - return !useStore.getState().apiKey && useEntitlementStore.getState().isPro; +export function useAiStatus(): AiStatus { + const apiKey = useStore((s) => s.apiKey); + const isPro = useEntitlementStore((s) => s.isPro); + const aiQuotaRemaining = useEntitlementStore((s) => s.aiQuotaRemaining); + const quotaLimit = useEntitlementStore((s) => s.quotaLimit); + const freeAiRemaining = useEntitlementStore((s) => s.freeAiRemaining); + const freeAiLimit = useEntitlementStore((s) => s.freeAiLimit); + return computeAiStatus({ apiKey, isPro, aiQuotaRemaining, quotaLimit, freeAiRemaining, freeAiLimit }); +} + +export function useCanUseAI(): boolean { + return useAiStatus().mode !== "locked"; +} + +/** Non-reactive Ai status for imperative call sites (async handlers). */ +export function aiStatusNow(): AiStatus { + const e = useEntitlementStore.getState(); + return computeAiStatus({ + apiKey: useStore.getState().apiKey, + isPro: e.isPro, + aiQuotaRemaining: e.aiQuotaRemaining, + quotaLimit: e.quotaLimit, + freeAiRemaining: e.freeAiRemaining, + freeAiLimit: e.freeAiLimit, + }); } let syncInitialized = false; /** - * Keeps the store in sync with entitlement changes made in other extension - * contexts (options page, background refresh) via chrome.storage.onChanged. - * Idempotent; a no-op outside a chrome-extension context (browser dev preview). + * Keeps the store in sync with entitlement/quota changes made in other + * extension contexts (options page, background refresh, a sidepanel AI call) + * via chrome.storage.onChanged. Idempotent; a no-op in the browser dev preview. */ export function initEntitlementSync(): void { if (syncInitialized) return; if (typeof chrome === "undefined" || !chrome.storage?.onChanged) return; syncInitialized = true; + const watched = [ENTITLEMENT_TOKEN_KEY, LICENSE_KEY_KEY, PRO_AI_QUOTA_KEY, FREE_AI_QUOTA_KEY]; chrome.storage.onChanged.addListener((changes, area) => { if (area !== "local") return; - if (changes[ENTITLEMENT_TOKEN_KEY] || changes[LICENSE_KEY_KEY] || changes[AI_USAGE_KEY]) { + if (watched.some((key) => changes[key])) { void useEntitlementStore.getState().hydrateEntitlement(); } }); diff --git a/app/src/lib/entitlement.test.ts b/app/src/lib/entitlement.test.ts index 4539cb1..e883152 100644 --- a/app/src/lib/entitlement.test.ts +++ b/app/src/lib/entitlement.test.ts @@ -4,19 +4,26 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; import { activate, - consumeAiQuota, + currentAiPeriod, deactivate, - getAiQuotaRemaining, + getFreeAiQuota, getInstallId, + getProAiRemaining, + getRawEntitlementToken, getRefreshFailureCount, getValidEntitlement, + recordFreeAiQuota, + recordProAiQuota, refreshNow, verifyEntitlementToken, - AI_USAGE_KEY, ENTITLEMENT_TOKEN_KEY, + FREE_AI_QUOTA_KEY, INSTALL_ID_KEY, LICENSE_KEY_KEY, + PRO_AI_QUOTA_KEY, REFRESH_FAILURES_KEY, + type EntitlementClaims, + type QuotaSnapshot, } from "@/lib/entitlement"; import { getStorageItem, setStorageItem } from "@/lib/storage"; import { @@ -137,6 +144,18 @@ describe("getValidEntitlement", () => { }); }); +describe("getRawEntitlementToken", () => { + it("returns null when no token is cached", async () => { + expect(await getRawEntitlementToken()).toBeNull(); + }); + + it("returns the raw stored token verbatim", async () => { + const token = await signTestToken(keys); + await setStorageItem(ENTITLEMENT_TOKEN_KEY, token); + expect(await getRawEntitlementToken()).toBe(token); + }); +}); + describe("getInstallId", () => { it("generates once and then returns the same id", async () => { const first = await getInstallId(); @@ -210,13 +229,25 @@ describe("refreshNow", () => { expect(await refreshNow(verifyOpts)).toBeNull(); }); - it("clears all license state when the license is revoked or unknown", async () => { + it("clears license state on a revoked/unknown license but keeps the free allowance", async () => { await setStorageItem(ENTITLEMENT_TOKEN_KEY, await signTestToken(keys)); + await setStorageItem(PRO_AI_QUOTA_KEY, { + period: TEST_PERIOD, + remaining: 5, + limit: 100, + } satisfies QuotaSnapshot); + await setStorageItem(FREE_AI_QUOTA_KEY, { + period: TEST_PERIOD, + remaining: 2, + limit: 5, + } satisfies QuotaSnapshot); refreshTokenMock.mockRejectedValue(new LicenseError("invalid", "revoked")); expect(await refreshNow(verifyOpts)).toBeNull(); expect(await getStorageItem(ENTITLEMENT_TOKEN_KEY)).toBeNull(); expect(await getStorageItem(LICENSE_KEY_KEY)).toBeNull(); + expect(await getStorageItem(PRO_AI_QUOTA_KEY)).toBeNull(); + expect(await getStorageItem(FREE_AI_QUOTA_KEY)).not.toBeNull(); }); it("does not persist a tampered refresh response", async () => { @@ -237,7 +268,16 @@ describe("deactivate", () => { await setStorageItem(LICENSE_KEY_KEY, "optia_live_abc"); await setStorageItem(INSTALL_ID_KEY, "install-1"); await setStorageItem(ENTITLEMENT_TOKEN_KEY, await signTestToken(keys)); - await setStorageItem(AI_USAGE_KEY, { period: TEST_PERIOD, used: 5 }); + await setStorageItem(PRO_AI_QUOTA_KEY, { + period: TEST_PERIOD, + remaining: 5, + limit: 100, + } satisfies QuotaSnapshot); + await setStorageItem(FREE_AI_QUOTA_KEY, { + period: TEST_PERIOD, + remaining: 2, + limit: 5, + } satisfies QuotaSnapshot); deactivateLicenseMock.mockRejectedValue(new LicenseError("network", "offline")); await deactivate(); @@ -245,39 +285,78 @@ describe("deactivate", () => { expect(deactivateLicenseMock).toHaveBeenCalledWith("optia_live_abc", "install-1"); expect(await getStorageItem(ENTITLEMENT_TOKEN_KEY)).toBeNull(); expect(await getStorageItem(LICENSE_KEY_KEY)).toBeNull(); - expect(await getStorageItem(AI_USAGE_KEY)).toBeNull(); + // Pro quota is license-scoped and cleared; the free allowance is + // install-scoped and survives deactivation. + expect(await getStorageItem(PRO_AI_QUOTA_KEY)).toBeNull(); + expect(await getStorageItem(FREE_AI_QUOTA_KEY)).not.toBeNull(); }); }); -describe("AI quota", () => { - beforeEach(async () => { - await setStorageItem(ENTITLEMENT_TOKEN_KEY, await signTestToken(keys, { quotaLimit: 3 })); +describe("currentAiPeriod", () => { + it("formats a date as YYYY-MM", () => { + expect(currentAiPeriod(new Date("2026-07-15T12:00:00Z"))).toBe("2026-07"); + expect(currentAiPeriod(new Date("2026-01-01T00:00:00Z"))).toBe("2026-01"); }); - it("computes remaining from the stored usage for the same period", async () => { - const claims = await getValidEntitlement(verifyOpts); - await setStorageItem(AI_USAGE_KEY, { period: TEST_PERIOD, used: 2 }); - expect(await getAiQuotaRemaining(claims!)).toBe(1); + it("defaults to the current month in YYYY-MM form", () => { + expect(currentAiPeriod()).toMatch(/^\d{4}-\d{2}$/); }); +}); - it("consumes down to zero and never goes negative", async () => { - expect(await consumeAiQuota(verifyOpts)).toBe(2); - expect(await consumeAiQuota(verifyOpts)).toBe(1); - expect(await consumeAiQuota(verifyOpts)).toBe(0); - expect(await consumeAiQuota(verifyOpts)).toBe(0); +describe("recordProAiQuota / getProAiRemaining", () => { + const proClaims: EntitlementClaims = { + sub: "lic_test_123", + subjectType: "license", + tier: "pro", + quotaLimit: 100, + period: TEST_PERIOD, + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }; + + it("falls back to the token's quotaLimit when nothing has been recorded", async () => { + expect(await getProAiRemaining(proClaims)).toBe(100); }); - it("resets the counter when the token period rolls over", async () => { - await setStorageItem(AI_USAGE_KEY, { period: "2026-06", used: 3 }); - const claims = await getValidEntitlement(verifyOpts); - expect(await getAiQuotaRemaining(claims!)).toBe(3); - expect(await consumeAiQuota(verifyOpts)).toBe(2); - expect(await getStorageItem(AI_USAGE_KEY)).toEqual({ period: TEST_PERIOD, used: 1 }); + it("returns the server-recorded remaining for a matching period", async () => { + await recordProAiQuota({ period: TEST_PERIOD, remaining: 7, limit: 100 }); + expect(await getStorageItem(PRO_AI_QUOTA_KEY)).toEqual({ + period: TEST_PERIOD, + remaining: 7, + limit: 100, + }); + expect(await getProAiRemaining(proClaims)).toBe(7); + }); + + it("ignores a stale-period snapshot and falls back to the token quotaLimit", async () => { + await recordProAiQuota({ period: "2026-06", remaining: 7, limit: 100 }); + expect(await getProAiRemaining(proClaims)).toBe(100); + }); + + it("never reports a negative remaining", async () => { + await recordProAiQuota({ period: TEST_PERIOD, remaining: -5, limit: 100 }); + expect(await getProAiRemaining(proClaims)).toBe(0); + }); +}); + +describe("recordFreeAiQuota / getFreeAiQuota", () => { + it("returns null when nothing has been recorded", async () => { + expect(await getFreeAiQuota()).toBeNull(); + }); + + it("returns the snapshot when its period is the current month", async () => { + const now = new Date("2026-07-15T00:00:00Z"); + const snapshot: QuotaSnapshot = { + period: currentAiPeriod(now), + remaining: 3, + limit: 5, + }; + await recordFreeAiQuota(snapshot); + expect(await getFreeAiQuota(now)).toEqual(snapshot); }); - it("consumes nothing without a valid entitlement", async () => { - await setStorageItem(ENTITLEMENT_TOKEN_KEY, "garbage"); - expect(await consumeAiQuota(verifyOpts)).toBe(0); - expect(await getStorageItem(AI_USAGE_KEY)).toBeNull(); + it("returns null for a stale-month snapshot", async () => { + await recordFreeAiQuota({ period: "2026-07", remaining: 3, limit: 5 }); + expect(await getFreeAiQuota(new Date("2026-08-15T00:00:00Z"))).toBeNull(); }); }); diff --git a/app/src/lib/entitlement.ts b/app/src/lib/entitlement.ts index ad886d3..5e23634 100644 --- a/app/src/lib/entitlement.ts +++ b/app/src/lib/entitlement.ts @@ -15,7 +15,11 @@ import { getStorageItem, removeStorageItem, setStorageItem } from "@/lib/storage export const ENTITLEMENT_TOKEN_KEY = "entitlement_token"; export const LICENSE_KEY_KEY = "license_key"; export const INSTALL_ID_KEY = "install_id"; -export const AI_USAGE_KEY = "ai_usage"; +// Server-authoritative AI quota snapshots, refreshed from every /ai/generate +// response. Pro quota is license-scoped; free quota is install-scoped and +// survives license changes (it is the anonymous free allowance). +export const PRO_AI_QUOTA_KEY = "pro_ai_quota"; +export const FREE_AI_QUOTA_KEY = "free_ai_quota"; export const REFRESH_FAILURES_KEY = "entitlement_refresh_failures"; const ISSUER = "optia-backend"; @@ -37,9 +41,16 @@ export interface VerifyOptions { now?: Date; } -interface AiUsageRecord { - period: string; - used: number; +/** A server-reported AI quota window (monthly). */ +export interface QuotaSnapshot { + period: string; // "YYYY-MM" + remaining: number; + limit: number; +} + +/** Current monthly accounting period, matching the backend's `YYYY-MM`. */ +export function currentAiPeriod(now: Date = new Date()): string { + return now.toISOString().slice(0, 7); } /** @@ -105,6 +116,15 @@ export async function hasStoredLicenseKey(): Promise { return (await getStorageItem(LICENSE_KEY_KEY)) !== null; } +/** + * The raw stored entitlement JWS, for authenticating Pro-tier proxy calls + * (sent as X-Optia-Entitlement). Returns null when no token is cached. The + * backend re-verifies it; the client never trusts it beyond presenting it. + */ +export async function getRawEntitlementToken(): Promise { + return getStorageItem(ENTITLEMENT_TOKEN_KEY); +} + /** * The cached entitlement, if it still verifies and has not expired. * Missing, expired, or tampered ⇒ null (free tier) — never Pro. @@ -124,7 +144,9 @@ export async function getRefreshFailureCount(): Promise { async function clearLocalLicenseState(): Promise { await removeStorageItem(ENTITLEMENT_TOKEN_KEY); await removeStorageItem(LICENSE_KEY_KEY); - await removeStorageItem(AI_USAGE_KEY); + // Pro quota is license-scoped; the free allowance (FREE_AI_QUOTA_KEY) is + // install-scoped and intentionally survives deactivation. + await removeStorageItem(PRO_AI_QUOTA_KEY); await removeStorageItem(REFRESH_FAILURES_KEY); } @@ -199,22 +221,33 @@ export async function refreshNow( return claims; } -/** Remaining AI quota for the given claims' period (own-key calls are not metered). */ -export async function getAiQuotaRemaining(claims: EntitlementClaims): Promise { - const usage = await getStorageItem(AI_USAGE_KEY); - const used = usage && usage.period === claims.period ? usage.used : 0; - return Math.max(0, claims.quotaLimit - used); +/** Persist the server-reported Pro quota after an entitlement-metered proxy call. */ +export async function recordProAiQuota(quota: QuotaSnapshot): Promise { + await setStorageItem(PRO_AI_QUOTA_KEY, quota); +} + +/** Persist the server-reported free quota after an install-metered proxy call. */ +export async function recordFreeAiQuota(quota: QuotaSnapshot): Promise { + await setStorageItem(FREE_AI_QUOTA_KEY, quota); +} + +/** + * Remaining Pro quota for the entitlement's period. The server is authoritative + * (last /ai/generate response); before any call this period, the token's + * quotaLimit is the best estimate. + */ +export async function getProAiRemaining(claims: EntitlementClaims): Promise { + const quota = await getStorageItem(PRO_AI_QUOTA_KEY); + if (quota && quota.period === claims.period) return Math.max(0, quota.remaining); + return claims.quotaLimit; } /** - * Records one metered AI call against the current period and returns the - * remaining quota. No valid entitlement ⇒ 0, nothing recorded. + * The free-tier quota for the current month, or null when unknown (no proxy + * call yet this period). Unknown is treated as "available" by callers. */ -export async function consumeAiQuota(options: VerifyOptions = {}): Promise { - const claims = await getValidEntitlement(options); - if (!claims) return 0; - const usage = await getStorageItem(AI_USAGE_KEY); - const used = (usage && usage.period === claims.period ? usage.used : 0) + 1; - await setStorageItem(AI_USAGE_KEY, { period: claims.period, used }); - return Math.max(0, claims.quotaLimit - used); +export async function getFreeAiQuota(now: Date = new Date()): Promise { + const quota = await getStorageItem(FREE_AI_QUOTA_KEY); + if (quota && quota.period === currentAiPeriod(now)) return quota; + return null; } diff --git a/app/src/lib/openai.test.ts b/app/src/lib/openai.test.ts deleted file mode 100644 index 04e7b0e..0000000 --- a/app/src/lib/openai.test.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; - -// Create a mock for the chat.completions.create method -const mockCreate = vi.fn(); - -// Mock the OpenAI module before imports -vi.mock("openai", () => { - return { - default: vi.fn().mockImplementation(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), - }; -}); - -// Import after mocking -import { - generateRecommendation, - generateH2Suggestion, - generateAllH2Suggestions, - generateAltText, - generateTitle, - generateMetaDescription, -} from "./openai"; - -function mockSuccessResponse(content: string) { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content } }], - }); -} - -function mockErrorResponse(status: number, message: string) { - const error = new Error(message) as Error & { status: number }; - error.status = status; - mockCreate.mockRejectedValueOnce(error); -} - -describe("openai", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockCreate.mockReset(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("generateRecommendation", () => { - it("generates a title recommendation", async () => { - mockSuccessResponse("Best SEO Tips for 2024 | Complete Guide"); - - const result = await generateRecommendation( - "test-api-key", - "title-keyword", - "SEO tips", - "My Blog About SEO", - ); - - expect(result).toBe("Best SEO Tips for 2024 | Complete Guide"); - expect(mockCreate).toHaveBeenCalledOnce(); - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "gpt-3.5-turbo", - max_tokens: 500, - temperature: 0.7, - }), - ); - }); - - it("generates a meta description recommendation", async () => { - mockSuccessResponse("Learn the best SEO tips to improve your rankings. Get actionable advice now!"); - - const result = await generateRecommendation( - "test-api-key", - "meta-description-keyword", - "SEO tips", - "Current description here", - ); - - expect(result).toBe("Learn the best SEO tips to improve your rankings. Get actionable advice now!"); - }); - - it("generates a URL slug recommendation", async () => { - mockSuccessResponse("seo-tips-guide"); - - const result = await generateRecommendation( - "test-api-key", - "keyword-url", - "SEO tips", - "https://example.com/old-url", - ); - - expect(result).toBe("seo-tips-guide"); - }); - - it("generates an H1 recommendation", async () => { - mockSuccessResponse("The Ultimate Guide to SEO Tips"); - - const result = await generateRecommendation( - "test-api-key", - "h1-keyword", - "SEO tips", - "Welcome to my site", - ); - - expect(result).toBe("The Ultimate Guide to SEO Tips"); - }); - - it("generates an intro paragraph recommendation", async () => { - mockSuccessResponse("Discover essential SEO tips that will transform your website's visibility."); - - const result = await generateRecommendation( - "test-api-key", - "keyword-intro", - "SEO tips", - "This is my introduction paragraph.", - ); - - expect(result).toBe("Discover essential SEO tips that will transform your website's visibility."); - }); - - it("generates advisory content for non-copyable checks", async () => { - mockSuccessResponse("Increase your internal linking to improve SEO crawlability."); - - const result = await generateRecommendation( - "test-api-key", - "internal-links", - "SEO tips", - "Currently 2 internal links", - ); - - expect(result).toBe("Increase your internal linking to improve SEO crawlability."); - }); - - it("handles keyword-density check", async () => { - mockSuccessResponse("Optimize keyword density for better SEO."); - - const result = await generateRecommendation( - "test-api-key", - "keyword-density", - "SEO", - "Low density", - ); - - expect(result).toBe("Optimize keyword density for better SEO."); - }); - - it("handles word-count check", async () => { - mockSuccessResponse("Add more content."); - - const result = await generateRecommendation( - "test-api-key", - "word-count", - "SEO", - "300 words", - ); - - expect(result).toBe("Add more content."); - }); - - it("handles unknown check IDs with generic advice", async () => { - mockSuccessResponse("Generic SEO advice for this issue."); - - const result = await generateRecommendation( - "test-api-key", - "unknown-check-id", - "SEO tips", - "Some context", - ); - - expect(result).toBe("Generic SEO advice for this issue."); - }); - - it("strips wrapping double quotes from response", async () => { - mockSuccessResponse('"Best SEO Tips for 2024"'); - - const result = await generateRecommendation( - "test-api-key", - "title-keyword", - "SEO tips", - "Current title", - ); - - expect(result).toBe("Best SEO Tips for 2024"); - }); - - it("strips wrapping single quotes from response", async () => { - mockSuccessResponse("'Best SEO Tips for 2024'"); - - const result = await generateRecommendation( - "test-api-key", - "title-keyword", - "SEO tips", - "Current title", - ); - - expect(result).toBe("Best SEO Tips for 2024"); - }); - - it("includes advanced options in user prompt", async () => { - mockSuccessResponse("SEO Tips for Your Landing Page"); - - await generateRecommendation( - "test-api-key", - "title-keyword", - "SEO tips", - "Current title", - { - pageType: "landing-page", - secondaryKeywords: "marketing, conversion", - }, - ); - - const callArgs = mockCreate.mock.calls[0][0]; - const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user"); - expect(userMessage.content).toContain("landing page"); - }); - - it("throws immediately on 401 auth error without retry", async () => { - mockErrorResponse(401, "Invalid API key"); - - await expect( - generateRecommendation("bad-key", "title-keyword", "SEO", "context"), - ).rejects.toThrow("Invalid API key"); - - // Should only be called once (no retries for auth errors) - expect(mockCreate).toHaveBeenCalledOnce(); - }); - - it("throws immediately on 403 forbidden error without retry", async () => { - mockErrorResponse(403, "Forbidden"); - - await expect( - generateRecommendation("bad-key", "title-keyword", "SEO", "context"), - ).rejects.toThrow("Forbidden"); - - expect(mockCreate).toHaveBeenCalledOnce(); - }); - - it("retries on transient errors then succeeds", async () => { - vi.useFakeTimers(); - - // First call fails with a transient error - mockCreate.mockRejectedValueOnce(new Error("Network error")); - // Second call succeeds - mockSuccessResponse("Success after retry"); - - const promise = generateRecommendation( - "test-api-key", - "title-keyword", - "SEO", - "context", - ); - - // Fast-forward through retry delay - await vi.runAllTimersAsync(); - - const result = await promise; - - expect(result).toBe("Success after retry"); - expect(mockCreate).toHaveBeenCalledTimes(2); - - vi.useRealTimers(); - }); - - it("throws after max retries exceeded", { timeout: 15000 }, async () => { - // All calls fail - the retry has exponential backoff (1s, 2s, 4s) - // so we need a longer timeout - const error = new Error("Persistent network error"); - mockCreate.mockRejectedValue(error); - - await expect( - generateRecommendation("test-api-key", "title-keyword", "SEO", "context"), - ).rejects.toThrow("Persistent network error"); - - // Initial + 2 retries = 3 total calls - expect(mockCreate).toHaveBeenCalledTimes(3); - }); - - it("returns empty string when API returns null content", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: null } }], - }); - - const result = await generateRecommendation( - "test-api-key", - "title-keyword", - "SEO", - "context", - ); - - expect(result).toBe(""); - }); - - it("returns empty string when API returns undefined content", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: {} }], - }); - - const result = await generateRecommendation( - "test-api-key", - "title-keyword", - "SEO", - "context", - ); - - expect(result).toBe(""); - }); - }); - - describe("generateH2Suggestion", () => { - it("generates an H2 suggestion", async () => { - mockSuccessResponse("Why SEO Tips Matter for Your Business"); - - const result = await generateH2Suggestion( - "test-api-key", - "About Us", - "SEO tips", - ); - - expect(result).toBe("Why SEO Tips Matter for Your Business"); - }); - - it("includes keyword in the prompt", async () => { - mockSuccessResponse("SEO Tips Section"); - - await generateH2Suggestion("test-api-key", "About", "my keyword"); - - const callArgs = mockCreate.mock.calls[0][0]; - const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user"); - expect(userMessage.content).toContain("my keyword"); - }); - - it("includes advanced options when provided", async () => { - mockSuccessResponse("Les Conseils SEO Essentiels"); - - await generateH2Suggestion( - "test-api-key", - "About", - "SEO tips", - { languageCode: "fr", pageType: "blog" }, - ); - - const callArgs = mockCreate.mock.calls[0][0]; - const systemMessage = callArgs.messages.find((m: { role: string }) => m.role === "system"); - expect(systemMessage.content).toContain("French"); - }); - }); - - describe("generateAllH2Suggestions", () => { - it("generates suggestions for all H2s", async () => { - mockSuccessResponse("First SEO Tips Heading"); - mockSuccessResponse("Second SEO Tips Section"); - mockSuccessResponse("Third SEO Tips Guide"); - - const result = await generateAllH2Suggestions( - "test-api-key", - ["Heading 1", "Heading 2", "Heading 3"], - "SEO tips", - ); - - expect(result).toHaveLength(3); - expect(result[0]).toBe("First SEO Tips Heading"); - expect(result[1]).toBe("Second SEO Tips Section"); - expect(result[2]).toBe("Third SEO Tips Guide"); - }); - - it("handles empty H2 array", async () => { - const result = await generateAllH2Suggestions( - "test-api-key", - [], - "SEO tips", - ); - - expect(result).toEqual([]); - expect(mockCreate).not.toHaveBeenCalled(); - }); - - it("handles single H2", async () => { - mockSuccessResponse("Single Heading About SEO Tips"); - - const result = await generateAllH2Suggestions( - "test-api-key", - ["Original Heading"], - "SEO tips", - ); - - expect(result).toHaveLength(1); - expect(result[0]).toBe("Single Heading About SEO Tips"); - }); - }); - - describe("generateAltText", () => { - it("generates alt text for an image", async () => { - mockSuccessResponse("Team discussing SEO tips in a modern office"); - - const result = await generateAltText( - "test-api-key", - "https://example.com/images/team-meeting.jpg", - "SEO tips", - ); - - expect(result).toBe("Team discussing SEO tips in a modern office"); - }); - - it("extracts filename from image URL", async () => { - mockSuccessResponse("Product screenshot showing SEO tips"); - - await generateAltText( - "test-api-key", - "https://example.com/images/product-screenshot.png?v=123", - "SEO tips", - ); - - const callArgs = mockCreate.mock.calls[0][0]; - const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user"); - expect(userMessage.content).toContain("product-screenshot.png"); - }); - - it("handles URL without query params", async () => { - mockSuccessResponse("Hero image alt text"); - - await generateAltText( - "test-api-key", - "https://example.com/hero.webp", - "keyword", - ); - - const callArgs = mockCreate.mock.calls[0][0]; - const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user"); - expect(userMessage.content).toContain("hero.webp"); - }); - }); - - describe("legacy exports", () => { - it("generateTitle calls generateRecommendation with title-keyword", async () => { - mockSuccessResponse("Generated Title"); - - const result = await generateTitle( - "test-api-key", - "keyword", - "context", - ); - - expect(result).toBe("Generated Title"); - expect(mockCreate).toHaveBeenCalledOnce(); - }); - - it("generateMetaDescription calls generateRecommendation with meta-description-keyword", async () => { - mockSuccessResponse("Generated meta description"); - - const result = await generateMetaDescription( - "test-api-key", - "keyword", - "context", - ); - - expect(result).toBe("Generated meta description"); - expect(mockCreate).toHaveBeenCalledOnce(); - }); - - it("legacy exports support advanced options", async () => { - mockSuccessResponse("Title with options"); - - await generateTitle( - "test-api-key", - "keyword", - "context", - { pageType: "blog" }, - ); - - const callArgs = mockCreate.mock.calls[0][0]; - const userMessage = callArgs.messages.find((m: { role: string }) => m.role === "user"); - expect(userMessage.content).toContain("blog"); - }); - }); -}); diff --git a/app/src/lib/store.test.ts b/app/src/lib/store.test.ts index 765772a..099e79c 100644 --- a/app/src/lib/store.test.ts +++ b/app/src/lib/store.test.ts @@ -76,12 +76,12 @@ describe("useStore", () => { expect(useStore.getState().apiKey).toBe("sk-test-123"); expect(chrome.storage.local.set).toHaveBeenCalledWith({ - openai_api_key: "sk-test-123", + anthropic_api_key: "sk-test-123", }); }); it("loadApiKey reads api key from storage and sets it", async () => { - await chrome.storage.local.set({ openai_api_key: "sk-loaded" }); + await chrome.storage.local.set({ anthropic_api_key: "sk-loaded" }); await useStore.getState().loadApiKey(); diff --git a/app/src/lib/store.ts b/app/src/lib/store.ts index 2c501bc..ff8a693 100644 --- a/app/src/lib/store.ts +++ b/app/src/lib/store.ts @@ -51,14 +51,14 @@ export const useStore = create((set) => ({ setActiveCategory: (category) => set({ activeCategory: category, view: category ? "subscores" : "score" }), setApiKey: async (key) => { - await setStorageItem("openai_api_key", key); + await setStorageItem("anthropic_api_key", key); set({ apiKey: key }); }, setError: (error) => set({ error }), showToast: (message) => set({ toast: { visible: true, message } }), hideToast: () => set({ toast: { visible: false, message: "" } }), loadApiKey: async () => { - const key = await getStorageItem("openai_api_key"); + const key = await getStorageItem("anthropic_api_key"); if (key) set({ apiKey: key }); const lang = await getStorageItem("default_language"); if (lang) set((state) => ({ settings: { ...state.settings, language: lang } })); diff --git a/app/src/options/Options.test.tsx b/app/src/options/Options.test.tsx index 9ded0f1..87e4884 100644 --- a/app/src/options/Options.test.tsx +++ b/app/src/options/Options.test.tsx @@ -6,7 +6,8 @@ import { useEntitlementStore } from "@/lib/entitlement-store"; import { activate, deactivate, - getAiQuotaRemaining, + getFreeAiQuota, + getProAiRemaining, getValidEntitlement, hasStoredLicenseKey, type EntitlementClaims, @@ -20,16 +21,19 @@ vi.mock("@/lib/entitlement", async (importOriginal) => { activate: vi.fn(), deactivate: vi.fn(), getValidEntitlement: vi.fn(), - getAiQuotaRemaining: vi.fn(), + getFreeAiQuota: vi.fn(), + getProAiRemaining: vi.fn(), hasStoredLicenseKey: vi.fn(), - consumeAiQuota: vi.fn(), + recordProAiQuota: vi.fn(), + recordFreeAiQuota: vi.fn(), }; }); const activateMock = vi.mocked(activate); const deactivateMock = vi.mocked(deactivate); const getValidEntitlementMock = vi.mocked(getValidEntitlement); -const getAiQuotaRemainingMock = vi.mocked(getAiQuotaRemaining); +const getFreeAiQuotaMock = vi.mocked(getFreeAiQuota); +const getProAiRemainingMock = vi.mocked(getProAiRemaining); const hasStoredLicenseKeyMock = vi.mocked(hasStoredLicenseKey); const proClaims: EntitlementClaims = { @@ -41,132 +45,133 @@ const proClaims: EntitlementClaims = { exp: Math.floor(Date.now() / 1000) + 3600, }; +function resetEntitlementState() { + useEntitlementStore.setState({ + entitlementLoaded: false, + isPro: false, + tier: "free", + expiresAt: null, + quotaLimit: 0, + aiQuotaRemaining: 0, + freeAiRemaining: null, + freeAiLimit: null, + canUseAdvancedOptions: false, + canUseMultiLanguage: false, + canUseSchema: false, + canBringOwnKey: false, + hasLicenseKey: false, + activating: false, + activationError: null, + }); +} + +/** Makes hydrateEntitlement resolve to a Pro entitlement (all canUse* true). */ +function mockPro(remaining = 100) { + getValidEntitlementMock.mockResolvedValue(proClaims); + getProAiRemainingMock.mockResolvedValue(remaining); + hasStoredLicenseKeyMock.mockResolvedValue(true); +} + describe("Options page", () => { beforeEach(() => { - // Reset chrome.storage mock between tests - (chrome.storage.local.get as ReturnType).mockImplementation( - () => Promise.resolve({}), + (chrome.storage.local.get as ReturnType).mockImplementation(() => + Promise.resolve({}), ); getValidEntitlementMock.mockResolvedValue(null); - getAiQuotaRemainingMock.mockResolvedValue(0); + getFreeAiQuotaMock.mockResolvedValue(null); + getProAiRemainingMock.mockResolvedValue(0); hasStoredLicenseKeyMock.mockResolvedValue(false); - useEntitlementStore.setState({ - entitlementLoaded: false, - isPro: false, - tier: "free", - expiresAt: null, - quotaLimit: 0, - aiQuotaRemaining: 0, - canUseAdvancedOptions: false, - hasLicenseKey: false, - activating: false, - activationError: null, - }); + resetEntitlementState(); }); // --- Rendering --- it("renders the settings heading", () => { render(); - expect( - screen.getByRole("heading", { name: /settings/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument(); }); - it("renders the OpenAI API key field", () => { + it("renders the default language control and a save button", () => { render(); - expect(screen.getByLabelText(/openai api key/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/default language/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); }); - it("renders the default language select", () => { - render(); - expect( - screen.getByLabelText(/default language/i), - ).toBeInTheDocument(); - }); + // --- Free tier gating --- - it("renders a save button", () => { + it("hides the Anthropic API key input for free users (Pro upsell instead)", async () => { render(); + await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument()); + + expect(screen.queryByLabelText(/anthropic api key/i)).not.toBeInTheDocument(); expect( - screen.getByRole("button", { name: /save/i }), + screen.getByText(/bring your own anthropic key with optia pro/i), ).toBeInTheDocument(); }); - // --- Loading saved values --- - - it("loads saved API key from chrome.storage on mount", async () => { - (chrome.storage.local.get as ReturnType).mockImplementation( - () => - Promise.resolve({ - openai_api_key: "sk-test-key-123", - default_language: "en", - }), + it("disables the language select and pins English for free users", async () => { + (chrome.storage.local.get as ReturnType).mockImplementation(() => + Promise.resolve({ default_language: "fr" }), ); - render(); + await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument()); - await waitFor(() => { - expect(screen.getByLabelText(/openai api key/i)).toHaveValue( - "sk-test-key-123", - ); - }); + const select = screen.getByLabelText(/default language/i); + expect(select).toBeDisabled(); + expect(select).toHaveValue("en"); }); - it("loads saved language from chrome.storage on mount", async () => { - (chrome.storage.local.get as ReturnType).mockImplementation( - () => - Promise.resolve({ - openai_api_key: "", - default_language: "fr", - }), - ); - + it("saves only the language (English) for free users, never a key", async () => { + const user = userEvent.setup(); render(); + await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument()); - await waitFor(() => { - expect(screen.getByLabelText(/default language/i)).toHaveValue("fr"); - }); + await user.click(screen.getByRole("button", { name: /save/i })); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ default_language: "en" }); + const call = (chrome.storage.local.set as ReturnType).mock.calls[0][0]; + expect(call).not.toHaveProperty("anthropic_api_key"); }); - // --- User interactions --- + // --- Pro tier --- - it("allows typing an API key", async () => { - const user = userEvent.setup(); + it("renders the Anthropic API key input (password) for Pro users", async () => { + mockPro(); render(); - const input = screen.getByLabelText(/openai api key/i); - await user.clear(input); - await user.type(input, "sk-new-key"); - - expect(input).toHaveValue("sk-new-key"); + await waitFor(() => { + expect(screen.getByLabelText(/anthropic api key/i)).toBeInTheDocument(); + }); + expect(screen.getByLabelText(/anthropic api key/i)).toHaveAttribute("type", "password"); + expect(screen.getByLabelText(/default language/i)).toBeEnabled(); }); - it("allows selecting a language", async () => { - const user = userEvent.setup(); + it("loads a saved Anthropic key and language for Pro users", async () => { + mockPro(); + (chrome.storage.local.get as ReturnType).mockImplementation(() => + Promise.resolve({ anthropic_api_key: "sk-ant-123", default_language: "de" }), + ); render(); - const select = screen.getByLabelText(/default language/i); - await user.selectOptions(select, "de"); - - expect(select).toHaveValue("de"); + await waitFor(() => { + expect(screen.getByLabelText(/anthropic api key/i)).toHaveValue("sk-ant-123"); + }); + expect(screen.getByLabelText(/default language/i)).toHaveValue("de"); }); - it("saves settings to chrome.storage when save is clicked", async () => { + it("saves the Anthropic key and language for Pro users", async () => { + mockPro(); const user = userEvent.setup(); render(); + await waitFor(() => expect(screen.getByLabelText(/anthropic api key/i)).toBeInTheDocument()); - const input = screen.getByLabelText(/openai api key/i); - await user.clear(input); - await user.type(input, "sk-saved-key"); - - const select = screen.getByLabelText(/default language/i); - await user.selectOptions(select, "es"); - + await user.type(screen.getByLabelText(/anthropic api key/i), "sk-ant-saved"); + await user.selectOptions(screen.getByLabelText(/default language/i), "es"); await user.click(screen.getByRole("button", { name: /save/i })); expect(chrome.storage.local.set).toHaveBeenCalledWith( expect.objectContaining({ - openai_api_key: "sk-saved-key", + anthropic_api_key: "sk-ant-saved", default_language: "es", }), ); @@ -175,29 +180,8 @@ describe("Options page", () => { it("shows a success message after saving", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: /save/i })); - - await waitFor(() => { - expect(screen.getByText(/saved/i)).toBeInTheDocument(); - }); - }); - - // --- Accessibility --- - - it("API key input has password type for security", () => { - render(); - expect(screen.getByLabelText(/openai api key/i)).toHaveAttribute( - "type", - "password", - ); - }); - - it("all form controls have associated labels", () => { - render(); - // These will throw if no label is associated - expect(screen.getByLabelText(/openai api key/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/default language/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(/saved/i)).toBeInTheDocument()); }); // --- License card --- @@ -206,9 +190,7 @@ describe("Options page", () => { render(); expect(screen.getByRole("heading", { name: /license/i })).toBeInTheDocument(); - await waitFor(() => { - expect(screen.getByText("Free")).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument()); expect(screen.getByLabelText(/license key/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /^activate$/i })).toBeDisabled(); }); @@ -216,9 +198,7 @@ describe("Options page", () => { it("activates a license key and switches to the Pro state", async () => { const user = userEvent.setup(); activateMock.mockImplementation(async () => { - getValidEntitlementMock.mockResolvedValue(proClaims); - getAiQuotaRemainingMock.mockResolvedValue(100); - hasStoredLicenseKeyMock.mockResolvedValue(true); + mockPro(100); return proClaims; }); render(); @@ -227,9 +207,7 @@ describe("Options page", () => { await user.click(screen.getByRole("button", { name: /^activate$/i })); expect(activateMock).toHaveBeenCalledWith("optia_live_abc"); - await waitFor(() => { - expect(screen.getByText("Pro")).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText("Pro")).toBeInTheDocument()); expect(screen.getByText(/100 of 100 remaining/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /deactivate/i })).toBeInTheDocument(); }); @@ -250,21 +228,19 @@ describe("Options page", () => { it("deactivates and returns to the free state", async () => { const user = userEvent.setup(); - getValidEntitlementMock.mockResolvedValue(proClaims); - getAiQuotaRemainingMock.mockResolvedValue(80); - hasStoredLicenseKeyMock.mockResolvedValue(true); - deactivateMock.mockResolvedValue(); + mockPro(80); + deactivateMock.mockImplementation(async () => { + // After deactivation the entitlement is gone; next hydrate resolves free. + getValidEntitlementMock.mockResolvedValue(null); + hasStoredLicenseKeyMock.mockResolvedValue(false); + }); render(); - await waitFor(() => { - expect(screen.getByText("Pro")).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText("Pro")).toBeInTheDocument()); await user.click(screen.getByRole("button", { name: /deactivate/i })); expect(deactivateMock).toHaveBeenCalled(); - await waitFor(() => { - expect(screen.getByText("Free")).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText("Free")).toBeInTheDocument()); expect(screen.getByLabelText(/license key/i)).toBeInTheDocument(); }); }); diff --git a/app/src/options/Options.tsx b/app/src/options/Options.tsx index 21dfe95..9984db3 100644 --- a/app/src/options/Options.tsx +++ b/app/src/options/Options.tsx @@ -76,7 +76,7 @@ function LicenseCard() { ) : (

- Enter your Optia Pro license key to unlock AI without an OpenAI key and advanced + Enter your Optia Pro license key to unlock AI without an Anthropic key and advanced analysis.

@@ -116,24 +116,31 @@ const languages = SUPPORTED_LANGUAGES.map((lang) => ({ })); export function Options() { + const canBringOwnKey = useEntitlementStore((s) => s.canBringOwnKey); + const canUseMultiLanguage = useEntitlementStore((s) => s.canUseMultiLanguage); + const hydrateEntitlement = useEntitlementStore((s) => s.hydrateEntitlement); const [apiKey, setApiKey] = useState(""); const [language, setLanguage] = useState("en"); const [saved, setSaved] = useState(false); useEffect(() => { + void hydrateEntitlement(); chrome.storage.local - .get(["openai_api_key", "default_language"]) + .get(["anthropic_api_key", "default_language"]) .then((result) => { - if (result.openai_api_key) setApiKey(result.openai_api_key); + if (result.anthropic_api_key) setApiKey(result.anthropic_api_key); if (result.default_language) setLanguage(result.default_language); }); - }, []); + }, [hydrateEntitlement]); + + // Multi-language is a Pro feature — free users are pinned to English. + const effectiveLanguage = canUseMultiLanguage ? language : "en"; const handleSave = async () => { - await chrome.storage.local.set({ - openai_api_key: apiKey, - default_language: language, - }); + const toStore: Record = { default_language: effectiveLanguage }; + // BYO key is Pro-only; never persist a key for a free user. + if (canBringOwnKey) toStore.anthropic_api_key = apiKey; + await chrome.storage.local.set(toStore); setSaved(true); setTimeout(() => setSaved(false), 2000); }; @@ -149,37 +156,60 @@ export function Options() {

Settings

- Connect OpenAI to unlock AI-powered recommendations. + Free users get AI recommendations through Optia's hosted service. Activate Pro to bring + your own Anthropic key and unlock multi-language output.

-
- - setApiKey(e.target.value)} - className="rounded-input border border-border bg-surface px-3.5 py-3 text-body text-ink shadow-card placeholder:text-faint outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30" - /> -

- Stored locally in your browser — only ever sent directly to OpenAI. -

-
+ {canBringOwnKey ? ( +
+ + setApiKey(e.target.value)} + className="rounded-input border border-border bg-surface px-3.5 py-3 text-body text-ink shadow-card placeholder:text-faint outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30" + /> +

+ Stored locally in your browser — only ever sent directly to Anthropic. +

+
+ ) : ( +
+
+ Anthropic API key + + Pro + +
+

+ Bring your own Anthropic key with Optia Pro for unlimited, uncapped AI. +

+
+ )}
- +
+ + {!canUseMultiLanguage && ( + + Pro + + )} +
+ {!canUseMultiLanguage && ( +

+ Multi-language AI output is an Optia Pro feature. +

+ )}
-
- - setLocalApiKey(e.target.value)} - className="w-full rounded-input border border-border bg-surface px-3.5 py-2.5 text-body text-ink placeholder:text-faint outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30" - /> -
+ {canBringOwnKey ? ( +
+ + setLocalApiKey(e.target.value)} + className="w-full rounded-input border border-border bg-surface px-3.5 py-2.5 text-body text-ink placeholder:text-faint outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30" + /> +
+ ) : ( +
+
+ Anthropic API key + + Pro + +
+

+ Free AI runs through Optia's hosted service. Activate Pro to use your own Anthropic + key with no cap. +

+
+ )} - setSettings({ language: e.target.value })} + /> + ) : ( +
+
+ AI recommendations language + + Pro + +
+

+ English only on the free tier — unlock multi-language output with Optia Pro. +

+
+ )}