Commit Graph

138 Commits

Author SHA1 Message Date
Elie Habib
26ecf3d91d feat(seeds): add BIS data seed job and relax health thresholds (#1131)
* feat(seeds): add BIS data seed job and relax health thresholds

Add seed-bis-data.mjs that fetches all 3 BIS datasets (policy rates,
exchange rates, credit-to-GDP) in parallel and writes to Redis. This
keeps the cache warm instead of relying on on-demand RPC calls.

Relax BIS health thresholds from 1440min (24h) to 2880min (48h) since
BIS data is monthly/quarterly — 24h was too aggressive.

* fix(health): relax minerals and giving thresholds to 7 days

Both are static/hardcoded data with no external API calls.
2880min (48h) was too aggressive for annual data.

* fix(gpsjam): write seed-meta for health freshness tracking

The fetch-gpsjam script seeded Redis data but never wrote
seed-meta:intelligence:gpsjam, causing health to report STALE_SEED.
2026-03-06 17:47:54 +04:00
Elie Habib
7c760c575a fix(health): resolve bisCredit empty data and theater posture warnings (#1124) 2026-03-06 16:06:52 +04:00
Elie Habib
804e4128f6 fix(cyber): suppress MaxListenersExceededWarning in GeoIP hydration (#1120)
setMaxListeners on AbortSignal to match concurrent fetch count,
preventing 100+ warning lines in Railway logs.
2026-03-06 13:53:27 +04:00
Elie Habib
5e25bb1386 fix(health): resolve all critical health check failures (#1111)
## Summary
- Reclassify 10 on-demand keys (BIS, supply chain, theater posture, etc.) from BOOTSTRAP → STANDALONE + ON_DEMAND to stop false CRITs
- Fix seed-insights Railway OOM by correcting service-level settings
- Unify LLM fallback chain (Groq → OpenRouter → Ollama) in seed-insights
- Switch OpenRouter model to `openai/gpt-oss-safeguard-20b:nitro`
- Fix GDELT v2/geo → v1/gkg_geojson for unrestEvents and positiveGeoEvents (v2 endpoint is dead)
- Add seed-meta writes for marketQuotes/commodityQuotes in AIS relay (zero extra Yahoo calls)
- Remove aggressive coord filter in cyber threats that dropped all threats when GeoIP rate-limited

## Health impact
- 6 false CRITs → eliminated (reclassified as on-demand)
- marketQuotes/commodityQuotes STALE_SEED → OK (seed-meta tracking)
- unrestEvents EMPTY_DATA → OK (GDELT v1 fix)
- positiveGeoEvents EMPTY_DATA → OK (GDELT v1 fix in relay)
- cyberThreats resilience improved (coord filter removal)
2026-03-06 13:49:15 +04:00
Elie Habib
1262e79b38 fix: remove data files from git tracking (#1114)
* data(iran): import 100 events + add 27 geocoded locations

Import latest LiveUAMap events (March 5-6, 2026) covering
US-Israeli strikes on Iran, Iranian retaliatory attacks on
Gulf states and Israel, and regional diplomatic developments.

New LOCATION_COORDS: Paveh, Parchin, Rasht, Khorramabad,
Damavand, Parand, Javanrud, Basra, Karbala, Nakhchivan,
Koya, Elad, Juffair, Hodeidah, Sana'a, Ma'ameer, Pakdasht,
Alborz, Khor al-Zubair, Prince Sultan AB, Ben Gurion,
Tel Nof, Azerbaijan, Yemen.

* fix: remove seed script and event data from git tracking

These files are already in .gitignore but were committed previously.
Event data belongs in Redis only, not in the repo.
2026-03-06 11:37:46 +04:00
Elie Habib
d5cabc6ecd feat(market): add CoinPaprika fallback for crypto/stablecoin data (#1092)
* feat(api): add comprehensive health check endpoint for UptimeRobot

Checks all 44 Redis cache keys (33 bootstrap + 11 standalone) plus
17 seed-meta freshness timestamps in a single Redis pipeline.

- Returns HEALTHY/DEGRADED/UNHEALTHY with per-key status
- Distinguishes seed-backed keys (STALE_SEED) from on-demand keys (EMPTY_ON_DEMAND)
- No auth required, ?compact=1 for minimal payload
- UptimeRobot: keyword monitor on "HEALTHY", HTTP 503 on UNHEALTHY

* feat(market): add CoinPaprika fallback for crypto/stablecoin data

CoinGecko 429 rate limiting causes seed and RPC failures.
Added CoinPaprika (free, 250K req/mo, no key) as automatic fallback
when CoinGecko fails. Also adds CoinGecko Pro key support.

- _shared.ts: fetchCryptoMarkets() unified wrapper (CoinGecko → CoinPaprika)
- list-crypto-quotes.ts: use fetchCryptoMarkets instead of direct CoinGecko
- list-stablecoin-markets.ts: same, removed duplicate CoinGecko fetch
- seed-crypto-quotes.mjs: CoinPaprika fallback + Pro key support
- seed-stablecoin-markets.mjs: same
- ais-relay.cjs: both seedCryptoQuotes and seedStablecoinMarkets
2026-03-06 08:40:30 +04:00
Elie Habib
314d341563 fix: gracefully skip seed write when validation fails (empty data) (#1089)
At midnight UTC, FIRMS API returns 0 fire detections due to date
rollover. The validateFn correctly rejects empty data, but previously
this threw a FATAL error and crashed. Now it exits cleanly (code 0),
preserving existing cached data in Redis for the next successful run.
2026-03-06 08:03:13 +04:00
Elie Habib
320786f82a fix: prevent CF caching SPA HTML + Polymarket bandwidth optimization (#1058)
* perf: reduce Vercel data transfer costs with CDN optimization

- Increase polling intervals (markets 8→12min, feeds 15→20min, crypto 8→12min)
- Increase background tab hiddenMultiplier from 10→30 (polls 3x less when hidden)
- Double CDN s-maxage TTLs across all cache tiers in gateway
- Add CDN-Cache-Control header for Cloudflare-specific longer edge caching
- Add ETag generation + 304 Not Modified support in gateway (zero-byte revalidation)
- Add CDN-Cache-Control to bootstrap endpoint
- Add explicit SPA rewrite rule in vercel.json for CF proxy compatibility
- Add Cache-Control headers for /map-styles/, /data/, /textures/ static paths

* fix: prevent CF from caching SPA HTML + reduce Polymarket bandwidth 95%

- vercel.json: apply no-cache headers to ALL SPA routes (same regex as
  rewrite rule), not just / and /index.html — prevents CF proxy from
  caching stale HTML that references old content-hashed bundle filenames
- Polymarket: add server-side aggregation via Railway seed script that
  fetches all tags once and writes to Redis, eliminating 11-request
  fan-out per user per poll cycle
- Bootstrap: add predictions to hydration keys for zero-cost page load
- RPC handler: read Railway-seeded bootstrap key before falling back to
  live Gamma API fetch
- Client: 3-strategy waterfall (bootstrap → RPC → fan-out fallback)
2026-03-05 16:38:51 +04:00
Elie Habib
478df641fa fix: rate-guard AbuseIPDB calls and disable duplicate cyber seed loop (#1055)
Root cause: AbuseIPDB has 100 calls/day limit. The cyber seed cron runs
every 2h with a 2h TTL — tight race causes Vercel handler fallthrough
to live fetches when the key expires between cron runs.

Three fixes:
1. Rate-guard AbuseIPDB in seed-cyber-threats.mjs: checks Redis key
   `rate:abuseipdb:last-call` before calling API, uses cached threats
   from `cache:abuseipdb:threats` between calls (2h minimum interval)
2. Disable duplicate cyber seed loop in ais-relay.cjs (standalone cron
   handles it — avoids 12 extra AbuseIPDB calls/day)
3. Increase seed TTL from 2h to 3h to survive 1 missed cron cycle
2026-03-05 14:37:21 +04:00
Elie Habib
86c1d1a807 fix: correct cyber seed Redis key mismatch and add missing market seed functions (#1054)
The cyber seed wrote to `cyber:threats:v2:0:::` but the handler reads
from `cyber:threats:v2` — seed data was invisible to the handler, causing
every request to fall through to live AbuseIPDB/OTX/URLhaus fetches and
burning API quotas.

Additionally, 4 market domains (crypto, gulf, stablecoins, ETF flows) had
handler-side seed-reading code but no corresponding seed functions in the
Railway relay. All requests fell through to live CoinGecko/Yahoo fetches.

Changes:
- Fix CYBER_RPC_KEY to match handler's REDIS_CACHE_KEY
- Add seed-meta:cyber:threats write with fetchedAt timestamp
- Add seedGulfQuotes() — Yahoo Finance, 14 symbols, 1h interval
- Add seedEtfFlows() — Yahoo Finance, 10 BTC ETFs, 1h interval
- Add seedCryptoQuotes() — CoinGecko, 4 coins, 30min interval
- Add seedStablecoinMarkets() — CoinGecko, 5 stablecoins, 30min interval
- All new seeds write both data key and seed-meta key
- Wire all into seedAllMarketData() loop
2026-03-05 13:38:02 +04:00
Elie Habib
6128efcdef fix: add R2 fallback for military bases seed data (#1053)
When the 34MB data file isn't available locally, the script now:
1. Checks /data/ (Railway volume mount)
2. Checks scripts/data/ (local)
3. Downloads from Cloudflare R2 bucket (worldmonitor-data)
4. Falls back to Redis check (skip if data already seeded)

R2 bucket: worldmonitor-data/seed-data/military-bases-final.json
Requires CLOUDFLARE_R2_TOKEN or CLOUDFLARE_API_TOKEN env var on Railway.
2026-03-05 12:34:42 +04:00
Elie Habib
2b48350b07 fix: make seed-military-bases resilient to missing data file (#1051)
- Check Railway volume mount (/data/) first, then local scripts/data/
- If no file found, check if Redis already has active data — skip gracefully
- No more crash when deployed as cron without the 34MB data file

The data uses Redis GEO/HASH keys with no TTL (persists indefinitely).
Re-seeding only needed when base data changes or Redis is wiped.
2026-03-05 12:09:37 +04:00
Elie Habib
309eeea6fc feat: add 24 geocoder locations and auto-rotate CDN cache buster for Iran events (#1047)
- Add missing locations to seed script: Bukan, Saqqez, Sardasht, Marivan,
  Baneh, Sulaymaniyah, Riffa, Al-Kharj, Al-Jawf, Mehrabad, Mahallati,
  Tehransar, Borujerdi, Incirlik, Aqaba, Ashkelon, Jerusalem, Sri Lanka,
  Tabriz, Yazd, Hatay, Najaf, Hazmieh
- Replace hardcoded ?_v=9 cache-bust with time-based rotation (2-min buckets)
  so CDN cache refreshes automatically after Redis imports
- Update iran-events-latest.json with Mar 5 import data (100 events)
2026-03-05 11:04:23 +04:00
Hasan AlDoy
02f3fe77a9 feat: Arabic font support and HLS live streaming UI (#1020)
* feat: enhance support for HLS streams and update font styles

* chore: add .vercelignore to exclude large local build artifacts from Vercel deploys

* chore: include node types in tsconfig to fix server type errors on Vercel build

* fix(middleware): guard optional variant OG lookup to satisfy strict TS

* fix: desktop build and live channels handle null safety

- scripts/build-sidecar-sebuf.mjs: Skip building removed [domain]/v1/[rpc].ts (removed in #785)
- src/live-channels-window.ts: Add optional chaining for handle property to prevent null errors
- src-tauri/Cargo.lock: Bump version to 2.5.24

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: address review issues on PR #1020

- Remove AGENTS.md (project guidelines belong to repo owner)
- Restore tracking script in index.html (accidentally removed)
- Revert tsconfig.json "node" types (leaks Node globals to frontend)
- Add protocol validation to isHlsUrl() (security: block non-http URIs)
- Revert Cargo.lock version bump (release management concern)

* fix: address P2/P3 review findings

- Preserve hlsUrl for HLS-only channels in refreshChannelInfo (was
  incorrectly clearing the stream URL on every refresh cycle)
- Replace deprecated .substr() with .substring()
- Extract duplicated HLS display name logic into getChannelDisplayName()

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-03-05 10:16:43 +04:00
Elie Habib
f06db59720 fix: graceful degradation for seed scripts with missing keys or downed sources (#1045)
- seed-unrest-events: relax validation (ACLED missing + GDELT 404 = crash),
  console.warn → console.log for non-fatal failures
- seed-natural-events: relax validation, console.error → console.log
- seed-climate-anomalies: relax validation, console.error → console.log
- seed-internet-outages: console.error → console.log for missing key

Railway tags console.warn/error as severity:error, making healthy runs
look like crashes in the dashboard.
2026-03-05 10:09:27 +04:00
Elie Habib
32d3023815 fix: query 3 VIIRS satellites sequentially for fire detections (#1036)
SNPP and NOAA20 frequently return 0 rows (data gaps). The previous
single-source parallel approach hit all 9 regions simultaneously,
causing FIRMS rate limits and timeouts on Railway.

Changes:
- Query SNPP + NOAA20 + NOAA21 with deduplication
- Sequential requests with 200ms delay (avoids rate limiting)
- 30s timeout (was 15s)
- Restore strict validation (length > 0)
2026-03-05 07:08:06 +04:00
Elie Habib
2f2486cc8e fix: harden seed scripts with 429 rate-limit retry and relaxed validation (#1026)
* fix: allow zero fire detections in seed validation

FIRMS NRT data has a rolling window — at certain hours, all 9 monitored
regions can legitimately return 0 active fire detections. The strict
length > 0 validation caused CRASHED status on Railway cron runs
during these periods. Structure-only validation is sufficient.

* fix: add rate-limit-aware retry for CoinGecko 429s

The default withRetry (1s/2s/4s backoff) is too short for CoinGecko
rate limits. New fetchWithRateLimitRetry uses 10s/20s/30s/40s/50s
delays with up to 5 attempts specifically for 429 responses.

* fix: add 429 rate-limit retry to all Yahoo and CoinGecko seed scripts

Yahoo Finance and CoinGecko both return 429 when rate limited. The
default withRetry (1s/2s/4s) is too fast for rate limits. Added
per-request 429-specific retry with longer backoff:
- Yahoo: 5s/10s/15s/20s (4 attempts per symbol)
- CoinGecko: 10s/20s/30s/40s/50s (5 attempts)

Scripts updated: seed-etf-flows, seed-gulf-quotes, seed-commodity-quotes,
seed-market-quotes, seed-stablecoin-markets.
2026-03-05 06:31:04 +04:00
Elie Habib
b001d25527 fix(relay): add GPS jamming seed loop to Railway relay (#1016)
The GPS jamming data pipeline had no scheduled seed — fetch-gpsjam.mjs
existed as a standalone script but was never wired into the relay's
setInterval-based seed system. Redis key intelligence:gpsjam:v1 was
always empty, forcing the edge function to fall back to direct
gpsjam.org fetches (without lat/lon pre-computation).

Adds startGpsJamSeedLoop() that runs every 6 hours:
- Fetches manifest + CSV from gpsjam.org
- Parses H3 hex data with min-aircraft threshold
- Converts H3→lat/lon via h3-js (pre-computed for frontend)
- Classifies regions for conflict zone tagging
- Writes enriched data to Redis with 24h TTL
2026-03-04 22:41:12 +04:00
Elie Habib
4a8ab3855f fix: seed-insights digest shape extraction (#1011)
* feat: add seed-first pattern to 15 RPC handlers with Railway seed scripts

Migrate handlers from direct external API calls to seed-first pattern:
Railway cron seeds Redis → handlers read from Redis → fallback to live
fetch if seed stale and SEED_FALLBACK_* env enabled.

Handlers updated: earthquakes, fire-detections, internet-outages,
climate-anomalies, unrest-events, cyber-threats, market-quotes,
commodity-quotes, crypto-quotes, etf-flows, gulf-quotes,
stablecoin-markets, natural-events, displacement-summary, risk-scores.

Also adds:
- scripts/_seed-utils.mjs (shared seed framework with atomic publish,
  distributed locks, retry, freshness metadata)
- 13 seed scripts for Railway cron
- api/seed-health.js monitoring endpoint
- scripts/validate-seed-migration.mjs post-deploy validation
- Restored multi-source CII in get-risk-scores (8 sources: ACLED,
  UCDP, outages, climate, cyber, fires, GPS, Iran)

* feat: add seed scripts for market quotes, commodity quotes & airport delays

New seed scripts:
- seed-market-quotes.mjs: 28 symbols via Yahoo Finance + Finnhub
- seed-commodity-quotes.mjs: 6 commodity futures via Yahoo Finance
- seed-airport-delays.mjs: FAA + NOTAM airport closure data

Handler changes (seed-first pattern):
- list-market-quotes.ts: read from seed data before live fetch
- list-commodity-quotes.ts: read from seed data before live fetch
- list-airport-delays.ts: seed-first for FAA and NOTAM data

Other changes:
- ais-relay.cjs: add DISABLE_RELAY_MARKET_SEED guard for cutover
- _seed-utils.mjs: add sleep, parseYahooChart, writeExtraKey helpers
- seed-health.js: monitor 4 new seed domains
- validate-seed-migration.mjs: add new domains to validation

* fix: extract digest items from category buckets in seed-insights

The news digest Redis key stores items nested in category buckets
({ categories: { politics: { items: [...] }, ... } }), not as a
flat array. The script was checking for digest.items which is
undefined, causing "Digest has no items" errors on every run.
2026-03-04 21:59:23 +04:00
Elie Habib
40abcae887 feat: CII Railway seed — pre-compute instability scores from 8 sources (#996)
Adds seedCiiScores() to ais-relay.cjs that runs every 10 minutes:
- Reads 7 Redis sources (UCDP, outages, climate, cyber, fires, GPS jam, Iran)
- Calls ACLED API directly for protests/riots/battles
- Computes simplified CII scores for 20 TIER1 countries
- Writes to risk:scores:sebuf:v1 (TTL 900s) + stale key (TTL 3600s)

Frontend bootstrap hydration (already on main) consumes these scores
for instant CII panel render on page load.
2026-03-04 20:56:39 +04:00
Elie Habib
124085edd6 fix: add process.exit(0) to seed scripts for Railway cron compatibility (#999)
Railway marks cron jobs as "failed" when the Node.js process doesn't
exit cleanly. The seed scripts relied on natural event loop drain,
but undici's connection pool keeps handles alive, causing Railway to
kill the process and mark it as failed.

Changes:
- Add process.exit(0) on success and lock-skip paths in runSeed()
- Fix recordCount for crypto (.quotes) and stablecoin (.stablecoins)
- Add writeExtraKey, sleep, parseYahooChart shared utilities
- Add extraKeys option to runSeed for bootstrap hydration keys
2026-03-04 20:43:16 +04:00
Elie Habib
80b8071356 feat: server-side AI insights via Railway cron + bootstrap hydration (#1003)
Move the heavy AI insights pipeline (clustering, scoring, LLM brief)
from client-side (15-40s per user) to a 5-min Railway cron job. The
frontend reads pre-computed insights instantly via bootstrap hydration,
with graceful fallback to the existing client-side pipeline.

- Add _clustering.mjs: Jaccard clustering + importance scoring (pure JS)
- Add seed-insights.mjs: Railway cron reads digest, clusters, calls
  Groq/OpenRouter for brief, writes to Redis with LKG preservation
- Register insights key in bootstrap.js FAST_KEYS tier
- Add insights-loader.ts: module-level cached bootstrap reader
- Modify InsightsPanel.ts: server-first path (2-step progress) with
  client fallback (4-step, unchanged behavior)
- Add unit tests for clustering (12) and insights-loader (7)
2026-03-04 20:42:51 +04:00
Elie Habib
42cd258f5a fix: RSS redirect crash — allowedDomains was renamed but redirect handler not updated (#995)
The RSS_ALLOWED_DOMAINS refactor missed the redirect handler at line 4755,
causing ReferenceError: allowedDomains is not defined every time an RSS
feed returns a 301/302 redirect. This crashes the entire relay process.
2026-03-04 19:34:09 +04:00
Elie Habib
898ac7b1c4 perf(rss): route RSS direct to Railway, skip Vercel middleman (#961)
* perf(rss): route RSS direct to Railway, skip Vercel middleman

Vercel /api/rss-proxy has 65% error rate (207K failed invocations/12h).
Route browser RSS requests directly to Railway (proxy.worldmonitor.app)
via Cloudflare CDN, eliminating Vercel as middleman.

- Add VITE_RSS_DIRECT_TO_RELAY feature flag (default off) for staged rollout
- Centralize RSS proxy URL in rssProxyUrl() with desktop/dev/prod routing
- Make Railway /rss public (skip auth, keep rate limiting with CF-Connecting-IP)
- Add wildcard *.worldmonitor.app CORS + always emit Vary: Origin on /rss
- Extract ~290 RSS domains to shared/rss-allowed-domains.cjs (single source of truth)
- Convert Railway domain check to Set for O(1) lookups
- Remove rss-proxy from KEYED_CLOUD_API_PATTERN (no longer needs API key header)
- Add edge function test for shared domain list import

* fix(edge): replace node:module with JSON import for edge-compatible RSS domains

api/_rss-allowed-domains.js used createRequire from node:module which is
unsupported in Vercel Edge Runtime, breaking all edge functions (including
api/gpsjam). Replaced with JSON import attribute syntax that works in both
esbuild (Vercel build) and Node.js 22+ (tests).

Also fixed middleware.ts TS18048 error where VARIANT_OG[variant] could be
undefined.

* test(edge): add guard against node: built-in imports in api/ files

Scans ALL api/*.js files (including _ helpers) for node: module imports
which are unsupported in Vercel Edge Runtime. This would have caught the
createRequire(node:module) bug before it reached Vercel.

* fix(edge): inline domain array and remove NextResponse reference

- Replace `import ... with { type: 'json' }` in _rss-allowed-domains.js
  with inline array — Vercel esbuild doesn't support import attributes
- Replace `NextResponse.next()` with bare `return` in middleware.ts —
  NextResponse was never imported

* ci(pre-push): add esbuild bundle check and edge function tests

The pre-push hook now catches Vercel build failures locally:
- esbuild bundles each api/*.js entrypoint (catches import attribute
  syntax, missing modules, and other bundler errors)
- runs edge function test suite (node: imports, module isolation)
2026-03-04 18:42:00 +04:00
Elie Habib
78a14306d9 feat: add seed-first pattern to 15 RPC handlers with Railway seed scripts (#989)
Migrate handlers from direct external API calls to seed-first pattern:
Railway cron seeds Redis → handlers read from Redis → fallback to live
fetch if seed stale and SEED_FALLBACK_* env enabled.

Handlers updated: earthquakes, fire-detections, internet-outages,
climate-anomalies, unrest-events, cyber-threats, market-quotes,
commodity-quotes, crypto-quotes, etf-flows, gulf-quotes,
stablecoin-markets, natural-events, displacement-summary, risk-scores.

Also adds:
- scripts/_seed-utils.mjs (shared seed framework with atomic publish,
  distributed locks, retry, freshness metadata)
- 13 seed scripts for Railway cron
- api/seed-health.js monitoring endpoint
- scripts/validate-seed-migration.mjs post-deploy validation
- Restored multi-source CII in get-risk-scores (8 sources: ACLED,
  UCDP, outages, climate, cyber, fires, GPS, Iran)
2026-03-04 17:37:15 +04:00
Elie Habib
c7942b800a feat: Railway CII seed + bootstrap hydration for instant panel render (#984)
* fix: add circuit breaker + bootstrap to CII risk scores

Same pattern as theater posture (#948): replace fragile in-memory cache
+ manual persistent-cache with circuit breaker (SWR, IndexedDB, cooldown)
and bootstrap hydration. Eliminates learning-mode delay on cold start
and survives RPC failures without blanking the panel.

* fix: add localStorage sync prime for CII risk scores

getCachedScores() is called synchronously by country-intel.ts as a
fallback during learning mode. Without localStorage priming, the
breaker's async IndexedDB hydration hasn't run yet and returns null.

- Add shape validator (isValidCiiEntry) for untrusted localStorage data
- Add loadFromStorage/saveToStorage with 24h staleness ceiling
- Prime breaker synchronously at module load from localStorage
- Skip priming for empty cii arrays to avoid cached-empty trap
- Save to localStorage on both bootstrap and RPC success paths

* feat: Railway CII seed + bootstrap hydration for instant panel render

- Add 8-source CII seed to Railway (ACLED, UCDP, outages, climate, cyber, fires, GPS, Iran strikes)
- Neuter Vercel handler to read-only (returns Railway-seeded cache, never recomputes)
- Register riskScores in bootstrap FAST tier for CDN-cached delivery
- Add early CII hydration in data-loader before intelligence signals
- Add CIIPanel.renderFromCached() for instant render from bootstrap data
- Refactor cached-risk-scores.ts: circuit breaker + localStorage sync prime + bootstrap hydration
- Progressive enhancement: cached render → full 18-source local recompute (no spinner)

* fix: remove duplicate riskScores key in BOOTSTRAP_TIERS after merge
2026-03-04 15:09:48 +04:00
Elie Habib
5709ed45a2 fix: remove smartraveller.gov.au feeds causing 503 errors (#982)
The AU Smartraveller RSS feeds have been consistently returning 503
from both Vercel edge and Railway relay. Remove all references from
security-advisories feeds, rss-proxy allowed domains, and relay allowlist.
2026-03-04 14:37:24 +04:00
Elie Habib
9b46bf6f73 perf(positive-events): move GDELT fetch to Railway seed, serve from Redis cache (#957)
GDELT GEO API had 99.9% timeout rate on Vercel Edge (746 invocations, ~31s
sequential calls vs 25s edge limit). Move fetching to Railway cron (15min),
write to Redis, have Vercel serve read-only from cache with bootstrap hydration.

- Add startPositiveEventsSeedLoop() to ais-relay.cjs (3 queries, dedup, classify)
- Rewrite handler to cache-read-only pattern (matches UCDP)
- Register bootstrap key in FAST_KEYS for instant first render
- Wire getHydratedData() in data-loader before RPC fallback
2026-03-04 07:41:34 +04:00
Elie Habib
a80b462306 perf(oref): reduce proxy bandwidth with gzip + local file persistence (#928)
Add --compressed to all OREF curl requests (~90% bandwidth reduction).
Introduce 3-tier bootstrap: local file (Railway volume) → Redis → upstream,
so restarts never need to re-fetch the full AlertsHistory.json through the
paid residential proxy. Local file is kept in sync after every poll cycle
and upstream bootstrap. OREF_DATA_DIR env var opts in to local persistence.
2026-03-03 21:47:07 +04:00
Elie Habib
6ec076c8d3 test(circuit-breakers): harden regression tests with try/finally and existence guards (#911)
- Wrap all 4 behavioral it() blocks in try/finally so clearAllCircuitBreakers()
  always runs on assertion failure (P2 — leaked breaker state between tests)
- Add assert.ok(fnStart !== -1) guards for fetchHapiSummary, fetchPositiveGdeltArticles,
  and fetchGdeltArticles so renames produce a clear diagnostic (P2 — silent false-positives)
- Fix misleading comment in seed-wb-indicators.mjs: WLD/EAS are 3-char codes and
  aren't filtered by iso3.length !== 3 (P3)
- Add timeout-minutes: 10 and permissions: contents: read to seed GHA workflow (P3)
2026-03-03 15:13:29 +04:00
Elie Habib
07aca2c396 feat(conflict): seed 100 Iran events + add 20 geocoding locations (#899)
- Import latest LiveUAMap Iran events (100 events, March 2026)
- Add missing LOCATION_COORDS: Khomein, Markazi, Kashan, Qom, Ahvaz,
  Dezful, Khorramshahr, Ilam, Laar, Kermanshah, Fujairah, Hermel,
  Amman, Jeddah, Dhahran, Al Minhad, Galilee, Evin
- Bump cache-bust param _v=8 → _v=9 to bypass stale CDN/IndexedDB
2026-03-03 13:41:31 +04:00
Elie Habib
a5b2af8e11 feat(tech-readiness): bootstrap hydration via Railway seed + bootstrap key (#889)
* feat(tech-readiness): bootstrap hydration via Railway seed + bootstrap key

Add pre-computed TechReadiness rankings to the bootstrap payload so the
panel renders immediately on first load instead of waiting for 4 slow
World Bank RPC calls (which can trip circuit breakers on cold starts,
causing persistent "No data available" until the 5-min cooldown expires).

- scripts/seed-wb-indicators.mjs: new Railway seed script that fetches
  IT.NET.USER.ZS / IT.CEL.SETS.P2 / IT.NET.BBND.P2 / GB.XPD.RSDV.GD.ZS
  for all countries, computes rankings (same weights as the frontend
  getTechReadinessRankings), and writes economic:worldbank-techreadiness:v1
  to Redis with a 7-day TTL
- api/bootstrap.js: register techReadiness key in BOOTSTRAP_CACHE_KEYS
  and SLOW_KEYS (s-maxage=3600, appropriate for annual WB data)
- src/services/economic/index.ts: fast-path in getTechReadinessRankings()
  returns getHydratedData('techReadiness') immediately on first page load;
  country-specific comparison requests still use live RPCs

* ci: add weekly GHA workflow for WB tech readiness seed
2026-03-03 13:30:42 +04:00
Elie Habib
40be228713 fix(cyber): seed cyber threats on Railway + fix Cloudflare 500 errors (#880)
Railway seeding:
- Add full cyber threats seed loop in scripts/ais-relay.cjs (5 IOC sources:
  Feodo, URLhaus, C2IntelFeeds, AlienVault OTX, AbuseIPDB)
- GeoIP hydration via ipinfo.io → freeipapi.com with FIFO-capped cache (2048)
- Writes both RPC cache key (cyber:threats:v2:0:::) and bootstrap key
  (cyber:threats-bootstrap:v2) with 3h TTL
- Register cyberThreats in api/bootstrap.js BOOTSTRAP_CACHE_KEYS + SLOW_KEYS

Cloudflare 500 fixes:
- error-mapper.ts: map SyntaxError → 400 (req.json() on malformed POST body)
- summarize-article.ts: reduce LLM timeout 30s → 25s (was equal to edge budget)
- intelligence/_shared.ts: reduce UPSTREAM_TIMEOUT_MS 30_000 → 25_000
- cyber/_shared.ts: reduce source/geo timeouts and concurrency to fit edge budget
2026-03-03 10:47:37 +04:00
Elie Habib
e7f5a5b8e5 fix(market): add bootstrap hydration for markets & commodities panels (#867)
Markets and commodities panels showed "Failed to load" because they
relied entirely on the listMarketQuotes RPC while sectors worked via
bootstrap hydration. Both also shared a single circuit breaker — 2
transient failures across both calls triggered a 5-minute cooldown.

- Add bootstrap Redis keys (market:stocks-bootstrap:v1 and
  market:commodities-bootstrap:v1) to Railway seed and bootstrap API
- Hydrate markets/commodities from bootstrap on page load (same
  pattern as sectors)
- Split circuit breaker: separate stockBreaker and commodityBreaker
  so commodity failures don't kill market retries and vice versa
2026-03-03 08:30:47 +04:00
Elie Habib
e6ab1883ca fix(market): parse comma-separated query params and align Railway cache keys (#856)
* fix(market): parse comma-separated query params and align Railway cache keys

Two bugs causing all market panels to show "Failed to load":

1. Sebuf codegen assigns `params.get("symbols")` (a string) to fields
   typed as `string[]`. At runtime handlers receive "AAPL,AMZN,..."
   instead of ["AAPL","AMZN",...]. This causes:
   - `[..."string"]` spreading into characters → garbage Redis cache keys
   - `symbols.filter()` → TypeError (strings lack .filter())
   - Handlers fall through to catch → return empty `{ quotes: [] }`

2. Frontend routes commodities and sectors through `listMarketQuotes`
   RPC (via `fetchMultipleStocks`), constructing Redis keys like
   `market:quotes:v1:^VIX,CL=F,...`. But Railway seeds wrote to
   `market:commodities:v1:...` and `market:sectors:v1` — different
   key prefixes → permanent cache miss → fallback to Yahoo from
   Vercel IP → 429 rate limit → empty data.

Fix:
- Add `parseStringArray()` helper that normalizes string|string[] → string[]
- Apply to all market handlers (quotes, commodities, crypto, stablecoins)
- Railway seed now also writes under `market:quotes:v1:` keys matching
  what the Vercel handler constructs for commodity and sector symbols

* fix(economic): add 20s client-side timeout to all RPC calls

All EconomicServiceClient calls (FRED, World Bank, EIA, BIS) lacked
AbortSignal timeouts. If Vercel hangs or is slow, the circuit breaker's
execute() awaits forever, keeping panels stuck in "Fetching" state.
Add AbortSignal.timeout(20_000) to every client call so the circuit
breaker can catch the AbortError and fall through to cached/default data.
2026-03-03 04:03:36 +04:00
Elie Habib
6c4901f5da fix(aviation): move AviationStack fetching to Railway relay, reduce to 40 airports (#858)
AviationStack API calls cost ~$100/day because each cache miss triggered
114 individual API calls from Vercel Edge (where isolates don't share
in-flight dedup). This moves all AviationStack fetching to the Railway
relay (like market data, OREF, UCDP) and reduces to 40 top international
hubs (down from 114).

- Add AVIATIONSTACK_AIRPORTS constant (40 curated IATA codes)
- Add startAviationSeedLoop() to ais-relay.cjs (2h interval, 4h TTL)
- Make Vercel handler cache-read-only (getCachedJson + simulation fallback)
- Delete Vercel cron (warm-aviation-cache.ts) and remove from vercel.json
2026-03-03 03:56:38 +04:00
Elie Habib
411b015e0b fix(market+feeds): Railway market data cron + complete missing tech feed categories (#850)
* fix(tech): add missing dev/ipo/producthunt feed categories + market debug logging

Developer, IPO & SPAC, and Product Hunt panels showed UNAVAILABLE on
tech.worldmonitor.app because these categories had no server-side feed
definitions in _feeds.ts. The client fell back to per-feed RSS proxy
mode gated behind a disabled feature flag, resulting in empty panels.

- Add dev (4 feeds), ipo (2 feeds), producthunt (1 feed) to server-side
  VARIANT_FEEDS.tech so the digest endpoint includes them
- Add ipo and producthunt to client-side tech variant FEEDS so
  loadNews() iterates and renders these categories from the digest
- Add console.warn logging to Finnhub, Yahoo direct, and Yahoo relay
  failure paths in _shared.ts (all errors were silently swallowed,
  making market data debugging impossible)

* fix(market+feeds): add Railway market data cron + missing hardware/outages feed categories

Market data: Yahoo Finance returns HTTP 429 from Vercel edge IPs. Railway
relay has a different IP that Yahoo does not rate-limit. Add periodic seed
job (5min interval) that fetches quotes from Finnhub/Yahoo and writes to
Redis, so Vercel handlers serve from cache via cachedFetchJson.

- seedMarketQuotes: 25 stocks via Finnhub + 3 indices via Yahoo (staggered)
- seedCommodityQuotes: 6 commodities via Yahoo (staggered 150ms)
- seedSectorSummary: 12 sector ETFs via Finnhub, Yahoo fallback
- Redis keys match Vercel handler construction exactly (verified)
- TTL 1800s survives 5 missed seed cycles
- CHROME_UA hoisted to top-level (was defined after market code)

Feed categories: hardware and outages were missing from server-side
VARIANT_FEEDS.tech, causing UNAVAILABLE panels on tech.worldmonitor.app.
2026-03-03 03:13:22 +04:00
Elie Habib
67cdf009fd fix(relay): add exponential backoff for failing RSS feeds (#853)
RSS feeds that fail (socket hang up, timeout, non-2xx) were retried
every 60s indefinitely, hammering broken upstreams. Adds per-feed
exponential backoff: 1min → 2min → 4min → 8min → 15min cap.

- Separate rssBackoffUntil/rssFailureCount maps (no response cache mutation)
- Stale successful data served during backoff (BACKOFF-STALE)
- 503 + Retry-After header when no stale data available
- Failure count preserved across backoff expiry for fast re-escalation
- Reset on success (2xx or 304 revalidation)
2026-03-03 03:13:09 +04:00
Elie Habib
f1faf07144 fix(market+tech): Yahoo relay fallback + RSS digest relay for blocked feeds (#835)
* fix(market): route Yahoo Finance through Railway relay to bypass 429 rate limits

Yahoo Finance returns 429 from all Vercel edge IPs, causing empty market
data across MARKETS, COMMODITIES, and HEATMAP panels. Empty rate-limited
responses were also cached at full 8-min TTL, compounding the outage.

- Add /yahoo-chart proxy endpoint to Railway relay with 5-min in-memory cache
- Add relay fallback to fetchYahooQuote(): direct Yahoo → relay → null
- Return null for all empty quote results (120s negative cache vs 480s)

* fix: remove unused yahooRateLimited variable

* fix(tech-panels): route RSS digest through Railway relay when direct fetch fails

Server-side digest builder fetches RSS feeds directly from Vercel edge,
but many tech sites (a16z, Stratechery, EU Startups, etc.) block Vercel
IPs. This caused vcblogs, regionalStartups, unicorns, accelerators, and
policy categories to return 0 items → UNAVAILABLE panels.

Add Railway relay fallback to fetchAndParseRss(): direct fetch → on
failure → relay /rss proxy → parse. Same pattern as Yahoo chart fix.
2026-03-03 01:31:44 +04:00
Elie Habib
37f07a6af2 fix(prod): CORS fallback, rate-limit bump, RSS proxy allowlist (#814)
- Add wildcard CORS headers in vercel.json for /api/* routes so Vercel
  infra 500s (which bypass edge function code) still include CORS headers
- Bump rate limit from 300 to 600 req/60s in both rate-limit files to
  accommodate dashboard init burst (~30-40 parallel requests)
- Add smartraveller.gov.au (bare + www) to Railway relay RSS allowlist
- Add redirect hostname validation in fetchWithRedirects to prevent SSRF
  via open redirects on allowed domains
2026-03-03 00:25:09 +04:00
Elie Habib
9c5ad83651 feat(conflict): seed 100 Iran war events and expand geocoder (#792)
Add 26 new locations to seed script geocoder (Beersheba, Akrotiri,
Bandar Abbas, Kerman, Natanz, Beirut, Baalbek, Ras Tanura, Ras Laffan,
Quneitra, etc.) and bump CDN cache-bust _v=7 → _v=8.
2026-03-02 22:01:55 +04:00
Elie Habib
392349ee27 fix(relay): deduplicate UCDP constants crashing Railway container (#766)
PR #760 added a second UCDP implementation block (HTTP relay handler)
that redeclared const UCDP_PAGE_SIZE, UCDP_VIOLENCE_TYPE_MAP, and
functions ucdpFetchPage/ucdpDiscoverVersion already declared by the
Redis seeder block — causing SyntaxError on startup and crash-loop.

Rename relay-specific identifiers with RELAY prefix; shared constants
(UCDP_PAGE_SIZE, UCDP_TRAILING_WINDOW_MS) are reused from block 1.
2026-03-02 16:27:03 +04:00
Elie Habib
b423995363 feat(conflict): wire UCDP (#760)
* feat(conflict): wire UCDP API access token across full stack

UCDP API now requires an `x-ucdp-access-token` header. Renames the
stub `UC_DP_KEY` to `UCDP_ACCESS_TOKEN` (matching ACLED convention)
and wires it through Rust keychain, sidecar allowlist + verification,
handler fetch headers, feature toggles, and desktop settings UI.

- Rename UC_DP_KEY → UCDP_ACCESS_TOKEN in type system and labels
- Add ucdpConflicts feature toggle with required secret
- Add UCDP_ACCESS_TOKEN to Rust SUPPORTED_SECRET_KEYS (24→25)
- Add sidecar ALLOWED_ENV_KEYS entry + validation with dynamic GED version probing
- Handler sends x-ucdp-access-token header when token is present
- UC_DP_KEY fallback in handler for one-release migration window
- Update .env.example, desktop-readiness, and docs

* feat(conflict): pre-fetch UCDP events via Railway cron + Redis cache

Replace the 228-line edge handler that fetched UCDP GED API on every
request with a thin Redis reader. The heavy fetch logic (version
discovery, paginated backward fetch, 1-year trailing window filter)
now runs as a setInterval loop in the Railway relay (ais-relay.cjs)
every 6 hours, writing to Redis key conflict:ucdp-events:v1.

Changes:
- Add UCDP seed loop to ais-relay.cjs (6h interval, 6 pages, 2K cap)
- Rewrite list-ucdp-events.ts as thin Redis reader (35 lines)
- Add conflict:ucdp-events:v1 to bootstrap batch keys
- Protect key from cache-purge via durable data prefix
- Add manual-only seed-ucdp-events workflow + standalone script
- Rename panel "UCDP Events" → "Armed Conflict Events" in locale
- Add 24h TTL + 25h staleness check as safety nets
2026-03-02 16:17:17 +04:00
Elie Habib
16673d7110 fix(desktop-package): detect linux node target from host arch (#742) 2026-03-02 12:05:54 +04:00
Elie Habib
b279e881a2 feat(rag): worker-side vector store with opt-in Headline Memory (#675)
* Add Security Advisories panel with government travel alerts (#460)

* feat: add Security Advisories panel with government travel advisory feeds

Adds a new panel aggregating travel/security advisories from official
government foreign affairs agencies (US State Dept, AU DFAT Smartraveller,
UK FCDO, NZ MFAT). Advisories are categorized by severity level
(Do Not Travel, Reconsider, Caution, Normal) with filter tabs by
source country. Includes summary counts, auto-refresh, and persistent
caching via the existing data-freshness system.


* chore: update package-lock.json


* fix: event delegation, localization, and cleanup for SecurityAdvisories panel

P1 fixes:
- Use event delegation on this.content (bound once in constructor) instead
  of direct addEventListener after each innerHTML replacement — prevents
  memory leaks and stale listener issues on re-render
- Use setContent() consistently instead of mixing with this.content.innerHTML
- Add securityAdvisories translations to all 16 non-English locale files
  (panels name, component strings, common.all key)
- Revert unrelated package-lock.json version bump

P2 fixes:
- Deduplicate loadSecurityAdvisories — loadIntelligenceData now calls the
  shared method instead of inlining duplicate fetch+set logic
- Add Accept header to fetch calls for better content negotiation

* feat(advisories): add US embassy alerts, CDC, ECDC, and WHO health feeds

Adds 21 new advisory RSS feeds:
- 13 US Embassy per-country security alerts (TH, AE, DE, UA, MX, IN, PK, CO, PL, BD, IT, DO, MM)
- CDC Travel Notices
- 5 ECDC feeds (epidemiological, threats, risk assessments, avian flu, publications)
- 2 WHO feeds (global news, Africa emergencies)

Panel gains a Health filter tab for CDC/ECDC/WHO sources.
All new domains added to RSS proxy allowlist.
i18n "health" key added across all 17 locales.

* feat(cache): add negative-result caching to cachedFetchJson (#466)

When upstream APIs return errors (HTTP 403, 429, timeout), fetchers
return null. Previously null results were not cached, causing repeated
request storms against broken APIs every refresh cycle.

Now caches a sentinel value ('__WM_NEG__') with a short 2-minute TTL
on null results. Subsequent requests within that window get null
immediately without hitting upstream. Thrown errors (transient) skip
sentinel caching and retry immediately.

Also filters sentinels from getCachedJsonBatch pipeline reads and fixes
theater posture coalescing test (expected 2 OpenSky fetches for 2
theater query regions, not 1).

* feat: convert 52 API endpoints from POST to GET for edge caching (#468)

* feat: convert 52 API endpoints from POST to GET for edge caching

Convert all cacheable sebuf RPC endpoints to HTTP GET with query/path
parameters, enabling CDN edge caching to reduce costs. Flatten nested
request types (TimeRange, PaginationRequest, BoundingBox) into scalar
query params. Add path params for resource lookups (GetFredSeries,
GetHumanitarianSummary, GetCountryStockIndex, GetCountryIntelBrief,
GetAircraftDetails). Rewrite router with hybrid static/dynamic matching
for path param support.

Kept as POST: SummarizeArticle, ClassifyEvent, RecordBaselineSnapshot,
GetAircraftDetailsBatch, RegisterInterest.

Generated with sebuf v0.9.0 (protoc-gen-ts-client, protoc-gen-ts-server).

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

* fix: add rate_limited field to market response protos

The rateLimited field was hand-patched into generated files on main but
never declared in the proto definitions. Regenerating wiped it out,
breaking the build. Now properly defined in both ListEtfFlowsResponse
and ListMarketQuotesResponse protos.

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

* chore: remove accidentally committed .planning files

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Cloudflare edge caching infrastructure for api.worldmonitor.app (#471)

Route web production RPC traffic through api.worldmonitor.app via fetch
interceptor (installWebApiRedirect). Add default Cache-Control headers
(s-maxage=300, stale-while-revalidate=60) on GET 200 responses, with
no-store override for real-time endpoints (vessel snapshot). Update CORS
to allow GET method. Skip Vercel bot middleware for API subdomain using
hostname check (non-spoofable, replacing CF-Ray header approach). Update
desktop cloud fallback to route through api.worldmonitor.app.

* fix(beta): eagerly load T5-small model when beta mode is enabled

BETA_MODE now couples the badge AND model loading — the summarization-beta
model starts loading on startup instead of waiting for the first summarization call.

* fix: move 5 path-param endpoints to query params for Vercel routing (#472)

Vercel's `api/[domain]/v1/[rpc].ts` captures one dynamic segment.
Path params like `/get-humanitarian-summary/SA` add an extra segment
that has no matching route file, causing 404 on both OPTIONS preflight
and direct requests. These endpoints were broken in production.

Changes:
- Remove `{param}` from 5 service.proto HTTP paths
- Add `(sebuf.http.query)` annotations to request message fields
- Update generated client/server code to use URLSearchParams
- Update OpenAPI specs (YAML + JSON) to declare query params
- Add early-return guards in 4 handlers for missing required params
- Add happy.worldmonitor.app to runtime.ts redirect hosts

Affected endpoints:
- GET /api/conflict/v1/get-humanitarian-summary?country_code=SA
- GET /api/economic/v1/get-fred-series?series_id=T10Y2Y&limit=120
- GET /api/market/v1/get-country-stock-index?country_code=US
- GET /api/intelligence/v1/get-country-intel-brief?country_code=US
- GET /api/military/v1/get-aircraft-details?icao24=a12345

* fix(security-advisories): route feeds through RSS proxy to avoid CORS blocks (#473)

- Advisory feeds were fetched directly from the browser, hitting CORS
  on all 21 feeds (US State Dept, AU Smartraveller, US Embassies, ECDC,
  CDC, WHO). Route through /api/rss-proxy on web, keep proxyUrl for desktop.
- Fix double slash in ECDC Avian Influenza URL (323//feed → 323/feed)
- Add feeds.news24.com to RSS proxy allowlist (was returning 403)

* feat(cache): tiered edge Cache-Control aligned to upstream TTLs (#474)

* fix: move 5 path-param endpoints to query params for Vercel routing

Vercel's `api/[domain]/v1/[rpc].ts` captures one dynamic segment.
Path params like `/get-humanitarian-summary/SA` add an extra segment
that has no matching route file, causing 404 on both OPTIONS preflight
and direct requests. These endpoints were broken in production.

Changes:
- Remove `{param}` from 5 service.proto HTTP paths
- Add `(sebuf.http.query)` annotations to request message fields
- Update generated client/server code to use URLSearchParams
- Update OpenAPI specs (YAML + JSON) to declare query params
- Add early-return guards in 4 handlers for missing required params
- Add happy.worldmonitor.app to runtime.ts redirect hosts

Affected endpoints:
- GET /api/conflict/v1/get-humanitarian-summary?country_code=SA
- GET /api/economic/v1/get-fred-series?series_id=T10Y2Y&limit=120
- GET /api/market/v1/get-country-stock-index?country_code=US
- GET /api/intelligence/v1/get-country-intel-brief?country_code=US
- GET /api/military/v1/get-aircraft-details?icao24=a12345

* feat(cache): add tiered edge Cache-Control aligned to upstream TTLs

Replace flat s-maxage=300 with 5 tiers (fast/medium/slow/static/no-store)
mapped per-endpoint to respect upstream Redis TTLs. Adds stale-if-error
resilience headers and X-No-Cache plumbing for future degraded responses.
X-Cache-Tier debug header gated behind ?_debug query param.

* fix(tech): use rss() for CISA feed, drop build from pre-push hook (#475)

- CISA Advisories used dead rss.worldmonitor.app domain (404), switch to rss() helper
- Remove Vite build from pre-push hook (tsc already catches errors)

* fix(desktop): enable click-to-play YouTube embeds + CISA feed fixes (#476)

* fix(tech): use rss() for CISA feed, drop build from pre-push hook

- CISA Advisories used dead rss.worldmonitor.app domain (404), switch to rss() helper
- Remove Vite build from pre-push hook (tsc already catches errors)

* fix(desktop): enable click-to-play for YouTube embeds in WKWebView

WKWebView blocks programmatic autoplay in cross-origin iframes regardless
of allow attributes, Permissions-Policy, mute-first retries, or secure
context. Documented all 10 approaches tested in docs/internal/.

Changes:
- Switch sidecar embed origin from 127.0.0.1 to localhost (secure context)
- Add MutationObserver + retry chain as best-effort autoplay attempts
- Use postMessage('*') to fix tauri://localhost cross-origin messaging
- Make sidecar play overlay non-interactive (pointer-events:none)
- Fix .webcam-iframe pointer-events:none blocking clicks in grid view
- Add expand button to grid cells for switching to single view on desktop
- Add http://localhost:* to CSP frame-src in index.html and tauri.conf.json

* fix(gateway): convert stale POST requests to GET for backwards compat (#477)

Stale cached client bundles still send POST to endpoints converted to
GET in PR #468, causing 404s. The gateway now parses the POST JSON body
into query params and retries the match as GET.

* feat(proxy): add Cloudflare edge caching for proxy.worldmonitor.app (#478)

Add CDN-Cache-Control headers to all proxy endpoints so Cloudflare can
cache responses at the edge independently of browser Cache-Control:

- RSS: 600s edge + stale-while-revalidate=300 (browser: 300s)
- UCDP: 3600s edge (matches browser)
- OpenSky: 15s edge (browser: 30s) for fresher flight data
- WorldBank: 1800s/86400s edge (matches browser)
- Polymarket: 120s edge (matches browser)
- Telegram: 10s edge (matches browser)
- AIS snapshot: 2s edge (matches browser)

Also fixes:
- Vary header merging: sendCompressed/sendPreGzipped now merge existing
  Vary: Origin instead of overwriting, preventing cross-origin cache
  poisoning at the edge
- Stale fallback responses (OpenSky, WorldBank, Polymarket, RSS) now
  set Cache-Control: no-store + CDN-Cache-Control: no-store to prevent
  edge caching of degraded responses
- All no-cache branches get CDN-Cache-Control: no-store
- /opensky-reset gets no-store (state-changing endpoint)

* fix(sentry): add noise filters for 4 unresolved issues (#479)

- Tighten AbortError filter to match "AbortError: The operation was aborted"
- Filter "The user aborted a request" (normal navigation cancellation)
- Filter UltraViewer service worker injection errors (/uv/service/)
- Filter Huawei WebView __isInQueue__ injection

* feat: configurable VITE_WS_API_URL + harden POST→GET shim (#480)

* fix(gateway): harden POST→GET shim with scalar guard and size limit

- Only convert string/number/boolean values to query params (skip objects,
  nested arrays, __proto__ etc.) to prevent prototype pollution vectors
- Skip body parsing for Content-Length > 1MB to avoid memory pressure

* feat: make API base URL configurable via VITE_WS_API_URL

Replace hardcoded api.worldmonitor.app with VITE_WS_API_URL env var.
When empty, installWebApiRedirect() is skipped entirely — relative
/api/* calls stay on the same domain (local installs). When set,
browser fetch is redirected to that URL.

Also adds VITE_WS_API_URL and VITE_WS_RELAY_URL hostnames to
APP_HOSTS allowlist dynamically.

* fix(analytics): use greedy regex in PostHog ingest rewrites (#481)

Vercel's :path* wildcard doesn't match trailing slashes that
PostHog SDK appends (e.g. /ingest/s/?compression=...), causing 404s.
Switch to :path(.*) which matches all path segments including
trailing slashes. Ref: PostHog/posthog#17596

* perf(proxy): increase AIS snapshot edge TTL from 2s to 10s (#482)

With 20k requests/30min (60% of proxy traffic) and per-PoP caching,
a 2s edge TTL expires before the next request from the same PoP arrives,
resulting in near-zero cache hits. 10s allows same-PoP dedup while
keeping browser TTL at 2s for fresh vessel positions.

* fix(markets): commodities panel showing stocks instead of commodities (#483)

The shared circuit breaker (cacheTtlMs: 0) cached the stocks response,
then the stale-while-revalidate path returned that cached stocks data
for the subsequent commodities fetch. Skip SWR when caching is disabled.

* feat(gateway): complete edge cache tier coverage + degraded-response policy (#484)

- Add 11 missing GET routes to RPC_CACHE_TIER map (8 slow, 3 medium)
- Add response-headers side-channel (WeakMap) so handlers can signal
  X-No-Cache without codegen changes; wire into military-flights and
  positive-geo-events handlers on upstream failure
- Add env-controlled per-endpoint tier override (CACHE_TIER_OVERRIDE_*)
  for incident response rollback
- Add VITE_WS_API_URL hostname allowlist (*.worldmonitor.app + localhost)
- Fix fetch.bind(globalThis) in positive-events-geo.ts (deferred lambda)
- Add CI test asserting every generated GET route has an explicit cache
  tier entry (prevents silent default-tier drift)

* chore: bump version to 2.5.20 + changelog

Covers PRs #452–#484: Cloudflare edge caching, commodities SWR fix,
security advisories panel, settings redesign, 52 POST→GET migrations.

* fix(rss): remove stale indianewsnetwork.com from proxy allowlist (#486)

Feed has no <pubDate> fields and latest content is from April 2022.
Not referenced in any feed config — only in the proxy domain allowlist.

* feat(i18n): add Korean (한국어) localization (#487)

- Add ko.json with all 1606 translation keys matching en.json structure
- Register 'ko' in SUPPORTED_LANGUAGES, LANGUAGES display array, and locale map
- Korean appears as 🇰🇷 한국어 in the language dropdown

* feat: add Polish tv livestreams (#488)

* feat(rss): add Axios (api.axios.com/feed) as US news source (#494)

Add api.axios.com to proxy allowlist and CSP connect-src, register
Axios feed under US category as Tier 2 mainstream source.

* perf: bootstrap endpoint + polling optimization (#495)

* perf: bootstrap endpoint + polling optimization (phases 3-4)

Replace 15+ individual RPC calls on startup with a single /api/bootstrap
batch call that fetches pre-cached data from Redis. Consolidate 6 panel
setInterval timers into the central RefreshScheduler for hidden-tab
awareness (10x multiplier) and adaptive backoff (up to 4x for unchanged
data). Convert IntelligenceGapBadge from 10s polling to event-driven
updates with 60s safety fallback.

* fix(bootstrap): inline Redis + cache keys in edge function

Vercel Edge Functions cannot resolve cross-directory TypeScript imports
from server/_shared/. Inline getCachedJsonBatch and BOOTSTRAP_CACHE_KEYS
directly in api/bootstrap.js. Add sync test to ensure inlined keys stay
in sync with the canonical server/_shared/cache-keys.ts registry.

* test: add Edge Function module isolation guard for all api/*.js files

Prevents any Edge Function from importing from ../server/ or ../src/
which breaks Vercel builds. Scans all 12 non-helper Edge Functions.

* fix(bootstrap): read unprefixed cache keys on all environments

Preview deploys set VERCEL_ENV=preview which caused getKeyPrefix() to
prefix Redis keys with preview:<sha>:, but handlers only write to
unprefixed keys on production. Bootstrap is a read-only consumer of
production cache — always read unprefixed keys.

* fix(bootstrap): wire sectors hydration + add coverage guard

- Wire getHydratedData('sectors') in data-loader to skip Yahoo Finance
  fetch when bootstrap provides sector data
- Add test ensuring every bootstrap key has a getHydratedData consumer
  — prevents adding keys without wiring them

* fix(server): resolve 25 TypeScript errors + add server typecheck to CI

- _shared.ts: remove unused `delay` variable
- list-etf-flows.ts: add missing `rateLimited` field to 3 return literals
- list-market-quotes.ts: add missing `rateLimited` field to 4 return literals
- get-cable-health.ts: add non-null assertions for regex groups and array access
- list-positive-geo-events.ts: add non-null assertion for array index
- get-chokepoint-status.ts: add required fields to request objects
- CI: run `typecheck:api` (tsconfig.api.json) alongside `typecheck` to catch
  server/ TS errors before merge

* feat(military): server-side military bases 125K + rate limiting (#496)

* feat(military): server-side military bases with 125K entries + rate limiting (#485)

Migrate military bases from 224 static client-side entries to 125,380
server-side entries stored in Redis GEO sorted sets, served via
bbox-filtered GEOSEARCH endpoint with server-side clustering.

Data pipeline:
- Pizzint/Polyglobe: 79,156 entries (Supabase extraction)
- OpenStreetMap: 45,185 entries
- MIRTA: 821 entries
- Curated strategic: 218 entries
- 277 proximity duplicates removed

Server:
- ListMilitaryBases RPC with GEOSEARCH + HMGET + tier/filter/clustering
- Antimeridian handling (split bbox queries)
- Blue-green Redis deployment with atomic version pointer switch
- geoSearchByBox() + getHashFieldsBatch() helpers in redis.ts

Security:
- @upstash/ratelimit: 60 req/min sliding window per IP
- IP spoofing fix: prioritize x-real-ip (Vercel-injected) over x-forwarded-for
- Require API key for non-browser requests (blocks unauthenticated curl/scripts)
- Input validation: allowlisted types/kinds, regex country, clamped bbox/zoom

Frontend:
- Viewport-driven loading with bbox quantization + debounce
- Server-side grid clustering at low zoom levels
- Enriched popup with kind, category badges (airforce/naval/nuclear/space)
- Static 224 bases kept as search fallback + initial render

* fix(military): fallback to production Redis keys in preview deployments

Preview deployments prefix Redis keys with `preview:{sha}:` but military
bases data is seeded to unprefixed (production) keys. When the prefixed
`military:bases:active` key is missing, fall back to the unprefixed key
and use raw (unprefixed) keys for geo/meta lookups.

* fix: remove unused 'remaining' destructure in rate-limit (TS6133)

* ci: add typecheck:api to pre-push hook to catch server-side TS errors

* debug(military): add X-Bases-Debug response header for preview diagnostics

* fix(bases): trigger initial server fetch on map load

fetchServerBases() was only called on moveend — if the user
never panned/zoomed, the API was never called and only the 224
static fallback bases showed.

* perf(military): debounce base fetches + upgrade edge cache to static tier (#497)

- Add 300ms debounce on moveend to prevent rapid pan flooding
- Fixes stale-bbox bug where pendingFetch returns old viewport data
- Upgrade edge cache tier from medium (5min) to static (1hr) — bases are
  static infrastructure, aligned with server-side cachedFetchJson TTL
- Keep error logging in catch blocks for production diagnostics

* fix(cyber): make GeoIP centroid fallback jitter deterministic (#498)

Replace Math.random() jitter with DJB2 hash seeded by the threat
indicator (IP/URL), so the same threat always maps to the same
coordinates across requests while different threats from the same
country still spread out.

Closes #203

Co-authored-by: Chris Chen <fuleinist@users.noreply.github.com>

* fix: use cross-env for Windows-compatible npm scripts (#499)

Replace direct `VAR=value command` syntax with cross-env/cross-env-shell
so dev, build, test, and desktop scripts work on Windows PowerShell/CMD.

Co-authored-by: facusturla <facusturla@users.noreply.github.com>

* feat(live-news): add CBC News to optional North America channels (#502)

YouTube handle @CBCNews with fallback video ID 5vfaDsMhCF4.

* fix(bootstrap): harden hydration cache + polling review fixes (#504)

- Filter null/undefined values before storing in hydration cache to
  prevent future consumers using !== undefined from misinterpreting
  null as valid data
- Debounce wm:intelligence-updated event handler via requestAnimationFrame
  to coalesce rapid alert generation into a single render pass
- Include alert IDs in StrategicRiskPanel change fingerprint so content
  changes are detected even when alert count stays the same
- Replace JSON.stringify change detection in ServiceStatusPanel with
  lightweight name:status fingerprint
- Document max effective refresh interval (40x base) in scheduler

* fix(geo): tokenization-based keyword matching to prevent false positives (#503)

* fix(geo): tokenization-based keyword matching to prevent false positives

Replace String.includes() with tokenization-based Set.has() matching
across the geo-tagging pipeline. Prevents false positives like "assad"
matching inside "ambassador" and "hts" matching inside "rights".

- Add src/utils/keyword-match.ts as single source of truth
- Decompose possessives/hyphens ("Assad's" → includes "assad")
- Support multi-word phrase matching ("white house" as contiguous)
- Remove false-positive-prone DC keywords ('house', 'us ')
- Update 9 consumer files across geo-hub, map, CII, and asset systems
- Add 44 tests covering false positives, true positives, edge cases

Co-authored-by: karim <mirakijka@gmail.com>
Fixes #324

* fix(geo): add inflection suffix matching + fix test imports

Address code review feedback:

P1a: Add suffix-aware matching for plurals and demonyms so existing
keyword lists don't regress (houthi→houthis, ukraine→ukrainian,
iran→iranian, israel→israeli, russia→russian, taiwan→taiwanese).
Uses curated suffix list + e-dropping rule to avoid false positives.

P1b: Expand conflictTopics arrays in DeckGLMap and Map with demonym
forms so "Iranian senate..." correctly registers as conflict topic.

P2: Replace inline test functions with real module import via tsx.
Tests now exercise the production keyword-match.ts directly.

* fix: wire geo-keyword tests into test:data command

The .mts test file wasn't covered by `node --test tests/*.test.mjs`.
Add `npx tsx --test tests/*.test.mts` so test:data runs both suites.

* fix: cross-platform test:data + pin tsx in devDependencies

- Use tsx as test runner for both .mjs and .mts (single invocation)
- Removes ; separator which breaks on Windows cmd.exe
- Add tsx to devDependencies so it works in offline/CI environments

* fix(geo): multi-word demonym matching + short-keyword suffix guard

- Add wordMatches() for suffix-aware phrase matching so "South Korean"
  matches keyword "south korea" and "North Korean" matches "north korea"
- Add MIN_SUFFIX_KEYWORD_LEN=4 guard so short keywords like "ai", "us",
  "hts" only do exact-match (prevents "ais"→"ai", "uses"→"us" false positives)
- Add 5 new tests covering both fixes (58 total, all passing)

* fix(geo): support plural demonyms in keyword matching

Add compound suffixes (ians, eans, ans, ns, is) to handle plural
demonym forms like "Iranians"→"iran", "Ukrainians"→"ukraine",
"Russians"→"russia", "Israelis"→"israel". Adds 5 new tests (63 total).

---------

Co-authored-by: karim <mirakijka@gmail.com>

* chore: strip 61 debug console.log calls from 20 service files (#501)

* chore: strip 61 debug console.log calls from services

Remove development/tracing console.log statements from 20 files.
These add noise to production browser consoles and increase bundle size.

Preserved: all console.error (error handling) and console.warn (warnings).
Preserved: debug-gated logs in runtime.ts (controlled by verbose flag).

Removed: debugInjectTestEvents() from geo-convergence.ts (test-only code).
Removed: logSummary()/logReport() methods that were pure console.log wrappers.

* fix: remove orphaned stubs and remaining debug logs from stripped services

- Remove empty logReport() method and unused startTime variable (parallel-analysis.ts)
- Remove orphaned console.group/console.groupEnd pair (parallel-analysis.ts)
- Remove empty logSignalSummary() export (signal-aggregator.ts)
- Remove logSignalSummary import/call and 3 remaining console.logs (InsightsPanel.ts)
- Remove no-op logDirectFetchBlockedOnce() and dead infrastructure (prediction/index.ts)

* fix: generalize Vercel preview origin regex + include filters in bases cache key (#506)

- api/_api-key.js: preview URL pattern was user-specific (-elie-),
  rejecting other collaborators' Vercel preview deployments.
  Generalized to match any worldmonitor-*.vercel.app origin.

- military-bases.ts: client cache key only checked bbox/zoom, ignoring
  type/kind/country filters. Switching filters without panning returned
  stale results. Unified into single cacheKey string.

* fix(prediction): filter stale/expired markets from Polymarket panel (#507)

Prediction panel was showing expired markets (e.g. "Will US strike Iran
on Feb 9" at 0%). Root causes: no active/archived API filters, no
end_date_min param, no client-side expiry guard, and sub-market selection
picking highest volume before filtering expired ones.

- Add active=true, archived=false, end_date_min API params to all 3
  Gamma API call sites (events, markets, probe)
- Pre-filter sub-markets by closed/expired BEFORE volume selection in
  both fetchPredictions() and fetchCountryMarkets()
- Add defense-in-depth isExpired() client-side filter on final results
- Propagate endDate through all market object paths including sebuf
  fallback
- Show expiry date in PredictionPanel UI with new .prediction-meta
  layout
- Add "closes" i18n key to all 18 locale files
- Add endDate to server handler GammaMarket/GammaEvent interfaces and
  map to proto closesAt field

* fix(relay): guard proxy handlers against ERR_HTTP_HEADERS_SENT crash (#509)

Polymarket and World Bank proxy handlers had unguarded res.writeHead()
calls in error/timeout callbacks that race with the response callback.
When upstream partially responds then times out, both paths write
headers → process crash. Replace 5 raw writeHead+end calls with
safeEnd() which checks res.headersSent before writing.

* feat(breaking-news): add active alert banner with audio for critical/high RSS items (#508)

RSS items classified as critical/high threat now trigger a full-width
breaking news banner with audio alert, auto-dismiss (60s/30s by severity),
visibility-aware timer pause, dedup, and a toggle in the Intelligence
Findings dropdown.

* fix(sentry): filter Android OEM WebView bridge injection errors (#510)

Add ignoreErrors pattern for LIDNotifyId, onWebViewAppeared, and
onGetWiFiBSSID — native bridge functions injected by Lenovo/Huawei
device SDKs into Chrome Mobile WebView. No stack frames in our code.

* chore: add validated telegram channels list (global + ME + Iran + cyber) (#249)

* feat(conflict): add Iran Attacks map layer + strip debug logs (#511)

* chore: strip 61 debug console.log calls from services

Remove development/tracing console.log statements from 20 files.
These add noise to production browser consoles and increase bundle size.

Preserved: all console.error (error handling) and console.warn (warnings).
Preserved: debug-gated logs in runtime.ts (controlled by verbose flag).

Removed: debugInjectTestEvents() from geo-convergence.ts (test-only code).
Removed: logSummary()/logReport() methods that were pure console.log wrappers.

* fix: remove orphaned stubs and remaining debug logs from stripped services

- Remove empty logReport() method and unused startTime variable (parallel-analysis.ts)
- Remove orphaned console.group/console.groupEnd pair (parallel-analysis.ts)
- Remove empty logSignalSummary() export (signal-aggregator.ts)
- Remove logSignalSummary import/call and 3 remaining console.logs (InsightsPanel.ts)
- Remove no-op logDirectFetchBlockedOnce() and dead infrastructure (prediction/index.ts)

* feat(conflict): add Iran Attacks map layer

Adds a new Iran-focused conflict events layer that aggregates real-time
events, geocodes via 40-city lookup table, caches 15min in Redis, and
renders as a toggleable DeckGL ScatterplotLayer with severity coloring.

- New proto + codegen for ListIranEvents RPC
- Server handler with HTML parsing, city geocoding, category mapping
- Frontend service with circuit breaker
- DeckGL ScatterplotLayer with severity-based color/size
- MapPopup with sanitized source links
- iranAttacks toggle across all variants, harnesses, and URL state

* fix: resolve bootstrap 401 and 429 rate limiting on page init (#512)

Same-origin browser requests don't send Origin header (per CORS spec),
causing validateApiKey to reject them. Extract origin from Referer as
fallback. Increase rate limit from 60 to 200 req/min to accommodate
the ~50 requests fired during page initialization.

* fix(relay): prevent Polymarket OOM via request deduplication (#513)

Concurrent Polymarket requests for the same cache key each fired
independent https.get() calls. With 12 categories × multiple clients,
740 requests piled up in 10s, all buffering response bodies → 4.1GB
heap → OOM crash on Railway.

Fix: in-flight promise map deduplicates concurrent requests to the
same cache key. 429/error responses are negative-cached for 30s to
prevent retry storms.

* fix(threat-classifier): add military/conflict keyword gaps and news-to-conflict bridge (#514)

Breaking news headlines like "Israel's strike on Iran" were classified as
info level because the keyword classifier lacked standalone conflict phrases.
Additionally, the conflict instability score depended solely on ACLED data
(1-7 day lag) with no bridge from real-time breaking news.

- Add 3 critical + 18 high contextual military/conflict keywords
- Preserve threat classification on semantically merged clusters
- Add news-derived conflict floor when ACLED/HAPI report zero signal
- Upsert news events by cluster ID to prevent duplicates
- Extract newsEventIndex to module-level Map for serialization safety

* fix(breaking-news): let critical alerts bypass global cooldown and replace HIGH alerts (#516)

Global cooldown (60s) was blocking critical alerts when a less important
HIGH alert fired from an earlier RSS batch. Added priority-aware cooldown
so critical alerts always break through. Banner now auto-dismisses HIGH
alerts when a CRITICAL arrives. Added Iran/strikes keywords to classifier.

* fix(rate-limit): increase sliding window to 300 req/min (#515)

App init fires many concurrent classify-event, summarize-article, and
record-baseline-snapshot calls, exhausting the 200/min limit and causing
429s. Bump to 300 as a temporary measure while client-side batching is
implemented.

* fix(breaking-news): fix fake pubDate fallback and filter noisy think-tank alerts (#517)

Two bugs causing stale CrisisWatch article to fire as breaking alert:
1. Non-standard pubDate format ("Friday, February 27, 2026 - 12:38")
   failed to parse → fallback was `new Date()` (NOW) → day-old articles
   appeared as "just now" and passed recency gate on every fetch
2. Tier 3+ sources (think tanks) firing alerts on keyword-only matches
   like "War" in policy analysis titles — too noisy for breaking alerts

Fix: parsePubDate() handles non-standard formats and falls back to
epoch (not now). Tier 3+ sources require LLM classification to fire.

* fix: make iran-events handler read-only from Redis (#518)

Remove server-side LiveUAMap scraper (blocked by Cloudflare 403 on
Vercel IPs). Handler now reads pre-populated Redis cache pushed from
local browser scraping. Change cache tier from slow to fast to prevent
CDN from serving stale empty responses for 30+ minutes.

* fix(relay): Polymarket circuit breaker + concurrency limiter (OOM fix) (#519)

* fix(rate-limit): increase sliding window to 300 req/min

App init fires many concurrent classify-event, summarize-article, and
record-baseline-snapshot calls, exhausting the 200/min limit and causing
429s. Bump to 300 as a temporary measure while client-side batching is
implemented.

* fix(relay): add Polymarket circuit breaker + concurrency limiter to prevent OOM

Railway relay OOM crash: 280 Polymarket 429 errors in 8s, heap hit 3.7GB.
Multiple unique cache keys bypassed per-key dedup, flooding upstream.

- Circuit breaker: trips after 5 consecutive failures, 60s cooldown
- Concurrent upstream limiter: max 3 simultaneous requests
- Negative cache TTL: 30s → 60s to reduce retry frequency
- Upstream slot freed on response.on('end'), not headers, preventing
  body buffer accumulation past the concurrency cap

* fix(relay): guard against double-finalization on Polymarket timeout

request.destroy() in timeout handler also fires request.on('error'),
causing double decrement of polymarketActiveUpstream (counter goes
negative, disabling concurrency cap) and double circuit breaker trip.

Add finalized guard so decrement + failure accounting happens exactly
once per request regardless of which error path fires first.

* fix(threat-classifier): stagger AI classification requests to avoid Groq 429 (#520)

flushBatch() fired up to 20 classifyEvent RPCs simultaneously via
Promise.all, instantly hitting Groq's ~30 req/min rate limit.

- Sequential execution with 2s min-gap between requests (~28 req/min)
- waitForGap() enforces hard floor + jitter across batch boundaries
- batchInFlight guard prevents concurrent flush loops
- 429/5xx: requeue failed job (with retry cap) + remaining untouched jobs
- Queue cap at 100 items with warn on overflow

* fix(relay): regenerate package-lock.json with telegram dependency

The lockfile was missing resolved entries for the telegram package,
causing Railway to skip installation despite it being in package.json.

* chore: trigger deploy to flush CDN cache for iran-events endpoint

* Revert "fix(relay): regenerate package-lock.json with telegram dependency"

This reverts commit a8d5e1dbbd.

* fix(relay): add POLYMARKET_ENABLED env flag kill switch (#523)

Set POLYMARKET_ENABLED=false on Railway to disable all Polymarket
upstream requests. Returns 503 immediately, preventing OOM crashes.

* fix(breaking-news): fill keyword gaps missing real Iran attack headlines (#521)

* fix(breaking-news): fill keyword gaps that miss real Iran attack headlines

Three root causes for zero alerts during the Iran war:

1. Keyword gaps — top Iran headlines failed classification:
   - "US and Israel attack Iran" → info (no "attack iran" keyword)
   - "attacked Iran" → info (only "attacks iran" existed, plural)
   - "Explosions heard in Tehran" → info (no "explosions" keyword)
   Added: attack iran, attacked iran, attack on iran, attack against iran,
   bombing/bombed iran, war against iran (CRITICAL); explosions,
   launched/launches attacks, retaliatory/preemptive/preventive attack (HIGH)

2. 5-item RSS limit — Al Jazeera's CRITICAL "major combat operations"
   headline was item #7 and never reached the classifier. Increased
   per-feed limit from 5 to 10.

3. False positive — "OpenAI strikes deal with Pentagon" matched HIGH
   keyword "strikes". Added "strikes deal/agreement/partnership" to
   exclusions.

* fix(threat-classifier): prevent Iran compound keyword false positives

"attack iran" as plain substring matched "Iran-backed" and "Iranian"
in headlines about proxy groups, not direct attacks on Iran.

Added TRAILING_BOUNDARY_KEYWORDS set with negative lookahead (?![\w-])
for all Iran compound keywords. This rejects "Iran-backed militias"
and "Iranian targets" while still matching "attack Iran:" and
"attack Iran" at end of string.

Addresses Codex review comment on PR #521.

* fix(relay): regenerate package-lock.json with telegram dependency (#522)

The lockfile was missing resolved entries for the telegram package,
causing Railway to skip installation despite it being in package.json.

* fix(iran): bypass stale CDN cache for iran-events endpoint (#524)

The CDN cached empty {events:[],scrapedAt:0} from the pre-Redis
deployment and Vercel deploy didn't purge all edge nodes. Add ?_v=2
query param to force cache miss until CDN naturally expires the
stale slow-tier entry.

* fix(focal-points): attribute theater military activity to target nations (#525)

The signal aggregator attributed military flights/vessels to the country
they're physically over (point-in-polygon). Aircraft attacking Iran from
the Persian Gulf got attributed to XX/IQ/SA, not IR — so Iran showed
ELEVATED in Focal Points despite being under active attack (CRIT in
Strategic Posture).

Feed theater-level posture data back into the signal aggregator for
target nations (Iran, Taiwan, North Korea, Gaza, Yemen) so they get
credited for military activity in their theater bounding box. Includes
double-count guard to skip if the nation already has signals.

Also fixes stale "sebuf" comment in threat-classifier.

* fix(relay): block rsshub.app requests with 410 Gone (#526)

Stale clients still send RSS requests to rsshub.app (NHK, MOFCOM, MIIT).
These feeds were migrated to Google News RSS but cached PWA clients keep
hitting the relay, which forwards to rsshub.app and gets 403.

- Add explicit blocklist returning 410 Gone before allowlist check
- Remove rsshub.app from all allowlists (relay, edge proxy, vite)
- Remove dead AP News dev proxy target

* feat(map): prioritize Iran Attacks layer (#527)

* feat(map): move Iran Attacks layer to first position and enable by default

Move iranAttacks to the top of the layer toggle list in the full
(geopolitical) variant so it appears first. Enable it by default on
both desktop and mobile during the active conflict.

* feat(map): add Iran Attacks layer support to SVG/mobile map

- Implement setIranEvents() in SVG Map (was no-op)
- Render severity-colored circle markers matching DeckGL layer
- Add iranAttacks to mobile layer toggles (first position)
- Forward setIranEvents to SVG map in MapContainer
- Add IranEventPopupData to PopupData union for click popups
- Add .iran-event-marker CSS with pulse animation
- Add data-layer-hidden-iranAttacks CSS toggle

* fix(geo): expand geo hub index with 60+ missing world locations (#528)

The geo hub index only had ~30 entries, missing all Gulf states (UAE,
Qatar, Bahrain, Kuwait, Oman), Iraq cities, and many world capitals.
News mentioning Abu Dhabi, Dubai, Baghdad, etc. had no lat/lon assigned
so they never appeared on the map.

Added: Gulf capitals (Abu Dhabi, Dubai, Doha, Manama, Kuwait, Muscat),
Iraq (Baghdad, Erbil, Basra), Jordan, Istanbul, Haifa, Dimona, Isfahan,
Kabul, Mumbai, Shanghai, Hong Kong, Singapore, Manila, Jakarta, Bangkok,
Hanoi, Canberra, all major European capitals (Rome, Madrid, Warsaw,
Bucharest, Helsinki, Stockholm, Oslo, Baltics, Athens, Belgrade, Minsk,
Tbilisi, Chisinau, Yerevan, Baku), Americas (Ottawa, Mexico City,
Brasilia, Buenos Aires, Caracas, Bogota, Havana), Africa (Nairobi,
Pretoria, Lagos, Kinshasa, Mogadishu, Tripoli, Tunis, Algiers, Rabat),
conflict zones (Iraq, Kashmir, Golan), chokepoints (Malacca, Panama,
Gibraltar), and US military bases (Ramstein, Incirlik, Diego Garcia,
Guam, Okinawa).

* fix(iran): bust CDN cache to serve updated Gulf-geocoded events (#532)

CDN edge cache was still serving stale 93-event response without
Gulf state coordinates (UAE, Bahrain, Qatar, Kuwait). Bump cache
key from ?_v=2 to ?_v=3 so browsers fetch fresh 100-event data.
Also gitignore internal/ for private tooling scripts.

* fix(alerts): remove SESSION_START gate that blocked pre-existing breaking news (#533)

The isRecent() function used Math.max(now - 15min, SESSION_START) as
recency cutoff. Since SESSION_START = Date.now() at module load, items
published before page load could never trigger alerts — they failed the
SESSION_START gate in the first 15 min, then aged past the 15-min window.

Now uses only the 15-minute recency window. Spam prevention remains via
per-event dedup (30 min), global cooldown (60s), and source tier filter.

* fix(relay): Telegram + OOM + memory cleanup (#531)

* fix(relay): resolve Telegram missing package, OOM crashes, and memory cleanup

- Add `telegram` and `ws` to root dependencies so Railway's `npm install` installs them
- Log V8 heap limit at startup to confirm NODE_OPTIONS is active
- Make MAX_VESSELS/MAX_VESSEL_HISTORY env-configurable (default 20k, down from 50k)
- Add permanent latch to skip Telegram import retries when package is missing
- Raise memory cleanup threshold from 450MB to 2GB (env-configurable)
- Clear all caches (RSS, Polymarket, WorldBank) during emergency cleanup

* fix(relay): treat blank env vars as unset in safeInt

Number('') === 0 passes isFinite, silently clamping caps to 1000
instead of using the 20000 default. Guard empty/null before parsing.

* fix(live-news): replace 7 stale YouTube fallback video IDs (#535)

Validated all 23 YouTube fallbackVideoIds via oEmbed API and all 9
HLS URLs. Found 5 broken IDs (403 embed-restricted or 404 deleted)
plus 2 previously identified stale IDs:

- Fox News: QaftgYkG-ek → ZvdiJUYGBis
- Sky News Arabia: MN50dHFHMKE → U--OjmpjF5o
- RTVE 24H: 7_srED6k0bE → -7GEFgUKilA
- CNN Brasil: 1kWRw-DA6Ns → 6ZkOlaGfxq4
- C5N: NdQSOItOQ5Y → SF06Qy1Ct6Y
- TBS NEWS DIG: ohI356mwBp8 → Anr15FA9OCI
- TRT World: CV5Fooi8WDI → ABfFhWzWs0s

All 9 HLS URLs validated OK. 16 remaining YouTube IDs validated OK.

* fix(relay): fix telegram ESM import path and broaden latch regex

- `import('telegram/sessions')` fails with "Directory import is not
  supported resolving ES modules" — use explicit `telegram/sessions/index.js`
- Broaden permanent-disable latch to also catch "Directory import" errors

* fix(ui): move download banner to bottom-right (#536)

* fix(alerts): remove SESSION_START gate that blocked pre-existing breaking news

The isRecent() function used Math.max(now - 15min, SESSION_START) as
recency cutoff. Since SESSION_START = Date.now() at module load, items
published before page load could never trigger alerts — they failed the
SESSION_START gate in the first 15 min, then aged past the 15-min window.

Now uses only the 15-minute recency window. Spam prevention remains via
per-event dedup (30 min), global cooldown (60s), and source tier filter.

* fix(ui): move download banner to bottom-right of screen

Repositioned from top-right (overlapping content) to bottom-right.
Dismissal already persists via localStorage. Added TODO for header
download link.

* Revert "fix(relay): fix telegram ESM import path and broaden latch regex"

This reverts commit 1f2f0175ab.

* Revert "Revert "fix(relay): fix telegram ESM import path and broaden latch regex"" (#537)

This reverts commit ad41a2e2d245850fd4b699af2adbe53acca80325.

* feat: add day/night solar terminator overlay to map (#529)

* Trigger redeploy with preview env vars

* Trigger deployment

* chore: trigger redeploy for PR #41

* chore: trigger Vercel redeploy (edge function transient failure)

* chore: retrigger Vercel deploy

* feat: add Nigeria feeds and Greek locale feeds (#271)

- Add 5 Nigeria news sources to Africa section (Premium Times, Vanguard,
  Channels TV, Daily Trust, ThisDay)
- Add 5 Greek feeds with lang: 'el' for locale-aware filtering
  (Kathimerini, Naftemporiki, in.gr, iefimerida, Proto Thema)
- Add source tiers for all new outlets
- Allowlist 8 new domains in RSS proxy

* fix: enforce military bbox filtering and add behavioral cache tests (#284)

* fix: add request coalescing to Redis cache layer

Concurrent cache misses for the same key now share a single upstream
fetch instead of each triggering redundant API calls. This eliminates
duplicate work within Edge Function invocations under burst traffic.

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

* fix: reduce AIS polling frequency from 10s to 30s

Vessel positions do not change meaningfully in 10 seconds at sea.
Reduces Railway relay requests by 66% with negligible UX impact.
Stale threshold bumped to 45s to match the new interval.

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

* fix: quantize military flights bbox cache keys to 1-degree grid

Precise bounding box coordinates caused near-zero cache hit rate since
every map pan/zoom produced a unique key. Snapping to a 1-degree grid
lets nearby viewports share cache entries, dramatically reducing
redundant OpenSky API calls.

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

* fix: parallelize ETF chart fetches instead of sequential await loop

The loop awaited each ETF chart fetch individually, blocking on every
Yahoo gate delay. Using Promise.allSettled lets all 10 fetches queue
concurrently through the Yahoo gate, cutting wall time from ~12s to ~6s.

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

* fix: add Redis pipeline batch GET to reduce round-trips

Add getCachedJsonBatch() using the Upstash pipeline API to fetch
multiple keys in a single HTTP call. Refactor aircraft details batch
handler from 20 sequential GETs to 1 pipelined request.

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

* test: add structural tests for Redis caching optimizations

18 tests covering: cachedFetchJson request coalescing (in-flight dedup,
cache-before-fetch ordering, cleanup), getCachedJsonBatch pipeline API,
aircraft batch handler pipeline usage, bbox grid quantization (1-degree
step, expanded fetch bbox), and ETF parallel fetch.

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

* fix: enforce military bbox contract and add behavioral cache tests

---------

Co-authored-by: Elias El Khoury <efk@anghami.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add User-Agent and Cloudflare 403 detection to all secret validation probes (#296)

Sidecar validation probes were missing User-Agent headers, causing
Cloudflare-fronted APIs (e.g. Wingbits) to return 403 which was
incorrectly treated as an auth rejection. Added CHROME_UA to all 13
probes and isCloudflare403() helper to soft-pass CDN blocks.

* fix: open external links in system browser on Tauri desktop (#297)

Tauri WKWebView/WebView2 traps target="_blank" navigation, so news
links and other external URLs silently fail to open. Added a global
capture-phase click interceptor that routes cross-origin links through
the existing open_url Tauri command, falling back to window.open.

* fix: add Greek flag mapping to language selector (#307)

* fix: add missing country brief i18n keys and export PDF option (#308)

- Add levels, trends, fallback keys to top-level countryBrief in en/el/th/vi
  locales (fixes raw key display in intelligence brief and header badge)
- Add Export PDF option to country brief dropdown using scoped print dialog
- Add exportPdf i18n key to all 17 locale files

* feat: add day/night solar terminator overlay to map

Add a real-time day/night overlay layer using deck.gl PolygonLayer that
renders the solar terminator (boundary between day and night zones).
The overlay uses astronomical formulas (Meeus) to compute the subsolar
point and trace the terminator line at 1° resolution.

- New toggleable "Day/Night" layer in all 3 variants (full/tech/finance)
- Theme-aware styling (lighter fill on light theme, darker on dark)
- Auto-refresh every 5 minutes with conditional timer (only runs when
  layer is enabled, pauses when render is paused)
- Cached polygon computation to avoid recomputing on every render
- i18n translations for all 17 locales
- Updated documentation with new layer entry

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

* fix: address review feedback — equinox terminator + locale indentation

- Replace safeTanDecl epsilon clamp with proper equinox handling:
  when |tanDecl| < 1e-6, draw terminator as vertical great circle
  through the poles (subsolar meridian ±90°) instead of clamping
- Fix JSON indentation in all 17 locale files: dayNight and
  tradeRoutes keys were left-aligned instead of matching 8-space
  indentation of surrounding keys

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
Co-authored-by: Elias El Khoury <efk@anghami.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(relay): auto-reconnect on Telegram AUTH_KEY_DUPLICATED and fix IranIntl handle (#539)

- On AUTH_KEY_DUPLICATED (406), disconnect client and set to null so
  next poll cycle reconnects fresh — self-heals after competing client dies
- Fix IranIntl → iranintltv (correct Telegram channel handle)

* fix(live-news): add fallback video ID for LiveNOW from FOX channel (#538)

The livenow-fox channel had no fallbackVideoId, relying solely on
YouTube handle lookup which fails intermittently. Added ZvdiJUYGBis
(confirmed live stream) as fallback.

* fix(iran): bump CDN cache-bust to v4 for fresh event data (#544)

100 new events pushed to Redis covering active Iran-Israel-US
conflict theater including Gulf states (UAE, Bahrain, Qatar,
Kuwait, Jordan). Bump ?_v=3 to ?_v=4 to bypass stale CDN.

* fix(telegram): fix ESM import path in session-auth script (#542)

telegram/sessions → telegram/sessions/index.js (same fix as relay)

* fix(telegram): latch AUTH_KEY_DUPLICATED to stop retry spam (#543)

AUTH_KEY_DUPLICATED is permanent — the session string is invalidated
and no amount of retrying will fix it. Previously the relay retried
every 60s, spamming logs. Now it logs a clear error message with
instructions to regenerate the session and stops retrying.

Renamed telegramImportFailed → telegramPermanentlyDisabled to cover
both import failures and auth failures under one latch.

* fix(live-news): fix broken Europe channel handles + add fallback video IDs (#541)

* fix(live-news): fix broken Europe channel handles + add fallback video IDs

- Fix France 24 English handle: @FRANCE24English (404) → @France24_en
- Fix WELT handle: @WELTNachrichtensender (hijacked to "Movie Glow") → @WELTVideoTV
- Add fallbackVideoId for BBC News, France 24 EN, TRT Haber, NTV Turkey,
  CNN TURK, TVP Info, Telewizja Republika (verified via Playwright)
- Update stale fallback IDs for Fox News, RTVE, CNN Brasil, C5N, TBS News,
  Sky News Arabia, TRT World

* fix(live-news): update CBS News fallback video ID

* fix(live-news): update Newsmax fallback video ID

* fix(live-news): add NBC News fallback video ID

* fix(live-news): full channel audit — fix 10 broken handles + update 8 stale fallbacks

Broken handles fixed:
- Bloomberg: @Bloomberg (404) → @markets
- WION: @WIONews (wrong channel "Write It Out") → @WION
- CTI News: @CtiTv (404) → @中天新聞CtiNews
- VTC NOW: @VTCNOW (404) → @VTCNowOfficial
- Record News: @recordnewsoficial (404) → @RecordNews
- T13: @T13 (404) → @Teletrece
- Channels TV: @channelstv (404) → @ChannelsTelevision
- KTN News: @KTNNewsKE (404) → @ktnnews_kenya
- eNCA: @enewschannel (404) → @eNCA
- SABC News: @SABCNews (404) → @SABCDigitalNews

Stale fallback video IDs refreshed:
- Sky News, NASA, CBC News, CNN Brasil, C5N, TBS NEWS DIG,
  Sky News Arabia, TRT World

* feat(oref): add OREF sirens panel with Hebrew-to-English translation (#545)

Add real-time Israel Home Front Command (OREF) siren alerts panel:
- Edge Function proxy at api/oref-alerts.js
- OrefSirensPanel component with live/history views
- oref-alerts service with 10s polling and update callbacks
- Hebrew→English translation via existing translateText() LLM chain
  with 3-layer caching (in-memory Map → server Redis → circuit breaker)
- i18n strings for all 23 locales
- Panel registration, data-loader integration, CSS styles

* fix(relay): use execFileSync for OREF curl to avoid shell injection (#546)

Proxy credentials with special characters (semicolons, dollar signs)
were interpolated into a shell command via execSync. Switch to
execFileSync which passes args directly without shell parsing.

* gave the user freedom to resize panels "fixes issue #426" (#489)

* gave the user freedom to resize panles

* feat(panels): add horizontal resize with col-span persistence

* feat(cii): integrate Iran strike events into CII scoring, country brief & timeline (#547)

Iran had ~100 geolocated strike events but the CII was unaware of them:
conflict score stuck at 70 (ACLED only), no strike chip in Active Signals,
timeline conflict lane empty, intelligence brief silent on strikes.

Changes:
- Add strikes[] to CountryData and ingestStrikesForCII() with geo-lookup
  fallback (bounding boxes when GeoJSON not yet loaded)
- Boost CII conflict score with 7-day freshness window
  (min(50, count*3 + highSev*5))
- Cache iranEvents in IntelligenceCache, preserve across refresh cycles
- Wire data loading: always load Iran events (not gated by map layer),
  ingest into CII, trigger panel refresh
- Add activeStrikes to CountryBriefSignals with geo-lookup counting
- Render strike chip in Active Signals and include in fallback brief
- Feed strike events into 7-day timeline (conflict lane)
- Add structured strikeCount/highSeverityStrikeCount fields to GeoSignal
  (replaces fragile regex parsing in focal-point-detector)
- Add active_strike signal type to InsightsPanel focal points
- Add bounding-box fallback to signal aggregator for conflict events
- Add i18n keys for activeStrikes

* fix(alerts): add compound escalation for military action + geopolitical target (#548)

Keyword matching was too rigid — "attacks on iran" didn't match CRITICAL
because only "attack on iran" (singular) existed. Headlines like
"strikes by US and Israel on Iran" also missed because words weren't
adjacent.

Added compound escalation: if a HIGH military/conflict keyword matches
AND the headline mentions a critical geopolitical target (Iran, Russia,
China, Taiwan, NATO, US forces), escalate to CRITICAL. Also added
missing Iran keyword variants (plural forms, "Iran retaliates/strikes").

* feat(conflict): enhance Iran events popup with severity badge and related events (#549)

Rewrite the Iran events popup to follow the established popup pattern
(conflict/protest) with severity-colored header, badge, close button,
stat rows, and source link using CSS classes instead of inline styles.

- Add normalizeSeverity helper (clamps unknown values to 'low')
- Show related events from same location (normalized matching, max 5)
- Add IranEventPopupData to PopupData union (removes unsafe double cast)
- Add iranEvent header CSS with severity border-left colors
- Add i18n keys for en/ar/fr

* feat(telegram): add Telegram Intel panel (#550)

* feat(telegram): add Telegram Intel panel consuming relay feed

- Service layer: fetchTelegramFeed() with 30s cache, types matching relay shape
- Panel component: topic filter tabs, safe DOM rendering via h()+replaceChildren()
- DataLoader + RefreshScheduler pattern (60s interval, hidden-tab aware)
- Handles enabled=false and empty states from relay
- CSS following existing gdelt-intel pattern
- Panel title localized across all 18 locales

* fix(i18n): add components.telegramIntel translations to 10 remaining locales

* feat(live-news): add residential proxy + gzip decompression for YouTube detection (#551)

YouTube blocks Vercel datacenter IPs — returns HTML without videoDetails/isLive
data. Switch from edge runtime to Node.js serverless to enable HTTP CONNECT
tunnel proxy via YOUTUBE_PROXY_URL env var. Add zlib decompression for gzip
responses (YouTube returns empty body without Accept-Encoding header).

Also adds missing fallback video IDs for WELT, KTN News, CNA NewsAsia,
and updates TBS NEWS DIG fallback.

* debug(live-news): add debug param to diagnose proxy env var on Vercel

* fix(live-news): set explicit runtime: 'nodejs' for proxy support

Vercel defaults to edge runtime when not specified. node:http/https/zlib
imports are unavailable in edge — causing FUNCTION_INVOCATION_FAILED.
Remove debug param added in previous commit.

* fix(live-news): lazy-load node modules + proxy fallback to direct fetch

Top-level import of node:http/https/zlib crashes if Vercel bundles
for edge despite runtime: 'nodejs' config. Use dynamic import() to
lazy-load at call time. Also add try/catch around proxy so it falls
back to direct fetch if proxy connection fails.

* feat(aviation): integrate AviationStack API for non-US airport delays (#552)

Replace 100% simulated delay data for international airports with real
flight data from AviationStack API. Add 28 Middle East/conflict-zone
airports (Iran, Iraq, Lebanon, Syria, Yemen, Pakistan, Libya, Sudan).

Key changes:
- AviationStack integration with bounded concurrency (5 parallel),
  rotating batch (20 airports/cycle), and 20s deadline
- Redis SETNX lock prevents cross-isolate cache stampede on expiry
- Split FAA/intl caches (both 30min TTL) with isolated error handling
- Fix severity colors (was checking 'GS'/'GDP', now minor/moderate/major/severe)
- Fix tooltip (was obj.airport, now obj.name + obj.iata)
- Add FLIGHT_DELAY_TYPE_CLOSURE for airport/airspace closures
- Add closure i18n key across all 18 locales
- Graceful fallback: no API key → simulation; API failure → simulation

* feat(live-news): move YouTube proxy scraping to Railway relay

Vercel serverless cannot use node:http/https for HTTP CONNECT proxy
tunnels. Move the residential proxy YouTube scraping to the Railway
relay (ais-relay.cjs) which has full Node.js access.

- Add /youtube-live route to relay with proxy + direct fetch fallback
- Add 5-min in-memory cache for channel lookups, 1hr for oembed
- Revert Vercel api/youtube/live.js to edge runtime — now proxies to
  Railway first, falls back to direct scrape

* feat(settings): add AVIATIONSTACK_API to desktop settings page (#553)

Register the key in Rust keychain (SUPPORTED_SECRET_KEYS), frontend
RuntimeSecretKey type, feature toggle, and settings UI under
"Tracking & Sensing" category.

* fix(live-news): use correct relay auth header for YouTube proxy (#554)

Edge function was sending X-Relay-Auth header with RELAY_AUTH_TOKEN env
var, but the Railway relay expects x-relay-key header validated against
RELAY_SHARED_SECRET. This mismatch caused the relay to reject requests
from Vercel, falling back to direct YouTube scrape (which fails from
datacenter IPs for many channels).

* fix(live-news): align YouTube edge function with relay auth pattern (#555)

Use same getRelayBaseUrl/getRelayHeaders as other edge functions:
- WS_RELAY_URL env var instead of VITE_WS_API_URL
- RELAY_SHARED_SECRET + RELAY_AUTH_HEADER for flexible auth
- Dual x-relay-key + Authorization headers

* fix(i18n): rename OREF Sirens panel to Israel Sirens (#556)

Remove internal implementation references (OREF, proxy relay, oref.org.il)
from all user-facing strings across 18 locales and panel config.

* fix(live-news): annotate empty catches and sanitize error output (#560)

- Add context comments to empty catch blocks for debuggability
- Replace error.message leak with generic client-safe message

* fix(sentry): add noise filters and fix beforeSend null-filename leak (#561)

- Add 8 new ignoreErrors patterns: signal timeout, premium gate,
  hybridExecute/mag/webkit bridge injections, postMessage null,
  NotSupportedError, appendChild injection, luma assignment
- Fix LIDNotify regex to match both LIDNotify and LIDNotifyId
- Fix beforeSend: strip null/anonymous filename frames so deck.gl
  TypeErrors (28 events, 8 users) are properly suppressed

* feat(cii): wire OREF sirens into CII score & country brief (#559)

* feat(cii): wire OREF sirens into CII score and country brief

Active OREF sirens now boost Israel's CII score through two channels:
- Conflict component: +25 base + min(25, alertCount*5) for active sirens
- Blended score: +15 for active sirens, +5/+10 for 24h history thresholds

Country brief for Israel shows a siren signal chip when alerts are active.

* refactor(cii): extract getOrefBlendBoost helper to DRY scoring paths

* fix(relay): add graceful shutdown + poll concurrency guard for Telegram (#562)

- SIGTERM/SIGINT handler disconnects Telegram client before container dies
- telegramPollInFlight guard prevents overlapping poll cycles
- Mid-poll AUTH_KEY_DUPLICATED now permanently disables (was reconnect loop)

* fix(aviation): query all airports instead of rotating batch (#557)

* fix(aviation): query all airports instead of rotating batch of 20

The rotating batch (20 airports/cycle) caused major airports like DXB
(52% cancellations) to be missed entirely for multiple cache cycles.
With a paid AviationStack plan, query all ~90 non-US airports per
refresh with concurrency 10 and 50s deadline (~9 chunks × 5s = 45s).

* feat(cii): feed airport disruptions into CII and country brief

Major/severe airport delays and closures now boost the CII security
score and appear as signal chips in country briefs. Only major+
severity alerts are ingested to avoid noise from minor delays.

- Add aviationDisruptions to CountryData and ingestAviationForCII()
- Boost security score: closure +20, severe +15, major +10, moderate +5
- Store flight delays in intelligenceCache for country brief access
- Add aviation disruptions chip in country brief signals grid

* fix(relay): replace smart quotes crashing relay on startup (#563)

* fix(relay): replace Unicode smart quotes crashing Node.js CJS parser

* fix(relay): await Telegram disconnect + guard startup poll

* fix(cii): resolve Gulf country strike misattribution via multi-match bbox disambiguation (#564)

Dubai/Doha/Bahrain/Kuwait coordinates matched Iran's bounding box first
due to iteration order. Now collects ALL matching bboxes, disambiguates
via isCoordinateInCountry() geometry, and falls back to smallest-area bbox.

- Add BH, QA, KW, JO, OM to bounds tables (previously missing entirely)
- Extract ME_STRIKE_BOUNDS + resolveCountryFromBounds() into country-geometry.ts
- All 4 consumer files use shared constant (single source of truth)
- Bump CDN cache-bust param for iran-events endpoint

* fix(relay): upstreamWs → upstreamSocket in graceful shutdown (#565)

* fix(relay): install curl in Railway container for OREF polling (#567)

* fix(relay): increase Polymarket cache TTL to 10 minutes (#568)

* fix(relay): increase Polymarket cache TTL to 10 minutes

All requests were MISS with 2-min TTL under concurrent load.
Bump to 10-min cache and 5-min negative cache to reduce upstream pressure.

* fix(relay): normalize Polymarket cache key from canonical params

Raw url.search as cache key meant ?tag=fed&endpoint=events and
?endpoint=events&tag_slug=fed produced different keys for the same
upstream request — defeating both cache and inflight dedup, causing
121 MISS entries in 3 seconds.

Build cache key from parsed canonical params (endpoint + sorted
query string) so all equivalent requests share one cache entry.

* feat(webcams): add Iran tab to live webcams panel (#569)

Add dedicated Iran region tab as the first/default tab with 4 feeds:
Tehran, Middle East overview, Tehran (alt angle), and Jerusalem.

* fix(relay): replace nixpacks.toml with railpack.json for curl (#571)

Railway uses Railpack (not Nixpacks). nixpacks.toml in scripts/ was
silently skipped. Use railpack.json at repo root with deploy.aptPackages
to install curl at runtime for OREF polling.

* fix(webcams): replace duplicate Tehran feed with Tel Aviv, rename Iran tab (#572)

- Remove duplicate iran-tehran2 feed (same channel/video as iran-tehran)
- Remove iran-mideast feed
- Add Tel Aviv feed (-VLcYT5QBrY) to Iran Attacks tab
- Rename tab label from "IRAN" to "IRAN ATTACKS" across all 18 locales

* feat(scripts): add Iran events seed script and latest data (#575)

Add seed-iran-events.mjs for importing Iran conflict events into Redis
(conflict:iran-events:v1). Includes geocoding by location keywords and
category-to-severity mapping. Data file contains 100 events from
2026-02-28.

* fix(relay): add timeouts and logging to Telegram poll loop (#578)

GramJS getEntity/getMessages have no built-in timeout. When the first
channel hangs (FLOOD_WAIT, MTProto stall), telegramPollInFlight stays
true forever, blocking all future polls — zero messages collected, zero
errors logged, frontend shows "No messages available".

- Add 15s per-channel timeout on getEntity + getMessages calls
- Add 3-min overall poll cycle timeout
- Force-clear stuck in-flight flag after 3.5 minutes
- Detect FLOOD_WAIT errors and break loop early
- Log per-cycle summary: channels polled, new msgs, errors, duration
- Track media-only messages separately (no text → not a bug)
- Expose lastError, pollInFlight, pollInFlightSince on /status endpoint

* feat(cii): hook security advisories into CII scoring & country briefs (#579)

Travel advisories (Do Not Travel, Reconsider, Caution) from US, AU, UK,
NZ now act as a floor and boost on CII scores. Do Not Travel guarantees
a minimum score of 60 (elevated), Reconsider floors at 50. Multi-source
corroboration (3+ govts) adds +5 bonus.

Advisory chips appear in country brief signal grid with level-appropriate
styling, and advisory context is passed to AI brief generation.

- Extract target country from advisory titles via embassy feed tags and
  country name matching
- Add advisoryMaxLevel/advisoryCount/advisorySources to CII CountryData
- Wire ingestAdvisoriesForCII into data loader pipeline
- Add travelAdvisories/travelAdvisoryMaxLevel to CountryBriefSignals
- Render advisory signal chips in CountryBriefPage

* fix(sentry): guard setView against invalid preset + filter translateNotifyError (#580)

- DeckGLMap.setView(): early-return if VIEW_PRESETS[view] is undefined,
  preventing TypeError on 'longitude' when select value is invalid
- Add ignoreErrors pattern for Google Translate widget crash

* feat(relay): bootstrap OREF 24h history on startup (#582)

* fix(relay): improve OREF curl error logging with stderr capture

-s flag silenced curl errors. Add -S to show errors, capture stderr
via stdio pipes, and log curl's actual error message instead of
generic "Command failed" from execFileSync.

* feat(relay): bootstrap OREF 24h history on startup and add missing headers

- Fetch AlertsHistory.json once on startup to populate orefState.history
  immediately instead of starting empty
- Add X-Requested-With: XMLHttpRequest header required by Akamai WAF
- Add IST→UTC date converter handling DST ambiguity
- Redact proxy credentials from error logs and client responses
- Fix historyCount24h to count individual alert records, not snapshots
- Guard historyCount24h reducer for both array and string data shapes
- Add input validation to orefDateToUTC for malformed dates

* feat(aviation): add comprehensive logging to flight delay pipeline (#581)

Adds structured [Aviation] logging throughout the handler chain:
- Handler: timing, FAA/intl alert counts
- fetchIntlWithLock: cache hit/miss, lock status, fallback triggers
- fetchAviationStackDelays: airport count, deadline hits, success/fail stats
- fetchSingleAirport: per-airport flight stats, API errors, severity
- Annotate empty catches with error context

* feat: add GPS/GNSS jamming map layer + CII integration (#570)

* feat: add GPS/GNSS jamming data ingestion from gpsjam.org

- scripts/fetch-gpsjam.mjs: standalone fetcher that downloads daily H3
  hex data, filters medium/high interference, converts to lat/lon via
  h3-js, and writes JSON. Can be run on cron.
- api/gpsjam.js: Vercel Edge Function that proxies gpsjam.org data with
  1hr cache, returns medium/high hexes for frontend consumption.
- src/services/gps-interference.ts: frontend service that fetches from
  the Edge API, converts H3→lat/lon, and classifies by conflict region.
- h3-js added as dependency for hex→coordinate conversion.

* feat: add GPS jamming map layer, CII integration, and country brief signals

Wire gpsjam.org data into map visualization, instability scoring, and
country intelligence. ScatterplotLayer renders high (red) and medium
(orange) interference hexes. CII security score incorporates jamming
counts per country via h3→country geocoding with cache. Country briefs
show jamming zone chip. Full i18n across 18 locales including popup
labels. Data loads with intelligence signals cycle (15min), gated by
1hr client-side cache.

* feat(aviation): add NOTAM closure detection via ICAO API (#583)

* feat(aviation): add NOTAM closure detection via ICAO API

Adds international airport closure detection via ICAO NOTAMs:
- New fetchNotamClosures() queries ICAO realtime-notams endpoint
- Detects closures via Q-codes (FA/AH/AL/AW/AC/AM) and text patterns
- Batches airports in groups of 20 per API call
- 4-hour cache TTL via cachedFetchJson (stampede-safe)
- NOTAM closures override existing AviationStack alerts for same airport
- Graceful: no ICAO_API_KEY env var = silently skipped

To activate: set ICAO_API_KEY env var (register at dataservices.icao.int)

* feat(settings): add ICAO_API_KEY to desktop app settings

Adds ICAO NOTAM API key to the desktop settings UI:
- Rust: SUPPORTED_SECRET_KEYS [23→24]
- TypeScript: RuntimeSecretKey + RuntimeFeatureId unions
- Feature definition: 'icaoNotams' in Tracking & Sensing category
- Settings UI: label, signup URL, analytics name

* feat(aviation): limit NOTAM queries to MENA airports only

Per user request, ICAO NOTAM closure detection is scoped to
Middle East airports only (region='mena', ~35 airports).
This reduces API calls (2 batches vs 5) and focuses on the
region where closures are most relevant.

* fix(aviation): align NOTAM cache TTL to 30 min (matching FAA/intl)

* feat(risk): wire theater posture + breaking news into strategic risk score (#584)

The composite score ignored theater military buildup and breaking news
alerts, causing misleadingly low scores during active military events.

- Add theater boost from raw asset counts + strike capability (capped +25)
- Add breaking news severity boost (critical=15, high=8, capped +15)
- Listen for wm:breaking-news events with 30-min TTL and auto-expiry
- Read cached theater postures via getCachedPosture() with stale discount
- Lower trend threshold from ±5 to ±3 for faster escalation detection
- Cleanup listeners and timers in destroy()

* fix(relay): delay Telegram connect 60s on startup to prevent AUTH_KEY_DUPLICATED (#587)

Railway zero-downtime deploys start the new container before the old one
receives SIGTERM. Both containers connect with the same session string
simultaneously, triggering Telegram's AUTH_KEY_DUPLICATED which permanently
invalidates the session. A 60s startup delay gives the old container time
to disconnect gracefully. Configurable via TELEGRAM_STARTUP_DELAY_MS env.

* feat(feeds): add RT (Russia Today) RSS feeds (#585)

- Add RT main feed (rt.com/rss/) and RT Russia desk (rt.com/rss/russia/)
- Add to SOURCE_TIERS (tier 3), SOURCE_TYPES (wire), SOURCE_CREDIBILITY
- Add rt.com to rss-proxy ALLOWED_DOMAINS

* feat(live-news): add RT channel via HLS + enable HLS on web (#586)

- Add RT (rt.com) as optional channel in Europe region
- HLS stream: rt-glb.rttv.com/dvr/rtnews/playlist.m3u8 (CORS: *)
- Enable native <video> HLS playback on web (was desktop-only)
- Channels in DIRECT_HLS_MAP with CORS headers now work everywhere

* fix(telegram): add missing relay auth headers to telegram-feed edge function (#590)

The edge function was the only relay proxy missing RELAY_SHARED_SECRET
auth headers. The relay returns 401 for all non-public routes, so the
panel always received an error response → "No messages available".

* fix(aviation): replace broken lock mechanism with direct cache, add cancellation tiers (#591)

The Redis SETNX lock for AviationStack had a 3s wait but the API takes
~25s for 50 airports. Every lock-loser fell back to simulation — meaning
AviationStack data was never served despite the API key being configured.

Changes:
- Remove fetchIntlWithLock/tryAcquireLock, use getCachedJson/setCachedJson
  with conditional TTL (30min real data, 5min simulation fallback)
- Add cancellation severity tiers: ≥20% moderate, ≥10% minor (DXB at 32%
  cancelled was previously dropped as null)
- Bump cache key to v2 to invalidate stale simulation data
- Add HTML content-type detection for NOTAM API (ICAO CloudFront returns
  HTML challenge pages from Vercel datacenter IPs)

* fix(relay): stop Polymarket cache stampede from concurrent limit + CDN bypass (#592)

Three issues caused continuous MISS every 5 seconds:

1. Concurrent limit rejection poisoned cache: 11 tags fire via Promise.all
   but POLYMARKET_MAX_CONCURRENT=3, so 8 tags got negative-cached with
   empty [] (5 min TTL). Those 8 tags NEVER got positive cache because
   they were always throttled. Fix: replace reject-with-negative-cache
   with a proper queue — excess requests wait for a slot instead of
   being silently rejected.

2. Cache key fragmentation: fetchPredictions(limit=20) and
   fetchCountryMarkets(limit=30) created separate cache entries for the
   same tag. Fix: normalize to canonical limit=50 upstream, cache key
   is shared regardless of caller's requested limit.

3. CDN bypass: end_date_min timestamp in query string made every URL
   unique, preventing Vercel CDN caching entirely. Fix: strip
   end_date_min, active, archived from proxy params (relay ignores them
   anyway).

* fix(polymarket): add queue backpressure and response limit slicing (#593)

- Add POLYMARKET_MAX_QUEUED=20 cap to prevent unbounded queue growth
  under sustained load (rejects with negative cache when full)
- Use requestedLimit to slice cached responses — callers requesting
  limit=20 now get 20 items instead of the full 50-item upstream payload
- Hoist PROXY_STRIP_KEYS Set to module level (avoids per-call allocation)

* fix(webcams): add 4th Iran Attacks feed to fill 2x2 grid (#601)

Add Middle East multi-cam (4E-iFtUM2kk) as 4th Iran region feed.
Previously only 3 feeds for a 4-cell grid, leaving one cell black.

* fix(aviation): route NOTAM through relay + improve intl logging (#599)

Root cause: ICAO NOTAM API times out from Vercel edge (>10s).
AviationStack alerts indistinguishable from simulation in logs.

Changes:
- Add /notam proxy endpoint to Railway relay (25s timeout, 30min cache)
- Route fetchNotamClosures through relay when WS_RELAY_URL is set
- Fall back to direct ICAO call (20s timeout) when no relay
- Log cache hits with real vs simulated alert counts
- Send all MENA airports in single NOTAM request (was batched by 20)

Requires: ICAO_API_KEY env var on Railway relay

* chore(telegram): update channel list — remove nexta_live, air_alert_ua; add wfwitness (#600)

* fix(sentry): guard YT player methods + filter GM/InvalidState noise (#602)

- Guard loadVideoById/cueVideoById with typeof check in LiveNewsPanel
  (race condition: YT.Player object exists before onReady fires)
- Add ignoreErrors for GM_getValue (Greasemonkey extension) and
  InvalidStateError (IndexedDB/DOM state errors from browser internals)

* fix(aviation): always show all monitored airports on flight delays map (#603)

* fix(aviation): always show MENA airports on map regardless of delay status

MENA airports only appeared when they had active delays/closures.
GCC airports like DOH, AUH, RUH were invisible during normal operations.
Now fills in "normal operations" entries for all MENA airports without
alerts so they always render as gray dots on the flight delays layer.

* fix(aviation): show all monitored airports globally, not just MENA

Extend normal-operations fill to all 128 monitored airports worldwide,
not just the 35 MENA airports. Any airport without active delays now
appears as a gray dot on the flight delays map layer.

* fix(webcams): fix broken live news channels — eNCA handle, remove VTC NOW, fix CTI News (#604)

- eNCA: fix YouTube handle from @eNCA to @encanews
- VTC NOW: remove — VTC shut down Jan 2025 (Vietnam govt restructuring)
- CTI News: remove stale fallbackVideoId and useFallbackOnly, let auto-detect work

* docs(readme): comprehensive update for Telegram, OREF, GPS jamming, airports, and more (#606)

Reflects 30+ recent commits with updates across the README:
- Update data layer counts (36 → 40+), webcam counts (19 → 22), data sources (16 → 28+)
- Add 6 new How It Works sections: Telegram OSINT, OREF Rocket Alerts, GPS/GNSS
  Interference, Security Advisories, Airport Delays/NOTAMs, Strategic Risk Score
- Expand Railway relay section with service table (AIS, OpenSky, Telegram, OREF,
  Polymarket, NOTAM) and update architecture ASCII diagram
- Add Iran/Attacks webcam tab, HLS native streaming details, RT coverage
- Add Algorithmic Design Decisions section (log vs linear scoring, Welford's,
  H3 hex grids, cosine-lat correction, negative caching)
- Add 2 new architecture principles (graceful degradation, multi-source corroboration)
- Add 8 new roadmap items for recently shipped features
- Update Tech Stack table with Telegram, OREF, gpsjam.org in Geopolitical APIs

* chore: bump version to 2.5.21 (#605)

* fix(aviation): invalidate stale IndexedDB cache + reduce CDN TTL (#607)

Bump circuit breaker name from 'FAA Flight Delays' to 'Flight Delays v2'
to force all clients to discard stale IndexedDB entries that predate
PR #603 (which added normal-ops airport fill). Also downgrade CDN cache
tier from 'slow' (15 min) to 'medium' (5 min) since airport status
changes more frequently than other slow-tier endpoints.

* fix(relay): increase OREF curl maxBuffer to prevent ENOBUFS (#609)

* fix(relay): increase OREF curl maxBuffer to 10MB to prevent ENOBUFS

AlertsHistory.json response exceeds execFileSync default 1MB buffer,
causing spawnSync ENOBUFS on Railway container at startup.

* fix(relay): use curl -o tmpfile for OREF history instead of stdout buffer

Large AlertsHistory.json overflows execFileSync stdout buffer (ENOBUFS).
Now writes to temp file via curl -o, reads with fs.readFileSync, cleans up.
Live alerts (tiny payload) still use stdout path.

* fix: RT channel HLS-only recovery, test shim, and LiveNOW fallback (#610)

- Remove useFallbackOnly from RT channel — RT is HLS-only (banned from
  YouTube), so the flag was causing undefined videoId on HLS failure
  instead of graceful offline state
- Add response-headers shim to redis-caching test so military flights
  bbox tests can import list-military-flights.ts
- Restore LiveNOW from FOX fallbackVideoId (removed in channel audit)

* fix(docs): add blank lines after CHANGELOG headings for markdownlint (#608)

* Expand country brief and CII signal coverage (#611)

* perf(rss): raise news refresh interval to 10min and cache TTL to 20min (#612)

Reduces upstream RSS polling frequency and extends client-side cache
lifetime to lower API load and bandwidth usage.

* fix(api): harden cache-control headers for polymarket and rss-proxy (#613)

* docs(changelog): add v2.5.21 entry covering 86 merged PRs (#616)

Comprehensive changelog for 2026-03-01 release including Iran Attacks
layer, Telegram Intel panel, OREF sirens, GPS jamming, AviationStack,
breaking news alerts, and strategic risk score.

* fix(aviation): increase cache TTL from 30min to 2h to reduce API quota usage (#617)

* feat(oref): show history waves timeline with translation and NaN fix (#618)

- Fetch and display alert history waves in OrefSirensPanel (cap 50 most recent)
- Last-hour waves highlighted with amber border and RECENT badge
- Translate Hebrew history alerts via existing translateAlerts pipeline
- Guard formatAlertTime/formatWaveTime against NaN from unparseable OREF dates
- Cap relay history bootstrap to 500 records
- Add 3-minute TTL to prevent re-fetching history on every 10s poll
- Remove dead .oref-footer/.oref-history CSS; add i18n key for history summary

* feat(map): native mobile map experience with location detection and full feature parity (#619)

- Fix URL restore: lat/lon now override view center when explicitly provided
- Fix touch scroll: 8px threshold before drag activation, preventDefault once active
- Add location bootstrap: timezone-first detection, optional geolocation upgrade
- Enable DeckGL on mobile with deviceMemory capability guard
- Add DeckGL state sync on moveend/zoomend for URL param updates
- Fix breakpoint off-by-one: JS now uses <= to match CSS max-width: 768px
- Add country-click on SVG fallback with CSS transform inversion
- Add fitCountry() to both map engines (DeckGL uses fitBounds, SVG uses projection)
- Add SVG inertial touch animation with exponential velocity decay
- Add mobile map e2e tests for timezone, URL restore, touch, and breakpoint

* fix(sentry): guard pauseVideo optional chaining + add 4 noise filters (#624)

- Fix: `this.player.pauseVideo()` → `this.player.pauseVideo?.()` at
  LiveNewsPanel line 1301 (6 Sentry issues, 33 events)
- Noise: Chrome extension "Could not establish connection"
- Noise: Safari "webkitCurrentPlaybackTargetIsWireless" internal
- Noise: Sentry SDK crash on iOS "a.theme"
- Noise: Broaden Can't find variable to include EmptyRanges
- Noise: Catch stale cached builds with /this.player.\w+ is not a function/

* fix(aviation): prevent AviationStack API quota blowout (#623)

- Replace manual getCachedJson/setCachedJson with cachedFetchJson for
  intl delays — prevents thundering herd (concurrent misses each firing
  93 API calls independently)
- Bump Redis cache TTL from 30min to 2h
- Bump frontend polling from 10min to 2h to match server cache
- Bump circuit breaker browser cache from 5min to 2h
- Bump CDN edge tier from medium (5min) to static (1h)
- Bump cache key to v3 to force fresh entry with new TTL

* feat(news): server-side feed aggregation to reduce edge invocations by ~95% (#622)

Phase 1: Force CDN caching on rss-proxy (s-maxage=300 for 2xx, short
TTL for errors) — fixes bug where upstream no-cache headers were passed
through verbatim, defeating Vercel CDN.

Phase 2: Add ListFeedDigest RPC that aggregates all feeds server-side
into a single Redis-cached response. Client makes 1 request instead of
~90 per cycle. Includes circuit breaker with persistent cache fallback,
per-feed AI reclassification, and headline ingestion parity.

Phase 3: Increase polling interval from 5min to 7min to offset CDN
cache alignment.

New files:
- proto/worldmonitor/news/v1/list_feed_digest.proto
- server/worldmonitor/news/v1/{_feeds,_classifier,list-feed-digest}.ts
- src/services/ai-classify-queue.ts (extracted from rss.ts)

* feat(rss): add conditional GET (ETag/If-Modified-Since) to Railway relay (#625)

When RSS cache expires, send If-None-Match/If-Modified-Since headers
on revalidation. Upstream 304 responses refresh the cache timestamp
and serve cached body with zero bandwidth, cutting egress ~80-95%
for feeds that support conditional GET.

* fix(map): sync layer toggles to URL for shareable links (#576) (#621)

Layer toggles were not updating the browser URL, so shared links
would not carry the user's current layer state. Extracted the
debounced URL sync to a reusable class method and call it on
every layer change.

* chore(cache): bump all sub-5min cache TTLs and polling intervals (#626)

Audit and raise every cache/poll under 5 min to reduce upstream API
pressure and Vercel function invocations across 31 files:

Frontend polling: markets 4→8min, crypto 4→8min, predictions 5→10min,
natural 5→1hr, cableHealth 5→2hr, oref 10s→2min, maritime 30s→5min,
IntelligenceGapBadge 1→3min.

Circuit breakers: PizzINT 2→30min, aviation 5→20min, seismology/weather/
outages/statuses/wildfires 5→30min, military-vessels/flights/GDACS/
maritime/polymarket/GDELT 5→10min, chokepoint 5→20min.

Server Redis: market-quotes 2→8min, stablecoins 2→8min/5→10min,
vessel-snapshot 10s→5min, earthquakes 5→30min, sector 5→10min,
predictions/crypto/commodities 5→10min, outages/statuses 5→30min,
macro-signals/chokepoints 5→15min, aviation-sim 5→15min.

CDN edge: market RPCs fast→medium, infra/seismology fast→slow.

* fix(military): narrow ICAO hex ranges to stop civilian false positives (#627)

* fix(military): narrow hex ranges and callsign regex to stop civilian false positives (#462)

MILITARY_HEX_RANGES used entire country ICAO allocations instead of
military-specific sub-ranges (sourced from tar1090-db/ranges.json).
This flagged ALL commercial aircraft from Italy, Spain, Japan, India,
South Korea, etc. as military activity.

Key changes:
- Remove A00000-A3FFFF (US civilian N-numbers) — military starts at ADF7C8
- Italy 300000-33FFFF → 33FF00-33FFFF (top 256 codes only)
- Spain 340000-37FFFF → 350000-37FFFF (upper 3/4 confirmed military)
- Japan 840000-87FFFF removed (no confirmed JASDF sub-range)
- France narrowed to 3AA000-3AFFFF + 3B7000-3BFFFF
- Germany narrowed to 3EA000-3EBFFF + 3F4000-3FBFFF
- India 800000-83FFFF → 800200-8002FF (256 codes)
- Canada C00000-C0FFFF → C20000-C3FFFF (upper half)
- Remove unconfirmed: South Korea, Sweden, Singapore, Pakistan
- Add confirmed: Austria, Belgium, Switzerland, Brazil
- Drop overly broad /^[A-Z]{4,}\d{1,3}$/ callsign regex from server

* fix(military): constrain short prefixes + add classification tests

Move ambiguous 2-letter prefixes (AE, RF, TF, PAT, SAM, OPS, CTF,
IRG, TAF) to SHORT_MILITARY_PREFIXES — these now only match when
followed by a digit (e.g. AE1234=military, AEE123=Aegean Airlines).

Add 97-case test suite covering:
- Military callsign detection (19 known patterns)
- Short prefix digit gating (6 cases)
- Civilian airline non-detection (26 airlines)
- Short prefix letter rejection (6 cases)
- Military hex range boundaries (7 confirmed ranges)
- Civilian hex non-detection (19 codes)
- Boundary precision (ADF7C8 start, 33FF00 start, etc.)
- No-full-allocation guard (10 countries)

* fix: use charAt() instead of bracket indexing for strict TS

* fix(sidecar): add AVIATIONSTACK_API and ICAO_API_KEY to env allowlist (#632)

Both keys were added to Rust SUPPORTED_SECRET_KEYS and runtime-config.ts
but the sidecar's own ALLOWED_ENV_KEYS was never updated. This caused
"key not in allowlist" 403 when saving/verifying these keys from the
desktop settings UI.

Also adds AviationStack API validation in validateSecretAgainstProvider.

* fix(linux): sanitize env for xdg-open in AppImage (#631)

* fix(desktop): backoff on errors to stop CPU abuse + shrink settings window (#633)

Three bugs combine to burn 130% CPU when sidecar auth fails:

1. RefreshScheduler resets backoff multiplier to 1 (fastest) on error,
   causing failed endpoints to poll at base interval instead of backing off.
   Fix: exponential backoff on errors, same as unchanged-data path.

2. classify-event batch system ignores 401 (auth failure) — only pauses
   on 429/5xx. Hundreds of classify calls fire every 2s, each wasted.
   Fix: pause 120s on 401, matching the 429/5xx pattern.

3. Fetch patch retries every 401 (refresh token + retry), doubling all
   requests to the sidecar even when token refresh consistently fails.
   Fix: 60s cooldown after a retry-401 still returns 401.

Also shrinks settings window from 760→600px (min 620→480) to reduce
the empty whitespace below content on all tabs.

* fix(sentry): null guards for classList teardown crashes + noise filters + regex fix (#637)

- Guard document.body?.classList in event handlers that fire during page
  teardown (visibilitychange, resize, mouseup) — fixes WORLDMONITOR-40/6Y/6Z
- Add 7 ignoreErrors patterns for third-party noise: Safari videoTrack,
  deck.gl setProps, Facebook/WeChat injections, media abort, regex injection
- Fix beforeSend chunk-hash regex to include underscores ([A-Za-z0-9_-])
  so deck.gl errors from hashes like BPZ27_H4 are properly suppressed

* fix(desktop): route register-interest to cloud when sidecar lacks CONVEX_URL (#639)

* fix(desktop): route register-interest to cloud when sidecar lacks CONVEX_URL

The waitlist registration endpoint needs Convex (cloud-only dependency).
The sidecar handler returned 503 without cloud fallback, and
getRemoteApiBaseUrl() returned '' on desktop (VITE_WS_API_URL unset),
so the settings window fetch resolved to tauri://localhost → 404.

Three-layer fix:
1. Sidecar: tryCloudFallback() when CONVEX_URL missing (proxies to
   https://worldmonitor.app via remoteBase)
2. runtime.ts: getRemoteApiBaseUrl() defaults to https://worldmonitor.app
   on desktop when VITE_WS_API_URL is unset
3. CI: add VITE_WS_API_URL=https://worldmonitor.app to all 4 desktop
   build steps

* chore(deps): bump posthog-js to fix pre-push typecheck

* Cost/traffic hardening, runtime fallback controls, and PostHog removal (#638)

- Remove PostHog analytics runtime and configuration
- Add API rate limiting (api/_rate-limit.js)
- Harden traffic controls across edge functions
- Add runtime fallback controls and data-loader improvements
- Add military base data scripts (fetch-mirta-bases, fetch-osm-bases)
- Gitignore large raw data files
- Settings playground prototypes

* fix: remove accidental intelhq submodule entry (#640)

* feat: add Redis caching for GPS jamming data (#646)

* fix(oref): show history count in badge and stop swallowing fetch errors (#648)

Badge always showed 0 when no active sirens, even with 500+ history
records from the relay. Three fixes:

- Badge count falls back to historyCount24h when no active alerts
- History fetch errors are now logged instead of silently swallowed
- Shows immediate history summary while full wave data loads

* perf(map): optimize DeckGLMap pan/zoom by deferring work off hot path (#620)

- Guard filterByTime() with layer enablement checks (skip 9/11 calls when layers disabled)
- Defer getLeaves() from viewport change to click time (lazy-load cluster items on demand)
- Pre-aggregate riotTimeMs in supercluster map/reduce to avoid getLeaves for riot pulse
- Hoist iconMapping objects to module-level constants (stable references prevent atlas rebuild)
- Precompute conflict zones GeoJSON at module level (constant data, no per-render allocation)
- Remove 10 of 11 redundant ghost pick layers (pickingRadius:10 provides sufficient tolerance)
- Cache datacenterSCSource to avoid redundant filter on every click/viewport change

* feat: add Oman Observer and NDTV feeds + NDTV live TV (#650)

* feat: add Oman Observer and NDTV RSS feeds + NDTV 24x7 live TV

- Add Oman Observer RSS to Middle East feeds and proxy allowlist
- Add NDTV Top Stories RSS to Asia feeds (client + server)
- Add NDTV 24x7 YouTube live channel to Asia region

* fix: move NDTV to existing asia key in server feeds (TS1117)

* fix(server): cache hardening across 27 RPC handlers (#651)

* fix(server): cache hardening across 27 RPC handlers

Stop stale data promotion, fix cache keys, add error guards and fallbacks.

Critical fixes:
- C1: Add Redis caching to positive-events (was uncached, 3 GDELT fetches per request)
- C2: Bucket unrest cache keys by date instead of raw epoch ms
- C3: Move giving summary slicing outside fetcher (was caching first caller's limits)
- C4-C6: Stop stale promotion in etf-flows, stablecoin-markets, market-quotes
- C7: Remove early stale return in macro-signals computeMacroSignals()

High fixes:
- H1: Remove slice params from displacement cache key, apply post-cache
- H2: Clamp cyber pageSize before cache key construction
- H3: Don't cache chokepoint failure state (upstreamUnavailable: true)
- H4: Add outer try-catch to aircraft-details-batch
- H5-H6: Await setCachedJson in theater-posture and usni-fleet-report
- H7: Redesign UCDP cache flow — single writer, no stale promotion, post-cache country filter

Medium fixes:
- M1-M5: Add outer try-catch to classify-event, country-intel-brief, pizzint-status, climate-anomalies, fire-detections
- M7-M9: Add in-memory stale fallback to sector-summary, internet-outages, service-statuses
- M10-M14: Add keyed in-memory fallback Maps to commodity-quotes, crypto-quotes, country-stock-index, feed-digest, acled-events

* fix(server): clone cached giving result + apply filters on fallback paths

- giving: spread summary before slicing platforms/categories to avoid
  mutating the shared object returned by cachedFetchJson's inflight map
- outages: extract filterOutages() and apply on catch fallback path
- statuses: extract filterAndSortStatuses() and apply on catch fallback path

* fix(server): enforce fallback TTL in UCDP handler

Gate fallback cache usage with timestamp + ttlMs check and clear
expired entries, preventing indefinitely stale data on warm instances.

* fix(oref): grab newest history records and preserve bootstrap data (#653)

OREF AlertsHistory.json returns records newest-first, but the bootstrap
used .slice(-500) which took the oldest 500 — all outside the 24h window.
The poll loop then purged them all, leaving historyCount24h = 0.

Three fixes:
- Use .slice(0, 500) to take the newest 500 records from OREF history
- Extend history purge from 24h to 7 days so bootstrap data persists
- Add totalHistoryCount field for badge fallback when 24h count is zero

* fix(finance): restore 6 missing news categories + add finance favicons (#654)

PR #622 (server-side feed aggregation) only ported 8 of 14 finance
categories to VARIANT_FEEDS. The 6 omitted categories (derivatives,
fintech, regulation, institutional, analysis, gccNews) caused
"UNAVAILABLE" panels on finance.worldmonitor.app.

Restores all feeds to match the pre-migration frontend config exactly.
Also adds missing public/favico/finance/ directory (404s on favicon).

* fix(oref): add static Hebrew→English translations for common alert types (#655)

AI translation via summarize-article hits 429 rate limits, leaving
history waves in Hebrew. Adds a static lookup map for ~20 common
OREF phrases (alert types, instructions, categories) that applies
instantly without any API call. AI translation is still attempted
for unknown strings as a second pass.

* feat(api): add cache-purge edge function for admin Redis cache invalidation (#657)

After deploying fixes, Vercel CDN edge cache can serve stale responses
for 15-30 min. This endpoint provides programmatic Redis cache purge
so the origin data is fresh when CDN TTL expires or on redeploy.

Auth: Bearer RELAY_SHARED_SECRET
Methods: POST (purge), OPTIONS (CORS preflight)
Safety: blocklist (rl:, __), durable-data guard for patterns,
  max 200 deletions, timing-safe token comparison, audit logging

* feat: expand live channels with HLS support, Oceania region, and YouTube fallbacks (#660)

Add new channels: Tagesschau24, TV5 Monde Info, NRK1, Al Jazeera Balkans (Europe),
Arirang News, India Today, ABP News (Asia), Kan 11 (Middle East), Arise News (Africa),
ABC News Australia (Oceania). Add 15 HLS direct streams for desktop playback.
Add verified YouTube fallback IDs for abc-news-au, arise-news, nhk-world.
Add regionOceania translations to all 18 locale files.
Remove n1-bih (credential-leaking HLS URL).

* feat(oref): add 1,478 Hebrew→English location translations + wire sirens into breaking news banner (#661)

- Generate static location map from eladnava/pikud-haoref-api cities.json (1,478 entries)
- Lazy-load translations in oref-alerts.ts with retry on failure
- Add dispatchOrefBreakingAlert() with stable dedupe key and global cooldown bypass
- Wire oref siren alerts into breaking news banner on initial fetch and polling updates

* ui(investments): redesign panel with card layout and collapsible filters (#663)

Replace table with 2-line card rows (flag+name+entity+value / country+sector+status+year).
Move filters behind collapsible toggle button, add sort pill buttons. Add full .fdi-* CSS block
with dark theme support, hover states, and sector badges.

* fix(map): stabilize deck.gl layer IDs to prevent interleaved-mode null crash (#664)

Commit cf4fcfa7 (#620) removed 10 ghost pick layers for performance.
In interleaved mode, deck.gl creates shadow MapLibre custom layers per
deck.gl layer ID. When IDs vanish between renders, stale references in
MapLibre's render cycle hit null.id — outside deck.gl's onError boundary.

Add createEmptyGhost() — zero-cost sentinel layers (data:[], visible:false)
that preserve stable layer IDs without rendering anything. All 10 removed
ghost IDs are now stabilized.

Fixes: TypeError: Cannot read properties of null (reading 'id') in _drawLayers

* fix(ci): strip bundled GPU/Wayland libs from AppImage to fix black screen on non-Ubuntu distros (#666)

Tauri's `bundleMediaFramework: true` causes linuxdeploy to bundle Ubuntu's
Mesa libEGL, libGLESv2, libwayland-*, and DRI drivers. On Arch-based distros
with NVIDIA GPUs on Wayland, these override the host GPU drivers via
LD_LIBRARY_PATH, causing EGL_BAD_ALLOC / EGL_BAD_PARAMETER → black screen.

Post-build step extracts the AppImage, strips 14 GPU/Wayland lib patterns
plus Mesa DRI drivers (all on the official AppImage excludelist), repackages
with appimagetool 1.9.1 (SHA256-verified), verifies no banned libs remain,
and re-uploads the stripped AppImage to the GitHub Release.

* feat(rag): add worker-side vector store with opt-in Headline Memory setting

Move all vector storage and similarity search into the ML Web Worker,
keeping the main thread free of embedding data and cosine-similarity
compute. RSS headlines are ingested as Float32Array embeddings into
IndexedDB (cursor-based search, auto-prune at 5k vectors, promise-queue
serialized). Country briefs are enriched with historical parallels via
the worker when the new "Headline Memory" toggle is enabled in settings.

- New: src/workers/vector-db.ts (IDB wrapper, runs in worker only)
- New: src/utils/hash.ts (FNV-1a 52-bit client-side hash)
- New: e2e/rag-vector-store.spec.ts (7 E2E tests)
- Modified: ml.worker.ts (3 new message handlers)
- Modified: ml-worker.ts (3 new manager API methods)
- Modified: rss.ts (fire-and-forget ingest hook)
- Modified: country-intel.ts (RAG context enrichment with budget split)
- Modified: ai-flow-settings.ts + UnifiedSettings.ts (opt-in toggle)
- Modified: 18 locale files (i18n keys)

Co-authored-by: Yigtwxx <156921875+Yigtwxx@users.noreply.github.com>

* fix(rag): auto-load embeddings on toggle, translate locales, stabilize e2e

P1: Headline Memory toggle now triggers mlWorker.init() + loadModel('embeddings')
    so the feature no longer silently no-ops when embeddings aren't loaded.
    Also prevents worker termination when browserModel is off but headlineMemory is on.

P2: E2E tests restructured with serial mode and shared model init via beforeAll,
    eliminating repeated model downloads that cause CI timeouts.

P3: Translated sectionIntelligence, headlineMemoryLabel, headlineMemoryDesc
    into all 17 non-English locales (ar, de, el, es, fr, it, ja, ko, nl, pl,
    pt, ru, sv, th, tr, vi, zh).

Co-authored-by: Yigtwxx <156921875+Yigtwxx@users.noreply.github.com>

* fix(rag): teardown ML on headlineMemory disable, use shared page in e2e

P1: When headlineMemory is toggled off, unload embeddings model and
terminate the worker if browserModel is also off (non-desktop). Prevents
ML from staying active after user disables the feature.

P2: E2E tests now share a single persistent page with the model loaded
once in beforeAll, eliminating per-test model re-downloads that caused
CI timeouts.

Co-authored-by: Yigtwxx <156921875+Yigtwxx@users.noreply.github.com>

* fix(rag): add vectorStoreReset to close IDB before delete in e2e

The shared-page e2e model keeps the worker's IDB connection open,
blocking indexedDB.deleteDatabase(). Add closeDB() to vector-db.ts,
a vector-store-reset worker message, and vectorStoreReset() manager
method. E2E clearVectorDB() now closes the worker DB handle first,
then awaits deleteDatabase completion.

Co-authored-by: Yigtwxx <156921875+Yigtwxx@users.noreply.github.com>

* fix(e2e): don't resolve early on IDB deleteDatabase onblocked

onblocked means the DB still has open connections and hasn't been
deleted yet. Wait for onsuccess instead, with a 2s timeout fallback
to prevent hanging if connections linger.

Co-authored-by: Yigtwxx <156921875+Yigtwxx@users.noreply.github.com>

* fix(rag): use deterministic store.clear() instead of deleteDatabase in reset

resetStore() clears all records via IDB transaction then closes the
handle — fully deterministic, no deleteDatabase/onblocked race.
Co-authored-by: Yigtwxx <Yigtwxx@users.noreply.github.com>

---------

Co-authored-by: Sebastien Melki <sebastien@anghami.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Konstanty Szumigaj <kostekszumigaj@gmail.com>
Co-authored-by: Chris Chen <fuleinist@users.noreply.github.com>
Co-authored-by: facusturla <facusturla@users.noreply.github.com>
Co-authored-by: karim <mirakijka@gmail.com>
Co-authored-by: maxime.io <maxime.de.visscher@gmail.com>
Co-authored-by: Elias El Khoury <efk@anghami.com>
Co-authored-by: Fayez Bast <96446332+FayezBast@users.noreply.github.com>
Co-authored-by: Yigtwxx <156921875+Yigtwxx@users.noreply.github.com>
Co-authored-by: Yigtwxx <Yigtwxx@users.noreply.github.com>
2026-03-01 20:25:21 +04:00
Elie Habib
98150d639d feat: persist OREF history to Redis + retry bootstrap (#674)
* feat: persist OREF history to Redis + retry bootstrap on failure

OREF history was lost on every container restart — single curl call with
no retry, no persistence. Panel showed "0 alerts" until history
re-accumulated over hours.

Changes to scripts/ais-relay.cjs:
- Add Upstash Redis REST helpers (upstashGet/upstashSet) using https.request
  with HTTPS-only validation, 5s timeout, never-throw semantics
- Persist history to Redis after each poll mutation (version-deduped,
  concurrent-guarded, 200-wave hard cap, 7d TTL matching purge window)
- Bootstrap from Redis first on startup (schema validation, 7d purge filter,
  24h count recompute, lastAlertsJson seeding to prevent duplicate waves)
- Fall through to upstream retry if Redis data is empty or all stale
- Upstream retry: 3 attempts with exponential backoff + jitter (~70s budget)
- Expose redisEnabled + bootstrapSource in /health endpoint
- Preserves main's totalHistoryCount field and 7-day history retention

Failure matrix:
- UP/UP: Redis first (instant), poll refreshes + persists
- UP/DOWN: Bootstrap from upstream, persist fails silently
- DOWN/UP: Bootstrap from Redis cache
- DOWN/DOWN: 3 retries then empty history

* fix: increment persist version after upstream bootstrap to seed Redis

Without this, _persistVersion stays at 0 after bootstrap, matching
_lastPersistedVersion — orefPersistHistory() skips the write. A restart
before any new alerts would lose all bootstrapped history.
2026-03-01 19:02:17 +04:00
Elie Habib
cae3c08436 feat(oref): add 1,478 Hebrew→English location translations + wire sirens into breaking news banner (#661)
- Generate static location map from eladnava/pikud-haoref-api cities.json (1,478 entries)
- Lazy-load translations in oref-alerts.ts with retry on failure
- Add dispatchOrefBreakingAlert() with stable dedupe key and global cooldown bypass
- Wire oref siren alerts into breaking news banner on initial fetch and polling updates
2026-03-01 15:59:53 +04:00
Elie Habib
c6b94a55bf fix(oref): grab newest history records and preserve bootstrap data (#653)
OREF AlertsHistory.json returns records newest-first, but the bootstrap
used .slice(-500) which took the oldest 500 — all outside the 24h window.
The poll loop then purged them all, leaving historyCount24h = 0.

Three fixes:
- Use .slice(0, 500) to take the newest 500 records from OREF history
- Extend history purge from 24h to 7 days so bootstrap data persists
- Add totalHistoryCount field for badge fallback when 24h count is zero
2026-03-01 14:16:32 +04:00
Elie Habib
88215cb517 feat: add Redis caching for GPS jamming data (#646) 2026-03-01 12:52:57 +04:00
Elie Habib
36e36d8b57 Cost/traffic hardening, runtime fallback controls, and PostHog removal (#638)
- Remove PostHog analytics runtime and configuration
- Add API rate limiting (api/_rate-limit.js)
- Harden traffic controls across edge functions
- Add runtime fallback controls and data-loader improvements
- Add military base data scripts (fetch-mirta-bases, fetch-osm-bases)
- Gitignore large raw data files
- Settings playground prototypes
2026-03-01 11:53:20 +04:00