Commit Graph

124 Commits

Author SHA1 Message Date
Elie Habib
fb567fd8d7 fix: simplify LNG callback; add fire-and-forget comment 2026-04-09 14:58:23 +04:00
Elie Habib
03a9d2fa82 fix(energy): decouple LNG fetch from energy panel; clear stale LNG data on error 2026-04-09 13:08:59 +04:00
Elie Habib
6a87cf9530 feat(energy): bootstrap oilStocksAnalysis hydration + LNG vulnerability UI (#2854)
* feat(energy): bootstrap oilStocksAnalysis hydration + LNG vulnerability UI (V6-1)

Register oilStocksAnalysis and lngVulnerability in BOOTSTRAP_CACHE_KEYS
(both tiers: slow). Wire getHydratedData fast-path for oil stocks analysis,
add fetchLngVulnerability() with bootstrap fallback, and render a top-5
LNG-dependent countries table in EnergyComplexPanel.

* fix(energy): multiply LNG share by 100 for display; remove bootstrap-refetch fallback

* fix(energy): use String() instead of toLocaleString() for LNG imports display
2026-04-09 13:06:24 +04:00
Elie Habib
de0ae31b0a feat(map): rename layer to Chokepoints + transit chart in popup (#2841)
* feat(map): rename layer to Chokepoints + add transit chart to popup

Rename: "Strategic Waterways" → "Chokepoints" in layer toggle and i18n.

Transit chart: clicking a chokepoint marker on the map now shows the
same stacked-bar transit history chart from the Supply Chain panel
directly inside the popup. The chart mounts after popup DOM insertion
and is destroyed on popup close. Plumbed via:
  data-loader → MapContainer.setChokepointData → DeckGLMap/Map →
  MapPopup.setChokepointData (stored); looked up by name (case-insensitive)
  when rendering waterway popup.

Only chokepoints with PortWatch history data show the chart placeholder.

* fix(map): add globe branch to setChokepointData dispatch

Greptile P1: MapContainer.setChokepointData was missing the useGlobe
branch, breaking the established three-way dispatch pattern. GlobeMap
also owns a MapPopup instance and needs chokepointData forwarded to it
so the transit chart renders correctly if globe mode gains waterway popups.
2026-04-08 22:41:11 +04:00
Elie Habib
e0dc630ed5 feat(energy): days of cover global view (Phase 4 PR B) (#2767)
* feat(energy): days of cover analysis key + EnergyComplexPanel oil stocks section

- seed-iea-oil-stocks.mjs exports buildOilStocksAnalysis and writes
  energy:oil-stocks-analysis:v1 via afterPublish hook after main index
- Rankings sorted by daysOfCover desc (net-exporters last), vsObligation,
  obligationMet, regional summaries (Europe/Asia-Pacific/North America)
- EnergyComplexPanel.setOilStocksAnalysis() renders IEA member table with
  below-obligation badges, rank, days vs 90d obligation, regional summary rows
- Health monitoring: seed-meta:energy:oil-stocks-analysis (42d maxStaleMin)
- Gateway cache tier: static (monthly seed data)
- 13 new tests covering sorting, exclusions, regional rollups, obligation logic

* feat(energy): add proto + regenerate service for oil stocks analysis RPC

- Add get_oil_stocks_analysis.proto with OilStocksAnalysisMember,
  OilStocksRegionalSummary sub-messages, and GetOilStocksAnalysisResponse
- Use proto3 optional fields for nullable int32 (daysOfCover, vsObligation,
  avgDays, minDays) avoiding google.protobuf.wrappers complexity
- Regenerate service_client.ts + service_server.ts via make generate
- Update handler fallback and panel null-safety guards for optional fields
- Regenerated OpenAPI docs include getOilStocksAnalysis endpoint

* fix(energy): preserve oil-stocks-analysis TTL via extraKeys; fix seed-meta TTL to exceed health threshold

- Move ANALYSIS_KEY into ANALYSIS_EXTRA_KEY in extraKeys so runSeed() extends
  its TTL on fetch failure or validation skip (was only written in afterPublish,
  leaving the key unprotected on the sad path)
- afterPublish now writes only the seed-meta for ANALYSIS_KEY with a 50-day TTL
  (Math.max(86400*50, TTL_SECONDS)) — exceeds the health maxStaleMin threshold
- Add optional metaTtlSeconds param to writeExtraKeyWithMeta() (backward-compat,
  defaults to existing 7-day value for all other callers)
- Update health.js oilStocksAnalysis maxStaleMin from 42d to 50d to stay below
  the new seed-meta TTL and avoid false stale/missing reports

* fix(energy): preserve seed-meta:oil-stocks-analysis TTL via extraKeys on seeder failure
2026-04-06 16:28:04 +04:00
Elie Habib
3e9556c37f feat(resilience): Phase 1 §3.2+§3.3 — full-country sanctions counts + grey-out ranking (#2763)
* feat(resilience): Phase 1 §3.2+§3.3 — full-country sanctions counts + grey-out ranking

§3.2 — Switch sanctions from top-12 to full country counts
- RESILIENCE_SANCTIONS_KEY: 'sanctions:pressure:v1' → 'sanctions:country-counts:v1'
- New key is a plain ISO2→entryCount map covering ALL countries (no top-12 truncation)
- Replaces compound pressure formula with normalizeSanctionCount() piecewise scale:
  0=100, 1-10=90-75, 11-50=75-50, 51-200=50-25, 201+=25→0
- IMPUTE.ofacSanctions removed (country-counts covers all countries; no absent-country
  imputation needed for sanctions)

§3.3 — Grey-out criteria for ranking
- Proto: ResilienceRankingItem.overall_coverage (field 5) + GetResilienceRankingResponse.greyed_out
- GREY_OUT_COVERAGE_THRESHOLD = 0.40: countries below this are excluded from ranking
  but still appear on choropleth in "insufficient data" style
- buildRankingItem() now computes overallCoverage from domain/dimension data
- getResilienceRanking() splits items into ranked (≥0.40) + greyedOut (<0.40)

Tests updated for new sanctions format; overall score anchor updated (67.56).

* fix(resilience): fix ranking cache guard for all-greyed-out + stale shape cases

Two cache bugs:

1. Empty-items guard: `cached?.items?.length` fails when every country falls
   below GREY_OUT_COVERAGE_THRESHOLD (items=[], greyedOut=[…]). The cache was
   written correctly but never served, causing unnecessary rewarming on every
   request for sparse-data deployments.
   Fix: `cached != null && (items.length > 0 || greyedOut.length > 0)`

2. Stale-shape test: agent-written cache test stored a payload without
   `greyedOut` or `overallCoverage`, locking in pre-PR shape. Updated to the
   correct post-deploy shape so the test reflects actual cached content.

Cache key was already bumped to resilience:ranking:v2 (forces fresh compute
on first post-deploy request, avoiding old-shape responses in production).

* fix(resilience): consume greyedOut on choropleth; version ranking cache key

- Add 'insufficient_data' level to ResilienceChoroplethLevel and RESILIENCE_CHOROPLETH_COLORS
- Extend buildResilienceChoroplethMap to accept optional greyedOut array
- Thread greyedOut through DeckGLMap.setResilienceRanking, MapContainer.setResilienceRanking (with replay), and data-loader.ts
- Add 'Insufficient data' tooltip guard for greyed-out countries in DeckGLMap
- Bump RESILIENCE_RANKING_CACHE_KEY to resilience:ranking:v2 to invalidate stale schema-mismatched cache entries
- Update api/health.js probe key to match

* fix(resilience): include greyedOut in seed-meta count to avoid false health alert

seed-meta:resilience:ranking was written with count=response.items.length,
which excludes greyedOut countries. In an all-greyed-out deployment, count=0
causes api/health.js to report the ranking as EMPTY_DATA/critical even though
the cached payload is valid (items:[], greyedOut:[…]).

Fix: count = items.length + greyedOut.length — total scoured countries
regardless of ranking eligibility.

* test(resilience): pin all-greyed-out cache-hit regression

Adds the missing test case: cached payload with items=[] and greyedOut=[…]
must be served from cache without triggering score rewarming.

Previously, `cached?.items?.length` was falsy for this shape, making the
guard ineffective. The fix (items.length > 0 || greyedOut.length > 0) was
correct but unpinned — this test locks it in.
2026-04-06 14:19:16 +04:00
Lucas Passos
a3278e0bb0 feat(resilience): add choropleth map layer (#2666)
* feat(resilience): add choropleth map layer

Rebased replacement for #2666, based on main. Extracts only the map
layer work, excludes unrelated ocean-ice climate service (~1000 lines)
and duplicate proto/seeder/scoring code from the stacked chain.

- Add resilienceScore to MapLayers, URL state, panel defaults, variant
  defaults, and test harness defaults
- Add resilience-choropleth-utils.ts: normalize ranking rows, map
  scores to five-level color scale, mutual exclusion with ciiChoropleth
- Render DeckGL country choropleth with tooltip and legend rows
- Load getResilienceRanking() through data-loader.ts, cache/rehydrate
  in MapContainer.ts
- Clear stale resilience data when premium access is unavailable

Tests: 8 new tests (color scale, data normalization, choropleth
exclusivity), all pass.

Refs #2488

Co-authored-by: Lucas Passos <lspassos1@users.noreply.github.com>

* fix(resilience): clarify choropleth exclusion, remove redundant filter

- Document CII-wins fallback in normalizeExclusiveChoropleths with
  explicit branch for each "just enabled" case and a comment for the
  both-new fallback (bookmark restore)
- Remove redundant pre-filter in data-loader that duplicated
  buildResilienceChoroplethMap's validation

* fix(resilience): use server-provided level for tooltip labels

The 5-band client-side level (very_low to very_high at 20/40/60/80)
was overriding the server's 3-band level (low/medium/high at 40/70)
in the tooltip. A country at 65 showed "High" on the map but the
API contract says "medium".

Fix: keep 5-band colors for visual rendering (getResilienceChoroplethLevel),
use server-provided level for tooltip text (serverLevel). Store both
in ResilienceChoroplethEntry.

* fix(resilience): normalize choropleths at app state level, fix layer readiness

- Apply normalizeExclusiveChoropleths at all hydration points (App.ts
  storage/URL restore, panel-layout.ts setLayers) so app state and
  renderer stay consistent. Previously only DeckGL normalized, leaving
  ctx.mapLayers inconsistent on bookmark restore.
- Use buildResilienceChoroplethMap().size for layer readiness instead
  of raw items.length, so placeholder rows with overallScore: -1 don't
  mark the layer as ready while nothing renders.

* fix(resilience): skip ranking fetch when DeckGL is not active

Expose isDeckGLActive() on MapContainer and guard loadResilienceRanking
so mobile/SVG sessions with resilienceScore in URL state don't make
unnecessary premium API calls for a layer that can't render.

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
Co-authored-by: Lucas Passos <lspassos1@users.noreply.github.com>
2026-04-04 17:40:37 +04:00
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
Elie Habib
6b21566338 feat(news): composite importance score (E1) (#2621)
* feat(news): composite importance score (E1)

Adds a 0-100 importance score to every digest NewsItem, computed from four
weighted signals:

  severity    x 0.40  (critical=100, high=75, medium=50, low=25, info=0)
  source tier x 0.20  (tier 1 wire services score highest)
  corroboration x 0.25  (unique sources covering the same story in this cycle)
  recency     x 0.15  (linear decay to 0 over 30 min)

Key changes:
- proto: add importance_score (9) + corroboration_count (10) to NewsItem
- server/_shared/source-tiers.ts: new shared source tier lookup for server code
- list-feed-digest.ts: corroboration map before truncation, sort by importanceScore
- _classifier.ts: export SEVERITY_VALUES
- notification-relay.cjs: score gate behind IMPORTANCE_SCORE_LIVE env flag;
  shadow log to shadow:score-log:v1 (7-day window, always runs)
- ais-relay.cjs: include pubDate + importanceScore in rss_alert payloads

IMPORTANT: IMPORTANCE_SCORE_LIVE=1 must NOT be set until scores are validated
in shadow:score-log:v1 over several days.

* fix(news-alerts): add relay gates, importance score threshold, and RELAY_GATES_READY guard

- ais-relay.cjs: add RELAY_GATES_READY gate that skips tier-4 sources and
  stale items (>15min) when relay takes over external notifications
- breaking-news-alerts.ts: add RELAY_GATES_READY constant (VITE_ prefix for
  browser) and IMPORTANCE_SCORE_MIN=30 threshold; suppress /api/notify when
  relay is active; skip items below importance threshold in selectBest;
  propagate importanceScore into BreakingAlert for downstream logging

Fixes gap A (relay tier/recency gate) and gap B (RELAY_GATES_READY guard)
from docs/plans/2026-04-02-003-fix-news-alerts-pr-gaps-plan.md

* fix(relay): remove duplicate shadowLogScore from rebase artifact

Biome noRedeclare: two shadowLogScore definitions landed in
notification-relay.cjs after rebase. Removed the older version
(threshold=65, JSON blob) in favour of the newer E1 version
(SHADOW_SCORE_LOG_KEY constant, timestamped member encoding).
2026-04-02 21:35:20 +04:00
Elie Habib
6c017998d3 feat(e3): story persistence tracking (#2620)
* feat(e3): story persistence tracking

Adds cross-cycle story tracking layer to the RSS digest pipeline:

- Proto: StoryMeta message + StoryPhase enum on NewsItem (fields 9-11).
  importanceScore and corroborationCount stubs added for E1.
- list-feed-digest.ts: builds corroboration map across ALL items before
  truncation; batch-reads existing story:track hashes from Redis; writes
  HINCRBY/HSET/HSETNX/SADD/EXPIRE per story in 80-story pipeline chunks;
  attaches StoryMeta (firstSeen, mentionCount, sourceCount, phase) to
  each proto item using read-back data.
- cache-keys.ts: STORY_TRACK_KEY_PREFIX, STORY_SOURCES_KEY_PREFIX,
  DIGEST_ACCUMULATOR_KEY_PREFIX, STORY_TRACKING_TTL_S.
- src/types/index.ts: StoryMeta, StoryPhase, NewsItem extended.
- data-loader.ts: protoItemToNewsItem maps STORY_PHASE_* → client phase.
- NewsPanel.ts: BREAKING/DEVELOPING/ONGOING phase badges in item rows.

New story first appearance: phase=BREAKING. After 2 mentions within 2h:
DEVELOPING. After 6+ mentions or >2h: SUSTAINED. If score drops below
50% of peak: FADING (used by E1; defaults to SUSTAINED for now).

Redis keys per story (48h TTL):
  story:track:v1:<hash16>   → hash (firstSeen,lastSeen,mentionCount,...)
  story:sources:v1:<hash16> → set  (feed names, for cross-source count)

* fix(e3): correct storyMeta staleness and mentionCount semantics

P1 — storyMeta was always one cycle behind because storyTracks was read
before writeStoryTracking ran. Fix: keep read-before-write but compute
storyMeta from merged in-memory state (stale.mentionCount + 1, fresh
sourceCount from corroborationMap). New stories get mentionCount=1 and
phase=BREAKING in the same cycle they first appear — no extra Redis
round-trip needed.

P2 — mentionCount incremented once per item occurrence, so a story seen
in 3 sources in its first cycle was immediately stored as mentionCount=3.
Fix: deduplicate by titleHash in writeStoryTracking so each unique story
gets exactly one HINCRBY per digest cycle regardless of source count.
SADD still collects all sources for the set key.

* fix(e3): Unicode hash collision, ALERT badge regression, FADING comment

P1 — normalizeTitle used [^\w\s] without the u flag; \w is ASCII-only
so every Arabic/CJK/Cyrillic title stripped to "" and shared one Redis
hash. Fixed: use /[^\p{L}\p{N}\s]/gu (Unicode property escapes require
the u flag).

P1 — ALERT badge was gated on !item.storyMeta, suppressing the indicator
for any tracked story regardless of isAlert. Phase and alert are
orthogonal signals; ALERT now renders unconditionally when isAlert=true.

P2 — FADING branch is intentionally inactive until E1 ships real scores
(currentScore/peakScore placeholder 0 via HSETNX). Added comment to
document the intentional ordering.

* fix(news-alerts): skip sustained/fading stories in breaking alert selectBest

Sustained and fading story phases are already well-covered by the feed;
only breaking and developing phases warrant a banner interrupt. Items
without storyMeta (phase unspecified) pass through unchanged.

Fixes gap C from docs/plans/2026-04-02-003-fix-news-alerts-pr-gaps-plan.md

* fix(e3): remove rebase artifacts from list-feed-digest

Removes a stray closing brace, duplicate ASCII normalizeTitle
(Unicode-aware version from the fix commit is correct), and
a leftover storyPhase assignment that references a removed field.

All typecheck and typecheck:api pass clean.
2026-04-02 21:16:35 +04:00
Elie Habib
e7bfd4cf5a refactor(economic): merge stress index into EconomicPanel tab, fold compute into seed-economy (#2499) 2026-03-29 13:49:41 +04:00
Elie Habib
aa3e84f0ab feat(economic): Economic Stress Composite Index panel (FRED 6-series, 0-100 score) (#2461)
* feat(economic): Economic Stress Composite Index panel (FRED 6-series, 0-100 score)

- Add T10Y3M and STLFSI4 to FRED_SERIES in seed-economy.mjs and ALLOWED_SERIES
- Create proto message GetEconomicStressResponse with EconomicStressComponent
- Register GetEconomicStress RPC in EconomicService (GET /api/economic/v1/get-economic-stress)
- Add seed-economic-stress.mjs: reads 6 pre-seeded FRED keys via Redis pipeline, computes weighted composite score (0-100) with labels Low/Moderate/Elevated/Severe/Critical
- Create server handler get-economic-stress.ts reading from economic:stress-index:v1
- Register economicStress in BOOTSTRAP_CACHE_KEYS (both cache-keys.ts and api/bootstrap.js) as slow tier
- Add gateway.ts cache tier entry (slow) for new RPC route
- Create EconomicStressPanel.ts: composite score header, gradient needle bar, 2x3 component grid with score bars, desktop notification on threshold cross (>=70, >=85)
- Wire economic-stress panel in panels.ts (all 4 variants), panel-layout.ts, and data-loader.ts
- Regenerate OpenAPI docs and TypeScript client/server types

* fix(economic-stress): null for missing FRED data + tech variant panel

- Add 'economic-stress' panel to TECH_PANELS defaults (was missing, only appeared in full/finance/commodity variants)
- Seed: write rawValue: null + missing: true when no valid FRED observation found, preventing zero-valued yield curve/bank spread readings from being conflated with missing data
- Proto: add missing bool field to EconomicStressComponent message; regenerate client/server types + OpenAPI docs
- Server handler: propagate missing flag from Redis; pass rawValue: 0 on wire when missing to satisfy proto double type
- Panel: guard on c.missing (not rawValue === 0) to show grey N/A card with no score bar for unavailable components

* fix(economic-stress): add purple Critical zone to gradient bar

Update gradient stops to match the 5 equal tier boundaries (0-20-40-60-80-100),
adding the #8e44ad purple stop at 80% so scores 80-100 render as Critical purple
instead of plain red.
2026-03-29 11:19:35 +04:00
Elie Habib
ba54dc12d7 feat(commodity): gold layer enhancements (#2464)
* feat(commodity): add gold layer enhancements from fork review

Enrich the commodity variant with learnings from Yazan-Abuawwad/gold-monitor fork:

- Add 10 missing gold mines to MINING_SITES: Muruntau (world's largest
  open-pit gold mine), Kibali (DRC), Sukhoi Log (Russia, development),
  Ahafo (Ghana), Loulo-Gounkoto (Mali), South Deep (SA), Kumtor
  (Kyrgyzstan), Yanacocha (Peru), Cerro Negro (Argentina), Tropicana
  (Australia). Covers ~40% of top-20 global mines previously absent.

- Add XAUUSD=X spot gold and 9 FX pairs (EUR, GBP, JPY, CNY, INR, AUD,
  CHF, CAD, TRY) to shared/commodities.json. All =X symbols auto-seeded
  via existing seedCommodityQuotes() — no new seeder needed. Registered
  in YAHOO_ONLY_SYMBOLS in both _shared.ts and ais-relay.cjs.

- Add XAU/FX tab to CommoditiesPanel showing gold priced in 10 currencies.
  Computed live from GC=F * FX rates. Commodity variant only.

- Fix InsightsPanel brief title: commodity variant now shows
  "⛏️ COMMODITY BRIEF" instead of "🌍 WORLD BRIEF".

- Route commodity variant daily market brief to commodity feed categories
  (commodity-news, gold-silver, mining-news, energy, critical-minerals)
  via new newsCategories option on BuildDailyMarketBriefOptions.

- Add Gold Silver Worlds + FX Empire Gold direct RSS feeds to gold-silver
  panel (9 sources total, up from 7).

* fix(commodity): address review findings from PR #2464

- Fix USDCHF=X multiply direction: was true (wrong), now false (USD/CHF is USD-per-CHF convention)
- Fix newsCategories augments BRIEF_NEWS_CATEGORIES instead of replacing (preserves macro/Fed context in commodity brief)
- Add goldsilverworlds.com + www.fxempire.com to RSS allowlist (api + shared + scripts/shared)
- Rename "Metals" tab label conditionally: commodity variant gets "Metals", others keep "Commodities"
- Reset _tab to "commodities" when hasXau becomes false (prevent stale XAU tab re-activation)
- Add Number.isFinite() guard in _renderXau() before computing xauPrice
- Narrow fxMap filter to =X symbols only
- Collapse redundant two-branch number formatter to Math.round().toLocaleString()
- Remove XAUUSD=X from shared/commodities.json: seeded but never displayed (saves 150ms/cycle)

* feat(mcp): add get_commodity_geo tool and update get_market_data description

* fix(commodity): correct USDCHF direction, replace headline categories, restore dep overrides

* fix(commodity): empty XAU grid fallback and restore FRED timeout to 20s

* fix(commodity): remove XAU/USD from MCP description, revert Metals tab label

* fix(commodity): remove dead XAUUSD=X from YAHOO_ONLY_SYMBOLS

XAU widget uses GC=F as base price, not XAUUSD=X. Symbol was never
seeded (not in commodities.json) and never referenced in the UI.
2026-03-29 11:13:40 +04:00
Elie Habib
564c252d48 feat(panels): move FrameworkSelector to AI Market Implications panel (#2394)
* feat(panels): move FrameworkSelector from CountryDeepDivePanel to MarketImplicationsPanel

CountryDeepDivePanel was the only panel where the framework selector
wasn't connected to the AI generation path (the country-intel.ts service
reads the framework independently via getActiveFrameworkForPanel).
MarketImplicationsPanel is a better home for it.

Changes:
- Remove FrameworkSelector + hasPremiumAccess from CountryDeepDivePanel
- Add FrameworkSelector (panelId: 'market-implications') to MarketImplicationsPanel
  with note "Applies to next AI regeneration"
- Add 'market-implications' to AnalysisPanelId union type
- fetchMarketImplications() now accepts optional framework param and passes
  it as ?framework= query string to the API
- data-loader subscribes to 'market-implications' framework changes and
  re-triggers loadMarketImplications(), passing the active framework

* fix(market-implications): cache per-framework to prevent N×1 API calls

Previously: any active framework bypassed the client-side TTL cache
entirely, so 100 users with the same framework = 100 separate API calls
per 10-minute window.

Fix:
- Client: replace single-entry cache with Map<frameworkId, {data, cachedAt}>
  so each framework variant is cached separately for 10 min
- API: pass ?frameworkId= (stable ID, not the full systemPromptAppend text)
- Server: reads intelligence:market-implications:v1:{frameworkId} when a
  known framework ID is present; falls back to the default key if the
  framework-specific key doesn't exist yet
- Proto: add framework_id field (sebuf.http.query) to ListMarketImplicationsRequest

With this, 100 users sharing the same framework hit the server once per
10 min per unique frameworkId — the server's framework-keyed Redis entry
is shared across all of them.

* chore(proto): regenerate OpenAPI spec after ListMarketImplicationsRequest frameworkId field
2026-03-28 02:08:33 +04:00
Elie Habib
ed05569b79 feat(panels): wire GeoHubsPanel, TechHubsPanel, and RegulationPanel (#2392)
These three panels were fully implemented (component, i18n, services) but
never registered or wired up, making them completely unreachable.

- GeoHubsPanel ('geo-hubs', "Geopolitical Hubs"): lazyPanel + feeds from
  getTopActiveGeoHubs(latestClusters) after each news clustering pass
- TechHubsPanel ('tech-hubs', "Hot Tech Hubs"): same cluster-driven wiring
- RegulationPanel ('ai-regulation', "AI Regulation Dashboard"): lazyPanel
  using static AI_REGULATIONS config data (no async fetch needed)

Also adds 'geo-hubs'/'tech-hubs' to FULL_PANELS and 'tech-hubs'/'ai-regulation'
to TECH_PANELS (all disabled by default, priority 2).
2026-03-28 01:58:42 +04:00
Elie Habib
1f56afeb82 feat(panels): disease outbreaks panel/layer, social velocity panel, shipping stress tab (#2383)
* feat(panels): disease outbreaks panel/layer, social velocity panel, shipping stress tab

- DiseaseOutbreaksPanel: feed-style panel with alert/warning/watch filter pills, source links, relative timestamps (WHO/ProMED/HealthMap)
- SocialVelocityPanel: ranked Reddit trending posts by velocity score with subreddit badge, vote/comment counts, velocity bar
- SupplyChainPanel: Stress tab with composite stress gauge and carrier table with sparklines (GetShippingStressResponse)
- diseaseOutbreaks map layer: ScatterplotLayer via country centroids, color/radius by alert level, tooltip
- MapContainer.setDiseaseOutbreaks(): cached setter with DeckGLMap delegation
- data-loader: loadDiseaseOutbreaks/loadSocialVelocity/loadSupplyChain with stress wired into tasks
- MapLayers.diseaseOutbreaks added to types, layer registry (globe icon), full variant order, all default objects

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.com/claude-code) + Compound Engineering v2.49.0

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

* fix(supply-chain): add upstreamUnavailable to ShippingStressResponse, restore test-compatible banner guard

* fix(panels): filter pills use alertLevel equality, sanitizeUrl on hrefs, globe TODO, E2E layer enabled

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:52:59 +04:00
Elie Habib
110ab402c4 feat(intelligence): analytical framework selector for AI panels (#2380)
* feat(frameworks): add settings section and import modal

- Add Analysis Frameworks group to preferences-content.ts between Intelligence and Media sections
- Per-panel active framework display (read-only, 4 panels)
- Skill library list with built-in badge, Rename and Delete actions for imported frameworks
- Import modal with two tabs: From agentskills.io (fetch + preview) and Paste JSON
- All error cases handled inline: network, domain validation, missing instructions, invalid JSON, duplicate name, instructions too long, rate limit
- Add api/skills/fetch-agentskills.ts edge function (proxy to agentskills.io)
- Add analysis-framework-store.ts (loadFrameworkLibrary, saveImportedFramework, deleteImportedFramework, renameImportedFramework, getActiveFrameworkForPanel)
- Add fw-* CSS classes to main.css matching dark panel aesthetic

* feat(panels): wire analytical framework store into InsightsPanel, CountryDeepDive, DailyMarketBrief, DeductionPanel

- InsightsPanel: append active framework to geoContext in updateFromClient(); subscribe in constructor, unsubscribe in destroy()
- CountryIntelManager: pass framework as query param to fetchCountryIntelBrief(); subscribe to re-open brief on framework change; unsubscribe in destroy()
- DataLoaderManager: add dailyBriefGeneration counter for stale-result guard; pass frameworkAppend to buildDailyMarketBrief(); subscribe to framework changes to force refresh; unsubscribe in destroy()
- daily-market-brief service: add frameworkAppend? field to BuildDailyMarketBriefOptions; append to extendedContext before summarize call
- DeductionPanel: append active framework to geoContext in handleSubmit() before RPC call

* feat(frameworks): add FrameworkSelector UI component

- Create FrameworkSelector component with premium/locked states
- Premium: select dropdown with all framework options, change triggers setActiveFrameworkForPanel
- Locked: disabled select + PRO badge, click calls showGatedCta(FREE_TIER)
- InsightsPanel: adds asterisk note (client-generated analysis hint)
- Wire into InsightsPanel, DailyMarketBriefPanel, DeductionPanel (via this.header)
- Wire into CountryDeepDivePanel header right-side (no Panel base, panel=null)
- Add framework-selector CSS to main.css

* fix(frameworks): make new proto fields optional in generated types

* fix(frameworks): extract firstMsg to satisfy strict null checks in tsconfig.api.json

* fix(docs): add blank lines around lists/headings to pass markdownlint

* fix(frameworks): add required proto string fields to call sites after make generate

* chore(review): add code review todos 041-057 for PR #2380

7 review agents (TypeScript, Security, Architecture, Performance,
Simplicity, Agent-Native, Learnings) identified 17 findings across
5 P1, 8 P2, and 4 P3 categories.
2026-03-27 23:36:44 +04:00
Elie Habib
e3b863d30f feat(panels): EU data tabs for Energy, Macro, Yield, Commodities panels (#2355)
* feat(panels): add EU data tabs to EnergyComplex, MacroTiles, YieldCurve, Commodities

- EnergyComplexPanel: US Nat Gas Storage (EIA) + EU Gas Storage (GIE AGSI+) sections below crude
- MacroTilesPanel: US/EU tab toggle; EU shows avg HICP/unemployment/GDP for DE/FR/IT/ES + ECB ESTR
- YieldCurvePanel: Curve/ECB Rates tab; Rates shows ESTR + EURIBOR 3M/6M/1Y sparklines via FRED
- CommoditiesPanel: Commodities/EUR FX tab; FX shows ECB EUR pairs (USD/GBP/JPY/CHF/CAD/CNY/AUD)
- Add getEuGasStorageData() + getEurostatCountryData() service functions with circuit breakers
- Register eurostatCountryData in bootstrap.js + cache-keys.ts (SLOW tier)
- Wire fetchNatGasStorageRpc, getEuGasStorageData, getEcbFxRatesData in data-loader.loadOilAnalytics

* fix(commodities-panel): always re-render on data update so FX tab bar appears
2026-03-27 12:23:34 +04:00
Elie Habib
f3b0280227 feat(economic): EIA weekly crude oil inventory seeder (#2142) (#2168)
* feat(economic): EIA weekly crude oil inventory seeder (#2142)

- scripts/seed-economy.mjs: add fetchCrudeInventories() fetching WCRSTUS1, compute weeklyChangeMb, write economic:crude-inventories:v1 (10-day TTL)
- proto/worldmonitor/economic/v1/get_crude_inventories.proto: new proto with CrudeInventoryWeek and GetCrudeInventories RPC
- server/worldmonitor/economic/v1/get-crude-inventories.ts: RPC handler reading seeded key with getCachedJson(..., true)
- server/worldmonitor/economic/v1/handler.ts: wire in getCrudeInventories
- server/gateway.ts: add static cache tier for /api/economic/v1/get-crude-inventories
- api/health.js: crudeInventories in BOOTSTRAP_KEYS + SEED_META (maxStaleMin: 20160, 2x weekly cadence)
- src/services/economic/index.ts: add fetchCrudeInventoriesRpc() with circuit breaker
- src/components/EnergyComplexPanel.ts: surface 8-week sparkline and WoW change in energy panel
- src/app/data-loader.ts: call fetchCrudeInventoriesRpc() in loadOilAnalytics()

* fix: remove stray market-implications gateway entry from crude-inventories branch

* fix(crude-inventories): address ce-review P1/P2 findings before merge

- api/bootstrap.js: register crudeInventories in BOOTSTRAP_CACHE_KEYS + SLOW_KEYS (P1-001)
- server/_shared/cache-keys.ts: add crudeInventories key + tier to match bootstrap.js
- api/health.js: remove bundled marketImplications (belongs in separate PR) (P1-002)
- src/services/economic/index.ts: add isFeatureAvailable('energyEia') gate (P2-003)
- src/services/economic/index.ts: use getHydratedData('crudeInventories') on first load
- proto/get_crude_inventories.proto: weekly_change_mb → optional double (P2-004)
- scripts/seed-economy.mjs: CRUDE_INVENTORIES_TTL 10d → 21d (3× cadence) (P2-005)
- scripts/seed-economy.mjs: period format validation with YYYY-MM-DD regex (P3-007)
- src/app/data-loader.ts: warn on crude fetch rejection (P2-006)

* fix(crude-inventories): schema validation, MIN_ITEMS gate, handler logging, raw=true docs

- Handler: document raw=true param, log errors instead of silent catch
- Seeder: CRUDE_MIN_WEEKS=4 guard prevents quota-hit empty writes
- Seeder: isValidWeek() schema validation before Redis write

* chore: regenerate openapi docs after rebase (adds getCrudeInventories + getEconomicCalendar)

* fix(gateway): add list-market-implications to RPC_CACHE_TIER

* chore: exclude todos/ from markdownlint
2026-03-27 09:42:26 +04:00
Elie Habib
290f24fb77 fix(heatmap): derive sectorBars from sectors data to fix missing bar chart (#2344)
getHydratedData() is a one-shot read that deletes the cache entry after reading.
FearGreedPanel.fetchData() and loadMarkets() both ran concurrently and both called
getHydratedData('fearGreedIndex'). FearGreedPanel always won the race (reads
synchronously at function start), leaving loadMarkets() with undefined — so
sectorBars was always undefined and the bar chart never rendered.

Fix: derive sectorBars from the already-available sectors data (sectors[].change
equals sectorPerformance.change1d). No extra RPC call, no race condition, no side
effect on FearGreedPanel hydration.
2026-03-27 09:33:56 +04:00
Elie Habib
d80736d1ba feat(heatmap): add sorted bar chart view to sector HeatmapPanel (#2326)
* feat(heatmap): add sorted bar chart view to HeatmapPanel (#2246)

- Add FearGreedSectorPerformance message to proto + regenerate
- Expose sectorPerformance array in GetFearGreedIndexResponse RPC handler
- HeatmapPanel.renderHeatmap accepts optional sectorBars; renders
  sorted horizontal bar chart below existing tile grid
- Sectors ranked by day change (gainers first, losers last)
- Green/red bars proportional to |change| (max ~3% = 100% width)
- Reuses fearGreedIndex bootstrap data — zero extra network calls
- Add .heatmap-bar-chart CSS: 18px rows, 10px font, compact layout

* fix(heatmap): address code review P1 findings on PR #2326

- Server: guard change1d with Number.isFinite fallback so non-numeric
  Redis values (NaN) coerce to 0 instead of propagating through
- Client: filter out any NaN change1d entries before computing maxAbs;
  Math.max propagates NaN so a single bad entry makes all bars invisible
  (width:NaN%); also bail early if filtered list is empty

* fix(heatmap): address Greptile review comments on PR #2326

Use var(--green)/var(--red) for bar fill colour to match text label
CSS variables and avoid visual mismatch across themes.
2026-03-27 09:13:51 +04:00
Elie Habib
0245ed53a9 fix(auth): consolidate premium access into hasPremiumAccess() — fix PRO panel gating (#2299)
* fix(auth): consolidate premium access checks into hasPremiumAccess()

Single source of truth for all premium access paths: desktop API key,
wm-pro-key / wm-widget-key tester keys, and Clerk Pro role.

- Export hasPremiumAccess() from panel-gating.ts (covers all 3 paths)
- Remove duplicate local hasPremiumAccess() from data-loader.ts
- Add market-implications to WEB_PREMIUM_PANELS so it gets auth gating
- Fix lazy panel race: call updatePanelGating() inside lazyPanel() so
  daily-market-brief and market-implications aren't stuck loading
- Remove stale getSecretState / isProUser imports from panel-layout.ts
- Update static-analysis tests to assert hasPremiumAccess instead of isProUser

* fix(auth): pass getAuthState() to initial applyProBlockGating call

Prevents a flash of incorrect state for Clerk Pro users on first paint
before the auth subscription fires.
2026-03-26 19:28:13 +04:00
Elie Habib
a969a9e3a3 feat(auth): integrate clerk.dev (#1812)
* feat(auth): integrate better-auth with @better-auth/infra dash plugin

Wire up better-auth server config with the dash() plugin from
@better-auth/infra, and the matching sentinelClient() on the
client side. Adds BETTER_AUTH_API_KEY to .env.example.

* feat(auth): swap @better-auth/infra for @convex-dev/better-auth

[10-01 task 1] Install @convex-dev/better-auth@0.11.2, remove
@better-auth/infra, delete old server/auth.ts skeleton, rewrite
auth-client.ts to use crossDomainClient + convexClient plugins.

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

* feat(auth): create Convex auth component files

[10-01 task 2] Add convex.config.ts (register betterAuth component),
auth.config.ts (JWT/JWKS provider), auth.ts (better-auth server with
Convex adapter, crossDomain + convex plugins), http.ts (mount auth
routes with CORS). Uses better-auth/minimal for lighter bundle.

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

* feat(auth): add admin, organization, and dash plugins

[10-01] Re-install @better-auth/infra for dash() plugin to enable
dash.better-auth.com admin dashboard. Add admin() and organization()
plugins from better-auth/plugins for user and org management.
Update both server (convex/auth.ts) and client (auth-client.ts).

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

* fix(auth): drop @better-auth/infra (Node.js deps incompatible with Convex V8)

Keep admin() and organization() from better-auth/plugins (V8-safe).
@better-auth/infra's dash() transitively imports SAML/SSO with
node:crypto, fs, zlib — can't run in Convex's serverless runtime.
Dashboard features available via admin plugin endpoints instead.

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

* feat(11-01): create auth-state.ts with OTT handler and session subscription

- Add initAuthState() for OAuth one-time token verification on page load
- Add subscribeAuthState() reactive wrapper around useSession nanostore atom
- Add getAuthState() synchronous snapshot getter
- Export AuthUser and AuthSession types for UI consumption

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

* feat(11-01): add Google OAuth provider and wire initAuthState into App.ts

- Add socialProviders.google with GOOGLE_CLIENT_ID/SECRET to convex/auth.ts
- Add all variant subdomains to trustedOrigins for cross-subdomain CORS
- Call initAuthState() in App.init() before panelLayout.init()
- Add authModal field to AppContext interface (prepares for Plan 02)
- Add authModal: null to App constructor state initialization

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

* feat(11-02): create AuthModal with Sign In/Sign Up tabs and Google OAuth

- Sign In tab: email/password form calling authClient.signIn.email()
- Sign Up tab: name/email/password form calling authClient.signUp.email()
- Google OAuth button calling authClient.signIn.social({ provider: 'google', callbackURL: '/' })
- Auto-close on successful auth via subscribeAuthState() subscription
- Escape key, overlay click, and X button close the modal
- Loading states, error display, and client-side validation

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

* feat(11-02): add AuthHeaderWidget, mount in header, add auth CSS

- AuthHeaderWidget: reactive header widget showing Sign In button (anonymous) or avatar + dropdown (authenticated)
- User dropdown: name, email, Free tier badge, Sign Out button calling authClient.signOut()
- setupAuthWidget() in EventHandlerManager creates modal + widget, mounts at authWidgetMount span
- authWidgetMount added to panel-layout.ts header-right, positioned before download wrapper
- setupAuthWidget() called from App.ts after setupUnifiedSettings()
- Full auth CSS: modal styles, tabs, forms, Google button, header widget, avatar, dropdown

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

* fix(11-02): add localhost:3000 to trustedOrigins for local dev CORS

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

* fix(auth): remove admin/organization plugins that break Convex adapter validator

The admin() plugin adds banned/role fields to user creation data, but the
@convex-dev/better-auth adapter validator doesn't include them. These plugins
are Phase 12 work — will re-add with additionalFields config when needed.

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

* feat(12-01): add Resend email transport, verification + reset callbacks, role field

- Install resend SDK for transactional email
- Add emailVerification with sendOnSignUp:true and fire-and-forget Resend callbacks
- Add sendResetPassword callback with 1-hour token expiry
- Add user.additionalFields.role (free/pro, input:false, defaultValue:free)
- Create userRoles fallback table in schema with by_userId index
- Create getUserRole query and setUserRole mutation in convex/userRoles.ts
- Lazy-init Resend client to avoid Convex module analysis error

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

* feat(12-01): enhance auth-state with emailVerified and role fields

- Add emailVerified (boolean) and role ('free' | 'pro') to AuthUser interface
- Fetch role from Convex userRoles table via HTTP query after session hydration
- Cache role per userId to avoid redundant fetches
- Re-notify subscribers asynchronously when role is fetched for a new user
- Map emailVerified from core better-auth user field (default false)
- Derive Convex cloud URL from VITE_CONVEX_SITE_URL env var

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

* chore(12-01): add Convex generated files from deployment

- Track convex/_generated/ files produced by npx convex dev --once

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

* feat(12-03): create panel-gating service with auth-aware showGatedCta

- Add PanelGateReason enum (NONE/ANONYMOUS/UNVERIFIED/FREE_TIER)
- Add getPanelGateReason() computing gating from AuthSession + premium flag
- Add Panel.showGatedCta() rendering auth-aware CTA overlays
- Add Panel.unlockPanel() to reverse locked state
- Extract lockSvg to module-level const shared by showLocked/showGatedCta
- Add i18n keys: signInToUnlock, signIn, verifyEmailToUnlock, resendVerification, upgradeDesc, upgradeToPro

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

* feat(12-02): add forgot password flow, password reset form, and token detection

- Widen authModal interface in app-context.ts to support reset-password mode and setResetToken
- AuthModal refactored with 4 views: signin, signup, forgot-password, reset-password
- Forgot password view sends reset email via authClient.requestPasswordReset
- Reset password form validates matching passwords and calls authClient.resetPassword
- auth-state.ts detects ?token= param from email links, stores as pendingResetToken
- App.ts routes pending reset token to auth modal after UI initialization
- CSS for forgot-link, back-link, and success message elements

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

* feat(12-02): add email verification banner to AuthHeaderWidget and tier badge

- Show non-blocking verification banner below header for unverified users
- Banner has "Resend" button calling authClient.sendVerificationEmail
- Banner is dismissible (stored in sessionStorage, reappears next session)
- Tier badge dynamically shows Free/Pro based on user.role
- Pro badge has gradient styling distinct from Free badge
- Dropdown shows unverified status indicator with yellow dot
- Banner uses fixed positioning, does not push content down
- CSS for banner, pro badge, and verification status indicators

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

* feat(12-03): wire reactive auth-based gating into panel-layout

- Add WEB_PREMIUM_PANELS Set (stock-analysis, stock-backtest, daily-market-brief)
- Subscribe to auth state changes in PanelLayoutManager.init()
- Add updatePanelGating() iterating panels with getPanelGateReason()
- Add getGateAction() returning CTA callbacks per gate reason
- Remove inline showLocked() calls for web premium panels
- Preserve desktop _lockPanels for forecast, oref-sirens, telegram-intel
- Clean up auth subscription in destroy()

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

* feat(13-01): create auth-token utility and inject Bearer header in web fetch redirect

- Add src/services/auth-token.ts with getSessionBearerToken() that reads session token from localStorage
- Add WEB_PREMIUM_API_PATHS Set for the 4 premium market API paths
- Inject Authorization: Bearer header in installWebApiRedirect() for premium paths when session exists
- Desktop installRuntimeFetchPatch() left unchanged (API key only)

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

* feat(13-01): create server-side session validation module

- Add server/auth-session.ts with validateBearerToken() for Vercel edge gateway
- Validates tokens via Convex /api/auth/get-session with Better-Auth-Cookie header
- Falls back to userRoles:getUserRole Convex query for role resolution
- In-memory cache with 60s TTL and 100-entry cap
- Network errors not cached to allow retry on next request

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

* feat(13-02): add bearer token fallback auth for premium API endpoints

- Dynamic import of auth-session.ts when premium endpoint + API key fails
- Valid pro session tokens fall through to route handler
- Non-pro authenticated users get 403 'Pro subscription required'
- Invalid/expired tokens get 401 'Invalid or expired session'
- Non-premium endpoints and static API key flow unchanged

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

* fix(auth): sign-in button invisible in dark theme — white on white

--accent is #fff in dark theme, so background: var(--accent) + color: #fff
was invisible. Changed to transparent background with var(--text) color.

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

* fix(auth): add premium panel keys to full and finance variant configs

stock-analysis, stock-backtest, and daily-market-brief were defined in
the shared panels.ts but missing from variant DEFAULT_PANELS, causing
shouldCreatePanel() to return false and panel gating CTAs to never render.

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

* test(auth): add Playwright smoke tests for auth UI (phases 12-13)

6 tests covering: Sign In button visibility, auth modal opening,
modal views (Sign In/Sign Up/Forgot Password), premium panel gating
for anonymous users, and auth token absence when logged out.

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

* fix(auth): remove role additionalField that breaks Convex component validator

The betterAuth Convex component has a strict input validator for the
user model that doesn't include custom fields. The role additionalField
caused ArgumentValidationError on sign-up. Roles are already stored in
the separate userRoles table — no data loss.

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

* fix(auth): use Authorization Bearer header for Convex session validation

Better-Auth-Cookie header returned null — the crossDomain plugin's
get-session endpoint expects Authorization: Bearer format instead.
Confirmed via curl against live Convex deployment.

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

* fix(auth): use verified worldmonitor.app domain for auth emails

Was using noreply@resend.dev (testing domain) which can't send to
external recipients. Switched to noreply@worldmonitor.app matching
existing waitlist/contact emails.

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

* fix(auth): await Resend email sends — Convex kills dangling promises

void (fire-and-forget) causes Convex to terminate the fetch before
Resend receives it. Await ensures emails actually get sent.

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

* chore: update Convex generated auth files after config changes

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

* fix(auth): guard against undefined VITE_CONVEX_SITE_URL in auth-state

The Convex cloud URL derivation crashed the entire app when
VITE_CONVEX_SITE_URL wasn't set in the build environment (Vercel
preview). Now gracefully defaults to empty string and skips role
fetching when the URL is unavailable.

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

* feat(auth): add dash + organization plugins, remove Google OAuth, fix dark mode button

- Add @better-auth/infra dash plugin for hosted admin dashboard
- Add organization plugin for org management in dashboard
- Add dash.better-auth.com to trustedOrigins
- Remove Google OAuth (socialProviders, button, divider, CSS)
- Fix auth submit button invisible in dark mode (var(--accent) → #3b82f6)

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

* fix(auth): replace dash plugin with admin — @better-auth/infra incompatible with Convex V8

@better-auth/infra imports SSO/SAML libraries requiring Node.js built-ins
(crypto, fs, stream) which Convex's V8 runtime doesn't support.
Replaced with admin plugin from better-auth/plugins which provides
user management endpoints (set-role, list-users, ban, etc.) natively.

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

* chore: remove stale Convex generated files after plugin update

Convex dev regenerated _generated/ — the per-module JS files
(auth.js, http.js, schema.js, etc.) are no longer emitted.

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

* chore(auth): remove organization plugin — will add in subsequent PR

Organization support (team accounts, invitations, member management)
is not wired into any frontend flow yet. Removing to keep the auth
PR focused on email/password + admin endpoints. Will add back when
building the org/team feature.

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

* docs: add authentication & panel gating guide

Documents the auth stack, panel gating configuration, server-side
session enforcement, environment variables, and user roles.
Includes step-by-step guide for adding new premium panels.

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

* fix(test): stub panel-gating in RuntimeConfigPanel test harness

Panel.ts now imports @/services/panel-gating, which wasn't stubbed —
causing the real runtime.ts (with window.location) to be bundled,
breaking Node.js tests with "ReferenceError: location is not defined".

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

* fix(auth): allow Vercel preview origins in Convex trustedOrigins

* fix(auth): broaden Convex trustedOrigins to cover *.worldmonitor.app previews

* fix(auth): use hostonly wildcard pattern for *.worldmonitor.app in trustedOrigins

* fix(auth): add Convex site origins to trustedOrigins

* fix(ci): add convex/ to vercel-ignore watched paths

* fix(auth): remove admin() plugin — adds banned/role fields rejected by Convex validator

* fix(auth): remove admin() plugin — injects banned/role fields rejected by Convex betterAuth validator

* feat(auth): replace email/password with email OTP passwordless flow

- Replace emailAndPassword + emailVerification with emailOTP plugin
- Rewrite AuthModal: email entry -> OTP code verification (no passwords)
- Remove admin() plugin (caused Convex schema validation errors)
- Remove email verification banner and UNVERIFIED gate reason (OTP
  inherently verifies email)
- Remove password reset flow (forgot/reset password views, token handling)
- Clean up unused CSS (tabs, verification banner, success messages)
- Update docs to reflect new passwordless auth stack

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

* fix(quick-2): harden Convex userRoles and add role cache TTL

- P0: Convert setUserRole from mutation to internalMutation (not callable from client)
- P2: Add 5-minute TTL to role cache in auth-state.ts
- P2: Add localStorage shape warning on auth-token.ts
- P3: Document getUserRole public query trade-off
- P3: Fix misleading cache comment in auth-session.ts

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

* fix(quick-2): auth widget teardown, E2E test rewrite, gateway comment

- P2: Store authHeaderWidget on AppContext, destroy in EventHandlerManager.destroy()
- P2: Also destroy authModal in destroy() to prevent leaked subscriptions
- P1: Rewrite E2E tests for 2-view OTP modal (email input + submit button)
- P1: Remove stale "Sign Up" and "Forgot Password" test assertions
- P2: Replace flaky waitForTimeout(5000) with Playwright auto-retry assertion
- P3: Add clarifying comment on premium bearer-token fallback in gateway

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

* feat(header): restructure header/footer, add profile editing, pro-gate playback/export

- Remove version, @eliehabib, GitHub link, and download button from header
- Move version + @eliehabib credit to footer brand line; download link to footer nav
- Move auth widget (profile avatar) to far right of header (after settings gear)
- Add default generic SVG avatar for users with no image and no name
- Add profile editing in auth dropdown: display name + avatar URL with Save/Cancel
- Add Settings shortcut in auth dropdown (opens UnifiedSettings)
- Gate Historical Playback and Export controls behind pro role (hidden for free users)
- Reactive pro-gate: subscribes to auth state changes, stores unsub in proGateUnsubscribers[]
- Clean up proGateUnsubscribers on EventHandlerManager.destroy() to prevent leaks
- Fix: render Settings button unconditionally (hidden via style), stable DOM structure
- Fix: typed updateUser call with runtime existence check instead of (any) cast
- Make initFooterDownload() private to match class conventions

* feat(analytics): add Umami auth integration and event tracking

- Wire analytics.ts facade to Umami (port from main #1914):
  search, country, map layers, panels, LLM, theme, language,
  variant switch, webcam, download, findings, deeplinks
- Add Window.umami shim to vite-env.d.ts
- Add initAuthAnalytics() that subscribes to auth state and calls
  identifyUser(id, role) / clearIdentity() on sign-in/sign-out
- Add trackSignIn, trackSignUp, trackSignOut, trackGateHit exports
- Call initAuthAnalytics() from App.ts after initAuthState()
- Track sign-in/sign-up (via isNewUser flag) in AuthModal OTP verify
- Track sign-out in AuthHeaderWidget before authClient.signOut()
- Track gate-hit for export, playback (event-handlers) and pro-banner

* feat(auth): professional avatar widget with colored initials and clean profile edit

- Replace white-circle avatar with deterministic colored initials (Gmail/Linear style)
- Avatar color derived from email hash across 8-color palette
- Dropdown redesigned: row layout with large avatar + name/email/tier info
- Profile edit form: name-only (removed avatar URL field)
- Remove Settings button from dropdown (gear icon in header is sufficient)
- Discord community widget: single CTA link, no redundant text label
- Add all missing CSS for dropdown interior, profile edit form, menu items

* fix(auth): lock down billing tier visibility and fix TOCTOU race

P1: getUserRole converted to internalQuery — billing tier no longer
accessible via any public Convex client API. Exposed only through
the new authenticated /api/user-role HTTP action which validates
the session Bearer token before returning the role.

P1: subscribeAuthState generation counter + AbortController prevents
rapid sign-in/sign-out from delivering stale role for wrong user.

P2: typed RawSessionUser/RawSessionValue interfaces replace any casts
at the better-auth nanostore boundary. fetchUserRole drops userId
param — server derives identity from Bearer token only.

P2: isNewUser heuristic removed from OTP verify — better-auth emailOTP
has no reliable isNewUser signal. All verifications tracked as
trackSignIn. OTP resend gets 30s client-side cooldown.

P2: auth-token.ts version pin comment added (better-auth@1.5.5 +
@convex-dev/better-auth@0.11.2). Gateway inner PREMIUM_RPC_PATHS
comment clarified to explain why it is not redundant.

Adds tests/auth-session.test.mts: 11 tests covering role fallback
endpoint selection, fail-closed behavior, and CORS origin matching.

* feat(quick-4): replace better-auth with Clerk JS -- packages, Convex config, browser auth layer

- Remove better-auth, @convex-dev/better-auth, @better-auth/infra, resend from dependencies
- Add @clerk/clerk-js and jose to dependencies
- Rewrite convex/auth.config.ts for Clerk issuer domain
- Simplify convex/convex.config.ts (remove betterAuth component)
- Delete convex/auth.ts, convex/http.ts, convex/userRoles.ts
- Remove userRoles table from convex/schema.ts
- Create src/services/clerk.ts with Clerk JS init, sign-in, sign-out, token, user metadata, UserButton
- Rewrite src/services/auth-state.ts backed by Clerk (same AuthUser/AuthSession interface)
- Delete src/services/auth-client.ts (better-auth client)
- Delete src/services/auth-token.ts (localStorage token scraping)
- Update .env.example with Clerk env vars, remove BETTER_AUTH_API_KEY

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

* feat(quick-4): UI components, runtime fetch, server-side JWT, CSP, and tests

- Delete AuthModal.ts, create AuthLauncher.ts (thin Clerk.openSignIn wrapper)
- Rewrite AuthHeaderWidget.ts to use Clerk UserButton + openSignIn
- Update event-handlers.ts to use AuthLauncher instead of AuthModal
- Rewrite runtime.ts enrichInitForPremium to use async getClerkToken()
- Rewrite server/auth-session.ts for jose-based JWT verification with cached JWKS
- Update vercel.json CSP: add *.clerk.accounts.dev to script-src and frame-src
- Add Clerk CSP tests to deploy-config.test.mjs
- Rewrite e2e/auth-ui.spec.ts for Clerk UI
- Rewrite auth-session.test.mts for jose-based validation
- Use dynamic import for @clerk/clerk-js to avoid Node.js test breakage

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

* fix(auth): allow Clerk Pro users to load premium data on web

The data-loader gated premium panel loading (stock-analysis, stock-backtest,
daily-market-brief) on WORLDMONITOR_API_KEY only, which is desktop-only.
Web users with Clerk Pro auth were seeing unlocked panels stuck on "Loading..."
because the requests were never made.

Added hasPremiumAccess() helper that checks for EITHER desktop API key OR
Clerk Pro role, matching the migration plan Phase 7 requirements.

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

* fix(auth): address PR #1812 review — all 4 merge blockers + 3 gaps

Blockers:
1. Remove stale Convex artifacts (http.js, userRoles.js, betterAuth
   component) from convex/_generated/api.d.ts
2. isProUser() now checks getAuthState().user?.role === 'pro' alongside
   legacy localStorage keys
3. Finance premium refresh scheduling now fires for Clerk Pro web users
   (not just API key holders)
4. JWT verification now validates audience: 'convex' to reject tokens
   scoped to other Clerk templates

Gaps:
5. auth-session tests: 10 new cases (valid pro/free, expired, wrong
   key/audience/issuer, missing sub/plan, JWKS reuse) using self-signed
   keys + local JWKS server
6. premium-stock-gateway tests: 4 new bearer token cases (pro→200,
   free→403, invalid→401, public unaffected)
7. docs/authentication.mdx rewritten for Clerk (removed all better-auth
   references, updated stack/files/env vars/roles sections)

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

* fix: address P1 reactive Pro UI + P2 daily-market-brief + P3 stale env vars

P1 — In-session Pro UI changes no longer require a full reload:
- setupExportPanel: removed early isProUser() return, always creates
  and relies on reactive subscribeAuthState show/hide
- setupPlaybackControl: same pattern — always creates, reactive gate
- Custom widget panels: always loaded regardless of Pro status
- Pro add-panel and MCP add-panel blocks: always rendered, shown/hidden
  reactively via subscribeAuthState callback
- Flight search wiring: always wired, checks Pro status inside callback
  so mid-session sign-ins work immediately

P2 — daily-market-brief added to hasPremiumAccess() block in loadAllData()
so Clerk Pro web users get initial data load (was only primed in
primeVisiblePanelData, missing from the general reload path)

P3 — Removed stale CONVEX_SITE_URL and VITE_CONVEX_SITE_URL from
docs/authentication.mdx env vars table (neither is referenced in codebase)

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

* fix: add isProUser import, populate PREMIUM_RPC_PATHS, and fix bearer token auth flow

- Added missing isProUser import in App.ts (fixes typecheck)
- Populated PREMIUM_RPC_PATHS with stock analysis endpoints
- Restructured gateway auth: trusted browser origins bypass API key for
  premium endpoints (client-side isProUser gate), while bearer token
  validation runs as a separate step for premium paths when present

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

* fix(gateway): require credentials for premium paths + defer free-tier enforcement until auth ready

P0: Removed trusted-origin bypass for premium endpoints — Origin header
is spoofable and cannot be a security boundary. Premium paths now always
require either an API key or valid bearer token.

P1: Deferred panel/source free-tier enforcement until auth state resolves.
Previously ran in the constructor before initAuthState(), causing Clerk Pro
users to have their panels/sources trimmed on every startup.

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

* fix(auth): apply WorldMonitor design system to Clerk modal

Theme-aware appearance config passed to clerk.load(), openSignIn(),
and mountUserButton(). Dark mode: dark bg (#111), green primary
(#44ff88), monospace font. Light mode: white bg, green-600 primary
(#16a34a). Reads document.documentElement.dataset.theme at call time
so theme switches are respected.

* fix(auth): gate Clerk init and auth widget behind BETA_MODE

Clerk auth initialization and the Sign In header widget are now only
activated when localStorage `worldmonitor-beta-mode` is set to "true",
allowing silent deployment for internal testing before public rollout.

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

* fix(auth): gate Clerk init and auth widget behind isProUser()

Clerk auth initialization and the Sign In header widget are now only
activated when the user has wm-widget-key or wm-pro-key in localStorage
(i.e. isProUser() returns true), allowing silent deployment for internal
testing before public rollout.

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

* fix(data-loader): replace stale isProUser() with hasPremiumAccess()

loadMarketImplications() still referenced the removed isProUser import,
causing a TS2304 build error. Align with the rest of data-loader.ts
which uses hasPremiumAccess() (checks both API key and Clerk auth).

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

* fix(auth): address PR #1812 review — P1 security fixes + P2 improvements

P1 fixes:
- Add algorithms: ['RS256'] allowlist to jwtVerify (prevents alg:none bypass)
- Reset loadPromise on Clerk init failure (allows retry instead of permanent breakage)

P2 fixes:
- Extract PREMIUM_RPC_PATHS to shared module (eliminates server/client divergence risk)
- Add fail-fast guard in convex/auth.config.ts for missing CLERK_JWT_ISSUER_DOMAIN
- Add 50s token cache with in-flight dedup to getClerkToken() (prevents concurrent races)
- Sync Clerk CSP entries to index.html and tauri.conf.json (previously only in vercel.json)
- Type clerkInstance as Clerk instead of any

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

* fix(auth): clear cached token on signOut()

Prevents stale token from being returned during the ≤50s cache window
after a user signs out.

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

---------

Co-authored-by: Sebastien Melki <sebastien@anghami.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sebastien Melki <sebastienmelki@gmail.com>
2026-03-26 13:47:22 +02:00
Elie Habib
d07f8a6c99 feat(cii): wire earthquakes + sanctions into CII score; fix 3 brief context gaps (#2269)
* feat(cii): wire earthquakes + sanctions into CII score; add displacement/climate/tier1 to AI brief context

Gap 1 — displacement outflow, climate stress, isTier1 now included in
buildBriefContextSnapshot() so the AI brief sees displacement/climate
data for relevant countries.

Gap 2 — ingestEarthquakesForCII(): M5.5–6.4 = +2, M6.5–7.4 = +5,
M7.5+ = +10, capped at +25. Earthquakes showed in the brief signal count
but contributed zero to the CII score.

Gap 3 — ingestSanctionsForCII(): tiered boost (3/5/8/12) by designation
count + +2 escalation for new entries. Sanctions showed in the brief
context but didn't affect the instability score.

Both ingests called in data-loader after their respective cache writes,
consistent with all other ingest functions.

* fix(cii): add 7-day recency filter to earthquake CII ingest

Without a time window, all M5.5+ quakes in the USGS cache (up to 30
days) accumulated — permanently pinning Japan, Indonesia, Philippines
etc. at the +25 earthquake boost cap even during quiet periods.

Added a 7-day lookback cutoff on eq.occurredAt. Also moved processedCount
increment after the recency check so stats count only contributing events.

Also passes now= for testability (consistent with other CII calc functions).
2026-03-26 09:55:10 +04:00
Elie Habib
2939b1f4a1 feat(finance-panels): add 7 macro/market panels + Daily Brief context (issues #2245-#2253) (#2258)
* feat(fear-greed): add regime state label, action stance badge, divergence warnings

Closes #2245

* feat(finance-panels): add 7 new finance panels + Daily Brief macro context

Implements issues #2245 (F&G Regime), #2246 (Sector Heatmap bars),
#2247 (MacroTiles), #2248 (FSI), #2249 (Yield Curve), #2250 (Earnings
Calendar), #2251 (Economic Calendar), #2252 (COT Positioning),
#2253 (Daily Brief prompt extension).

New panels:
- MacroTilesPanel: CPI YoY, Unemployment, GDP, Fed Rate tiles via FRED
- FSIPanel: Financial Stress Indicator gauge (HYG/TLT/VIX/HY-spread)
- YieldCurvePanel: SVG yield curve chart with inverted/normal badge
- EarningsCalendarPanel: Finnhub earnings calendar with BMO/AMC/BEAT/MISS
- EconomicCalendarPanel: FOMC/CPI/NFP events with impact badges
- CotPositioningPanel: CFTC disaggregated COT positioning bars
- MarketPanel: adds sorted bar chart view above sector heatmap grid

New RPCs:
- ListEarningsCalendar (market/v1)
- GetCotPositioning (market/v1)
- GetEconomicCalendar (economic/v1)

Seed scripts:
- seed-earnings-calendar.mjs (Finnhub, 14-day window, TTL 12h)
- seed-economic-calendar.mjs (Finnhub, 30-day window, TTL 12h)
- seed-cot.mjs (CFTC disaggregated text file, TTL 7d)
- seed-economy.mjs: adds yield curve tenors DGS1MO/3MO/6MO/1/2/5/30
- seed-fear-greed.mjs: adds FSI computation + sector performance

Daily Brief: extends buildDailyMarketBrief with optional regime,
yield curve, and sector context fed to the LLM summarization prompt.

All panels default enabled in FINANCE_PANELS, disabled in FULL_PANELS.

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0

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

* fix(finance-panels): address code review P1/P2 findings

P1 - Security/Correctness:
- EconomicCalendarPanel: add escapeHtml on all 7 Finnhub-sourced fields
- EconomicCalendarPanel: fix panel contract (public fetchData():boolean,
  remove constructor self-init, add retry callbacks to all showError calls)
- YieldCurvePanel: fix NaN in xPos() when count <= 1 (divide-by-zero)
- seed-earnings-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-economic-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-earnings-calendar: add isMain guard around runSeed() call
- health.js + bootstrap.js: register earningsCalendar, econCalendar, cotPositioning keys
- health.js dataSize(): add earnings + instruments to property name list

P2 - Quality:
- FSIPanel: change !resp.fsiValue → resp.fsiValue <= 0 (rejects valid zero)
- data-loader: fix Promise.allSettled type inference via indexed destructure
- seed-fear-greed: allowlist cnnLabel against known values before writing to Redis
- seed-economic-calendar: remove unused sleep import
- seed-earnings-calendar + econ-calendar: increase TTL 43200 → 129600 (36h = 3x interval)
- YieldCurvePanel: use SERIES_IDS const in RPC call (single source of truth)

* fix(bootstrap): remove on-demand panel keys from bootstrap.js

earningsCalendar, econCalendar, cotPositioning panels fetch via RPC
on demand — they have no getHydratedData consumer in src/ and must
not be in api/bootstrap.js. They remain in api/health.js BOOTSTRAP_KEYS
for staleness monitoring.

* fix(compound-engineering): fix markdown lint error in local settings

* fix(finance-panels): resolve all P3 code-review findings

- 030: MacroTilesPanel: add `deltaFormat?` field to MacroTile interface,
  define per-tile delta formatters (CPI pp, GDP localeString+B),
  replace fragile tile.id switch in tileHtml with fmt = deltaFormat ?? format
- 031: FSIPanel: check getHydratedData('fearGreedIndex') at top of
  fetchData(); extract fsi/vix/hySpread from headerMetrics and render
  synchronously; fall back to live RPC only when bootstrap absent
- 032: All 6 finance panels: extract lazy module-level client singletons
  (EconomicServiceClient or MarketServiceClient) so the client is
  constructed at most once per panel module lifetime, not on every fetchData
- 033: get-fred-series-batch: add BAMLC0A0CM and SOFR to ALLOWED_SERIES
  (both seeded by seed-economy.mjs but previously unreachable via RPC)

* fix(finance-panels): health.js SEED_META, FSI calibration, seed-cot catch handler

- health.js: add SEED_META entries for earningsCalendar (1440min), econCalendar
  (1440min), cotPositioning (14400min) — without these, stopped seeds only alarm
  CRIT:EMPTY after TTL expiry instead of earlier WARN:STALE_SEED
- seed-cot.mjs: replace bare await with .catch() handler consistent with other seeds
- seed-fear-greed.mjs: recalibrate FSI thresholds to match formula output range
  (Low>=1.5, Moderate>=0.8, Elevated>=0.3; old values >=0.08/0.05/0.03 were
  calibrated for [0,0.15] but formula yields ~1-2 in normal conditions)
- FSIPanel.ts: fix gauge fillPct range to [0, 2.5] matching recalibrated thresholds
- todos: fix MD022/MD032 markdown lint errors in P3 review files

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-03-26 08:03:09 +04:00
Elie Habib
45469fae3b feat(forecasts): NEXUS panel redesign + simulation theater data via theaterSummariesJson (#2244)
Redesigns ForecastPanel with a theater-first NEXUS layout that surfaces simulation
outcomes alongside forecast probabilities in a unified view. Adds the
theaterSummariesJson field to the GetSimulationOutcome proto so the server can return
pre-condensed UI data from a single Redis read without any additional R2 fetches.

- proto: add theater_summaries_json = 9 to GetSimulationOutcomeResponse
- seed-forecasts.mjs: embed condensed uiTheaters array in Redis pointer at write time
- get-simulation-outcome.ts: serialize pointer.uiTheaters → theaterSummariesJson in RPC response
- src/services/forecast.ts: add fetchSimulationOutcome() returning theaterSummariesJson string
- src/app/data-loader.ts: load simulation outcome alongside forecasts, call updateSimulation()
- ForecastPanel.ts: full NEXUS redesign with SVG circular gauges, expandable theater detail,
  compact prob table, CSS custom property for per-theater accent color, race condition guard
  (skip render when forecasts array still empty on simulation arrival)
2026-03-25 22:44:48 +04:00
Elie Habib
81b8bc5bc6 fix(fear-greed): correct M2SL YoY window (12→52 weeks), WALCL WoW→MoM, VIX9D gate (#2236)
* fix(panels): show radar error state on fetch failure across 22 panels

Add showError() call in catch blocks for 21 data-loader loaders so all
panels display the red radar error state instead of staying on "Loading..."
when upstream fetches fail. Also wraps ConsumerPricesPanel.fetchData() in
try/catch with showError + retry since it self-fetches.

Panels covered: stock-analysis, stock-backtest, tech-events, weather,
infra-outages, cyber-threats, protests, webcams, flight-delays,
energy-complex, trade-policy, supply-chain, satellite-fires,
security-advisories, sanctions-pressure, radiation-watch,
thermal-escalation, displacement, climate, oref-sirens,
population-exposure, consumer-prices.

* fix(panels): correct wrong panel IDs and fix ConsumerPrices error state

data-loader.ts:
- tech-events → events (actual registered panel ID)
- infra-outages → internet-disruptions (actual registered panel ID)
- Remove callPanel showError for weather/cyber-threats/protests/webcams/
  flight-delays — these are map layers with no panel registration, so
  those calls were no-ops

ConsumerPricesPanel.ts:
- Check overview.upstreamUnavailable after Promise.all and call showError()
  with retry, since service functions swallow transport failures and return
  the emptyOverview default (upstreamUnavailable:true) rather than rejecting
  — the try/catch alone was dead code for this failure mode

* fix(panels): remove events showError from map-only catch, fix all-markets path

- Remove callPanel('events', 'showError') from loadTechEvents catch —
  that loader runs a map-specific query (conference/mappable/90d) and
  TechEventsPanel manages its own independent data fetch; a map failure
  must not blank a healthy panel
- Add upstreamUnavailable check in ConsumerPricesPanel fetchData()
  all-markets branch — the default view (market='all') called
  fetchAllMarketsOverview() without checking the result, so all users
  on the default view still got "Pending data" rows instead of the
  radar error state

* fix(panels): remove update([], 0) after showError in satellite-fires catch

update() always re-renders, so calling it after showError() was
immediately replacing the radar error state with the empty dataset UI.

* revert(panels): drop ConsumerPricesPanel error state changes

upstreamUnavailable:true is set by the server on both cache miss
(seed not yet populated) and transport failure — the two cases are
indistinguishable from the client. Treating it as a hard error would
show the radar error screen on fresh deploys before the seeder has run.
The existing seeding placeholder is accurate for both states.
ConsumerPricesPanel is out of scope for this PR.

* fix(panels): remove dead showError calls from cache-fallback paths; add retry callbacks

stock-analysis / stock-backtest catch blocks had callPanel('showError')
at the top, immediately followed by a cache-fallback fetch that calls
renderAnalyses/renderBacktests (setContent) on success — wiping the
error state. Remove the premature callPanel calls; the explicit
panel.showError() at the end of each catch already handles the
no-cache path correctly.

Add onRetry callbacks for panels with long refresh intervals so
transient failures show a retry button instead of a permanent error
state lasting up to 6 hours:
- energy-complex (6h interval): → () => this.loadOilAnalytics()
- trade-policy (~1h): → () => this.loadTradePolicy()
- supply-chain (~1h): → () => this.loadSupplyChain()

* fix(fear-greed): correct M2SL YoY window (12→52 weeks), WALCL MoM (1→4 weeks), VIX9D gate

M2SL converted to weekly in 2021. fredNMonthsAgo(m2Obs, 12) = 12 weeks ≈ 3
months, not 12 months. True YoY requires 52 weekly observations. m2Score
was systematically understating expansionary conditions.

WALCL (Fed balance sheet) is weekly. fredNMonthsAgo(walclObs, 1) = 1 week
(WoW noise from settlements), not MoM. Use 4 observations for a cleaner
~1-month signal.

VIX term structure condition gated on both vix9d and vix3m, but vix9d was
unused in the actual score. If ^VIX9D failed on Yahoo, the entire term
structure fell back to neutral 50 even when ^VIX3M was available. Now
gates on vix3m only.
2026-03-25 17:29:13 +04:00
Elie Habib
537ff8c2c6 fix(cross-source-signals): show radar error state when seeder hasn't run or fetch fails (#2221)
- Panel: call showError() with radar UI when evaluatedAt=0 (seeder never ran yet)
- Panel: add showFetchError() method for network failure path
- data-loader: call showFetchError() on catch instead of silently logging
- Seeder: log names of missing Redis source keys (was only showing count)
2026-03-24 23:41:54 +04:00
Elie Habib
e548e6cca5 feat(intelligence): cross-source signal aggregator with composite escalation (#2143) (#2164)
* feat(intelligence): cross-source signal aggregator with composite escalation (#2143)

Adds a threshold-based signal aggregator seeder that reads 15+ already-seeded
Redis keys every 15 minutes, ranks cross-domain signals by severity, and detects
composite escalation when >=3 signal categories co-fire in the same theater.

* fix(cross-source-signals): wire panel data loading, inline styles, seeder cleanup

- New src/services/cross-source-signals.ts: fetch via IntelligenceServiceClient with circuit breaker
- data-loader.ts: add loadCrossSourceSignals() + startup batch entry (SITE_VARIANT !== 'happy' guard)
- App.ts: add primeVisiblePanelData entry + scheduleRefresh at 15min interval
- base.ts: add crossSourceSignals: 15 * 60 * 1000 to REFRESH_INTERVALS
- CrossSourceSignalsPanel.ts: replace all CSS class usage with inline styles (MarketImplicationsPanel pattern)
- seed-cross-source-signals.mjs: remove dead isMain var, fix afterPublish double-write, deterministic signal IDs, GDELT per-topic tone keys (military/nuclear/maritime) with 3-point declining trend + < -1.5 threshold per spec, bundled topics fallback

* fix(cross-source-signals): complete bootstrap wiring, seeder fixes, cmd-k entry

- cache-keys.ts: add crossSourceSignals to BOOTSTRAP_CACHE_KEYS + BOOTSTRAP_TIERS (slow)
- bootstrap.js: add crossSourceSignals key + SLOW_KEYS entry
- cross-source-signals.ts: add getHydratedData('crossSourceSignals') bootstrap hydration
- seed script: fix isDeclinig typo, maritime theater ternary (Global->Indo-Pacific), displacement year dynamic
- commands.ts: add panel:cross-source-signals to cmd-k

* feat(cross-source-signals): redesign panel — severity bars, filled badges, icons, theater pills

- 4px severity accent bar on all signal rows (scannable without reading badges)
- Filled severity badges: CRITICAL=solid red/white, HIGH=faint red bg, MED=faint yellow bg
- Type badge emoji prefix:  composite, 🔴 geo-physical, 📡 EW, ✈️ military, 📊 market, ⚠️ geopolitical
- Composite card: full glow (box-shadow) instead of 3px left border only
- Theater pill with inline age: "Middle East · 8m ago"
- Contributor pills: individual chips instead of dot-separated string
- Pulsing dot on composite escalation banner

* fix(cross-source-signals): code review fixes — module-level Sets, signal cap, keyframe scoping, OREF expansion

- Replace per-call Array literals in list-cross-source-signals.ts with module-level Set constants for O(1) lookups
- Add index-based fallback ID in normalizeSignal to avoid undefined ids
- Remove unused military:flights:stale:v1 from SOURCE_KEYS
- Add MAX_SIGNALS=30 cap before writing to Redis
- Expand extractOrefAlertCluster to any "do not travel" advisory (not just Israel)
- Add BASE_WEIGHT inline documentation explaining scoring scale
- Fix animation keyframe: move from setContent() <style> block to constructor (injected once), rename to cross-source-pulse-dot
- Fix GDELT extractor to read per-topic gdelt:intel:tone:{topic} keys with correct decline logic
- Fix isDeclinig typo, maritime dead ternary, and displacement year reference
2026-03-24 23:18:31 +04:00
Elie Habib
d095a1b303 fix(panels): lazy panels reappear after close and cascade-hide on applyPanelSettings (#2203)
* fix(market): warm circuit-breaker cache from bootstrap hydration data

After #2198, commodities/sectors route to dedicated breakers (commodityBreaker,
sectorBreaker) that start with empty caches. On the first page load, bootstrap
hydration bypasses fetchCommodityQuotes/fetchSectors entirely, so the breakers
have no cached data. At the 12-min scheduled refresh, if the live RPC fails even
twice (5-min cooldown), the panel blanks out with "data temporarily unavailable"
and won't recover until the next refresh cycle (~24min with backoff).

Fix: call warmCommodityCache() / warmSectorCache() when bootstrap hydration
succeeds. This pre-populates the circuit-breaker in-memory + IndexedDB cache,
so the SWR path serves the bootstrap data as stale fallback if the live RPC fails.

Closes #2192

* fix(panels): hide lazy panels immediately if disabled in saved settings

Lazy panels (forecast, displacement, climate, etc.) load asynchronously.
By the time their import promise resolves and insertByOrder runs,
applyPanelSettings() has already executed at startup. Disabled panels
were being inserted as visible and stayed that way until the next
applyPanelSettings() call, which only triggers on another close or
storage event.

This caused two symptoms:
1. Closed panels reappear after every page load
2. Closing one panel causes others to disappear (they were wrongly
   visible, and applyPanelSettings() on close corrected them all)

Fix: after insertByOrder, immediately call hide() if the saved config
has enabled=false.

Fixes #2190
2026-03-24 19:15:50 +04:00
Elie Habib
663a58bf80 fix(market): route sectors/commodities to correct RPC endpoints (#2198)
* fix(fear-greed): add undici to scripts/package.json (ERR_MODULE_NOT_FOUND on Railway)

* fix(market): route sectors/commodities to correct RPC endpoints

fetchMultipleStocks called listMarketQuotes which reads market:stocks-bootstrap:v1.
Sector ETFs (XLK, XLF...) and commodity futures (GC=F, CL=F...) are NOT in that key,
so the live-fetch fallback always returned empty after the one-shot bootstrap hydration
was consumed, causing panels to show "data temporarily unavailable" on every reload.

Fix: add fetchSectors() -> getSectorSummary (reads market:sectors:v1) and
fetchCommodityQuotes() -> listCommodityQuotes (reads market:commodities-bootstrap:v1),
each with their own circuit breaker and persistent cache. Remove useCommodityBreaker
option from fetchMultipleStocks which no longer serves commodities.

* feat(heatmap): show friendly sector names instead of ETF tickers

The relay seeds name:ticker into Redis (market:sectors:v1), so the
heatmap showed XLK/XLF/etc which is non-intuitive for most users.

Fix: build a sectorNameMap from shared/sectors.json (keyed by symbol)
and apply it in both the hydrated and live fetch paths. Also update
sectors.json names from ultra-short aliases (Tech, Finance) to clearer
labels (Technology, Financials, Health Care, etc).

Closes #2194

* sync scripts/shared/sectors.json

* feat(heatmap): show ticker + sector name side by side

Each tile now shows:
  XLK              <- dim ticker (for professionals)
  Technology       <- full sector name (for laymen)
  +1.23%

Sector names updated: Tech→Technology, Finance→Financials,
Health→Health Care, Real Est→Real Estate, Comms→Comm. Svcs, etc.

Refs #2194
2026-03-24 17:26:29 +04:00
Elie Habib
81faac6e1f fix(market-implications): load in parallel with daily-brief, add primeVisiblePanelData + scheduleRefresh (#2188)
Before: loadDailyMarketBrief() was awaited before loadMarketImplications(),
so a slow LLM summarization call (5-10s) blocked the fast Redis read (~100ms).
market-implications was also missing from primeVisiblePanelData (no viewport-
entry priming) and scheduleRefresh (no periodic refresh in long sessions).

After:
- Both loads run in Promise.allSettled — market-implications hits Redis
  immediately in parallel with the daily-brief LLM call.
- market-implications added to primeVisiblePanelData (triggers on viewport entry).
- market-implications added to scheduleRefresh inside finance variant block
  with 75-min interval (matches seed cadence).
- REFRESH_INTERVALS.marketImplications = 75min added to base.ts.
2026-03-24 12:19:24 +04:00
Elie Habib
a1c3c1d684 feat(panels): AI Market Implications — LLM trade signals from live world state (#2146) (#2165)
* fix(intelligence): use camelCase field names for ListMarketImplicationsResponse

* fix(bootstrap): register marketImplications in cache-keys.ts and add hydration consumer

* chore: stage all market-implications feature files for proto freshness check

* feat(market-implications): add LLM routing env vars for market implications stage

* fix(market-implications): move types to services layer to fix boundary violation

* fix: add list-market-implications gateway tier entry

* fix(market-implications): add health.js entries + i18n tooltip key

- api/health.js: add marketImplications to BOOTSTRAP_KEYS
  ('intelligence:market-implications:v1') and SEED_META
  (seed-meta:intelligence:market-implications, maxStaleMin=150 = 2x
  the 75min TTL, matching gold standard)
- en.json: add components.marketImplications.infoTooltip which was
  referenced in MarketImplicationsPanel but missing from locales

* fix(market-implications): wire CMD+K entry and panels.marketImplications i18n key

- commands.ts: add panel:market-implications command with trade/signal
  keywords so the panel appears in CMD+K search
- en.json: add panels.marketImplications used by UnifiedSettings panel
  toggle display and SearchModal label resolution
2026-03-24 08:01:47 +04:00
Elie Habib
d294bde165 feat(trade): Strategic Flows tab in TradePolicyPanel (UN Comtrade #2089) (#2160)
* feat(trade): add Strategic Flows tab to TradePolicyPanel using UN Comtrade data

Wires the listComtradeFlows RPC (from #2089) into TradePolicyPanel as a new
"Strategic Flows" tab. Shows world-total rows per reporter/commodity, deduped
to latest year, sorted anomalies first then by absolute YoY change.
Anomaly rows (>30% YoY) are highlighted with a badge.

* fix(trade-policy): P1/P2 comtrade tab bugs — WTO gate, activeTab guard, dedup, i18n

* test(trade-policy): update static analysis test to match comtrade activeTab guard
2026-03-23 23:23:06 +04:00
Elie Habib
bbffbd6998 feat(economic): BLS direct integration for CES/LAUMT/ECI series (#2141)
* feat(economic): BLS direct integration for CES/LAUMT/ECI series (#2046)

* fix(bls-series): add isMain guard, series allowlist, and empty-value filter

- Wrap runSeed() in isMain guard (process.argv[1]) to prevent seed
  from executing when the module is imported by tests or other scripts.
  This is the critical codebase-wide pattern documented in MEMORY.md.
- Add KNOWN_SERIES_IDS allowlist in getBlsSeries handler to block
  arbitrary Redis key enumeration via user-supplied series_id values.
- Fix observation filter in seed script: add d.value truthiness check
  so empty strings from null/undefined BLS values do not pass through
  as valid observations alongside the existing dash-value guard.

* fix(bls-series): align TTL, cache tier, and maxStaleMin with gold standard

- CACHE_TTL: 86400 → 259200 (3× daily interval)
- maxStaleMin: 1440 → 2880 (2× daily interval)
- gateway cache tier: static → daily (CDN s-maxage=86400 for daily-seeded data)
- process.exit(1) → process.exit(0)

* feat(economic): surface BLS series in EconomicPanel Labor Market tab

Adds Labor Market tab to EconomicPanel (TODO-088) consuming the BLS
direct integration from PR #2141. Closes issue #2046 UI gap.

- fetchBlsData() in economic service: parallel getBlsSeries calls
  for all 5 series, adapted to FredSeries shape with sparkline data
- EconomicPanel: Labor Market tab with national section (payrolls,
  ECI) and Metro Unemployment sub-section (SF, Boston, NYC)
- Tab hidden gracefully when BLS data unavailable (cron not yet run)
- loadBlsData() wired in data-loader parallel task list
- DataSourceId + data-freshness metadata for bls source
- All 21 locales: laborMarket + metroUnemployment keys

* fix(generated): restore main-compatible generated files with BLS additions

* fix(generated): correct indentation from manual merge
2026-03-23 22:40:20 +04:00
Elie Habib
939a81b9f2 fix(gateway): unblock premium stock panels for web pro users (#2115)
* fix(gateway): allow trusted browser origins on premium stock RPCs

Web pro users (isProUser()) can see premium stock analysis/backtest
panels but calls were failing with 401 because PREMIUM_RPC_PATHS
set forceKey=true even for worldmonitor.app browser sessions.

The client-side isProUser() / WORLDMONITOR_API_KEY guard already
controls whether these panels load. Removing forceKey for trusted
browser origins mirrors how daily-market-brief works.

Non-trusted origins (no recognized Origin header) still get 403/401
via the existing validateApiKey flow.

* docs(gateway): document PREMIUM_RPC_PATHS gap pending payment PR

PREMIUM_RPC_PATHS is empty because the original forceKey=true broke
web pro users (Origin header is spoofable, no server-validated token
exists yet). Added TODO comments pointing to the payment PR where
the real entitlement check should be wired in.
2026-03-23 09:26:42 +04:00
Elie Habib
6bd17fcec0 fix(panels): gate stock panels on isProUser, fix intelligence loading, add missing tooltips (#2083)
- stock-analysis and stock-backtest now load for Pro users without API key
  (same fix applied earlier to daily-market-brief in #2077)
- intelligence signals (climate, population-exposure, security-advisories,
  radiation-watch, etc.) now load on any variant that shows those panels,
  not exclusively SITE_VARIANT=full — panels visible on finance/ or custom
  layouts no longer stay stuck at Loading forever
- add infoTooltip (?) to: Premium Stock Analysis, Premium Backtesting,
  Daily Market Brief, DeFi Tokens, AI Tokens, Alt Tokens, Central Bank Watch
- NewsPanel constructor accepts optional infoTooltip param
- i18n: 8 new tooltip keys across all 21 locale files
2026-03-23 01:19:40 +04:00
Elie Habib
03e3e80b33 fix(daily-market-brief): unblock panel for Pro users without API key (#2077)
Panel unlocked correctly for Pro users (isProUser() = true) but stayed
permanently in Loading... because data loading was gated on API key only.

Added isProUser() as OR condition in primeTask, refreshScheduler, and
loadDailyMarketBrief early-return to mirror the panel-layout unlock logic.
2026-03-23 00:54:10 +04:00
Elie Habib
b7363b73c2 fix(health+economic-panel): unrestEvents threshold + FRED state render bug (#2074)
* fix(health): raise unrestEvents maxStaleMin 75→120, fix cron doc

Actual Railway cron for seed-unrest-events is 45min (per seed script
comment) not 15min (per stale architecture.mdx). With a 45min cron,
the 75min threshold only gives 30min grace — any brief Railway hiccup
triggered a false STALE_SEED WARN. 120min = 2h grace (2.67x cron).

* fix(economic-panel): prevent render() from overwriting FRED error state

When updateSpending() or updateBis() fires after loadFredData() shows
an error, render() was calling setContent() which replaced the error UI
with the noIndicatorData empty message.

Fix: track FRED load state (loading/ok/error/retrying) inside
EconomicPanel. renderIndicators() now uses this state to show the
correct message. Replace showError()/showRetrying() calls in
loadFredData() with setFredError()/setFredRetrying() which store
state before re-rendering.

Also fix circuit breaker name mismatch: loadFredData() was checking
'FRED Economic' but the breaker is named 'FRED Batch'.
2026-03-23 00:01:09 +04:00
Elie Habib
ddc6603cce feat(infra): Cloudflare Radar DDoS attacks + traffic anomaly endpoints (#2067)
* feat(infra): add Cloudflare Radar DDoS attacks + traffic anomaly endpoints

Extends the existing Cloudflare Radar integration (internet outages) with
two new data streams, both confirmed accessible with the current token:
- L3/L4 DDoS attack summaries (protocol + vector breakdowns, 7d window)
- Traffic anomaly events (DNS/BGP/ICMP anomalies with country + ASN context)

Changes:
- proto: add DdosAttackSummaryEntry + TrafficAnomaly messages; new
  list_internet_ddos_attacks.proto and list_internet_traffic_anomalies.proto;
  wire two new RPCs into InfrastructureService
- buf generate: regenerated server/client TypeScript from updated protos
- seed-internet-outages.mjs: add fetchDdosData() + fetchTrafficAnomalies()
  called inside fetchAll() before runSeed() (process.exit-safe pattern);
  writes cf:radar:ddos:v1 and cf:radar:traffic-anomalies:v1
- list-ddos-attacks.ts + list-traffic-anomalies.ts: read-from-seed handlers
- handler.ts: wire new handlers
- cache-keys.ts + api/bootstrap.js: add ddosAttacks + trafficAnomalies
  bootstrap keys (fast tier); kept in sync to pass bootstrap parity tests
- gateway.ts: add RPC_CACHE_TIER entries (slow) for new routes
- services/infrastructure: add fetchDdosAttacks() + fetchTrafficAnomalies()
  with circuit breakers + hydration support

UI surface (cards alongside outage map) deferred to follow-up.

Closes #2043

* fix(i18n): rename Internet Outages → Internet Disruptions

Broader term covers outages, DDoS events, and traffic anomalies now
seeded from Cloudflare Radar. Updated in en.json (layer label, tooltip,
country brief count strings), map-layer-definitions.ts fallback label,
and commands.ts search keywords.

Other locales retain their translated strings (not degraded — they
already use broader equivalents like "internet disruption" in many langs).

* feat(map): render traffic anomalies + DDoS target locations on disruptions layer

Adds geo-coordinates to both data types so they appear as map markers
under the Internet Disruptions toggle alongside existing outage circles.

- Proto: add latitude/longitude to TrafficAnomaly (fields 10/11), add new
  DdosLocationHit message, add top_target_locations to DdosAttacksResponse
- Seeder: resolve lat/lon from COUNTRY_COORDS for traffic anomalies; fetch
  CF Radar top/locations/target endpoint for DDoS top-target locations
- Server handler: pass topTargetLocations through from Redis seed cache
- DeckGLMap: amber trafficAnomaly layer + purple ddosHit layer with tooltips
- GlobeMap: TrafficAnomalyMarker + DdosHitMarker with emoji indicators
- MapContainer: expose setTrafficAnomalies() + setDdosLocations() setters
- data-loader: fire-and-forget anomaly/DDoS fetches after outages load

* fix(review): address code review findings + add Internet Disruptions panel

- fix: totalCount returns filtered count when country param is set
- fix: countryName uses clientCountryName fallback (was always empty)
- fix: remove duplicate toEpochMsFromIso (consolidate into toEpochMs)
- fix: anomalies guard >= 0 → > 0 (don't write empty array to Redis)
- fix: GlobeMap uses named top-level imports instead of inline imports
- feat: InternetDisruptionsPanel with 3 tabs (Outages / DDoS / Anomalies)
2026-03-22 22:58:41 +04:00
Elie Habib
c20c498286 feat(thermal): simplify panel cards + wire data into country brief (#1952) 2026-03-20 21:35:52 +04:00
Elie Habib
b14793ee94 feat(panels): unified panel registry — all panels accessible across all variants (#1911)
* feat(market): add crypto sectors heatmap and token panels (DeFi, AI, Other) backend

- Add shared/crypto-sectors.json, defi-tokens.json, ai-tokens.json, other-tokens.json configs
- Add scripts/seed-crypto-sectors.mjs and seed-token-panels.mjs seed scripts
- Add proto messages for ListCryptoSectors, ListDefiTokens, ListAiTokens, ListOtherTokens
- Add change7d field (field 6) to CryptoQuote proto message
- Run buf generate to produce updated TypeScript bindings
- Add server handlers for all 4 new RPCs reading from seeded Redis cache
- Wire handlers into marketHandler and register cache keys with BOOTSTRAP_TIERS=slow
- Wire seedCryptoSectors and seedTokenPanels into ais-relay.cjs seedAllMarketData loop

* feat(panels): add crypto sectors heatmap and token panels (DeFi, AI, Other)

- Add TokenData interface to src/types/index.ts
- Wire ListCryptoSectorsResponse/ListDefiTokensResponse/ListAiTokensResponse/ListOtherTokensResponse into market service with circuit breakers and hydration fallbacks
- Add CryptoHeatmapPanel, TokenListPanel, DefiTokensPanel, AiTokensPanel, OtherTokensPanel to MarketPanel.ts
- Register 4 new panels in panels.ts FINANCE_PANELS and cryptoDigital category
- Instantiate new panels in panel-layout.ts
- Load data in data-loader.ts loadMarkets() alongside existing crypto fetch

* fix(crypto-panels): resolve test failures and type errors post-review

- Add @ts-nocheck to regenerated market service_server/client (matches repo convention)
- Add 4 new RPC routes to RPC_CACHE_TIER in gateway.ts (route-cache-tier test)
- Sync scripts/shared/ with shared/ for new token/sector JSON configs
- Restore non-market generated files to origin/main state (avoid buf version diff)

* fix(crypto-panels): address code review findings (P1-P3)

- ais-relay seedTokenPanels: add empty-guard before Redis write to
  prevent overwriting cached data when all IDs are unresolvable
- server _feeds.ts: sync 4 missing crypto feeds (Wu Blockchain, Messari,
  NFT News, Stablecoin Policy) with client-side feeds.ts
- data-loader: expose panel refs outside try block so catch can call
  showRetrying(); log error instead of swallowing silently
- MarketPanel: replace hardcoded English error strings with t() calls
  (failedSectorData / failedCryptoData) to honour user locale
- seed-token-panels.mjs: remove unused getRedisCredentials import
- cache-keys.ts: one BOOTSTRAP_TIERS entry per line for consistency

* fix(crypto-panels): three correctness fixes for RSS proxy, refresh, and Redis write visibility

- api/_rss-allowed-domains.js: add 7 new crypto domains (decrypt.co,
  blockworks.co, thedefiant.io, bitcoinmagazine.com, www.dlnews.com,
  cryptoslate.com, unchainedcrypto.com) so rss-proxy.js accepts the
  new finance feeds instead of rejecting them as disallowed hosts
- src/App.ts: add crypto-heatmap/defi-tokens/ai-tokens/other-tokens to
  the periodic markets refresh viewport condition so panels on screen
  continue receiving live updates, not just the initial load
- ais-relay seedTokenPanels: capture upstashSet return values and log
  PARTIAL if any Redis write fails, matching seedCryptoSectors pattern

* feat(panels): unified panel registry — all panels accessible across variants

Converts panel system from hard access gates to soft defaults. Users can
now enable any panel from any variant (finance panels on full, etc.) via
settings. Variant = default configuration, not access control.

- ALL_PANELS: union of all 5 variant registries as single source of truth
- VARIANT_DEFAULTS: per-variant ordered default panel lists (derived programmatically)
- VARIANT_PANEL_OVERRIDES + getEffectivePanelConfig(): render-time label resolution
- isPanelEntitled(): shared entitlement helper (API key + desktop-only gates)
- No reset on variant change: merge-only, user choices preserved
- One-time migration seeds all panels for existing users
- UnifiedSettings + settings-window: normalize against ALL_PANELS, show all categories
- data-loader: remove SITE_VARIANT === 'finance' gates (panel-driven loaders)
- TV mode + panel ordering use panelSettings instead of DEFAULT_PANELS

* refactor(panels): address code review findings on unified panel registry

- Replace require() in isPanelEntitled() with static imports (no circular
  dep risk — isDesktopRuntime was already imported, getSecretState only
  imports from runtime which is already in panels.ts)
- Use SITE_VARIANT consistently in merge/migration loops instead of
  currentVariant alias for clarity
- Fix feeds fallback: use ALL_PANELS instead of DEFAULT_PANELS so user
  choices are not bypassed when resolving news panel configs
- Drop unused DEFAULT_PANELS import from panel-layout.ts
- Remove SITE_VARIANT !== 'finance' guard inside loadDailyMarketBrief()
  so non-finance users with API keys can load the panel
- Fix savePanelSettings to shallow-copy draftPanelSettings directly
  instead of re-normalizing via clonePanelSettings (avoids writing 200+
  entries on every save)
- Category sidebar now filters on enabled panels only, preventing
  variant-irrelevant categories from appearing when all panels are seeded

* fix(lint): add boundary-ignore comment for getSecretState import in panels.ts
2026-03-20 12:36:22 +04:00
Elie Habib
c0bf784d21 feat(finance): crypto sectors heatmap + DeFi/AI/Alt token panels + expanded crypto news (#1900)
* feat(market): add crypto sectors heatmap and token panels (DeFi, AI, Other) backend

- Add shared/crypto-sectors.json, defi-tokens.json, ai-tokens.json, other-tokens.json configs
- Add scripts/seed-crypto-sectors.mjs and seed-token-panels.mjs seed scripts
- Add proto messages for ListCryptoSectors, ListDefiTokens, ListAiTokens, ListOtherTokens
- Add change7d field (field 6) to CryptoQuote proto message
- Run buf generate to produce updated TypeScript bindings
- Add server handlers for all 4 new RPCs reading from seeded Redis cache
- Wire handlers into marketHandler and register cache keys with BOOTSTRAP_TIERS=slow
- Wire seedCryptoSectors and seedTokenPanels into ais-relay.cjs seedAllMarketData loop

* feat(panels): add crypto sectors heatmap and token panels (DeFi, AI, Other)

- Add TokenData interface to src/types/index.ts
- Wire ListCryptoSectorsResponse/ListDefiTokensResponse/ListAiTokensResponse/ListOtherTokensResponse into market service with circuit breakers and hydration fallbacks
- Add CryptoHeatmapPanel, TokenListPanel, DefiTokensPanel, AiTokensPanel, OtherTokensPanel to MarketPanel.ts
- Register 4 new panels in panels.ts FINANCE_PANELS and cryptoDigital category
- Instantiate new panels in panel-layout.ts
- Load data in data-loader.ts loadMarkets() alongside existing crypto fetch

* fix(crypto-panels): resolve test failures and type errors post-review

- Add @ts-nocheck to regenerated market service_server/client (matches repo convention)
- Add 4 new RPC routes to RPC_CACHE_TIER in gateway.ts (route-cache-tier test)
- Sync scripts/shared/ with shared/ for new token/sector JSON configs
- Restore non-market generated files to origin/main state (avoid buf version diff)

* fix(crypto-panels): address code review findings (P1-P3)

- ais-relay seedTokenPanels: add empty-guard before Redis write to
  prevent overwriting cached data when all IDs are unresolvable
- server _feeds.ts: sync 4 missing crypto feeds (Wu Blockchain, Messari,
  NFT News, Stablecoin Policy) with client-side feeds.ts
- data-loader: expose panel refs outside try block so catch can call
  showRetrying(); log error instead of swallowing silently
- MarketPanel: replace hardcoded English error strings with t() calls
  (failedSectorData / failedCryptoData) to honour user locale
- seed-token-panels.mjs: remove unused getRedisCredentials import
- cache-keys.ts: one BOOTSTRAP_TIERS entry per line for consistency

* fix(crypto-panels): three correctness fixes for RSS proxy, refresh, and Redis write visibility

- api/_rss-allowed-domains.js: add 7 new crypto domains (decrypt.co,
  blockworks.co, thedefiant.io, bitcoinmagazine.com, www.dlnews.com,
  cryptoslate.com, unchainedcrypto.com) so rss-proxy.js accepts the
  new finance feeds instead of rejecting them as disallowed hosts
- src/App.ts: add crypto-heatmap/defi-tokens/ai-tokens/other-tokens to
  the periodic markets refresh viewport condition so panels on screen
  continue receiving live updates, not just the initial load
- ais-relay seedTokenPanels: capture upstashSet return values and log
  PARTIAL if any Redis write fails, matching seedCryptoSectors pattern
2026-03-20 10:34:20 +04:00
Jon Torrez
7355448ec8 fix(webcams): clamp zoom floor so globe renderer loads webcam data (#1607)
GlobeMap.getState() always returns zoom: 1 because the 3D globe
doesn't map to a traditional tile zoom level. The webcam loader
had a guard `if (zoom < 2) return` that silently skipped fetching,
so toggling the webcam layer on the globe never populated markers.

Clamp to Math.max(2, zoom) so the fetch always fires regardless
of renderer.
2026-03-19 10:03:01 +04:00
Elie Habib
6eb909ba1d fix(gdelt-intel): remove retry loop for empty articles; signal seed-unavailable (#1836)
* fix(gdelt-intel): remove empty-articles retry loop; signal seed-unavailable

Under the gold standard (Vercel reads Redis, Railway writes), retrying
searchGdeltDocuments when articles are empty accomplishes nothing — the
RPC reads the same expired Redis key every time, producing the same empty
result. The 3×15s retry loop caused the Live Intelligence panel to show
"Retrying... (15s)" for 45 seconds before finally displaying an error.

- search-gdelt-documents.ts: return error:'seed-unavailable' (vs '')
  when the Redis key is missing, so clients can distinguish transient
  seed expiry from a legitimate "no articles matched this topic" result.
- gdelt-intel.ts: on seed-unavailable, fall back to stale client-side
  cache (articleCache) if available — panel shows last-seen articles
  rather than going blank.
- GdeltIntelPanel.ts: remove the empty-articles retry loop. Empty
  articles are a valid (and common) state — just render the empty state
  immediately. Network errors still show the error state with retry CTA.

* fix(gdelt-intel): remove pointless empty-result retries in TechEventsPanel and UCDP

Same anti-pattern as GdeltIntelPanel: both listTechEvents and listUcdpEvents
are pure Redis-reads under the gold standard. Retrying on empty returns the
same stale result until Railway refreshes the seed — the 2×15s wait only
delays the user reaching a useful empty/error state.

- TechEventsPanel: collapse 3-attempt loop to single try/catch; remove
  empty-events showRetrying. Network errors still show error state.
- data-loader UCDP: remove 2-attempt retry loop on !result.success.
  Log and return immediately — prior event state is retained either way.

* fix(gdelt-intel): cap stale-cache fallback at 1h ceiling to prevent serving arbitrarily old headlines
2026-03-19 02:18:26 +04:00
Elie Habib
188b17ca3f perf: gate PizzINT to full variant only (#1808)
PizzINT was loading and polling on tech, finance, commodity, and happy
variants where the indicator is never rendered. This fired two RPC calls
(PizzINT status + GDELT tensions) every 10 minutes on sessions that never
show the widget, wasting edge requests.

Changes: startup load, 10-min scheduler entry, and indicator setup all
now skip non-full variants.
2026-03-18 17:20:06 +04:00
Elie Habib
80cb7d5aa7 fix(cache): digest TTL alignment + slow-browser tier + feedStatuses trim (#1798)
* fix(cache): align Redis digest + RSS feed TTLs to CF CDN TTL

RSS feed TTL 600s → 3600s; digest TTL 900s → 3600s.
CF CDN caches at 3600s, so Redis expiring earlier caused every hourly
CF revalidation to hit a cold origin and run the full buildDigest()
pipeline (75 feeds, up to 25s). Aligning both to 3600s ensures CF
revalidation gets a warm Redis hit and returns immediately.

* fix(cache): emit only non-ok feedStatuses; update proto comment + make generate

Digest was emitting 'ok' for every successful feed (~50 entries, ~1-2KB
per response). No in-repo client reads feedStatuses values. Changed to
only emit 'empty' and 'timeout'; absent key implies ok.

Updated proto comment to document the absence-implies-ok contract and
ran make generate to regenerate docs/api/ OpenAPI files.

* fix(cache): add slow-browser tier; move digest route to it

New 'slow-browser' tier is identical to 'slow' but adds max-age=300,
letting browsers skip the network for 5 minutes. Without max-age,
browsers ignore s-maxage and send conditional If-None-Match on every
20-min poll — each costing 1 billable edge request even for 304s.

Scoped only to list-feed-digest (a safe polling endpoint). Premium
user-triggered endpoints (analyze-stock, backtest-stock) stay on 'slow'
where browser caching is inappropriate.

* test: regression tests for feedStatuses and slow-browser tier

- digest-no-reclassify: assert buildDigest does not write 'ok' to feedStatuses
- route-cache-tier: include slow-browser in tier regex; assert slow-browser
  has max-age and slow tier does not

* fix(cache): add variant to per-feed RSS cache key

rss:feed:v1:${url} was shared across variants even though classifyByKeyword()
bakes variant-specific threat/category labels into the cached ParsedItem[].
Feeds shared between full and tech variants (Verge, Ars, HN, etc.) had
whichever variant populated the cache first control the other variant's
classifications for the full 3600s TTL — turning a pre-existing 10-minute
bleed-through into a 1-hour accuracy bug for the tech dashboard.

Fix: key is now rss:feed:v1:${variant}:${url}.

* fix(cache): bypass browser HTTP cache on digest fetch

max-age=300 on the slow-browser tier lets browsers serve the digest
from their HTTP cache for up to 5 minutes, including on explicit
in-app refresh (window.location.reload) or page reload after a
breaking event. Users would see stale data until the TTL expired.

Add cache: 'no-cache' to tryFetchDigest() so every fetch revalidates
against CF edge. CF returns 304 (minimal cost) when data is unchanged,
or 200 with the current digest. s-maxage and CF-level caching are
unaffected; max-age still benefits browser back/forward cache.

* fix(cache): 15-min consistent TTL + degrade guard for digest

Issue 1 — TTL alignment: Redis digest TTL reverted to 900s (from 3600).
slow-browser tier reduced from s-maxage=1800/CDN=3600 to s-maxage=900 on
both sides, matching the Redis TTL. The freshness window is now consistently
15 minutes across Redis, Vercel edge, and CF CDN. max-age=300 (browser
local) is kept to avoid unnecessary revalidations on tab switch.

Issue 2 — Cache poisoning: replaced cachedFetchJson in listFeedDigest with
explicit getCachedJson/setCachedJson. After buildDigest(), if total items
across all categories is 0 the response is treated as degraded: Redis write
is skipped and markNoCacheResponse(ctx.request) is called so the gateway
sets Cache-Control: no-store instead of the normal tier headers. This
prevents a transient bad run from poisoning Redis and browser/CDN for the
full TTL. Error paths also call markNoCacheResponse.
2026-03-18 10:19:17 +04:00
Elie Habib
5ada764391 feat(ui): add Thermal Escalation panel (#1786)
* feat(ui): add Thermal Escalation panel

The thermal-escalation seed, RPC handler, service client, data-loader,
and bootstrap key all existed but the panel component was never created.
This adds ThermalEscalationPanel with summary cards, cluster table, and
map click-to-center, plus wires it into panels.ts and panel-layout.ts.

* fix(data): use shouldLoad() for sanctions/radiation data loading

The sanctions-pressure and radiation-watch panels used
this.ctx.panels[key] to decide whether to load data. For lazy-loaded
panels (radiation-watch), the panel isn't in ctx.panels yet when
loadData() runs, so data loading was skipped entirely and the panel
stayed at "Loading..." forever. Switch to shouldLoad() which checks
viewport proximity via DEFAULT_PANELS config, matching the pattern
used by thermal-escalation and other priority-2 panels.

* fix(ui): display thermal countDelta as raw count, not percent

countDelta is an absolute observation-count difference, not a ratio.
Multiplying by 100 and appending % inflated every value by 100x.
2026-03-17 22:45:00 +04:00
Elie Habib
0d4519c324 feat(ui): separate macro stress and energy complex panels (#1749)
* feat(ui): separate macro stress and energy complex panels

Cherry-picked from chained branch onto clean main. Splits the Economic
panel into focused Macro Stress (FRED indicators) and Energy Complex
(oil analytics + market tape) panels.

Also fixes negative dollar-B changes now display with minus sign (was
rendering as positive due to Math.abs without sign prefix).

* fix: restore BIS/spending loading, split commodity/energy flags

Addresses 2 blockers from PR review:

1. Restore BIS and USASpending data loading in all 3 call sites
   (prime tasks, periodic refresh, initial load). Restore full
   spending and centralBanks tab rendering in EconomicPanel while
   keeping the new macro stress indicators view.

2. Split shared commoditiesLoaded flag into separate metalsLoaded
   and energyLoaded flags so each panel falls back independently.
   Energy data arriving cannot suppress metals fallback fetch.

EconomicPanel now has 3 tabs: Indicators (macro stress), Spending,
Central Banks. Oil tab removed (lives in EnergyComplexPanel).

* fix(i18n): restore economic panel translation keys for BIS/spending tabs

PR removed i18n keys needed by the restored spending and centralBanks
tabs (gov, centralBanks, awards, policyRate, etc.). Also updated the
infoTooltip to accurately describe the mixed surface (macro + gov +
central banks) instead of claiming macro-only.
2026-03-17 14:36:14 +04:00
Elie Habib
3702463321 Add thermal escalation seeded service (#1747)
* feat(thermal): add thermal escalation seeded service

Cherry-picked from codex/thermal-escalation-phase1 and retargeted
to main. Includes thermal escalation seed script, RPC handler,
proto definitions, bootstrap/health/seed-health wiring, gateway
cache tier, client service, and tests.

* fix(thermal): wire data-loader, fix typing, recalculate summary

Wire fetchThermalEscalations into data-loader.ts with panel forwarding,
freshness tracking, and variant gating. Fix seed-health intervalMin from
90 to 180 to match 3h TTL. Replace 8 as-any casts with typed interface.
Recalculate summary counts after maxItems slice.

* fix(thermal): enforce maxItems on hydrated data + fix bootstrap keys

Codex P2: hydration branch now slices clusters to maxItems before
mapping, matching the RPC fallback behavior.

Also add thermalEscalation to bootstrap.js BOOTSTRAP_CACHE_KEYS and
SLOW_KEYS (was lost during conflict resolution).

* fix(thermal): recalculate summary on sliced hydrated clusters

When maxItems truncates the cluster array from bootstrap hydration,
the summary was still using the original full-set counts. Now
recalculates clusterCount, elevatedCount, spikeCount, etc. on the
sliced array, matching the handler's behavior.
2026-03-17 14:24:26 +04:00