Checks VERCEL_GIT_PULL_REQUEST_ID before proceeding.
Branch pushes without an open PR are skipped (exit 0),
eliminating wasted build minutes from 378+ feature branches.
Production (main) always builds.
Seed services and relay were redeploying on every push (blog, frontend, etc)
because no watchPatterns were configured. Added utility script that sets
watchPatterns via Railway GraphQL API so services only redeploy when their
actual source files change. Already applied to all 23 services.
* feat(blog): add Astro blog at /blog with 16 SEO-optimized posts
Adds a static Astro blog built during Vercel deploy and served at
worldmonitor.app/blog. Includes 16 marketing/SEO posts covering
features, use cases, and comparisons from customer perspectives.
- blog-site/: Astro static site with content collections, RSS, sitemap
- Vercel build pipeline: build:blog builds Astro and copies to public/blog/
- vercel.json: exclude /blog from SPA catch-all rewrite and no-cache headers
- vercel.json: ignoreCommand triggers deploy on blog-site/ changes
- Cache: /blog/_astro/* immutable, blog HTML uses Vercel defaults
* fix(blog): fix markdown lint errors in blog posts
Add blank lines around headings (MD022) and lists (MD032) across
all 16 blog post files to pass markdownlint checks.
* fix(ci): move ignoreCommand to script to stay under 256 char limit
Vercel schema validates ignoreCommand max length at 256 characters.
Move the logic to scripts/vercel-ignore.sh and reference it inline.
* fix(blog): address PR review findings
- Add blog sitemap to robots.txt for SEO discovery
- Use www.worldmonitor.app consistently (canonical domain)
- Clean public/blog/ before copy to prevent stale files
- Use npm ci for hermetic CI builds
* fix(blog): move blog dependency install to postinstall phase
Separates dependency installation from compilation. Blog deps are
now installed during npm install (postinstall hook), not during build.
- Add AVIATIONSTACK and NOTAM proto enum values for accurate source attribution
- AviationStack flight data alerts now show "Flight Data" instead of "Computed"
- NOTAM closure/restriction alerts now show "NOTAM"
- Remove generateSimulatedDelay() fallback that produced fake random alerts
- Reduce all aviation cache TTLs from 2h to 30min for fresher data
- Reduce relay seed interval from 1h to 30min, TTL from 4h to 1h
- Reduce seed freshness threshold from 45min to 20min
- Update health check maxStaleMin from 90 to 60min
- Update all 21 locale files with new source labels
* fix(health): fix riskScores seeding gap and seed-meta key mismatch
- Switch RPC handler to cachedFetchJsonWithMeta so stale key is refreshed
on every successful response (cache hit or miss), not just cache misses
- Fix seed-meta key mismatch: health.js and seed-health.js now check
seed-meta:risk:scores:sebuf (matching what cachedFetchJson writes)
- Add warm-ping loop in relay (8min interval) to keep RPC cache fresh
- Remove dead startCiiSeedLoop and 345 lines of unused CII seed code
* fix(scoring): await stale key write to prevent edge runtime drop
Edge/serverless runtimes may terminate the isolate before a
fire-and-forget Redis write completes. Await the setCachedJson
call so the stale key TTL is guaranteed to be extended.
* feat(natural): add tropical cyclone tracking from NHC and GDACS
Integrate NHC ArcGIS REST API (15 storm slots across AT/EP/CP basins)
and GDACS TC field extraction to provide real-time tropical cyclone data
with forecast tracks, uncertainty cones, and historical track paths.
- Proto: add optional TC fields (storm_id, wind_kt, pressure_mb, etc.)
plus ForecastPoint, PastTrackPoint, CoordRing messages
- Server/seed: NHC two-pass query (forecast points then detail layers),
GDACS wind/pressure parsing, Saffir-Simpson classification, dedup
strategy (NHC > GDACS > EONET), pressureMb validation (850-1050),
advisory date with Number.isFinite guard
- Globe: dashed red forecast track, per-segment wind-colored past track,
semi-transparent orange forecast cone polygon
- Popup: TC details panel with color-coded category badge, wind/pressure
- Frontend mapper: forward all TC fields, convert CoordRing to number[][][]
* fix(natural): improve GDACS dedup, NHC classification, and TC popup i18n
- GDACS dedup now checks name + geographic proximity instead of name-only
- NHC classification uses stormtype field for subtropical/post-tropical
- TC popup labels use t() for localization instead of hardcoded English
* feat(map): add cyclone-specific deck.gl layers for 2D map
- Storm center ScatterplotLayer with Saffir-Simpson wind coloring
- Past track PathLayer with per-segment wind-speed color ramp
- Forecast track PathLayer with dashed line via PathStyleExtension
- Cone PolygonLayer for forecast uncertainty visualization
- Tooltip and click routing for all new storm layer IDs
* fix(map): remove click routing for synthetic storm track/cone layers
Track and cone layers carry lightweight objects without full NaturalEvent
fields. Clicking them would pass incomplete data to the popup renderer.
Only storm-centers-layer (which holds the full NaturalEvent) routes to
the natEvent popup. Tracks and cones remain tooltip-only.
* fix(map): attach parent NaturalEvent to synthetic storm layers for clicks
Synthetic track/cone objects now carry _event reference to the parent
NaturalEvent. Click handler unwraps _event before passing to popup,
so clicking any storm element opens the full TC popup.
* feat(map): add NOTAM overlay + satellite imagery integration
NOTAM Overlay:
- Expand airport monitoring from MENA-only to 64 global airports
- Add ScatterplotLayer (55km red rings) on flat map for airspace closures
- Add CSS-pulsing ring markers on globe for closures
- Independent of flights layer toggle (works when flights OFF)
- Bump NOTAM cache key v1 to v2
Satellite Imagery:
- Add Capella SAR STAC catalog proxy at /api/imagery/v1
- SSRF protection via URL allowlist + bbox/datetime validation
- SatelliteImageryPanel with preview thumbnails and scene metadata
- PolygonLayer footprints on flat map with viewport-triggered search
- Polygon footprints on globe with "Search this area" button
- Full variant only, default disabled
Layer key propagation across all 23+ files including variants,
harnesses, registry, URL state, and renderer channels.
* fix(imagery): wire panel data flow, fix viewport race, add datetime filter
P1 fixes:
- Imagery scenes now flow through MapContainer.setOnImageryUpdate()
callback, making data available to both renderers and panel
- Add version guard to fetchImageryForViewport() preventing stale
responses from overwriting newer viewport data
- Wire SatelliteImageryPanel.update() and setOnSearchArea() in
panel-layout.ts (panel was previously unhooked)
- Globe mode "Search this area" fetches via MapContainer.getBbox()
P2 fix:
- search-imagery.ts now filters STAC items by datetime range when
the client provides the datetime parameter
Also:
- Add MapContainer.getBbox() for viewport-aware imagery fetching
- Add DeckGLMap.getBbox() public method
- Data-loader layer toggle triggers initial imagery fetch
* fix(imagery): complete source filter + fix date-only end bound
- Filter STAC items by constellation when source param is provided,
making the API contract match actual behavior
- Date-only end bounds (YYYY-MM-DD without T) now include the full
day (23:59:59.999Z) instead of only midnight
Add Yehud, Sitra, Sanandaj, Ma'ameer, Northern Cyprus to
LOCATION_COORDS for geolocating new Iran conflict events.
74 events seeded to Redis from LiveUAMap import.
* fix(seed): add new locations and day-ago parsing for Iran events
Add 11 new location coordinates (al-Kharj, Petah Tikva, Beersheba,
Oman, Oslo, Aghdasiyeh, Rey, Beirut, Azraq) and support "a day ago"
/ "N days ago" relative time parsing.
* feat(pro): add early access promotional banner to dashboard
Thin, dismissible top banner promoting WorldMonitor Pro with
"Reserve your spot" CTA linking to /pro. Dismisses for 7 days
via localStorage timestamp. Slide-down animation, responsive,
light/dark theme compatible via CSS variables.
Remove patterns that match zero satellites in CelesTrak:
- OFEK/EROS (Israel), IGS (Japan) — classified
- LACROSSE/TOPAZ (US NRO) — retired/listed as USA-*
- KONDOR/PERSONA/BARS-M/RESURS-P (Russia) — listed as COSMOS 2xxx
- HISEA/SUPERVIEW (China), CSO-/HELIOS (France) — not in groups
- RISAT/EOS-0x (India) — not in resource group
- Add CelesTrak 'active' group (~6000 sats, filtered down)
- Add Israeli (OFEK, EROS), Indian (RISAT, CARTOSAT, EOS), Japanese (IGS),
Turkish (GOKTURK, RASAT), French (CSO, HELIOS), US NRO (LACROSSE, TOPAZ, USA-*)
- Add Russian (KONDOR, PERSONA, BARS-M, RESURS-P), Chinese (HISEA, SUPERVIEW, ZIYUAN)
- Widen COSMOS regex to 2[4-9]xx for newer Russian recon sats
- Add country colors for IL, IN, JP, TR on globe
Railway Nixpacks images don't include curl. Replaced curlFetchJson()
with proxyFetchJson() using Node.js http/https/tls modules for
HTTP CONNECT proxy tunneling to OpenSky.
Track ~80-120 intelligence-relevant satellites on the 3D globe using CelesTrak
TLE data and client-side SGP4 propagation (satellite.js). Satellites render at
actual orbital altitude with country-coded colors, 15-min orbit trails, and
ground footprint projections.
Architecture: Railway seeds TLEs every 2h → Redis → Vercel CDN (1h cache) →
browser does SGP4 math every 3s (zero server cost for real-time movement).
- New relay seed loop (ais-relay.cjs) fetching military + resource groups
- New edge handler (api/satellites.js) with 10min cache + negative cache
- Frontend service with circuit breaker and propagation lifecycle
- GlobeMap integration: markers, trails (pathsData), footprints, tooltips
- Layer registry as globe-only "Orbital Surveillance" with i18n (21 locales)
- Full documentation at docs/ORBITAL_SURVEILLANCE.md with roadmap
- Fix pre-existing SearchModal TS error (non-null assertion)
* feat(seeds): standalone military flights seed + relay cleanup
- Create scripts/seed-military-flights.mjs as standalone Railway cron seed
with 3-tier fallback: OpenSky auth → OpenSky anonymous → Wingbits
- Remove military flights seed from ais-relay.cjs (452 lines)
- Theater posture seed remains in relay with its own OpenSky + Wingbits fallback
- Standalone seed writes military:flights:v1, stale, and theater posture keys
* feat(opensky): HTTP CONNECT tunnel via residential proxy + better logging
- New OPENSKY_PROXY_AUTH env var (falls back to OREF_PROXY_AUTH)
- _openskyProxyConnect() helper for HTTP CONNECT tunneling in relay
- Updated _attemptOpenSkyTokenFetch() and _openskyRawFetch() to route
through proxy when OPENSKY_PROXY_AUTH is set
- /opensky-diag now shows proxyEnabled status
- Startup log shows (via proxy) or (direct)
- seed-military-flights.mjs: curl-based proxy for OpenSky auth + anon
- seed-military-flights.mjs: verbose Wingbits logging (response shape,
per-area flight counts, sample data) to debug 0-aircraft issue
- Better HTTP error logging: status code + response body on non-2xx
* fix(wingbits): use correct response key 'data' instead of 'flights'
Wingbits API returns { alias, data: [...] } not { flights: [...] }.
This caused 0 aircraft from Wingbits in both standalone seed and relay
theater posture. Also fixed field mappings: 'c' (country), 'ra' (timestamp),
'og' (onGround) match actual Wingbits response format.
Verified locally: 3761 raw → 52 military matches from WESTERN region alone.
* fix(wingbits): correct field mapping + wingbits-first fetch order
- 'c' field is internal Wingbits classification (A0-C2), NOT country code
Removed from originCountry mapping to avoid false matches
- Wingbits now tier 1 (no proxy, fast, reliable), OpenSky supplements
via proxy as tier 2/3 for additional aircraft coverage
- Verified: Wingbits returns altitude in feet, speed in knots already
(ft→m→ft round-trip through unified pipeline is correct)
When OpenSky is unavailable (both proxy auth and anonymous API),
Wingbits now serves as fallback. When both sources are available,
Wingbits supplements OpenSky with additional aircraft (deduped by
icao24). Wingbits data is converted to OpenSky state array format
for unified processing through the existing military detection pipeline.
When Railway's OpenSky OAuth2 auth times out (IP-level rate limiting),
the military flights seed now falls back to OpenSky's anonymous public
API endpoint directly. This ensures data flows even when the
authenticated proxy is blocked.
* fix(relay): compute theater posture directly instead of pinging Vercel RPC
The relay's theater posture seed loop was pinging the Vercel RPC endpoint
which itself needed WS_RELAY_URL to proxy OpenSky back through the relay —
a circular dependency that resulted in empty theaters.
Now the relay fetches OpenSky directly via its own localhost proxy,
applies military callsign filtering, computes postures for all 9 theaters,
and writes all 3 Redis keys (live/stale/backup) + seed-meta directly.
Wingbits API serves as fallback when OpenSky is unavailable.
* feat(relay): seed military flights to Redis, rewire frontend to read from edge handler
Relay fetches OpenSky every 5 min, filters by ICAO hex ranges (29) and
callsign patterns (83), converts units (m→ft, m/s→kts), and writes to
Redis. Frontend reads pre-filtered data via /api/military-flights edge
handler instead of proxying through OpenSky directly — eliminates
cascading 429 rate limits.
- Add seedMilitaryFlights() with in-flight mutex, 2-region fetch, empty guard
- Add api/military-flights.js edge handler (Redis → stale fallback)
- Rewrite src/services/military-flights.ts to fetchFromRedis()
- Add health.js tracking (STANDALONE_KEYS, SEED_META, CASCADE_GROUPS)
- Theater posture consumes shared flight data (no duplicate OpenSky calls)
* fix(relay): theater posture starvation, aircraftType downgrade, and openskyRelay gate
- Move lastMilFlightsSeedMs after successful posture passthrough so the
standalone fallback runs when passthrough keeps erroring
- Use f.aircraftType from military seed instead of re-classifying via
theaterDetectAircraftType (which has no fighter branch)
- Add militaryFlights feature flag with no required secrets; swap gate
from openskyRelay so desktop users get Redis-backed data without
legacy OpenSky credentials
* fix(relay): add fighter branch to theaterDetectAircraftType
The standalone theater posture fallback path could not classify fighter
callsigns (BOLT, VIPER, RAPTOR, etc.), causing them to be counted as
unknown and underreporting strike_capable posture levels.
* fix(military-flights): preserve OpenSky direct path for desktop, Redis for web
Desktop (Tauri) cannot reach /api/military-flights (Vercel edge handler).
Restore the original OpenSky direct fetch path for desktop mode while
using the new Redis-backed path for web/cloud. Route based on
isDesktopRuntime() — desktop uses openskyRelay feature gate + direct
OpenSky, web uses militaryFlights feature gate + Redis edge handler.
The relay's theater posture seed loop was pinging the Vercel RPC endpoint
which itself needed WS_RELAY_URL to proxy OpenSky back through the relay —
a circular dependency that resulted in empty theaters.
Now the relay fetches OpenSky directly via its own localhost proxy,
applies military callsign filtering, computes postures for all 9 theaters,
and writes all 3 Redis keys (live/stale/backup) + seed-meta directly.
Wingbits API serves as fallback when OpenSky is unavailable.
* fix(health): improve UCDP auth error handling, fix insights TTL
- UCDP: detect 401/403 and string error responses with clear message
about missing UCDP_ACCESS_TOKEN env var (API now requires auth)
- Insights: increase cache TTL from 600s (10min) to 1800s (30min) to
match health maxStaleMin and survive missed/delayed cron runs
* fix(health): use Promise.any for UCDP version discovery
UCDP v25.1 API hangs (30s timeout) while v24.1 works fine.
Promise.allSettled waited for ALL candidates to settle, wasting 30s.
Promise.any returns as soon as the first version succeeds (~3s for v24.1).
Also adds empty-result check in discovery to skip versions that return
0 events (v24.1 data only goes through 2023).
* fix(scripts): add h3-js dependency for Railway gpsjam cron service
fetch-gpsjam.mjs imports h3-js for H3 hex → lat/lon conversion.
scripts/package.json needs it since Railway builds from rootDirectory: "scripts".
Add geocoding for Riyadh, Haifa, Sulaimaniyah, Yazd, Qazvin, Kish,
Mehran, Jubail, Shaybah, Al Dhafra, Juffair, Qeshm, and others.
Increase Redis TTL from 24h to 48h for longer event retention.
* feat(gpsjam): migrate GPS jamming from gpsjam.org to Wingbits API
Replace gpsjam.org CSV scraping with Wingbits customer API for GPS/GNSS
interference data. This is a proper API with structured JSON responses
instead of fragile web scraping.
Key changes:
- Rewrite fetch-gpsjam.mjs seeder for Wingbits API (x-api-key auth)
- Delete ~150-line gpsjam seed loop from ais-relay.cjs (now standalone cron)
- Simplify api/gpsjam.js to Redis-only reads with v1→v2 fallback
- Update data shape: pct/good/bad/total → npAvg/sampleCount/aircraftCount
- Redis key: intelligence:gpsjam:v1 → v2 (with dual-write transition)
- Add vite dev plugin for local development
- Update all frontend components (MapPopup, DeckGLMap, GlobeMap, locales)
Zero-downtime: seeder dual-writes both v1 and v2 keys, edge handler
falls back to v1 with shape normalization. Remove v1 code after 24-72h.
* fix(gpsjam): improve v1 fallback normalization and update all locale files
- v1 fallback now derives npAvg from severity thresholds (high: 0.3,
medium: 0.8) instead of hardcoding 0, uses bad/total for counts
- Update all 20 non-English locale files to use new gpsJamming keys
(navPerformance, samples, aircraft) with English fallback values
* fix(gpsjam): dual-write v1 in old schema shape and catch Redis errors
- Seeder now converts v2 data back to v1 shape (pct/good/bad/total)
for the dual-write, so old deployments and rollbacks parse correctly
- Edge handler wraps readFromRedis calls in try-catch so network/timeout
errors return graceful 503 instead of platform 500s
- Add World Bank seed loop to ais-relay (24h interval) for techReadiness,
progressData, and renewableEnergy — previously manual-only with no cron
- Add seed-meta writes for UCDP and GPSJAM relay loops so health endpoint
can track freshness (was always showing STALE/unknown)
- Fix theater posture and service statuses RPC URLs from worldmonitor.app
to api.worldmonitor.app to bypass Cloudflare bot protection (403)
- Adjust UCDP maxStaleMin from 60 to 420 to match 6h relay interval
Railway rootDirectory isolates build context — postinstall cp from
../shared/ fails because parent dirs aren't in the Nixpacks image.
Commit JSON/CJS configs directly into scripts/shared/.
- Remove useless postinstall from scripts/package.json
- Remove scripts/shared/ from .gitignore
- Commit all shared config files into scripts/shared/
- Add sync test to catch drift between shared/ and scripts/shared/
Railway deploys seed services with rootDirectory=scripts/, placing files
at /app/ without the parent shared/ directory. The createRequire +
require('../shared/X.json') pattern resolves to /shared/ which doesn't
exist in the container.
- Add loadSharedConfig() to _seed-utils.mjs: tries ../shared/ (local)
then ./shared/ (Railway) with clear error on miss
- Add requireShared() to ais-relay.cjs with same dual-path fallback
- Add postinstall to scripts/package.json that copies ../shared/ into
./shared/ during Railway build
- Update all 6 seed scripts to use loadSharedConfig instead of
createRequire + require
- Add scripts/shared/ to .gitignore
Fixes crash introduced by #1212 (shared JSON consolidation).
* fix(aviation): unify NOTAM status logic between map and ops table
Both endpoints now use a shared NOTAM loader (seed-first with live
fallback) so they see the same closure snapshot. When an airport has
a NOTAM *and* real flight data, the new mergeNotamWithExistingAlert()
preserves observed stats instead of hard-replacing with severe/closure.
This fixes DXB showing NORMAL in the ops table but SEVERE on the map.
- Extract loadNotamClosures() to _shared.ts (used by both handlers)
- Add mergeNotamWithExistingAlert() for NOTAM + flight data merge
- Align ops-summary severity: NOTAM floor = moderate (operating) or
severe (no flights), matching the map's merge logic
- Fix OMAD → OMAA ICAO typo in seed MENA list (AUH)
* fix: resolve strict tsconfig type errors in API build
* fix(aviation): preserve closure delayType for NOTAM-closed airports
Downstream consumers (MapPopup, data-loader, country-instability) rely
on delayType === 'closure' as the only closure signal. Always set
delayType to closure in mergeNotamWithExistingAlert() since the NOTAM
confirms the airport is closed — severity is still nuanced by flight data.
* fix: eliminate frontend external API calls, enforce gold standard pattern
- Polymarket: remove browser fan-out (536→105 lines), bootstrap → RPC only
- USASpending: remove direct API calls, read from bootstrap hydration
- NWS Weather: remove direct API calls, read from bootstrap hydration
- Nominatim: proxy through api/reverse-geocode.js with Redis cache + SSRF clamping
- Add seed scripts for weather alerts (15min) and spending (60min)
- Wire both seed loops into ais-relay.cjs
- Register weatherAlerts + spending in bootstrap.js and health.js
- Add 4 missing standalone keys to health.js (cyberThreatsRpc, militaryBases, temporalAnomalies, displacement)
* fix: resolve reload regressions and null-cache poisoning from #1217
- Weather/Spending: fall back to `/api/bootstrap?keys=` on scheduled
reloads after the one-shot `getHydratedData()` is consumed
- Prediction: add client-side bootstrap filter for country markets
when RPC fails (server skips bootstrap for query-based requests)
- Reverse-geocode: restore abort/timeout guard so transient network
errors don't permanently poison the in-memory cache
* feat(infrastructure): expand submarine cables to 86 via TeleGeography API seed
- Add `seed-submarine-cables.mjs` Railway cron script fetching 86 strategic
cables from TeleGeography API (was 19 hand-curated)
- Update `geo.ts` static baseline with full cable data (routes, landing points,
owners, RFS year, regions)
- Update `get-cable-health.ts` cable name/landing mappings for new slug-based IDs
- Add `data?.cables?.length` to `_seed-utils.mjs` record count heuristic
- Update `map-harness.ts` cable ID references
- Remove GitHub Actions workflows for UCDP and WB indicators (Railway cron only)
* fix(infrastructure): cable route matching, name false positives, validation threshold
- Fix route geometry: only strip numeric suffix when result matches a known
cable slug, preventing seamewe-6→seamewe, farice-1→farice, etc.
- Fix name matching: use word-boundary regex instead of substring includes;
disambiguate short names (ACE→ACE CABLE, SAFE→SAFE CABLE, PEACE→PEACE CABLE,
TEAMS→TEAMS CABLE) to prevent false matches on common NGA words
- Raise validation threshold from 50 to 75 (88% success required) to prevent
heavily partial upstream results from overwriting good cached data
* fix(infrastructure): tie validation threshold to 90% of configured cable count
Dynamic threshold based on CABLE_REGIONS length instead of a hardcoded number.
Currently requires >= 78 of 86 cables (90%).
Adding a new item (crypto, ETF, stablecoin, gulf symbol, etc.) previously
required editing 2-4 files because the same list was hardcoded independently
in seed scripts, RPC handlers, and frontend config. Following the proven
shared/crypto.json pattern, extract 6 new shared JSON configs so each list
has a single source of truth.
New shared configs:
- shared/stablecoins.json (ids + coinpaprika mappings)
- shared/etfs.json (BTC spot ETF tickers + issuers)
- shared/gulf.json (GCC indices, currencies, oil benchmarks)
- shared/sectors.json (sector ETF symbols + names)
- shared/commodities.json (VIX, gold, oil, gas, silver, copper)
- shared/stocks.json (market symbols + yahoo-only set)
All seed scripts, RPC handlers, and frontend config now import from
these shared JSON files instead of maintaining independent copies.
- Use resolved-flights-only denominator (landed+active+cancelled+diverted)
instead of all flights including scheduled/unknown. DXB was showing 15%
cancelled (NORMAL) when the real rate among resolved flights is ~58% (MAJOR).
- Add flight_date=today filter to AviationStack API calls to avoid mixing
historical/future flights into today's cancellation stats.
- Factor cancellation rate into ops summary table severity (was ignored,
only delay minutes were considered). Uses shared severityFromCancelRate()
to avoid threshold duplication.
- Add minimum resolved threshold (>=10) before using resolved denominator
to prevent extreme percentages from tiny samples.
- Add 12 major airports to AviationStack monitoring: YVR, SCL, DUB, LIS,
ATH, WAW, CAN, TPE, MNL, AMM, KWI, CMN (40→52 airports).
* fix: three panel issues — Tech Readiness toggle, Crypto top 10, FIRMS key check
1. #1132 — Add tech-readiness to FULL_PANELS so it appears in the
Settings toggle list for Full/Geopolitical variant users.
2. #979 — Expand crypto panel from 4 coins to top 10 by market cap
(BTC, ETH, USDT, BNB, SOL, XRP, USDC, ADA, DOGE, TRX) across
client config, server metadata, CoinPaprika fallback map, and
seed script.
3. #997 — Check isFeatureAvailable('nasaFirms') before loading FIRMS
data. When the API key is missing, show a clear "not configured"
message instead of the generic "No fire data available".
Closes#1132, closes#979, closes#997
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: replace stablecoins with AVAX/LINK, remove duplicate key, revert FIRMS change
- Replace USDT/USDC (stablecoins pegged ~$1) with AVAX and LINK
- Remove duplicate 'usd-coin' key in COINPAPRIKA_ID_MAP
- Add CoinPaprika fallback IDs for avalanche-2 and chainlink
- Revert FIRMS API key gating (handled differently now)
- Add sync comments across the 3 crypto config locations
* fix: update AIS relay + seed CoinPaprika fallback for all 10 coins
The AIS relay (primary seeder) still had the old 4-coin list.
The seed script's CoinPaprika fallback map was also missing the
new coins. Both now have all 10 entries.
* refactor: DRY crypto config into shared/crypto.json
Single source of truth for crypto IDs, metadata, and CoinPaprika
fallback mappings. All 4 consumers now import from shared/crypto.json:
- src/config/markets.ts (client)
- server/worldmonitor/market/v1/_shared.ts (server)
- scripts/seed-crypto-quotes.mjs (seed script)
- scripts/ais-relay.cjs (primary relay seeder)
Adding a new coin now requires editing only shared/crypto.json.
* chore: fix pre-existing markdown lint errors in README.md
Add blank lines between headings and lists per MD022/MD032 rules.
* fix: correct CoinPaprika XRP mapping and add crypto config test
- Fix xrp-ripple → xrp-xrp (current CoinPaprika id)
- Add tests/crypto-config.test.mjs: validates every coin has meta,
coinpaprika mapping, unique symbols, no stablecoins, and valid
id format — bad fallback ids now fail fast
* test: validate CoinPaprika ids against live API
The regex-only check wouldn't have caught the xrp-ripple typo.
New test fetches /v1/coins from CoinPaprika and asserts every
configured id exists. Gracefully skips if API is unreachable.
* fix(test): handle network failures in CoinPaprika API validation
Wrap fetch in try-catch so DNS failures, timeouts, and rate limits
skip gracefully instead of failing the test suite.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
* feat(intelligence): server-side batch AI classification for news headlines
Move LLM classification from per-client RPCs to a server-side seed loop.
The relay batch-classifies digest titles every 15min via any OpenAI-compatible
endpoint, caches results in Redis, and the digest handler enriches items
from cache before serving — eliminating ~80 classify-event calls per client.
- Remove duplicate digest branch in data-loader.ts (dead code)
- Add _batch-classify.ts with provider-agnostic batch LLM classify
- Update classify-event.ts to use LLM_API_KEY/LLM_API_URL/LLM_MODEL env vars
- Add upstashMGet helper + classify seed loop to ais-relay.cjs
- Add enrichWithAiCache to list-feed-digest.ts (single batch Redis read)
- Preserve high-confidence keyword hits (>= 0.9) via upgrade rule
* fix(intelligence): use protocol-aware transport in relay LLM fetch, tolerate wrapped JSON in classify-event
- classifyFetchLlm: pick http/https module based on URL protocol so
http://localhost works for local Ollama/vLLM during dev
- classify-event.ts: extract JSON object from fenced/wrapped model output
(matches relay's tolerant parsing for OpenAI-compatible providers)
* perf(edge): reduce unnecessary Vercel edge invocations (#6 findings)
Phase 1 — Client-only fixes:
- Remove predictions double getHydratedData read from data-loader.ts;
fetchPredictions() handles hydration internally
- Fix UCDP delete-on-read race: read hydratedUcdp once in data-loader,
pass to both fetchUcdpClassifications() and fetchUcdpEvents()
Phase 2 — Batch RPCs (proto + server + client):
- Add GetHumanitarianSummaryBatch RPC: replaces 20-request HAPI fanout
with single batch call (getCachedJsonBatch + per-key Redis caching)
- Add GetFredSeriesBatch RPC: replaces 7-request FRED fanout with
single batch call (same pattern)
- Both batch RPCs have 404 deploy-skew fallback to per-item calls
Phase 3 — Seed gap:
- Add seed-service-statuses.mjs standalone seed script
- Add 15-min warm-ping loop in AIS relay for service statuses
- Remove serviceStatuses from ON_DEMAND_KEYS in health.js
Net savings: up to 28 edge calls eliminated on cold miss per page load.
* fix(edge): address code review findings (P1–P3)
P1: Fix dead 404 deploy-skew fallback — circuit breaker was swallowing
the ApiError before the catch block could detect it. Move 404 fallback
inside the breaker callback so it executes before the breaker catches.
P2: Replace 172-line seed-service-statuses.mjs (duplicated parser logic)
with a 60-line warm-ping that triggers the existing RPC handler.
P2: Extract shared ISO2_TO_ISO3 mapping to conflict/v1/_shared.ts,
eliminating duplication between single and batch HAPI handlers.
P3: Remove unnecessary UPSTASH_ENABLED guard from relay warm-ping
(it calls Vercel RPC, not Redis directly).
P3: Clean up unused per-series FRED breakers and fetchSingleFredSeries
(replaced by batch breaker). Update getFredStatus() accordingly.
* fix(edge): use concurrent fetches in batch handlers
HAPI batch: replace serial loop with groups of 5 concurrent fetches
using Promise.allSettled for partial-success resilience. Bump client
timeout to 60s (4 rounds × 15s upstream timeout worst case).
FRED batch: replace serial loop with fully parallel Promise.allSettled
(max 10 series, each hits separate FRED endpoint).
Both changes prevent empty-result regression on cold cache that the
serial approach caused when upstream latency exceeded the client timeout.
* fix(cyber): prevent AbuseIPDB quota burn when Redis rate check fails
The catch block in fetchAbuseIpDb() was falling through to the API call
when the Redis rate-limit check failed (e.g. Redis down, first run with
no key). With a 10-minute cron interval, this could exhaust the 100
calls/day free-plan limit in under 17 hours.
Now returns early with { ok: false, threats: [] } so the other 4 IOC
sources still seed normally while AbuseIPDB is safely skipped.
* fix(seeds): respect API rate limits and log fetch failures
1. seed-fire-detections.mjs: increase delay from 200ms to 6s between
FIRMS API calls. Free tier allows 10 req/min; 27 calls at 200ms
exceeded this and caused silent failures.
2. ais-relay.cjs (positive events): increase GDELT delay from 500ms to
5.5s to respect the documented 1 req/5s rate limit.
3. ais-relay.cjs (cyber fetchers): replace 5 silent `catch { return [] }`
blocks with `console.warn` logging so failures are visible in Railway
logs. Dead code today (cyber loop disabled) but sets the right example
for contributors.
* fix(seeds): extend FIRMS lock TTL and restore AbuseIPDB resilience
P1: seed-fire-detections.mjs — the 6s FIRMS pacing makes the job take
~162s minimum, exceeding the default 120s lock TTL. Extend lockTtlMs
to 300s (5 min) to prevent overlapping cron invocations.
P2: seed-cyber-threats.mjs — revert the early return on Redis rate-check
failure. A transient Redis blip should not permanently disable AbuseIPDB
for that run. Instead, log a warning and proceed with caution. The 2h
rate-limit interval + 10-min cron means at most 1 extra call per Redis
outage window, well within the 100/day budget.
* fix(wildfire): extend lock TTL to 10 min for worst-case FIRMS timeouts
27 calls × (6s pacing + 30s per-request timeout) = 972s worst case.
300s lock was still too short under partial upstream slowness.
* Add Pakistan–Afghanistan hotspot and conflict zone
Introduce a new INTEL_HOTSPOTS entry (pak_afghan) to track Pakistan–Afghanistan border tensions, including location, keywords, agencies, status, escalation indicators, and humanitarian significance. Also add a CONFLICT_ZONES polygon for 'Pakistan–Afghanistan War' with center, intensity, parties, startDate (Feb 21, 2026), key developments, and displacement/casualty notes to enable monitoring of cross-border strikes, TTP activity, and regional instability.
* Update conflict zone center coordinates
Adjust the center coordinates for the specified conflict zone in src/config/geo.ts from [50, 30] to [69, 31.8] to better reflect the actual Pakistan/Afghanistan border region and improve map centering/visualization accuracy.
* Add country boundary overrides (Pakistan)
Support optional country boundary overrides by loading public/data/country-boundary-overrides.geojson and replacing main country geometries when ISO codes match. Add a script (scripts/fetch-pakistan-boundary-override.mjs) to fetch Pakistan's de facto boundary from Natural Earth and write the override file, and document the override workflow in CONTRIBUTING.md. The country-geometry service now attempts to apply overrides and updates cached polygons/bboxes; failures are ignored since overrides are optional.
* fix: neutralize language, parallel override loading, fetch timeout
- Rename conflict zone from "War" to "Border Conflict", intensity high→medium
- Rewrite description to factual language (no "open war" claim)
- Load country boundary overrides in parallel with main GeoJSON
- Neutralize comments/docs: reference Natural Earth source, remove political terms
- Add 60s timeout to Natural Earth fetch script (~24MB download)
- Add trailing newline to GeoJSON override file
* refactor: serve country boundary overrides from R2 CDN
Move country-boundary-overrides.geojson from public/data/ to R2 bucket
(worldmonitor-maps) to avoid serving large static files through Vercel.
Update fetch URL, docs, and script with rclone upload instructions.
* fix: use maps.worldmonitor.app for R2 override URL (CF-proxied)
* fix(geo): bound optional country override fetch
---------
Co-authored-by: Elie Habib <elie.habib@gmail.com>
* feat: premium panel gating, code cleanup, and backend simplifications
Recovered stranded changes from fix/desktop-premium-error-unification.
Premium gating:
- Add premium field ('locked'|'enhanced') to PanelConfig and LayerDefinition
- Panel.showLocked() with lock icon, CTA button, and _locked guard
- PRO badge for enhanced panels when no WM API key
- Exponential backoff auto-retry on showError() (15s→30s→60s→180s cap)
- Gate oref-sirens and telegram-intel panels behind WM API key
- Lock gpsJamming and iranAttacks layer toggles, badge ciiChoropleth
- Add tauri-titlebar drag region for custom titlebar
Code cleanup:
- Extract inline CSS from AirlineIntelPanel, WorldClockPanel to panels.css
- Remove unused showGeoError() from CountryBriefPage
- Remove dead geocodeFailed/retryBtn/closeBtn locale keys (20 files)
- Clean up var names and inline styles across 6 components
Backend:
- Remove seed-meta throttle from redis.ts (unnecessary complexity)
- Risk scores: call handler functions directly instead of raw Redis reads
- Update OpenRouter model to gpt-oss-safeguard-20b:nitro
- Add direct UCDP API fetching with version probing
Config:
- Remove titleBarStyle: Overlay from tauri.conf.json
- Add build:pro and build-sidecar-handlers to build:desktop
- Remove DXB/RUH from default aviation watchlist
- Simplify reverse-geocode (remove AbortController wrapper)
* fix: cast handler requests to any for API tsconfig compat
* fix: revert stale changes that conflict with merged PRs
Reverts files to main versions where old branch changes would
overwrite intentional fixes from PRs #1134, #1138, #1144, #1154:
- news/_shared.ts: keep gemini-2.5-flash model (not stale gpt-oss)
- redis.ts: keep seed-meta throttle from PR #1138
- reverse-geocode.ts: keep AbortController timeout from PR #1134
- CountryBriefPage.ts: keep showGeoError() from PR #1134
- country-intel.ts: keep showGeoError usage from PR #1134
- get-risk-scores.ts: revert non-existent imports
- watchlist.ts: keep DXB/RUH airports from PR #1144
- locales: restore geocodeFailed/retryBtn/closeBtn keys
* fix: neutralize language, parallel override loading, fetch timeout
- Rename conflict zone from "War" to "Border Conflict", intensity high→medium
- Rewrite description to factual language (no "open war" claim)
- Load country boundary overrides in parallel with main GeoJSON
- Neutralize comments/docs: reference Natural Earth source, remove political terms
- Add 60s timeout to Natural Earth fetch script (~24MB download)
- Add trailing newline to GeoJSON override file
* fix: restore caller messages in Panel errors and vessel expansion in popups
- Move UCDP direct-fetch cooldown after successful fetch to avoid
suppressing all data for 10 minutes on a single failure
- Use caller-provided messages in showError/showRetrying instead of
discarding them; respect autoRetrySeconds parameter
- Restore cluster-toggle click handler and expandable vessel list
in military cluster popups
* fix(health): add seed-meta tracking for all bootstrap keys missing freshness data
6 bootstrap keys had no SEED_META entries in health.js, so health
endpoint could never track their freshness (always seedStale: true).
health.js:
- Add progressData + renewableEnergy to BOOTSTRAP_KEYS (missed in PR #1159)
- Add SEED_META entries for: positiveGeoEvents, riskScores, iranEvents,
ucdpEvents, sectors, techReadiness, progressData, renewableEnergy
ais-relay.cjs:
- Add seed-meta writes for positive events (GDELT), risk scores (CII),
and sectors — these loops had no freshness tracking
- iranEvents and ucdpEvents already write seed-meta via their seed scripts
* fix(seed): add seed-meta writes to seed-wb-indicators.mjs
The seed script wrote data keys but never wrote seed-meta keys,
causing health endpoint to report STALE_SEED for techReadiness,
progressData, and renewableEnergy indefinitely.
* fix(economic): seed all WB indicators on Railway, never call WB API from frontend
Extends seed-wb-indicators.mjs to pre-compute progress data (4 indicators)
and renewable energy data (EG.ELC.RNEW.ZS) alongside tech readiness rankings.
Frontend callers (progress-data.ts, renewable-energy-data.ts, getTechReadinessRankings,
getCountryComparison) now read exclusively from bootstrap/Redis seed data.
Zero Vercel Edge → World Bank API calls remain.
* fix: address code review findings (P1+P2)
- Fix triple JSON.parse in seed verification (P1)
- Graceful fallback for renewable data fetch failure (P2)
- Use Map lookup instead of Array.find in progress-data (P2)
- Update regression test for bootstrap-only getTechReadinessRankings (P2)
Manual seed script for Iran conflict events. Reads from
scripts/data/iran-events-latest.json, geocodes via LOCATION_COORDS,
seeds to conflict:iran-events:v1. Data file stays gitignored.
- Exit 0 on failure so Railway cron doesn't restart container
- Wait 3s after RPC warm before re-reading digest from Redis
- Fall back to existing insights (LKG) when digest key is missing