feat(entitlement): add client-side entitlement layer and feature-flag system#23
Merged
PAMulligan merged 1 commit intoJul 21, 2026
Conversation
… system Adds the client half of the licensing split (closes #8): a single well-tested layer that verifies signed entitlements and exposes feature flags, so the rest of the app never reads raw license data. - entitlement.ts: verifies Ed25519 JWS entitlements via jose (EdDSA-only allowlist, iss/aud/kid checks, 60s clock tolerance, claim-shape validation); any failure resolves to the free tier, never Pro. Caches token/license key in chrome.storage.local via existing storage helpers, tracks a per-period AI usage counter, and generates a stable anonymous install id for the backend's per-license seat model. - backend.ts: client for POST /license/activate|refresh|deactivate with typed LicenseError codes (403/404 invalid, 429 rate-limited with Retry-After, 5xx server, fetch failure network). - entitlement-keys.ts: bundled public verification JWKs and backend base URL per environment, selected at build time via Vite mode (staging build: vite build --mode staging). - entitlement-store.ts: Zustand flags store (isPro, tier, expiresAt, quotaLimit, aiQuotaRemaining, canUseAdvancedOptions) with useCanUseAI() = own OpenAI key OR Pro with quota, and cross-context sync via chrome.storage.onChanged. - entitlement-alarm.ts: background auto-refresh via chrome.alarms at 75% of token lifetime (never later than exp-1h), exponential backoff capped at 6h on network/server failures while keeping the last valid unexpired token, and immediate state wipe on revocation. - UI wiring: AI components take aiDisabled driven by useCanUseAI(); metered (Pro-without-key) generations consume quota; Advanced Analysis is Pro-gated in SetupPage with a plan status row; Options gains a License card (activate/deactivate, tier, renewal, quota). - manifest: add "alarms" permission; bump minimum_chrome_version to 137 (Ed25519 in WebCrypto shipped by default in 137 and verification is now load-bearing). - tests: signed-token fixtures generate a fresh Ed25519 keypair per suite (no checked-in secrets) covering valid, expired, tampered, alg-confusion (none/HS256), wrong iss/aud/kid, offline fallback, revocation, quota rollover, alarm scheduling/backoff, and UI gating. - fix pnpm-workspace.yaml allowBuilds placeholder that broke pnpm install under pnpm 11 (CI's pnpm 10 path unaffected). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #8.
Adds the client half of the licensing split: a single well-tested layer that verifies signed entitlements against the bundled public key and exposes typed feature flags. The rest of the app reads only the flags — never raw license data.
What's in here
Core layer
lib/entitlement.ts— verifies Ed25519 JWS entitlements viajose(EdDSA-only algorithm allowlist,iss/aud/kidchecks, 60s clock tolerance, claim-shape validation). Any failure — missing, expired, tampered, alg confusion — resolves to the free tier, never Pro. Caches token + license key inchrome.storage.localvia the existing storage helpers, meters a per-period AI usage counter, and generates a stable anonymous install id for the backend's per-license seat model.lib/backend.ts— client forPOST /license/activate|refresh|deactivate({ licenseKey, installId }, per the shipped backend contract from Implement license verification and signed entitlements #6) with typedLicenseErrorcodes: 403/404 → invalid, 429 → rate-limited (parsesRetry-After), 5xx → server, fetch failure → network.lib/entitlement-keys.ts— bundled public verification JWKs (public by design) + backend base URL per environment, selected at build time via Vite mode. Staging build:vite build --mode staging.Flags + refresh
lib/entitlement-store.ts— Zustand store (isPro,tier,expiresAt,quotaLimit,aiQuotaRemaining,canUseAdvancedOptions) withuseCanUseAI()= own OpenAI key OR (Pro AND quota remaining), and cross-context sync viachrome.storage.onChanged.background/entitlement-alarm.ts— auto-refresh viachrome.alarmsat 75% of token lifetime (never later thanexp − 1h); exponential backoff capped at 6h on network/server/429 failures while keeping the last valid unexpired token (offline-graceful); revoked/unknown license wipes local state immediately. Re-arms on install/startup and on storage changes, so activation in Options arms the alarm with no message passing.UI wiring
EditableRecommendation,ImageAltTextList,H2SelectionList) now takeaiDisableddriven byuseCanUseAI(); metered (Pro-without-key) generations consume quota inSubscoresPage.SetupPage(disabled toggle + Pro badge,advancedModeforced off without entitlement) with a plan-status row in settings.Notable decisions
minimum_chrome_version116 → 137: Ed25519 in WebCrypto shipped by default in Chrome 137, and signature verification is now load-bearing.canUseAIcomposition: existing users with their own OpenAI key keep working unchanged and are never metered. Keyless-Pro AI calls still need the backend/aiproxy (separate issue) — gating, flags, and quota metering are in place for it to slot into.quotaLimit/periodbut no usage, so remaining = limit − locally tracked counter, reset on period rollover; the server stays authoritative once AI calls are proxied.app/pnpm-workspace.yamlhad a literalallowBuildsplaceholder that brokepnpm installunder pnpm 11 locally; set real values (CI's pinned pnpm 10 path unaffected).Acceptance criteria
app/src/lib/store.ts) with typed flagschrome.storage.localvia the existingstorage.tshelpersisPro,canUseAI,aiQuotaRemaining, advanced-options gatetsc+ build, lint clean)Test plan
pnpm test— 373 passing, including the verification matrix (valid / expired / within-skew / tampered payload /alg:none/ HS256 confusion / wrongiss/aud/kid), offline fallback, revocation wipe, tampered refresh response, quota rollover, alarm scheduling + backoff, and UI gating. Token fixtures generate a fresh Ed25519 keypair per suite — no secrets checked in.pnpm build(runstsc) andpnpm lint— clean.app/distunpacked → AI disabled hint for free users, own-key path unchanged; staging build + staging license key in Options → Pro badge/expiry, sidepanel updates without reload; deactivate → immediate downgrade.🤖 Generated with Claude Code