Files
worldmonitor/vitest.config.mts
Sebastien Melki 9893bb1cf2 feat: Dodo Payments integration + entitlement engine & webhook pipeline (#2024)
* feat(14-01): install @dodopayments/convex and register component

- Install @dodopayments/convex@0.2.8 with peer deps satisfied
- Create convex/convex.config.ts with defineApp() and dodopayments component
- Add TODO for betterAuth registration when PR #1812 merges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(14-01): extend schema with 6 payment tables for Dodo integration

- Add subscriptions table with status enum, indexes, and raw payload
- Add entitlements table (one record per user) with features blob
- Add customers table keyed by userId with optional dodoCustomerId
- Add webhookEvents table for full audit trail (retained forever)
- Add paymentEvents table for billing history (charge/refund)
- Add productPlans table for product-to-plan mapping in DB
- All existing tables (registrations, contactMessages, counters) unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(14-01): add auth stub, env helper, and Dodo env var docs

- Create convex/lib/auth.ts with resolveUserId (returns test-user-001 in dev)
  and requireUserId (throws on unauthenticated) as sole auth entry points
- Create convex/lib/env.ts with requireEnv for runtime env var validation
- Append DODO_API_KEY, DODO_WEBHOOK_SECRET, DODO_PAYMENTS_WEBHOOK_SECRET,
  and DODO_BUSINESS_ID to .env.example with setup instructions
- Document dual webhook secret naming (library vs app convention)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(14-02): add seed mutation for product-to-plan mappings

- Idempotent upsert mutation for 5 Dodo product-to-plan mappings
- Placeholder product IDs to be replaced after Dodo dashboard setup
- listProductPlans query for verification and downstream use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(14-02): populate seed mutation with real Dodo product IDs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(15-02): add plan-to-features entitlements config map

- Define PlanFeatures type with 5 feature dimensions
- Add PLAN_FEATURES config for 6 tiers (free through enterprise)
- Export getFeaturesForPlan helper with free-tier fallback
- Export FREE_FEATURES constant for default entitlements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(15-01): add webhook HTTP endpoint with signature verification

- Custom httpAction verifying Dodo webhook signatures via @dodopayments/core
- Returns 400 for missing headers, 401 for invalid signature, 500 for processing errors
- HTTP router at /dodopayments-webhook dispatches POST to webhook handler
- Synchronous processing before 200 response (within Dodo 15s timeout)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(15-02): add subscription lifecycle handlers and entitlement upsert

- Add upsertEntitlements helper (creates/updates per userId, no duplicates)
- Add isNewerEvent guard for out-of-order webhook rejection
- Add handleSubscriptionActive (creates subscription + entitlements)
- Add handleSubscriptionRenewed (extends period + entitlements)
- Add handleSubscriptionOnHold (pauses without revoking entitlements)
- Add handleSubscriptionCancelled (preserves entitlements until period end)
- Add handleSubscriptionPlanChanged (updates plan + recomputes entitlements)
- Add handlePaymentEvent (records charge events for succeeded/failed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(15-01): add idempotent webhook event processor with dispatch skeleton

- processWebhookEvent internalMutation with idempotency via by_webhookId index
- Switch dispatch for 7 event types: 5 subscription + 2 payment events
- Stub handlers log TODO for each event type (to be implemented in Plan 03)
- Error handling marks failed events and re-throws for HTTP 500 + Dodo retry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(15-01): complete webhook endpoint plan

- Update auto-generated api.d.ts with new payment module types
- SUMMARY, STATE, and ROADMAP updated (.planning/ gitignored)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(15-03): wire subscription handlers into webhook dispatch

- Replace 6 stub handler functions with imports from subscriptionHelpers
- All 7 event types (5 subscription + 2 payment) dispatch to real handlers
- Error handling preserves failed event status in webhookEvents table
- Complete end-to-end pipeline: HTTP action -> mutation -> handler functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(15-04): install convex-test, vitest, and edge-runtime; configure vitest

- Add convex-test, vitest, @edge-runtime/vm as dev dependencies
- Create vitest.config.mts scoped to convex/__tests__/ with edge-runtime environment
- Add test:convex and test:convex:watch npm scripts

* test(15-04): add 10 contract tests for webhook event processing pipeline

- Test all 5 subscription lifecycle events (active, renewed, on_hold, cancelled, plan_changed)
- Test both payment events (succeeded, failed)
- Test deduplication by webhook-id (same id processed only once)
- Test out-of-order event rejection (older timestamp skipped)
- Test subscription reactivation (cancelled -> active on same subscription_id)
- Verify entitlements created/updated with correct plan features

* fix(15-04): exclude __tests__ from convex typecheck

convex-test uses Vite-specific import.meta.glob and has generic type
mismatches with tsc. Tests run correctly via vitest; excluding from
convex typecheck avoids false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(16-01): add tier levels to PLAN_FEATURES and create entitlement query

- Add tier: number to PlanFeatures type (0=free, 1=pro, 2=api, 3=enterprise)
- Add tier values to all plan entries in PLAN_FEATURES config
- Create convex/entitlements.ts with getEntitlementsForUser public query
- Free-tier fallback for missing or expired entitlements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(16-01): create Redis cache sync action and wire upsertEntitlements

- Create convex/payments/cacheActions.ts with syncEntitlementCache internal action
- Wire upsertEntitlements to schedule cache sync via ctx.scheduler.runAfter(0, ...)
- Add deleteRedisKey() to server/_shared/redis.ts for explicit cache invalidation
- Redis keys use raw format (entitlements:{userId}) with 1-hour TTL
- Cache write failures logged but do not break webhook pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(16-02): add entitlement enforcement to API gateway

- Create entitlement-check middleware with Redis cache + Convex fallback
- Replace PREMIUM_RPC_PATHS boolean Set with ENDPOINT_ENTITLEMENTS tier map
- Wire checkEntitlement into gateway between API key and rate limiting
- Add raw parameter to setCachedJson for user-scoped entitlement keys
- Fail-open on missing auth/cache failures for graceful degradation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(16-03): create frontend entitlement service with reactive ConvexClient subscription

- Add VITE_CONVEX_URL to .env.example for frontend Convex access
- Create src/services/entitlements.ts with lazy-loaded ConvexClient
- Export initEntitlementSubscription, onEntitlementChange, getEntitlementState, hasFeature, hasTier, isEntitled
- ConvexClient only loaded when userId available and VITE_CONVEX_URL configured
- Graceful degradation: log warning and skip when Convex unavailable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(16-04): add 6 contract tests for Convex entitlement query

- Free-tier defaults for unknown userId
- Active entitlements for subscribed user
- Free-tier fallback for expired entitlements
- Correct tier mapping for api_starter and enterprise plans
- getFeaturesForPlan fallback for unknown plan keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(16-04): add 6 unit tests for gateway entitlement enforcement

- getRequiredTier: gated vs ungated endpoint tier lookup
- checkEntitlement: ungated pass-through, missing userId graceful degradation
- checkEntitlement: 403 for insufficient tier, null for sufficient tier
- Dependency injection pattern (_testCheckEntitlement) for clean testability
- vitest.config.mts include expanded to server/__tests__/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(17-01): create Convex checkout session action

- DodoPayments component wraps checkout with server-side API key
- Accepts productId, returnUrl, discountCode, referralCode args
- Always enables discount code input (PROMO-01)
- Forwards affiliate referral as checkout metadata (PROMO-02)
- Dark theme customization for checkout overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(17-03): create PricingSection component with tier cards and billing toggle

- 4 tiers: Free, Pro, API, Enterprise with feature comparison
- Monthly/annual toggle with "Save 17%" badge for Pro
- Checkout buttons using Dodo static payment links
- Pro tier visually highlighted with green border and "Most Popular" badge
- Staggered entrance animations via motion
- Referral code forwarding via refCode prop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(17-01): extract shared ConvexClient singleton, refactor entitlements

- Create src/services/convex-client.ts with getConvexClient() and getConvexApi()
- Lazy-load ConvexClient via dynamic import to preserve bundle size
- Refactor entitlements.ts to use shared client instead of inline creation
- Both checkout and entitlement services will share one WebSocket connection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(17-03): integrate PricingSection into App.tsx with referral code forwarding

- Import and render PricingSection between EnterpriseShowcase and PricingTable
- Pass refCode from getRefCode() URL param to PricingSection for checkout link forwarding
- Update navbar CTA and TwoPathSplit Pro CTA to anchor to #pricing section
- Keep existing waitlist form in Footer for users not ready to buy
- Build succeeds with no new errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update generated files after main merge and pro-test build

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prefix unused ctx param in auth stub to pass typecheck

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(17-04): add 4 E2E contract tests for checkout-to-entitlement flow

- Test product plan seeding and querying (5 plans verified)
- Test pro_monthly checkout -> webhook -> entitlements (tier=1, no API)
- Test api_starter checkout -> webhook -> entitlements (tier=2, apiAccess)
- Test expired entitlements fall back to free tier (tier=0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(17-02): install dodopayments-checkout SDK and create checkout overlay service

- Install dodopayments-checkout@1.8.0 overlay SDK
- Create src/services/checkout.ts with initCheckoutOverlay, openCheckout, startCheckout, showCheckoutSuccess
- Dark theme config matching dashboard aesthetic (green accent, dark bg)
- Lazy SDK initialization on first use
- Fallback to /pro page when Convex is unavailable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(17-02): wire locked panel CTAs and post-checkout return handling

- Create src/services/checkout-return.ts for URL param detection and cleanup
- Update Panel.ts showLocked() CTA to trigger Dodo overlay checkout (web path)
- Keep Tauri desktop path opening URL externally
- Add handleCheckoutReturn() call in PanelLayoutManager constructor
- Initialize checkout overlay with success banner callback
- Dynamic import of checkout module to avoid loading until user clicks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(17-02): add Upgrade to Pro section in UnifiedSettings modal

- Add upgrade section at bottom of settings tab with value proposition
- Wire CTA button to open Dodo checkout overlay via dynamic import
- Close settings modal before opening checkout overlay
- Tauri desktop fallback to external URL
- Conditionally show "You're on Pro" when user has active entitlement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unused imports in entitlement-check test to pass typecheck:api

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(17-01): guard missing DODO_PAYMENTS_API_KEY with warning instead of silent undefined

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(17-01): use DODO_API_KEY env var name matching Convex dashboard config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(17-02): add Dodo checkout domains to CSP frame-src directive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(17-03): use test checkout domain for test-mode Dodo products

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(18-01): shared DodoPayments config and customer upsert in webhook

- Create convex/lib/dodo.ts centralizing DodoPayments instance and API exports
- Refactor checkout.ts to import from shared config (remove inline instantiation)
- Add customer record upsert in handleSubscriptionActive for portal session support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(18-01): billing queries and actions for subscription management

- Add getSubscriptionForUser query (plan status, display name, renewal date)
- Add getCustomerByUserId and getActiveSubscription internal queries
- Add getCustomerPortalUrl action (creates Dodo portal session via SDK)
- Add changePlan action (upgrade/downgrade with proration via Dodo SDK)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(18-02): add frontend billing service with reactive subscription watch

- SubscriptionInfo interface for plan status display
- initSubscriptionWatch() with ConvexClient onUpdate subscription
- onSubscriptionChange() listener pattern with immediate fire for late subscribers
- openBillingPortal() with Dodo Customer Portal fallback
- changePlan() with prorated_immediately proration mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(18-02): add subscription status display and Manage Billing button to settings

- Settings modal shows plan name, status badge, and renewal date for entitled users
- Status-aware colors: green (active), yellow (on_hold), red (cancelled/expired)
- Manage Billing button opens Dodo Customer Portal via billing service
- initSubscriptionWatch called at dashboard boot alongside entitlements
- Import initPaymentFailureBanner for Task 3 wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(18-02): add persistent payment failure banner for on_hold subscriptions

- Red fixed-position banner at top of dashboard when subscription is on_hold
- Update Payment button opens Dodo billing portal
- Dismiss button with sessionStorage persistence (avoids nagging in same session)
- Auto-removes when subscription returns to active (reactive via Convex)
- Event listeners attached directly to DOM (not via debounced setContent)
- Wired into panel-layout constructor alongside subscription watch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review — identity bridge, entitlement gating, fail-closed, env hygiene

P0: Checkout-to-user identity bridge
- Pass userId as metadata.wm_user_id in checkout sessions
- Webhook resolveUserId: try metadata first, then customer table, then dev-only fallback
- Fail closed in production when no user identity can be resolved

P0: Unify premium gating to read Dodo entitlements
- data-loader.ts: hasPremiumAccess() checks isEntitled() || API key
- panels.ts: isPanelEntitled checks isEntitled() before API key fallback
- panel-layout.ts: reload on entitlement change to unlock panels

P1: Fail closed on unknown product IDs
- resolvePlanKey throws on unmapped product (webhook retries)
- getFeaturesForPlan throws on unknown planKey

P1: Env var hygiene
- Canonical DODO_API_KEY (no dual-name fallback in dodo.ts)
- console.error on missing key instead of silent empty string

P1: Fix test suite scheduled function errors
- Guard scheduler.runAfter with UPSTASH_REDIS_REST_URL check
- Tests skip Redis cache sync, eliminating convex-test write errors

P2: Webhook rollback durability
- webhookMutations: return error instead of rethrow (preserves audit row)
- webhookHandlers: check mutation return for error indicator

P2: Product ID consolidation
- New src/config/products.ts as single source of truth
- Panel.ts and UnifiedSettings.ts import from shared config

P2: ConvexHttpClient singleton in entitlement-check.ts
P2: Concrete features validator in schema (replaces v.any())
P2: Tests seed real customer mapping (not fallback user)

P3: Narrow eslint-disable to typed interfaces in subscriptionHelpers
P3: Real ConvexClient type in convex-client.ts
P3: Better dev detection in auth.ts (CONVEX_IS_DEV)
P3: Add VITE_DODO_ENVIRONMENT to .env.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(payments): security audit hardening — auth gates, webhook retry, transaction safety

- Gate all public billing/checkout endpoints with resolveUserId(ctx) auth check
- Fix webhook retry: record events after processing, not before; delete failed events on retry
- Fix transaction atomicity: let errors propagate so Convex rolls back partial writes
- Fix isDevDeployment to use CONVEX_IS_DEV (same as lib/auth.ts)
- Add missing handlers: subscription.expired, refund.*, dispute.*
- Fix toEpochMs silent fallback — now warns on missing billing dates
- Use validated payload directly instead of double-parsing webhook body
- Fix multi-sub query to prioritize active > on_hold > cancelled > expired
- Change .unique() to .first() on customer lookups (defensive against duplicates)
- Update handleSubscriptionActive to patch planKey/dodoProductId on existing subs
- Frontend: portal URL validation, getProWidgetKey(), subscription cleanup on destroy
- Make seedProductPlans internalMutation, console.log → console.warn for ops signals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review — identity bridge, entitlement gating, fail-safe dev detection

P0: convex/lib/auth.ts + subscriptionHelpers.ts — remove CONVEX_CLOUD_URL
heuristic that could treat production as dev. Now uses ctx.auth.getUserIdentity()
as primary auth with CONVEX_IS_DEV-only dev fallback.

P0: server/gateway.ts + auth-session.ts — add bearer token (Clerk JWT) support
for tier-gated endpoints. Authenticated users bypass API key requirement; userId
flows into x-user-id header for entitlement check. Activated by setting
CLERK_JWT_ISSUER_DOMAIN env var.

P1: src/services/user-identity.ts — centralized getUserId() replacing scattered
getProWidgetKey() calls in checkout.ts, billing.ts, panel-layout.ts.

P2: src/App.ts — premium panel prime/refresh now checks isEntitled() alongside
WORLDMONITOR_API_KEY so Dodo-entitled web users get data loading.

P2: convex/lib/dodo.ts + billing.ts — move Dodo SDK config from module scope
into lazy/action-scoped init. Missing DODO_API_KEY now throws at action boundary
instead of silently capturing empty string.

Tests: webhook test payloads now include wm_user_id metadata (production path).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address P0 access control + P1 identity bridge + P1 entitlement reload loop

P0: Remove userId from public billing function args (getSubscriptionForUser,
getCustomerPortalUrl, changePlan) — use requireUserId(ctx) with no fallback
to prevent unauthenticated callers from accessing arbitrary user data.

P1: Add stable anonymous ID (wm-anon-id) in user-identity.ts so
createCheckout always passes wm_user_id in metadata. Breaks the infinite
webhook retry loop for brand-new purchasers with no auth/localStorage identity.

P1: Skip initial entitlement snapshot in onEntitlementChange to prevent
reload loop for existing premium users whose shouldUnlockPremium() is
already true from legacy signals (API key / wm-pro-key).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address P1 billing flow + P1 anon ID claim path + P2 daily-market-brief

P1 — Billing functions wired end-to-end for browser sessions:
- getSubscriptionForUser, getCustomerPortalUrl, changePlan now accept
  userId from args (matching entitlements.ts pattern) with auth-first
  fallback. Once Clerk JWT is wired into ConvexClient.setAuth(), the
  auth path will take precedence automatically.
- Frontend billing.ts passes userId from getUserId() on all calls.
- Subscription watch, portal URL, and plan change all work for browser
  users with anon IDs.

P1 — Anonymous ID → account claim path:
- Added claimSubscription(anonId) mutation to billing.ts — reassigns
  subscriptions, entitlements, customers, and payment events from an
  anonymous browser ID to the authenticated user.
- Documented the anon ID limitation in user-identity.ts with the
  migration plan (call claimSubscription on first Clerk session).
- Created follow-up issue #2078 for the full claim/migration flow.

P2 — daily-market-brief added to hasPremiumAccess() block in
data-loader.ts loadAllData() so it loads on general data refresh
paths (was only in primeVisiblePanelData startup path).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: P0 lock down billing write actions + P2 fix claimSubscription logic

P0 — Billing access control locked down:
- getCustomerPortalUrl and changePlan converted to internalAction — not
  callable from the browser, closing the IDOR hole on write paths.
- getSubscriptionForUser stays as a public query with userId arg (read-only,
  matching the entitlements.ts pattern — low risk).
- Frontend billing.ts: portal opens generic Dodo URL, changePlan returns
  "not available" stub. Both will be promoted once Clerk auth is wired
  into ConvexClient.setAuth().

P2 — claimSubscription merge logic fixed:
- Entitlement comparison now uses features.tier first, breaks ties with
  validUntil (was comparing only validUntil which could downgrade tiers).
- Added Redis cache invalidation after claim: schedules
  deleteEntitlementCache for the stale anon ID and syncEntitlementCache
  for the real user ID.
- Added deleteEntitlementCache internal action to cacheActions.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(billing): strip Dodo vendor IDs from public query response

Remove dodoSubscriptionId and dodoProductId from getSubscriptionForUser
return — these vendor-level identifiers aren't used client-side and
shouldn't be exposed over an unauthenticated fallback path.

Addresses koala73's Round 5 P1 review on #2024.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(billing): address koala review cleanup items (P2/P3)

- Remove stale dodoSubscriptionId/dodoProductId from SubscriptionInfo
  interface (server no longer returns them)
- Remove dead `?? crypto.randomUUID()` fallback in checkout.ts
  (getUserId() always returns a string via getOrCreateAnonId())
- Remove unused "failed" status variant and errorMessage from
  webhookEvents schema (no code path writes them)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing isProUser import and allow trusted origins for tier-gated endpoints

The typecheck failed because isProUser was used in App.ts but never imported.
The unit test failed because the gateway forced API key validation for
tier-gated endpoints even from trusted browser origins (worldmonitor.app),
where the client-side isProUser() gate controls access instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(gateway): require credentials for premium endpoints regardless of origin

Origin header is spoofable — it cannot be a security boundary. Premium
endpoints now always require either an API key or a valid bearer token
(via Clerk session). Authenticated users (sessionUserId present) bypass
the API key check; unauthenticated requests to tier-gated endpoints get 401.

Updated test to assert browserNoKey → 401 instead of 200.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(tests): add catch-all route to legacy endpoint allowlist

The [[...path]].js Vercel catch-all route (domain gateway entry point)
was missing from ALLOWED_LEGACY_ENDPOINTS in the edge function tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert: remove [[...path]].js from legacy endpoint allowlist

This file is Vercel-generated and gitignored — it only exists locally,
not in the repo. Adding it to the allowlist caused CI to fail with
"stale entry" since the file doesn't exist in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(payment): apply design system to payment UI (light/dark mode)

- checkout.ts: add light themeConfig for Dodo overlay + pass theme flag
  based on current document.dataset.theme; previously only dark was
  configured so light-mode users got Dodo's default white UI
- UnifiedSettings: replace hardcoded dark hex values (#1a1a1a, #323232,
  #fff, #909090) in upgrade section with CSS var-driven classes so the
  panel respects both light and dark themes
- main.css: add .upgrade-pro-section / .upgrade-pro-cta / .manage-billing-btn
  classes using var(--green), var(--bg), var(--surface), var(--border), etc.

* fix(checkout): remove invalid theme prop from CheckoutOptions

* fix: regenerate package-lock.json with npm 10 (matches CI Node 22)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(gateway): enforce pro role check for authenticated free users on premium paths

Free bearer token holders with a valid session bypassed PREMIUM_RPC_PATHS
because sessionUserId being set caused forceKey=false, skipping the role
check entirely. Now explicitly checks bearer role after API key gate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused getSecretState import from data-loader

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: gate Dodo Payments init behind isProUser() (same as Clerk)

Entitlement subscription, subscription watch, checkout overlay, and
payment banners now only initialize for isProUser() — matching the
Clerk auth gate so only wm-pro-key / wm-widget-key holders see it.

Also consolidates inline isEntitled()||getSecretState()||role checks
in App.ts to use the centralized hasPremiumAccess() from panel-gating.

Both gates (Clerk + Dodo) to be removed when ready for all users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: retrigger workflows

* chore: retrigger CI

* fix(redis): use POST method in deleteRedisKey for consistency

All other write helpers use POST; DEL was implicitly using GET via fetch default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(quick-5): resolve all P1 security issues from koala73 review

P1-1: Fail-closed entitlement gate (403 when no userId or lookup fails)
P1-2: Checkout requires auth (removed client-supplied userId fallback)
P1-3: Removed dual auth (PREMIUM_RPC_PATHS) from gateway, single entitlement path
P1-4: Typed features validator in cacheActions (v.object instead of v.any)
P1-5: Typed ConvexClient API ref (typeof api instead of Record<string,any>)
P1-6: Cache stampede mitigation via request coalescing (_inFlight map)
Round8-A: getUserId() returns Clerk user.id, hasUserIdentity() checks real identity
Round8-B: JWT verification pinned to algorithms: ['RS256']

- Updated entitlement tests for fail-closed behavior (7 tests pass)
- Removed userId arg from checkout client call
- Added env-aware Redis key prefix (live/test)
- Reduced cache TTL from 3600 to 900 seconds
- Added 5s timeout to Redis fetch calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(quick-5): resolve all P2 review issues from koala73 review

P2-1: dispute.lost now revokes entitlements (downgrades to free tier)
P2-2: rawPayload v.any() documented with JSDoc (intentional: external schema)
P2-3: Redis keys prefixed with live/test env (already in P1 commit)
P2-4: 5s timeout on Redis fetch calls (already in P1 commit)
P2-5: Cache TTL reduced from 3600 to 900 seconds (already in P1 commit)
P2-6: CONVEX_IS_DEV warning logged at module load time (once, not per-call)
P2-7: claimSubscription uses .first() instead of .unique() (race safety)
P2-8: toEpochMs fallback accepts eventTimestamp (all callers updated)
P2-9: hasUserIdentity() checks real identity (already in P1 commit)
P2-10: Duplicate JWT verification removed (already in P1 commit)
P2-11: Subscription queries bounded with .take(10)
P2-12: Entitlement subscription exposes destroyEntitlementSubscription()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(convex): resolve TS errors in http.ts after merge

Non-null assertions for anyApi dynamic module references and safe
array element access in timing-safe comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(tests): update gateway tests for fail-closed entitlement system

Tests now reflect the new behavior where:
- API key + no auth session → 403 (entitlement check requires userId)
- Valid bearer + no entitlement data → 403 (fail-closed)
- Free bearer → 403 (entitlements unavailable)
- Invalid bearer → 401 (no session, forceKey kicks in)
- Public routes → 200 (unchanged)

The old tests asserted PREMIUM_RPC_PATHS + JWT role behavior which
was removed per koala73's P1-3 review (dual auth elimination).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(review): resolve 4 P1 + 2 P2 issues from koala73 round-9 review

P1 fixes:
- API-key holders bypass entitlement check (were getting 403)
- Browser checkout passes userId for identity bridge (ConvexClient
  has no setAuth yet, so createCheckout accepts optional userId arg)
- /pro pricing page embeds wm_user_id in Dodo checkout URL metadata
  so webhook can resolve identity for first-time purchasers
- Remove isProUser() gate from entitlement/billing init — all users
  now subscribe to entitlement changes so upgrades take effect
  immediately without manual page reload

P2 fixes:
- destroyEntitlementSubscription() called on teardown to clear stale
  premium state across SPA sessions (sign-out / identity change)
- Convex queries prefer resolveUserId(ctx) over client-supplied
  userId; documented as temporary until Clerk JWT wired into
  ConvexClient.setAuth()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(review): resolve 4 P1 + 1 P2 from koala73 round-10 review

P1 — Checkout identity no longer client-controlled:
  - createCheckout HMAC-signs userId with DODO_PAYMENTS_WEBHOOK_SECRET
  - Webhook resolveUserId only trusts metadata when HMAC signature is
    valid; unsigned/tampered metadata is rejected
  - /pro raw URLs no longer embed wm_user_id (eliminated URL tampering)
  - Purchases without signed metadata get synthetic "dodo:{customerId}"
    userId, claimable later via claimSubscription()

P1 — IDOR on Convex queries addressed:
  - Both getEntitlementsForUser and getSubscriptionForUser now reject
    mismatched userId when the caller IS authenticated (authedUserId !=
    args.userId → return defaults/null)
  - Created internal getEntitlementsByUserId for future gateway use
  - Pre-auth fallback to args.userId documented with TODO(clerk-auth)

P1 — Clerk identity bridge fixed:
  - user-identity.ts now uses getCurrentClerkUser() from clerk.ts
    instead of reading window.Clerk?.user (which was never assigned)
  - Signed-in Clerk users now correctly resolve to their Clerk user ID

P1 — Auth modal available for anonymous users:
  - Removed isProUser() gate from setupAuthWidget() in App.ts
  - Anonymous users can now click premium CTAs → sign-in modal opens

P2 — .take(10) subscription cap:
  - Bumped to .take(50) in both getSubscriptionForUser and
    getActiveSubscription to avoid missing active subscriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(review): resolve remaining P2/P3 feedback from koala73 + greptile on PR #2024

JWKS: consolidate duplicate singletons — server/_shared/auth-session.ts now
imports the shared JWKS from server/auth-session.ts (eliminates redundant cold-start fetch).

Webhook: remove unreachable retry branch — webhookEvents.status is always
"processed" (inserted only after success, rolled back on throw). Dead else removed.

YAGNI: remove changePlan action + frontend stub (no callers — plan changes use
Customer Portal). Remove unused by_status index on subscriptions table.

DRY: consolidate identical pro_monthly/pro_annual into shared PRO_FEATURES constant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): read CLERK_JWT_ISSUER_DOMAIN lazily in getJWKS() — fixes CI bearer token tests

The shared getJWKS() was reading the env var from a module-scope const,
which freezes at import time. Tests set the env var in before() hooks
after import, so getJWKS() returned null and bearer tokens were never
verified — causing 401 instead of 403 on entitlement checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(payments): resolve all blocking issues from review rounds 1-2

Data integrity:
- Use .first() instead of .unique() on entitlements.by_userId to survive
  concurrent webhook retries without permanently crashing reads
- Add typed paymentEventStatus union to schema; replace dynamic dispute
  status string construction with explicit lookup map
- Document accepted 15-min Redis cache sync staleness bound
- Document webhook endpoint URL in .env.example

Frontend lifecycle:
- Add listeners.clear() to destroyEntitlementSubscription (prevents
  stale closure reload loop on destroy/re-init cycles)
- Add destroyCheckoutOverlay() to reset initialized flag so new layouts
  can register their success callbacks
- Complete PanelLayoutManager.destroy() — add teardown for checkout
  overlay, payment failure banner, and entitlement change listener
- Preserve currentState across destroy; add resetEntitlementState()
  for explicit reset (e.g. logout) without clearing on every cycle

Code quality:
- Export DEV_USER_ID and isDev from lib/auth.ts; remove duplicates
  from subscriptionHelpers.ts (single source of truth)
- Remove DODO_PAYMENTS_API_KEY fallback from billing.ts
- Document why two Dodo SDK packages coexist (component vs REST)

* fix(payments): address round-3 review findings — HMAC key separation, auth wiring, shape guards

- Introduce DODO_IDENTITY_SIGNING_SECRET separate from webhook secret (todo 087)
  Rotating the webhook secret no longer silently breaks userId identity signing
- Wire claimSubscription to Clerk sign-in in App.ts (todo 088)
  Paying anonymous users now have their entitlements auto-migrated on first sign-in
- Promote getCustomerPortalUrl to public action + wire openBillingPortal (todo 089)
  Manage Billing button now opens personalized portal instead of generic URL
- Add rate limit on claimSubscription (todo 090)
- Add webhook rawPayload shape guard before handler dispatch (todo 096)
  Malformed payloads return 200 with log instead of crashing the handler
- Remove dead exports: resetEntitlementState, customerPortal wrapper (todo 091)
- Fix let payload; implicit any in webhookHandlers.ts (todo 092)
- Fix test: use internal.* for internalMutation seedProductPlans (todo 095)

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(payments): address round-4 P1 findings — input validation, anon-id cleanup

- claimSubscription: replace broken rate-limit (bypassed for new users,
  false positives on renewals) with UUID v4 format guard + self-claim
  guard; prevents cross-user subscription theft via localStorage injection
- App.ts: always remove wm-anon-id after non-throwing claim completion
  (not only when subscriptions > 0); adds optional chaining on result.claimed;
  prevents cold Convex init on every sign-in for non-purchasers

Resolves todos 097, 098, 099, 100

* fix(payments): address round-5 review findings — regex hoist, optional chaining

- Hoist ANON_ID_REGEX to module scope (was re-allocated on every call)
- Remove /i flag — crypto.randomUUID() always produces lowercase
- result.claimed accessed directly (non-optional) — mutation return is typed
- Revert removeItem from !client || !api branch — preserve anon-id on
  infrastructure failure; .catch path handles transient errors

* fix(billing): wire setAuth, rebind watches, revoke on dispute.lost, defer JWT, bound collect

- convex-client.ts: wire client.setAuth(getClerkToken) so claimSubscription and
  getCustomerPortalUrl no longer throw 'Authentication required' in production
- clerk.ts: expose clearClerkTokenCache() for force-refresh handling
- App.ts: rebind entitlement + subscription watches to real Clerk userId on every
  sign-in (destroy + reinit), fixing stale anon-UUID watches post-claim
- subscriptionHelpers.ts: revoke entitlement to 'free' on dispute.lost + sync Redis
  cache; previously only logged a warning leaving pro access intact after chargeback
- gateway.ts: compute isTierGated before resolveSessionUserId; defer JWKS+RS256
  verification to inside if (isTierGated) — eliminates JWT work on ~136 non-gated endpoints
- billing.ts: .take(1000) on paymentEvents collect — safety bound preventing runaway
  memory on pathological anonymous sessions before sign-in

Closes P1: setAuth never wired (claimSubscription always throws in prod)
Closes P2: watch rebind, dispute.lost revocation, gateway perf, unbounded collect

* fix(security): address P1 review findings from round-6 audit

- Remove _debug block from validateApiKey that contained all valid API keys
  in envVarRaw/parsedKeys fields (latent full-key disclosure risk)
- Replace {db: any} with QueryCtx in getEntitlementsHandler (Convex type safety)
- Add pre-insert re-check in upsertEntitlements with OCC race documentation
- Fix dispute.lost handler to use eventTimestamp instead of Date.now() for
  validUntil/updatedAt (preserves isNewerEvent out-of-order replay protection)
- Extract getFeaturesForPlan("free") to const in dispute.lost (3x → 1x call)

Closes todos #103, #106, #107, #108

* fix(payments): address round-6 open items — throw on shape guard, sign-out cleanup, stale TODOs

P2-4: Webhook shape guards now throw instead of returning silently,
so Dodo retries on malformed payloads instead of losing events.

P3-2: Sign-out branch now destroys entitlement and subscription
watches for the previous userId.

P3: Removed stale TODO(clerk-auth) comments — setAuth() is wired.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(payments): address codex review — preserve listeners, honest TODOs, retry comment

- destroyEntitlementSubscription/destroySubscriptionWatch no longer clear
  listeners — PanelLayout registers them once and they must survive auth
  transitions (sign-out → sign-in would lose the premium-unlock reload)
- Restore TODO(auth) on entitlements/billing public queries — the userId
  fallback is a real trust gap, not just a cold-start race
- Add comment on webhook shape guards acknowledging Dodo retry budget
  tradeoff vs silent event loss

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(types): restore non-null assertions in convex/http.ts after merge

Main removed ! and as-any casts, but our branch's generated types
make anyApi properties possibly undefined. Re-added to fix CI typecheck.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(types): resolve TS errors from rebase — cast internal refs, remove unused imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: regenerate package-lock.json — add missing uqr@0.1.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(payments): resolve P2/P3 review findings for PR #2024

- Bound and parallelize claimSubscription reads with Promise.all (4x queries
  -> single round trip; .collect() -> .take() to cap memory)
- Add returnUrl allowlist validation in createCheckout to prevent open redirect
- Make openBillingPortal return Promise<string | null> for agent-native callers
- Extend isCallerPremium with Dodo entitlement tier check (tier >= 1 is premium,
  unifying Clerk role:pro and Dodo subscriber as two signals for the same gate)
- Call resetEntitlementState() on sign-out to prevent entitlement state leakage
  across sessions (destroyEntitlementSubscription preserves state for reconnects;
  resetEntitlementState is the explicit sign-out nullifier)
- Merge handlePaymentEvent + handleRefundEvent -> handlePaymentOrRefundEvent
  (type inferred from event prefix; eliminates duplicate resolveUserId call)
- Remove _testCheckEntitlement DI export from entitlement-check.ts; inline
  _checkEntitlementCore into checkEntitlement; tests now mock getCachedJson
- Collapse 4 duplicate dispute status tests into test.each
- Fix stale entitlement variable name in claimSubscription return value

* fix(payments): harden auth and checkout ownership

* fix(gateway): tighten auth env handling

* fix(gateway): use convex site url fallback

* fix(app): avoid redundant checkout resume

* fix(convex): cast alertRules internal refs for PR-branch generated types

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-04-03 00:25:18 +04:00

10 lines
261 B
TypeScript

import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "edge-runtime",
server: { deps: { inline: ["convex-test"] } },
include: ["convex/__tests__/**/*.test.ts", "server/__tests__/**/*.test.ts"],
},
});