Skip to content

feat(entitlement): add client-side entitlement layer and feature-flag system#23

Merged
PAMulligan merged 1 commit into
mainfrom
8-add-client-side-entitlement-layer-and-feature-flag-system
Jul 21, 2026
Merged

feat(entitlement): add client-side entitlement layer and feature-flag system#23
PAMulligan merged 1 commit into
mainfrom
8-add-client-side-entitlement-layer-and-feature-flag-system

Conversation

@PAMulligan

Copy link
Copy Markdown
Contributor

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 via jose (EdDSA-only algorithm allowlist, iss/aud/kid checks, 60s clock tolerance, claim-shape validation). Any failure — missing, expired, tampered, alg confusion — resolves to the free tier, never Pro. Caches token + license key in chrome.storage.local via 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 for POST /license/activate|refresh|deactivate ({ licenseKey, installId }, per the shipped backend contract from Implement license verification and signed entitlements #6) with typed LicenseError codes: 403/404 → invalid, 429 → rate-limited (parses Retry-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) with useCanUseAI() = own OpenAI key OR (Pro AND quota remaining), and cross-context sync via chrome.storage.onChanged.
  • background/entitlement-alarm.ts — auto-refresh via chrome.alarms at 75% of token lifetime (never later than exp − 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

  • AI components (EditableRecommendation, ImageAltTextList, H2SelectionList) now take aiDisabled driven by useCanUseAI(); metered (Pro-without-key) generations consume quota in SubscoresPage.
  • Advanced Analysis is Pro-gated in SetupPage (disabled toggle + Pro badge, advancedMode forced off without entitlement) with a plan-status row in settings.
  • Options page gains a License card: activate/deactivate, tier badge, renewal date, remaining quota.

Notable decisions

  • minimum_chrome_version 116 → 137: Ed25519 in WebCrypto shipped by default in Chrome 137, and signature verification is now load-bearing.
  • canUseAI composition: existing users with their own OpenAI key keep working unchanged and are never metered. Keyless-Pro AI calls still need the backend /ai proxy (separate issue) — gating, flags, and quota metering are in place for it to slot into.
  • Quota source: token carries quotaLimit/period but no usage, so remaining = limit − locally tracked counter, reset on period rollover; the server stays authoritative once AI calls are proxied.
  • pnpm fix: app/pnpm-workspace.yaml had a literal allowBuilds placeholder that broke pnpm install under pnpm 11 locally; set real values (CI's pinned pnpm 10 path unaffected).

Acceptance criteria

  • Entitlement store added (Zustand, matching app/src/lib/store.ts) with typed flags
  • Signed entitlement verified against a bundled public key before being trusted
  • Cached in chrome.storage.local via the existing storage.ts helpers
  • Auto-refresh before expiry; on failure, fall back to last valid unexpired entitlement
  • Expired or missing entitlement resolves to the free tier, never Pro
  • Feature flags exposed: isPro, canUseAI, aiQuotaRemaining, advanced-options gate
  • Unit tests cover valid, expired, tampered, and offline cases
  • CI passes (locally: 373 tests across 22 files, tsc + build, lint clean)

Test plan

  • pnpm test — 373 passing, including the verification matrix (valid / expired / within-skew / tampered payload / alg:none / HS256 confusion / wrong iss/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 (runs tsc) and pnpm lint — clean.
  • Manual smoke: load app/dist unpacked → 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

… 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>
@PAMulligan PAMulligan linked an issue Jul 21, 2026 that may be closed by this pull request
8 tasks
@PAMulligan
PAMulligan merged commit 0e5c767 into main Jul 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add client-side entitlement layer and feature-flag system

1 participant