Compare commits

...
852 Commits
Author SHA1 Message Date
Hanzo Dev 4f699c714b release: v2.4.37 — insider ISO-8859-1 fix + alt-data plane live
Aligns package.json + VERSION to the release (both had drifted). This is the
first deployable release carrying the four /v1/world/* macro signals with the
insider charset fix proven against the live SEC feed.
2026-07-21 17:18:44 -07:00
Hanzo Dev 513de4b1e2 fix(world/insider): decode the ISO-8859-1 SEC feed (was silently empty)
The live EDGAR getcurrent atom feed is served as ISO-8859-1, not UTF-8, so
plain xml.Unmarshal errored ('encoding "ISO-8859-1" declared but
Decoder.CharsetReader is nil') and parseEdgarAtom returned zero entries — the
endpoint answered 200 with count:0 in prod. Give the decoder a CharsetReader
(golang.org/x/net/html/charset) so any declared charset decodes. The unit test
now uses an ISO-8859-1 fixture with a Latin-1 byte as a regression guard.
Proven against the live feed: 100 entries decode cleanly.
2026-07-21 17:14:46 -07:00
hanzo-dev f4e5c7c6f3 router: enso fold reads router/{ledger,rewards} (routes renamed from export-routing-*) 2026-07-21 16:57:13 -07:00
Hanzo Dev 0077dfd590 release: v2.4.36 — alt-data plane (insider · defi · layoffs · congress)
Four macro signal sources under /v1/world/*:
  insider  — SEC EDGAR Form 4 filing flow (keyless, live)
  defi     — DeFiLlama TVL + stablecoin float (keyless, live)
  layoffs  — Texas WARN notices (keyless, live)
  congress — Quiver Quant congressional trades (QUIVER_API_KEY, degrades clean)
Bumps the vestigial VERSION file to the release for the first time in a while.
2026-07-21 16:54:10 -07:00
Hanzo Dev 81cd2e1455 feat(world): alt-data plane — /v1/world/layoffs (TX WARN) + /v1/world/congress (Quiver, key-gated)
Completes the four macro signal sources on the alt-data plane:
  insider (SEC Form 4), defi (DeFiLlama), layoffs (TX WARN), congress (Quiver).
layoffs reads the Texas Workforce Commission WARN Socrata feed (keyless, live).
congress reads Quiver Quant when QUIVER_API_KEY is set and degrades to a clean
unavailable payload otherwise — the free house/senate feeds are all gated now,
so this follows the same key-gated pattern as finnhub/eia/acled. Pure parsers
are unit-tested; the route sweep confirms both degrade without 5xx or HTML leak.
2026-07-21 16:53:54 -07:00
Hanzo Dev f24786e5a7 feat(world): alt-data plane — /v1/world/defi (DeFiLlama) + /v1/world/insider (SEC Form 4)
DeFi snapshot: total TVL across chains, top-10 chains by share, stablecoin
float (top-6 by market cap) — one cached pull shared by all viewers.

Insider flow: SEC EDGAR Form 4 filing stream (buys/sells by officers,
directors, 10% holders) — the filing-velocity macro signal. SEC-compliant
descriptive User-Agent; pure atom parsing unit-tested.

Both cached, single-flighted via cachedJSON, degrade to a clean 200
unavailable payload. Routes swept by TestRoutesRespond.
2026-07-21 16:53:54 -07:00
hanzo-dev 8346fb65db release: v2.4.35 — globe-render e2e green + __deckMap-when-functional (on the mapbox-mercator globe-blank fix) 2026-07-21 16:06:05 -07:00
hanzo-dev 21e1bb6577 fix(world/globe): restore globe-render e2e to green + expose __deckMap when functional
The globe-render acceptance suite went red as the flagship Cloud view evolved
(cloud now opens on the native 3D globe, controls collapsed into dropdowns); none
of it was a globe-render bug — the sphere renders and occludes correctly — the
harness just no longer matched the app. Restore it to 6/6:

- e2e/globe-render: pin the 2D->3D toggle path with ?mode=2d. Cloud defaults to the
  immersive 3D globe, which parks the mapbox map and mounts .deckgl-map-wrapper
  hidden, so the old `toBeVisible` precondition could never pass. Enter 3D through
  the proj-btn's REAL click handler (the immersive dock floats over the full-view
  globe canvas, so a headless synthesized mouse click's hit-test lands on the
  canvas, not the collapsed-dropdown button). This spec verifies GLOBE render
  (occlusion/terrain/parity/live dots); dock pointer reachability is out of scope.

- DeckGLMap: expose __deckMap/__mapboxMap as soon as the instance is FUNCTIONAL
  (right after initBasemap creates this.mapboxMap) instead of on the async mapbox
  'load'. When Cloud opens straight into the parked-mapbox native globe, 'load' can
  trail the globe by seconds, so the e2e read an undefined __deckMap right after
  go3D. buildLayers()/asGlobeSource()/setOcclusionCenter()/occludeFarSide() are all
  live at construction; __deckOverlay stays exposed in initDeck where it is built.

- vite: optimizeDeps.include the deck.gl / luma.gl / mapbox graph so a cold dev/e2e
  server pre-bundles @luma.gl/webgl's device adapter up front and never re-optimizes
  mid-load — that reload otherwise races GlobeNative's `new Deck()` and the 3D globe
  never initialises. Prod is unaffected (Rollup build, no dep optimizer).

Verified: e2e/globe-render.spec.ts 6/6 green on an unminified dev server; the dot
globe renders (4726 land dots), traffic dots occlude (back-hemisphere alpha 0),
count badges cull, terrain drapes single-context, live dots update.
2026-07-21 16:05:16 -07:00
hanzo-dev be2bc30a7c fix(cloud-admin): liveness-authoritative up for uninstrumented subsystems
The admin CloudServicesPanel set each subsystem's up from /v1/o11y/status,
so any subsystem o11y lacked telemetry for rendered as DOWN — a live service
shown 'not active'. probeService now treats o11y as metrics enrichment and
derives up from a real liveness probe (livenessURL) when o11y has no
per-deployment data; instrumented stays a separate signal. Live-but-
uninstrumented ⇒ UP with no metrics, never a false down. TDD: 2 new tests green.
2026-07-21 15:33:26 -07:00
hanzo-dev 3d74a312f2 docs(world): AGENTS.md — prefer Hanzo MCP browser over Playwright, globe/dot map + release notes
Repo had no agent doc. Records the browser-control preference (Hanzo MCP
extension for interactive UI, Playwright only for the deterministic e2e
suite or when the extension is absent), the dot-lattice cybermap default
(one getLandDots() shared by 2D DeckGLMap + 3D GlobeNative), and the
bump-patch/tag/dispatch release convention.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-21 15:18:14 -07:00
hanzo-dev 423af9abad fix(world/map): globe blank — keep parked mapbox in mercator when the native globe is on
world.hanzo.ai showed a blank center (globe gone). Root cause: the mapbox basemap
was created with projection:'globe' in 3D mode, and mapbox-gl v3.26 throws
"Missing theme" when a plain (non-Standard) style loads in globe projection —
which aborts the whole map init, taking the native deck.gl globe down with it.
(Surfaced now because the v2.4.33 .dockerignore switched CI from runner-cached
node_modules to a clean lockfile npm ci = mapbox-gl 3.26.0.)

When the native deck.gl globe is on (the default), the mapbox map is only a parked,
invisible basemap behind GlobeNative — it never needs globe projection. Add
mapboxProjection(): globe ONLY when 3D AND native globe is off; otherwise mercator.
Applied at map construction + in applyProjection(). No visual change (parked map is
invisible); the native globe now renders.

Verified with a swiftshader (software WebGL) playwright run: the dot-globe renders,
.globe-native-canvas mounts, no 'Missing theme'. tsc + vite build green.

Claude-Session: https://claude.ai/code/session_81b00bd9
2026-07-21 15:17:58 -07:00
Hanzo Dev 75cc42fd1c Merge lux.fund fund variant — Lux Macro terminal + Lux Book + rotation notebook 2026-07-21 14:16:10 -07:00
Hanzo Dev 362cbed84f Merge rotation scanner — RRG sector-rotation detection (red-reviewed) 2026-07-21 14:16:03 -07:00
Hanzo Dev 5b77463ad0 world: lux.fund hides the emoji view-switcher (academic, focused terminal) 2026-07-21 14:15:41 -07:00
Hanzo Dev edbbfe58e4 world: rotation model research notebook (Hanzo Notebook)
A reproducible numpy/scipy reference implementation of the RRG rotation
model that powers the engine. Loads a 6-month daily snapshot of the
32-symbol universe (research/data), formalises RS-Ratio / RS-Momentum and
the quadrant classification, reproduces the current read and the Lux Book
top-10 allocation, and backtests the rotate-out-of-Weakening /
into-Improving thesis.

The numpy read cross-validates the Go engine exactly (Uranium 97.4/103.1,
Hyperscalers weakening 101.4/95.9, Great Rotation 0.55 WATCH). The backtest
is reported as indicative on a single window, not an alpha claim.

Executed notebook (rotation_model.ipynb) + percent-format source + the
data snapshot + the RRG plot.
2026-07-21 14:10:07 -07:00
Hanzo Dev a80b683f61 world: fund variant — Lux Macro terminal (lux.fund white-label)
Add a `fund` variant: the same World 3D-globe engine as finance, Lux
white-labeled and sharpened for the fund's macro read. lux.fund defaults
to it; the Lux wordmark replaces the Hanzo mark on that host.

The Lux Book (LuxBookPanel) derives the top-10 model allocation live from
the rotation engine — conviction weights each theme by quadrant, momentum
tilt and oversold-base bonus, normalised to a 100% book, and rebalances
every refresh. Each position carries a globe anchor and the book is
emitted on the `lux-book` document event for the globe layer to plot.

Rotation scanner and market radar lead the layout; feeds reuse the finance
set; energy/commodity hubs join the globe. Build meta + dev/build:fund
scripts added.
2026-07-21 14:09:36 -07:00
Hanzo Dev 2031ce3a79 world: rotation scanner — harden against bad Yahoo ticks (red review)
Red review fixes:
- Drop non-positive (0/negative) closes in the rotation fetch path via
  positiveCloses. A spurious Yahoo 0 tick on a thin symbol was surviving
  compact() (which keeps 0 for legitimate zero-volume bars) and could flip
  a lead theme's quadrant, toggling the public Great Rotation signal. The
  shared compact() is untouched so volume series stay correct.
- Guard round2s against NaN/Inf (return 0) so a stray non-finite can never
  reach json.Marshal after cachedJSON has cached the value — an airtight
  "never 5xx".
- RotationScannerPanel drops an aborted fetch silently instead of flashing
  "unavailable" over the still-valid render on rapid refresh/destroy.

Tests: positiveCloses drops bad bars; a 0 bar no longer flips the quadrant.
2026-07-21 14:08:04 -07:00
Hanzo Dev 42e8ce1b60 world: rotation scanner — RRG sector-rotation detection
Add /v1/world/rotation: a Relative Rotation Graph engine scoring theme
baskets (AI/semis, hyperscalers, energy, natural gas, uranium, nuclear
power, plus the GICS sectors as context) on RS-Ratio x RS-Momentum
against SPY, into four rotation quadrants and named thesis triggers —
AI distribution, energy-complex accumulation, and the combined Great
Rotation that needs both legs to fire.

RotationScannerPanel renders the quadrant plot with rotation tails, the
signal chips and a momentum-ranked leaderboard. Computed server-side,
cached 15m, single-flighted; degrades to a clean unavailable 200 when
upstream data is thin.

Registered in the finance, tech and full variants.
2026-07-21 13:47:20 -07:00
hanzo-dev 0550159a9a release: v2.4.33 — honest measured Enso benchmark numbers
Ships the corrected benchmark closeout (already on main, now released):
- summary.json: real measured accuracy for GPQA-Diamond / LiveCodeBench / HLE
  / CharXiv across the frontier arm pool + enso/enso-flash/enso-ultra; degraded
  (content-filter/length-truncated) rows carry NO number and are dropped rather
  than shown as a low score.
- SWE-Bench Pro + Terminal-Bench honestly marked pending (not yet run), not faked.
- Dropped the aspirational reference table; the panel stands on measured numbers.
- .dockerignore so host node_modules can't clobber the image npm ci.
Total measured spend $564.25, all real. Handler gate + honest-data tests pass.
2026-07-21 08:59:25 -07:00
Hanzo Dev 2f52c34f9c world: resync; degraded benchmark rows carry no number and are dropped
The snapshot is resynced to enso-bench, where a run lost mostly to blanks now reports no
accuracy at all. The handler already drops degraded rows, so the dashboard shows only clean
complete-run measurements; this keeps the embedded data honest for any other reader of the
snapshot too.
2026-07-20 05:47:44 -07:00
Hanzo Dev d41d4a5b69 world: show honest complete-run scores, drop degraded rows
The benchmark snapshot is resynced to the corrected enso-bench numbers, which are
complete-run scores rather than blank-excluded estimates. Excluding blanks inflated the
numbers because blanks land on the hard items a model gets wrong, so the dashboard was
about to show fable-5 at a spurious 93.6 against its true 81.3. A run that lost most of its
items to blanks is now marked degraded and dropped from the table entirely rather than
displayed with a misleading number, so glm-5.2's 65%-blank run no longer appears as a
score.
2026-07-19 23:49:42 -07:00
Hanzo Dev 7084a2fbbb world: stand on corrected measured numbers, drop the aspirational reference table
The enso benchmark snapshot carried an enso_reported block whose figures did not match our
own measurements -- it showed enso-ultra at 50.0 on HLE where we measured 34.6, and 93.2 on
LiveCodeBench where we measured 81.6 -- and its columns named other vendors. A dashboard
that shows fabricated superiority beside real measurements is worse than one that shows
less, so the block is removed and the dashboard now stands on the corrected measured tables
alone. The tests assert the reference table and its columns are absent rather than present.
2026-07-19 23:37:02 -07:00
Hanzo Dev 2546b0c9d0 world: refresh the enso benchmark snapshot with blank-corrected accuracy
The dashboard embedded a snapshot in which infrastructure blanks -- upstream 200s carrying
no answer -- were scored as wrong, so it displayed glm-5.2 at 31.8 and fable-5 at 81.3
against their true 94.0 and 93.6. The measured block is regenerated from the corrected
enso-bench summary, which computes accuracy over the items the API actually answered, and
the enso family's clean-path results are included.

The head-to-head test asserted the raw livecodebench figure and the old HLE preflight stub;
it now checks the corrected accuracy within a band and treats HLE as the real n=500
measurement it has become.
2026-07-19 23:32:27 -07:00
hanzo-dev e5d2898fd9 fix(build): add .dockerignore so host node_modules can't clobber the image npm ci at COPY . .
No .dockerignore existed, so `COPY . .` (Dockerfile:31) copied the host's
node_modules over the fresh `npm ci` tree — poisoning BuildKit's node_modules
cache-mount ('cannot replace directory with file') and breaking every world
image build (main + PR #19). The web stage installs its own deps, so the host
node_modules/dist/.git must never enter the build context.
2026-07-19 11:30:08 -07:00
hanzo-dev 10caeb61b4 release: v2.4.32 — cinematic dot-globe hero (translucent 3D lattice) + data-driven Enso caveats
Frontend — the "cool 3D dot map" is now the hero everywhere:
- DEFAULT_BASEMAP_STYLE = 'dot' for ALL variants (was cloud/ai only); the
  glowing dot lattice opens every mode. Persisted choice + style switcher win.
- GlobeNative: translucent glowing dot-globe — two passes (a DIM far lattice
  drawn through the sphere + a BRIGHT near pass depth-tested for real back-of-
  globe occlusion), additive blend so dense continents bloom; cool ice-blue rim
  glow atmosphere; radial-vignette background as the premium first-frame state
  (never a dead black hole). Shared position accessor → zero per-frame alloc.
- DeckGLMap: the SAME dot lattice + palette on the 2D mercator basemap (glow
  underlay + crisp core), so 2D ↔ 3D read as one surface; 'Dot' added to the
  style switcher; DEFAULT_BASEMAP_STYLE drives the default/fallback.
- land-dots: LAND_DOT_NEAR/FAR palette lives in ONE home, imported by both
  renderers (values, not places).
- Immersive: panels dock to a translucent GLASS right-rail with the globe as the
  centre hero; light-theme frosted variant + mobile glass bottom-sheet; legend
  becomes glass. CRITICAL: no transform/filter/backdrop-filter on .panels-grid
  (parent of the fixed globe → would trap it) — glass lives on the sibling cards.
- Desktop opens at zoom 1.35 so the globe fills the immersive viewport.

Backend — Enso benchmark caveats are fully data-driven (no literal drift):
- LiveCodeBench headline + premium-arm reference computed from the parsed table
  (premium arm read from the table's own Opus row when present).
- HLE "preflight only" note emitted ONLY when HLE is genuinely a preflight
  (n≤1) in the snapshot — never claims "not scored" over a real scored run.

CI gate green: tsc + vite build + go build (-tags sqlite_fts5) all pass.

Claude-Session: https://claude.ai/code/session_81b00bd9
2026-07-19 11:09:07 -07:00
hanzo-dev a6aaae65f2 release: v2.4.31 — map interaction (right-click no-select, non-country no-popup) + denser mobile grid + immersive video above footer 2026-07-19 06:37:09 -07:00
hanzo-dev c314f3941b fix(world/map): right-click never selects · non-country click shows nothing · denser mobile grid · immersive video above footer
- Right/middle click never picks-and-selects a country (deck.gl's tap recognizer
  fires onClick for those too); only LEFT click (button 0) selects. Guard in both
  handleClick paths (DeckGLMap flat + GlobeNative globe) via shared isNonLeftClick +
  MapClickEvent, event threaded through onClick + the globe source.
- Left-click on ocean/empty space no longer opens the 'identifying country'
  interstitial — onCountryClick only fires when a country actually resolves.
- Mobile: panels reflow to a denser >=2-up adaptive grid (was one giant column),
  honoring the cell-size control; min(--panel-col-min,46vw) guarantees 2 columns.
- Immersive video reserves the footer dock height so the full video sits ABOVE the
  footer — nothing occluded by the controls bar.
2026-07-19 06:36:18 -07:00
hanzo-dev da0ae02281 release: v2.4.30 — per-user usage history (searches + watch queue follow the signed-in user) 2026-07-19 00:07:16 -07:00
hanzo-dev 83890e7958 feat(world): per-user usage history (/v1/world/history) — searches + watch queue follow the user
- Backend: GET/PUT /v1/world/history via the SAME per-identity settings store, new
  'history' namespace. Extracted shared handleIdentityBlob (dashboard + history are
  one opaque-blob mechanism, two namespaces — DRY). Never fabricated.
- Frontend: generalized the dashboard-sync observer to route each localStorage key to
  its namespace — dashboard keys -> /v1/world/dashboard, history keys
  (worldmonitor_recent_searches, hanzo-world-watch-queue) -> /v1/world/history. One
  observer, boot-hydrates both, debounced per-group PUTs. SearchModal + watch-queue
  untouched (observer transparent).
- Tests: Go history round-trip + isolation + namespace separation + 401/400; e2e
  history routing + boot hydration. Dashboard e2e still green (5/5).
2026-07-19 00:06:14 -07:00
hanzo-dev 15dae13e49 release: v2.4.29 — Enso Router panel (cost/quality slider + judge viz) + AI Compute gate
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-19 00:05:18 -07:00
hanzo-dev 7807ff5601 feat(world/enso): Enso Router panel — cost↔quality slider + mean-field judge panel
Cloud-variant panel surfacing two shipped router features:

- Section A: a Savings↔Quality bias slider (0..1) wired to a new org-scoped
  proxy GET|PUT /v1/world/cloud/router-preference → ai /v1/router/preference
  (caller IAM bearer forwarded). Debounced PUT + "saved" confirmation; stays
  interactive and shows an honest preview-only note when the gateway route
  isn't deployed yet.

- Section B: the Mean-Field Judge Panel via GET /v1/world/cloud/judge-panel →
  ai /v1/router/judge-panel?scope=platform — per-judge reliability-weight bars,
  calibrated means + n, sample rate + enabled state, and the published
  rank-corr-with-ground-truth benchmark (MFJP 0.966 vs single-judge 0.110).
  Honest "warming up" empty state when unavailable.

Both proxies never 5xx — they answer a well-formed {available:false} on any
upstream failure (including a 404 before the gateway routes ship), so the panel
degrades gracefully. New files only (handlers_cloud_router_pref.go,
EnsoRouterPanel.ts, router-preference.ts, judge-panel.ts) plus mount/route/CSS
wiring; reuses the cloud-* design system and theme tokens.

Verified with Playwright on ?variant=cloud: slider drags 0.5→0.90 with a live
label + honest "couldn't save" chip, judge section renders its warming-up
state, zero JS errors (only the expected not-yet-deployed 404s); dark + light
theme parity and mobile (374px, no overflow) confirmed.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-19 00:01:54 -07:00
hanzo-dev d25df44f21 fix(world): honestly gate the signed-out AI Compute plane (no zero-grid)
The inference plane is org-scoped ("metered to your org"). Signed out, the
public pulse only ever returns a zero-stub ($0 spend, 0 GPUs, 0/0 machines),
which rendered as a "live"-badged grid of zeros — exactly the "zero dressed up
as live traffic" the panel promises never to show, and reads as a dead/broken
platform. Gate on !isAuthenticated() at the top of render() (same sign-in
affordance as Fleet / My Usage), completing the honest-gate direction of v2.4.23.
The now-redundant inner signed-out branch is removed (one way to gate).
Signed-in behaviour unchanged. Release v2.4.28.
2026-07-18 23:50:49 -07:00
hanzo-dev 37a05c025f release: v2.4.27 — per-user dashboard persistence (GET/PUT /v1/world/dashboard, sqlite v0.3.2)
Signed-in dashboards persist server-side per identity and follow the user across
devices: panels/order/spans, layers, sources, custom feeds, map mode, the free
layout geometry, text-size and grid-size — hydrated on boot (server precedence),
debounce-synced on change via a localStorage observer. Anon stays localStorage-only.
Backend reuses store.Settings (namespace 'dashboard'); sqlite v0.3.0->v0.3.2 (fts5
build tag). Go tests + 3/3 frontend e2e green.
2026-07-18 23:46:29 -07:00
hanzo-dev 775493ebf2 feat(world): dashboard sync frontend — per-user server persistence via a localStorage observer (held)
Signed in, the composed dashboard (panels/order/spans/layers/sources/custom feeds/
map mode + the free-layout geometry) persists per identity and follows the user
across devices; signed out is unchanged (localStorage only).

- services/dashboard.ts: on boot, hydrate this identity's dashboard from
  GET /v1/world/dashboard into localStorage (server precedence) before the app
  reads it; then a Storage.prototype setItem/removeItem observer debounce-PUTs a
  snapshot of the dashboard keys on any change. One decoupled hook — App,
  grid-config and Panel stay unaware. Captures the worldmonitor-layout:* family so
  the free {x,y,w,h}/mode geometry follows the user too.
- main.ts: await initDashboardSync() before new App() (auth-gated, bounded, never
  blocks boot).
- e2e: hydration (server precedence) + change→debounced PUT of the snapshot + anon
  no-sync — 3/3 green (real app, faked IAM + stubbed endpoint).

Not released. Ships as v2.4.21 with sources + usage history.
2026-07-18 23:43:49 -07:00
hanzo-dev 3dc761d736 feat(world): per-user dashboard persistence backend + sqlite v0.3.2 (held for 2.4.18)
- GET/PUT /v1/world/dashboard: per-identity dashboard config through the existing
  store.Settings (namespace 'dashboard') — no new table; mirrors /v1/world/monitors
- generalize identityFor -> identityForDoc: one bearer->identity gate for the
  namespaced settings store (monitors + dashboard resolve through it)
- bump hanzoai/sqlite v0.3.0 -> v0.3.2 (vendored pure-Go, zero modernc.org/*).
  The vendored engine gates FTS5 behind -tags sqlite_fts5; add it to the Dockerfile
  go build (was implicit under v0.3.0's modernc backend) or store.Open degrades
- tests: dashboard round-trip + per-identity isolation + 401/400/405 boundary gates

Not released. Ships as 2.4.18 AFTER 2.4.17 (layout) is live+stable.
2026-07-18 23:43:49 -07:00
hanzo-dev 604b135add release: v2.4.26 — Enso Live Training shows the REAL served-model catalog (not opaque arm-N) 2026-07-18 23:38:43 -07:00
hanzo-dev 2335dcc4ca fix(world): Enso Live Training shows the REAL model catalog, not opaque arm-N (held)
User feedback: the panel showed "random amounts." Verified there is NO random data
anywhere in the Enso path — it rendered the upstream OPAQUE "arm-N" routing mix (real
counts relabeled to meaningless "Enso arm 1/2/3" with no model identity), which reads
as arbitrary.

- EnsoTrainingPanel now renders the REAL served-model catalog from the existing public
  /v1/world/cloud/models (→ ai /v1/models): real model names, tiers, providers, context
  and pricing — the same real models served on the platform.
- The real aggregates (cost-saved proxy, learned-engine share, events, throughput,
  cumulative index, shadow agreement, retrain gate) are unchanged — all real.
- Dropped the opaque by_model arm rows (armRows/armIndex). Honest empty states; never
  fabricated.
- e2e: stubs both feeds (router-stats incl. an arm-N map) and asserts the panel shows
  real model names + "models served" and NEVER renders "Enso arm"/"arm-N". Green.

Not released — hanzo assigns version/sequence.
2026-07-18 23:36:48 -07:00
hanzo-dev 55075bcbbe release: v2.4.25 — layout: snap-to-grid, no corner chevrons, finer grid, text-size control 2026-07-18 23:30:53 -07:00
hanzo-dev ec595bc567 feat(world): layout polish — remove resize glyphs, finer grid, text-size control (held)
Owner feedback on the free layout, batched with the snap-to-grid fix:

- No visible corner/edge resize glyphs (#2): the dots + corner brackets are hidden
  (display:none on the handle ::after); the handles stay invisible hit areas so
  dragging from any edge/corner still resizes — the resize cursor is the affordance.
- Finer grid (#3): MIN_CELL_SIZE 120->80 + dock "Widget size" slider min 120->80,
  step 20->10, so the grid — and the snap, which reads cellSize — goes finer.
- Text-size / UI-scale control (#4, accessibility): a dock "Text size" slider drives
  a --ui-scale var that zooms the readable panel CONTENT only (never the panels-grid,
  so drag/snap coordinates are untouched); persisted in localStorage (utils/ui-scale.ts),
  applied before first paint. Per-user SQLite dashboard-sync picks up the key.
- e2e: glyphs-hidden-but-still-resizes, finer-80px-floor, text-size-scales+persists.
  Full layout suite 21/21 green; build clean.

Not released — batched with the snap fix; hanzo assigns version/sequence.
2026-07-18 23:29:35 -07:00
hanzo-dev f86fb162f8 fix(world): free-mode drags + resizes snap to a logical grid (held)
User feedback on the free layout: "the snap is not logical." Free mode placed
panels at arbitrary px, which felt loose to drag. Now free-mode drag snaps {x,y}
to grid-line origins and resize snaps {w,h} to whole-cell multiples, so panels
land on consistent, aligned tracks — while keeping the independence that made free
mode good (resize/move one panel, siblings never move; opposite edge stays pinned).

- ONE logical grid, DRY: grid-config.freeSnap() = { cell: getCellSize() (the
  slider-driven --panel-col-min), row: --panel-row (16), gap }. Free and grid share
  one grid definition — no second magic number.
- panel-drag: snapLine (position -> nearest grid line) + snapCells (size -> whole
  cells, >= floor); threaded through moveFree + applyFreeResize. Hold Alt for fine,
  un-snapped placement. Grid mode untouched.
- e2e: two new proofs (drag -> two panels align to the same track; resize -> whole-
  cell width + opposite edge pinned); the arbitrary-px tests now hold Alt. 18/18 green.

Not released — version/sequence assigned by hanzo.
2026-07-18 23:29:35 -07:00
hanzo-dev 3bf9cc994c fix(world): Enso benchmark shows only our measured results — confident, sells
Dropped the external "reported" comparison column + the self-deprecating footnote
from the public benchmark. The panel now shows ONLY Enso's own live-measured accuracy
and per-system cost, with a confident, true caveat: every number is measured against
real APIs through the Hanzo gateway, Enso reaches frontier-competitive accuracy at a
fraction of the blended cost by routing each task to the right model, and it reports
what it spends. No external names, no incoherent framing.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 23:06:54 -07:00
hanzo-dev 3d1f91ecd8 release: v2.4.24 — Enso benchmark branding
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 22:52:08 -07:00
hanzo-dev da762aee5b fix(world): brand benchmarks as Enso — scrub the old codename
The Enso benchmark panel + summary data carried the pre-release codename in labels,
JSON keys, Go structs, and the TS consumer. Renamed consistently to Enso / Enso-Ultra
across the data, the /v1/world backend handler, and the frontend so the public
benchmark shows only the Enso brand. No numbers changed; identifiers stay in sync
(measured Enso vs Enso's originally-reported figures).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 22:51:28 -07:00
hanzo-dev 032c33d55a release: v2.4.23 — geo-aware model-improvement consent + honest AI Compute gate
Sweeps the unreleased range: geo-aware consent toggle in the account menu (reflects
the backend's effective default — opt-out non-EU, opt-in EU/UK/EEA) with honest copy;
and the AI Compute panel showing a plain "Sign in" gate instead of a scary
"unreachable" when signed out. (Signup disclosure + privacy/terms shipped separately
in hanzo.id + hanzo.ai.)

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 21:09:52 -07:00
hanzo-dev 607edfdbcd feat(world/consent): honest geo-aware microcopy for the model-improvement toggle
The "Help improve Hanzo AI" account toggle already reads the effective consent
from ai (getTrainingContribution) and paints it once loaded. Make the microcopy
and comments honest for the GEO-AWARE model — opt-in/default-OFF in the EU, UK &
EEA; opt-out/default-ON, disclosed at signup, elsewhere — instead of claiming a
universal "off by default". No logic change: the toggle stays a verbatim mirror
of ai's answer, so a non-EU org's ON effective default now reads as an accurate
label rather than a contradicted one, and the service/loader comments no longer
claim "never opted in => OFF".

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 21:05:55 -07:00
hanzo-dev 2c0445a308 release: v2.4.22 — per-org Analytics + Insights cards (world cloud view) 2026-07-18 20:49:06 -07:00
zandGitHub b6c423e358 Merge pull request #18 from hanzoai/feat/org-analytics-insights-cards
feat(world/cloud): per-org Analytics + Insights cards
2026-07-18 20:48:14 -07:00
hanzo-dev 07607bf57a feat(world/cloud): per-org Analytics + Insights cards
Two org-scoped event-platform cards for the Cloud view, sourced from the
live native analytics warehouse on api.hanzo.ai (verified live):

- Analytics (org-analytics): /v1/analytics/overview + timeseries + top —
  per-org requests/visitors/tokens/spend, requests trend, top models.
- Insights (org-insights): /v1/insights/events — active users, top events,
  activity trend derived client-side from the real event stream.

Org pinned server-side from the validated bearer owner claim (principal.Org),
reusing org-scope.scopedHeaders() like getMyBilling — no new fetch mechanism.
Reuses the existing Panel + cloud-format primitives + cloud-* CSS. Honest
loading/signed-out/empty/error states; never fabricated numbers.
2026-07-18 20:20:15 -07:00
hanzo-dev 50a7448678 fix(world): AI Compute shows honest "sign in" gate, not a scary "unreachable"
Signed-out, the live compute pulse is account-scoped, but the panel rendered
"Compute telemetry unavailable — compute plane unreachable" which reads as a backend
outage. Match the Fleet / My Usage pattern: signed-out shows a plain "Sign in to see
the live inference plane…"; the unreachable error copy is reserved for a genuine
failure while signed in.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 20:13:20 -07:00
hanzo-dev 870ec49f3d release: v2.4.21 — mobile search icon + consent toggle (unique tag; 2.4.20 was claimed concurrently) 2026-07-18 17:45:22 -07:00
b7a90b2774 fix(world/header): give the mobile search button a magnifier icon (#16)
At <=680px both the Ctrl/Cmd-K hint (already hidden <=900px) and the
'Search' label were hidden, leaving the search button an empty bordered
box. Add an always-present magnifier icon that reveals once the K hint
collapses, so the button is never empty at any width. (2.4.20)

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-18 17:42:55 -07:00
hanzo-dev 493fb3d4e7 feat(world/consent): opt-in "Help improve Hanzo AI" toggle (default OFF) (2.4.20)
Add a model-improvement consent toggle to the account menu, wired to ai's
OrgSettings.TrainingContribution (the source of truth the automated judge + trainer
read). Honest, opt-in, off by default, no dark patterns.

- src/components/AccountMenu.ts: a "Data & privacy" switch in the signed-in dropdown.
  Painted OFF until the async read lands (never a false "on"); optimistic flip that
  reverts if the server refuses. Exact microcopy: rating keeps only a numeric score,
  never prompts/outputs, runs confidential/anonymous, off anytime.
- src/services/training-contribution.ts: get/set via the app's org-scoped authed
  fetch (orgHeaders). Signed-out / error / unset all read false — privacy-safe.
- internal/world/handlers_training.go: same-origin proxy /v1/world/training-contribution
  → ai get-/update-training-contribution, forwarding the caller's bearer + X-Org-Id.
  World holds NO copy; ai self-scopes to the caller's own org and is the sole authority.

Tests: proxy gates signed-out (401, no upstream call), forwards bearer + org, and
unwraps the casibase envelope to a bare {enabled} on both GET and POST.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:41:23 -07:00
hanzo-dev 09954907cf fix(world): default to free layout — panels move/resize independently, no reflow (2.4.19)
The dashboard defaulted to grid snap, so dragging or resizing one panel
reflowed all the others and resize jumped between coarse column/row spans.
CSS grid can't move a panel without disturbing siblings, so make FREE the
default layout: every panel owns its own {x,y,w,h}. The current grid
arrangement is frozen as the starting geometry (grid-config.applyDefaultLayout,
run once after panels+map lay out), so the flip is invisible; Grid stays a
dropdown choice and grid-config records an explicit pick so the default never
overrides it.

- Independent positioning (#1): free mode — moving/resizing one panel leaves
  every sibling exactly where it was (proven: siblings drift 0px).
- Granular resize (#2): pixel-exact w/h from any edge/corner, not span jumps.
- Narrower panels (#3): free floors 160->96 (map 240->140); grid track floor
  140->120. A panel drags to 96px, well under the old min.
- Viewport-flexible (#4): map + live-news resize freely, small to large.

Hidden panels no longer persist 0x0 rects; a panel shown while free seeds a
tidy default slot. Tests: +2 harness proofs (no-sibling-reflow, sub-160 width);
repaired two stale span-2 assertions to the fine-grid data-span reality.
2026-07-18 17:26:25 -07:00
hanzo-dev 08df02b535 release: v2.4.18 — dotted-land cybermap globe + fullscreen-video idle fix
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:05:35 -07:00
hanzo-dev 138d96a1b8 fix(world/live-news): never idle-kill a fullscreen video (exited fullscreen + stopped)
Passively listening to a live stream in fullscreen fires no mousedown/keydown/scroll
(fullscreen video captures input), so the 5-min input-idle timer fired and pauseForIdle
→ destroyPlayer tore down the iframe — which force-exits fullscreen AND stops playback
(the reported "listening fullscreen and it exited + stopped"). Fix: treat fullscreen as
the strongest "actively watching" signal — never idle-kill when our player is the
fullscreen element — and count fullscreenchange as activity so entering fullscreen
resets the idle timer.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:05:34 -07:00
hanzo-dev cf2092380b feat(world): dotted-land "cybermap" globe — restore the dot map
Restores the dotted-land basemap (land shown as a lattice of glowing dots over a
black-ocean sphere — the abstract cybermap surface): new land-dots.ts (getLandDots()
samples /data/countries.geojson into a cached lat/long lattice, land points only),
GlobeNative renders it as a ScatterplotLayer on the 3D globe, variant.ts defaults
cloud + AI to the 'dot' style, and App opens cloud/AI on the 3D globe (the live-traffic
centerpiece). Recovered from a stashed WIP; DeckGLMap kept at current main (preserves
the shipped layer panel + control dropdowns), so this ports cleanly with no regression.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:00:43 -07:00
hanzo-dev d8175e0a37 feat(cloud): default the Cloud view to the immersive globe — bring back the cool 3D dot map
The flagship Cloud view was defaulting to the grid dashboard, demoting the
full-viewport 3D dot map to a small corner tile. Restore it: when the visitor
hasn't explicitly picked a layout, the Cloud variant opens on the immersive
globe (map fills the viewport, panels float over it). An explicit grid choice
and the ?layout= URL param still win; other variants unchanged.
2026-07-18 16:58:16 -07:00
hanzo-dev 97fed1fee2 release: v2.4.16 — analyst grounded in live cloud metrics (answers spend/fleet/enso)
Ships 3d892d96: the AI analyst now folds a live Hanzo-cloud snapshot into its context
(traffic, platform 24h, Enso router cost-saved/reward/engine-share, flywheel ledger,
billing), so it can answer questions about the dashboard's real numbers — on top of
its existing 26-command control surface.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 16:27:49 -07:00
hanzo-dev 3d892d9633 feat(world/analyst): ground the AI in live cloud metrics so it can answer anything
The analyst's context fed panel names + visibility but NOT the live values, so it
couldn't answer "what's my spend / how many DO nodes / is Enso saving money". On the
cloud board it now also folds a real-data snapshot — live traffic (rps/req), platform
24h (requests/tokens/nodes/GPUs/regions/uptime), the Enso router (cost saved, reward
rate, engine share, models routed), the flywheel ledger, and the caller's billing
(spend/balance/events). Best-effort + parallel with the news feeds; real data only,
mirroring the panels. Pairs with the 26-command control surface — the analyst can now
both control the dashboard AND answer questions about what it shows.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 16:27:04 -07:00
hanzo-dev 8c0e928789 release: world 2.4.15 — bake VITE_MAPBOX_TOKEN so Satellite/Terrain load
2.4.14 tagged before the build_secrets wiring landed, so it builds without the
Mapbox token. 2.4.15 = same tree + the KMS-sourced VITE_MAPBOX_TOKEN build-arg
(via hanzoai/ci v1.0.5), so satellite-streets/outdoors render in 2D and 3D
instead of the keyless dark fallback.
2026-07-18 12:34:27 -07:00
hanzo-dev 0b52de0236 release: world 2.4.14 — Satellite/Terrain via KMS Mapbox token
Root cause of the dark Sat/Terrain: no VITE_MAPBOX_TOKEN was baked at build, so
the token-gated Mapbox satellite-streets-v12 / outdoors-v12 styles silently fell
back to the keyless CartoDB dark basemap in both 2D and 3D. The publishable pk
token now lives in KMS (hanzo/deploy/VITE_MAPBOX_TOKEN, env=prod) and is sourced
by hanzoai/ci (v1.0.5) as a build_secret --build-arg into the Vite SPA.

Rides the pending main fixes (analyst reasoning recovery, AI Compute rate,
map-control dropdowns).
2026-07-18 12:33:11 -07:00
hanzo-dev 3fbbbbebf5 release: v2.4.14 — dropdown map controls, 2×2 panels, AI Compute fix, analyst recovery
Sweeps the unreleased range: map controls collapsed to 3 dropdowns + symmetric 2×2
tiles + dropped regions tile (3fc42b1f); AI Compute rate un-flattened (interval enum
bug) + honest sub-cent usage rows (4c41abbb); analyst chat recovers reasoning-model
answers instead of "empty response" (a17d63c1).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:31:13 -07:00
hanzo-dev a17d63c10d fix(world/analyst): recover reasoning-model answers so chat stops returning "empty response"
The analyst rendered "The analyst couldn't answer — empty response" whenever the
served model streamed its answer on the reasoning channel with an EMPTY content
channel — gpt-oss / harmony, deepseek-r1 and kin. The ONE completion path
(chatMessagesModel + chatMessagesModelStream) read only the content channel, so a
reasoning model's output was dropped and surfaced as a blank.

- Read the reasoning channel (message.reasoning_content / reasoning, and the SSE
  delta.reasoning_content / reasoning) as a fallback when content is empty, and
  forward reasoning deltas live so the replyExtractor shows them on the thinking line.
  Content is always preferred; non-reasoning models (best/zen5) are unaffected.
- Replace the opaque "empty response" with an honest, model-named error
  ("the <model> model returned an empty answer"): never misreads a normal
  completion's response id as an error code, never trips the 402 top-up CTA.

Tests: reasoning-channel recovery (buffered + SSE) and the honest empty-answer error.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:30:15 -07:00
hanzo-dev 4c41abbb29 fix(world): AI Compute rate no longer flat (interval enum) + honest sub-cent usage rows
AI Compute (and cloud-pulse) rps/tps were dead flat: the usage series reports its
bucket width as a WORD enum ("hour"/"day"), but intervalSeconds parsed it with
time.ParseDuration — which fails on "hour" (needs "1h") — so usageRate always fell
back to total24h/86400, a constant. Resolve the enum (minute/hour/day/week) before
ParseDuration; the rate now reflects the freshest hour and moves. Regression tests
for intervalSeconds + usageRate.

My Usage & Bill: a real sub-cent inference charge (< $0.005) rounds to 0 integer
cents and rendered a bare "$0.00" that reads as "not billed". Show "< $0.01" for a
real row that rounded to zero — honest about a tiny real charge (the ledger only
carries integer cents on the wire).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:17:03 -07:00
hanzo-dev 3fc42b1f1f fix(world): collapse map controls to dropdowns; symmetric 2×2 tiles; drop regions
Map controls:
- Projection (2D/3D), basemap (Dark/Sat/Terrain) and time-range each collapse into ONE
  compact dropdown (trigger shows the active value + caret; options in a popover). The
  top dock is now a single tidy row of three instead of two wrapping rows. Global
  .deckgl-dd treatment (one way, every map host). e2e specs open the dropdown before
  clicking an option via a shared clickMapControl helper.

Panel symmetry (real data, never padded):
- Live Traffic: added a real 4th tile (distinct countries with traffic) → 2×2.
- Model Improvement: split scored-events into its own tile → 2×2 (reward rate,
  requests scored, active days, retrains).
- Cloud Overview / Global Platform: dropped the 'regions' tile (fleet carries region
  detail) per CTO.
- New .cloud-stat-grid-2 (exactly two columns) for the symmetric 2×2 layout.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:13:53 -07:00
hanzo-devandz 5a65e739a9 fix(world): globe overlays hug the sphere — real occlusion, terrain seams, live dots (2.4.13)
The Hanzo-Cloud globe 'spazzed': data layers floated above the sphere, the
far hemisphere showed through, count badges hovered over the planet, terrain
striped at tile seams, and the live request-geo dots froze on the 3D view.

- GlobeNative seats every data layer against the depth-writing ocean sphere
  (depth-TEST, no write) so points/icons/text/paths/arcs occlude on the GPU.
- occludeFarSide now faces the globe's OWN live camera (mapbox is parked/frozen
  behind the native GlobeView) and culls TextLayer badges too, so a back-side
  count badge disappears instead of floating over the limb. Camera is fed each
  rebuild + on drag >2°; escape path (?globe=mapbox) keeps the live mapbox center.
- Draped ESRI terrain/satellite tiles stop writing depth (coplanar tiles were
  z-fighting at seams → striping); the ocean sphere owns the depth buffer.
- Cloud /v1/world/cloud/* poll gates on tab visibility, not renderPaused, so the
  realtime dots keep updating on the native 3D globe (the default view).
- Request-origin → serving-region arcs ON by default in Cloud (real feed, empty
  when no traffic — never demo).
- e2e/globe-render.spec.ts asserts occlusion (dot + badge), 2D/3D parity,
  terrain single-context, and live-dot updates.
2026-07-18 11:57:14 -07:00
hanzo-dev d4820277af release: v2.4.12 — map controls top-row + Layers, org logo, fleet RAM, globe cables
Ships the unreleased 2.4.11..HEAD range (all UI): globe cables hug the sphere, map
controls in one top row + top-left Layers button (footer/scrollbar removed), one Fleet
panel with system RAM, BYO once, and the org logo in the account switcher.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 11:53:11 -07:00
hanzo-dev d40c8560bb fix(world): render the org logo in the account switcher (was dropped)
The account chip showed the USER avatar next to the org name and never the org's
own image — IAM's get-organizations returns each org's Casdoor `logo`, but listOrgs
parsed only name + displayName and dropped it, so the hanzo org logo never rendered.

Thread `logo` end-to-end (OrgInfo → OrgOption → scope) and render an ORG avatar in
the switcher trigger: the active org's logo when set, else org initials (rounded-
square so it reads as an org, not a person). The dropdown's user row keeps the user
avatar. Falls back to the user avatar only when there's no org scope (signed out).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 11:34:46 -07:00
hanzo-dev 7d737f2a53 fix(world): map controls to one top row + top-left Layers; BYO once; hide page scrollbar
Map controls (CTO revision of the bottom dock):
- 2D/3D toggle + Dark/Sat/Terrain switcher + time-range pills now sit in ONE row
  across the map's TOP, side by side (was a bottom-border dock). Zoom stays top-right,
  legend bottom.
- New top-left "Layers" button on the map toggles the floating layer panel (opens
  just below it). Removed the redundant Layers button from the page footer dock —
  layers now live on the map itself.
- Hid the .main-content right-edge scrollbar (wheel/trackpad scroll unchanged) — it
  read as a stray "sidebar scroller" over the map. Same treatment header/switchers use.

Fleet:
- BYO nodes rendered TWICE — once as a "byo" provider group and again as the BYO GPU
  workers list (same dbc/evo/spark). Managed providers (DO/GCP/AWS) now render as
  provider groups; BYO renders once, as the richer workers list.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 10:50:39 -07:00
hanzo-dev 35e07a65ae feat(world/fleet): render system memory in the machine spec line
The fleet machine rows showed GPUs · VRAM · vCPU · OS but never system RAM —
there was no memory field on the path at all. Thread `mem` end-to-end on the
world side: decode it from /v1/machines, carry it on fleetMachineRow, expose it
on FleetMachineRow, and render it in machineSpec (GPUs · VRAM · vCPU · RAM · OS)
only when present. The cloud service now populates `mem` (DO size-slug + upstream
memSize); world shows whatever is real and omits it otherwise — never fabricated.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 09:28:14 -07:00
hanzo-dev 8d3d20eb79 fix(world): globe cables hug sphere · docked map controls · one Fleet panel
Globe (3D):
- Cables + pipelines (PathLayer) drew straight chords across the sphere — deck.gl
  has no greatCircle for paths (only ArcLayer). Pre-densify each static path along
  its great circle into <=4-deg hops so the many short segments hug the surface.
  Flat map keeps the raw sparse vertices.

Map controls:
- The 2D/3D toggle, basemap switcher and time-range pills were scattered across the
  wide empty map header (top-center, mid-left, top-right). Dock them into ONE tidy
  strip along the map's bottom border via the existing controlsHost mechanism (App
  passed none). The dock mounts on the map container — a SIBLING of the deck wrapper
  — because 3D parks that wrapper (visibility:hidden) and renders via GlobeNative; a
  dock inside it would vanish on the globe. Reuses the .world-dock inlining CSS.

Fleet:
- "Fleet & GPUs" (FleetPanel, all users) and "Fleet & clusters" (CloudFleetPanel,
  admin) were duplicate platform-fleet renders. Collapse to ONE "Fleet & GPUs":
  widen FleetPanel, port the utilNote line, derive the provider-scope label from the
  providers actually reporting (not a hardcoded four), delete CloudFleetPanel.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 09:13:40 -07:00
hanzo-dev 0cda941c4c fix(world): builder needs git + gh_token for private indirect dep hanzoai/csqlite
golang:1.26-alpine ships no git; go resolves the private indirect
github.com/hanzoai/csqlite 'direct', so 'go mod download' died with
git-not-found. Add git, set GOPRIVATE, mount the gh_token secret ci already
passes. Unbreaks world main builds (broken since the Go-base-1.26 drop-in).
2026-07-17 23:29:05 -07:00
hanzo-dev 385a3a30bd world 2.4.11 — release (verify semver CR pin via hanzoai/ci@v1) 2026-07-17 23:18:40 -07:00
hanzo-dev 16d0f000ea fix(world): Docker Go base 1.26 for hanzoai/sqlite drop-in
go.mod now requires go >= 1.26.4 (github.com/hanzoai/sqlite). Bump the
builder base golang:1.25-alpine -> golang:1.26-alpine so the prod image
keeps building. The binary stays CGO-free (CGO_ENABLED=0), so hanzoai/sqlite
selects its pure-Go modernc backend — FTS5 built in, works. CGO test path
(no FTS5 without -tags sqlite_fts5 + libsqlcipher) is not what world ships.
2026-07-17 22:03:54 -07:00
hanzo-dev f52b5b2366 Merge feat/sqlite-hanzo-driver 2026-07-17 21:59:53 -07:00
hanzo-dev 3dd4823e3e fix(world): wide panels overflowed the fine grid (AI Compute rendered under things) + DRY o11y usage
.panel-wide kept grid-row: span 2 (the old 200px-grid assumption) after the move
to 16px rows — so all 7 wide panels (ai-compute, cloud-overview, live-news,
traffic-globe, enso-flywheel, model-improvement, hanzo-status) reserved only ~36px
of track while being 350px tall, overflowing their cell and painting over the
panels below. Fixed to span 18 (≈356px) with the shared 48px min-height floor.

Also: extract fetchLLMUsage (DRY) shared by cloud-pulse + ai-pulse, and fall back
to LLM observability in ai-pulse so AI Compute shows real usage (not zeros) for an
authorized operator.
2026-07-17 21:52:34 -07:00
hanzo-dev c8aae3366c feat(world): real per-model usage + volume from LLM observability (admin)
Model Usage showed 'warming up' / nothing and usage totals were 0 for a signed-in
admin whose super-admin usage ledger (?org=all) is denied. Fixed: the admin path
now folds REAL platform usage from LLM observability (/v1/admin/o11y) with the
caller's own bearer — measured requests, tokens, and top models by REAL name (not
the opaque router arms) — clearing volumeModeled. Falls back honestly to empty if
the caller isn't authorized for platform observability upstream.
2026-07-17 21:45:26 -07:00
hanzo-dev 997630fcb5 feat(world): admin-only Enso benchmark suite (head-to-head vs SOTA + Fugu)
Private, competitive data (names competitor models + Fugu), so it is gated
server-side, never bundled into the public SPA:

- backend: GET /v1/world/enso-benchmarks reuses the exact requireAdmin gate
  (401 no-token / 403 non-admin, IAM-introspection). Reshapes the SAME embedded
  enso-bench snapshot the flywheel reads (one embed, two views) into the measured
  head-to-head + verify-then-select ablation + agentic SWE-Bench Pro pilot +
  Fugu-reported reference + server-authored honest caveats. No upstream forward.
- snapshot: refresh ensodata/summary.json to the full measured set (LCB+GPQA+HLE,
  6 arms), ultra_ablation, and the committed agentic result folded in.
- frontend: EnsoBenchmarkPanel (admin-only; adminOnlyState when not admin) +
  services/enso-benchmarks.ts (bearer, 403->null). Registered in the admin-only
  Cloud panels. Theme-safe tables, scroll-in-box, no body h-scroll.
- privacy: no competitor names, arm ids, numbers, or our routing methodology
  names are baked into the public bundle — all arrive only via the gated endpoint.

Honest framing: enso MATCHES the best single SOTA arm at a fraction of the cost
(LCB 91.4% @ $5.24 vs opus $39.43; GPQA ties top); does NOT beat every SOTA;
HLE is preflight-only; we are below Fugu's REPORTED numbers. Decisive wins are
cost-efficiency, the ablation (better AND cheaper), and agentic step-routing.

Tests: gate matrix (anon 401 / non-admin 403 / admin 200 + no-leak) + real-number
payload assertions green; existing enso-training + red admin-gate suites green.
2026-07-17 21:41:55 -07:00
hanzo-dev 5d1c6fff5d fix(world): smooth fine 16px panel resize — no coarse jump, no min-height trap
Vertical resize snapped in coarse 200px tiers (120/200/400/600/800) and a hard
200px min-height blocked shrinking — the 'jump too much / can't fit content'
complaint. Now the grid is a fine 16px row unit (@hanzo/gui space-4): per-panel
resize snaps in ~20px steps, uncapped, and min-height drops to 48px so 200px is
just the DEFAULT for a new panel, not a floor. Tier presets (size menu + map) are
redefined on the same grid so they still work; the map resizes smoothly too.
Horizontal already fills width in 160px column steps.
2026-07-17 21:08:08 -07:00
hanzo-dev 84995c9ac1 fix(world): Model Usage never shows opaque arm-N — real model names or 'warming up'
The public router-stats by_model is relabeled to opaque arm-N ids on platform
scope (privacy), so it is NOT real model names. cloud-pulse wrongly surfaced it
as the model mix, so Model Usage showed 'arm-8, arm-7, arm-6'. Now the public
fallback carries real request rate + throughput only; the model mix comes solely
from the measured usage ledger (admin path) — otherwise Models stays empty and
the panel honestly shows 'warming up'.
2026-07-17 19:40:28 -07:00
hanzo-dev 4a269d65d4 feat: migrate SQLite driver to hanzoai/sqlite drop-in 2026-07-17 12:25:19 -07:00
hanzo-dev 768a0c6bf8 fix(world): cloud-pulse shows ALL real regions, not just the 8-city catalog
The region breakdown dropped any machine whose region wasn't one of the 8
catalog cities (real DO regions like tor1, or GCP/AWS regions), so a fleet in
uncatalogued regions rendered regions:0. Now every real region is counted —
catalog coords/name when known, else the raw region string as its own region.
2026-07-17 12:21:56 -07:00
hanzo-dev 06417f4243 feat(world): fleet drill-down — real DO nodes by region + BYO GPU specs; drop last demo fallbacks
Fleet & GPUs now shows every real machine grouped by provider/region (with GPU
model, VRAM, vCPU, OS) plus BYO GPU workers with specs (Spark/Evo/dbc: hostname,
GPU, VRAM, capabilities) — one shared renderer (cloud-fleet-view) used by both
FleetPanel (flagship, admin) and CloudFleetPanel (deep). Backend adds vCPU from
visor MachineView.

Removes the last fabricated data: byo-gpu and traffic map layers are now
real-or-empty (no demoGPUs / demoTraffic), so a signed-out visitor sees honest
empty globe layers, never fake clusters/arcs.
2026-07-17 12:18:59 -07:00
hanzo-dev 7e41959b01 feat(world): real user + signup metrics from IAM global-users
Wires the one signups/growth gap that has a real source: IAM /v1/iam/global-users
(all users, global-admin only), read with the signed-in admin's OWN bearer and
folded into AGGREGATES ONLY (never PII): total users, signups 24h/7d, active-now
(isOnline), and a 14-day daily-signup series (from each user's createdTime).
Attached to the cloud-pulse admin path only (p.users, omitempty — the public
teaser never carries it). CloudOverviewPanel renders users / +N signups·24h /
active-now tiles + a new-users sparkline, live at the 30s poll.

Remaining gaps stay honestly empty — they have NO upstream source (verified against
the visor/o11y OpenAPI specs): GPU util/mem/temp (visor GpuView: "Live telemetry is
omitted"), disk (no field), historical retention cohorts (no login-events store).
Those need real instrumentation upstream, never faked.

go build/vet/test green (TestCloudPulseAdminBearer extended); tsc + vite clean.
App.ts untouched.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 11:56:57 -07:00
hanzo-dev c7d3d11579 feat(world): real fleet + GPU globe + AI compute — whole Cloud tab admin-aware
Completes the "make it all real" pass on the cloud flagship:
- FleetPanel shows the REAL platform fleet (visor /v1/world/cloud/fleet) for an
  admin — machines + GPUs across DO/GCP/AWS/BYO, rolled up by region, 30s refresh.
  Removed the demo/sample-region fallback — never fabricates regions now.
- Globe BYO-GPU layer real for admins (handleCloudBYOGPU, caller bearer, no-store)
  — real GPU clusters instead of the 8 demoGPUs dots. Anonymous keeps the
  clearly-flagged demo sample (no public GPU inventory source).
- AiComputePanel: ai-pulse admin-aware; EventSource can't send a bearer, so the
  panel uses an authenticated poll when signed in — z@hanzo.ai gets the full
  measured compute pulse (tokens/s, req/s, spend, top models, fleet), own bearer.

Every admin/authed response is Vary: Authorization + private,no-store. Honest gaps
stay labeled (GPU util/mem/temp, disk, signups/cohorts need real collection
upstream). go build/vet/test green (TestBYOGPUAdminReal, TestAIPulseAdminPoll);
tsc + vite clean. App.ts untouched.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 10:59:07 -07:00
hanzo-dev 99bd0d71ac feat(world): admin-aware cloud-pulse — signed-in admin sees full real all-org data
cloud-pulse never looked at who was logged in (server service-token only), and the
real admin panels were gated to orgs {admin, built-in} — so z@hanzo.ai (org hanzo)
got 403 and saw only the public aggregate. Now:

- cloud-pulse is admin-aware (handlers_cloud.go): a signed-in admin's request is
  served the FULL measured all-org aggregate fetched with THEIR OWN bearer
  (get-cloud-usages?org=all + visor), Cache-Control: private,no-store + Vary:
  Authorization so it never mixes with the public edge cache. No server-side
  super-admin token needed — the admin's own login drives it. The ai gateway
  independently re-checks ?org=all, so a non-global-admin degrades to real public
  data, never fake (gateway is authoritative).
- Operator org recognized as admin (cloud_identity.go): {admin, built-in} +
  operator org (default hanzo, override WORLD_ADMIN_ORGS) — unlocks the existing
  real admin panels (services/llm/fleet/analytics) for z@hanzo.ai. Anonymous
  short-circuits, so the public path stays cheap.
- Frontend forwards the bearer (cloud-pulse.ts) and the client admin gate
  (iam.ts isAdmin/cachedIsAdmin) matches the server, so App.ts's existing
  isAdmin() reveal works — App.ts untouched.

go build/vet/test green (added TestCloudPulseAdminBearer); tsc + vite clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 10:24:34 -07:00
hanzo-dev 3c95187c8e fix(world): kill fabricated cloud metrics — real sources or honest-empty
The flagship cloud dashboard showed modeled numbers under a "live" badge:
demoPulse() sine-wave rps/24h/tokens + sparklines, demoModels() mix, demoRegions()
per-city rates, and a hardcoded 99.98% uptime. Replaced with an honest source
ladder (producePulse in handlers_cloud.go):
  - RequestsPerSec  <- /v1/traffic/globe totals.rps_1m (the real ~0.62)
  - Requests24h + sparkline <- /v1/router/stats?scope=platform
  - Models mix <- ledger byModel (super-admin) else router-stats by_model
  - UptimePct <- status.hanzo.ai Gatus up/total (0 -> tile dropped, never a const)
  - Regions <- real visor fleet
Tokens24h/TokenSeries have no non-ledger source -> honest 0/empty, volumeModeled
stays true (UI renders "—"). demo:true now means only "nothing measured yet".

Frontend (CloudOverview/LiveActivity/ModelUsage) keys the live badge on
!demo && !volumeModeled — no modeled number wears a live badge; LiveActivity
regions show real visor node counts. TrafficGlobe filters garbage country codes
to ISO-3166. Dead admin-panel gate fixed (mountAdminCloudPanels !== 'saas' ->
'cloud', so Cloud Fleet + LLM panels mount for admins).

Full all-org volume + per-model tokens need a super-admin HANZO_CLOUD_PULSE_TOKEN
in KMS; until then honestly blank. go build/vet/test green; tsc + vite clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 01:27:05 -07:00
hanzo-dev 32da053f28 feat(world): tab order Cloud·AI·Crypto·Finance·Tech·World, Title-case labels, SVG icons
- Header tabs reordered to Cloud, AI, Crypto, Finance, Tech, World (World last).
- Tab + status labels Title-case, not ALL CAPS (World/Crypto/Finance/Tech/Live;
  AI kept as the acronym) across en + nl.
- Export (⬇) and historical-playback ( ⏮ ◀ ▶ ⏭) emoji replaced with clean
  16px stroke SVGs matching the header's search/theme icons.

tsc clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 00:49:28 -07:00
06ff53615b feat(cloud): Cloud flagship view — H-merge + CLOUD tab, in-map controls, flicker fix, flywheel panels (#12)
* feat(cloud): Cloud-view polish — compact H + CLOUD tab, in-map controls, flywheel history, accurate legend

- Header: merge the redundant H-icon + "Hanzo" wordmark into one compact (16px)
  canonical H mark (brand/home toggle); rename the flagship tab "Hanzo" → "Cloud"
  (switcher: [Cloud | World | AI | Crypto | Finance | Tech]). Canonical variant id
  renamed hanzo → cloud, with hanzo + saas kept as runtime aliases (links + stored
  prefs keep working).
- Controls: dock the 2D/3D toggle, basemap switcher and time-range pills IN the map
  (controlsHost = map) alongside the zoom, layer toggles and legend that already
  overlay it, instead of floating them in the bottom toolbar; time pills move to the
  bottom-left so they clear the top-left layer toggles; the empty dock slot collapses.
- Flywheel history: a same-origin GET /v1/world/cloud/router-history proxy (→ ai's
  public /v1/router/history?scope=platform; honest empty, never demo) + a Model
  Improvement panel — reward-rate line chart with retrain-version markers, a ticking
  cumulative cost-saved hero, and an adoption sparkline. Charts start empty and GROW
  with real data; nothing is seeded.
- Legend: add a Cloud data-class legend (request origin·volume, validator node, GPU
  fleet, cloud region, datacenter) so it matches what the globe plots rather than the
  geopolitical default.

tsc + vite build clean; world Go build + gofmt clean.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* fix(cloud): kill the 3D-globe flicker — settle the globe, guard the cables layer

Root cause (per a deep read-only diagnosis): the 3D globe is a custom GlobeNative
deck.gl renderer whose idle auto-rotate (~2°/s) keeps thin coplanar on-sphere vector
features (borders/coastlines/markers) perpetually sub-pixel-moving, re-rasterized
every frame — steady-state limb shimmer on real GPUs. Fixes:

- GlobeNative created with autoRotate:false — the globe SETTLES, so the deck render
  loop idles (verified: camera longitude does not drift and renders drop from ~40/s
  to a couple of data-sync ticks). Drag-to-rotate still works; re-enable the idle spin
  only with a coplanar depth/polygonOffset fix so it no longer z-fights the sphere.
- Guard createCablesLayer: filter UNDERSEA_CABLES to paths with >=2 finite [lon,lat]
  vertices, so one malformed cable can no longer make the PathLayer assert at init and
  silently drop the whole layer (the "[GlobeNative] cables-layer assertion").
- Cables OFF in the Cloud view (not a Hanzo Cloud data class; absent from the Cloud
  legend) so the globe stays to the plotted classes and the legend matches exactly.

Playwright: a new cloud-mode test asserts the 3D globe settles (lonDelta<0.001,
renders<12), the legend shows the Cloud classes (not the geopolitical default), and no
cables assertion fires. tsc + the full cloud-mode suite green.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* feat(cloud): CLOUD variant rename, compact H, in-map controls, flywheel panel + proxy

Completes the Cloud-view content (the prior checkpoint captured only the file renames):

- variant.ts: canonical variant renamed hanzo → cloud, with hanzo + saas as runtime
  aliases; host-default cloud on world.hanzo.ai. isHanzoBrandHost gates the brand.
- App.ts: header merges the redundant H-icon + wordmark into one compact 16px
  canonical H (brand/home toggle); first tab renamed Hanzo → ☁️ Cloud; createPanels
  wires the cloud panel set incl. the Model Improvement panel; controlsHost = the map
  so the 2D/3D toggle + basemap + time pills dock IN the map cluster.
- ModelImprovementPanel + router-history service + /v1/world/cloud/router-history
  proxy: the flywheel-over-time story (reward-rate line + retrain markers, ticking
  cost-saved hero, adoption sparkline) — honest empty until the ledger fills.
- variants/cloud.ts (mirror) + saas.ts alias; flywheel + in-map-controls CSS.

tsc + vite build clean.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* feat(cloud): regional activity — traffic-origin tooltip shows task mix + volume

Hover a traffic origin on the globe (or flat map) → its request volume + the router
task mix (code/reasoning/chat/vision — what customers there are DOING), falling back
to the coarse service class until the per-geo byTask recording lands. Uses the new
TrafficGlobePoint.byTask from /v1/traffic/globe. Works in both 2D and 3D (GlobeNative
delegates tooltips to this handler).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 20:13:22 -07:00
3c50a167c7 feat(hanzo): Hanzo mode — H-logo switcher + live-traffic globe (flagship default) (#11)
* feat(hanzo): Hanzo mode — H-logo switcher + live-traffic globe (flagship default)

world.hanzo.ai now leads with the first-class `hanzo` variant. The H logo toggles
Hanzo mode, revealing the [Hanzo | World | AI | Crypto | Finance | Tech] switcher
(Hanzo first + default on the world.hanzo.ai host). The Hanzo entry + toggle appear
ONLY on hanzo brand hosts (white-label rule); off-brand/OSS hosts keep the plain
home-link logo + an always-on switcher. The former `saas` variant is folded in and
kept as a runtime alias (saas → hanzo, normalized in variant.ts).

Globe: a new native deck.gl `traffic` layer plots WHERE requests hit api.hanzo.ai
from — pulsing points sized/heated by count at country/US-state centroids, breathing
on the shared cloud-pulse clock. Fed by a same-origin proxy /v1/world/cloud/traffic-
globe → the ai backend's public /v1/traffic/globe (aggregates only, no IPs). The
existing traffic-arc layer now draws from the SAME native source (one source of
truth). Both degrade to an HONEST empty state — never demo — until the ai release
lands.

Side panels (the globe's companions): Live Traffic (native throughput totals + top
origins), Enso Live Training + Enso Flywheel (router stats), AI Compute, Model Usage
(model mix), Fleet, Cloud Overview, Hanzo Status (uptime), My Usage.

Tests: Go proxy passthrough + honest-empty state; tsc + vite build; a Playwright spec
driving the H toggle → switcher reveal (6 tabs, Hanzo first) + the globe layer toggle,
plus the TrafficGlobePanel live + empty states.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* refactor(hanzo): lead the globe with the honest native points layer, arcs off by default

The trafficArcs feed carries a demo fallback (demoTraffic), so enabling it by
default on the flagship could paint fabricated flows when native data is absent. The
new native `traffic` points layer is ALWAYS honest (empty until real traffic lands,
never demo), so the hanzo default now leads with it and ships trafficArcs OFF — still
one toggle away for the animated flow lines (which are native-sourced once live).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 15:50:14 -07:00
hanzo-dev b62ad51e7c feat(panels): AI Compute + Enso Flywheel on the DEFAULT world
The platform's own live compute/usage pulse and router-training flywheel
were gated to ?variant=ai — invisible on plain world.hanzo.ai. They are
front-page telemetry: registered in FULL_PANELS and instantiated for the
default (full) variant alongside ai.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 14:36:55 -07:00
cbaae9e800 feat(analyst): content-free AI reward signals via @hanzo/ai SDK (#10)
Wire thumbs up/down + regenerate reward signals into the analyst chat,
keyed on the gateway response id, through a same-origin /v1/feedback BFF
proxy and the just-published @hanzo/ai@0.2.1 sendFeedback.

Backend:
- Thread the gateway response id out of every completion: chatResponse.ID
  (non-stream) + the per-chunk SSE frame id (first non-empty wins);
  runAnalystLoop returns it; the analyst done/JSON payloads carry "id".
- New same-origin BFF /v1/feedback (bare, matching the gateway path so the
  SDK's baseUrl:'' reaches it). Forwards the caller's IAM bearer +
  org/project selectors to a.base+"/feedback". Content-free is enforced
  server-side: the body is WHITELISTED to exactly {request_id, signal,
  rating?}; rating rides only with signal=="rating" and only for 1..3; a
  dismiss or any non-rating signal never forwards a rating. No bearer,
  bad signal, empty id, or malformed JSON => quiet 204 no-op (never 401).

Frontend:
- analyst-transport: surface id on AnalystResponse (JSON + SSE done).
- AnalystChat: store id + originating prompt on the assistant message; add
  thumbs-up/down (mutually-exclusive pressed state) + regenerate to the
  meta row, matching the copy button. emitFeedback helper calls the SDK
  same-origin with a lazy IAM bearer; opt-out via VITE_HANZO_FEEDBACK.
- icons: thumbs-up, thumbs-down, refresh-cw (lucide).

Content-free is a HARD invariant in BOTH layers: the SDK FeedbackInput type
union and the Go BFF whitelist. No prompt/response/text ever transits.

Tests: handlers_feedback_test.go (whitelist, rating rules, no-op paths,
preflight) + analyst id propagation. go test ./internal/world/... green;
tsc --noEmit + vite build green.

Rollout: merge to main ships via the world operator Service CR
(universe: infra/k8s/operator/crs/world.yaml) through hanzoai/ci.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 11:26:24 -07:00
zandGitHub 39288fe709 Merge pull request #9 from hanzoai/fix/world-router-stats-unwrap
fix(world 2.4.8): unwrap ai envelope in router-stats proxy
2026-07-16 09:17:16 -07:00
hanzo-dev f5a426c923 fix(world 2.4.8): unwrap ai casibase envelope in router-stats proxy
/v1/world/cloud/router-stats raw-passed ai's {status,data:{…}} envelope, but the
Enso Live Training panel (and this proxy's own unavailable fallback) expect the
BARE RouterStats — so live data would never render (the panel sat on
"connecting…" because stats.window was undefined). Unwrap `data` on success;
a missing/null data or non-ok status degrades to the honest unavailable payload
(never 5xx). Test now feeds the enveloped upstream and asserts the unwrap.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 09:16:46 -07:00
zandGitHub 0bdf531004 Merge pull request #8 from hanzoai/feat/enso-live-training
world 2.4.4: Enso Live Training panel (saas variant)
2026-07-16 09:08:35 -07:00
hanzo-dev 59c9eb52eb world 2.4.7: Enso Live Training panel (saas variant)
An "Enso Live Training" panel for the Hanzo Cloud (saas) variant of
world.hanzo.ai — live routing throughput, learned-engine agreement, per-arm
routing share (opaque arm-N), cumulative cost-saved, and the last-retrain gate
verdict — fed by the PUBLIC /v1/router/stats?scope=platform aggregate.

Data via the same-origin Go proxy /v1/world/cloud/router-stats (the established
/v1/world/cloud/* convention): hard-pins scope=platform (client can't request a
vendor-labeled scope), clamps hours to [1,168], degrades to 200 {unavailable}
on an unreachable upstream (never 5xx, never fabricated). API base configurable
via HANZO_API_BASE (default https://api.hanzo.ai).

Enso stays private: the public scope already relabels arms to opaque arm-N; the
panel labels them "Enso arm N" and an e2e test asserts no vendor token ever
reaches the DOM.

Reconciled with the parallel Enso Flywheel panel that landed on main (ai
variant, /v1/world/enso-training): distinct variant + audience (this is the
Cloud self-metrics economics view; the flywheel is the public eval-loop view).
The colliding services/enso-training.ts was split — theirs kept, mine moved to
services/router-stats.ts — so both panels coexist with no shared symbol. VERSION
bumped past the flywheel's 2.4.6 to 2.4.7.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 09:06:46 -07:00
hanzo-dev dee04c5c89 feat(world/ai): Enso Flywheel panel + /v1/world/enso-training
The router self-improvement loop made visible for the AI variant.
/v1/world/enso-training folds three honest sources: the routing-decision ledger
tail (export-routing-ledger ?since=, super-admin JSONL) → decision count,
engine-vs-heuristic mix, a routing-confidence histogram, task/model distributions;
the reward tail (export-routing-rewards) → rewarded count + mean reward; and the
latest enso-bench eval scores. Eval scores come from a committed snapshot of
enso-bench results/summary.json (go:embed), overridable live via ENSO_BENCH_URL —
so the panel is useful even signed-out; state (live/partial/demo) says which live
sources resolved. The payload is event-typed (Events[]) so retrain/deploy
milestones slot into the same timeline.

Frontend: enso-training.ts + EnsoFlywheelPanel registered for the ai variant,
reusing the existing cloud stat-tile / model-row / share-bar idioms.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:33:23 -07:00
hanzo-dev 7cd8035964 feat(world/ai): AI Compute panel + /v1/world/ai-pulse SSE
Live Hanzo inference-plane telemetry for the AI variant. /v1/world/ai-pulse
reuses the analyst SSE plumbing + the shared cloud-usage seam: it polls the
platform usage ledger (get-cloud-usages allOrgs → tokens/s, req/s, spend, top
models) + visor (gpu/machine/region counts) on an interval and pushes typed
usage/fleet/status frames to EventSource clients, coalesced through cache.go so N
streams cause ~one upstream sweep per 10s. A plain GET returns one JSON snapshot —
the panel's poll fallback. No token ⇒ honest state:"unavailable".

Frontend: ai-pulse.ts (SSE stream + poll fallback, cloned from cloud-pulse.ts),
AiComputePanel registered for the ai variant (panels.ts AI_PANELS + variants/ai.ts
+ App.ts). Reuses the existing cloud-panel stat-tile / model-row idioms.

Shared usage helpers (usageRate/topModelsFromUsage) decomplected into
cloud_service.go so cloud-pulse and ai-pulse derive rate/top-models one way.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:27:00 -07:00
hanzo-dev 4ef1b36e45 feat(world/cloud): cloud-pulse folds real platform volume (get-cloud-usages allOrgs)
/v1/world/cloud-pulse now reads MEASURED 24h request/token volume + top models
from the ClickHouse-backed usage ledger (get-cloud-usages ?org=all, super-admin)
on top of the live model/node/GPU/region counts, dropping demo:true AND
volumeModeled:true. Without a token — or with a non-admin token that can't reach
the allOrgs ledger — it stays honestly demo/modeled; platform numbers are never
faked silently, and the service bearer never reaches the browser.

One credential, one accessor: serviceToken()/serviceAuth()/apiHost() are now the
single service-plane seam (cloud-pulse + cloud-map share it) reading the existing
KMS-injected HANZO_CLOUD_PULSE_TOKEN. New cloud_service.go holds the shared
get-cloud-usages decode + fetcher, reused by the forthcoming ai-pulse.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:18:49 -07:00
hanzo-dev 7bbfe0f5a7 world 2.4.3: analyst top-up CTA on out-of-credits + 'Ask anything. Update your world.' placeholder
The analyst returned an opaque 'empty response' when a signed-in user's AI
credits were exhausted. The ONE AI gateway (hanzo/ai routers/filter_balance.go)
signals this as HTTP 402 {code: insufficient_balance}; the gateway can also
answer 2xx with an error envelope carrying no choices. World now detects EXACTLY
that one contract (no keyword heuristics, no invented codes — world stays a thin
client of api.hanzo.ai) and renders a wallet CTA: 'You're out of AI credits →
Add credits (console.hanzo.ai/billing) / View usage' instead of a dead bubble.
Adds failure logging on the analyst path (was silent). Placeholder set to the
canonical 'Ask anything. Update your world.'
2026-07-15 12:40:48 -07:00
hanzo-dev 426f918286 fix(world/analyst): shrink chat placeholder so it stops wrapping to two lines
The ~52-char placeholder wrapped at 14px inside the ~360px composer,
shifting the box when it did. The placeholder now renders at 12.5px; the
unitless 1.5 line-height stays pegged to the input's 14px row, so a
smaller placeholder never moves the row vertically — typed text stays
14px, the composer stays one line.
2026-07-15 01:57:32 -07:00
hanzo-dev 322248b98c chore(world): 2.4.2 — streaming analyst + model menu
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 21:54:39 -07:00
hanzo-dev 27e52c0ea2 feat(world/ai): the analyst answers stream live (SSE)
/v1/world/analyst with Accept: text/event-stream now emits round /
think / delta / tool / done events while the agentic loop runs; the
done event carries exactly the JSON path's payload, so deltas are
cosmetic and the final render is unchanged. Server: chatMessagesModelStream
reads upstream OpenAI SSE; replyExtractor incrementally decodes the
strict-JSON envelope's "reply" value (chunk-safe escapes, byte-at-a-time
tested) and routes a reasoning model's pre-envelope prose to think
events. Tool rounds stream silently; tools surface as live chips.
Client: streaming bubble with breathing caret, dim italic thinking that
real answer text supersedes; non-SSE responses (old backend, sign-in
gate, agent path) fall back to the JSON contract in the same call.
Proven live: 61 think + 41 delta frames then done, gpt-oss-120b.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 21:49:28 -07:00
hanzo-dev 5b3f257a78 feat(world/ai): grouped model menu with family marks — no Zen ring on gpt-oss
The composer pill (fixed Zen logo + native <select>) becomes a proper
listbox popover: rows carry per-family marks (Zen ring ONLY on zen*,
sparkles for the Best router, G/L/C monograms for gpt/llama/claude,
bot for agents), group headers, active check. Assistant avatars and the
thinking row now wear the SERVING model's mark. One mapping in
src/utils/model-marks.ts, mirrored server-side by modelGroup() so the
roster groups honestly (Auto/Zen/GPT/Llama/Claude). Menu is
body-mounted + fixed so panel overflow never clips it. npm run dev
gains a /v1 proxy (prod by default, VITE_DEV_API_PROXY override).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:55:17 -07:00
hanzo-dev 9ff7367f47 Merge feat/china-macro: China macro snapshot + release calendar
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:08:00 -07:00
hanzo-dev b2c86b60f5 feat(world/econ): China macro snapshot + release calendar
/v1/world/china-macro — OECD CPI+CLI (2 sequential, non-retryable), BIS
policy rate (kv-seeded; not_configured until a feed exists), FRED
DEXCHUS, HKMA CNY context, NBS release-calendar scrape + PBoC LPR
candidates (2026 CN business calendar; 2027 entry required before Jan).
launchReady gates on all four required categories;
contentObservationDate anchors to the OLDEST required observation.
EconomicPanel CN tab renders staleness-honest tiles + upcoming releases;
keys mirrored across all 13 locales. Ported from upstream worldmonitor.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:07:52 -07:00
hanzo-dev ca6ab46c0a fix(world/tech): CISA feed rides our same-origin /v1 rss-proxy
The one feed still pinned to upstream's rss.worldmonitor.app/api host;
cisa.gov is already on the server allowlist.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:00:42 -07:00
hanzo-dev 9e56b6768f chore(world): 2.4.1 changelog; PWA guardrail asserts real precache intent
The stale guard demanded an html-free precache glob, contradicting the
committed vite.config (offline.html precached, index/settings excluded
via globIgnores). Assert the actual invariant instead.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:31 -07:00
hanzo-dev df125ac864 feat(world/natural): Western Pacific cyclone attribution
Cross-agency TC canonicalization ported from upstream worldmonitor:
GDACS WP-box observations + HKO warning signals (new same-origin
/v1/world/hko-warnings passthrough; browser CORS) dedup by alias
(<=750km/<=18h) or unnamed proximity (<=90km/<=3h), preserving each
agency's own wind + averaging period. Popup renders per-agency wind
rows and canonical-match confidence. JMA/JTWC upstream stubs skipped
(preflight-blocked upstream, fetch nothing).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:31 -07:00
hanzo-dev 8f024d9227 perf(world/go): round sparkline closes to 7 significant digits
Yahoo closes come back float32-widened (17.209999084472656); Go re-emits
the noise verbatim, ~40-50% of sparkline payload bytes. One sparkline()
emit path (né tailN) rounds via strconv 'g'/7; scalars stay exact.
Ported from upstream worldmonitor seed-utils.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev f4d5827263 perf(world/ui): paint news before ML, bound analyst snapshot fetches
News panels gated their first DOM write on enrichWithVelocityML — on a
cold browser that means a 65MB sentiment-model download before any
headline appears (15-25s after the feeds answered in <1s). Paint with
keyword velocity immediately, upgrade sentiment in place, guarded by
renderRequestId. Analyst context fetches now cap at 2.5s so a cold
endpoint can't delay the chat send.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev 8511a46d38 perf(world/go): SWR cache with single-flight + GDELT warm keys
cachedJSON/passthrough now serve stale instantly and refresh in the
background (one coalesced flight per key; cold misses single-flighted).
gdelt-doc/gdelt-geo stalled every request ~10s on each 5-minute TTL
lapse; the analyst grounding + protests-layer keys are additionally
kept warm every ~4min, GDELT-paced.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev 014080d3e8 fix(world/ai): 'best' default model, honest upstream errors, roster lead
zen5 went dark when the inference plane's claim catalog shifted — an
unclaimed id falls through to a proxy that rejects every credential, so
every AI endpoint died with an opaque 402/401. 'best' is the gateway's
own routing alias (owned_by hanzo) and always resolves; non-2xx bodies
now surface their code (insufficient_balance, spend_cap_exceeded, …) in
the chat instead of a bare status.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:02 -07:00
hanzo-dev bd5aec5e12 fix(world/globe): arcs hug the sphere (greatCircle) + compact model selector
The 3D globe launched arcs far off the silhouette: deck.gl ArcLayer arches each
arc by ~1x the straight-line CHORD, so long cross-continent arcs (displacement
flows, traffic pulses) ballooned into space — the 'off-globe' glitch in the
screenshots. On the globe (mode==='3d') set greatCircle:true + getHeight:0 so
arcs follow the great circle flat on the surface; the flat map keeps its arch.

Also: the analyst model selector was a wide bar (max-width 160px showing full
model ids) — capped to 104px + ellipsis so it reads as a compact chip.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-13 08:20:16 -07:00
hanzo-dev 7f58503cf6 Merge feat/analyst-full-control: layout/monitor/queue/language commands 2026-07-12 11:31:56 -07:00
hanzo-dev baac10392f feat(world/ai): full app control from chat — layout mode, monitors, queue, language
The analyst could show/hide/move panels and drive the map, but NOT change the
layout mode, add a topic to watch, or drive the Watch Queue — so 'reconfigure
the layout and all widgets from the chat' was not actually true.

New commands (each drives the SAME public API the UI does; no second path):
- set_layout_mode: grid / free / immersive
- set_immersive_background: map | video
- set_language
- add_monitor / remove_monitor — 'watch for X'; goes through the one monitor
  path, so an AI-added monitor persists + matches server-side like a typed one
- queue_next / queue_prev — drive the Watch Queue

AppState snapshot now exposes layoutMode, immersiveBg, language, the user's
monitors, and queue depth — the model cannot drive what it cannot see.

Fixes a real WatchQueue restore bug the e2e caught: a persisted blob whose
currentId pointed at a non-'watching' item silently lost watch-tracking
(next() only finishes a 'watching' item). load() now restores the invariant.

e2e/analyst-full-control.spec.ts: AI really flips to immersive, really adds a
monitor that lands in storage, really advances the queue — 3/3. Full suite 56.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 11:31:56 -07:00
hanzo-dev 881c464e08 Merge feat/go-native-monitors: server-side monitor list + lake-wide matching 2026-07-12 09:23:02 -07:00
hanzo-dev b03f317986 feat(world/go): Go-native monitors — server-side list + lake-wide matching
Monitors lived entirely in the browser: keywords in localStorage, matching done
client-side over whatever headlines that tab had loaded. So a monitor only ever
saw one client's slice of the corpus, and it ceased to exist when you closed the
tab or switched device.

- /v1/world/monitors (GET/PUT): the list is persisted PER IDENTITY, reusing the
  existing per-identity store (namespaced 'monitors') — no new mechanism, no new
  table. Bounded + normalised at the boundary (sanitizeMonitors).
- /v1/world/monitors/matches: matching runs against the LAKE — every item the
  backend ingested — via the existing FTS search. FTS tokenising can over-match,
  so each hit is re-checked against the same word-boundary rule the browser used
  ('ai' must never trip on 'train').
- store: Lake.Flush() — drains the write-behind buffer synchronously. Run() is
  still the steady-state consumer; Flush is for callers that need the write to be
  VISIBLE (deterministic tests, and a shutdown that wants its buffer on disk).
- services/monitors.ts: the ONE path to where monitors live. Signed in → server
  (and localStorage as the offline mirror); signed out → localStorage, unchanged.
  Local monitors are ADOPTED on first sign-in rather than silently dropped.
- MonitorPanel.renderServerMatches renders lake matches with the same markup (and
  the data-ctx-* attrs, so Summarize-with-AI works on them too).

Tests run against the real SQLite lake (temp WORLD_DATA_DIR), not a mock:
per-identity round-trip + isolation (user-2 cannot see user-1's monitors),
matching against the lake, and the word-boundary guarantee. 4/4 pass.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 09:23:02 -07:00
hanzo-dev 0ad40998e1 Merge feat/go-native-enrichment: server-side threat + geo enrichment 2026-07-12 09:11:19 -07:00
hanzo-dev a452df5282 feat(world/go): move news enrichment into the Go backend (proven parity)
The browser was classifying threat level and inferring geo for every headline,
on every client, on every render — the data and the logic both lived in the
frontend. The Go backend is the product; the browser is a view.

- enrichdata/{threat-keywords,geo-hubs}.json: the tables are DATA now, one
  source of truth (extracted by EVALUATING the TS, not transcribed).
- internal/world/enrich.go: Go classifier + geo inference. Two parity traps
  handled deliberately — JS object key order IS match priority (tables stay
  ordered slices, not maps), and JS sort is stable (sort.SliceStable).
- feeds-batch now returns threat + resolved geo (hub, lat/lon, name) per item;
  parseFeedItems stays a pure XML parser, enrichFeedItems does the meaning.
- rss.ts consumes the server's enrichment; the browser no longer geo-locates
  anything (the import is gone). Single feeds now batch too, so the per-feed
  browser path survives only for feeds that aren't proxy-shaped.

PROOF, not a claim: enrich_test.go replays a 494-case golden corpus generated
from the real TypeScript (every keyword × both variants, every geo-hub keyword,
every exclusion, plus adversarial word-boundary cases) — 494×2 classifications
and all geo inferences are byte-identical to the code being replaced.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 09:11:19 -07:00
hanzo-dev 959138ac28 Merge feat/ui-polish: summarize context action, chrome cleanup, auth-aware CTA 2026-07-11 23:57:29 -07:00
hanzo-dev 329a741409 test(world): refresh e2e screenshots from the ui-polish run 2026-07-11 23:57:29 -07:00
hanzo-dev a8485d11af feat(world/ui): Summarize-with-AI on right-click; drop header rule, drag pill; hide Try-Hanzo when signed in
- Summarize with AI: any item already annotated data-ctx-headline (news, custom
  feeds) gets it as the FIRST context-menu entry. It routes into the ONE analyst
  dock via a capability port (registerSummarizePort), mirroring the existing
  map port — the menu never reaches into the AI. New AnalystChat.ask() /
  AiAnalystDock.askInDock() drive the same send path as typing; no second AI.
- Header: drop the border-bottom. The header shares the app background, so the
  rule just cut the page in half.
- Map: drop the visible drag pill (::after). The grip strip is still the drag
  target (cursor:grab) — the map stays chromeless, drag is unchanged.
- Try Hanzo is an ACQUISITION CTA: it now disappears once identity resolves as
  signed-in, driven by one new 'hanzo:auth' event emitted from the single place
  auth resolves (AccountMenu.refresh).
  FIXES A REAL BUG the e2e caught: .try-hanzo sets display:inline-flex, which
  beats [hidden]'s UA rule — so hiding it would have silently done nothing.

e2e/ui-polish.spec.ts covers all four against real DOM/CSS: 4/4 pass.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:51:55 -07:00
hanzo-dev abfbd61d1d feat(world/queue): keyless YouTube search — no API key required
queue_video was dead in prod: no YOUTUBE_API_KEY is configured, so Data-API
search returned empty. Scrape the results page instead — the same keyless
pattern handleYouTubeLive already uses (browser UA + consent cookies, since
datacenter IPs get an interstitial). The Data API stays as the fast path when
a key exists; the scraper degrades to an honest empty list, never a fake hit.

Verified live: 'Milken Institute Jensen Huang' returns the real talks
(HT8-KPAjpiA = Global Conference 2025) as the top results.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:23:25 -07:00
hanzo-dev cb67e2bbec chore(world): drop the accidentally-committed cmd/world binary; gitignore it 2026-07-11 23:22:00 -07:00
hanzo-dev 44ad7190cc Merge feat/watch-queue: persistent Watch Queue + AI queue_video + youtube search 2026-07-11 23:21:51 -07:00
hanzo-dev bc11f1111e feat(world/queue): persistent Watch Queue — AI-queued video, skip/back, tracked watched
Content the AI surfaced was ephemeral: a video it found (e.g. the Milken
Institute Jensen Huang talk) was lost on reload. Now everything consumable
lands in ONE persistent queue.

- WatchQueuePanel: stage (video/image/story) + list; prev / finish-and-next /
  fullscreen; click a row to play, x to remove. Owns no state — a pure view
  over watchQueue, so panel and immersive views can never disagree.
- queue_video app-command: the analyst resolves free text ('milken jensen
  2025') to a real video and queues it — reachable over MCP like every other
  command.
- /v1/world/youtube/search: Data-API search, cached, honest empty result when
  no key is configured (never fakes hits).
- Queue persists to localStorage: survives reload, tracks watched/finished.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:21:51 -07:00
hanzo-dev c75b442523 feat(world/queue): WatchQueue core — one source of truth for consumable media
Unifies non-live news clips, AI-surfaced media, and story cards into ONE
ordered queue with tracked per-item state (queued/watching/watched), so
content can be consumed reliably instead of flashing by. Pure logic, no DOM;
persisted to localStorage so the queue + watched history survive reload.
Idempotent enqueue (dedup by id) + bounded size (prune oldest watched).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 11:10:22 -07:00
hanzo-dev 6bf39c2ebd feat(world/video): fullscreen/enlarge button on the live-news player
Adds a fullscreen control next to mute/play that promotes the video player
container to true fullscreen (and toggles back). CSS makes the iframe fill
the fullscreen root edge-to-edge. Answers 'make the video bigger / go full
screen so I can enjoy streaming it'.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 11:02:28 -07:00
hanzo-dev 2351a1e5bb fix(world/video): stop idle timer from killing a video you're watching
Both live-video panels ran a 5-min input-idle timer (mousedown/keydown/
scroll/touchstart on document). Passively watching a video fires none of
those, so after ~5 min the timer destroyed the player/iframes mid-watch —
exactly the 'video disappears after a few minutes' report. LiveNewsPanel
had no non-visibility resume path, so it stayed dead until a tab switch.

Fix: when the idle timer fires while the video is actively playing and the
tab is visible, reschedule instead of tearing down. The visibilitychange
handler (tab hidden) and the webcam IntersectionObserver (panel off-screen)
still cover the genuine 'user left' cases, so abandoned tabs still pause.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 10:58:02 -07:00
hanzo-dev 6461b9f7eb Merge perf/world-gzip-cache: gzip + immutable-cache static SPA 2026-07-11 10:50:06 -07:00
hanzo-dev 9f6ae73148 perf(world): gzip + immutable-cache the static SPA
The cutover from the hanzoai/static image to the cmd/world Go binary dropped
response compression and asset caching, so every cold load pulled the Vite
bundle raw: main.js 1.5MB, map.js 2.7MB — ~4.5MB of uncompressed JS.

- gzipStatic wraps ONLY the SPA handler (never /v1/world/*, which streams):
  gzip for compressible types, Range + non-gzip clients pass through,
  already-compressed media skipped. main.js -73%, map.js -72% on the wire.
- setCacheHeaders: content-hashed assets/ → immutable 1y; every unhashed file
  (favicons, manifest, service worker) stays no-cache so SW updates aren't
  stuck behind a stale cache.
- static_test.go: proves encoding, ratio, round-trip decode, cache policy.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 10:50:00 -07:00
hanzo-dev 43bfcd9463 feat(world/layout): resize free-mode panels from all four corners
Free mode gains NW/NE/SW corner grips alongside the existing SE one, so a
panel can be resized from any corner like a real window manager. The resize
is anchor-aware: dragging a corner pins the opposite corner — w/n edges shift
the panel's left/top in lock-step with the size, e/s grow from a fixed
top-left as before. One primitive (applyFreeResize over a {n,s,e,w} edge set)
drives every edge and corner handle; attachPanelCornerResize takes a `corner`
and only the SE corner also drives grid-mode span/column snapping (top/left
resize has no meaning in grid flow, so those grips are free-mode only, hidden
in grid via CSS). Header controls are raised above the grips so the close/info
buttons stay clickable where a top corner overlaps them.

Verified: e2e all-four-corners test asserts each corner resizes and pins its
opposite corner (14/14 panel-drag specs green); tsc clean.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 21:46:29 -07:00
hanzo-dev 71c53613e4 test(world): keep RED adversarial admin-gate matrix (17 cases, hermetic) — CI coverage for requireAdmin fail-closed 2026-07-10 14:32:22 -07:00
hanzo-dev d5cccfa026 chore(world): gitignore the embedded SQLite lake (world.db + WAL/SHM)
The datastore lake/settings DB (store.dbFile = "world.db", opened in WAL
mode) lands in the repo's data/ dir when WORLD_DATA_DIR points there in
local dev. Ignore world.db and its -wal/-shm sidecars alongside the
existing world-model runtime snapshots so they are never committed.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev fb5b277927 refactor(world/src): delete 3 orphaned data-fetcher services
conflict-impact, github-trending, hackernews (244 LOC): none imported
anywhere, none re-exported via services/index.ts, and every exported
symbol (correlateConflictImpact, fetchGitHubTrending, fetchHackerNews, …)
has zero references repo-wide. The github/HN panels now fetch through the
/v1/world/* URL registry in config/variants/base.ts, which superseded
these client-side fetchers. tsc + vite build green; bundle unaffected.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev c90e39c018 chore(world): drop unused deps @vercel/analytics, youtubei.js
Neither is imported anywhere in src/, src-tauri/, or api/ (0 refs
repo-wide). Removing them drops 4 packages from the lockfile (the two
plus their now-orphaned transitives @bufbuild/protobuf and meriyah) and
~26 MB of install footprint (youtubei.js alone is 21 MB).

Proven inert: with all four physically removed from node_modules,
`tsc --noEmit` and `vite build` pass and the JS bundle is byte-identical
(6,139,678 B) — they were never bundled, so bundle-size delta is 0; the
win is pure supply-chain / install-surface reduction.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev a5d6da77ae refactor(world): one IAM-userinfo introspection path
introspectOwner and introspectIdentity were the same memoized userinfo
call under two cache-key namespaces ("cloud-owner:" vs "identity:"), so a
token used for both the admin gate and settings sync was fetched and
cached twice. Delete introspectOwner; route requireAdmin through
introspectIdentity and read the owner claim off it. One function, one
cache entry per token, one source of identity truth.

Fail-closed semantics unchanged: no token → 401, introspection error or
non-admin owner → 403. Admin-gate and settings tests green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev 217f13a582 refactor(world): delete dead code (deadcode-verified, tests included)
Five funcs unreachable even counting test callers, per
golang.org/x/tools/cmd/deadcode -test:

- strings.go fieldsCollapse   — 0 refs
- mcp.go     toolError         — 0 refs
- model.Engine.History()       — dead accessor; the /history route reads
                                 e.history directly (comment was stale)
- model.Engine.Context()       — superseded grounding path; AI handlers
                                 ground via CountryContext + HistoryDigest
                                 (comment "called by the AI handlers" was false)
- model.toneNote()             — only called by the removed Context()

ftoa/signed/round2 verified still live (history.go, model.go). Build,
vet, and internal/world tests green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev 73bd9d78f8 feat(world): "Try Hanzo" product-switcher dropdown in header
Replace the signed-out header CTA with the hanzo.ai "Try" pattern: a white
.hz-cta pill "Try Hanzo ▾" that drops a monochrome menu of Hanzo products
(World/Chat/Dev/App/Cloud/Desktop), with world.hanzo.ai highlighted as the
current product. The former "Try Hanzo World" pill was the AccountMenu login
CTA — relabel it to a secondary "Sign in" so the header has one primary action
(hanzo.ai layout: [Try Hanzo ▾] [Sign in]).

- New self-contained stylesheet src/styles/try-hanzo.css (not main.css).
- Menu portaled to <body> + position:fixed so it never widens the page and is
  never clipped by the header's mobile overflow-y:hidden tab scroller.
- Closes on click-away and Escape; icon-only trigger < 680px. No h-overflow at
  1440/390; theme-aware (white pill dark / black pill light).
- All product URLs verified live (200): world.hanzo.ai, hanzo.chat,
  hanzo.ai/code, hanzo.app, console.hanzo.ai, hanzo.ai/desktop.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:28:33 -07:00
hanzo-dev 5dc7232cda feat(world/analyst): redesign the analyst copilot — markdown, real Zen mark, window modes
The dock chat is rebuilt to a native-@hanzo/ai, hanzo.chat-grade copilot in a
self-contained `hzc-` namespace + its own stylesheet (analyst-chat.css), so it
never touches main.css.

P0 fixes
- Markdown now renders: a tiny XSS-safe renderer (utils/markdown.ts) does
  bold/italic/lists/headings/code/links/tables — no raw HTML passthrough, every
  text run escaped once, every href run through sanitizeUrl.
- HTML entities decoded at the source: the backend HTML-escapes replies, so an
  apostrophe arrived as the literal `&#39;`. decodeEntities() runs first, then a
  single re-escape on output — the browser shows `'`, never `&#39;` and never the
  double-escaped `&amp;#39;`.

Brand + look
- Real Zen mark everywhere (the notched ring from zen/logo/dist/logo-mono.svg):
  icon('zen') is now the scaled logo and a new zenLogo() drives the FAB, the
  header, the assistant avatar, and the pulsing "thinking" state — no more
  generic enso/sparkle.
- vercel-black monochrome, Geist, sentence case: clean user/assistant bubbles,
  a card composer with an inline model selector, the model tag on each reply,
  tidy action-log + tool-trace styling.

Window modes (expanded scope)
- Closed = a small Zen-mark FAB bottom-right. Open = dock / sidebar / split /
  fullscreen, toggled from the header. Sidebar is the default and reflows #app
  via `body.hzc-shift` + the `--hzc-width` var (the auto-fit grid reflows
  cleanly); left-edge drag resizes; mode/width/open-state persist so a
  variant-switch reload reopens the same rail.

App control + data
- New bulk commands hide_all + show_only in the command registry (composed from
  existing AppHost methods, so they auto-propagate to the backend via
  commandManifest()); "hide all panels / show news / flip to finance" now
  visibly re-aligns the dashboard with an action-log receipt.
- MCP data-tool traces render inline as compact tables, not raw JSON.

Transport is still request/response (no SSE server yet), so slow zen5 shows a
live pulsing-Zen "thinking" state rather than a dead spinner; the SSE seam stays
in analyst-transport.ts for when a streaming cap lands.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:17:43 -07:00
hanzo-dev 9119ebbf3c fix(world/iam): sign-out clears local session first, bounds the IdP call (2.5s) so it never hangs; RP-logout with id_token_hint ends SSO
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-10 14:05:47 -07:00
hanzo-dev 966882d9b7 fix(world): uncapped panel height resize (no max) + silence CartoDB CJK glyph 403 spam
1. PANEL HEIGHT CAP (CTO bug): grid-mode height snapped to the span ladder and
   hard-capped at span-4 (~800px); dragging the bottom edge further did nothing.
   Now height snaps to the fine tier ladder [120,200,400,600,800] up to its top,
   then grows CONTINUOUSLY with NO upper cap — each extra ~200px adds a whole row
   (spanForHeight, shared by the bottom-edge + corner handles). Spans beyond the
   CSS ladder (>4) are driven inline (grid-row + min-height; dataset.span is the
   source of truth via currentSpan), so a panel can be made arbitrarily tall.
   Free mode was already pixel-unbounded. MIN (120px) kept, no MAX. resetHeight
   clears the inline geometry too.
   Proof: grid 200→1628px, free 200→1700px — both persist across reload.

2. CJK glyph 403 spam: CartoDB's CJK glyph fonts (HanWang / NanumBarun / Noto
   Sans CJK…) 403 on CORS whenever a CJK label is in view, flooding the console.
   A map transformRequest repoints just those glyph fetches at the CORS-open Latin
   font (Montserrat), so CJK labels fall back cleanly with zero failed requests.
   Latin fonts (incl. "Noto Sans Regular") are untouched.
   Proof (panned to East Asia): 0 font 403s; Montserrat fallback in use.

Gate: typecheck + build clean; e2e panel-drag 13/13; responsive 17/17.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:49:01 -07:00
hanzo-dev 56c8f3770a feat(world/menu): workspace context menu on empty background — always a way back
Right-clicking the empty workspace (grid / content background, no panel or map
under the cursor) now opens a workspace menu: Grid / Free / Immersive layout, Add
widget, Reset layout. So even if the user hides every panel and the map, a
right-click always offers a way to reset and restore. Native menu still shows in
text inputs and over the chrome (header, dock, modals); panel/map right-clicks keep
their existing specific menus.

Proof: synthetic contextmenu on .main-content → menu with
[Grid layout, Free layout, Immersive layout, Add widget, Reset layout].

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:20:58 -07:00
hanzo-dev 897a9e1280 feat(world/layout): window-manager snap-zone tiling — drag to halves/quadrants with live preview
Free-mode drag now tiles like macOS / FancyZones: dragging a panel near an EDGE
previews a HALF, near a CORNER previews a QUADRANT, the centre stays free
placement. A monochrome translucent target rectangle fades in during the drag and
glides between zones as the cursor moves; on drop the panel resizes + positions to
the zone (with a clean gutter). Escape cancels (restores the pre-drag position);
reduced-motion drops the glide. One mechanism: the zone's viewport rect converts to
the panel's grid-relative free geometry on commit — no new coordinate system.

Zones are computed against the visible workspace (grid ∩ scroll viewport), so
tiling always fills the on-screen area regardless of scroll. This is the "toss
panels around like a window manager" experience over the map/globe.

Proof: drag → left-half preview 720×810 → drop → panel = left half (708×798);
top-right corner → quadrant 720×405 → panel = top-right quarter. Screenshots of the
mid-drag highlight captured.

Follow-ups (noted, not in this commit): center/left/right THIRDS, and split-view
when dragging over another panel's half.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev c953754197 fix(world/map): never black out without a Mapbox token — keyless deck raster basemap
mapbox-gl v3 refuses to paint ANY basemap without a token (the canvas goes fully
black even though the third-party CartoDB tiles fetch 200), which the CTO hit on
localhost and any deploy missing VITE_MAPBOX_TOKEN. Fix: when no token is present,
drape a keyless CartoDB raster basemap rendered by DECK (token-independent, same as
the data dots and the native ESRI globe) at the bottom of the overlaid deck stack —
theme-aware (dark_all / light_all), works in both the flat MapView and the globe
_GlobeView. Data layers draw on top. The token path is untouched: with a token the
mapbox vector basemap stands and no deck basemap is added.

Proof (VITE_MAPBOX_TOKEN unset): 2D = full CartoDB dark basemap w/ coastlines +
labels (60% non-black, was 0), 3D = basemap draped on the globe. Never a black void.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 490acbb1c5 fix(world/panels): header cleanup — count clear of ✕ + hidden at 0, retire LIVE chip, label the summarize button
- Item count moves inline after the title (left), so it never collides with the
  hover-✕ (top-right); hidden entirely until count>0 (a loading "0" read as broken).
- Retire the "LIVE" text chip: it's all live by default and the static label
  misled while a feed was loading/empty. Live state now shows only a subtle pulsing
  dot (reduced-motion: static); cached/unavailable keep their informative labels.
- Summarize: the cryptic  becomes a labelled " Summarize" button (monochrome,
  sentence case, aria-label), reserved clear of the ✕. Funded-key path unchanged.

Playwright: 0 LIVE text chips, 0 zero/empty counts shown, summarize reads
" Summarize".

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev da5be4b051 feat(world/layout): one Grid/Free/Immersive mode dropdown; decomplect immersive (map bg) from snap; news-tag WCAG fix
- Dock: replace the Immersive button + Grid/Free segmented control with a single
  Layout dropdown (Grid / Free / Immersive). One control, one way.
- Decomplect background from snap: immersive is a BACKGROUND mode (map fills the
  viewport, panels float on top); grid/free is the SNAP behaviour. Immersive
  defaults to freestyle floating (free snap) over the FULL viewport — the old
  narrow right-docked column is gone (it collapsed once free-mode made the cards
  absolute). Map stays fixed full-viewport via !important over the inline free geom.
- Background Map/Video + collapse controls show only while immersive is active.
- NewsPanel category tags: drop inline color (threat colour as text failed WCAG);
  text now var(--text), threat colour carried by border + translucent bg only.

Verified: dropdown drives grid→free→immersive→grid (map fixed full-viewport in
immersive, cards float visibly over it); 17/17 P0 checks still green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 678bd10d0d fix(world/layout): kill map/panel overlap, fill full width, retire mobile modal, map edge-to-edge + maplogo flag
Layered on the integrated layout engine (194e6657). The grid's auto-rows
collapsed to their 120px floor, so every span-2 panel (min-height 400) — the
map included — overflowed ~150px onto the row below and OVERLAPPED it
(43 overlaps across 41 panels). Root fix: definite 200px row unit
(grid-auto-rows: var(--panel-row,200px)) so span-N reserves exactly its height;
content that exceeds a panel scrolls inside it, never onto neighbours.

- Full width: auto-fill → auto-fit so real columns always stretch to consume
  100% width, no dead right-hand gutter at any size (verified 9 widths).
- Map edge-to-edge: the 14px drag-grip is now an absolute overlay, not in flow,
  so the canvas fills the panel top (black top strip 15px → 1px).
- ?maplogo=0 (+ localStorage) hides the basemap wordmark; a compact "ⓘ"
  AttributionControl is always mounted so basemap ToS still holds.
- Mobile-view warning modal retired (layout is responsive to 390px).
- span-0 stays a genuine 120px short tier (top-aligned in its 200px track).

Proof: 17/17 playwright checks (full/crypto/saas × 1920–390): hOverflow=0,
0 overlaps, gutter=grid-padding only, no modal. Live-news video grows to full
width (353→1430px, 16:9, no cap). Dark/Sat/Terrain inline with 2D/3D in the dock.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev a338fc440b feat(world): panel layout engine integrated onto origin/main + free live-news video
Rebuilds the free-form + snap-to-grid layout engine on top of the advanced
origin/main (footer dock + responsive work) and unifies the two grid-cell
mechanisms into one:

- grid-config drives the dock's `--panel-col-min` (default 160px, range 140–360,
  matching the base .panels-grid rule + the dock slider) — the duplicate
  `--grid-cell` override is gone. One variable, one way. Default 160 = the grid
  is byte-identical until the slider moves it, so no fixed-span panel shrinks.
- window.worldGrid exposes the engine to the footer dock's Grid⇄Free toggle +
  cell-size slider (App.gridApi delegates to it).
- main.css layout-engine section is append-only after the dock CSS; only NEW
  selectors (corner grip, snap overlay, free-mode absolute layout, mobile guard).

Live News (video) now resizes freely with the video filling at any size (CTO
ask): the panel carries id="live-news" so the intended `#live-news .panel-content`
fill CSS (padding:0 + flex-column) finally applies — the 16:9 `.live-news-player`
fills the container edge-to-edge (was 16px short under generic padding). With the
new right-edge + corner grips and the uncapped column snap (span up to full
width), Live News drags to 2-3 cols or full-width-top in grid mode and to any
pixel size in free mode; the width-driven video fills at every size.

Tests (13, all green; typecheck + build pass): 4 original drag/resize + 7
layout-engine (grid snap, corner resize, overlay, cell re-snap, free drag+resize
persist across reload, map participates, toggle) + 2 real-app live-news (grid
default→3col→full-width fill setup + resize, free arbitrary-size). Screenshot:
e2e/layout-shots/live-news-fullwidth.png (full-width-top, large video area).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 6fdfc0d3a6 feat(world): instant news via warm feed cache + queryable ingested-data lake
Make news/feeds INSTANT and give world one place to query everything.

Feed hot cache (two-tier, ONE way to read a feed body):
- L1 per-pod in-mem mirror (sub-ms hot reads) + L2 hanzo-kv (shared across
  pods, survives restart). A body warmed by any pod is instantly served by
  every pod; a restarted pod reads the still-warm shared cache instead of
  cold-starting. Graceful degrade to L1-only when hanzo-kv is unreachable.
- Background warmer: on boot + every ~5m (jittered) fetches every warm feed,
  write-throughs the body, and folds items into the lake. rss-proxy and
  feeds-batch serve from the warm cache and never block on upstream while any
  cached copy exists (stale-while-revalidate; revalidation runs in background).
- Demand-driven warm set (fleet-wide via hanzo-kv) + a curated seed of the
  crypto / financial-regulation / crypto-news feeds so a brand-new pod warms
  THOSE first. Cross-pod fetch dedupe via shared copy freshness.

Ingested-data lake (embedded SQLite, modernc.org/sqlite — pure Go, FTS5):
- One normalized `items` table (id, kind, source, ts, title, text, tickers,
  country, geo, payload) fed by feed news AND world-model observations, so
  everything is searchable/countable together.
- GET /v1/world/search (FTS5 bm25 ranking; filters kind/since/country/ticker)
  and GET /v1/world/analytics (totals, by kind/source, top tickers). Rolling
  retention window with an hourly prune job. Write-behind ingest (never on the
  request path).

Per-identity settings (same SQLite DB, own table):
- GET/PUT /v1/world/settings, bearer-gated, keyed by (org, user_sub, project)
  from IAM userinfo, so a signed-in dashboard syncs across devices. Anonymous
  → 401 (client keeps localStorage).

Everything degrades cleanly (never-5xx): no hanzo-kv → per-pod cache; no SQLite
→ empty search/analytics + settings say not-stored. Binary stays CGO-free
(CGO_ENABLED=0). go directive + Dockerfile bumped to 1.25 for modernc.

Latency (local, warm): feeds-batch warm 0.5-1.9ms vs 154ms cold; search 0.8-1.5ms.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:51:22 -07:00
hanzo-dev 1043182671 fix(world/analyst): enforce paid usage — chat needs a user bearer, never the funded key
The analyst chat is paid usage: metered to the signed-in user's org via their
own IAM bearer. handleAnalyst used s.ai.bearerFor(r), which falls back to the
funded service key (HANZO_AI_KEY) when no user token is present — so an anonymous
chat silently ran on the shared key (proven live: an unauthenticated POST to
/v1/world/analyst returns a real 200 answer). That made the backend "Sign in to
chat" gate dead code and meant paid usage was never actually enforced or metered.

Use userBearer(r) for the chat path (user-only). The funded key now backs ONLY the
anonymous auto-insight endpoints (summarize/classify/country-intel), exactly the
intended pro model. The sign-in gate is now live server-side, and the frontend
gate gains defense-in-depth. Roster listing (handleModels) stays on bearerFor —
it is read-only, not metered inference.

Test: TestAnalystChatRequiresUserBearer pins the contract (funded key present,
anonymous chat still refused with the sign-in skip). TestAnalystDataToolLoop now
drives the chat as a signed-in caller (Authorization header), the real paid path.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:25:14 -07:00
hanzo-dev 68a28f2eda feat(world/map): dark/satellite/terrain basemap styles on the native globe
The native deck GlobeView now honors the 2D basemap switcher (dark | satellite |
terrain), reading the SAME localStorage key (hanzo-world-basemap-style) and
reacting live to a basemap-style-changed event DeckGLMap fires — no reload.

- dark: the existing monochrome vercel-black sphere.
- satellite: keyless ESRI World Imagery draped via a deck TileLayer/BitmapLayer,
  back-faces culled so the far hemisphere never bleeds through; the near-black
  ocean sphere underneath supplies depth + darkens the imagery so data dots stay
  legible. Thin light country borders overlaid for intel orientation.
- terrain: keyless ESRI World Physical Map (natural relief).

Data layers (dots/arcs/borders) render ON TOP and keep picking; ocean sphere is
tinted dark slate-blue on bright styles so the Web-Mercator polar gap blends.
Since native uses keyless ESRI, the switcher's sat/terrain buttons no longer need
a Mapbox token when native is the 3D renderer (default), and setBasemapStyle
notifies the globe before the tokenless mapbox setStyle so a missing token can't
block the switch. Adds a style-switch e2e (single-context preserved, data still
picks) + harness hooks.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:20:44 -07:00
hanzo-dev a8af187b99 fix(world): split header/tooling into a bottom dock; kill 1366 overflow; layers collapsed; globe fit
Live 1366×768 had 167px of horizontal page overflow (documentElement.scrollWidth
1533 vs innerWidth 1366): the header's action row (Copy Link / theme / Immersive /
PANELS / SOURCES + variant tabs) could not shrink and pushed the account CTA off the
right edge. The layers panel also rendered OPEN by default, floating over the map and
the panels beneath it.

CTO-approved restructure: the header is now IDENTITY + NAV only (Hanzo mark, variant
tabs, LIVE, search, theme, account). All operational controls move to a new slim,
monochrome, collapsible BOTTOM TOOLBAR DOCK:

- Map's 2D/3D toggle, basemap switcher (Dark/Sat/Terrain) and time-range pills mount
  into the dock via a new optional DeckGLMap `controlsHost` (defaults to the map
  container, so the e2e harnesses that drive the overlay controls are unchanged).
- Region select, Layers toggle, Immersive controls, Panels, Sources, Copy link and
  Fullscreen relocate into the dock (same ids → existing handlers still bind).
- New controls: layout-mode (Grid ⇄ Free) + widget-size slider, wired through a
  feature-checked adapter that prefers window.worldGrid (world-layoutengine) and
  otherwise applies a self-contained fallback (CSS column-floor var + body data attr);
  and "+ Add widget", a searchable palette over the panel registry that shows a panel
  via the one setPanelEnabled() path — works in both grid and immersive layouts.

Fixes:
1. Horizontal overflow — scrollWidth === innerWidth at 1920/1440/1366/1024/768/390.
   Header groups are min-width:0 shrinkable; variant tabs collapse to icons then scroll
   INSIDE their own strip; the dock scrolls horizontally so the PAGE never scrolls.
2. Layers panel — hidden by default (`.open` reveals it via the dock's Layers button),
   anchored top-left WITHIN the map box, height-capped with internal scroll, draggable.
3. Basemap switcher — verified switching Dark/Sat/Terrain in 2D (tiles load, deck data
   layers survive setStyle, choice persists in localStorage). The token IS in the
   runtime bundle. In native-3D-globe mode the deck GlobeView renders its own sphere,
   so sat/terrain don't retexture it there — a pre-existing renderer limitation, noted.
4. Globe fit — the deck globe canvas fills the map container exactly (measured
   1356×384 == map box; no letterboxing); CSS forces the wrappers/canvas to 100%.

Gates: typecheck ✓, vite build ✓, e2e globe-overlay 1/1 ✓, panel-drag 4/4 ✓.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:01:09 -07:00
hanzo-dev 7ff99bc99d fix(world/cloud): unblock saas Chains/Overview + hide API-key names from public
P1: on the Cloud (saas) dashboard the Chains and Cloud Overview widgets sat on a
loading spinner for 30s+. Root cause: /v1/world/cloud/chain-nodes took ~24s on a
cold cache because unreachable L1 RPCs (zoo/hanzo) hung until the request-wide
deadline, and CloudOverviewPanel compounded it by awaiting getChainNodes() in the
same Promise.all as the (instant) pulse. Crypto only looked fine because its cache
was already warm.

Fixes:
- Backend: bound each chain's telemetry fetch with a 4s perChainTimeout so one
  dead L1 can't stall the whole response. Cold chain-nodes 24.0s -> 4.0s (verified
  on fresh servers); live chains unaffected, dead chains honestly live:false.
- CloudOverviewPanel: primary render now depends only on pulse+models (both
  instant); the chain-scale tiles fetch independently and fold in when ready —
  the slow fetch can never gate first paint again. Reordered the two real
  chain-scale tiles ("chains live", "total block height") ahead of the
  demo-modeled nodes/GPUs/regions/uptime so they land in the panel's visible rows.

Polish — one honest-copy choke point (runtime.canConfigureKeys, used by
Panel.showConfigError and the FRED site): public web viewers now get the quiet
"No data available" line instead of raw "FINNHUB_API_KEY / NASA_FIRMS_API_KEY /
FRED_API_KEY not configured — add in Settings"; the specific, actionable copy
stays only for the desktop app / dev build where keys can actually be set.

Verified live via playwright (?variant=saas): Chains renders ETH 25,501,617 +
BTC 957,412 (count 5, LIVE); Cloud Overview populated incl. chain tiles (2-3
live / 26.46M total height); ?variant=full body scan for /_API_KEY/ returns null
across 91 panels; zero console errors. Gates: typecheck + build + go build/vet +
go test ./internal/world/ (17s) all green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 03:44:23 -07:00
hanzo-dev af45f23a81 feat(world): per-component right-click menus + world MCP data tools in the analyst loop
Context menus (panel-menu.ts): extend the single installed contextmenu
listener into a per-context system. Layers, most-specific first: text entry
→ native menu; component items via a data-ctx-* convention (news → Open/Copy
link + Copy headline; market/FX/commodity/yield rows → Copy value/symbol);
the map surface → Copy coordinates / Fly here / 2D-3D toggle through a narrow
MapContextPort (registered by MapContainer); then the panel baseline (Hide,
Move to top, five Size presets, Reset layout). A stationary right-click on the
map opens the menu; a rotate-drag still bails so maplibre keeps the gesture.
Size presets reuse Panel's setSpanClass/savePanelSpan; Move to top routes
through App.movePanelInGrid via a new panel-move-request event — one way each.
Panels only annotate their DOM (quoteRow, MarketPanel, NewsPanel,
CustomFeedPanel); DeckGLMap gains a read-only screenToLngLat.

Analyst data tools (handlers_analyst.go + mcp/): bind the 7 world MCP tools as
DATA tools in the analyst loop. mcp.CallTool executes them IN-PROCESS via the
same dispatcher tools/call uses (one path, no self-HTTP); mcp.ToolSpecs feeds a
derived tool contract into the system prompt. The handler runs an agentic loop
(≤3 tool rounds) — the model requests tools in a "tools" array, results are fed
back, then it answers citing live data. Tool traces ride the response and render
as collapsed "🔧 tool(...)" lines in the chat (AnalystChat). App-commands stay
auth-gated (unchanged); data tools dispatch anonymously.

Gates: go build+test (incl. new agentic-loop + parse/contract tests), tsc,
vite build, playwright (news/map menus + stubbed analyst tool round-trip) — all
green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 02:58:59 -07:00
hanzo-dev 5d8b0d918a feat(world): immersive layout, basemap style switcher, layer-panel drag, bigger widget defaults, calmer globe spin
Layout/style batch:

1. Bigger default widgets — the 160px column FLOOR stays (fine map column-resize),
   but auto-placed panels now default to `grid-column: span 2` (~324px) instead of a
   160px sliver. Map keeps full-width; height tiers untouched (still shrink to span-0).

2. Immersive mode (?layout=immersive, persisted) — body.immersive promotes the map to
   a fixed full-viewport background; panels float above as a translucent (#0a0a0acc +
   backdrop-blur) column docked right, still drag/resize/close-able. One background
   slot = map | video: the video option mounts the muted live-news YouTube embed as a
   fixed BG. Collapse-to-edge affordance slides the column off so the globe breathes.
   Escape / the header toggle returns to the grid; state survives reload. New service
   src/services/immersive.ts owns the state; App only wires header chrome to it.

3. Layers selector UX — the collapsed layer list is now a draggable panel (header grip,
   position persisted) with pointer drag-and-drop reordering of its entries (order
   persisted, purely cosmetic). Contained in DeckGLMap.

4. Calmer globe — AUTO_ROTATE_DEG_PER_SEC halved 4 -> 2 in BOTH renderers (GlobeNative,
   the active 3D globe, and the mapbox DeckGLMap globe — kept identical by design).

5. Nicer basemaps — a small dark/satellite/terrain switcher under the 2D/3D toggle
   (2D mapbox renderer). One setStyle path re-adds fog/atmosphere/projection + drapes
   terrain over the Mapbox DEM (exaggeration 1.4) on style.load; deck overlay survives.
   satellite/terrain require VITE_MAPBOX_TOKEN — gated (disabled) without one so the map
   never breaks. Data dots gain a thin dark halo over bright rasters (deck-canvas only).

Gates: typecheck + build green; e2e globe-overlay + panel-drag green; globe.spec
auto-rotate band updated to the new 2°/s. Deliverable screenshots under e2e/screens/.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 02:48:20 -07:00
hanzo-dev 5a63c0ed8f fix(world/cloud): live status-page path + native status board (no iframe)
Follow-up: the merged status-page proxy assumed the source repo's Gatus route
(/v1/status/endpoints/statuses), but the LIVE status.hanzo.ai deployment serves
the upstream-standard /api/v1/endpoints/statuses — so the endpoint returned
available:false/total:0. Probe both routes and use whichever answers with a
non-empty board (correct against either deployment). Live now yields 25 services,
24 up, 1 incident (AI/Chat).

Visual: replace the HanzoStatusPanel iframe embed of status.hanzo.ai with a
NATIVE monochrome board fed by the same /v1/world/cloud/status-page summary —
services grouped by group (AI/Apps/Commerce/…), per-service up/down dot + response
time, active-incidents section on top. One data source, one style, no iframe
sizing/scroll jank. Extract the shared "since" formatter to cloud-format.fmtAgo
(used by both status panels — no duplication).

Gates: typecheck + build + go build/vet + go test ./internal/world/ all green;
live JSON verified end-to-end through the running server.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:43:14 -07:00
hanzo-dev 3c6742fe86 fix(world/pwa): serve index.html network-first so multi-deploy tabs never flash a stale build
With several releases a day the SW precache served an old index.html that pointed
at hashed bundles a newer deploy had already replaced: the app flashed the old
build, then went black as old and new chunks mixed. Aligns SW behaviour with the
deploy caching policy (index.html no-store, /assets/* immutable).

vite.config PWA (generateSW):
- navigateFallback: null — disable the plugin's default 'index.html' fallback. A
  precache-served fallback is cache-first and re-introduces the stale shell; it was
  also generating createHandlerBoundToURL("index.html") against a non-precached URL.
- globIgnores += index.html, settings.html — keep the HTML shell out of the
  precache so navigations resolve to the network-first route, not a stale copy.
  Only immutable, content-hashed assets stay precached, so an old-HTML/new-chunk
  mix is impossible. offline.html remains the sole offline shell.
- runtimeCaching: NetworkOnly passthrough for /v1/world/* and /.well-known/*
  registered before the navigate handler, so the data plane and discovery docs are
  never intercepted or cached; the navigate handler stays NetworkFirst.
- skipWaiting + clientsClaim + registerType autoUpdate retained: a new SW activates
  immediately and (via workbox-window) reloads the tab exactly once on a real
  update — never on first install, never in a loop.

Client (src/services/sw-update.ts, wired from main.ts):
- Extract SW lifecycle into one module. Register via vite-plugin-pwa autoUpdate and
  call registration.update() on visibilitychange->visible (plus an hourly backstop),
  so a long-lived backgrounded tab learns about a deploy the instant it is looked at
  again — before the user triggers a lazy chunk import the server may have purged.

Verified: tsc + vite build clean; dist/sw.js contains self.skipWaiting()+clientsClaim(),
no NavigationRoute, index.html/settings.html absent from precache, /v1/world &
/.well-known are NetworkOnly. Playwright upgrade sim (Go server on :8193, build A ->
swap shell -> build B): tab lands on build B via network-first HTML with no
hard-refresh and no chunk-load errors (9/9).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:34:04 -07:00
hanzo-dev bda2b05852 fix(world): globe marker back-hemisphere occlusion + responsive 16:9 video + mobile legend
- DeckGLMap: cull far-side point markers by alpha on the 3D globe (overlaid
  deck.gl shares no depth buffer with mapbox's globe, so back-hemisphere dots
  projected through the front and looked like they floated off the sphere).
  One DRY helper clones Scatterplot/Icon layers with camera-facing color
  accessors, re-evaluated each buildLayers (move debounced ~10Hz).
- LiveNews video: width-driven 16:9 (flex:0 0 auto + aspect-ratio) so the embed
  scales with its container instead of being stretched by the flex parent.
- Mobile: keep the centered map legend inside the viewport (wrap + max-width).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:31:59 -07:00
hanzo-dev 4157889795 feat(world): fullscreen country application view with docked analyst sidebar
Clicking a country now opens a proper fullscreen application view instead of a
centered card overlay: intel content (instability index, brief, signals,
timeline, prediction markets, infrastructure) breathes across the canvas on the
left, with a docked AI analyst chat as a right sidebar.

- CountryBriefPage: fullscreen flex layout (header + cb-view = cb-main | resize
  handle | cb-analyst-sidebar). Docks the ONE AnalystChat component by
  composition — model picker + command registry intact — seeded with the country
  as the active region so the first question is grounded. Resizable divider,
  collapsible sidebar with a reopen pill; on mobile the sidebar is a bottom sheet
  (no horizontal scroll), intel leads by default.
- App: browser Back closes the view (opened via pushState), Forward re-opens it;
  ?country= deep link restores it. Escape/✕ still restore the dashboard. Passes
  the analyst host into the page. Replaced a fragile typeof-this type query with
  a stable class index access.
- main.css: append-only fullscreen layout + analyst sidebar styling (monochrome,
  Geist, sentence case), mobile bottom-sheet, print-flow restore.
- e2e/country-view.spec.ts: desktop fullscreen + docked chat + URL + Escape,
  collapse/reopen, mobile 390px bottom sheet, ?country=FR deep link. 4 passing.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:29:28 -07:00
hanzo-dev d77c6f7281 feat(world/map): native deck.gl GlobeView 3D renderer (single camera, single context)
Add GlobeNative — a pure deck.gl _GlobeView renderer for the 3D globe that
replaces the mapbox-gl globe + deck overlay (two cameras, two contexts). One
Deck, one canvas, one WebGL context: perfect data registration by construction,
proper far-side occlusion (depth-writing ocean sphere), monochrome vercel-black
basemap from the bundled /data/countries.geojson.

Data layers, tooltips and clicks are reused verbatim from DeckGLMap via an
additive asGlobeSource() bridge (no builder duplication; heatmap->scatter swap
comes free). Native is now the default 3D renderer; ?globe=mapbox is the escape
hatch. 2D stays mapbox mercator, untouched. Clean create/destroy on the 2D<->3D
toggle (no leaked contexts). Idle auto-rotate + fly-to + reduced-motion +
tab-hidden gates ported from the mapbox globe.

Adds e2e/globe-native.spec.ts (flag activation, single context, GlobeViewport,
pick-on-sphere) + a standalone harness.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:19:44 -07:00
hanzo-dev df2933f4fc style(world): header variant order — world, ai, crypto, finance, tech, cloud
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 22:16:58 -07:00
hanzo-dev 5ab54f9733 feat(world/cloud): real platform data in the Cloud dashboard
Expand the Cloud dashboard with live, honest platform telemetry across four
fronts. Every path keeps the layer's never-5xx + explicit-flag contracts.

1. Cloud Overview +2 real tiles: "chains live / N tracked" and summed "total
   block height", computed from the live chain-nodes feed. Omitted (not zeroed)
   when the feed is unavailable.

2. More chains: the chain catalog now carries three fetch strategies (luxfi/node,
   public EVM JSON-RPC, plain-integer height). Adds Hanzo (api/rpc.hanzo.network
   failover), Ethereum (eth_blockNumber/eth_chainId via ethereum-rpc.publicnode.com)
   and Bitcoin (blockchain.info height) — each SSRF-allowlisted, per-chain live
   flag, no invented heights. BlockchainPanel hides peers/chainId where not
   meaningful (public reference chains).

3. status.hanzo.ai integration: GET /v1/world/cloud/status-page proxies the
   Gatus board (/v1/status/endpoints/statuses), distilled to a per-service
   up/down board + active-incidents list (latest-by-timestamp, incident dated
   from last UNHEALTHY event). Surfaced in CloudServicesPanel as a public health
   section fused with the admin RED metrics. Honest available:false when down.

4. OpenSky OAuth2: when OPENSKY_CLIENT_ID/SECRET are present, fetch+cache a
   client-credentials bearer and attach it to the states/all proxy; anonymous
   path is byte-identical otherwise.

Tests: chain catalog + allowlist + hex parse, status-page summarize + live
degrade, OpenSky anonymous + invalid-cred fallback. All gates green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:09:23 -07:00
hanzo-dev bdb9244eb3 feat(world/mcp): MCP server (streamable-HTTP, JSON-RPC 2.0) at /v1/world/mcp
Clean-room MCP apps surface for Hanzo World — a thin, read-only projection
of the existing public /v1/world/* routes. Ideas-only from the upstream
study; no code copied.

- internal/world/mcp: JSON-RPC 2.0 over streamable HTTP. 7 tools
  (world_brief, country_instability, model_history, market_quotes,
  chain_status, traffic_map, feeds) each dispatched IN-PROCESS through the
  same world mux via an httptest recorder — one impl per data path, the
  model.Engine's unexported handlers reached through the mux composition
  point, zero edits to existing handlers.
- Two MCP apps (ui:// resources): world-brief + market-radar. resources/read
  returns a DATA-FREE static HTML shell (text/html;profile=mcp-app, strict
  CSP, textContent-only, no innerHTML) that receives data later via a
  tools/call over the host postMessage bridge (quota-safe two-phase).
- Discovery: root server.json (MCP registry manifest) + public/.well-known/
  mcp/server-card.json (tool + app inventory), both generated by
  cmd/gencard and digest-stable like the agent-skills index.
- feeds tool exposes a curated category enum (never raw URLs) over
  allowlisted RSS hosts — no SSRF surface.
- Anonymous public access; gate() attachment point noted per the
  internal/world/model gate() pattern for later pro-tier gating.

Tests: JSON-RPC initialize/tools.list/tools.call/resources round-trip over
live wiring, server-card + server.json drift guards, shell digest pins, and
a data-free/no-innerHTML shell-safety grep. Verified end-to-end against the
compiled binary with the official @modelcontextprotocol/inspector CLI.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:06:26 -07:00
hanzo-dev b1d8708d06 fix(world): kill map freeze on real hardware + finer grid sizing (2.9.34)
P0 perf — the CTO's Chromium froze on 2.9.33. The news-pulse (500ms) and
cloud-pulse (30fps) each re-ran buildLayers(), reconstructing ALL ~30 deck
layers with fresh accessor closures every tick — full-retina double-canvas fill
+ 2Hz/30Hz layer reconstruction + GC churn. Fixes:

- Animation-only update path: buildAnimationLayers() reuses the instances from the
  last full buildLayers() and swaps in ONLY the layers that animate (news/hotspot/
  protest pulse for the news clock; chainNodes/trafficArcs for the cloud clock).
  deck.gl's same-instance fast path (Layer._transferState: this===oldLayer → return)
  then skips the other ~28 — no alloc, no attribute upload. Measured (SwiftShader,
  cloud layers on): full rebuilds during a 5s pulse window 38 → 0; layers
  re-instanced per frame 48 → 2; long-task time 2825ms → ~0; effective 8fps → 31fps;
  heap drift 0% over 8s idle.
- News pulse 500ms → 2000ms, skips while paused/webgl-lost; pulse periods lengthened
  to ~8s so the 2s sampling still reads as a calm breathe (2s/318 aliased to a static
  ring).
- useDevicePixels capped at min(dpr, 2): a DPR-3 display no longer quadruples the
  second canvas fill; 2× keeps dots/text crisp.
- Cloud-pulse RAF now invalidates only the arc + chain-node layers (via the cloud
  clock) and still stops when its gate closes; chain-node dots memoized by source
  ref so the breathe is a radiusScale-only change.

Finer grid sizing (CTO: "tiny widgets, less constrained"):
- .panels-grid columns 280→160px (7 tracks @1280px, was ~4) and rows 200→120px.
- New .span-0 tiny tier (grid-row 1, 120px); height-resize snaps to the
  [120,200,400,600,800] tier ladder (attachPanelResize snapHeights) with minSpan 0.
- Map floor min-height 240px; participates in the same grid, width-resizes to any
  integer track (finer now that there are more tracks).
- Resize handles quiet by default, edge grips fade in on panel hover.

Verified: npm run typecheck && build && go build ./... && go test ./internal/world/;
e2e globe-overlay (P0 render) + panel-drag specs green; screenshots of the dense
grid, a 178×130 span-0 widget, and the map at ~third width in 3D.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:03:00 -07:00
hanzo-dev 42b8456ed4 feat(world/analyst): app-command registry, model dropdown, transport seam
PART 1 — one introspectable command registry + one dispatcher (decomplected):
- src/services/app-commands.ts is the single source of truth: AppHost capability
  port + 16 typed commands (name, JSON-schema params, human description, run()) +
  commandManifest() + the ONE dispatch(): gate (sign-in) → validate → execute →
  visible action log. Full control now covers panels (show/hide/move/resize),
  map (layers, 2D/3D, fly-to, region, time range), variant, theme, search,
  reset layout, custom feeds, and org switch — each driving existing App APIs
  through the port (no reaching into internals).
- analyst-actions.ts reduced to a type shim (AnalystHost=AppHost). AnalystChat
  dispatches through the registry and renders per-command ✓/✗ entries.
- Go tool contract DERIVES from the registry: the client sends its manifest with
  every request; handlers_analyst.go builds the action-contract prompt from it
  (renderCommandContract) and passes actions through generically, gated by the
  manifest's type set — the backend no longer duplicates the schema. Embedded
  mirror data/analyst_commands.json (generated by scripts/gen-analyst-commands.mjs,
  drift-guarded by TestAnalystCommandManifest) covers callers that omit a manifest.

Model dropdown + backend plane (CTO directive):
- Chat header carries a monochrome model select (Zen family first, then upstream
  /v1/models + agents); choice persists and rides every request. New same-origin
  /v1/world/models proxy; model passes through to inference (chatMessagesModel).
  "agent:<ref>" ids route to the agents run plane (invoke an agent in-chat).

P0 — errors always visible: the chat never shows a blank. An empty/degraded/errored
backend reply (e.g. prod's aud=hanzo-world 401) now surfaces the reason inline.

PART 2 — transport seam: src/services/analyst-transport.ts isolates the wire; HTTP
is the real impl, registerTransport() is the ZAP seam. Verdict: real browser ZAP
(@zap-proto/web) exists and is production-wired in hanzo/platform, but world has no
ZAP dep and no Go WS-ZAP server, and ZAP has no streaming primitive today — so HTTP
stays default, ZAP wiring documented in-file (no fantasy transport).

Gates: tsc + vite build + go build + go test ./internal/world/ all pass; Playwright
(e2e/analyst-command.spec.ts) proves signed-out sign-in prompt and a signed-in
command round-trip that visibly moves a panel + logs the action (screens/).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:00:13 -07:00
fe265f5fb7 fix(world/map): blend basemap edge, expand layers panel, style cloud nodes, fix e2e selectors (#6)
Four map fixes on the DeckGL/mapbox-gl basemap, dark aesthetic preserved:

1. Edge seam — the CartoDB `background` layer ships a near-black-grey fill
   (#0e0e0e) a shade lighter than the app shell, so the map canvas showed a
   visible seam against --map-bg (#020a08). applyAtmosphere() now repaints the
   basemap `background` layer to the themed --map-bg on load and after every
   setStyle, so the ocean/void blends into the container.

2. Layers panel collapsed — the bottom-left "Layers" control shipped with the
   toggle-list `collapsed` (display:none) and a ▶ caret by default. Now
   default-expanded (▼), showing the legend/guide without a click.

3. Bare white cloud markers — chain-validator dots rendered near-white
   [240,240,240] and BYO-GPU rings pure white [255,255,255], reading as
   unstyled loading placeholders. Restyled to the on-brand cloud palette:
   live validators cyan [0,200,255], online GPU rings teal [0,255,200], both
   dimming to slate when offline.

4. Stale e2e selectors — the maplibre→mapbox-gl migration left `.maplibregl-*`
   selectors in globe.spec.ts (drag-gate assertion) and map-harness.ts
   (deterministic control-hide CSS); updated to `.mapboxgl-*`.

Drag-pan verified working under the exact prod build + prod CSP (dragPan
enabled, viewport moves in 2D/3D; the deck overlay canvas is pointer-events:none
so events reach the map) — the live "can't drag" is a stale deploy this rolls
out.

Verified: prod build green; all four fixes asserted headlessly under the prod
CSP with live demo cloud data (bgPaint==--map-bg, panel expanded, GPU/chain
layer colors non-white, drag moves the viewport).

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 21:59:33 -07:00
hanzo-dev c96483de56 feat(world/map): reduced-motion-aware 2D<->3D morph (item 8)
The 3D-entry camera glide now honors prefers-reduced-motion: it snaps instantly
(jumpTo) instead of the ~1.1s easeTo flourish (bumped from 900ms). The overlaid
deck layers already reproject through the whole transition (proven by the globe
spec's 2D<->3D round-trips), so the morph carries the data with it. mapbox handles
the globe<->mercator projection morph itself on exit.
2026-07-09 17:32:34 -07:00
hanzo-dev 59a6d178db feat(world/map): chromeless map — slim hover drag-grip + corner timestamp (item 6)
Kill the 'Global Situation' title + centred UTC header bar that ate a strip off the
top of the map. The map's .panel-header becomes a slim (14px) grip that is
invisible until you hover the map, then shows a small drag pill — it is still the
exact element panel-drag grabs (mouse + touch), so reorder/drag is unchanged, just
chromeless. The UTC clock keeps its #headerClock id (zero JS change) but moves into
a small, halo'd bottom-right corner overlay that stays legible over bright
basemaps. Map controls (time pills, 2D/3D, zoom, layers, legend) are children of
.map-container and are untouched. Removed the now-dead .header-clock rule.

Verified live: no title bar, grip == .panel-header (14px), UTC in the corner, drag
still reorders and the full-width map stays anchored first.
2026-07-09 17:30:11 -07:00
hanzo-dev 175a46d631 chore(world): drop redundant variant-in-URL plumbing after rebase
origin/main independently added 'keep variant in URL' to buildMapUrl (via the
SITE_VARIANT const). Remove the parallel state.variant param this branch had added
for the same purpose so there is exactly one mechanism; setSiteVariant still
overrides ?variant for the switch, and getShareUrl no longer needs to pass it.
2026-07-09 17:22:12 -07:00
hanzo-dev 4ab870660a perf(world/map): throttle the map render loop when the tab is hidden or idle
CPU profile flagged residual main-thread saturation on saas/crypto entirely in the
map render loop: the 500ms news-pulse setInterval doing a 2Hz FULL deck-layer
rebuild, the idle globe-spin RAF, and cloud-poll re-renders — all kept running when
the tab was backgrounded or the user had gone idle. The visibilitychange + 2-min
idle handlers only toggled a CSS class; they never paused the JS/GPU render loop.

Route both signals through one chokepoint, syncMapRenderActive(), which calls the
existing DeckGLMap.setRenderPaused(document.hidden || isIdle). setRenderPaused
already stops the pulse interval, the auto-rotate RAF and the cloud pulse, and
drains a pending render on resume — so a hidden/idle tab now does zero map render
work and picks straight back up on focus/activity. Verified: renderPaused flips
true on hidden (pulse+spin cleared) and false on visible.

Note (follow-up): foreground-active cost is the pulse's full buildLayers re-running
every accessor over all data because each rebuild makes fresh accessor closures.
Only cables/pipelines are instance-cached today; extending that cache (keyed by
theme + zoom-bucket + highlight-signature) to the ~15 static config-driven layers
is the scoped reuse-layer-instances refactor.
2026-07-09 17:20:07 -07:00
hanzo-dev fb84b56a31 feat(world): variant switch preserves the exact live view; URL self-describing (item 10)
SITE_VARIANT is an import-time const woven through ~30 modules, so a true
zero-reload in-place variant swap is out of scope here (report notes the refactor).
The pragmatic switch makes the reload feel like only the panels changed:

- One switch path (header tabs + analyst set_variant both call setSiteVariant),
  replacing the divergent localStorage+reload() the tabs used. It FLUSHES the exact
  live map view (camera + 2D/3D mode + layers + time range) into the URL
  synchronously — closing the 250ms URL-sync debounce gap — and stamps ?variant.
- getShareUrl/buildMapUrl now carry the active non-default variant, so every URL
  sync keeps it (WORLD omitted as the clean default) and shared links reopen the
  same view.
- applyInitialUrlState restore was the missing half: it treated ANY view (incl. the
  'global' free-camera default that share/switch URLs always emit) as a region
  preset and reset the camera, so lat/lon/zoom never restored. Now only real region
  presets (america/mena/eu/…) own the camera; 'global' honors the exact URL camera
  in one move.
- DeckGLMap keeps state.zoom in step with direct wheel/pinch zoom on moveend, so
  the flushed URL captures the LIVE zoom, not a stale one.
- main.ts sets the DEV/e2e window.__app hook before init() so it never waits on the
  initial data load.

Verified live (127.0.0.1:8195): switching WORLD→AI in 3D at a panned camera reloads
with variant=ai, mode=3d, the globe at the identical centre/zoom, panels swapped.
2026-07-09 17:20:07 -07:00
hanzo-dev 66c55d4f78 feat(world): Zen-brand the AI analyst — ensō dock mark + valid zen5 default (item 5)
Replace the generic lucide 'sparkles' on the floating analyst dock (fab + title)
with a house Zen ensō: a single open brush-ring that inherits currentColor and
the shared icon wrapper's round caps (brush tips), so it reads as one of our
icons. No zen asset existed in the repo; this adds one inline SVG path.

Fix the AI model default: the bare "zen" was NOT a servable id
(api.hanzo.ai/v1/models lists zen5/zen5-flash/zen3-* etc., no plain "zen"), so
every AI endpoint — the analyst included — would 4xx and silently fall back.
Default to zen5 (the flagship general Zen chat model); HANZO_AI_MODEL still
overrides per-deploy.
2026-07-09 17:20:07 -07:00
hanzo-dev 7cb4cc8c6c fix(world/iam): web OIDC redirect always uses site origin; desktop uses loopback (P0-4)
REDIRECT_URI was computed once at import as location.origin with no runtime
branch. On web that is already correct (world.hanzo.ai + every *.hanzo.app fork
stay self-consistent), but the Tauri desktop shell runs on an app-scheme origin
(tauri://localhost) that no OIDC provider can redirect back to — hence the
127.0.0.1:5219 confusion. Make the branch explicit and evaluate it per call:
web (isDesktopRuntime() false) ALWAYS uses ${location.origin}/auth/callback;
desktop uses the fixed loopback http://127.0.0.1:5219/auth/callback that the app
captures (registered in the hanzo-world client allowlist, added IAM-side).

Verified live: on http://127.0.0.1:8195 the authorize redirect_uri resolves to
http://127.0.0.1:8195/auth/callback (site origin), never a hardcoded host.
2026-07-09 17:20:07 -07:00
hanzo-dev 103e40ea6f fix(world): heal full-width-map layout anchor + one header stacking scale (P0-2, P0-3)
The CTO's broken page had two braided causes; both are the full-width map fighting
the grid, fixed at their chokepoints:

P0-2 layout void — a full-width map MUST be the first grid child, else CSS grid
wraps a stray panel into row 1 (a thin sliver top-left) and shoves the map into a
black void under the header. The only way a panel lands ahead of the map is live
drag (restore already filters the map out), so guard the drag chokepoint:
panel-drag's referenceNodeAt refuses to insert before a leading-anchor panel
(redirects the ref to its nextSibling). App owns the invariant via healMapAnchor()
— idempotent, called on load, drag-commit, width-resize and analyst move — which
also heals any stale saved state (reset-layout already clears it). The critical
banner offset is corrected to the real 46px header height and now tracks the
banner's measured height (--critical-banner-h) instead of a guessed 50px that left
a seam. Dead .map-section.pinned / .map-pin-btn CSS (nothing toggles it) removed.
main.ts exposes window.__app in DEV/e2e to drive the invariant in tests.

P0-3 dropdowns under the map — .header carries a backdrop-filter that silently
creates a z-index:auto stacking context, while .panels-grid is z-index:1, so the
whole grid+map painted OVER the header and its dropdowns. Give .header
position:relative; z-index:200 — one documented stacking scale (modals 1000+ >
header 200 > grid/map 1), no per-control arms race. Verified: export/status/
playback dropdowns resolve above the full-width map via elementFromPoint.
2026-07-09 17:20:07 -07:00
hanzo-dev f130a7f95a fix(world/map): render deck.gl overlays on the mapbox-gl v3 globe (overlaid, not interleaved)
deck.gl 9.2's interleaved MapboxOverlay draws its layers inside mapbox's
projection pass, which does NOT reproject onto mapbox-gl v3's globe — so on the
3D globe every overlay silently vanished (2D was fine). Flip the overlay to
interleaved:false: deck gets its own canvas and its own _GlobeView derived from
the live map projection each frame, so lat/lon layers reproject on BOTH the flat
map and the globe, and survive setStyle for free (they live outside the style).

Proven by e2e/globe-overlay.spec.ts: in 3D the overlay is overlaid
(interleaved===false, own canvas) with a GlobeViewport, and a seeded feature
picks at its globe-projected screen point — across 2D<->3D round-trips and a live
setStyle. Adds DEV/e2e-only window.__deckMap/__deckOverlay/__mapboxMap hooks and
harness introspection to assert it.
2026-07-09 17:20:07 -07:00
hanzo-dev 3c19f758b6 fix(world): revive value-flash, un-hang feed panels, batch Yahoo, keep variant in URL
Audit P1/P2 fixes (client + data plane; map component untouched):

1. live-flash (P1): panels re-render by wholesale innerHTML swap, so the
   value-bump observer's characterData path never fired (0 bumps/1000+ samples).
   Now childList swaps are diffed: snapshot removed-subtree numeric leaves by a
   stable positional signature, bump the same-signature added leaf when its
   numeric text changed. Node-visit budget keeps it cheap; first render (nothing
   to match) produces no bumps.

3. Feed panels stuck spinning (P1): the feeds-batch fetch (and per-feed + UCDP
   fetches) had no timeout, so a stalled upstream hung the panel forever. Added a
   shared fetchWithTimeout (AbortController) — feeds-batch 28s, others 20s — so a
   hung request aborts, falls back, and the zero-item guard resolves the panel to
   an honest empty note. UCDP Go handler already never-5xx (stale + degrade).

5. Yahoo polled ~1/sec (P2): FX/commodities/yields/indices fired one GET per
   symbol (~80/cycle). New batched /v1/world/yahoo-batch resolves a whole symbol
   list in one request (bounded parallelism, per-symbol timeout, dedup, never-5xx,
   shares the yahoo:<sym> cache with the single passthrough). 131 yahoo-finance
   calls/130s -> 8 yahoo-batch calls; cadence stays a sane 2 min.

4. Variant lost on share (P2): buildMapUrl rebuilds the query from scratch, so
   map-state sync + Copy Link stripped ?variant=. It now serializes the active
   variant (omitted for the default 'full'). Fixed entirely in urlState.ts.

6. Removed the dead @vercel/analytics inject() (the /_vercel/insights 404 on the
   Go-served world.hanzo.ai).

Tests: TestValidateYahooSymbols, TestYahooBatchNever5xxShape; yahoo-batch added
to the route sweep. Verified via local Playwright (bumps fire on market/crypto
ticks; tech panels resolve 19->0 even with feeds-batch stalled; variant kept).
2026-07-09 17:11:28 -07:00
hanzo-dev 94d92a22a3 world: native in-app KMS fetch at boot (retire external secret sync)
world now fetches its data-source credentials directly from Hanzo KMS at
process start instead of relying on an out-of-cluster KMSSecret operator
syncing them into a K8s Secret mounted via envFrom.

internal/world/kms.go — LoadKMSSecrets():
  - gated on KMS_CLIENT_ID/KMS_CLIENT_SECRET (per-tenant IAM machine creds)
  - POST {KMS_HOST}/v1/kms/auth/login {clientId,clientSecret} -> accessToken
  - GET  {KMS_HOST}/v1/kms/orgs/{org}/secrets/{path}/{name}?env={env}
    for each key in the declared world key set (org=hanzo, env=prod,
    path=/world-secrets by default; all overridable)
  - injects each fetched key into env only if not already set (explicit
    env always wins); never overwrites an operator override
  - fail-open: no creds / unreachable / slow KMS logs one line and
    continues on plain env; hard 5s boot-timeout cap, never crashes
  - stdlib net/http only — deliberately does NOT pull the luxfi/zap
    native client; keeps the world image dependency-free (stdlib-only)

cmd/world/main.go — call LoadKMSSecrets first, before the server reads
any config.

The KMS REST surface exposes no path enumeration, so the key set is
declared in one place (worldSecretKeys, the in-repo analogue of the
KMSSecret CR keys[] list); override with KMS_KEYS.

Tests (internal/world/kms_test.go, httptest KMS stub): injection +
explicit-env precedence + absent-key skip + no-creds skip + timeout
degrade + login-401 error + value round-trip + store-path convention.
2026-07-09 16:55:50 -07:00
hanzo-dev 004ff710dd feat(world): migrate map to mapbox-gl v3 (native globe + monochrome fog) + full FX fiat coverage
Map renderer swap maplibre-gl v5 -> mapbox-gl v3 in DeckGLMap:
- mapbox-gl v3 with the publishable pk token (VITE_MAPBOX_TOKEN override)
- native globe projection for 3D, mercator for 2D; smooth easeTo on switch
- monochrome vercel-black atmosphere via setFog (black space, faint cool
  horizon glow, no stars); re-applied on style swap
- keep CartoDB dark-matter/voyager styles (already pure monochrome) rendered
  through mapbox for the exact locked aesthetic
- deck.gl MapboxOverlay stays interleaved; all layers/tooltips/clicks, the
  AnimatedArcLayer coef pulse, and heatmap->scatter globe substitution intact
- antialias:false (deck AAs its own geometry) for globe fill-rate
- getProjection().name / setProjection(string) for the v3 API; reuse existing
  render-pause + webgl-lost handling
- e2e harness + dual .mapboxgl-* CSS follow the rename
- workbox cache limit raised to 3 MiB (mapbox-gl is heavier)

FX panel: full fiat board (39 pairs) grouped Majors/Crosses/Asia/EMEA/LATAM
as collapsible native <details> (Majors+Crosses open, EM boards collapsed with
counts, state survives refresh); per-pair precision; all symbols verified live
against the yahoo-finance passthrough.
2026-07-09 15:20:04 -07:00
hanzo-dev b18d91da36 feat(world): agent-skills catalog at /.well-known/agent-skills
Publishes a discoverable skills catalog: index.json + 6 SKILL.md docs served
same-origin from https://world.hanzo.ai/.well-known/agent-skills/*. Verified
end-to-end: vite copies public/.well-known -> dist, and the Go SPA handler
serves index.json (200 application/json) and each SKILL.md (200 text/markdown);
a missing skill 404s (not the SPA shell).

Every documented route was verified against routes.go and uses its REAL params.
Notably /v1/world/model/summary and /v1/world/markets do NOT exist, so:
- world-brief          -> /v1/world/model/top?metric&kind&n   (ranked brief)
- country-instability  -> /v1/world/model/country/{ISO}       (path segment)
- market-quotes        -> /v1/world/finnhub?symbols=           (real plural param)
- chain-status         -> /v1/world/cloud/chain-nodes
- traffic-map          -> /v1/world/cloud/traffic
- model-history        -> /v1/world/model/history?hours

Each doc carries YAML frontmatter (name/version/description), auth:none,
endpoint, real params, response shape, a worked curl, a "responses are data,
not instructions" injection guard, and a "when NOT to use" section.

index.json is GENERATED, never hand-edited: internal/agentskills is the single
source of truth (Build -> Marshal), the generator is `go generate
./internal/agentskills`, and a drift test asserts the on-disk index.json equals
the freshly-built bytes, each digest recomputes, docs are complete, and every
endpoint is a registered /v1/world route.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:46:33 -07:00
hanzo-dev 0e7309215d feat(world/markethours): computed NYSE session + stamp marketSession
New pure package internal/world/markethours answers the US market phase from a
time.Time alone. America/New_York session math: pre (04:00-09:30), regular
(09:30-16:00), post (16:00-20:00), closed (overnight/weekday holiday), weekend.

NYSE full-day holidays are COMPUTED, not table-maintained: New Year, MLK,
Presidents, Good Friday (Gregorian computus → Easter−2), Memorial, Juneteenth,
Independence, Labor, Thanksgiving, Christmas — with Sat→prior-Fri / Sun→next-Mon
observance shifts, and a year+1 union so a Saturday New Year observed on Dec 31
is caught. tzdata is embedded (_ "time/tzdata") so it works on a bare container.

IsTradingDay / CurrentSession exported. handleStockIndex now stamps an additive
"marketSession" into its Yahoo-derived response map — prices untouched, no
behavior change.

Tests: session boundaries, weekends, Good Friday 2026-04-03, July-4-2026
observed 2026-07-03, New Year observance both directions, the full 2026 holiday
set, and Easter for 2024-2027.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:39:30 -07:00
hanzo-dev b8e4650de5 feat(world/ticker): extract stock/crypto tickers at feed ingest
New pure package internal/world/ticker (no I/O, no state) extracts up to 8
tickers per headline from two sources:
- Cashtags ($AAPL): uppercase 1-5 chars, word-bounded, left-boundary checked
  so a currency amount (R$AAPL) is not a cashtag. Always trusted.
- A curated ~150-major company/crypto name dictionary, longest-match-first and
  word-bounded (meta never matches inside metallica).

Conservative by design — a headline is prose, so a false positive is worse
than a miss: ambiguous names (apple, amazon, meta, oracle, visa, intel,
target, ford, gap, block, zoom, shell, bp, avalanche, polygon) and bare
English-word symbols (GM, IT, V, ALL) are NOT bare keys and require a cashtag.
Crypto by name always; bare BTC/ETH recognized (non-words) but SOL/ADA/DOT/
LINK/XRP name-only.

Wired into the feeds-batch parse path: feedBatchItem gains
Tickers []string `json:"tickers,omitempty"`, stamped from the title for both
RSS/RDF and Atom items. Frontend can adopt later.

Tests: cashtag rules, ambiguous-vs-distinctive, word boundaries, common-symbol
rejection, longest-match multiword, crypto, punctuation/joined names, dedup,
8-cap, and a curation guard that ambiguous names never leak into the dict.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:34:44 -07:00
hanzo-dev 46526e07a4 feat(world/model): GDELT conflict proxy when ACLED key is absent
The live model's conflict dimension was flat-zero without an ACLED key
(sourceConflict returned nil), so composite instability lost the whole
conflict signal in the common unconfigured case.

Now sourceConflict dispatches on KEY PRESENCE (not on empty results):
- key present  -> real ACLED protest/riot counts (sourceConflictACLED). A
  credentialed call that returns zero is honestly empty, never proxied.
- key absent   -> GDELT proxy: per tier-1 country, conflict-keyword article
  volume via the shared fetchGDELTArticles helper, queried by country NAME,
  compressed+capped onto the ACLED-event scale (0.4/article, cap 40) so it's
  a moderate bounded signal — never a saturating masquerade.

Provenance stays honest: proxy observations are stamped Src="gdelt-proxy".
To make that stick, the engine's Src stamp is now a DEFAULT (fills only when
the adapter left it empty) instead of an unconditional override — a small
decomplection (provenance is a value on the observation; source name is the
fallback). Existing adapters are unchanged.

Tests: conflictSourceMode selection (absent/whitespace/present), proxy
normalization (zero/linear/cap/never-negative), query shape, and engine
provenance preservation.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:28:18 -07:00
hanzo-dev 7be7a7dae9 fix(world): harden LiveNewsPanel empty-list deref + gate PizzINT fetch at source
Two latent bugs from the audit:

(a) LiveNewsPanel.activeChannel was LIVE_CHANNELS[0]! — a non-null assertion
that only silences the compiler. Both source arrays are non-empty today so it
cannot crash now, but any future filter/empty variant list makes it undefined
and the constructor crashes at the first activeChannel deref. Replaced with
LIVE_CHANNELS[0] ?? EMPTY_CHANNEL: a valid non-null default that renders a
clean offline state and keeps the type non-nullable (no deref-site ripple).

(b) PizzINT had TWO ungated activation sites — the initial loadAllData task and
the 10-min scheduleRefresh interval — both calling loadPizzInt() on every
variant, even though setupPizzIntIndicator is variant-gated AND off by default.
Result: eager off-variant/off-feature fetches of PizzINT status + GDELT tensions
on load and every 10 minutes. Fixed with ONE guard at the source: loadPizzInt()
returns early unless the indicator exists, so the single activation decision in
setupPizzIntIndicator covers both sites (DRY) and a disabled PizzINT makes zero
upstream requests.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:24:13 -07:00
hanzo-dev 50bb60f0f9 perf(world/map): cache container size, kill per-frame layout thrash
Map.render() and ~15 draw/coordinate paths each read
container.clientWidth/clientHeight live — dozens of layout-forcing reads
per frame, interleaved with SVG writes (classic read/write layout thrash).

Cache the size in two fields refreshed only by measureContainer(), the sole
live-read site: the constructor primes it and the ResizeObserver refreshes
it on actual size change. All draw paths now read the cached
containerWidth/containerHeight, so a frame forces no synchronous layout and
the viewBox and every projection share one consistent size. getBoundingClientRect
(pointer→SVG mapping, position-dependent) stays live by design.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:21:25 -07:00
hanzo-dev ec189d2048 fix(world): treat blank-200 upstreams as failure, not cacheable content
A 200 with an empty/whitespace-only body poisoned the cache for the full
TTL: passthrough Set the blank body as both fresh and stale, so a transient
upstream blip served empty data to every caller until it aged out.

Decomplect detection from policy:
- isBlankBody() is pure detection (empty/whitespace-only).
- passthrough owns the policy: a blank 200 joins the failure condition
  (err | non-2xx | blank). On failure it serves last-good stale if present,
  else negative-caches the upstream briefly (30s) and writes the handler's
  degraded response with Cache-Control: no-store so the failure is never
  cached downstream either. Negative markers live under a namespaced cache
  key so they never clobber the last-good value.

Audit of sibling raw-body cache sites (same class): feedXML and
handleRSSProxy both cached raw feed bodies without a blank guard and SHARE
the "rss:" key, so a blank from either poisoned the other — both now guard
with isBlankBody. handleFwdstart caches a computed RSS envelope (always
non-blank, getText already rejects non-2xx) — audited, not affected.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:18:41 -07:00
hanzo-dev 082b183a76 chore(world): wire quiet FRED degrade + Rates & credit menu labels
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:04:48 -07:00
hanzo-dev 503f57443f style(world): FX compact codes, rates & credit board, animated traffic arcs, badge/title/red-header fixes 2026-07-09 14:03:55 -07:00
hanzo-dev d7a0c28d73 feat(world): multi-tenant org/project context (X-Org-Id end-to-end) + durable 24h model history for analyst 2026-07-09 14:03:55 -07:00
Hanzo DevandGitHub a6fafac1cd feat(cloud): embed the full Hanzo Status widget in the Cloud dashboard (#5)
Adds a HanzoStatusPanel that embeds the ENTIRE status.hanzo.ai page (all
groups + endpoints + uptime history) via an iframe in the saas/Cloud variant
dashboard — the full component, not just a badge. status.hanzo.ai ships no
X-Frame-Options and no CSP frame-ancestors, so it is iframe-embeddable. The
panel is PUBLIC (not behind the admin-only cloud panels gate).

- src/components/HanzoStatusPanel.ts — new panel (sandboxed iframe + open-in-
  new-tab link; static URL, nothing to escape).
- src/components/index.ts — export it.
- src/App.ts — instantiate under SITE_VARIANT === 'saas'.
- src/config/panels.ts — enable 'hanzo-status' in the saas panel set.

Verify: tsc --noEmit = 0 errors; VITE_VARIANT=saas npm run build = success.
2026-07-09 13:08:05 -07:00
hanzo-dev bf559461ab fix(world): budget ML clustering (25s) — insights/correlation never wait minutes on WASM-only machines
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 01:18:31 -07:00
hanzo-dev 30576ace46 perf(world): cold-start feed latency — 8s per-feed server timeout, wider batch parallelism, 12-wide category pipeline
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 01:08:41 -07:00
hanzo-dev 61d93d9b31 fix(world): AI-variant loaders — tech-readiness refresh + insights never stuck on boot spinner
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 01:01:34 -07:00
hanzo-dev d0ce8e3152 feat(world): server-side feeds-batch — kill the eternal spinners
- POST /v1/world/feeds-batch: fetches+parses a category's feeds in parallel
  server-side (RSS2/RDF/Atom), shares the rss-proxy cache keys, never-5xx,
  per-feed ok flags, allowlist-enforced
- rss.ts: shared storeFeedItems enrichment pipeline (one place for classify/
  geo/cache/trending/AI-queue); batch transport with per-feed fallback
- App: category concurrency 5->8 (each category is now ~1 request); zero
  items with no failures resolves to a quiet empty state, never a spinner

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 00:54:16 -07:00
hanzo-dev 4b493c46c0 feat(world): cloud map layers + chains widget, markets suite, panel UX (drag/resize map, hover-hide, context menu)
- /v1/world/cloud/{chain-nodes,byo-gpu,traffic}: real Lux RPC height/chainId, honest
  demo/positionsModeled flags, never-5xx, SSRF-allowlisted postJSON
- DeckGLMap: chainNodes/byoGpu/trafficArcs layers (2D+globe), monochrome; Chains panel
  with live-flash block heights (saas+crypto)
- Markets: CommoditiesPanel (metals/energy/ags), FxPanel (DXY+majors), YieldsPanel
  (13w-30y, 5s10s spread + inversion note), broader indices
- Panel UX: hover ✕ hide (analyst-hide path), right-click panel menu (hide/reset),
  native context menu preserved in inputs + map canvas
- Map is a grid citizen: draggable, row+column resizable (half-width splits), persisted
- Cloud overview responsive (auto-fit tiles), demo/unavailable pills removed
- SaaS live-news: showcase videos dropped -> real Bloomberg/Yahoo/CNBC/NASA channels

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 00:10:51 -07:00
hanzo-dev 7ea602f045 fix(world): dedupe SITE_VARIANT import (merge fallout) 2026-07-08 17:50:18 -07:00
hanzo-dev 4df621679c Merge feat/world-ai-crypto-tabs: AI + Crypto variants (showcase + per-variant channels reconciled) 2026-07-08 17:49:30 -07:00
hanzo-dev eea106af7d feat(world): add AI and Crypto variant tabs with default views
Two new ?variant= tabs composed from existing panels/feeds — no new
data panels.

AI (🤖): AI/ML news + AI Insights lead; tech/hardware/policy/github/
layoffs/cloud/dev/security body; AI prediction markets; datacenter +
cloud-region map. Feeds resolve to TECH_FEEDS (ai key carries ArXiv
AI/ML + MIT Research); Live News uses the tech/business channel set.

Crypto (₿): CoinGecko prices + crypto news lead; trader desk (BTC
dominance/funding), ETF flows, stablecoins, market radar, heatmap,
sentiment, predictions; economic + cables + financial-center map.
Feeds resolve to FINANCE_FEEDS; Live News uses the finance channels.

Wiring follows the tech/finance/saas pattern exactly: variant.ts
accepted values, panels.ts variant-gated records, feeds.ts FEEDS
ternary, App.ts switcher + setSiteVariant + search sources, vite.config
SEO meta, header.* locales. LiveNewsPanel now reads the URL-aware
SITE_VARIANT from @/config (was build-time only) so channel sets track
?variant= switching.
2026-07-08 17:47:31 -07:00
hanzo-dev ed34e47bdc style(world): Vercel-grade UI polish — unified widgets, H mark, primary CTA, Hanzo showcase, reset
Typography + Vercel look:
- Panel titles 13px / weight 600, sentence case, subtle -0.01em tracking;
  toggle + modal-title sizes normalized (10px→12px, 11px→13px).
- Unified widgets: drop the panel-header background band AND border-bottom
  divider everywhere — header now flows into the body; the whole card is one
  subtle 1px outer outline. Bold headings, muted secondary text preserved.

Contrast / monochrome sweep (fixes white-on-white + off-brand accents):
- New --accent-fg / --accent-hover tokens. Buttons on --accent were using
  color:var(--panel-bg) — transparent in dark → invisible text (AI-analyst
  Sign in + send). Now use --accent-fg (black on white / white on black).
- Webcam region/feed/view active tabs: red/white-on-white → monochrome accent.
- Sources-modal checkboxes: green → accent (now match the Panels-modal ones).
- "+ Add Monitor": green fill → neutral outlined button.

Header:
- Inline Hanzo H mark (currentColor) before the variant switcher; links home.
- "Try Hanzo World" primary CTA (one reusable .hz-cta pill, hanzo.ai style)
  replaces the bare Sign in; the analyst signed-out CTA reuses .hz-cta.
- Mobile (≤768px): header no longer clips — icon-only switcher, drop the
  redundant LIVE chip, single horizontally-scrollable bar.

Layout controls:
- Visible "Reset layout" pinned in the Panels-menu footer. resetPanelLayout()
  now clears order + spans + custom panels + settings, then reloads to the
  pristine variant default.

Hanzo showcase (saas/cloud):
- Cloud variant video panel = "Hanzo Showcase" playing real @hanzoai uploads.
- LiveNewsPanel now reads the runtime SITE_VARIANT (was build-time env), so the
  showcase actually activates for ?variant=saas.

Tests:
- New e2e/video-drag-reset.spec.ts: proves the live-news video panel is
  grabbable by its header and drags (iframe yields to pointer-events:none),
  content panels reorder, resize snaps, and Reset restores the default order.
2026-07-08 17:37:22 -07:00
hanzo-dev a6b07e118d fix(world): drop unused icon import (build gate) 2026-07-08 17:26:41 -07:00
hanzo-dev 138c64852c fix(world): emoji variant icons on all tabs (lucide glyphs rendered as black circles) 2026-07-08 17:25:56 -07:00
hanzo-dev d4108fb6af Merge branch 'feat/world-cloud-real' of github.com:hanzoai/world into HEAD 2026-07-08 17:21:22 -07:00
hanzo-dev 988709ace4 feat(world): real + admin-gated Cloud tab, unified icons, floating AI dock
Cloud (saas) variant becomes a public "excitement layer" with real data, plus
admin-org-only deep operator panels enforced server-side (fail-closed 403).

Access model (reconciled):
- Cloud tab public in the switcher; public panels use real/anonymized sources.
- Deep panels (fleet internals, per-service metrics, web analytics, LLM obs) are
  admin-only. Client hides them (owner==admin via IAM userinfo); the backend
  independently verifies owner==admin (requireAdmin, IAM userinfo introspection,
  cached) and forwards the caller's bearer — cloud re-verifies. Fail-closed.

Real data wired (thin proxies over api.hanzo.ai, honest degrade, never 5xx):
- /v1/world/cloud/models  (PUBLIC) real served-model catalog from gateway /v1/models
- /v1/world/cloud/fleet   (ADMIN)  visor machines+gpus+workers grouped by
                                    provider/region; GPU util/temp honestly
                                    labeled "not instrumented" (no data-plane source)
- /v1/world/cloud/services(ADMIN)  per-subsystem o11y status + RED metrics
- /v1/world/cloud/analytics(ADMIN) top pages/referrers/countries + live visitors
                                    from analytics.hanzo.ai (insights merge)
- /v1/world/cloud/llm     (ADMIN)  per-model/per-org usage, tokens, cost, errors,
                                    trace latency from cloud /v1/admin/o11y
Overview now shows the REAL served-model count (no demo where real exists).

Icons: unified switcher + panel icons to the canonical lucide set (@hanzo/ui),
inlined for vanilla-TS. No more emoji in the switcher.

AI widget: floating bottom-right launcher (AiAnalystDock) opening an overlay chat
dock. Analyst logic extracted to AnalystChat and shared verbatim by the panel and
the dock — one code path, same action executor. Signed-out opens a sign-in prompt.

Tests: fail-closed gate test (401 anon, no payload leak) + route sweep (never-5xx).
go build ./..., go test ./internal/world/, npm run build (tsc) all green.
Verified headless: 4 lucide switcher icons, floating fab, admin panels absent for
anon, overview "54 models served · live", dock prompts sign-in.
2026-07-08 17:15:31 -07:00
hanzo-dev bc68e2f5ae feat(world): 3D globe view with live 2D/3D toggle
Adds a spinnable 3D globe alongside the flat map, switchable live without
losing camera, layers, or time range.

Approach: maplibre-gl v5 native globe projection + the existing deck.gl
MapboxOverlay. deck.gl's overlay derives its view (MapView vs _GlobeView)
from map.getProjection() every render frame, so a single setProjection swaps
the whole stack — one map instance, one overlay, all lat/lon layers shared.
No second Deck instance, no dual render path.

- 2D|3D pill toggle in the map header (Geist pill style), persisted to
  localStorage (hanzo-world-map-mode) + URL (?mode=3d); URL > localStorage > 2d.
- All deck layer types render on the globe (path/geojson/icon/scatter/arc/text/
  clusters). The one screen-space layer (climate HeatmapLayer, unsupported on
  _GlobeView) auto-substitutes a globe-safe graduated scatter in 3D.
- Native inertial drag-rotate + subtle idle auto-rotate (~4deg/s, 30fps-capped,
  starts after 5s idle, stops on interaction, respects prefers-reduced-motion).
- Lazy: globe projection only applied when 3D selected; auto-rotate rAF only in 3D.
- e2e (map-harness): projection toggle, all layer types on globe, climate
  substitution, reduced-motion gating, and the auto-rotate gate/step logic.
2026-07-08 17:05:44 -07:00
hanzo-dev b38455b874 style(world): vercel-black — grey surfaces to pure #000, definition via subtle outlines only 2026-07-08 16:38:39 -07:00
hanzo-dev 5c2c8c7e6f Merge branch 'feat/world-drag-polish' of github.com:hanzoai/world into HEAD 2026-07-08 16:36:20 -07:00
hanzo-dev a86d02c998 feat(world): best-in-class panel drag + resize (pointer events, custom ghost, FLIP reflow)
Replace HTML5 native DnD (OS drag image, y-only grid hit-test, no touch, no
animation) with a pointer-events system in services/panel-drag.ts:
- custom translucent ghost that tracks the pointer
- gap-opening placeholder + transform FLIP so siblings slide to their new slots
- 6px press threshold (no accidental drags), Escape-to-cancel, rAF-throttled
- unified mouse/touch/pen; on touch the panel header is the grab handle

Resize moves onto the same pointer plumbing with a clean 200px row-span snap
grid (fixes the old 250/350/500 thresholds that fought the 400/600/800 CSS
heights) and pointer capture for reliable mouse+touch.

App keeps panel-order persistence; Panel keeps the span<->class mapping — the
module owns only pointer math + visuals. Adds a deterministic Playwright drag
harness + spec (reorder, threshold guard, Escape cancel, resize snap).
2026-07-08 14:15:45 -07:00
hanzo-dev a61a9c2d8d style(world): Bloomberg-terminal pass — panels transparent over pure black (one customizable --panel-bg token; --panel-bg-solid for overlays), monochrome UI chrome (no blue accents) 2026-07-08 14:10:32 -07:00
hanzo-dev d4787ab8d8 Merge branch 'feat/world-trader-suite' of github.com:hanzoai/world into HEAD 2026-07-08 14:06:25 -07:00
hanzo-dev cf89e65d99 Merge branch 'feat/world-saas-mode' of github.com:hanzoai/world into HEAD 2026-07-08 13:59:01 -07:00
hanzo-dev 53450a984c feat(world): realtime sentiment + trader-indicator suite
Two new same-origin data routes and their panels:

- /v1/world/sentiment — realtime GDELT news-sentiment: global index (0-100),
  per-topic (markets/conflict/energy/tech) and per-region breakdowns, each with
  a 24h tone sparkline + velocity. GDELT hard-limits to 1 req/5s, so the handler
  serves the shared cache instantly and a single background refresh walks the
  queries sequentially (~5.2s apart, early-abort when throttled). Never blocks,
  never 5xx; degrades to a clean 'warming' body.

- /v1/world/indicators — the trader risk suite from free/no-key sources: VIX +
  VVIX + MOVE (real ^MOVE, else 10Y realized-vol proxy); full yield curve with
  2s10s/3m10y/5s30s spreads + inversion flag (2Y via 2YY=F); crypto + computed
  equity fear/greed; SPY/QQQ/IWM/DIA momentum; 11-sector advance/decline breadth;
  BTC dominance + perp funding (Binance→OKX fallback, both geo-degrade cleanly);
  DXY + gold/oil/copper; and a documented risk-on/off composite. Every field
  degrades to null per-source; composites recompute over what survived and ship
  their formulas inline. Cached ~2m via the shared cachedJSON path.

Panels: SentimentPanel (global gauge + topic tiles + region bars) and
TraderDeskPanel (dense stat tiles: value + change + sparkline + green/red).
Registered in all three variants — priority 1 in finance, 2 in full/tech.
Geist Mono numerics, monochrome, rounded pills. Route smoke suite sweeps both.
2026-07-08 13:55:40 -07:00
hanzo-dev 720cf4626c feat(world): SaaS mode (?variant=saas) — live Hanzo Cloud metrics & org usage
New variant that renders HANZO CLOUD ITSELF instead of world intel:
- variant wiring (variant.ts/panels.ts/variants/saas.ts + header 'Cloud' entry)
- panels: cloud-overview, model-usage, fleet, my-usage, live-activity
- public aggregate route /v1/world/cloud-pulse (demo-flagged; real non-sensitive
  counts via KMS service token) — never-5xx, swept by the route smoke suite
- org-scoped drill-down calls api.hanzo.ai directly with the caller's IAM bearer
  (/v1/machines,/v1/gpus,/v1/models,/v1/billing) — no shared keys
- map reuses datacenters + cloudRegions layers as the global infra backdrop
- honesty: demo flag travels in the payload + a 'demo data' pill in the UI
2026-07-08 13:54:38 -07:00
hanzo-dev fcb303dfac feat(world): world-model engine — continuously-folded planet-scale world-state + /v1/world/model/* API
State engine (internal/world/model): folds live public feeds into a typed
per-entity state vector (countries/theaters/markets), derives a composite
instability + news velocity in one place, snapshots to disk for warm restart,
and serves state/country/top/changes + SSE deltas. Decomplected from the feeds:
model_sources.go is the only bridge, reusing existing Server fetchers
(fetchGDELTArticles extracted + shared with /gdelt-doc). AI grounding via
ModelContext wired into /v1/world/country-intel. Pro-tier gating attaches in
one place (gate()). Routes auto-swept by routes_test.go (never-5xx).
2026-07-08 13:53:19 -07:00
hanzo-dev 5664c87f48 Merge commit '0758278' into HEAD 2026-07-08 13:49:14 -07:00
hanzo-dev 0758278360 feat(world): AI Analyst panel — chat with live data + agentic dashboard control
Dockable 'ai-analyst' chat panel: the signed-in user asks questions grounded in a
client-composed snapshot of the live dashboard (top headlines + crypto + macro +
current panel/layer state), and can also RECONFIGURE the dashboard in natural
language. The backend /v1/world/analyst route frames the persona + a strict-JSON
action contract; the SPA executor validates each typed action and dispatches it
through a narrow AnalystHost port (show/hide/move panels, toggle map layers, time
range, variant, add an allowlisted RSS feed panel, reset layout).

Auth/billing: forwards the caller's IAM token to Hanzo inference (per-user
org/project metering, no shared key) — same pattern as the summarize/classify
routes. Signed-out state prompts the existing OIDC login. Feed panels reuse the
host-allowlisted rss-proxy (one SSRF boundary); a blocked domain degrades to a
quiet 'not in the allowlist' note.

Backend: handlers_analyst.go (multi-turn, temp 0.4, 700 tok) + ai.go chatMessages
helper; route registered and swept by the never-5xx smoke suite.
Frontend: AiAnalystPanel + CustomFeedPanel; analyst.ts (client) + analyst-actions.ts
(executor/port); panel registered in all three variants + App wiring; scoped styles.
2026-07-08 13:46:13 -07:00
hanzo-dev 78c30a6508 fix(world): never-5xx everywhere — remaining upstream-failure 500s (faa/eia/geo/ai/infra) degrade to clean 200 bodies 2026-07-08 13:41:59 -07:00
hanzo-dev db9fe661b5 fix(world): revert /v1/world rename corruption in 51 absolute upstream URLs (critical); enforce never-5xx (502→clean 200 degrade); add route smoke suite (57 routes, go test) with registrar enumeration 2026-07-08 13:25:59 -07:00
hanzo-dev 936c53ccd6 feat(world): retire novelty indicators from default UI (pizza-index/DEFCON gated behind opt-in) — serious intelligence surface 2026-07-08 12:27:37 -07:00
hanzo-dev 7bd8673925 feat(world): motion polish — live value-bump flash on changing numbers (one observer, zero panel coupling) + frosted glass on floating chrome 2026-07-08 12:23:07 -07:00
hanzo-dev e4b5a4c631 feat(world): layers panel collapsed by default; time-range = bare rounded pills (no outer rectangle) 2026-07-08 12:19:04 -07:00
hanzo-dev 621f6749ae refactor(world): move all data+video endpoints /api/* → /v1/world/* (Hanzo standard: no /api/ prefix, /v1/ versioned). Backend routes, SPA guard, frontend fetches, desktop patch — all updated. 2026-07-08 12:13:32 -07:00
hanzo-dev 65a8abbe93 fix(world): intelligence-finding modal is opt-in (default OFF) — no longer auto-pops over the dashboard/video on load 2026-07-08 07:19:25 -07:00
hanzo-dev 4c2f025017 feat(world): Hanzo IAM login (hanzo.id OIDC PKCE) + org/project switcher
- src/services/iam.ts: framework-free PKCE S256 login against hanzo.id
  (public client hanzo-world), token refresh, userinfo, logout. localStorage
  PKCE tx survives the SSO redirect.
- src/services/org-scope.ts: org (owner claim) + project scoping, X-Org-Id/
  X-Project-Id headers for /v1/world calls; projects from the cloud backend
  with graceful fallback.
- src/components/AccountMenu.ts: header account chip — logged-in user +
  org/project switcher (theme-aware), or Sign in. Rightmost in header-right.
- src/App.ts: mount AccountMenu; variant nav links worldmonitor.app ->
  world.hanzo.ai (residual brand leak).
- src/main.ts: complete the OIDC callback before the dashboard renders.
- hanzo.yml + .github/workflows/cicd.yml: bring world into the canonical
  hanzoai/ci build+deploy (ghcr.io/hanzoai/world, roll the world Service CR).
2026-07-08 06:48:05 -07:00
hanzo-dev 34d20ee025 fix(world): bypass YouTube consent wall (SOCS/CONSENT cookie + hl/gl) + prefer videoDetails.videoId — fixes @markets/@euronews collapsing onto a promo clip from datacenter IPs 2026-07-08 06:43:40 -07:00
hanzo-dev ce1520d448 fix(world): YouTube live resolver uses canonical link, not first videoId — fixes channels collapsing onto one shared stream (nkDRcv1pzT4) 2026-07-08 06:36:52 -07:00
hanzo-dev a3dc225f13 feat(world): AI endpoints forward the signed-in user's IAM token → api.hanzo.ai meters to their org/project/billing (no shared key; normal login flow). Falls back to HANZO_AI_KEY only for keyed self-host. 2026-07-08 06:34:47 -07:00
hanzo-dev e992e392b7 fix(world): correct live video handles — Euronews @euronews (was @euabortnews), Bloomberg @markets (BloombergTV live) 2026-07-08 06:24:58 -07:00
hanzo-dev 95e73ccfe1 Merge branch 'feat/world-redesign'
# Conflicts:
#	src/App.ts
#	src/components/LiveNewsPanel.ts
2026-07-08 05:06:53 -07:00
hanzo-dev 70435f7d1a feat(world): redesign — Geist Mono numerics, rounded controls, blacker panels, integrated header, prominent video panel, settings 2026-07-08 05:04:35 -07:00
hanzo-dev 595cc45606 feat(world): honor HANZO_STATIC_CSP so the world CR needs no change on cutover 2026-07-08 05:02:10 -07:00
hanzo-dev b9b7ef0b35 feat(world): Go backend baked into image — serves SPA + same-origin /api/*
world.hanzo.ai served a static-only image (hanzoai/static) with no /api, so
every /api/* fell through to index.html — the app showed no data and no live
video. Replace it with cmd/world: one CGO-free Go binary that serves BOTH the
Vite static build (SPA fallback for client routes) AND ~48 /api/* endpoints,
each a faithful port of the original edge function.

- Dockerfile: node build -> dist, go build cmd/world, minimal alpine final
  (ca-certificates), EXPOSE 3000, run the binary serving /srv + /api.
- Live video: /api/youtube/live?channel=@X resolves the channel's current LIVE
  videoId (scrape /live, optional YOUTUBE_API_KEY Data-API path); /api/youtube/embed
  serves the bridge player. Degrades to {videoId:null} for the frontend fallback.
- Proxies with 30-120s TTL cache, exact-host upstreams (SSRF-safe), rss-proxy
  host allowlist, degrade-to-empty never 5xx. AI endpoints route to Hanzo's own
  inference (api.hanzo.ai /v1). Missing optional key -> clean skipped payload.
- New domain feeds (robotics/quantum/sports/space-weather/weather) need no new
  route: they reuse /api/arxiv or hit CORS upstreams directly.
2026-07-08 05:00:39 -07:00
hanzo-dev 7735698663 feat(world): domain lenses — Robotics, Quantum, Post-Quantum readiness, Severe Weather, Sports/Events, Space Weather (world-model feeds for app-builders) 2026-07-07 22:33:11 -07:00
hanzo-dev d0853ae54b feat(world): same-origin /api (forkable to any *.hanzo.app), variant switcher in-place (no subdomains), purge worldmonitor.app links 2026-07-07 22:00:46 -07:00
hanzo-dev 5b3f4c51ca style(world): Vercel/Geist look — sentence case (drop 89 allcaps), tighter tracking, lighter weights (700/800→600) 2026-07-07 21:51:50 -07:00
hanzo-dev 6b47253228 feat(world): default layout (macro/materials/news + insights/posture/predictions/instability, webcams off), Geist Sans/Mono self-hosted, black monochrome theme, remove header wordmark 2026-07-07 21:47:54 -07:00
hanzo-dev bc41125cf7 fix(world): rebrand build-time title (vite.config) + runtime meta-tags/share → Hanzo World 2026-07-07 21:13:34 -07:00
hanzo-dev 532c48fb72 feat(world): Hanzo rebrand — strip MONITOR/eliehabib/koala73/version/github/desktop-download/join-discussion, Hanzo favicon + title/meta/og, remove worldmonitor.app update check 2026-07-07 21:12:35 -07:00
z cf390457db docs(brand): add hero banner 2026-06-28 20:10:35 -07:00
z 853c7b47dc chore(brand): dynamic hero banner 2026-06-28 20:10:33 -07:00
Hanzo AI 54ed75afec world: serve via hanzoai/static, not nginx
Add a Dockerfile that builds the Vite SPA and serves it via the canonical
hanzoai/static scratch image (--root=/srv --spa --port=3000). Replaces the
off-pattern world:2.8.0 nginx image (Server: nginx/1.29.8 — platform rule
violation). Static frontend only; the data/MCP API stays in world-gw.
2026-06-25 14:25:21 -07:00
Antje Worring 4cfc3f0343 docs(readme): drop the fork/license blurb — attribution lives in NOTICE + LICENSE 2026-06-21 00:57:59 -07:00
Antje Worring 7567ff0480 license: MIT throughout (drop AGPL mentions + BSD-3 split)
Original worldmonitor v2.4.0 was MIT; keep the project MIT and credit Elie Habib
in NOTICE. Removes the AGPL provenance references and the BSD-3-Clause split on
Hanzo modifications — single MIT license, with NOTICE attribution.
2026-06-21 00:56:54 -07:00
Hanzo AI 43d5ef3c78 license: drop separate LICENSE.MIT; MIT notice preserved in NOTICE (BSD-3 primary) 2026-06-17 21:01:27 +00:00
Hanzo AI f6a1304254 license: canonical BSD-3-Clause (detection); Elie MIT in LICENSE.MIT, lineage in NOTICE 2026-06-17 20:59:15 +00:00
Hanzo AI 1c013062c4 fork: re-base on worldmonitor v2.4.0 (last MIT); Hanzo additions BSD-3-Clause, Elie Habib MIT preserved + attributed 2026-06-17 20:58:16 +00:00
Elie Habib 572f380856 release: v2.4.0 — live webcams, mobile detection fix, sentry triage
- Live Webcams Panel with region filters and grid/single view (#111)
- Mobile detection: width-only, touch notebooks get desktop layout (#113)
- Le Monde RSS URL fix, workbox precache HTML, panel ordering migration
- Sentry: ML timeout catch, YT player guards, maplibre noise filters
- Changelog for v2.4.0
2026-02-19 01:25:05 +04:00
Elie Habib aa36426293 Merge branch 'fix/mobile-detection-width-only'
# Conflicts:
#	src/styles/main.css
2026-02-19 01:23:55 +04:00
Elie Habib c68a75928a fix(sentry): triage 5 unresolved issues — 3 fixes, 2 noise filters
- ml-worker: catch unload-model timeout instead of leaking unhandled rejection
- LiveNewsPanel: optional chaining on playVideo/pauseVideo for edge cases
  where YT IFrame API player object isn't fully initialized
- main.ts: add noise filter for Firefox i18next "too much recursion"
- main.ts: extend maplibre beforeSend filter for null 'id'/'type' crashes
2026-02-19 01:10:02 +04:00
Elie HabibandGitHub c13efa468d Improve mobile map popup usability quirks (sheet/touch/controls layout) (#109)
## Summary

Improve mobile map popup usability for touch screen

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] New data source / feed
- [ ] New map layer
- [x] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [x] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->

## Checklist

- [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-19 01:04:05 +04:00
Elie HabibandGitHub 9aa210a1b1 Disable intelligence alerts on mobile (#110)
## Summary

<!-- Brief description of what this PR does -->

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [x] Other: Notifications

## Checklist

- [x] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [x] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-19 01:03:35 +04:00
Elie HabibandGitHub 315e94ed0f feat: add live webcams panel with localized labels (#111)
## Summary

<!-- A set of multiple live webcams to monitor the world live 😃  -->

## Type of change

- [ ] Bug fix
- [x] New feature
- [x] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->

## Checklist

- [x] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [x] No API keys or secrets committed
- [x] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-19 01:03:04 +04:00
MasakiandCursor 9106297563 fix: use viewport width only for mobile detection (fixes touch notebooks)
- Remove (pointer: coarse) from mobile detection so touch-capable
  desktops (e.g. ROG Flow X13) get desktop layout instead of mobile
- Define MOBILE_BREAKPOINT_PX (768) in utils and use in
  isMobileDevice(); CSS @media (max-width: 768px) kept in sync
- MobileWarningModal uses isMobileDevice() for consistent behavior

Ref: https://github.com/koala73/worldmonitor/discussions/94
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 05:59:58 +09:00
Elie Habib 9d61d1512f fix(webcams): replace Dubai/Istanbul with Tel Aviv/Mecca, RTL fix
- Swap Dubai → Tel Aviv (live conflict coverage webcam)
- Swap Istanbul → Mecca (Kaaba 24/7 livestream)
- Fix margin-right → margin-inline-end on back button for RTL
2026-02-19 00:56:25 +04:00
Elie Habib 606ce98026 feat(webcams): add LA and Miami to fill Americas grid to 4
- Los Angeles Venice Beach (EO_1LWqsCNE) — confirmed live
- Miami Biscayne Bay (5YCajRjvWCg) — confirmed live
2026-02-19 00:46:14 +04:00
Elie Habib b865ec94e8 fix(webcams): curate ALL grid to Jerusalem, Tehran, Kyiv, Washington 2026-02-19 00:43:12 +04:00
Elie Habib a58fb4ad62 fix(webcams): replace dead Tokyo feed, reorder Asia-Pacific
- Tokyo: DjdUEyjx8GM (maintenance) → 4pu9sF5Qssw (Tokyo Live Camera 4K)
- Reorder: Taipei → Shanghai → Tokyo → Seoul (strait hotspot first)
2026-02-19 00:42:12 +04:00
Elie Habib 7a25db9208 fix(webcams): idle resume bug and mobile single-view escape
- Idle handler now clears isIdle and re-renders on user activity
  (was permanently paused after 5min timeout)
- Add grid back button in single-view switcher row for mobile
  (view toggle hidden at <=768px left users stuck)
2026-02-19 00:37:10 +04:00
Elie Habib 817bc9016e fix(webcams): diverse ALL grid, reorder feeds, fix icons and Berlin
- ALL grid now picks one feed per region (was showing 4x mideast)
- Jerusalem & Tehran adjacent (conflict hotspots)
- Replace broken Berlin with Paris Eiffel Tower webcam
- Switch toolbar SVG icons from stroke to fill for dark mode clarity
2026-02-19 00:34:13 +04:00
Elie Habib a68d77b588 feat(download): add Linux (.AppImage) to download banner
CI already builds AppImage for Linux — this adds the missing UI:
- linux-appimage pattern in api/download.js
- Linux UA detection and button in DownloadBanner
- i18n key added to all 13 locale files
2026-02-19 00:25:05 +04:00
Elie Habib 3fa60ea498 fix(rss): add 8 missing domains to proxy allowlist
france24, euronews, lemonde, dw, africanews, lasillavacia,
channelnewsasia, thehindu were returning 403 in production.
2026-02-19 00:24:56 +04:00
Elie Habib 356967f7db fix(ui): repair malformed HTML tags in panel template literals
Spaces inside HTML tags (e.g. `< span class= "trend-up" >`) caused
browsers to render raw markup text instead of elements. Fixed ~45
instances across CIIPanel, ServiceStatusPanel, and StrategicPosturePanel.
2026-02-19 00:24:46 +04:00
Elie Habib 935a58abd4 feat: add live webcams panel with localized labels 2026-02-19 00:01:31 +04:00
Elie Habib de34ccd8d7 Disable intelligence alerts on mdl 2026-02-18 23:51:53 +04:00
Elie Habib 00e5e7299a Improve mobile map popup QA 2026-02-18 23:31:44 +04:00
Elie Habib 943b5e41cd fix(ml): resolve beta mode race condition and timeout failures
- Fix summarization race: check isModelLoaded before attempting browser
  T5, fall through to cloud providers while model loads in background
- Increase modelLoadTimeoutMs 30s→600s and inferenceTimeoutMs 45s→120s
  to accommodate first-time model downloads
- Use flan-t5-small (60MB) instead of flan-t5-base (250MB) in beta mode
  for country brief fallback
- Skip ML summarization in country brief when model not loaded to avoid
  blocking UX
- Broadcast model-loaded notifications from worker on implicit loads so
  manager's loadedModels stays in sync
- Suppress ONNX CleanUnusedInitializersAndNodeArgs warnings via
  ort.env.logLevel
- Fix non-monotonic progress reporting with separate warm/cold step counts
2026-02-18 22:54:15 +04:00
Elie Habib 265a4b7bc7 fix(sentry): guard .closest() call on non-Element event target in Panel dragstart handler
e.target can be a text node when dragging text content inside a panel.
Text nodes lack .closest(), causing TypeError. Add instanceof Element check.

Resolves Sentry P2: TypeError: o.closest is not a function
2026-02-18 22:31:56 +04:00
Elie HabibandGitHub e2926d0ba2 feat(i18n): Comprehensive Localization & Regional Intelligence (#103)
## Summary
This release introduces comprehensive localization support, transforming
WorldMonitor into a truly global intelligence platform.

### Key Features
- **Multilingual UI**: Full support for 12 languages (EN, FR, ES, DE,
IT, PT, NL, SV, RU, AR, CN, JP).
- **RTL Support**: Native right-to-left layout for Arabic and Hebrew,
including mirrored UI components and correct text alignment.
- **Regional Intelligence**: 
  - Dynamic feed selection based on language preference.
- Dedicated regional monitoring panels for Africa, LatAm, Middle East,
and Asia.
  - Granular error handling for regionally blocked feeds.
- **AI Integration**: On-demand translation and summarization of news
items using localized prompts.

### Technical Changes
- **Refactoring**: Overhauled \src/services/i18n.ts\ and
\src/config/feeds.ts\ to support dynamic loading.
- **Styling**: Added \src/styles/rtl-overrides.css\ using logical CSS
properties for maintenance-free RTL support.
- **Cleanup**: Resolved CSS lint warnings and improved code quality.

### Verification
- **Build**: \
pm run build\ passed successfully.
- **Manual Testing**: Verified RTL layout, language switching, and
regional feed loading.
2026-02-18 22:12:18 +04:00
Elie Habib fb7711bfe4 merge: sync latest main and resolve App import conflict 2026-02-18 21:50:43 +04:00
Elie Habib 7446fb53a3 merge: resolve main conflicts in App and feeds for PR #103 2026-02-18 21:49:15 +04:00
Elie HabibandGitHub f87c882fc3 feat: add developer beta mode for T5-small evaluation (#108)
Console-activated (beta=true) mode that swaps browser summarization to
Flan-T5-small (60MB) as first provider with comparison logging against
Groq. Persists via localStorage, shows amber BETA badge in header.

## Summary

<!-- Brief description of what this PR does -->

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->

## Checklist

- [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-18 21:47:17 +04:00
Elie Habib d5f8345509 feat: add developer beta mode for T5-small evaluation
Console-activated (beta=true) mode that swaps browser summarization to
Flan-T5-small (60MB) as first provider with comparison logging against
Groq. Persists via localStorage, shows amber BETA badge in header.
2026-02-18 21:45:52 +04:00
Elie Habib 0564db5e99 fix(i18n): scope caches by locale and lazy-load translations 2026-02-18 21:43:05 +04:00
Elie Habib 06074a2d7b fix: validate API response data before reporting ok, narrow maplibre noise filter to stack-checked beforeSend 2026-02-18 21:06:14 +04:00
Elie Habib 7c46ddeb9e fix: wire up missing API status indicators (GDELT Doc, EIA, USASpending, Cyber Threats)
- GDELT Doc: rename 'GDELT' → 'GDELT Doc' to match StatusPanel allowlist
- EIA: add updateApi calls in loadOilAnalytics
- USASpending: add updateApi calls in loadGovernmentSpending
- Cyber Threats API: add updateApi calls alongside existing feed updates
2026-02-18 20:54:02 +04:00
Elie Habib ad3e2ba529 fix(sentry): broaden Failed to fetch filter to match domain-suffixed variants 2026-02-18 20:44:21 +04:00
Elie Habib 564ea5b433 fix(sentry): narrow shader link filter to null-only to preserve real GLSL error signal 2026-02-18 20:00:18 +04:00
Elie Habib e85b4331bb fix(sentry): filter maplibre internal null-access crashes (light, placement) 2026-02-18 19:57:44 +04:00
Elie Habib 2da05f172d fix: guard YT player .mute()/.unMute() with optional chaining, filter WebGL link errors
- LiveNewsPanel: player.mute/unMute may not exist before onReady (WORLDMONITOR-16)
- main.ts: add /Program failed to link/ noise filter (WORLDMONITOR-18)
2026-02-18 19:55:14 +04:00
Elie Habib 65618da405 fix: add console.warn to silent storage catch blocks for diagnosability 2026-02-18 19:51:49 +04:00
Elie Habib 0d08c23d85 fix: isolate baseline writes from fetch/render and guard snapshot rejections
- Wrap updateBaseline() in try/catch inside loadNewsCategory and intel
  path so IndexedDB write failures don't delete successfully fetched
  and rendered news data (P1)
- Add .catch() to saveCurrentSnapshot() initial call and setInterval
  callback to prevent unhandled promise rejections from IndexedDB
  readwrite failures (P2)
2026-02-18 19:43:14 +04:00
Elie Habib a0f8bcb3bc fix(sentry): safari fullscreen void return, narrow module-import filter, IndexedDB write-drop
- webkitRequestFullscreen returns void (not Promise) on Safari — use
  try/catch instead of .catch() to avoid undefined.catch() throw
- Module-import beforeSend filter: only suppress when stack frames
  originate from browser extensions, not by URL domain check
- withTransaction: throw on readwrite InvalidStateError after retry
  instead of silently returning undefined (prevents write-drop)
2026-02-18 19:39:42 +04:00
Elie Habib dfd5ffcbee fix(sentry): fullscreen Promise catch, narrow fetch filter, and classList guards
- toggleFullscreen: use void .catch() for Promise-based requestFullscreen/
  exitFullscreen + webkit prefix fallback for iOS Safari (WORLDMONITOR-11/13)
- Narrow /^TypeError: Failed to fetch/ to exact match (was suppressing real
  API failures). Move module-import-failed to beforeSend with extension/
  webview context check instead of blanket ignore (WORLDMONITOR-15)
- Guard classList?.contains and target.closest?. on event targets that may
  not be Elements (WORLDMONITOR-Z/10)
- Add noise filters: Fullscreen request denied, requestFullscreen,
  vc_text_indicators_context (WORLDMONITOR-12)
2026-02-18 19:33:58 +04:00
Elie Habib ffd99b0c58 fix(sentry): guard deck.gl setProps during WebGL context loss and add noise filters
Prevent getProjection null crash when WebGL context is lost by tracking
webglLost flag and skipping all setProps/layer rebuild calls until restored.
Add ignoreErrors for IndexedDB iOS kills, Twitter WebView injection, and
CSP unsafe-eval from extensions.
2026-02-18 18:05:30 +04:00
Elie Habib 7bbae36cb7 fix(sentry): broaden Failed to fetch filter to catch domain-suffixed variants
Browser extensions intercept window.fetch causing "Failed to fetch
(gamma-api.polymarket.com)" to leak as unhandled rejection. Remove
the $ anchor so the pattern matches any suffix.
2026-02-18 17:45:51 +04:00
Elie Habib 065fae2ed8 fix: expand Sentry noise filtering and address code review findings
- Add beforeSend filter to drop minified 1-3 char library errors (e.g., "vd")
- Filter transient network errors (Load failed, Failed to fetch, cancelled)
- Filter browser extension errors (runtime.sendMessage, Java object is gone)
- Filter non-Error promise rejections and SVG image load failures
- Filter MapLibre imageManager null ref during WebGL context restore
- Reset YouTube API promise on load failure to allow retry on next init
- Move USASpending timeout cleanup to finally block
- Log snapshot cleanup errors instead of silently swallowing
2026-02-18 17:42:52 +04:00
Elie Habib 609e372a3d fix: gracefully handle IndexedDB connection-closing errors on iOS
- withTransaction now returns undefined instead of throwing when
  InvalidStateError persists after retry (transient browser event)
- Add .catch() to fire-and-forget cleanOldSnapshots() call
2026-02-18 15:24:24 +04:00
Elie Habib 12d85320e2 fix: filter non-actionable Sentry errors (autoplay, WebView, deck.gl, extensions)
- Add NotAllowedError, InvalidAccessError, importScripts to Sentry ignoreErrors
- Add global unhandledrejection handler for YouTube IFrame API autoplay blocks
- Add onError handler to deck.gl MapboxOverlay for internal render-cycle races
2026-02-18 15:22:53 +04:00
Elie Habib 54f417d8fc fix: resolve Sentry errors — IndexedDB stale connection, USASpending timeout, WebGL context loss, RSS 403s
- storage.ts: add withTransaction() retry wrapper for IndexedDB InvalidStateError on iOS/Safari tab backgrounding
- usa-spending.ts: add 20s AbortController timeout to prevent Safari "Load failed" on stalled POST
- App.ts: add catch to runGuarded() to prevent unhandled rejections from task runner
- main.ts: add Sentry ignoreErrors for WebGL context loss and ResizeObserver loop
- DeckGLMap.ts: add webglcontextlost/restored handlers for graceful GPU recovery
- feeds.ts: route rsshub.app feeds (NHK, MIIT, MOFCOM) through Railway proxy, switch Nikkei Asia and ECFR to Google News proxy
- finance.ts: switch Nikkei Asia to Google News proxy, remove unused railwayRss helper
2026-02-18 14:25:59 +04:00
Elie Habib 30e2dda448 fix: gracefully handle YouTube IFrame API load failure
Resolve instead of reject when the script fails to load (ad blocker,
network issue). Guard initializePlayer against missing YT.Player.
Prevents noisy unhandled rejection errors in Sentry.
2026-02-18 14:07:23 +04:00
Elie Habib 941475eb79 feat: integrate Sentry browser error tracking
Initializes @sentry/browser early in main.ts with environment
detection (production/preview/development). Disabled on localhost
and Tauri desktop. Traces sampled at 10%.
2026-02-18 13:56:31 +04:00
Lib-LOCALE f8270689a0 feat(i18n): comprehensive localization, RTL support, and regional feeds revamp 2026-02-18 10:38:17 +01:00
Elie HabibandGitHub 71a62e5bb4 fix: gate SignalModal auto-popup on findings toggle (#101)
## Summary
- PR #97 hid the badge but the `SignalModal` kept auto-opening on new
signals — this is what the reporter was still seeing
- Gates all 5 automatic `this.signalModal?.show()` calls behind
`this.findingsBadge?.isEnabled()` so disabling Intelligence Findings
also suppresses the full-screen popup overlay and sounds
- Signal history is still recorded (`addToSignalHistory`) even when
popup is suppressed, so re-enabling the toggle shows them

Closes #89

## Test plan
- [x] Disable Intelligence Findings via PANELS toggle or right-click
- [x] Wait for signal refresh cycle — no full-screen popup should appear
- [x] Re-enable → popups resume on next signal detection
- [x] Build succeeds with no type errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-18 13:16:17 +04:00
Elie HabibandGitHub cb242f1b2c feat: add La Silla Vacía (Colombia) to LATAM feeds (#102)
## Summary
- Adds [La Silla Vacía](https://www.lasillavacia.com) RSS feed (`/rss`)
to the `latam` feed category
- Adds source tier entry (Tier 3 — specialty/investigative)
- Colombian independent outlet covering political power structures,
governance, and armed conflict

Ref #96

## Test plan
- [ ] Verify feed loads in LATAM news panel (content is in Spanish)
- [ ] Confirm no duplicate or broken entries in feed list

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-18 13:15:33 +04:00
Sebastien MelkiandClaude Opus 4.6 dae0de6d99 feat: add La Silla Vacía (Colombia) to LATAM feeds
Add lasillavacia.com RSS feed to improve Latin American political
coverage. Independent Colombian investigative outlet covering governance,
armed conflict, and regional power dynamics.

Ref #96

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:51:31 +02:00
Sebastien MelkiandClaude Opus 4.6 0aafe16fe0 fix: gate SignalModal auto-popup on findings toggle
PR #97 only hid the badge itself but the SignalModal kept auto-opening
on new signals. Gate all 5 automatic signalModal.show() calls behind
findingsBadge.isEnabled() so disabling Intelligence Findings also
suppresses the full-screen popup overlay.

Closes #89

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:24:28 +02:00
Elie Habib 922092c653 docs: add v2.3.9 changelog 2026-02-18 08:25:47 +04:00
Elie HabibandGitHub 55e83b1cfe feat(i18n): full internationalization — 14 locales, zero hardcoded English + Linux AppImage support (#100)
## Summary
- Full i18n system with 14 locales: en, fr, de, es, it, pl, pt, nl, sv,
ru, ar (RTL), zh, ja — all at 1132-key parity
- Eliminated ~110 hardcoded English strings across 50+ source files,
replaced with `t()` calls
- RTL support for Arabic with proper regional code normalization (ar-SA
→ ar)
- Dead English fallback literals (`t() || 'English'`) removed from all
components
- Community discussion floating widget (localized)
- Linux AppImage desktop build support
- Proper noun heuristic fallback for trending keywords when ML
unavailable

## Key changes
- **New**: `src/services/i18n.ts` — i18next setup with language
detection, RTL, locale switching
- **New**: 13 locale JSON files (1132 keys each) in `src/locales/`
- **New**: `src/styles/rtl-overrides.css` +
`src/styles/lang-switcher.css`
- **Modified**: 50+ components/services to use `t()` instead of
hardcoded strings
- **Modified**: `.github/workflows/build-desktop.yml` — Linux CI matrix
- **Modified**: `scripts/desktop-package.mjs` + `download-node.sh` —
Linux target support

## Test plan
- [ ] Verify language switcher shows all 14 languages
- [ ] Switch to Arabic — confirm `dir="rtl"` on `<html>`, layout mirrors
- [ ] Switch to Japanese — confirm all panel labels, tooltips, popups
render in Japanese
- [ ] Switch to French — confirm no English leaks in panels, modals, map
legend
- [ ] Verify `{{count}}` interpolation works in timeAgo strings
- [ ] Verify `tsc --noEmit` passes (confirmed locally)
- [ ] Test community widget dismiss/localStorage persistence
2026-02-18 08:21:13 +04:00
Elie Habib 6e2c6034e4 merge: resolve conflicts with main (community widget, trending keywords, CSS) 2026-02-18 08:19:48 +04:00
Elie Habib 179e9c1687 chore: bump version to 2.3.9 2026-02-18 08:16:36 +04:00
Elie Habib 19683aa1fe fix: community widget idempotency guard + suppress missing i18n help keys
- CommunityWidget: add DOM check to prevent duplicate widgets on repeated loadNews() calls
- RuntimeConfigPanel: compare t() result against key path to suppress missing help translations
2026-02-18 08:11:59 +04:00
Elie Habib 8ef6e11bed feat(feeds): add NHK World and Nikkei Asia RSS feeds for Japan coverage
Main variant: NHK World + Nikkei Asia in asia category.
Finance variant: Nikkei Asia in markets category.
Added asia.nikkei.com to RSS proxy allowlist.
2026-02-18 08:07:07 +04:00
Elie Habib c287e3dfba feat(feeds): add NHK World and Nikkei Asia RSS feeds for Japan coverage
Main variant: NHK World + Nikkei Asia in asia category.
Finance variant: Nikkei Asia in markets category.
Added asia.nikkei.com to RSS proxy allowlist.
2026-02-18 08:06:55 +04:00
Elie Habib 6b87bbb2b0 fix(i18n): remove dead English fallback literals in StoryModal
t() always returns a string, so || 'English' fallbacks were
unreachable. Removed all 15 instances.
2026-02-18 07:50:31 +04:00
Elie Habib 95ce713cf7 fix(i18n): remove dead English fallback literals in CountryIntelModal
t() always returns a string (key itself if missing), so || 'English'
fallbacks were unreachable dead code.
2026-02-18 07:43:34 +04:00
Elie Habib a242761b8a feat(i18n): add Japanese locale (1132 keys)
New ja.json with full translation parity to en.json.
Registered in i18n.ts: import, resources, supportedLngs, LANGUAGES, getLocale map.
2026-02-18 07:42:19 +04:00
Elie Habib bb62babb12 fix(i18n): replace remaining hardcoded English in source and locales
- CommunityWidget: aria-label="Close" → t('common.close')
- App.ts: ' (current)' suffix → t('common.currentVariant')
- fr.json: "Cloud & Infrastructure" → "Cloud et infrastructure"
- sv.json: "24h Vol" → "24t volym"
- Added common.close + common.currentVariant to all 12 locales
2026-02-18 07:28:02 +04:00
Elie Habib 4ccf6cf21f fix(i18n): normalize regional language codes in RTL detection
applyDocumentDirection() now splits on '-' before checking RTL set,
so ar-SA correctly triggers dir="rtl" on first detect.
2026-02-18 07:23:59 +04:00
Elie Habib a9f6909a20 fix(i18n): fix 5 P1/P2 bugs — timeAgo count, community widget, Linux AppImage, locale gaps, lang normalization
- Fix relative-time {{count}} placeholders in all 12 locales (5m ago, not m ago)
- Localize CommunityWidget: replace 3 hardcoded English strings with t() calls
- Add Linux AppImage to tech/finance Tauri configs, CI matrix (ubuntu-22.04), packaging scripts, and download-node.sh
- Fix language code normalization: add supportedLngs/nonExplicitSupportedLngs to i18next, normalize getCurrentLanguage()
- Translate ~240 untranslated English strings across 11 locale files (ru/ar/zh now 100% translated)
- Add components.community section to all 12 locales
- All 12 locales at 1130-key parity, 0 placeholder mismatches
2026-02-18 07:16:47 +04:00
Elie Habib 7e17e97a8d feat(i18n): eliminate ~95 hardcoded English strings and remove dead UTC clock
Replace hardcoded English text with t() calls across 12 source files:
signal context descriptions, alert titles/summaries, intel topic
names, map legend labels, asset/threat labels, UCDP panel headers,
stablecoin/status panel sections. Add ~114 new keys to en.json and
translate to all 11 locales (fr/de/es/it/pl/pt/nl/sv/ru/ar/zh).
Remove dead #timeDisplay span that was never updated by JS.

All 12 locales at 1127-key parity with 0 placeholder mismatches.
2026-02-18 06:55:16 +04:00
Elie Habib c92de74884 fix(css): restore intel-findings context menu styles removed in 6750dbf 2026-02-18 01:41:20 +04:00
Elie Habib e3cfd16f4b fix(community): point discussion link to main discussions page 2026-02-18 01:39:53 +04:00
Elie Habib 3088b34a88 style(community): vibrant green accent for discussion pill widget 2026-02-18 01:36:22 +04:00
Elie Habib 1955134fa2 feat(i18n): add Arabic (RTL) and Chinese Simplified locales
- Add ar.json and zh.json translations (1013 keys each, full parity)
- RTL support: dir="rtl" on <html>, targeted CSS overrides in rtl-overrides.css
- Font fallbacks: system Arabic/CJK fonts via --font-body CSS variable
- i18n.ts: RTL_LANGUAGES set, applyDocumentDirection(), isRTL(), getLocale()
- story-renderer.ts: locale-aware date formatting via getLocale()
- Type declarations for both new locales
2026-02-18 01:26:37 +04:00
Elie Habib 4a5dfa0d54 fix(css): remove broken @import for missing lang-switcher and rtl-overrides
These imports were accidentally committed from the i18n PR branch.
The files don't exist on main, causing Vercel build errors.
2026-02-18 01:10:38 +04:00
Elie Habib 7dcfcb25f0 fix(trending): proper-noun heuristic handles headline-start and lowercase terms
P1: Start-of-headline matches (idx=0) no longer count against the
capitalization ratio. When all matches are headline-start only,
falls back to acronym check instead of returning false.

P2: Removed dead [A-Z]{2,} shortcut — terms are lowercased by
asDisplayTerm() before reaching isLikelyProperNoun.
2026-02-18 01:07:41 +04:00
Elie Habib 6750dbfd08 feat: add community discussion floating pill widget
Floating pill badge (bottom-right) with pulsing dot, "Join the
Discussion" text, and CTA linking to GitHub Discussions #94.
Shows 15s after initial data load. "Don't show again" persists
dismissal to localStorage (wm-community-dismissed).
2026-02-18 01:05:24 +04:00
Elie Habib 19d45e967e feat: add floating pill community discussion widget
Shows a compact pill badge (bottom-right) inviting users to the GitHub
Discussions page. Includes pulsing dot indicator, CTA button, close
button, and "Don't show again" option that persists to localStorage.
2026-02-18 00:57:03 +04:00
Elie Habib 5f5c135e07 fix(trending): add proper-noun heuristic fallback when ML unavailable
When the NER model isn't loaded, isSignificantTerm was returning true
for all terms, letting common words like "here" trigger keyword spike
findings. Now falls back to a capitalization heuristic: single-word
terms must appear capitalized (mid-sentence) in >50% of headlines to
be treated as significant. Also added ~45 missing English stopwords.
2026-02-18 00:45:11 +04:00
Elie Habib 40b63d4d3d fix(trending): add proper-noun heuristic fallback when ML unavailable
When the NER model isn't loaded, isSignificantTerm was returning true
for all terms, letting common words like "here" trigger keyword spike
findings. Now falls back to a capitalization heuristic: single-word
terms must appear capitalized (mid-sentence) in >50% of headlines to
be treated as significant. Also added ~45 missing English stopwords.
2026-02-18 00:45:07 +04:00
Elie Habib 3440c83557 feat(i18n): add Russian locale and fix key parity across all 10 languages
Audit found missing keys in all non-EN locales (62-90 per file) and 25
stale keys in PT/NL/SV. Fixes gaps, removes stale keys, corrects FR
infra key misplacement, and adds complete Russian (ru) translation with
1013/1013 keys matching the English reference.
2026-02-18 00:41:20 +04:00
Elie Habib bff888a942 fix(i18n): localize remaining map/tooltips/popups and close locale gaps 2026-02-18 00:04:41 +04:00
Elie Habib 5d778fb2c4 fix(i18n): localize remaining UI literals and expand locale coverage 2026-02-17 22:16:05 +04:00
Elie Habib 182c419ebb fix(trending): add missing English stopwords to suppressed terms
"here" and other basic English words (pronouns, prepositions, adverbs)
were not in SUPPRESSED_TRENDING_TERMS, causing false keyword spike
findings for common words.
2026-02-17 21:16:17 +04:00
Elie Habib 641a7f4bb9 fix(trending): add missing English stopwords to suppressed terms
"here" and other basic English words (pronouns, prepositions, adverbs)
were not in SUPPRESSED_TRENDING_TERMS, causing false keyword spike
findings for common words.
2026-02-17 21:16:02 +04:00
Elie Habib 6c4e1c3f41 fix(i18n): close key coverage gaps and localize merged UI strings 2026-02-17 21:11:39 +04:00
Elie Habib 2a32213b2f fix(css): define --panel-bg and --panel-border theme variables
These CSS custom properties were used in 14 places but never defined,
causing transparent backgrounds on playback panel, toggle button,
header flash animation, select options, and offline retry button.
2026-02-17 21:00:33 +04:00
Elie Habib 30be26e779 merge(main): update PR #86 and resolve merge conflicts 2026-02-17 20:58:31 +04:00
Elie HabibandGitHub 8dae6889ea feat: add toggle to disable Intelligence Findings badge (#97)
## Summary
- Adds ability to hide the Intelligence Findings badge for passive/TV
viewing — stops polling, sounds, and pulse animations when disabled
- Two ways to toggle: right-click context menu on the badge, or the
PANELS settings modal
- Preference persists in `localStorage` across page reloads

Closes #89

## Test plan
- [ ] Load app — badge shows by default (no regression)
- [ ] Right-click badge → "Hide Intelligence Findings" → badge
disappears, no polling/sounds
- [ ] Open PANELS → "Intelligence Findings" toggle shows as disabled →
click to re-enable → badge reappears
- [ ] Refresh page → preference persists
- [ ] Build succeeds with no type errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-17 20:48:11 +04:00
Elie Habib 0efc819689 fix: resolve findings toggle state and badge listener lifecycle 2026-02-17 20:46:08 +04:00
Sebastien MelkiandClaude Opus 4.6 f3678b5d8b feat: add toggle to disable Intelligence Findings badge
Allow users to hide the Intelligence Findings badge for passive viewing
(e.g. TV displays). The badge can be disabled via right-click context
menu or the PANELS settings modal. Preference persists in localStorage.

Closes #89

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:37:09 +02:00
Elie Habib fe3fe09c41 chore(release): bump version to 2.3.8 2026-02-17 20:14:16 +04:00
Elie Habib 073cf9e42b chore: retrigger Vercel deploy 2026-02-17 20:08:35 +04:00
Elie Habib 5fc260ea2c chore: trigger Vercel redeploy (edge function transient failure) 2026-02-17 20:02:51 +04:00
Elie Habib c62592d81b feat: add BBC Persian, Iran International, Fars News, MIIT & MOFCOM feeds
- Middle East panel: BBC Persian (direct RSS), Iran International and
  Fars News (Google News fallbacks — no public RSS endpoints)
- Asia panel: MIIT and MOFCOM China government feeds via RSSHub
2026-02-17 19:55:02 +04:00
Elie Habib 52ff869f57 fix: replace 7 more dead/blocked tech RSS feeds with Google News fallbacks
Sequoia Blog, EU Startups, Tech in Asia, LAVCA, YC Launches,
Dev Events, and SemiAnalysis were returning 403/404/timeout errors.
2026-02-17 19:32:15 +04:00
Elie Habib 95a92befec fix: guard invalid RSS dates and replace dead/blocked feed URLs
- Guard against Invalid Date in RSS parser — malformed pubDate strings
  from IAEA/CrisisWatch feeds caused RangeError on toISOString()
- Replace 13 dead/blocked RSS feed URLs (403/404/500) with Google News
  site-scoped fallbacks: Politico, RUSI, Kyiv Independent, War Zone,
  MEI, Wilson Center, GMF, CNAS, Lowy, Arms Control, Bulletin, EU ISS
2026-02-17 19:27:07 +04:00
Elie Habib 54f1a5d578 test: add coverage for finance/trending/reload and stabilize map harness 2026-02-17 19:22:55 +04:00
Elie HabibandGitHub e0c6aa32be feat: auto-detect OS in download banner (#93)
## Summary
- Detects user's OS via `navigator.userAgent` and shows only the
relevant download button (Windows, macOS Apple Silicon, or macOS Intel)
- Uses WebGL renderer info to distinguish Apple Silicon from Intel Macs
where possible
- Adds a "Show all platforms" toggle so users can still access other
platform downloads
- Falls back to showing all 3 buttons if OS can't be detected

## Test plan
- [x] Open the web app on a Mac — should see only the macOS (Apple
Silicon) button
- [x] Open on Windows — should see only the Windows button
- [x] Click "Show all platforms" — all 3 buttons should appear
- [x] Click "Show less" — should collapse back to the detected platform
- [x] Spoof an unrecognized user agent — all 3 buttons should show (no
toggle)

Replaces #91
Closes #90

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-17 19:18:09 +04:00
Elie Habib de88ef8719 fix: show both Mac options (not Windows) for undetected Mac arch
Add 'macos' platform type for Macs where WebGL can't determine
Apple Silicon vs Intel. Shows both Mac download buttons without
the irrelevant Windows option.
2026-02-17 19:03:42 +04:00
Elie Habib b508db26bd fix: detect Intel Macs and fall back to showing both Mac options
WebGL renderer check now also detects Intel GPUs to return macos-x64.
When architecture can't be determined, return unknown so users see
both Apple Silicon and Intel download buttons instead of defaulting
to the wrong binary.
2026-02-17 18:54:29 +04:00
Elie Habib 922ed3fa62 Merge pull request #92 from koala73/fix/map-no-repeat-no-labels 2026-02-17 18:48:48 +04:00
Elie Habib 44b52e799d fix: persist country highlight across theme switches
Store the active highlighted ISO code so it can be re-applied after
setStyle() rebuilds map layers with empty default filters.
2026-02-17 18:44:51 +04:00
Elie Habib 0fafa2aea8 fix: rehydrate country layers after setStyle theme switch
setStyle() replaces all map sources/layers. Country boundaries were
only loaded once (guarded by countryGeoJsonLoaded) so they vanished
after theme toggle with no way to re-add them.

- Reset countryGeoJsonLoaded in switchBasemap so loadCountryBoundaries
  can re-run after the new style loads
- Listen for style.load before re-adding country source/layers
- Guard setupCountryHover with countryHoverSetup flag to prevent
  duplicate mousemove/mouseout listeners on re-load
- Apply theme-correct paint values after layer creation
2026-02-17 18:36:59 +04:00
Elie Habib b2509669a5 docs: add finance variant, Gulf FDI, tri-variant architecture to README 2026-02-17 16:52:33 +04:00
Elie Habib efba9a77a6 fix(map): prevent popups from overflowing below viewport edge
Add bottom viewport clamp so popups near the map's bottom edge slide
upward to remain fully visible instead of being cut off.
2026-02-17 16:48:53 +04:00
Sebastien MelkiandClaude Opus 4.6 4ac9ad4c00 feat: auto-detect OS in download banner, show only relevant platform
Closes #90

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:25:47 +02:00
Sebastien MelkiandClaude Opus 4.6 ad1e78a5c4 fix: disable map repetition & use English-only vector labels
Switch from CARTO dark_all raster tiles (which showed continent names
in local languages like 亚洲, AFRIKA, أفريقيا) to CARTO Dark Matter
vector style with English labels. Set renderWorldCopies: false to
prevent horizontal map duplication. Use interleaved deck.gl overlay
so basemap labels render above data layers.

Closes #81

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:24:04 +02:00
Elie Habib e383749926 chore: add finance variant to GitHub issue templates 2026-02-17 16:14:31 +04:00
Elie Habib 196c79c023 fix(cors): add finance.worldmonitor.app to Railway proxy allowlist
Polymarket and other proxied requests from finance variant were blocked
by CORS because the Railway relay only allowed worldmonitor.app and
tech.worldmonitor.app origins.
2026-02-17 16:02:32 +04:00
Elie Habib bfd4cfdd41 fix(finance): enable GCC investments layer and stabilize GCC feeds 2026-02-17 15:55:20 +04:00
Elie Habibandaa5064 cd139e99ee feat(finance): integrate GCC investments into finance variant
Wire Gulf FDI data from PR #61 into the existing finance variant:
- Add gulfInvestments map layer (ScatterplotLayer with SA/UAE colors)
- Add GCC Business News feeds (Arabian Business, The National, Arab News, etc.)
- Add gcc-investments and gccNews panels to finance panel config
- Add Gulf FDI types to MapLayers interface
- Add RSS proxy domains for Gulf news sources
- Auto-wiring creates NewsPanel for gccNews feed category

Co-authored-by: aa5064 <261283889+aa5064@users.noreply.github.com>
2026-02-17 15:31:58 +04:00
aa5064andElie Habib cfbd1ad8a1 feat: add Gulf FDI investment database and panel component
772-line database of Saudi Arabia and UAE foreign direct investments in
global critical infrastructure (ports, energy, data centers, railways, etc.)
with filterable/sortable table panel.

Originally contributed as standalone 'infra' variant in PR #61.
2026-02-17 15:31:37 +04:00
Elie Habib 8c8fb0ae0a fix: resolve P2 reload guard and harness assertion drift 2026-02-17 12:25:34 +04:00
Elie Habib 4a639c79c6 feat(header): move UTC clock from map overlay to centered header bar
- Add centered header-clock in GLOBAL SITUATION panel header
- Remove map-timestamp from Map.ts and DeckGLMap.ts
- Clock ticks every second with monospace font
2026-02-17 12:00:23 +04:00
Elie Habib fb512e7918 fix(trending): suppress month names and strip source attributions from titles
- Add goldman, sachs, off, and all 12 month names to SUPPRESSED_TRENDING_TERMS
- Add stripSourceAttribution() to remove feed name suffixes (e.g. "- Wall Street Journal") from RSS titles before keyword extraction
2026-02-17 11:34:28 +04:00
Elie Habib a78a072079 fix(deploy): prevent stale chunk 404s after Vercel redeployment
- vercel.json: add no-cache headers for / and /index.html so CDN never serves stale HTML
- main.ts: add vite:preloadError listener to auto-reload once on chunk 404s
- vite.config.ts: remove HTML from SW precache, add NetworkFirst for navigation requests
2026-02-17 11:30:58 +04:00
Lib-LOCALE 51e6f3553d i18n: restructure and translate popups section across all locales
- Restructure popups keys in de/es/it/pl/nl/pt/sv to match en.json nested structure
- Move orphaned top-level keys (earthquake, base, protest, flight, nuclear, cable, pipeline, etc.) into popups.* namespace
- Add full native translations for ~200 popup keys per locale
- Ensures i18next resolves popups.* keys correctly for all languages
2026-02-17 06:59:24 +01:00
Elie Habib 9a31eeba09 chore(release): finalize 2.3.8 changelog and version 2026-02-17 09:53:03 +04:00
Elie Habib a524ab3ad7 merge(main): integrate finance variant and resilience hardening
Merge branch 'feature/finance-variant' into main.

Changelog (2.3.8):
- add dedicated finance variant with finance/trading focused feeds, panels, and desktop profile
- stage feed category loading with bounded concurrency to reduce startup pressure
- add AI classification admission control and tighter non-finance budgets
- harden external source behavior (Polymarket fallback probing, FRED graceful empty payloads)
- replace brittle MarketWatch direct RSS usage with resilient Google News fallbacks across variants
- keep finance data-rich panels while adding news panels, and ensure first-class desktop Deck.GL behavior
2026-02-17 09:50:56 +04:00
Elie Habib 26c03ca545 fix(trending): suppress noisy finance/generic terms from keyword spikes
Add 28 terms to SUPPRESSED_TRENDING_TERMS: common finance jargon
(trading, stock, earnings, ipo, currency, dollar, usd, etc.),
generic news words (today, chief, focus, basel), date fragments,
and web artifacts (com, platform, block).
2026-02-17 09:47:51 +04:00
Elie Habib b2c3144a7e Harden full and tech variants against feed/API pressure 2026-02-17 09:46:25 +04:00
Elie Habib 116fc80fc7 Merge remote-tracking branch 'origin/main' into feature/finance-variant
# Conflicts:
#	index.html
#	src/components/DeckGLMap.ts
2026-02-17 09:37:47 +04:00
Elie Habib ed9cc922e2 Fix finance variant runtime resiliency and API pressure 2026-02-17 09:34:15 +04:00
Elie Habib 5382e79ac7 fix(finance): keep data panels and add desktop finance map layers 2026-02-17 09:06:35 +04:00
Elie Habib ef2b67ad22 fix(finance): address PR review blocking issues
- Add missing RSS proxy domains (seekingalpha, coindesk, cointelegraph)
- Fix operator precedence in finance marker zoom checks (explicit parens)
- Add Number.isFinite() NaN guards on all finance marker projections
- Include finance variant in aggregated test:e2e script
2026-02-17 08:38:25 +04:00
Elie Habib 2ac23dcabf fix: wire timeline filtering across map and news panels 2026-02-17 08:12:13 +04:00
Elie Habib b1e917972e fix(banner): hide desktop download prompt on mobile devices 2026-02-17 07:54:02 +04:00
Elie Habib 0ea47330ba fix(panels): standardize error messages with specific causes
Differentiate missing API key, upstream down, and empty data states.
Name the actual data source in error messages instead of generic text.
2026-02-17 00:24:55 +04:00
Elie Habib 65dea2e4f9 fix(desktop): batch keychain reads to reduce macOS password prompts
Add get_all_secrets command that reads all 18 keys in a single IPC call.
This triggers one Keychain prompt instead of 18 on fresh installs.
2026-02-17 00:24:44 +04:00
Elie Habib 631a5e2112 chore(ai): switch OpenRouter model to auto-routed free tier 2026-02-17 00:15:11 +04:00
Elie Habib 88ad25cb93 release: v2.3.7
Full light mode theme, header dark/light toggle, desktop update checker,
bundled Node.js in installer, CORS fixes, and panel defaults update.
2026-02-16 23:56:28 +04:00
Elie Habib 49d5100480 feat(panels): enable UCDP, UNHCR, Climate, and Population panels by default 2026-02-16 23:53:21 +04:00
Elie Habib c2992c2fae refactor(header): remove redundant theme toggle from panels modal
Now that the header has a sun/moon dark/light toggle, the Appearance
section in the settings modal is redundant. Revert modal title from
"Settings" back to "Panels" and remove theme radio buttons and CSS.
2026-02-16 23:48:39 +04:00
Elie Habib 9f0a2107ed chore: add ideas/ to .gitignore 2026-02-16 23:38:01 +04:00
Elie Habib 72d191c6db chore: add markdown linting config and CI workflow 2026-02-16 23:34:01 +04:00
Elie Habib c31dfbb70e docs: update documentation, release packaging, and validation report 2026-02-16 23:33:06 +04:00
Elie Habib c94ec0b4ad Adding Node in the Tauri Installer 2026-02-16 23:30:19 +04:00
Elie Habib 9a7a988846 feat(header): replace UTC clock with dark/light mode toggle
Remove the duplicate time display from the header bar and add a
theme toggle button (sun/moon icon) that hooks into the existing
theme-manager. Bidirectional sync with settings modal radios.
2026-02-16 23:20:32 +04:00
Elie Habib b59235fc45 CORS fixes for Tauri desktop app 2026-02-16 23:14:08 +04:00
Elie Habib cccfdac0f9 feat(update): add desktop update check with architecture-aware download links 2026-02-16 23:13:10 +04:00
Elie Habib 2e15ee6815 fix(markets): keep Yahoo-backed data visible when Finnhub is skipped 2026-02-16 23:12:00 +04:00
Elie Habib 60cec92a16 fix(windows): preserve UNC paths when sanitizing sidecar script path 2026-02-16 23:09:30 +04:00
Elie HabibandGitHub 6c41fec3bb feat: add light mode theme with full UI, map, and chart theming (#84)
## Summary

Adds a complete dual-theme system to WorldMonitor. Users can toggle
between dark and light mode in Settings, with the preference persisted
across sessions. Every panel, the map, charts, and all chrome are fully
themed.

**41 files changed, +1,591 / -1,128 lines**

## What's included

### CSS Foundation (Phase 1)
- 124+ hardcoded colors centralized into CSS custom properties
- Theme colors separated from semantic colors (threat reds, DEFCON,
status badges stay identical)
- `getCSSColor()` runtime utility with theme-aware cache for dynamic TS
components

### Theme Core (Phase 2)
- `ThemeManager` module with `get/set/apply` theme functions and
localStorage persistence
- FOUC prevention inline scripts in both HTML entry points
- Dark/Light radio toggle in Settings → Appearance section

### Map & Visualization Theming (Phase 3)
- Map auto-swaps between dark CARTO and light Voyager tiles (no flash)
- Deck.GL overlay layers adjust opacity/saturation per theme for
readability
- D3 CountryTimeline chart colors converted to theme-aware
`getCSSColor()`

### Polish & Accessibility (Phase 4)
- Smooth 200ms CSS transitions for all theme switching
- WCAG AA contrast compliance (`--text-muted` #767676, 4.54:1 ratio)
- Both build variants (full geopolitical + tech/startup) verified
working

## Demo

### Toggle dark ↔ light


https://github.com/user-attachments/assets/ce8d3bbe-fb5e-49cb-9bbd-5da5d900a15a

### Dark mode (unchanged)

<img width="5120" height="2648" alt="image"
src="https://github.com/user-attachments/assets/6a055ae6-e9a9-4d85-b328-a035b7bcd165"
/>

### Light mode

<img width="5120" height="2650" alt="image"
src="https://github.com/user-attachments/assets/3531c952-a801-43a5-8ef3-68c160fb85b8"
/>


## Review focus areas

1. **`src/styles/main.css`** — Bulk of the CSS variable conversion (~12K
lines). Check that `var()` references and `[data-theme="light"]`
overrides look correct.
2. **`src/utils/theme-manager.ts`** — New module. Simple but critical:
localStorage read/write, event dispatch, FOUC prevention.
3. **`src/utils/theme-colors.ts`** — `getCSSColor()` cache utility.
Verify cache invalidation on theme change is correct.
4. **`src/components/DeckGLMap.ts`** — Basemap tile swap logic and
`getOverlayColors()` per-theme adjustments.
5. **`index.html` / `settings.html`** — Inline FOUC prevention scripts.
CSP updated with `unsafe-inline` for script-src.
6. **`src/App.ts`** — Theme toggle UI wiring in settings modal. Check
event listener patterns.

## What NOT to worry about

- ~99 remaining hardcoded colors are documented acceptable exclusions
(canvas drawing, console.log, deprecated constants with migration paths,
category identifier colors)
- Dark mode is visually identical to before — no regressions

## Test plan

- [x] Run `npm run build` — builds without errors for both variants
- [x] Open app — dark mode loads by default, no FOUC
- [x] Go to Settings → Appearance → select Light — all panels, header,
sidebar switch smoothly
- [x] Verify map switches from dark CARTO to light Voyager tiles (no
blank flash)
- [x] Refresh page — light mode persists, no FOUC
- [x] Switch back to dark — everything returns to original look
- [x] Check semantic colors (threat dots, DEFCON badges, LIVE
indicators) identical in both themes
- [x] Open DevTools → inspect text contrast in light mode (should be
≥4.5:1)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-16 22:56:03 +04:00
Elie Habib d94dc32d3b fix: use darken-* variables for dark overlay backgrounds
overlay-heavy is rgba(255,255,255,0.2) in dark mode (lightening tint),
but 11 places originally used rgba(0,0,0,x) (darkening tint). This
caused visible regressions in dark mode (channel bar, CII cards, etc).

Adds --darken-light/medium/heavy CSS variables that stay black-tinted
in both themes, and applies them to the affected selectors.
2026-02-16 22:49:54 +04:00
Sebastien MelkiandClaude Opus 4.6 346b974ef6 fix: darken neon semantic colors for light mode readability
Add light-mode overrides for 12 semantic CSS variables that had
terrible contrast on light backgrounds (as low as 1.25:1 for #44ff88).
Uses Tailwind's light-friendly palette (green-600/700, amber-600,
orange-600/700, yellow-600, sky-600).

Also darken DeckGL overlay marker colors (startup hub, accelerator,
nuclear, datacenter) and their legend SVGs in light mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 18:03:04 +02:00
Sebastien MelkiandClaude Opus 4.6 297e9cf218 chore: remove .planning files from git tracking
These are local development/planning files that should be gitignored.
Already covered by .gitignore entries for .planning/ and .idea/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 db5eff4300 feat(04-01): add no-transition class lifecycle for FOUC prevention
- index.html and settings.html FOUC scripts add no-transition class to <html>
- src/main.ts removes no-transition after first paint via requestAnimationFrame
- src/settings-main.ts removes no-transition after first paint via requestAnimationFrame
- Prevents transition sweep from dark-to-light on page load while enabling smooth theme toggles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 78e0478228 feat(04-01): add theme transition CSS rules and fix text-muted contrast
- Add wildcard 200ms ease transition on background-color, color, border-color, box-shadow
- Add .no-transition suppression rule with !important for FOUC prevention
- Add canvas/maplibregl/deck-canvas transition exclusion to prevent rendering artifacts
- Fix --text-muted in light theme from #8a8a8a to #767676 (WCAG AA 4.54:1 vs #f8f9fa)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien Melki d450ef0f4c docs(03-01): complete DeckGL basemap and overlay theming plan
- Create 03-01-SUMMARY.md with execution results
- Update STATE.md with Phase 3 completion, metrics, and decisions
2026-02-16 17:17:30 +02:00
Sebastien Melki fa9b361754 feat(03-01): make Deck.GL overlay layer colors theme-aware
- Replace static COLORS constant with getOverlayColors() function
- Refresh COLORS at top of buildLayers() on each render cycle
- Conflict zone fills more transparent in light mode (alpha 60 vs 100)
- Conflict zone line color reduced alpha in light mode (120 vs 180)
- Displacement arc colors deeper/more saturated on light backgrounds
- Threat dots remain identical in both modes (user locked decision)
- Infrastructure markers unchanged (semantic category colors)
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 4e623cd5cb feat(03-02): update Map light theme CSS and add theme-change re-render
- Update --map-country to warm cream (#f0e8d8) for Voyager-style land
- Update --map-stroke to warm border (#c8b8a8) complementing cream land
- Subtler blue grid (#b0c8d8) for light mode
- Add theme-changed event listener in Map.ts resetting baseRendered flag
- Dark theme map values unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 72851b9936 feat(03-02): convert CountryTimeline hardcoded colors to theme-aware getCSSColor()
- Replace 8+ hardcoded rgba/hex colors with getCSSColor() CSS variable reads
- Convert tooltip, grid, axes, now-marker, empty labels, event circles
- Add theme-changed event listener for automatic re-render on theme toggle
- Keep semantic LANE_COLORS (protest/conflict/natural/military) unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 120bfa5c72 feat(02-02): add CSS styles for theme toggle section in settings modal
- Add .theme-toggle-section with border-bottom separator
- Add .modal .section-label for APPEARANCE/PANELS headings (scoped to modal)
- Add .theme-toggle-group flex layout with gap
- Add .theme-option radio button labels with hover/active states
- Hide radio inputs, use :has(input:checked) for active state
- Override .section-label padding inside .theme-toggle-section to avoid double-padding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 d77ef6c4cb feat(02-02): add theme toggle UI and event wiring in App.ts
- Import setTheme/getCurrentTheme from ThemeManager
- Add APPEARANCE section with Dark/Light radio buttons to settings modal
- Wire theme toggle change listener calling setTheme()
- Add theme-changed listener to re-render map on theme switch
- Rename modal title from 'Panel Settings' to 'Settings'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 eb8a4dcc87 docs(02-01): complete ThemeManager module plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 14f7d856cd feat(02-01): add FOUC prevention scripts and wire entry points
- Add inline FOUC prevention script to index.html and settings.html <head>
- Update CSP script-src to allow 'unsafe-inline' for FOUC prevention
- Import and call applyStoredTheme() in src/main.ts before App init
- Import and call applyStoredTheme() in src/settings-main.ts before loadDesktopSecrets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 3e3576ee7f feat(02-01): create ThemeManager module and barrel exports
- Add src/utils/theme-manager.ts with getStoredTheme, getCurrentTheme, setTheme, applyStoredTheme
- Export Theme type and all 4 functions from src/utils/index.ts barrel
- setTheme invalidates color cache, updates meta theme-color, dispatches theme-changed event
- applyStoredTheme is a lightweight pre-mount theme applicator (no events/cache invalidation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandGitHub fdd23888bd Merge branch 'main' into light_mode 2026-02-16 17:10:49 +02:00
Lib-LOCALE 87f28d5912 fix(i18n): Propagate common keys to all 9 locale files & fix CountryBriefPage
- CountryBriefPage: 2 aria-label='Close' + 3 export labels (Image/JSON/CSV)
- Added exportImage key to en.json and fr.json
- Expanded common section with ~48 native-translated keys in:
  de.json (German), es.json (Spanish), it.json (Italian), pl.json (Polish),
  nl.json (Dutch), pt.json (Portuguese), sv.json (Swedish)
- Also translated base nl/pt/sv common keys that were still in English
2026-02-16 14:27:36 +01:00
Lib-LOCALE 45ee7593ca fix(i18n): Translate remaining title attributes and panel-empty strings
- NewsPanel: Close button title attribute
- CountryIntelModal: 4 component tooltip titles (Unrest/Conflict/Security/Information)
- CIIPanel: Share story button + 4 component tooltip titles
- Added common.unrest/conflict/security/information/shareStory keys to en.json/fr.json
2026-02-16 14:11:00 +01:00
Lib-LOCALE 9224cf2f36 fix(i18n): Final deep scan - translate panel-empty, export, and disabled sources strings
- App.ts: 2 showError calls (All sources/Intel sources disabled)
- PopulationExposurePanel: 'No exposure data available' panel-empty
- SatelliteFiresPanel: 'No fire data available' panel-empty
- UcdpEventsPanel: 'No events in this category' panel-empty
- ClimateAnomalyPanel: 'No significant anomalies detected' panel-empty
- export.ts: Export Data title + Export CSV/JSON button labels
- Added 8 new common.* keys to en.json and fr.json
- Added t() import to export.ts
2026-02-16 13:47:24 +01:00
Lib-LOCALE f93aa53d16 fix(i18n): Replace all showLoading/showError hardcoded strings across 19 panels
- Panel.ts base class: showLoading/showError defaults now use t() keys
- Added 27 new common.* keys to en.json and fr.json
- Updated 19 panel files with t() calls for loading/error messages
- Added t() import to 12 panels: SatelliteFires, PopulationExposure,
  Displacement, ClimateAnomaly, StrategicRisk, Prediction, Cascade,
  GdeltIntel, NewsPanel, GeoHubs, Panel, PredictionPanel
- All showLoading custom messages now translated (UCDP events, thermal
  data, ETF data, stablecoins, climate data, etc.)
- All showError messages now translated (failed to load, no data, etc.)
2026-02-16 13:41:44 +01:00
Lib-LOCALE 2ddaa1899a fix(i18n): Replace all remaining hardcoded strings in PizzInt, Playback, Monitor, DeckGLMap
- PizzIntIndicator: panel title, tensions title, source label, 6 status labels, 3 time-ago strings
- PlaybackControl: Historical Playback header, LIVE button
- MonitorPanel: Add Monitor button
- DeckGLMap: 25 layer labels, 8 view selector options, Layers header, All button
- Added ~50 new keys to en.json (English) and fr.json (French)
2026-02-16 13:32:51 +01:00
Lib-LOCALE 95e51941dc fix(i18n): Replace remaining hardcoded strings with t() calls in 12 components
- StrategicPosturePanel: 25 strings (military units, tooltips, timer)
- App: 4 strings (GitHub link, pin map, filter, source counter)
- StatusPanel: 5 strings (system status, timestamps, storage)
- PizzIntIndicator: 3 strings + t() import (DEFCON, title, updated)
- PlaybackControl: 2 strings + t() import (toggle mode, LIVE)
- CountryBriefPage: 4 strings (share, print, export, source ref)
- DeckGLMap: 4 strings + t() import (zoom controls, layer guide)
- MonitorPanel: 2 strings (placeholder, no matches)
- TechEventsPanel: 4 strings (loading, empty, show on map, more info)
- TechReadinessPanel: 3 strings (internet, mobile, R&D tooltips)
- Removed deb from tauri.conf.json build targets
- Removed duplicate monitor key from en.json
2026-02-16 13:27:43 +01:00
Lib-LOCALE 00925011dc feat(i18n): Implement full internationalization support for 9 languages and add Linux AppImage config 2026-02-16 12:32:55 +01:00
Rau1CSandClaude Opus 4.6 7a734db5b8 fix: add finance types to PopupData union and auto-create variant panels
- Add StockExchangePopupData, FinancialCenterPopupData, CentralBankPopupData,
  CommodityHubPopupData to PopupData.data union, removing unsafe double casts
  (as unknown as) in MapPopup.ts
- Dynamically create NewsPanel instances for any FEEDS category that doesn't
  already have a hardcoded panel, so finance-specific categories (forex, bonds,
  centralbanks, derivatives, fintech, regulation, institutional, analysis, etc.)
  get their own dedicated panels instead of being silently dropped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:56:44 +01:00
Rau1CSandClaude Opus 4.6 67788bf886 fix: dynamically enumerate FEEDS categories in loadNews()
Replace hardcoded feed category list with Object.entries(FEEDS) so that
variant-specific feed categories (e.g. finance variant's markets, forex,
bonds, commodities, crypto, etc.) are automatically discovered and loaded
instead of being silently skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:40:29 +01:00
ClaudeandRau1CS 01fb23df5c feat: add finance/trading variant with market-focused dashboard
Add a new 'finance' site variant (finance.worldmonitor.app) following the
same pattern as the existing tech variant. Includes:

- Finance-specific RSS feeds: markets, forex, bonds, commodities, crypto,
  central banks, economic data, IPOs/M&A, derivatives, fintech, regulation,
  institutional investors, and market analysis (all free/open RSS sources)
- Finance-focused panels with trading-themed labels (Market Headlines,
  Live Markets, Forex & Currencies, Fixed Income, etc.)
- Geographic data for stock exchanges (30+), financial centers (20+),
  central banks (14), and commodity hubs (10) worldwide
- Four new map layers: stockExchanges, financialCenters, centralBanks,
  commodityHubs with tier-based icons and zoom-dependent labels
- Map popup rendering for all finance marker types
- Variant switcher updated with FINANCE tab in header
- Search modal with finance-specific sources and icons
- Vite HTML variant plugin metadata for SEO
- Build scripts (dev:finance, build:finance, test:e2e:finance)
- Tauri desktop config for Finance Monitor app

https://claude.ai/code/session_01CCmkws2EYuUHjYDonzXEtY
2026-02-16 11:22:17 +01:00
Sebastien MelkiandClaude Opus 4.6 b44790bf9f docs(01-04): complete dynamic inline style color conversion plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:20:51 +02:00
Sebastien MelkiandClaude Opus 4.6 53196a0bc8 feat(01-04): convert service and config color functions to getCSSColor()
- Add getThreatColor() function with CSS variable reads (threat-classifier)
- Deprecate THREAT_COLORS constant (kept for backward compat)
- Convert getTrendColor to --semantic-* vars (oil-analytics)
- Convert getSeverityColor to --semantic-* vars (weather)
- Convert getSeverityColor for climate anomalies (climate)
- Convert getStatusColor to --semantic-* and --text-* vars (data-freshness)
- Convert getDisplacementBadge to --semantic-* vars (unhcr)
- Add getPipelineStatusColor function using CSS vars (pipelines)
- Document MONITOR_COLORS and PIPELINE_COLORS as fixed category colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:15:52 +02:00
Sebastien MelkiandClaude Opus 4.6 1cb65ce4b3 feat(01-04): convert component inline style colors to getCSSColor()
- Replace hardcoded hex in getScoreColor, getTrendColor, getPriorityColor (StrategicRiskPanel)
- Convert levelColor, componentBars, updateNews threat colors (CountryBriefPage)
- Convert levelBadge, scoreBar colors (CountryIntelModal)
- Convert impactColors, typeColors, stanceColors objects (RegulationPanel)
- Replace THREAT_COLORS usage with getCSSColor threat var lookups (NewsPanel)
- Convert getLevelColor to semantic CSS vars (CIIPanel)
- Convert priorityColors in showAlert (SignalModal)
- Convert escalationColors, trendColors objects (MapPopup)
- Convert getImpactColor to semantic CSS vars (CascadePanel)
- Convert map background/grid/country/stroke to --map-* vars (Map)
- Convert fire brightness and AIS density colors (Map)
- Convert tooltip inline style colors (GeoHubsPanel, TechHubsPanel)
- Convert monitor color fallback (MonitorPanel)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:12:35 +02:00
Elie HabibandGitHub d734f30fbc fix: remove overly broad CORS origin regex (#82) 2026-02-16 13:02:29 +04:00
Sebastien MelkiandClaude Opus 4.6 827eca5cb8 docs(01-02): complete main.css color conversion plan
- Add 01-02-SUMMARY.md with conversion metrics and deviation documentation
- Update STATE.md with decisions and resolved blocker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:59:11 +02:00
Elias El KhouryandClaude Opus 4.6 32fd309e5d fix: remove overly broad CORS origin regex
The pattern /worldmonitor.*\.vercel\.app/ matched any Vercel deployment
with a "worldmonitor" prefix, allowing unauthorized origins to bypass
CORS and consume server-side API keys. The owner-scoped pattern on the
previous line already covers all legitimate preview deployments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:58:53 +02:00
Sebastien MelkiandClaude Opus 4.6 faf0634c00 feat(01-02): convert hardcoded colors in main.css lines 6001-12276 and fix opacity mappings
- Fix 24 incorrect var(--shadow-color) background uses: map high-opacity (0.6-0.88) to var(--bg), low-opacity (0.15-0.35) to var(--overlay-heavy)
- Fix 50 white rgba text colors incorrectly mapped to var(--overlay-heavy): map to proper text hierarchy (--text-muted, --text-faint, --text-ghost, --text-dim)
- Fix 2 translucent white text mapped to var(--accent): remap to var(--text-secondary)
- Convert remaining white/black rgba values in lines 6001+
- All 223 remaining rgba are semantic color tints (intentionally excluded)
- All 42 remaining hex are country/flag/decorative colors (intentionally excluded)
- Build passes with valid CSS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:57:03 +02:00
Sebastien MelkiandClaude Opus 4.6 f14e7ba55e docs(01-03): complete settings window & embedded style block color conversion plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:54:04 +02:00
Sebastien MelkiandClaude Opus 4.6 6f2ee37f9c feat(01-02): convert hardcoded colors in main.css lines 1-6000 to CSS variables
- Replace ~350 hardcoded hex colors with var() references (backgrounds, borders, text, accent)
- Replace ~170 hardcoded rgba() white/black overlays with var(--overlay-*) and var(--shadow-color)
- Convert dark background rgba values to var(--bg)
- Preserve semantic-colored rgba tints (red, orange, green, blue indicators)
- Preserve country/flag colors and 8-char hex alpha decorative values
- Build passes successfully with all CSS valid

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:53:03 +02:00
Sebastien MelkiandClaude Opus 4.6 4bdeb48215 feat(01-03): convert embedded <style> blocks in 8 TS components to CSS variables
- ClimateAnomalyPanel: table headers, borders, severity badges -> var(--text-muted), var(--border), var(--semantic-*)
- DisplacementPanel: stat boxes, tabs, crisis badges -> var(--threat-*), var(--overlay-*), var(--border)
- DownloadBanner: remove fallback values, button colors -> var(--green), color-mix() for tints
- PopulationExposurePanel: summary bar, card colors -> var(--threat-critical), var(--accent), var(--text-*)
- SatelliteFiresPanel: table styling -> var(--text-muted), var(--border), var(--threat-*)
- UcdpEventsPanel: tabs, death counts, actor text -> var(--semantic-*), var(--text-dim), var(--border)
- PizzIntIndicator: panel, location statuses -> var(--defcon-*), var(--overlay-*), var(--text-*)
- VerificationChecklist: checklist items, notes -> var(--surface-hover), var(--border), var(--text-*)
- DEFCON_COLORS object and verdictColors left as JS runtime values (Plan 04 scope)
- TypeScript compilation passes with zero errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:52:13 +02:00
Sebastien MelkiandClaude Opus 4.6 527c472c05 feat(01-03): convert settings-window.css hardcoded colors to CSS variables
- Replace 12 hardcoded hex/rgba definitions in .settings-shell with var(--*) references to global theme variables
- Convert ~13 remaining hardcoded rgba() calls to var(--overlay-*), var(--text-*), and color-mix() references
- Convert hardcoded #fff and #0b6fdb in button styles to var(--accent) and color-mix()
- Result: 0 hex values, 0 rgba values, 88 var(--) references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:48:40 +02:00
Sebastien MelkiandClaude Opus 4.6 a7bbf86eca feat(01-01): create getCSSColor() utility with theme-aware cache
- Add src/utils/theme-colors.ts with getCSSColor() and invalidateColorCache()
- getCSSColor reads CSS custom properties via getComputedStyle with caching
- Cache auto-invalidates when data-theme attribute changes
- Export both functions from src/utils/index.ts barrel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:43:55 +02:00
Sebastien MelkiandClaude Opus 4.6 8b6a1778c4 feat(01-01): expand :root CSS custom properties and add light theme overrides
- Expand :root from 14 to 48 CSS custom properties in two namespaces
- Add theme colors (backgrounds, borders, text, overlays, scrollbar, input, map)
- Add semantic colors (severity, threat, DEFCON, status indicators)
- Define [data-theme="light"] selector overriding only theme variables
- Preserve all original dark-mode values for zero visual regression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:40:47 +02:00
Elie HabibandGitHub 04252fa169 docs: fix stale documentation across project (#80) 2026-02-16 11:52:48 +04:00
Elie HabibandGitHub 89d50821a9 Add GitHub issue and PR templates (#79) 2026-02-16 11:52:34 +04:00
Sebastien MelkiandClaude Opus 4.6 8f8d07c3a1 docs: fix stale documentation across project
- CHANGELOG: add missing v2.3.5 and v2.3.6 entries
- README: update API edge function count (45+ → 60+), port count
  (84 → 83), desktop secret key count (15 → 17)
- DOCUMENTATION: fix version badge (2.1.4 → 2.3.6), CII monitored
  countries (20 → 22, add Brazil/UAE), add CNBC to live streams,
  fix vessel database count (50+ → 25+), fix port count (61 → 83),
  update news source count (80+ → 100+)
- DESKTOP_CONFIGURATION: update secret keys list from 13 to 17,
  add FINNHUB, URLHAUS, OTX, ABUSEIPDB, NASA_FIRMS keys
- Remove obsolete ROADMAP.md (all 5 proposed features are already
  implemented: geographic convergence, CII, temporal anomaly
  detection, trade route risk, infrastructure cascade)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:30:49 +02:00
Sebastien MelkiandClaude Opus 4.6 62634da3e8 Add GitHub issue and PR templates
- Bug report: variant selector, affected area dropdown, repro steps
- Feature request: area dropdown, problem/alternatives fields
- New data source: feed type, variant target, URL, justification
- PR template: change type, affected areas, RSS allowlist reminder
- Config: disable blank issues, link to docs and discussions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:15:55 +02:00
Elie Habib 700132adad fix: hide node.exe console window on Windows & bump v2.3.6
Add CREATE_NO_WINDOW (0x08000000) creation flag to the sidecar
Command::new() spawn on Windows. Without this, node.exe inherits
a visible console window that overlays the Tauri GUI.
2026-02-16 09:00:16 +04:00
Elie Habib 46010c3911 feat: differentiated panel error messages & auto-hide desktop config (v2.3.5)
- Add Panel.showConfigError() with amber styling and desktop Settings link
- Propagate `skipped` flag from Finnhub and FIRMS API responses
- Show "API key not configured" on Markets/Heatmap/Commodities/FIRMS panels
  when sidecar returns skipped (missing API key)
- ETF, Stablecoin, MacroSignals panels detect upstream API unavailability
  and show retry message instead of generic "Failed to fetch"
- RuntimeConfigPanel auto-hides when all features are configured
- Bump version to 2.3.5
2026-02-16 08:51:47 +04:00
Elie Habib 939ac3a864 fix: sync package-lock.json with markdownlint-cli2 devDependency
The lock file was out of sync causing npm ci to fail in CI for v2.3.3
and v2.3.4 builds.
2026-02-16 00:50:43 +04:00
Elie Habib 7d3b600364 fix: strip UNC path prefix for Windows sidecar, set explicit CWD & bump v2.3.4
Tauri resource_dir() on Windows returns \\?\ extended-length paths that
Node.js module resolution cannot handle, causing EISDIR: lstat 'C:'.
Strip the prefix before passing to Node.js, set current_dir to the
sidecar directory, and add package.json with "type": "module" to prevent
ESM scope walk-up to drive root.
2026-02-16 00:47:02 +04:00
Elie Habib f3581a5f9b fix: enable macOS Keychain backend for keyring crate & bump v2.3.3
keyring v3 ships with NO default platform backends — API keys were
stored in-memory only, lost on every app restart. Add apple-native
and windows-native features to use real OS credential stores.
2026-02-16 00:31:46 +04:00
Elie Habib d3fb116e16 fix: harden settings key persistence with soft-pass verification & resilient keychain reads 2026-02-16 00:31:46 +04:00
Elie HabibandGitHub 0a9226cc82 chore: lint markdown (#72)
MD012 / no-multiple-blanks Multiple consecutive blank lines
MD022 / blanks-around-headings Headings should be surrounded by blank
lines
MD032 / blanks-around-lists Lists should be surrounded by blank lines
2026-02-16 00:11:51 +04:00
Elie HabibandGitHub 751d8589d3 feat: add 6 verified think tank RSS feeds (#71)
FPRI trailing slash fix applied. RUSI was already in SOURCE_TIERS/SOURCE_TYPES from base branch.
2026-02-16 00:09:39 +04:00
Elie Habib 067eabaaff fix: add trailing slash to FPRI feed URL to avoid Cloudflare 403 2026-02-16 00:09:20 +04:00
Elie Habib cf62e169e9 chore: add PR #71 think tank domains to RSS proxy allowlist
warontherocks.com, www.aei.org, responsiblestatecraft.org,
www.fpri.org, jamestown.org
2026-02-15 23:42:06 +04:00
sethandGitHub eaff3e817d chore: lint markdown
MD012 / no-multiple-blanks Multiple consecutive blank lines
MD022 / blanks-around-headings Headings should be surrounded by blank lines
MD032 / blanks-around-lists Lists should be surrounded by blank lines
2026-02-15 11:38:22 -08:00
Elie Habib f3fddcb0e8 fix: settings UX — save verified keys, preserve inputs across renders, bump v2.3.2
- Save keys that pass verification even when others fail (was all-or-nothing)
- Capture un-blurred input values before render to prevent loss on checkbox toggle
- Fix missing isDisallowedOrigin import in PIZZINT endpoints
2026-02-15 23:33:19 +04:00
Elie Habib 0cd88a10a3 Integrate ML NER enrichment into trending keywords 2026-02-15 23:24:52 +04:00
InlitX 8fe7d57b92 feat: add 6 verified think tank RSS feeds 2026-02-15 20:18:26 +01:00
Elie Habib ab6dfccdeb fix: add missing tauri script to restore CI builds
@tauri-apps/cli in devDependencies causes tauri-action to run
`npm run tauri` instead of auto-installing globally.
2026-02-15 23:03:24 +04:00
Elie Habib a9b3582ae3 fix: harden sidecar verification, dedupe spikes, and bump v2.3.1 2026-02-15 22:57:09 +04:00
Elie Habib fb51b5bf40 fix: desktop settings UX overhaul & IPv4-safe fetch for sidecar
- Show "Staged" status/pill for buffered secrets instead of "Missing"
- Add macOS Edit menu (Cmd+C/V/X/Z) for WKWebView clipboard support
- Raise settings window when main gains focus (prevent hide-behind)
- Fix Cloudflare verification to probe Radar API (not token/verify)
- Fix EIA verification URL to valid v2 endpoint
- Force IPv4 globally: monkey-patch fetch() to avoid IPv6 ETIMEDOUT
  on government APIs (EIA, NASA FIRMS) with broken AAAA records
- Soft-pass on network errors during secret verification (don't block save)
- Add desktopRequiredSecrets to skip relay URLs on desktop
- Cross-window sync for secrets and feature toggles via localStorage events
- Add @tauri-apps/cli devDependency
2026-02-15 22:35:21 +04:00
Elie Habib b6881d96c0 fix: NER-gate trending spike alerts to suppress common-word noise
Common English verbs like "say" pass frequency thresholds and generate
false intelligence findings. Now runs browser NER (BERT) on spike
headlines before alerting — terms not recognized as named entities
(PER/ORG/LOC/MISC) are suppressed. Falls back gracefully when NER
is unavailable. Also adds ~55 base verb forms to the suppressed list
as hardened fallback for low-spec devices.
2026-02-15 22:28:24 +04:00
Elie Habib ea76446813 fix: make sidecar secret sync best-effort 2026-02-15 21:44:45 +04:00
Elie Habib f64af4c571 fix: harden CORS patterns & URL validation
- Allow hyphens in Vercel preview URL patterns (worldmonitor-xxx-yyy)
- Harden open_url command with proper URL parsing via reqwest::Url
- Update YouTube embed test assertions for quote style change
2026-02-15 21:34:00 +04:00
Elie Habib 0738e38baa settings: verify API keys via provider probes 2026-02-15 21:31:54 +04:00
Elie Habib 723279eedc chore: bump v2.3.0 — security hardening release with changelog
Major security hardening: CORS enforcement on all API endpoints,
sidecar auth bypass fix, postMessage origin validation, CSP
tightening, and service worker stale cache fix.
2026-02-15 20:38:54 +04:00
Elie Habib 7e7ab126c8 fix: force immediate SW activation to prevent stale asset errors
Add skipWaiting, clientsClaim, and cleanupOutdatedCaches to workbox
config. Fixes NS_ERROR_CORRUPTED_CONTENT / MIME type errors when
users have a cached service worker serving old HTML that references
asset hashes no longer on the CDN after redeployment.
2026-02-15 20:37:23 +04:00
Elie Habib a9224254a5 fix: security hardening — CORS, auth bypass, origin validation & bump v2.2.7
- Tighten CORS regex to block worldmonitorEVIL.vercel.app spoofing
- Move sidecar /api/local-env-update behind token auth + add key allowlist
- Add postMessage origin/source validation in LiveNewsPanel
- Replace postMessage wildcard '*' targetOrigin with specific origin
- Add isDisallowedOrigin() check to 25 API endpoints missing it
- Migrate gdelt-geo & EIA from custom CORS to shared _cors.js
- Add CORS to firms-fires, stock-index, youtube/live endpoints
- Tighten youtube/embed.js ALLOWED_ORIGINS regex
- Remove 'unsafe-inline' from CSP script-src
- Add iframe sandbox attribute to YouTube embed
- Validate meta-tags URL query params with regex allowlist
2026-02-15 20:33:20 +04:00
Elie Habib a31f81a0fe fix: filter trending noise, fix sidecar auth & restore tech panels — v2.2.6
- Expand SUPPRESSED_TRENDING_TERMS from 13 to ~170 entries to filter
  common English words (department, state, news, etc.) from intelligence
  findings
- Move sidecar admin endpoints (debug-toggle, traffic-log, env-update,
  local-status) before LOCAL_API_TOKEN auth gate — settings window sends
  bare fetch without token, causing silent 401 failures
- Restore Market Radar and Economic Indicators panels to tech variant
- Remove stale Documentation section from README
- Clean up .env.example cyber threat keys (handled internally)
- Bump v2.2.6
2026-02-15 20:00:17 +04:00
Elie Habib cd84eb1bb2 fix: remove Market Radar and Economic Data panels from tech variant 2026-02-15 19:33:07 +04:00
Elie Habib f16c34b6a4 docs: add developer X/Twitter link to Support section 2026-02-15 19:29:02 +04:00
Elie Habib d93eeaf551 docs: add cyber threat API keys to .env.example 2026-02-15 19:26:55 +04:00
Elie Habib 071836d390 chore: move test harnesses from root to tests/ 2026-02-15 19:22:40 +04:00
Elie Habib ac935d505e fix: migrate all Vercel edge functions to CORS allowlist & bump v2.2.5
Replace Access-Control-Allow-Origin: * with shared getCorsHeaders()
across 20 API edge functions to restrict access to worldmonitor.app,
tech.worldmonitor.app, and authorized Vercel preview URLs.

Version bump to 2.2.5 across package.json, tauri.conf.json, Cargo.toml.
2026-02-15 19:13:54 +04:00
Elie Habib 9f378d533b fix: restrict Railway relay CORS to allowed origins only
Replace Access-Control-Allow-Origin: * with an allowlist of our domains
(worldmonitor.app, tech.worldmonitor.app, localhost dev ports, Tauri,
and *.vercel.app previews). Prevents third parties from using the relay
as a free proxy.
2026-02-15 19:01:06 +04:00
Elie Habib 9c2104d936 fix: hide desktop config panel on web, route World Bank & Polymarket via Railway
Desktop Configuration panel was leaking to web due to being in
FULL_PANELS/TECH_PANELS configs. Removed from static configs (injected
programmatically for Tauri) and filtered from toggle UI on web.

World Bank API returns 403 to Vercel edge IPs. Added /worldbank proxy
route to Railway relay with full response transformation and 30-min cache.
Client tries Railway first, falls back to Vercel.

Polymarket gamma-api suffers Cloudflare JA3 blocking from Vercel.
Added /polymarket proxy route to Railway relay with 2-min cache.
Client fallback chain: browser-direct → Tauri → Railway → Vercel → production.
2026-02-15 18:59:01 +04:00
Elie HabibandGitHub 67d3673926 feat: cyber threat intelligence layer, trending keyword spikes, panel redesigns & docs (#68)
## Summary
- **New map layer** plotting live botnet C2 servers, malware hosts, and
malicious IPs from **5 threat intel sources**
- **Vercel edge API** (`/api/cyber-threats`) with triple-layer caching
(Redis → memory → stale fallback), rate limiting, and graceful
degradation
- **IP geolocation** via ipinfo.io + freeipapi.com fallback —
HTTPS-compatible with Edge runtime, geo results cached in Redis (24h
TTL)
- **Feature-gated** behind `VITE_ENABLE_CYBER_LAYER=true` — entire layer
hidden when disabled
- **Severity color coding**: critical (red) → high (orange) → medium
(amber) → low (yellow)
- **Tauri desktop**: 3 new keychain secrets (URLHAUS_AUTH_KEY,
OTX_API_KEY, ABUSEIPDB_API_KEY) with runtime config UI
- **Geo enrichment timeout**: 12s overall cap prevents slow GeoIP
providers from blocking the request
- **Download banner**: idempotency guard prevents duplicate panels on
repeated calls

## Data Sources
| Source | Auth | What |
|--------|------|------|
| **Feodo Tracker** | None (free) | Botnet C2 server IPs with country |
| **C2IntelFeeds** | None (free) | ~500 C2 IPs from GitHub CSV
(CobaltStrike, Metasploit, Sliver, etc.) |
| **URLhaus** | `URLHAUS_AUTH_KEY` | Malicious URL distribution hosts |
| **AlienVault OTX** | `OTX_API_KEY` | Community threat indicators (APT,
ransomware, botnets) |
| **AbuseIPDB** | `ABUSEIPDB_API_KEY` | Reported malicious IPs with
abuse confidence scores |

Sources with missing API keys gracefully skip (no error, `partial:
false`). Sources that are configured but fail set `partial: true`.

## Test plan
- [x] 7 unit tests pass (`node --test api/cyber-threats.test.mjs`)
- [x] All 5 sources aggregated and deduplicated in API response
- [x] Graceful degradation when API keys missing (free sources only)
- [x] `partial: true` when configured sources fail
- [x] Memory cache hit on repeated requests
- [x] Stale fallback when upstream fails after cache TTL
- [x] Geo enrichment capped at 12s overall timeout
- [x] Set `VITE_ENABLE_CYBER_LAYER=true` → toggle appears in layer
controls
- [x] Enable layer → purple/red dots render on map at threat locations
- [x] Hover → tooltip shows indicator, severity, malware family, source
- [x] Click → popup with full details (type, tags, coordinates,
timestamps)
- [x] TypeScript build passes (`tsc --noEmit`)
- [ ] Without env var → layer toggle hidden, no fetches made
- [ ] `curl /api/cyber-threats` → returns JSON with `success: true`,
`data[]` has lat/lon
- [ ] Second request within 10min → `X-Cache: MEMORY-HIT`
2026-02-15 18:41:37 +04:00
Elie Habib 141bf0f8b3 feat: redesign population exposure panel and reorder UCDP columns
PopulationExposure: replace truncated 4-column table with two-line
card layout (full event name + population/radius meta row).
UCDP: move deaths to column #2 (Country → Deaths → Date → Actors).
2026-02-15 18:39:35 +04:00
Elie Habib f91fb7c8ce docs: document cyber threats, trending keywords, oil analytics, population exposure, and entity index
Update README with 5 new How It Works sections and 10 inline updates
reflecting implemented features. Data layer count 29→30+, data sources
14→22, new Tech Stack and Roadmap entries.
2026-02-15 18:29:25 +04:00
Elie Habib 3a1088ee0e fix: harden trending spike processing and optimize hot paths 2026-02-15 18:21:27 +04:00
Elie HabibandGitHub 6e14e87ec1 fix: resolve z-index conflict between pinned map and panels grid (#67)
**Description:** I fixed a visual bug where the cards were overlapping
the map when using the "Pin" option. Now the cards slide correctly
underneath the map when scrolling.

**Before:** 
<img width="1914" height="947" alt="error"
src="https://github.com/user-attachments/assets/add5b838-b15c-46d9-adea-aff063563af4"
/>

**and After:**
<img width="1919" height="929" alt="Correction"
src="https://github.com/user-attachments/assets/421fb4af-1851-4f8e-a422-3d095ddb9c48"
/>
2026-02-15 18:10:40 +04:00
Elie Habib 0627df95ba feat: add trending keyword spike detection and e2e flow test 2026-02-15 18:03:18 +04:00
Elie Habib d33c19fb95 feat: redesign 4 panels with table layouts and scoped styles
Bring Climate Anomalies, Population Exposure, UCDP Conflict Events,
and UNHCR Displacement panels up to dashboard design standards
matching Fires/Markets/Stablecoins panels.

- Replace div-based layouts with proper <table> structures
- Add scoped <style> blocks (no inline style= attributes)
- CSS class-based severity badges, death colors, displacement badges
- tabular-nums on all numeric columns, right-aligned
- Row hover effects, text-overflow ellipsis for long text
- Stat grid for UNHCR global totals, total deaths summary for UCDP
- Custom tab styling for UCDP and Displacement panels
2026-02-15 17:55:33 +04:00
Elie Habib 0c632b1b57 fix: add Cyber Threats to System Health status panel allowlists
Both WORLD_FEEDS and TECH_FEEDS were missing 'Cyber Threats', causing
updateFeed() calls to be silently rejected by the allowlist check.
2026-02-15 17:49:46 +04:00
Elie Habib af6f321493 feat: dramatically increase cyber threat map density
Increase geo cap 100→250, concurrency 8→16, per-IP timeout 8→3s.
Add country centroid fallback with jitter for IPs with country codes.
2026-02-15 17:40:01 +04:00
Elie Habib 5abb30d420 fix: improve cyber threat tooltip/popup UX and dot visibility
- Tooltip: show "Cyber Threat" + severity + country instead of raw source names
- Popup header: "Cyber Threat" title instead of raw IP address
- Popup: show all 5 source labels and add malware family field
- Increase dot size for better visibility at global zoom level
2026-02-15 17:34:06 +04:00
Elie Habib 4d816c22f8 fix: cap geo enrichment at 12s overall timeout & prevent duplicate download banners
Addresses PR #68 review comments:
- P1: hydrateThreatCoordinates now races workers against a 12s overall
  timeout so slow GeoIP providers can't block the entire request
- P2: DownloadBanner adds module-level guard preventing duplicate panels
2026-02-15 17:25:38 +04:00
Elie Habib f2b650fc81 fix: replace ipwho.is/ipapi.co with ipinfo.io/freeipapi.com for geo enrichment
ipwho.is returns 403 from Node.js/Edge ("CORS not supported on Free plan")
and ipapi.co rate-limits aggressively (429). Both providers only work from
browser-side requests.

Switch to ipinfo.io (HTTPS, 50K/mo free, works from Edge runtime) as primary
with freeipapi.com as fallback.

Validated end-to-end: 20 C2 IPs geo-enriched in <1s with real coordinates.
2026-02-15 17:17:03 +04:00
Elie Habib 6e0dbbd15b feat: add C2IntelFeeds, OTX, and AbuseIPDB as cyber threat sources
Expands from 2 to 5 threat intel sources:
- C2IntelFeeds (free, no auth): ~500 active C2 server IPs (CobaltStrike, Metasploit)
- AlienVault OTX (optional API key): community-sourced IOCs with tags
- AbuseIPDB (optional API key): high-confidence abuse reports with geo
- Feodo: now includes recently-offline entries (still threat-relevant)

All sources fetched in parallel. Graceful degradation when API keys missing.
2026-02-15 17:06:14 +04:00
Elie Habib 62e81642b0 chore: bump version to 2.2.3 2026-02-15 16:52:48 +04:00
Elie Habib 5facae7105 feat: add cyber threat map layer with Feodo Tracker + URLhaus integration
Plot live botnet C2 servers, malware distribution nodes, and malicious IPs
on the globe using free abuse.ch APIs (Feodo Tracker + URLhaus).

- Vercel edge API with triple-layer caching (Redis → memory → stale fallback)
- IP geolocation via ipwho.is + ipapi.co (HTTPS-compatible with Edge runtime)
- Severity-based color coding (critical=red, high=orange, medium=amber, low=yellow)
- Feature-gated behind VITE_ENABLE_CYBER_LAYER=true env var
- Frontend circuit breaker, data sanitization, 10min auto-refresh
- Tauri desktop support: 3 new secret keys (URLHAUS, OTX, AbuseIPDB)
- Full test suite (6 unit tests), e2e harness updates, popup + tooltip rendering
2026-02-15 16:52:24 +04:00
Elie Habib ef389319a9 feat: add download desktop app slide-in banner for web visitors
Side panel slides in 12s after initial load with macOS/Windows download
links. Dismissed per session. Skipped on Tauri desktop runtime.
2026-02-15 16:25:06 +04:00
InlitX 114b5dfac6 fix: resolve z-index conflict between pinned map and panels grid 2026-02-15 13:03:16 +01:00
Elie Habib 3d16f3c4e1 perf: reduce idle CPU from pulse animation loop
- Add 60s startup cooldown to suppress pulse during initial data hydration
- Consolidate pulse lifecycle into syncPulseAnimation() across all setters
- Replace static predicates (h.level=high, c.maxSeverity=high) with dynamic-only
  checks: recent news, recent ACLED riots (<2h), breaking hotspots
- Exclude GDELT riots from pulse gating (noisy, not actionable)
- Reduce pulse interval from 250ms to 500ms (halves layer rebuild rate)
- Pause pulse when render is paused (country brief overlay)
- Add latestRiotEventTimeMs to MapProtestCluster with raw protest fallback
- Add Playwright tests for startup cooldown and lifecycle start/stop/GDELT exclusion
2026-02-15 15:17:07 +04:00
Elie Habib 96c6208bb0 fix: fall back to cloud API when local sidecar returns non-OK status
The runtime fetch patch previously returned local 4xx/5xx responses
as-is. Now falls back to worldmonitor.app cloud API on any non-OK
local response, matching the existing network-error fallback behavior.
2026-02-15 14:49:49 +04:00
Elie Habib 803c015002 Add country briefs to Cmd+K search 2026-02-15 14:44:03 +04:00
Elie Habib add310349b chore: bump version to 2.2.2 2026-02-15 14:10:35 +04:00
Elie Habib 6d100a6db6 Merge country-brief-page: full-page brief, geometry consolidation, news dedup
- Full-page Country Brief replacing modal overlay
- Tightened headline relevance with negative country filter
- Top News section replacing redundant Evidence/Sources
- Shared country-geometry service for local-first detection
- BR/AE added to tier-1, deep-link name resolution
- Prediction Markets moved to right column for balanced layout
2026-02-15 14:10:08 +04:00
Elie Habib 5c91c0ee71 fix: normalize country name from GeoJSON to canonical TIER1 name
GeoJSON uses "United States of America" but COUNTRY_TAG_MAP and
variant matching expect "United States". Normalize at entry point
so polymarket, search terms, and all downstream lookups work.
2026-02-15 12:21:40 +04:00
Elie Habib 30dfbd0a85 refactor: consolidate news into Top News, remove redundant Evidence section
- Remove Evidence/Sources section (duplicate of Top News)
- Top News now shows 8 items with citation anchor IDs
- AI brief [N] citations link to Top News cards with scroll+highlight
- Move Prediction Markets to right column for balanced layout
- Left: Risk + Brief + Top News | Right: Signals + Timeline + Markets + Infra
2026-02-15 12:14:55 +04:00
Elie Habib 4e8758cb54 Consolidate country detection around shared geometry service 2026-02-15 12:02:57 +04:00
Elie Habib 9bda79e10b fix: add BR/AE to tier-1, resolve deep-link names, fix military timeline filter
- Add Brazil and UAE to TIER1_COUNTRIES, BASELINE_RISK, EVENT_MULTIPLIER,
  COUNTRY_KEYWORDS, COUNTRY_BOUNDS, COUNTRY_ALIASES, and Polymarket maps
- Use Intl.DisplayNames to resolve non-tier ISO codes in deep-link URLs
- Guard raw 2-letter codes from becoming overly broad search terms
- Fix military timeline to use same either/or logic as getCountrySignals
2026-02-15 11:42:45 +04:00
Elie Habib e6fe946b34 fix: tighten headline relevance, add Top News section, compact markets
- Add negative country filter: headlines where another country is mentioned
  first in the title are excluded (fixes Venezuela brief showing for US)
- Filtered headlines used for both evidence section AND LLM context
- Add Top News section (5 items) between Intelligence Brief and Markets
- Limit prediction markets to max 3, tighter padding
2026-02-15 11:27:06 +04:00
Elie Habib e663741f4a fix: hide desktop config panel on web, fix irrelevant prediction markets
- RuntimeConfigPanel only created when isDesktopApp is true (was leaking
  full desktop config UI including secret key inputs to web visitors)
- Fix polymarket sub-market selection: when event title doesn't match
  country but a sub-market does, restrict candidates to matching
  sub-markets instead of picking highest-volume globally (was showing
  Xi Jinping market for France because Macron sub-market triggered match)
2026-02-15 11:16:25 +04:00
Elie Habib 458be05a59 feat: add full-page Country Brief Page replacing modal overlay
Replace the small CountryIntelModal with a comprehensive full-page
intelligence product per country. Implements all 5 phases of the plan:

Phase 1 - Full-page shell + bug fixes:
- CountryBriefPage.ts: score ring, component bars, signal chips,
  non-tier fallback, loading states
- Fix hardcoded earthquakes: 0 with bbox + place name filtering
- Export getCountryData, TIER1_COUNTRIES from country-instability
- Expand CountryBriefSignals with displacement, climate, conflict

Phase 2 - D3 multi-lane timeline:
- CountryTimeline.ts: 4 lanes, 7-day filter, severity-sized circles
- XSS-safe tooltip rendering via escapeHtml

Phase 3 - Infrastructure + Evidence:
- Export getNearbyInfrastructure from related-assets
- Local BriefAssetType union, port proximity lookup
- Evidence cards with threat levels and numbered references

Phase 4 - AI brief citations + Export:
- LLM prompt cites headlines as [N], bounded regex parsing
- Export dropdown: Image PNG, JSON, CSV, Print/PDF

Phase 5 - Deep-linking + Polish:
- country field in URL state, pre-capture before sync
- Force URL rewrite on open/close, mobile + print styles

Bug fixes: URL sync race, async response guards,
briefRequestToken cancellation, listener accumulation,
tier-1 detection via membership not data existence
2026-02-15 11:06:09 +04:00
Elie Habib b0e48f47f8 docs: add download badges and web app links to README
Shield.io badges for Windows, macOS ARM, and macOS Intel desktop downloads
pointing to the new /api/download redirect. Web app links restyled as badges.
2026-02-15 10:42:38 +04:00
Elie Habib d2568576a3 Add download redirect API for platform-specific installers
Edge function that redirects /api/download?platform=macos-arm64 to
the matching asset from the latest GitHub release. Falls back to
the releases page when no match is found.
2026-02-15 10:28:52 +04:00
Elie Habib 77fc5fe4bd fix(macos): hide window on close instead of quitting
Standard macOS behavior — app stays in dock, reopens on dock click.
2026-02-15 10:26:43 +04:00
Elie Habib 3512937658 fix: tone down climate anomalies heatmap to stop obscuring other layers
Halve radius, reduce intensity/opacity so markers and labels remain visible.
2026-02-15 10:22:10 +04:00
Elie Habib 7494e0780d Add auto-generated changelog to GitHub releases
New post-build job generates release notes from git commits between tags
and updates the release body via gh release edit.
2026-02-15 09:53:43 +04:00
Elie Habib a17b1b02e6 Fix release always created as draft on tag push
releaseDraft used != (OR) making tag pushes always draft.
Changed to == (AND) so only workflow_dispatch with draft=true creates drafts.
2026-02-15 09:41:43 +04:00
Elie Habib fd631ca86c perf: harden regression guardrails in CI, cache, and map clustering 2026-02-15 08:45:49 +04:00
Elie Habib 6dfdffc364 ci: set max timeout for desktop build matrix 2026-02-15 08:20:02 +04:00
Elie Habib 441b6f1a90 ci: harden tauri desktop build workflow 2026-02-15 08:14:12 +04:00
Elie Habib 6a293b1f18 Fix settings window show/focus even when init fails 2026-02-15 08:02:27 +04:00
Elie HabibandGitHub 68b3b22ad9 Fix: constrain layers menu height in DeckGLMap (#65)
I noticed a small UI bug where the Layers dropdown menu becomes too tall
causing it to underlap the Global Situation and also overlap the time
slider header. Also I made the scroll bar a little thinner it looks nice

This PR should fix it .
Fixed UI:
<img width="1892" height="537" alt="Screenshot 2026-02-14 235518"
src="https://github.com/user-attachments/assets/01e96b51-13bf-4d8a-89f7-c944ecee6722"
/>
<img width="1887" height="537" alt="Screenshot 2026-02-14 235529"
src="https://github.com/user-attachments/assets/cf605260-d33a-4400-9d26-df80f74193a9"
/>
2026-02-15 00:21:37 +04:00
Elie Habib 1912e248c6 Bump v2.2.1, remove CLAUDE.md from repo and add to .gitignore 2026-02-15 00:16:46 +04:00
Elie Habib e1925e735c Consolidate variant naming and fix PWA tile caching
- Rename variant default from 'world' to 'full' across config files
- Replace all startups.worldmonitor.app references with tech.worldmonitor.app
- Add CARTO basemap tile caching to Workbox runtime config (basemaps.cartocdn.com)
2026-02-15 00:15:33 +04:00
Elie Habib 5b1f980b70 Fix Windows settings window: async command, no menu bar, no white flash
- Make open_settings_window_command async to prevent WebView2 deadlock on Windows
- Create settings window with visible(false) to avoid white flash before content loads
- Remove menu bar from settings window on Windows/Linux (macOS uses screen-level menu)
- Frontend calls plugin:window|show + set_focus after init completes
2026-02-15 00:15:23 +04:00
Elie Habib a439992094 Add 40-minute timeout to desktop build jobs
Prevents builds from hanging indefinitely when Apple notarization
is slow or credentials are misconfigured.
2026-02-14 23:52:49 +04:00
ranzordly fe11a0ed0e fix: html attribute fix 2026-02-15 01:02:33 +05:30
Elie Habib 4a7f9bfdf4 Allow Cloudflare Insights script in CSP
Add https://static.cloudflareinsights.com to script-src directive
to prevent CSP blocking of Cloudflare Web Analytics beacon.
2026-02-14 22:52:26 +04:00
ranzordly 32973b5770 Fix: constrain layers menu height in DeckGLMap 2026-02-15 00:16:55 +05:30
Elie Habib 81559ba2d6 Use native M1 runner for ARM64 macOS builds
Switch ARM64 build from cross-compiling on macos-latest (Intel) to
native compilation on macos-14 (M1). Updates platform checks to
use contains() so signing works on both macOS runner types.
2026-02-14 22:33:29 +04:00
Elie Habib 7254dbb087 Fix macOS build failures when Apple signing secrets are missing
Gracefully handle missing/invalid Apple Developer certificates instead
of failing the entire build. Detects secret availability, validates
certificate file size, makes import failures non-fatal, and splits
each variant into signed/unsigned conditional paths.
2026-02-14 21:46:46 +04:00
Elie Habib a661497bcc Add latest release badge to README 2026-02-14 21:39:37 +04:00
Elie Habib 5e079f891d Add GitHub Actions workflow for cross-platform desktop builds
Builds macOS (ARM64 + x64) and Windows via tauri-apps/tauri-action.
Supports full/tech variants, optional code signing, and automatic
GitHub Release creation on tag push.
2026-02-14 21:31:07 +04:00
Elie Habib 2c2a6dfbc3 Fix YouTube CSP, add devtools menu, improve desktop channel switching
- Add worldmonitor.app to frame-src CSP in index.html (was only in
  tauri.conf.json, causing iframe block)
- Add devtools feature and Help > Toggle Developer Tools menu item
- Try native YouTube JS API first, fall back to cloud bridge on Error 153
- Add pause-then-play workaround for WKWebView channel switching
2026-02-14 21:09:55 +04:00
Elie Habib 9b216843be Fix YouTube playback in Tauri desktop with Player API and postMessage controls
Switch bridge page from raw iframe to YouTube IFrame Player API with
postMessage protocol for play/pause/mute commands. Use youtube-nocookie.com
host, add click-to-play overlay for WKWebView autoplay restrictions, and
route desktop embeds through cloud URL to avoid sidecar auth issues.
2026-02-14 20:22:37 +04:00
Elie HabibandRinZ27 ea4fe718aa Add token-based auth for local API sidecar
Prevents unauthorized local processes from accessing the sidecar on
localhost:46123. Token is generated at Tauri startup using RandomState
hasher, injected into sidecar env, and lazy-loaded by the frontend
fetch patch via get_local_api_token command.

Service-status endpoint remains public for health checks.

Co-authored-by: RinZ27 <RinZ27@users.noreply.github.com>
2026-02-14 20:05:17 +04:00
Elie Habib c353cf2070 Reduce egress costs, add PWA support, fix Polymarket and Railway relay
Egress optimization:
- Add s-maxage + stale-while-revalidate to all API endpoints for Vercel CDN caching
- Add vercel.json with immutable caching for hashed assets
- Add gzip compression to sidecar responses >1KB
- Add gzip to Railway RSS responses (4 paths previously uncompressed)
- Increase polling intervals: markets/crypto 60s→120s, ETF/macro/stablecoins 60s→180s
- Remove hardcoded Railway URL from theater-posture.js (now env-var only)

PWA / Service Worker:
- Add vite-plugin-pwa with autoUpdate strategy
- Cache map tiles (CacheFirst), fonts (StaleWhileRevalidate), static assets
- NetworkOnly for all /api/* routes (real-time data must be fresh)
- Manual SW registration (web only, skip Tauri)
- Add offline fallback page
- Replace manual manifest with plugin-generated manifest

Polymarket fix:
- Route dev proxy through production Vercel (bypasses JA3 blocking)
- Add 4th fallback tier: production URL as absolute fallback

Desktop/Sidecar:
- Dual-backend cache (_upstash-cache.js): Redis cloud + in-memory+file desktop
- Settings window OK/Cancel redesign
- Runtime config and secret injection improvements
2026-02-14 19:53:04 +04:00
Elie Habib 427d9c1f3f Fixing ACLED positions on the map 2026-02-14 19:53:04 +04:00
Elie Habib 24b5188db9 Fix hovering over certain countries highlighting others
GeoJSON had France, Norway, and Kosovo sharing ISO code -99, causing hover
on any one to highlight all three. Fix ISO codes and switch hover filter
to unique name property. Add data validation tests.
2026-02-14 19:53:04 +04:00
Elie HabibandGitHub 5ee050c28d feat: add think tank, arms control, and food security RSS feeds (#62)
- Add RUSI, Wilson Center, GMF, Stimson, CNAS, Lowy Institute feeds
- Add Arms Control Association, Bulletin of Atomic Scientists feeds
- Add FAO GIEWS food security alerts, EU ISS feeds
- Add www.iss.europa.eu to RSS proxy allowlist
- Add SOURCE_TIERS and SOURCE_TYPES entries for new feeds

Please review modified files (2) to ensure we are properly aligned on
goals/expectations. Cheers!

Jas
2026-02-14 19:51:20 +04:00
Admin e0e1b40e1a feat: add think tank, arms control, and food security RSS feeds
- Add RUSI, Wilson Center, GMF, Stimson, CNAS, Lowy Institute feeds
- Add Arms Control Association, Bulletin of Atomic Scientists feeds
- Add FAO GIEWS food security alerts, EU ISS feeds
- Add www.iss.europa.eu to RSS proxy allowlist
- Add SOURCE_TIERS and SOURCE_TYPES entries for new feeds
2026-02-13 15:06:33 -08:00
Elie Habib 75a85ebafc Fix desktop app reliability: YouTube embeds, panel failures, circuit breakers
- Fix YouTube Error 153 by serving embed bridge from cloud URL (origin match)
- Fix channel switching when playerContainer detached from DOM
- Fix Fires panel infinite spinner when API returns 0 or fails
- Make TECH variant button open web URL instead of being disabled
- Fix circuit breaker caching empty results as success in 6 services
  (polymarket, wingbits, military-flights, outages, conflicts, protests)
- Improve sidecar: cloud-preferred routing, failed import caching, log dedup
- Add FINNHUB_API_KEY and NASA_FIRMS_API_KEY to Tauri secret keys
- Add early 503 for missing ACLED token in risk-scores
2026-02-14 00:25:02 +04:00
Elie Habib ad4e52caee Fix Tauri desktop runtime reliability and settings UX 2026-02-13 23:05:51 +04:00
Elie Habib ac370e9a87 Remove @tauri-apps/cli from devDependencies to fix Railway npm ci
The previous commit (00e4d53) claimed to remove it but didn't.
package-lock.json was already missing the entry, causing sync failure.
2026-02-13 22:39:54 +04:00
Elie Habib 178b9563a8 Merge branch 'pr-45'
# Conflicts:
#	LICENSE
2026-02-13 20:41:05 +04:00
Elie Habib 871af119a3 Batch AI classification and Railway-direct AIS routing
- Add /api/classify-batch endpoint: classifies up to 20 headlines per Groq call
  (reduces 182 individual API calls to ~10 batched calls, 90% rate limit savings)
- Update threat-classifier.ts: collect headlines in batch queue, flush every 500ms
  or when batch reaches 20 items
- Route AIS snapshot through Railway directly when VITE_WS_RELAY_URL is set,
  falling back to Vercel — eliminates 503 when WS_RELAY_URL not configured on Vercel
2026-02-13 20:38:20 +04:00
Elie Habib c1584987f5 Route Atlantic Council RSS through Railway to fix 504 timeouts 2026-02-13 19:02:12 +04:00
Elie Habib 15134a4b7a Add gzip compression and WS client cap for further egress reduction
- gzip all JSON responses (OpenSky, UCDP, AIS snapshot): ~80% smaller
- Cap WebSocket clients at 10 (app uses HTTP snapshots, not WS)
2026-02-13 18:42:15 +04:00
Elie Habib 444aff5eb6 Add Vercel fallback when Railway UCDP route unavailable
Try Railway first, fall back to Vercel /api/ucdp-events on failure.
2026-02-13 18:39:44 +04:00
Elie Habib d3f9100f52 Add server-side caching to Railway relay — eliminates ~1.7TB/day egress
Critical cost optimization for 57+ concurrent clients:

- OpenSky response cache (30s TTL per bounding box): ~1.2TB/day saved
- RSS response cache (5min TTL per feed URL): ~30GB/day saved
- AIS WebSocket fanout throttled to 1/10 messages: ~400GB/day saved
- Cache cleanup interval, health stats, stale fallbacks
2026-02-13 18:37:13 +04:00
Elie Habib 0503f20943 Move UCDP proxy to Railway with persistent in-memory cache
- Add /ucdp-events handler to ais-relay.cjs with 6h TTL cache,
  background refresh, version discovery, and 30s per-page timeout
- Route client through Railway when VITE_WS_RELAY_URL is set
- Add 8s AbortController timeout to Vercel fallback fetchGedPage()
2026-02-13 18:29:30 +04:00
Elie Habib 4922ef454b Fix FAO News RSS feed URL
Old URL (www.fao.org/rss/home/en/) returns 404.
Updated to new feed at www.fao.org/feeds/fao-newsroom-rss.
2026-02-13 18:01:35 +04:00
Elie Habib b51fcc641f Fix FAO News RSS feed URL
Old URL (www.fao.org/rss/home/en/) returns 404.
Updated to new feed at www.fao.org/feeds/fao-newsroom-rss.
2026-02-13 18:01:25 +04:00
Elie Habib 2b70374e86 Add MIT LICENSE file
Fixes #57 - the README referenced a LICENSE file that didn't exist,
causing a 404 on GitHub.
2026-02-13 17:28:05 +04:00
Elie Habib 9a258f1a62 Add MIT LICENSE file
Fixes #57 - the README referenced a LICENSE file that didn't exist,
causing a 404 on GitHub.
2026-02-13 17:14:27 +04:00
Elie HabibandGitHub 76f9f470f2 Improve Vite chunk splitting and update documentation metadata (#56)
### Motivation
- Reduce main bundle pressure by splitting large third-party libraries
into logical chunks to improve load/ caching behavior during production
builds.
- Keep documentation accurate and ensure assets referenced from `docs/`
resolve correctly.

### Description
- Replaced static `manualChunks` object in `vite.config.ts` with a
function-based splitter that creates `ml` for ML deps
(`@xenova/transformers`, `onnxruntime-web`), `map` for map/visualization
deps (`@deck.gl`, `maplibre-gl`, `h3-js`), and preserves dedicated `d3`
and `topojson` chunks.
- Updated `docs/DOCUMENTATION.md` to bump the version badge to `2.1.4`
and fixed the dashboard image path to `../new-world-monitor.png` so it
loads when viewing files from the `docs/` folder.
- Changes applied to `vite.config.ts` and `docs/DOCUMENTATION.md` and
committed to the current branch.

### Testing
- Ran `npm run typecheck` and it completed successfully.
- Ran `npm run build` and the production build succeeded; rollup emitted
warnings about `eval` in a dependency and some chunks >500 kB but build
artifacts were produced.
- Ran `npm run test:e2e:full` which failed in this environment because
Playwright browser binaries are not installed (error: `Executable
doesn't exist ... chromium_headless_shell`).

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698f0083f99083338bf9ea2ebbefd191)
2026-02-13 15:26:04 +04:00
Elie Habib 1c90d4d1dd Improve build chunking and fix documentation metadata 2026-02-13 15:20:29 +04:00
Elie HabibandGitHub 7678b13109 docs: add Tauri validation preflight and remediation guidance (#55) 2026-02-13 14:42:29 +04:00
Elie Habib e185d0e6f7 build: fail fast when local tauri cli is missing 2026-02-13 14:40:57 +04:00
Elie Habib 9a803eab46 docs: add tauri network preflight and remediation guidance 2026-02-13 14:08:36 +04:00
Elie HabibandGitHub 3c68349ad2 Add optional Cargo vendoring config and offline packaging documentation (#54)
### Motivation

- Make Tauri/Rust dependency resolution reproducible in
restricted-network CI or offline environments by providing a documented
vendored source option.
- Surface two supported packaging flows (online and restricted-network)
in the release docs so packagers know how to provision offline inputs.
- Improve validation reporting to distinguish external registry outages
from failures caused by missing offline artifacts so QA results are
actionable.
- Allow committing `Cargo.lock` for `src-tauri` once generated by
removing it from the local `.gitignore` so dependency graphs can be
pinned.

### Description

- Added `src-tauri/.cargo/config.toml` defining a `vendored-sources`
replacement that points to `src-tauri/vendor/` and included inline
instructions to `cargo vendor` for populating it.
- Updated `docs/RELEASE_PACKAGING.md` with a new section describing two
Rust dependency modes and example commands for (1) the standard online
build and (2) a restricted-network build that uses vendored crates or an
internal mirror.
- Updated `docs/TAURI_VALIDATION_REPORT.md` to classify failures into
**External environment outage** and **Expected failure: offline mode not
provisioned**, with guidance for both.
- Modified `src-tauri/.gitignore` to stop ignoring `Cargo.lock` so a
lockfile can be generated and committed when network or vendored
artifacts are available.

### Testing

- Ran `cd src-tauri && cargo generate-lockfile`, which failed due to
blocked access to `https://index.crates.io` with `403 CONNECT tunnel
failed` (environmental network restriction).
- Ran `cd src-tauri && cargo generate-lockfile --offline`, which failed
as expected because `src-tauri/vendor/` was not provisioned (`no
matching package named keyring found`).
- No additional automated tests were run on the modified files;
documentation and config changes were validated by applying the edits
and confirming file contents.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ef3d2ac2c8333b576f1c85e6004a1)
2026-02-13 14:01:50 +04:00
Elie Habib 493ceed0e3 docs: refine tauri offline mode workflow 2026-02-13 14:01:03 +04:00
Elie Habib e22f7d8aeb docs: add tauri offline dependency packaging guidance 2026-02-13 13:52:56 +04:00
Elie HabibandGitHub 895de08e07 Codex-generated pull request (#53)
Codex generated this pull request, but encountered an unexpected error
after generation. This is a placeholder PR message.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698eecffdf0c833382ac8f5a79fd8090)
2026-02-13 13:46:49 +04:00
Elie Habib cca1a43df6 Harden local Tauri CLI invocation and fix validation report 2026-02-13 13:44:47 +04:00
Elie Habib b3b830f05e Use local Tauri CLI for desktop scripts 2026-02-13 13:28:58 +04:00
Elie HabibandGitHub d4e3ff66a9 Codex-generated pull request (#52)
Codex generated this pull request, but encountered an unexpected error
after generation. This is a placeholder PR message.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ed0c520348333874fc0d1cd6f09a0)
2026-02-13 13:18:02 +04:00
Elie Habib 6d770d8c63 docs: add tauri validation report with environment blockers 2026-02-13 13:15:24 +04:00
Elie HabibandGitHub 5b7b487ca2 Add desktop parity matrix and desktop-readiness fallback visibility (#51)
### Motivation

- Provide a compact, maintainable model for tracking which UI features
are fully local vs require keys or cloud fallback so desktop parity work
can be prioritized.
- Surface desktop-specific acceptance checks and per-feature fallback
behavior in the UI so operators can quickly assess "desktop ready"
status.
- Consolidate the local-sidecar parity audit into a machine-readable
matrix and documented acceptance criteria.

### Description

- Added a new desktop parity service `src/services/desktop-readiness.ts`
that defines `DesktopParityFeature`, encodes high-value features
(LiveNews, Monitor, StrategicRisk, map layers, summaries, markets,
wingbits/relays), and exposes `getDesktopReadinessChecks`,
`getKeyBackedAvailabilitySummary`, and `getNonParityFeatures` helper
functions.
- Updated `src/components/ServiceStatusPanel.ts` to render a new
"Desktop readiness" section that shows acceptance-check progress,
key-backed feature availability (`available/total`), and a collapsible
list of non-parity fallbacks for operator visibility.
- Reworked `docs/local-backend-audit.md` into a prioritized parity
matrix with closure ordering and explicit desktop-ready acceptance
criteria (startup, map rendering, core intelligence panels, summaries,
market panel, and at least one live-tracking mode).

### Testing

- Ran type checking with `npm run typecheck`, which completed
successfully.
- Executed an automated browser smoke run (Playwright) to capture a
Service Status screenshot against the dev server, which completed and
produced a screenshot artifact successfully.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ec64caff0833386d529e611b468f4)
2026-02-13 11:15:27 +04:00
Elie Habib 6602456408 Address parity review comments with service/API mapping fixes 2026-02-13 11:04:02 +04:00
Elie Habib ee304d0e89 Add desktop parity matrix and readiness fallback UI 2026-02-13 10:54:02 +04:00
Elie HabibandGitHub 5db09fc169 Add reproducible Tauri packaging workflow for macOS/Windows variants (#50)
### Motivation
- Provide a single, reproducible local packaging workflow for desktop
artifacts across macOS and Windows that supports both product variants
(full vs tech).
- Make signing/notarization flows explicit and safe by wiring optional
env-based hooks for Apple notarization and Windows Authenticode.
- Ensure outputs are variant-aware and documented so release engineers
can validate artifacts on clean machines.

### Description
- Add a reusable runner `scripts/desktop-package.mjs` that accepts `--os
<macos|windows>`, `--variant <full|tech>`, and an optional `--sign`
flag, pins bundler targets per-OS, and enforces required signing env
vars when signing is requested.
- Wire `package.json` to use the new runner for all `desktop📦*`
scripts and add a generic `desktop:package` entrypoint.
- Update `src-tauri/tauri.conf.json` and
`src-tauri/tauri.tech.conf.json` to keep explicit bundler targets and
align platform bundle settings (`macOS.hardenedRuntime`, and Windows
`digestAlgorithm`/`timestampUrl`).
- Expand `docs/RELEASE_PACKAGING.md` and update `README.md` with
reproducible per-OS commands, example env hooks for Apple notarization
and Windows Authenticode, variant-aware outputs, artifact locations, and
a clean-machine release checklist.

### Testing
- Ran TypeScript checks with `npm run typecheck`, which completed
successfully.
- Executed the packaging runner guard path with `node
scripts/desktop-package.mjs --os macos --variant full --sign`, which
failed as expected due to missing signing env vars and confirmed the
signing-enforcement checks are working.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ec3ac04e0833397294cf0b28ac97e)
2026-02-13 10:34:53 +04:00
Elie Habib e613ea5217 Refine signing guardrails for desktop packaging 2026-02-13 10:33:25 +04:00
Elie Habib a97157b9c5 Add reproducible cross-OS Tauri packaging workflow 2026-02-13 10:27:39 +04:00
Elie HabibandGitHub 0c1d594a42 Add reproducible Tauri packaging workflow for macOS/Windows with release checklist (#49)
### Motivation
- Provide reproducible local packaging commands for both product
variants (full vs tech) across macOS and Windows.
- Make signing/notarization and Authenticode flows optional and
environment-driven so builds can be reproducible unsigned or signed when
keys are available.
- Ensure release artifacts and variant identities are documented and
easy to validate on a clean machine.

### Description
- Added deterministic packaging scripts to `package.json` for macOS
(`.app` + `.dmg`) and Windows (`NSIS .exe` + `.msi`) for both `full` and
`tech` variants and optional signed variants via environment variables.
- Updated `src-tauri/tauri.conf.json` and
`src-tauri/tauri.tech.conf.json` to explicitly target `app`, `dmg`,
`nsis`, and `msi`, and added a Windows signing/timestamp hint (`sha256`
+ timestamp URL).
- Added `docs/RELEASE_PACKAGING.md` containing per-OS packaging
commands, optional signing/notarization env hooks, artifact output
locations, and a clean-machine startup validation checklist.
- Linked the new packaging guide from `README.md` and surfaced the new
`npm run desktop📦*` commands in the contributor command block.

### Testing
- Verified the updated JSON files parse successfully with a `node -e`
JSON parse check (succeeded).
- Ran `npm run -s typecheck` (TypeScript `tsc --noEmit`) to ensure no
type regressions (succeeded).

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ebfa41cbc8333ba9aeeab79aa8a72)
2026-02-13 10:20:02 +04:00
Elie Habib 9bc39ad2d1 Address packaging review feedback for signing hooks and docs clarity 2026-02-13 10:18:45 +04:00
Elie Habib 47f36416ce Add reproducible desktop packaging commands and release checklist 2026-02-13 10:11:16 +04:00
Elie HabibandGitHub fc805a757a Add desktop persistent cache, offline circuit-breaker states, and panel freshness badges (#48)
### Motivation
- Ensure the app remains useful when cloud APIs are unreachable by
providing durable local fallbacks and clearer UI indicators for
stale/offline data.
- Persist recent feed items, latest computed risk snapshots, and
stale-tolerant map overlays for desktop builds so a packaged app can
operate without network.
- Surface data freshness and whether values are `live`, `cached`, or
`unavailable` in panels so analysts understand what they are seeing.

### Description
- Add a desktop-aware persistent cache service
(`src/services/persistent-cache.ts`) that uses Tauri `invoke` commands
(`read_cache_entry` / `write_cache_entry`) with `localStorage` as a
fallback for non-desktop runtimes.
- Extend the Tauri backend (`src-tauri/src/main.rs`) to provide
file-backed JSON cache storage under app data and register
`read_cache_entry` and `write_cache_entry` handlers.
- Wire persistent fallbacks into runtime services: RSS feeds
(`src/services/rss.ts`), cached risk scores
(`src/services/cached-risk-scores.ts`), earthquake overlay data
(`src/services/earthquakes.ts`), and the Insights world-brief snapshot
(`src/components/InsightsPanel.ts`).
- Enhance circuit-breaker semantics in `src/utils/circuit-breaker.ts` to
track `BreakerDataState` (modes: `live` / `cached` / `unavailable`) and
detect desktop offline mode so callers can show more precise status and
use cached fallbacks.
- Add panel-level data-state badges and helpers in
`src/components/Panel.ts` and apply them to `NewsPanel`,
`InsightsPanel`, and `StrategicRiskPanel`, plus styling in
`src/styles/main.css`.
- Include static resources in desktop bundle config by adding `../data`
and `../src/config` to `src-tauri/tauri.conf.json` so pre-bundled
datasets are available in production desktop builds.

### Testing
- Ran `npm run typecheck` (`tsc --noEmit`) which completed successfully.
- Started the dev server and exercised the UI; captured a screenshot
demonstrating panel freshness badges (artifact recorded during the run).
- `cargo check` could not complete in this environment due to
network/crates.io access being blocked (error: CONNECT tunnel failed /
403), so native Rust/Tauri build verification was not possible here.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698eb89d5b448333b14174125f76d3c4)
2026-02-13 10:05:35 +04:00
Elie Habib 01731e0690 Fix freshness badge state transitions for cached summaries 2026-02-13 10:04:09 +04:00
Elie Habib 4f6d3396de Add desktop offline cache persistence and freshness badges 2026-02-13 09:46:11 +04:00
Elie HabibandGitHub 9bf128aab2 Codex-generated pull request (#47)
Codex generated this pull request, but encountered an unexpected error
after generation. This is a placeholder PR message.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698eb29a0bc48333b9bf8c07362391e5)
2026-02-13 09:36:37 +04:00
Elie Habib 4a75bf1f4d Fix runtime config gating for web and desktop-only secret writes 2026-02-13 09:35:34 +04:00
Elie Habib 124683090d Add desktop runtime config panel and secure secret vault hooks 2026-02-13 09:22:14 +04:00
Elie HabibandGitHub a007d44e67 Add Tauri local API sidecar and desktop runtime routing with cloud fallback (#46)
### Motivation
- Provide a local backend for the Tauri desktop app so core
functionality (news, summarization, markets, telemetry, status) does not
require Vercel edge functions and can run offline or with reduced cloud
dependency.
- Minimize frontend changes by exposing the same `/api/*` paths locally
and failing over to the cloud when handlers are missing or local
execution fails.

### Description
- Add a Node sidecar local API server at
`src-tauri/sidecar/local-api-server.mjs` that dispatches `/api/*` to
existing `api/*.js` handlers when present and proxies to the cloud
(`https://worldmonitor.app`) as a fallback.
- Start/stop the sidecar with the Tauri app lifecycle by launching it
from `src-tauri/src/main.rs` and managing the child process state.
- Update Tauri configuration in `src-tauri/tauri.conf.json` to allow the
local API origin (`http://127.0.0.1:46123`) in the CSP and to include
`../api` and `sidecar/local-api-server.mjs` as bundle resources.
- Desktop runtime routing changes in `src/services/runtime.ts`: default
desktop API base set to `http://127.0.0.1:46123`, added
`getRemoteApiBaseUrl()` and an `installRuntimeFetchPatch()` function
that patches `fetch` to route `/api/*` to the local sidecar with
automatic cloud fallback.
- Enable the runtime fetch patch at app start by calling
`installRuntimeFetchPatch()` from `src/main.ts`.
- Update `ServiceStatusPanel` (`src/components/ServiceStatusPanel.ts`)
to render local backend status and to show clear messaging when the
local backend is unavailable and the UI is using the cloud fallback.
- Add documentation `docs/local-backend-audit.md` listing prioritized
`/api/*` endpoints for desktop parity and describing the localization
strategy.
- Minor formatting run via `cargo fmt` adjusted `src-tauri/build.rs`.

### Testing
- `npm run typecheck` (`tsc --noEmit`) passed successfully.
- `cargo fmt` completed successfully and reformatted
`src-tauri/build.rs` where needed.
- `cargo check` failed in this environment due to network restrictions
while downloading crates index (environment-specific HTTP 403); this is
unrelated to the code changes themselves.
- Local sidecar smoke tests (automated invocation): launched `node
src-tauri/sidecar/local-api-server.mjs` and verified `GET
/api/local-status` and `GET /api/service-status` returned expected JSON
responses (local status included), demonstrating the sidecar dispatch
and health endpoints work in this environment.
- Playwright screenshot attempt failed because the dev server was not
reachable from the test environment (`net::ERR_EMPTY_RESPONSE`), so UI
screenshot validation could not be completed here.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ead50f548833398717fa3b8c92230)
2026-02-13 09:09:08 +04:00
Elie Habib 54b5adb8c7 Harden desktop sidecar route matching and fetch routing 2026-02-13 09:07:33 +04:00
Elie Habib b7ee69dbb7 Add Tauri local API sidecar with desktop routing fallback 2026-02-13 08:59:22 +04:00
Elie Habib 778bc830d6 Refine Tauri variant metadata and runtime detection 2026-02-13 08:58:55 +04:00
Elie Habib eb0f396d16 Add Tauri v2 desktop scaffold and runtime bridge 2026-02-13 08:47:12 +04:00
Elie Habib 19754716c6 feat: add intelligence layers and harden data ingestion 2026-02-13 08:14:53 +04:00
Elie Habib 3c46110632 test: guard cluster cache initialization and document checks 2026-02-13 07:59:00 +04:00
Elie Habib c4509e8a69 docs: add self-hosting section to README
Explains that `vercel dev` is needed instead of `npm run dev` for
the API edge functions to work. Covers three deployment options
(Vercel, local with CLI, static frontend only) and platform notes
for Raspberry Pi/ARM users. Addresses #44.
2026-02-13 07:23:08 +04:00
Elie HabibandGitHub 8a7e8b2230 Fix hotspot overlay positioning during map drag (#43)
## Summary
Fixes visual lag where high-activity hotspot overlays drift from their
geographic positions during map panning operations.

## Problem
HTML overlays for high-activity hotspots (`level=high` or
`hasBreaking=true`) were only updating positions on a throttled 100ms
delay. During map drag operations, this caused overlays to maintain
their screen position while the map moved underneath, resulting in a
"drift and snap" effect where markers would float away from their true
locations then jump back when the update triggered.

## Solution
- Added `updateHotspotPositions()` method that synchronizes overlay
positions with the map viewport in real-time during movement
- Bound method to the map `'move'` event for frame-by-frame position
updates during drag operations
- Eliminated the 100ms throttle delay for position calculations during
active map movement

## Changes
- **Added** `updateHotspotPositions()` method for real-time overlay
position synchronization
- **Added** `'move'` event listener to trigger position updates during
map drag/pan
- **Fixed** drifting issue for high-activity and breaking hotspot
markers
- **Added** dev mode helper to force 3 random hotspots as `breaking` for
easier overlay behavior testing (for some reason they just weren't
appearing on localhost)



https://github.com/user-attachments/assets/a1cfe749-69bb-499c-b396-24054e7891ce



https://github.com/user-attachments/assets/59c4265a-0667-4ea9-a657-a4f67f983f1e
2026-02-13 00:36:50 +04:00
Elie Habib 1716278538 Fix hotspot overlay sync path and harden map harness regressions 2026-02-13 00:34:19 +04:00
Ahmadhamdan47 5f9663c2c7 Fix hotspot overlay positioning during map drag
- Add updateHotspotPositions() method that updates overlay positions in real-time during map movement

- Call updateHotspotPositions() on 'move' event to prevent overlays from lagging behind their geographic positions

- Fixes issue where high-activity/breaking hotspot markers would drift during drag and snap back on release

- Add dev mode helper to force 3 random hotspots as breaking for testing overlay behavior

The HTML overlays for high-activity hotspots (level=high or hasBreaking=true) were only updating positions on a throttled/delayed basis (100ms), causing them to maintain screen position while the map moved underneath, then snap back when the update triggered. Now they update smoothly every frame during map movement.
2026-02-12 19:27:28 +02:00
Elie Habib 6294a43df3 Merge branch 'codex/pr-41' 2026-02-12 18:27:53 +04:00
Elie Habib 2b6add1f0d fix(map): reproject overlay clusters on pan and harden e2e visual determinism 2026-02-12 18:00:54 +04:00
Elie Habib b0e829a84c test(e2e): add variant and pan reprojection regression checks 2026-02-12 17:37:21 +04:00
Elie Habib 0697eeba3a test(e2e): add deterministic per-layer visual baselines 2026-02-12 13:21:45 +04:00
Elie Habib 302f3fba03 Expand Playwright map coverage across full and tech layer sets 2026-02-12 12:57:47 +04:00
Elie Habib a8a648bfd9 Add Playwright map harness smoke tests for layer regressions 2026-02-12 12:34:42 +04:00
Elie Habib 5db303f822 Fix cluster state tracking and stale overlay cache invalidation 2026-02-12 09:05:34 +04:00
Elie Habib dde49b7248 chore: trigger redeploy for PR #41 2026-02-12 08:55:29 +04:00
Elie Habib 08f03f8ed3 Fix tooltip coverage, pulse scheduling, and map interaction defaults 2026-02-12 08:50:49 +04:00
Elie Habib 419285d291 Fix conflict layer lifecycle by avoiding cached GeoJsonLayer instance 2026-02-12 08:29:23 +04:00
Elie Habib c876158abe Fix DeckGL map regressions and preserve perf optimizations 2026-02-12 08:18:01 +04:00
Ahmadhamdan47 2349675a9c Optimize map rendering performance with multi-tier caching
- Implements layer caching to avoid rebuilding unchanged deck.gl layers
- Adds cluster element DOM caching with position-based invalidation
- Adds cluster result caching to prevent redundant spatial computations
- Replaces setInterval with requestAnimationFrame for news pulse animation
- Adds throttle, debounce, and rafSchedule utilities for render optimization
- Removes hover tooltips in favor of click-based interactions (better performance)
- Adds performance monitoring with console warnings for >16ms operations
- Implements smart layer rebuilding only on zoom threshold crossings
- Reduces cluster overlay opacity during map moves for smoother UX
- Configures deck.gl with interleaved=false and useDevicePixels=false for performance

Performance improvements:
- Reduces redundant layer rebuilds by ~90% during map interactions
- Eliminates tooltip hover callback overhead
- Prevents cluster DOM thrashing with element reuse
- 60fps target for map panning/zooming maintained
2026-02-12 02:02:14 +02:00
Elie Habib c80136ffdb Add sparkline charts to Markets, Crypto, and Commodities panels
- Extract close price arrays from Yahoo Finance chart API for indices/commodities
- Switch CoinGecko crypto fetch to /coins/markets endpoint with 7d sparkline data
- Render inline SVG sparklines color-coded green/red by price direction
- Fix Vite dev proxy for CoinGecko (was hitting root instead of /api/v3/simple/price)
- Add endpoint=markets support to CoinGecko edge function
2026-02-12 00:20:44 +04:00
Sebastien MelkiandGitHub 7382f19a47 docs: add stargazers growth to the bottom of the README file (#40) 2026-02-11 21:21:06 +04:00
Elie Habib 06d2068da8 Expand README with market intelligence, architecture, and security docs
- Add Market & Crypto Intelligence data layer section
- Document macro signal analysis (7-signal BUY/CASH verdict, VWAP, Mayer Multiple)
- Document stablecoin peg monitoring and BTC ETF flow estimation algorithms
- Add strategic theater posture assessment (9 theaters)
- Add infrastructure cascade BFS modeling with chokepoint dependencies
- Add browser-side ML pipeline section (Transformers.js capabilities)
- Add dual-deployment architecture diagram (Vercel + Railway)
- Add caching architecture section (3-tier with stale-on-error)
- Add security model section (8-layer defense table)
- Update edge function count (30+ → 45+), tech stack, env vars
- Add Cmd+K search, virtual scrolling, UCDP/HAPI to capabilities
2026-02-11 19:07:39 +04:00
Elie Habib 14c67ff592 Optimize proxy usage with AIS snapshots, Upstash caching, and telemetry 2026-02-11 19:06:00 +04:00
3985def3c7 Add .env.example with all environment variables (#39)
* Add .env.example with all environment variables documented

* Fix .env.example: add missing vars, fix wrong names, organize by deployment

- Remove ghost var VITE_OPENSKY_RELAY_URL (not used in codebase)
- Add missing AISSTREAM_API_KEY (Railway relay)
- Add missing WS_RELAY_URL (Vercel server-side relay connection)
- Add missing VITE_VARIANT (site variant selector)
- Move OPENSKY_CLIENT_ID/SECRET to Railway section (used by relay, not Vercel)
- Label sections with deployment target (Vercel vs Railway)
- Remove stale docs/DOCUMENTATION.md reference from header

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-02-11 18:24:40 +04:00
Elie Habib ee3a3f1c6b Add Market Radar, BTC ETF Tracker, and Stablecoins panels
Three new crypto/market intelligence panels with self-fetching data:

- Market Radar (macro-signals): 7 signals from Yahoo Finance, mempool.space,
  alternative.me — liquidity, flow structure, macro regime, BTC trend with
  30d VWAP, hash rate, mining cost, fear & greed. SVG sparklines and donut gauge.
- BTC ETF Tracker (etf-flows): 10 spot Bitcoin ETFs via Yahoo Finance 5-day
  chart with estimated net flows, volume, and price change per ETF.
- Stablecoins (stablecoin-markets): CoinGecko proxy for peg health monitoring
  (ON PEG / SLIGHT DEPEG / DEPEGGED) and supply/volume breakdown.

All endpoints include CORS origin guard, input validation, in-memory caching
with stale fallback. Panels registered in both full and tech variants.
2026-02-11 14:35:28 +04:00
Elie Habib f7119b9ed6 Harden CORS, XSS, and input validation across all API endpoints and components
- Add CORS origin allowlist (api/_cors.js) replacing Access-Control-Allow-Origin: *
- Add isDisallowedOrigin guard to all API endpoints (acled, cloudflare-outages, finnhub, fred-data, hackernews, wingbits)
- Gut debug-env endpoint to return 404
- Tighten sanitizeUrl() with escapeAttr output and strict relative URL validation
- Add sanitizeUrl() adoption in CountryIntelModal, InsightsPanel, PredictionPanel, RegulationPanel, TechEventsPanel
- Comprehensive escapeHtml() hardening in MapPopup (cables, flights, vessels, clusters)
- Bound HackerNews concurrent fetches (MAX_CONCURRENCY=10), validate story type and limit params
- Add wingbits cache eviction (MAX_LOCAL_CACHE_ENTRIES=2000, sweep on TTL + LRU)
- Fix arxiv http→https, og-story parseInt safety with Number.isFinite + clamping
2026-02-11 14:35:07 +04:00
Elie HabibandGitHub e808371250 Fix live video stopping on tab switch (#37)
## Summary
- Stop destroying the YouTube player when the tab becomes hidden —
audio/video now continues in background tabs
- Suspend the 5-minute idle timer while the tab is hidden so it doesn't
silently kill background playback
- Idle timer resumes when the user returns to the tab

## Test plan
- [ ] Open the live news panel and start a stream
- [ ] Switch to another browser tab — verify audio continues playing
- [ ] Minimize the browser window — verify audio continues
- [ ] Return to the tab after >5 minutes — verify stream is still
playing
- [ ] Leave the tab visible but don't interact for 5 minutes — verify
idle pause still triggers
2026-02-11 07:40:41 +04:00
Elie Habib e5c652c2aa Allow live video to continue playing in background tabs
Previously, switching tabs or minimizing the window would immediately
destroy the YouTube player, cutting audio and video. Now the stream
continues playing when the tab is hidden, with the idle timer suspended
so it doesn't silently kill playback after 5 minutes in the background.
2026-02-11 07:39:22 +04:00
Elie Habib 45dc787224 Fix UCDP proxy: API returns snake_case fields, not PascalCase
UCDP API fields are conflict_id, location, side_a, intensity_level etc.
(snake_case) — proxy was reading ConflictId, Location, IntensityLevel
(PascalCase), causing all values to be empty/0.

Also paginate through all results instead of just first page.
2026-01-31 08:06:18 +04:00
Elie Habib 8d9265903f Pause AI classify queue on 500 errors, not just 429
When Groq returns 500, the queue kept firing requests against a failing
API. Now 500+ errors pause the queue for 30s (429 stays 60s) and flush
pending jobs, preventing the retry storm visible in console.
2026-01-31 08:03:03 +04:00
Elie Habib f7fd4ad24d Fix Chatham House 403 and FAO parse errors
- Chatham House RSS returns 403 from cloud IPs, use Google News fallback
- FAO FPMA feed returns HTML (Angular app), not RSS — replaced with fao.org/rss
- Updated rss-proxy allowlist domain
2026-01-31 08:00:13 +04:00
Elie Habib 8e6b627a66 Fix HAPI 500: correct endpoint URL, auth format, response parsing
- Endpoint was conflict-event (singular), fixed to conflict-events (plural)
- app_identifier must be base64("name:email"), passed as query param
- Response fields are (events, fatalities, event_type) per row, not
  pre-aggregated columns — fixed aggregation logic
- Remove Arab News and Times of Israel feeds (403 from cloud IPs)
2026-01-31 07:30:48 +04:00
Elie Habib c6f1da95c5 Integrate ACLED conflicts, UCDP classification, and HDX HAPI for real conflict detection
Replace hardcoded conflict floor scores with real data from three sources:
- ACLED battles/explosions/violence against civilians (30-day events)
- UCDP conflict classification (war vs minor vs none, data-driven floors)
- HDX HAPI aggregated monthly conflict counts (fallback/validation)

New CII component 'conflict' weighted 30% of event score. Updated formula:
unrest(25%) + conflict(30%) + security(20%) + information(25%)
2026-01-30 23:26:53 +04:00
Elie Habib 4481719db8 Raise conflict floor scores: Ukraine 72, Syria/Yemen 66, Myanmar/Israel 60 2026-01-30 22:58:21 +04:00
Elie Habib 097f850a05 Fix false 'Insufficient Data' on stale sources: extend stale threshold to 2h, count very_stale as active 2026-01-30 20:09:55 +04:00
Elie Habib f3b9cd8fa4 Fix FIRMS API key: accept NASA_FIRMS_API_KEY env var with fallback 2026-01-30 20:05:48 +04:00
Elie Habib c3c44a112d Remove dead/blocked intel RSS feeds (RUSI, CNAS, CFR, Wilson Center, Lowy, GMFUS, Arms Control, Stimson, Bulletin, World Bank, IMF) 2026-01-30 20:03:55 +04:00
Elie Habib 52226949a2 Replace hexagon icon with standard share/upload SVG icon for story buttons 2026-01-30 15:19:37 +04:00
Elie Habib fe36df976c Subtle fullscreen button active state: translucent bg instead of solid fill 2026-01-30 15:12:11 +04:00
Elie Habib 5860e5b5ff Replace bulky megaphone emoji with smaller ✦ symbol for protest markers 2026-01-30 13:04:35 +04:00
Elie Habib c36096261e Redesign story share UI: modern floating bar with SVG icons, X close button 2026-01-30 11:15:03 +04:00
Elie Habib 3db9dbbfc8 Rename Satellite Fires→Fires in UI, fix component bar scale, add more signal type labels 2026-01-30 11:11:13 +04:00
Elie Habib 31d492acfd Add WorldMonitor logo to story card header and footer 2026-01-30 11:03:23 +04:00
Elie Habib 148164915d Add humanizeSignalType for readable convergence signal labels in story cards 2026-01-30 11:01:00 +04:00
Elie Habib 41c52af5ea Fix SVG arc paths: add missing M (moveto) command 2026-01-30 10:54:41 +04:00
Elie Habib beac24c1f1 Redesign OG image as rich intelligence card
- Gradient background with left accent sidebar in level color
- Large 120px CII score with /100 + labeled score bar with tick marks
- Semicircle arc gauge on right with score + level badge
- Data indicator legend (threat, military, markets, convergence, signals)
- No-score fallback shows 4 feature cards with descriptions
- Bottom bar with W logo circle, brand text, and CTA button
- Subtle grid background, status pill, date stamp
2026-01-30 10:51:41 +04:00
Elie Habib 91dbfefd06 Improve story card clarity + fix OG image missing score data
- Story renderer: larger fonts, better contrast (#777 vs #555 headers,
  #e0e0e0 vs #ddd text), visible separators (#222 vs #1a1a2e), removed
  grid background noise, bigger score (72px), bigger headlines (26px)
- OG image: pass score & level params to og-story endpoint, improve
  contrast, larger score text (96px), better fallback when no score
- Meta tags: include s= and l= query params in og:image URL
2026-01-30 10:34:01 +04:00
Elie Habib d296f679a5 Improve story card clarity: larger fonts, better contrast, visible separators 2026-01-30 10:29:44 +04:00
Elie Habib d981c3b180 feat: add OG image and story page edge functions for Twitter card previews
- /api/og-story: generates SVG OG image with country name, CII score, level
- /api/story: serves meta tags to social crawlers, redirects real users to SPA
- Deep links now point to /api/story for proper crawler support
- Pass CII score/level through URL params for dynamic OG images
2026-01-30 06:22:18 +00:00
Elie Habib 0996525539 fix: use dynamic country flag in share texts, remove duplicate URL from twitter 2026-01-30 06:19:22 +00:00
Elie Habib 629b691bb5 fix: resolve TypeScript errors in data-freshness, focal-point, meta-tags, temporal-baseline 2026-01-30 06:14:36 +00:00
Elie Habib e34b1ca0a3 feat: satellite fires layer, temporal baseline, cleanup dead code, update README
- Add NASA FIRMS satellite fire detection map layer and panel
- Add temporal baseline anomaly detection (Welford's algorithm, Redis-backed)
- Wire signal aggregator with fires, temporal anomalies
- Remove 10 dead service files and unused markdown docs
- Deduplicate RSS feeds, clean up story templates
- Fix data freshness, status panel, and verification checklist
- Create og-image.png for social sharing meta tags
- Update README with signal aggregation, source tiering, edge architecture
- Bump version to 2.1.4
2026-01-30 06:07:40 +00:00
Elie Habib 8586c1485c feat: wire new services into signal aggregator
Add signal ingestion methods for all 9 new services:
- ingestSatelliteFires() - NASA FIRMS fire detection
- ingestDefenseActivity() - war analysis military activity
- ingestInfrastructureStatus() - IXP/cable/gateway status
- ingestSentimentAlerts() - social media sentiment shifts

Add 4 new SignalType enums:
- satellite_fire
- defense_activity
- infrastructure_status
- sentiment_shift

Update getSummary() and regional convergence to include new signal types.

This integrates all new intelligence services into the main signal feed.
2026-01-30 04:05:57 +00:00
Elie Habib 1023ac38a3 docs: ALL OSINT research features now implemented - 9/9 complete 2026-01-30 03:54:01 +00:00
Elie Habib 3ae4d66440 feat: add API for third-party integrations
Create src/services/api-server.ts:
- REST API with Express.js
- Authentication via API key (header or query param)
- Rate limiting (100 requests/minute)
- Endpoints: /stories, /signals, /reports, /countries, /categories
- Webhook service for event notifications
- Data export (JSON, CSV, Markdown)
- CORS support for external apps

This completes all features from OSINT research!
2026-01-30 03:53:49 +00:00
Elie Habib a52c21ff6e docs: update OSINT research - all features implemented 2026-01-30 03:43:05 +00:00
Elie Habib ef76e055c9 feat: add automated report generation service
Create src/services/report-generator.ts:
- Daily, weekly, and incident intelligence reports
- Calculate metrics: signal count, severity breakdown, top regions/categories
- Generate sections: executive summary, critical items, regional analysis, trends, recommendations
- Export formats: markdown, JSON
- Quick report for dashboard integration

This addresses Priority 4.1 (Automated report generation) from OSINT research.
2026-01-30 03:42:50 +00:00
Elie Habib 67596dbe07 feat: add war analysis tools (SALW tracking, military equipment)
Create src/services/war-analysis.ts:
- Track military equipment by type: aircraft, tank, artillery, naval, missile, vehicle, drone
- Monitor defense activities: drills, mobilization, deployment, exercise, procurement
- Analyze threat levels based on activity scale + equipment count
- 4 monitored regions: Ukraine, Middle East, East Asia
- Arms transfer tracking (simplified SIPRI-style)
- Convert to threat signals for intelligence feed

This addresses Priority 3.3 from OSINT research.
2026-01-30 03:40:58 +00:00
Elie Habib 451a5a1a6b feat: add internet infrastructure awareness service
Create src/services/infrastructure-map.ts:
- Track IXPs, data centers, cable landings, gateways for 10 regions
- Infrastructure report per region (active/degraded/offline counts)
- Detect cable integrity concerns (Red Sea, Taiwan Strait)
- Convert to threat signals for intelligence feed
- 20+ nodes tracked across monitored countries

This addresses Priority 2.1 from OSINT research.
2026-01-30 03:33:31 +00:00
Elie Habib 43d603a6e8 feat: implement Correlation Engine 2.0
Create src/services/correlation-engine.ts:
- Temporal correlations (events within same time window, shared keywords)
- Spatial correlations (events within 500km)
- Thematic correlations (3+ events sharing theme)
- Cascade detection (A happens → B happens 6-48h later)
- Temporal patterns (recurring events detection)
- Geographic clustering (events within 100km)

Converts correlations to threat signals for intelligence feed.

This is the "brain" that finds hidden connections between events.
2026-01-30 03:32:26 +00:00
Elie Habib a39f10f3a3 feat: add social media sentiment tracking service
Create src/services/sentiment-tracker.ts:
- Tracks sentiment shifts in 8 monitored countries (US, Russia, Ukraine, Iran, Israel, China, Turkey, Saudi Arabia)
- Keyword-based analysis with alert keyword monitoring
- Detects anomalies (sudden negative shifts, trending alert terms)
- Converts to threat signals for intelligence feed
- GetSentimentSummary() for dashboard overview

This addresses Priority 1.3 from OSINT research.
2026-01-30 03:31:29 +00:00
Elie Habib 285c421769 feat: add AI-generated content detection service
Create src/services/ai-detection.ts:
- Check for AI generation artifacts (EXIF, GPS, lighting, edges, patterns)
- Bellingcat\'s seven deadly sins of bad OSINT
- Quick verification checklist for content validation
- Helps users distinguish real news from AI-generated/propaganda

This addresses Priority 1.2 (Disinformation Detection) from OSINT research.
2026-01-30 03:17:46 +00:00
Elie Habib 5b9d232a51 docs: update OSINT research to mark implemented features 2026-01-30 03:15:57 +00:00
Elie Habib b88666c5c5 feat: add verification checklist UI based on Bellingcat OSH framework
- Create src/components/VerificationChecklist.ts
- 8-point verification checklist: recency, geolocation, source, cross-reference, AI detection, recycled footage check, metadata, context
- Score calculation (0-100%) with verdicts: verified/likely/uncertain/unreliable
- Notes section for manual verification notes
- Ready to wire into intelligence feed for content verification

This helps users distinguish real news from propaganda and recycled footage.
2026-01-30 03:15:03 +00:00
Elie Habib 817479c445 feat: implement NASA FIRMS satellite fire detection
- Create src/services/firms-satellite.ts for NASA Fire Information API integration
- Fetch active fires, thermal anomalies in monitored regions (Ukraine, Iran, Israel/Gaza, etc.)
- Detect fire anomalies vs baseline activity
- Convert fire data to threat signals for intelligence feed
- Update OSINT_RESEARCH.md to mark as IMPLEMENTED

This adds satellite imagery capability to detect fires, construction, and thermal
activity in conflict zones - a key differentiator from other OSINT tools.
2026-01-30 03:14:03 +00:00
Elie Habib e25eaa0f5a research: OSINT tools analysis and improvement ideas
Researched top OSINT tools (Spiderfoot, Shodan, Babel X, Censys) and war analysis
methodologies to identify improvement opportunities for World Monitor.

Priority improvements identified:
- Satellite imagery integration (NASA FIRMS)
- Disinformation detection (Bellingcat framework)
- Social media sentiment tracking
- Internet infrastructure awareness
- Verification checklist UI

Created OSINT_RESEARCH.md with detailed analysis and next steps.
2026-01-30 03:09:26 +00:00
Elie Habib 8f4cba06e5 feat(worldmonitor): 10 initiatives - launch prep and enhancements
COMPLETED INITIATIVES:

1. **Critical RSS Feeds** - Added 20+ feeds (RUSI, Chatham House, CFR, FAO, etc.)
2. **CII Trends** - 7-day/30-day rolling baselines with trend detection
3. **Trending Stories** - Analytics panel with localStorage persistence
4. **Launch Copy** - Finalized Twitter, LinkedIn, Reddit, Product Hunt materials
5. **Product Hunt** - Submission ready with tagline, bullets, screenshots
6. **Reddit Posts** - Ready for r/cybersecurity, r/INTELLIGENCE, r/geopolitics
7. **OG Meta Tags** - Dynamic meta tags for story sharing + Twitter Cards
8. **Story Templates** - 6 templates (analysis, crisis, brief, markets, compare, trend)
9. **Deep Link Router** - /story?c=UA routing to open country stories
10. **Self-Review Logging** - DEVELOPMENT_LOG.md per @jumperz pattern

FILES ADDED:
- src/services/meta-tags.ts - Dynamic OG/Twitter meta tags
- src/services/story-templates.ts - Template configurations
- src/services/cii-trends.ts - CII trend tracking
- src/services/trending-stories.ts - Story analytics
- 10_INITIATIVES.md - Initiative tracker
- ENHANCEMENT_PLAN.md - Comprehensive plan
- DEVELOPMENT_LOG.md - Self-review log

FILES MODIFIED:
- src/config/feeds.ts - Added 20+ RSS feeds
- api/rss-proxy.js - Added domains to allowlist
- src/main.ts - Initialize meta tags
- src/App.ts - Deep link handling
- index.html - OG meta tags
- LAUNCH_MATERIALS.md - Final launch copy

READY FOR LAUNCH!
2026-01-29 20:58:17 +00:00
Elie Habib ee18354d3e feat: add temporal anomaly detection service
- Detect when activity levels deviate from historical baselines
- Z-score calculation for military, vessels, protests, news
- Alert when activity is 1.5x-3x normal for the time period
- Supports weekday and month-specific baselines
2026-01-29 20:49:19 +00:00
Elie Habib 653d35c3f3 feat: enhance story sharing with templates, deep links, and multi-platform sharing 2026-01-29 20:48:30 +00:00
Elie Habib 20f5134d96 WhatsApp share: copy image to clipboard + open web.whatsapp.com
Deep links can't send images. Now: mobile uses Web Share API (sends
image natively), desktop copies image to clipboard then opens WhatsApp
Web so user can paste it.
2026-01-30 00:32:00 +04:00
Elie Habib fcc07c66ad Use https:// URL in WhatsApp share for clickable link 2026-01-30 00:30:10 +04:00
Elie Habib f0e495451b Fix WhatsApp deep link: only fallback to wa.me if app didn't open 2026-01-30 00:27:18 +04:00
Elie Habib 2f48d14c47 Fix WhatsApp share: use deep link + auto-download image
Try whatsapp:// deep link first (opens native app), fallback to wa.me.
On desktop: auto-downloads the image then opens WhatsApp so user can attach.
2026-01-30 00:25:51 +04:00
Elie Habib 794ab5fe51 Hide CountryIntelModal before opening story 2026-01-30 00:24:45 +04:00
Elie Habib 7f0c654eeb Prevent toast stacking on repeated clicks 2026-01-30 00:22:09 +04:00
Elie Habib 26309b5641 Gate story generation on data readiness
Block story rendering when data sources haven't loaded yet
(hasSufficientData check + news cluster check). Shows toast
notification instead of generating an incomplete/stale story.
2026-01-30 00:21:20 +04:00
Elie Habib 76f350be55 Fix story modal z-index and convergence score text overlap
- Story modal z-index 9999→10001 so it renders above CountryIntelModal (10000)
- Measure convergence score text width before changing font context
2026-01-30 00:19:31 +04:00
Elie Habib 96d99cf2f2 Improve story cards: fix % bug, add signals/convergence, share to WhatsApp/Instagram
- Fix prediction market % (yesPrice already 0-100, remove *100)
- Add Active Signals section (protests, military flights/vessels, outages)
- Add Signal Convergence section (score, signal types, regional descriptions)
- Show 24h CII change, component mini-bars, grid background
- Share options: Save PNG, WhatsApp, Instagram (Web Share API + fallbacks)
- Add share story button to CountryIntelModal (map country click)
- Fix: slashW measured with correct font, sync blob creation, footer overflow guard
- Bump to v2.1.3
2026-01-30 00:11:27 +04:00
Elie Habib c5b683212f Switch story rendering to client-side Canvas (WASM not allowed in Vercel Edge) 2026-01-29 23:49:48 +04:00
Elie Habib 5adefb1d76 Add World Stories: shareable vertical country intelligence snapshots via @vercel/og 2026-01-29 23:42:03 +04:00
Elie Habib 6f0c111d6c Remove BREAKING badges from hotspots, add 30s pulse ring on news location markers 2026-01-29 22:31:48 +04:00
Elie Habib e84488d971 Rate-limit AI classification queue, cache vessel counts in localStorage
- Throttle classifyWithAI to 2 concurrent requests with 300ms delay
- Pause queue for 60s on 429, flush pending jobs to avoid memory leak
- Cache vessel theater counts in localStorage (30min TTL) so page
  refresh instantly restores vessel data while AIS stream re-accumulates
2026-01-29 22:00:14 +04:00
Elie Habib b63ec5b7fd Add CII boost to theater posture, lower Iran naval thresholds
Theater posture now considers Country Instability Index: CII ≥85 forces
critical, ≥70 forces elevated. Fixes Iran Theater appearing lower than
Baltic despite CII 92 and active crisis reporting. Also lowered Iran
naval thresholds (elevated:2, critical:5) to account for poor AIS
coverage in Persian Gulf where military vessels routinely go dark.
2026-01-29 21:27:17 +04:00
Elie Habib 653b40192a Add category tag badge to news items from threat classification
Shows classified event category (Conflict, Cyber, Protest, etc.) as a
colored badge on each news item, leveraging the Groq LLM classification
pipeline. Color matches threat level. General category hidden to reduce noise.
2026-01-29 19:58:50 +04:00
Elie Habib 79368dc4e0 Add AI threat classification, map progressive disclosure, and bug fixes
- Hybrid keyword + Groq LLM classification with Redis cache (24h TTL)
- Progressive disclosure: bases/nuclear/datacenters hidden at low zoom
- Label deconfliction: BREAKING badges suppress overlaps by priority
- Zoom-adaptive opacity and marker sizing for reduced visual clutter
- Fix unbounded summary growth in alert merging (cap at 3 items)
- Fix Strategic Risk Panel showing "Insufficient Data" on startup
- Remove dead renderLimitedData code
- Expand README with algorithms, architecture, and new features
- Bump version to 2.1.2
2026-01-29 19:29:40 +04:00
Elie Habib a0bc560daf Lower theater posture thresholds for open-source tracking reality, add localStorage caching
Thresholds were calibrated for classified-level data visibility (50+ aircraft for
Iran ELEVATED). Open-source trackers (OpenSky/Wingbits) see ~10-20% of actual
military flights. Lowered all theaters ~5-6x to match real detection rates.

Added localStorage persistence to cached-theater-posture so the Strategic Posture
panel shows last-known data instantly on page reload instead of starting empty.
2026-01-29 16:40:56 +04:00
Elie Habib 08e11c2e17 Add news geo-location map markers and country prediction markets
Wire geo-hub keyword matching into RSS feed parsing to attach lat/lon
to news items, aggregate locations at cluster level, and render as
ScatterplotLayer on the deck.gl map colored by threat level.

Add fetchCountryMarkets() to Polymarket service with country-to-tag
mapping and variant matching, shown in CountryIntelModal with yes/no
bars when clicking a country.
2026-01-29 16:06:28 +04:00
Elie Habib d40dc246ae Improve Polymarket: tag-based event queries, clickable links
Switch from volume-only market fetching to tag-based event queries
for better relevance. Variant-aware tags (geopolitical vs tech).
Deduplicates across tag queries, falls back to top-volume markets
only when needed. Add clickable links to Polymarket event pages.
2026-01-29 15:19:31 +04:00
Elie Habib f5348e319d Update world monitor screenshot 2026-01-28 14:52:12 +04:00
Elie Habib aa1e7ca066 Sort news by threat severity instead of chronological, remove filter UI
Headlines now ranked critical→high→medium→low→info with time as tiebreaker.
Removed filter buttons, threat badges, and sort toggle — cleaner panel.
2026-01-27 23:50:44 +04:00
Elie Habib 7857bb9e2b Add threat level classification system with keyword classifier, UI badges, and filters
- New threat-classifier.ts: 5-level classification (critical/high/medium/low/info) with
  14 event categories, variant-aware keywords (tech adds outage/breach/layoff etc),
  word-boundary regex for short keywords to prevent false positives
- Wire classifier into RSS pipeline, derive isAlert from threat level (backward compat)
- Aggregate threat at cluster level (max level, most frequent category, tier-weighted confidence)
- NewsPanel: colored threat badges, 5-button filter bar, severity sort toggle
- Persist filter/sort prefs in localStorage
2026-01-27 23:34:33 +04:00
Elie Habib 8b58af0452 Remove unreliable Kuwait symbol (only 1 data point on Yahoo) 2026-01-27 18:14:14 +04:00
Elie Habib d804dc7c85 Fix Middle East stock indices: add UAE DFM, fix Kuwait, use 1mo range
- Add DFMGI.AE for UAE (DFM General Index)
- Use range=1mo instead of 5d to handle Sun-Thu trading weeks
- Take last ~5 trading days from monthly data for weekly % change
- Remove broken Qatar symbol (^QSI returns no data)
2026-01-27 17:23:47 +04:00
Elie Habib e28c3f7123 Remove broken Yahoo Finance symbols (AE, VN, NG, KE, CO, PK, BD, GR, RO)
Yahoo Finance doesn't carry indices for UAE, Vietnam, Nigeria, Kenya,
Colombia, Pakistan, Bangladesh, Greece, or Romania.
2026-01-27 17:20:19 +04:00
Elie Habib 67d06ebbf4 Add stock market index to country intel + pause renders during modal
- Stock index API endpoint (Yahoo Finance, Redis 1h cache, ~50 countries)
- Stock chip in modal: shows weekly % change with green/red styling
- Pause deck.gl renders while country modal is open (fixes flickering)
- Suppress signal modal clicks while country modal is visible
- Single stock fetch reused for both UI chip and AI brief context
2026-01-27 17:14:43 +04:00
Elie Habib 97f3d75249 Comprehensive country intel: geo-based military counts, richer context, expanded prompt
- Military signals now counted by geographic bounding box (lat/lon in country)
  instead of operator flag — matches what posture panel shows
- Add country aliases for headline matching (Israel → israeli, gaza, hamas, idf...)
- Feed convergence scores + regional alerts to AI context
- Expanded prompt: 5 sections, 300-400 words, military posture analysis,
  regional context, cross-signal correlation
- Bump max_tokens 500→900, cache version ci-v1→ci-v2
- Add 25+ country bounding boxes for geo-matching
2026-01-27 16:56:39 +04:00
Elie Habib b32674df10 UX: country hover highlight + instant loading feedback on click
- Add transparent interactive fill layer for country hover detection
- Hover shows subtle highlight + pointer cursor so user knows it's clickable
- Show loading modal immediately on click (before geocode completes)
- Dismiss modal if geocode fails (ocean click etc)
2026-01-27 16:49:56 +04:00
Elie Habib 1292f0595f v2.1.0: Country click → AI intelligence brief
Click empty map area to detect country via Nominatim reverse geocoding,
highlight it with GeoJSON boundaries, and show an AI-generated intel
brief (Groq, Redis-cached 2h) with CII score, active signals, and
contextual news headlines.
2026-01-27 16:43:52 +04:00
Elie Habib fd547bbc15 Suppress map location flashes during initial news load
Prevents the jarring effect of all detected locations flashing
simultaneously when the app first loads. Flashes only trigger
after the initial feed collection completes.
2026-01-27 16:03:50 +04:00
Elie Habib cbd55c4d08 Factor naval vessels into theater posture level calculation
Added navalThresholds per theater and recalcPostureWithVessels() that
uses "either triggers" logic: if aircraft OR vessels exceed their
respective thresholds, the theater level escalates. Previously only
aircraft counts were used, so 40 vessels + 10 aircraft showed "normal".
2026-01-27 10:22:00 +04:00
Elie Habib 9399fd89ee Fix map navigation: combine flyTo + zoom into single call
DeckGLMap.setCenter() uses maplibreMap.flyTo() (animated, 500ms) but
callers immediately followed with setZoom() which interrupted the
animation. Added optional zoom param to setCenter() and updated all
callers in App.ts to use setCenter(lat, lon, 4) instead of separate calls.
2026-01-27 09:42:26 +04:00
Elie Habib c94f225a04 Remove SVA (Saudi Arabian Airlines) from military callsign regex
SVA is Saudia's ICAO code - was causing false positive military
classification for civilian Saudi flights.
2026-01-27 09:33:23 +04:00
Elie Habib 60070e8685 Cherry-pick improvements from PRs #33, #34, #35 with fixes
- PR #33: Disambiguate NAVY callsign by origin country (US vs UK)
- PR #34: Use typecode fallback in determineAircraftInfo + Wingbits
  enrichment (guarded: only when type is still 'unknown')
- PR #35: Persist typeCode in enriched data (skip the broken
  pre-filter removal and false-positive regex from original PR)

Closes #33, #34, #35
2026-01-27 09:31:13 +04:00
Elie Habib 3ac09ba37e Add comprehensive debug logging for map navigation handler 2026-01-27 09:25:07 +04:00
Elie Habib 8cebd9e918 Add debug logging to trace handler setup 2026-01-27 09:20:31 +04:00
Elie Habib 766529a9f6 Add debug logging to theater click + map setCenter 2026-01-27 09:09:55 +04:00
Elie Habib 745694e236 Fix map setCenter calculation - remove incorrect zoom division
The pan formula was incorrectly dividing by zoom:
  pan.x = width/(2*zoom) - pos[0]  // Wrong

Corrected to:
  pan.x = width/2 - pos[0]  // Correct

The zoom-independent formula ensures clicking theaters in Strategic
Posture panel correctly centers on that region at any zoom level.
2026-01-27 09:08:49 +04:00
Elie Habib c3c2fd7341 Fix military detection false positives and code quality
- Remove CCA from MILITARY_PREFIXES (Air China airline, not military)
- Remove duplicate IAF entry from Middle East section
- Move AIRLINE_CODES to module level Set for O(1) lookup
- Remove duplicate entries (ELY, THY, SWR)
2026-01-27 08:33:38 +04:00
Elie Habib 2801453057 Add ADS-B Exchange military aircraft database
- 20,396 verified military hex IDs from adsbexchange.com
- Updated daily by ADS-B Exchange community
- O(1) lookup using Set
- Combines with callsign detection for comprehensive coverage
2026-01-27 08:21:20 +04:00
Elie Habib a7126f3ad2 Simplify hex ranges to high-confidence military blocks
Focus on ranges where military/civilian separation is clear:
- US (AE/AF), UK, France, Germany, NATO, Russia, China, Australia, Japan

For other countries, rely on callsign detection since hex ranges
include commercial aircraft mixed with military.
2026-01-27 08:14:29 +04:00
Elie Habib 5719075866 Refine military hex ranges to exclude commercial
Previous ranges were too broad, catching commercial airlines.
Now using confirmed military-only blocks based on OSINT sources.
2026-01-27 08:11:53 +04:00
Elie Habib 8f667b26b1 Add ICAO hex range detection for military aircraft
Military aircraft have specific transponder ID ranges by country.
This matches how OpenSky UI identifies military aircraft.
Now checks both callsign patterns AND hex ranges.

Ranges added for: USA, UK, France, Germany, Italy, NATO, Israel,
Saudi, UAE, Qatar, Turkey, Russia, China, Iran, India, Pakistan,
Australia, Japan, South Korea
2026-01-27 08:09:23 +04:00
Elie Habib aad378bc29 Fix military detection false positives
- Remove UAE from military prefixes (conflicts with Emirates airline)
- Use more specific UAEAF, BAHAF, OMAAF for Gulf military
- Expand airline exclusion list with major carriers worldwide
- This improves accuracy by removing commercial flight false positives
2026-01-27 08:06:37 +04:00
Elie Habib f241adca64 Expand military callsign detection
- Add more US military prefixes (ARMY, NAVY, USAF, OPS, etc.)
- Add Middle East military prefixes (RSAF, UAE, EMIRI, etc.)
- Add NATO tactical callsigns (SWORD, LANCE, etc.)
- Add short tactical pattern (3 letters + 1-2 digits) with airline exclusion
- Exclude known airline codes (SVA, QTR, THY, etc.) from short patterns
2026-01-27 07:59:05 +04:00
Elie Habib cd3369bf29 Fix isMilitaryCallsign - remove overly broad regex patterns
The patterns [A-Z]{2,3}\d{3,4} and [A-Z]{3,4}\d{2,4} were matching
commercial airlines like PGT5873, IAW9011, IZG3020. New pattern only
matches 4+ letter callsigns like DUKE01, VIPER12 which are typical
military tactical callsigns.
2026-01-27 07:52:25 +04:00
Elie Habib 175a34d5cc Fix Wingbits field mapping - use short field names (h, f, la, lo, ab, th, gs)
BUG FIX: Wingbits API returns short field names like 'h' for icao24,
'f' for flight, 'la' for latitude, etc. The previous code was looking
for long names like 'icao24', 'callsign', 'latitude' which don't exist
in the response, causing all flights to be filtered out.
2026-01-27 07:49:40 +04:00
Elie Habib 3f3bbd3e3d Add Wingbits as fallback flight data source when OpenSky fails
- Add /flights and /flights/batch endpoints to wingbits proxy
- Add fetchMilitaryFlightsFromWingbits() to theater-posture API
- Try OpenSky first, fallback to Wingbits on 429/failure
- Transform Wingbits data to match existing flight format
- Add 'source' field to response ('opensky' or 'wingbits')
2026-01-27 07:22:51 +04:00
Elie Habib 2082613c28 Improve theater posture caching and fix Times of Israel feed
- Switch Times of Israel from Railway to Vercel RSS proxy (already allowlisted)
- Increase stale cache TTL from 1 hour to 24 hours
- Add 7-day backup cache as last resort during prolonged outages
- Better error handling: try stale → backup → error
2026-01-27 07:09:33 +04:00
Elie Habib a4059ab2da Unify loading indicators with radar-style across all panels
- Replace base Panel.showLoading() with radar sweep animation
- Add customizable message parameter to showLoading(message)
- Update CIIPanel to use unified loader
- Add panel-loading CSS classes matching Strategic Posture style
- Consistent green radar aesthetic across all loading states
2026-01-27 06:46:28 +04:00
Elie HabibandGitHub 02fbb2335b Use consistent panel-order storage key and consolidate into constant (#32)
### Motivation
- Ensure the variant-change reset removes the same panel-order key used
across the app and centralize the key to avoid future mismatches.

### Description
- Added a `PANEL_ORDER_KEY` constant (`'panel-order'`) to `src/App.ts`
and used `this.PANEL_ORDER_KEY` for reads/writes instead of the
scattered literals; replaced the mistaken `'worldmonitor-panel-order'`
removal with the unified key and updated migration code and
`getSavedPanelOrder`/`savePanelOrder`.

### Testing
- No automated tests were run for this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8c537cc832eb4ef5d877cb282d6)
2026-01-26 23:14:10 +04:00
Elie HabibandGitHub 7c591e8504 Ensure map URLs record empty layer selections (#30)
### Motivation
- Map share URLs must unambiguously reflect when every layer is disabled
so viewers get the exact layer state.
- Empty or missing `layers` query values were previously ambiguous, so
the URL builder and parser need to explicitly encode and interpret that
state.

### Description
- `parseMapUrlState` now treats a present `layers` parameter with value
`''` or `'none'` as “all off” and sets every key in `LAYER_KEYS` to
`false`, while preserving the existing fallback behavior when the
parameter is omitted.
- `buildMapUrl` always emits a `layers` query parameter, using `none`
when no layers are active and otherwise joining active keys with commas.
- The parser normalizes and trims the `layers` value before splitting to
robustly handle whitespace and empty tokens.

### Testing
- No automated tests were run.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8cdf7dc832eae02f128875f8fed)
2026-01-26 23:13:11 +04:00
Elie HabibandGitHub d43d698c56 Expand map layer URL keys in parsing and serialization (#31)
### Motivation
- Ensure share URLs fully capture map layer state by including every key
from the `MapLayers` type (geopolitical and tech layers).
- Keep `parseMapUrlState` and `buildMapUrl` consistent so both parsing
and serialization cover the same complete set of layers.

### Description
- Expanded the `LAYER_KEYS` array in `src/utils/urlState.ts` to include
`ais`, `protests`, `military`, `spaceports`, `minerals`, `startupHubs`,
`cloudRegions`, `accelerators`, `techHQs`, and `techEvents` so it
matches `MapLayers`.
- `parseMapUrlState` continues to use `LAYER_KEYS` to populate a
`MapLayers` object from the `layers` query param, now covering the full
set.
- `buildMapUrl` uses `LAYER_KEYS` to serialize active layers into the
`layers` query param, ensuring all layer flags are represented in share
URLs.
- Change is contained to `src/utils/urlState.ts` and committed.

### Testing
- No automated tests were run for this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8c992e0832eb1acc9f4dd730f11)
2026-01-26 23:12:57 +04:00
Elie Habib 7d9c61e8ec Fix panel order storage key usage 2026-01-26 23:07:25 +04:00
Elie Habib f507479761 Expand map layer URL keys 2026-01-26 23:06:49 +04:00
Elie Habib 95cb966b9d Ensure map URLs record empty layers 2026-01-26 23:06:13 +04:00
Elie Habib 82c3eb376a Fix coordinate truthy check - 0 is a valid coordinate
The View Region button was navigating to wrong locations because
`if (lat && lon)` returns false when either coordinate is 0.
Changed to typeof check to properly handle all valid coordinates.
2026-01-26 23:02:28 +04:00
Elie Habib 2b66e6b612 Add debug logging for banner View Region click 2026-01-26 22:50:59 +04:00
Elie Habib 31471b19af Fix aircraft type classification defaulting to fighter
- Change default from 'fighter' to 'unknown' for unrecognized callsigns
- Add 'unknown' count to aircraft breakdown
- Show bombers, transport, drones, and 'other' in summary
- Fixes misleading '179 fighters' when most were unclassified
2026-01-26 22:46:39 +04:00
Elie Habib ad4053e069 Streamline README for directory submissions
- Condense README from 3,896 lines to ~230 lines
- Move full documentation to docs/DOCUMENTATION.md
- Add social badges (stars, forks, license, last commit)
- Add 'Why World Monitor?' value proposition table
- Add Quick Start one-liner
- Use collapsible sections for data layers
- Add 'Support the Project' call to action
- Link to full docs throughout
2026-01-26 22:40:32 +04:00
Elie Habib b1b5c35a61 Update live channels: add CNBC to main, remove TBPN from tech 2026-01-26 22:31:51 +04:00
Elie Habib a260c5a82b Update NASA TV live feed video ID 2026-01-26 22:23:38 +04:00
Elie Habib b6739ce100 Fix Al Jazeera and Al Arabiya live feeds
Add useFallbackOnly flag to skip auto-detection for channels
where it returns wrong video IDs. These channels will now
always use their hardcoded fallback video IDs.
2026-01-26 22:22:48 +04:00
Elie Habib c588476dbe Fix Tech Readiness panel: actually call refresh()
Panel was created but refresh() was never called - data never loaded.
Added to loadAllData() tasks for tech variant.
2026-01-26 22:17:36 +04:00
Elie Habib 66cc9faaa3 Add engaging loading state for Tech Readiness panel
- Multi-step progress showing 4 World Bank indicators being fetched
- Animated globe with spinning ring
- Staggered pulse animation on each indicator row
- Shows "Analyzing 200+ countries" context
2026-01-26 22:13:08 +04:00
Elie Habib 9b6415cb7e Fix zoom button click events in DeckGL map
- Use event delegation instead of individual listeners
- Add explicit pointer-events and z-index to buttons
2026-01-26 22:10:00 +04:00
Elie Habib 5e325fa478 Remove AI provider badge from World Brief 2026-01-26 22:05:25 +04:00
Elie Habib 26158f7cbb Fix monitor keyword matching: use word boundaries
"ai" was matching "train", "remains", "afraid" as substrings.
Now uses regex word boundaries so "ai" only matches standalone "AI".
2026-01-26 21:59:26 +04:00
Elie Habib ae23af799b Improve monitors: search descriptions, show match count
- Search both title and description for better keyword coverage
- Deduplicate results (article matching multiple monitors shown once)
- Show match count: "3 matches" or "Showing 10 of 25 matches"
- Show search scope when no matches: "No matches in X articles"
2026-01-26 21:58:46 +04:00
Elie Habib f181f7bfc0 Hide status banner when all is well
Only show banner during learning mode - no need to announce normalcy.
2026-01-26 21:54:38 +04:00
Elie Habib 50ea697a98 Remove cache status from Strategic Risk panel UI
Implementation details like "Using cached scores" shouldn't be shown to users.
2026-01-26 19:43:44 +04:00
Elie Habib a5bacd9c53 Add tooltip hint for clickable theaters 2026-01-26 17:42:45 +04:00
Elie Habib 5a9d72488e Fix panel order migration and emoji display
- Add v1.9 migration with strategic-posture in priority panels
- Add emoji fallback when no typed aircraft/vessel breakdown exists
- Use proper emoji variation selectors for airplane (✈️)
2026-01-26 17:41:28 +04:00
Elie Habib 04f3564788 Modernize panel UI and expand README documentation
UI Improvements:
- CII Panel: Replace dated bar chart emoji with modern radar scan animation
- Strategic Posture: Compact chip-based layout with air+naval on same row
- Add rotating scan ring, pulsing dot, and source chip indicators
- Inline stat display for expanded theaters (AIR/SEA domain rows)

README Documentation:
- Add Strategic Posture Analysis section with 9 theater definitions
- Document strike capability assessment thresholds
- Add naval vessel integration and classification details
- Document server-side risk score API with Redis caching
- Add CII contextual boost documentation (hotspot, news, focal)
- Update completed features list
- Add project structure entries for new components
2026-01-26 17:34:47 +04:00
Elie Habib 3e3c6713a0 Fix stale comment about boost values 2026-01-26 17:22:55 +04:00
Elie Habib 23265229bf Reduce CII boost values to prevent score inflation
- hotspotBoost: 30 → 10 max
- newsUrgencyBoost: 15 → 5 max
- focalBoost: 20 → 8 max
- Total max boost: 65 → 23 points

Prevents multiple countries from hitting 100 simultaneously
2026-01-26 15:32:59 +04:00
Elie Habib 8cd9e4f149 Fix weighted average calculation in strategic risk score
Bug: divisor returned last weight (0.40) instead of sum (3.5)
Fix: compute weights array, sum explicitly, divide correctly
2026-01-26 15:05:49 +04:00
Elie Habib d80e7a5db9 Fix ACLED API: update endpoint URL and add auth + baseline fallback
- ACLED changed API endpoint from api.acleddata.com to acleddata.com/api/
- Add Bearer token authentication via ACLED_ACCESS_TOKEN env var
- Add baseline scores fallback when ACLED is unavailable (no more 500s)
- Better error logging for auth failures
2026-01-26 14:38:28 +04:00
Elie Habib 3b71824869 Fix vessel caching bug + improve Strategic Posture loading UX
Root cause: Circuit breaker's 5-min cache AND vesselCache were both
caching empty results, blocking fresh data from showing for 5 minutes.

Fixes:
- Add clearCache() method to circuit breaker
- Don't cache empty vessel results in vesselCache
- Clear both caches when stream initializes
- Clear both caches when first vessel arrives
- Add re-augmentation at 90s and 120s (in addition to 30s/60s)

UX improvements:
- Add live elapsed time counter during loading
- Add "30-60 seconds" guidance note
- Stop timer on render/error/destroy (no memory leaks)

Also fixes focal point headlines showing irrelevant content by
only including headlines where entity name appears in title.
2026-01-26 14:24:28 +04:00
Elie Habib 9ed33b99ef Add stale cache fallback for risk-scores API when ACLED fails 2026-01-26 13:43:43 +04:00
Elie Habib 43d59d26cc Fix vessel timing: re-augment vessels at 30s and 60s after load 2026-01-26 13:41:34 +04:00
Elie Habib 158925ef41 Rename Strategic Posture to AI Strategic Posture, move after AI Insights 2026-01-26 13:39:37 +04:00
Elie Habib f56b2dcf52 Make focal point headlines clickable - links to source article 2026-01-26 13:38:39 +04:00
Elie Habib 93a432a5c9 Fix vessel tracking: include unknown types in naval count, add debug logging 2026-01-26 13:33:34 +04:00
Elie Habib e9e838b455 Fix bugs found in code review: missing isStale, variable shadowing, stale timestamp 2026-01-26 13:23:03 +04:00
Elie Habib 1b4d9b9157 Fix wingbits API: add explicit routes for nested paths (Vercel catch-all limitation) 2026-01-26 13:18:51 +04:00
Elie Habib 9467f79b15 Use Railway relay for OpenSky to avoid rate limits 2026-01-26 13:14:58 +04:00
Elie Habib 37d60d20c7 Clarify git branch rule: merging requires permission, pushing to current branch is OK 2026-01-26 13:12:55 +04:00
Elie Habib bcd774c888 Improve theater posture resilience and UX
- Add stale cache fallback when OpenSky is rate-limited (1h TTL)
- Return cached data instead of 500 error when API fails
- Improve empty/error state messages with context
- Add retry buttons to no-data states
- Show stale data warning when using cached fallback
2026-01-26 13:09:24 +04:00
Elie Habib 241a6dae85 Add critical git branch rule: never push to different branch without permission 2026-01-26 13:06:53 +04:00
Elie Habib 76af54caa3 Add Yemen/Red Sea theater (Houthi/Bab el-Mandeb) 2026-01-26 13:01:08 +04:00
Elie Habib 7ccbbf2f19 Add Israel/Gaza theater, adjust E.MED bounds 2026-01-26 12:58:21 +04:00
Elie Habib e83ea1a508 Add 3 more strategic theaters for better global coverage
- Korean Peninsula (covers Korean DMZ area)
- South China Sea (disputed waters)
- Eastern Mediterranean (Israel/Syria/Lebanon)

Bump cache key to v2 to invalidate old 4-theater cache.
2026-01-26 12:56:19 +04:00
Elie Habib 8565483826 Remove dead code: REAPER already handled by drone pattern 2026-01-26 12:53:11 +04:00
Elie Habib 7e9251a396 Fix critical bugs in theater posture feature
- Add missing byOperator to API response (was causing crash)
- Deep clone postures to prevent cached data mutation
- Add drone detection patterns (RQ/MQ/REAPER/PREDATOR)
- Separate drone from reconnaissance in callsign detection
2026-01-26 12:41:19 +04:00
Elie Habib 98aa4a1262 Fix posture panel: add missing vessel type and UI rows
- Add 'auxiliary' to vessel type filter (was missing supply ships)
- Add auxiliary vessels row to NAVAL section UI
- Add drones row to AIR section UI
2026-01-26 12:31:56 +04:00
Elie Habib 4e0e88d223 Unify theater posture: combine aircraft + naval vessels
- Add vessel fields to TheaterPostureSummary type (destroyers, frigates,
  carriers, submarines, patrol, auxiliaryVessels, totalVessels)
- Update API to return theater bounds for client-side vessel matching
- Panel fetches vessels client-side (AIS WebSocket) and merges with
  server-side aircraft data (OpenSky API)
- Display shows AIR and NAVAL sections when vessels detected
- Add posture-section-label CSS for section headers
2026-01-26 12:20:44 +04:00
Elie Habib 40cc5d7c7d Add bash guidelines to avoid output buffering issues
Document best practices for avoiding pipe buffering problems
with head/tail/less commands in bash operations.
2026-01-26 11:30:18 +04:00
Elie Habib d0eabdf3c8 Add timeout to OpenSky fetch in theater-posture API 2026-01-26 11:08:58 +04:00
Elie Habib 4247ba70b8 Add server-side caching for theater postures
- Create /api/theater-posture.js endpoint that fetches military flights
  from OpenSky, calculates theater postures, and caches in Upstash Redis
- Add cached-theater-posture.ts client service with deduplication
- Update StrategicPosturePanel to fetch from cached endpoint independently
- Update App.ts to use cached postures for critical alert banner

Benefits: Theater posture calculation shared across all users via Redis
cache (5-min TTL), panel works without military layer enabled
2026-01-26 09:55:36 +04:00
Elie Habib 69662cf28c Add Strategic Posture feature: theater-based military buildup detection
- Add theater posture aggregation to military-surge.ts (Iran, Taiwan, Baltic, Black Sea)
- Add critical alert banner for elevated/critical military buildups
- Create StrategicPosturePanel component showing aircraft breakdown by type
- Integrate theater context into AI Insights prompts
- Enable military layer by default
- Add strike capability detection (tankers + AWACS + fighters threshold)

Theaters detect buildup thresholds: 50 aircraft (elevated), 100 (critical)
Strike capable when tankers >= 10, AWACS >= 2, fighters >= 30
2026-01-26 09:07:49 +04:00
Elie Habib 01ce2ed2a4 Remove debug text from ML DETECTED section title 2026-01-25 23:55:50 +04:00
Elie Habib 4f1c0cf5a2 Change WORLD BRIEF to TECH BRIEF for tech variant
Make InsightsPanel header variant-aware:
- Tech variant: 🚀 TECH BRIEF
- Full variant: 🌍 WORLD BRIEF (unchanged)
2026-01-25 23:50:06 +04:00
Elie Habib 3740359732 Fix panel summary cache: add variant + version to bust stale caches 2026-01-25 23:42:28 +04:00
Elie Habib b6d333c451 Tech variant: use tech-focused AI prompts, ignore politics
- Remove Trump mention from date context for tech variant
- Add variant-aware system prompts:
  - Tech: "Focus ONLY on technology, startups, AI, funding..."
  - Tech: "IGNORE political news, trade policy, tariffs..."
- Use tech-appropriate examples: "OpenAI announced...", "Series B..."
- Bump cache version to v3 to force new summaries
2026-01-25 23:39:38 +04:00
Elie Habib 722bbd5a94 Fix Disrupt Africa feed (redirects to rate-limited URL) 2026-01-25 23:34:32 +04:00
Elie Habib 03a778aa79 Bust stale summary cache with version prefix (v2) 2026-01-25 23:33:03 +04:00
Elie Habib 2870b22bd2 Tech variant: skip Intel sources (defense/military news)
INTEL_SOURCES (Defense One, Breaking Defense, The War Zone, Janes,
Bellingcat) was being fetched for all variants and added to allNews,
which fed geopolitical content to AI insights in tech variant.

Now gated by SITE_VARIANT === 'full'.
2026-01-25 23:23:09 +04:00
Elie Habib 9123a6fbc6 Tech variant: gate geopolitical panels + add TechReadinessPanel
- Gate gdelt-intel, cii, cascade, strategic-risk panels to full variant only
- Wire up TechReadinessPanel (was implemented but never connected):
  - Add to TECH_PANELS config with priority 1
  - Export from components index
  - Instantiate in App.ts
- Reduces console noise and unnecessary data fetching in tech variant
2026-01-25 23:20:30 +04:00
Elie Habib 5541592874 Skip geopolitical intelligence fetching for tech variant
Tech variant was fetching military flights, vessels, protests, and
outages even though it has no CII/focal point panels. This wasted
bandwidth and polluted console logs.

Changes:
- Gate loadIntelligenceSignals() to only run for SITE_VARIANT='full'
- Gate scheduled intelligence refresh to only run for full variant
- This stops: military flights, military vessels, protests (ACLED/GDELT),
  outages, and AIS shipping data fetching for tech variant

Tech variant now only fetches data relevant to tech/startup news.
2026-01-25 23:13:25 +04:00
Elie Habib 737753f949 Fix tech variant AI insights: skip geopolitical data, move to top
Issues fixed:
1. Tech variant insights panel was using geopolitical signal data
   (military flights, protests, AIS) which polluted AI summaries
2. Insights panel was at priority 2 (bottom) for tech variant

Changes:
- panels.ts: Move insights to priority 1 (top) in TECH_PANELS
- InsightsPanel.ts: Skip signalAggregator and focalPointDetector
  for tech variant - only summarize tech news without geo context
- App.ts: Add one-time migration to move insights to top for
  existing tech variant users

Now tech variant AI insights only analyzes tech/startup news without
geopolitical military/protest/outage correlations.
2026-01-25 23:08:38 +04:00
Elie Habib ecf3829ee0 Fix AI summary cache collision between site variants
The Redis cache key for summaries was built from headlines + mode +
geoContext but did NOT include the site variant (full vs tech). This
caused cross-site cache collisions where a summary generated for the
full variant could be returned to tech variant users (and vice versa).

Changes:
- Pass SITE_VARIANT from frontend to summarization API
- Include variant in cache key: `summary:{variant}:{hash}{geoHash}`
- Updated both groq-summarize and openrouter-summarize endpoints

Now cache keys are scoped per variant, preventing incorrect summaries.
2026-01-25 23:03:22 +04:00
Elie Habib 1bcb098b01 Add curated events fallback for major tech conferences
The dev.events RSS feed is limited to 100 items sorted by "date added"
(not event date), causing major events like STEP Dubai to be pushed out
when newer events are added. Added a curated events list as fallback
for important conferences that may fall off the RSS feed:

- STEP Dubai 2026 (Feb 11-12) - 8,000+ attendees, AI economy focus
- GITEX Global 2026 (Dec 7-11) - World's largest tech show
- TOKEN2049 Dubai 2026 (Apr 29-30)
- Collision 2026 (Jun 22-25) - Toronto
- Web Summit 2026 (Nov 2-5) - Lisbon

Curated events are deduplicated with feed data to avoid duplicates.
2026-01-25 22:59:22 +04:00
Elie Habib db0c4a6019 Restructure README: geopolitical variant before tech variant 2026-01-25 22:42:53 +04:00
Elie Habib 8f218428f1 Update branding: World Monitor v2 with AI focus
- README: Title to "World Monitor v2", AI-powered description
- index.html: Title "Global Situation with AI Insights"
- All meta tags updated (og, twitter, JSON-LD)
- Added AI keywords and features
- Updated site.webmanifest with AI branding
2026-01-25 22:38:15 +04:00
Elie Habib b223f59f5b Update README screenshot to new-world-monitor.png 2026-01-25 22:35:43 +04:00
Elie Habib 0c35c5344d Fix TypeScript errors: add TechHubActivity and GeoHubActivity to PopupData
Added missing types to the PopupData data union to support techActivity
and geoActivity popup types used in Map.ts.
2026-01-25 22:31:28 +04:00
Elie Habib bed2479de4 Merge branch 'claude/add-deckgl-visualization-D1VHX' into main
Adds DeckGL WebGL map rendering for desktop, comprehensive README
documentation, and intelligence synthesis features.
2026-01-25 22:29:25 +04:00
Elie Habib 0375796ee4 Fix Military Surge Detection documentation to match actual implementation
The original documentation incorrectly described surge detection as
operator-count based. The actual algorithm uses:
- Baseline-based detection (2x historical activity = surge)
- Separate foreign presence detection for operators outside home regions
- Theater-based grouping with 48-hour baseline window
2026-01-25 22:27:05 +04:00
Elie Habib 3f037ba38a Expand README with comprehensive documentation for intelligence features
- Add AI Insights Panel section: summarization fallback chain, headline scoring, sentiment analysis
- Add Focal Point Detector section: intelligence synthesis, scoring algorithm, urgency classification
- Add Natural Disaster Tracking section: GDACS integration, EONET merge, deduplication
- Add Military Surge Detection section: surge criteria, severity levels, news correlation
- Add Service Status Monitoring section: external service health tracking
- Add Signal Aggregator section: central signal collection, country grouping, convergence
- Add Browser-Based ML section: ONNX Runtime, fallback strategy, lazy loading
- Update Tech Stack: add deck.gl + MapLibre GL for WebGL map rendering
- Update project structure: add new services (focal-point-detector, signal-aggregator, etc.)
- Update components: add DeckGLMap, MapContainer, InsightsPanel, ServiceStatusPanel
- Update Roadmap: add recently completed features
- Add Entity Registry Architecture section: lookup indexes, entity types
2026-01-25 22:08:40 +04:00
Elie Habib 4d9972e031 Fix economic/FRED data freshness tracking in System Health panel 2026-01-25 21:49:04 +04:00
Elie Habib bd3f08614c Fix shipping/AIS data freshness tracking in System Health panel 2026-01-25 21:41:09 +04:00
Elie Habib 544c5869a5 Integrate military surge alerts with focal point correlation
- Add getFocalPointForCountry() and getNewsCorrelationContext() to focal-point-detector
- Enhance foreignPresenceToSignal to include news correlation from focal points
- Map operator countries and affected regions to ISO codes for focal point lookup
- Update SignalModal to display correlated focal points and news headlines
- Add military_surge type label and styling for the signal modal
- Add CSS for focal point and news correlation sections in intelligence findings
2026-01-25 21:38:29 +04:00
Elie Habib 6658945645 Fix map controls z-index to prevent them being covered
Increased z-index from 100 to 500 for map controls to ensure they
always appear above other map elements like overlays and canvases.
2026-01-25 20:52:04 +04:00
Elie Habib 0f495d9c7a Allow map to expand nearly full-screen (viewport - 60px) 2026-01-25 20:42:56 +04:00
Elie Habib 8db227658b Fix map popup header cutoff with sticky positioning
- Make popup header sticky so it stays visible when scrolling
- Add solid backdrop via ::before to prevent content showing through
2026-01-25 20:39:39 +04:00
Elie Habib 7d877b114c Decouple CII intelligence data from map layer visibility
- Add loadIntelligenceSignals() that ALWAYS fetches protests, military,
  outages regardless of layer visibility
- Add intelligenceCache to prevent duplicate API calls when layers enabled
- Remove redundant layer-gated initial load tasks (handled by intelligence)
- Remove redundant refresh schedules for intelligence layers
- Add surge/foreign presence detection to loadIntelligenceSignals()
- Fix missing ACLED warning status in cached loadProtests() path

This ensures CII scores are accurate even when map layers are disabled.
Previously, Iran showed S:0 (no signals) because military layer was off.
2026-01-25 20:27:30 +04:00
Elie Habib 5090c19ecb Fix map resize: increase max-height from 70vh to 90vh 2026-01-25 20:10:34 +04:00
Elie Habib 63b4d2d6bd Fix port layer projection on initial load
Wait for MapLibre 'load' event before initializing deck.gl overlay.
Previously, deck.gl layers were created before MapLibre finished
initializing its view state, causing incorrect coordinate projections
on first render. After any view change, the map would correct itself.
2026-01-25 20:08:12 +04:00
Elie Habib c81305547a Add region selector to header + render throttling for DeckGLMap
Header:
- Add Global/Americas/MENA/EU/Asia/LatAm/Africa/Oceania dropdown
- Sync with map view state bidirectionally
- Hide duplicate selector in map controls

DeckGLMap render throttling:
- Convert all updateLayers() calls to render() for debouncing
- render() uses requestAnimationFrame to batch updates
- Reduces CPU usage when multiple data updates arrive quickly

APT markers: Already present as subtle orange dots (geopolitical variant)
2026-01-25 20:03:46 +04:00
Elie Habib f278b8a0ea Align DeckGLMap with old Map.ts: clusters, ports, AIS density
- Add missing military vessel/flight cluster layers
- Add cluster tooltips and click type mappings
- Fix activity type mismatches (deployment/transport vs patrol/surveillance)
- Add congestion coloring to AIS density (orange for deltaPct >= 15)
- Add port type-based colors and tooltip icons
- Fix getHotspotLevels/setHotspotLevels to use h.name
- Fix setLayerReady to use 'active' class with layer state check
2026-01-25 20:03:46 +04:00
Elie Habib 48d19e7ccf Add GDACS global disaster alerts integration
- Create gdacs.ts service fetching from GDACS API (earthquakes, floods,
  tropical cyclones, volcanoes, wildfires, droughts)
- Filter to Orange/Red alerts only for relevance
- Merge GDACS events with EONET natural events with deduplication
- Color-code markers: red for Red alerts, orange for Orange alerts
- Prioritize GDACS data (more authoritative) over EONET for overlaps
2026-01-25 20:03:46 +04:00
Elie Habib b2505d7d4c Make APT markers smaller and more subtle (orange, no outline) 2026-01-25 19:01:14 +04:00
Elie Habib 497c4abcfb Fix weather layer: use centroid property instead of non-existent lat/lon 2026-01-25 19:00:10 +04:00
Elie Habib 9f6dcd8cbc Fix map canvas corruption on zoom: add ResizeObserver and zoomend resize trigger 2026-01-25 18:57:01 +04:00
Elie Habib 71ca68c523 Fix popup positioning: measure actual height instead of hardcoded 500px 2026-01-25 18:54:32 +04:00
Elie Habib bfb0395724 Add focal point correlation info to AI Insights tooltip 2026-01-25 18:43:55 +04:00
Elie Habib 7a1ef22f4c Improve CII tooltip: explain U:S:I components and focal point detection 2026-01-25 18:42:00 +04:00
Elie Habib 9ba4bb8ab2 Fix AlArabiya feed: use Google News fallback (Cloudflare blocks cloud IPs) 2026-01-25 18:37:58 +04:00
Elie Habib d732a7c80f Fix popup truncation at top of viewport 2026-01-25 18:35:46 +04:00
Elie Habib fdae1a4345 Add critical missing features from old Map.ts to DeckGLMap
Feature parity improvements:
- Add layer help popup with ? button (explains all layer types)
- Add LAYER_ZOOM_THRESHOLDS constant for zoom-based label visibility
- Add toggleLayer() public method to toggle layers on/off
- Make zoomIn()/zoomOut() public methods for external access
- Add military cluster data storage (militaryFlightClusters, militaryVesselClusters)
- Add getMilitaryFlightClusters() and getMilitaryVesselClusters() getters
- Store clusters in setMilitaryFlights/setMilitaryVessels methods
2026-01-25 18:33:24 +04:00
Elie Habib 36ad870fee Restore hotspot popup and pulsating animation in DeckGLMap
- Add news storage for related news lookup on hotspot click
- Add getRelatedNews() method matching old Map.ts behavior
- Fix click handler to show popup with related news + GDELT intel
- Add HTML overlays for high-activity hotspots with CSS pulsating animation
- Filter high-activity hotspots from deck.gl layer to avoid double-render
- Add escapeHtml for safety in HTML templates
- Add debug logging for panel persistence investigation
2026-01-25 18:25:50 +04:00
Elie Habib 2a2bfb8335 Fix CII showing U:0 S:0 I:0 - ingest news before focal-points-ready 2026-01-25 18:05:18 +04:00
Elie Habib 4eff2889da Bump version to 2.0.0 2026-01-25 17:58:43 +04:00
Elie Habib af7dff34ed Trigger deployment 2026-01-25 17:56:47 +04:00
Elie Habib 2dcac9a1fe CII: Wait for focal points before showing scores
Problem: CII showed cached backend scores immediately, then updated
when focal points ready - showing misleading preliminary data.

Fix:
- Show "Analyzing intelligence..." state until focal points ready
- Only calculate and render after focal-points-ready event
- Remove unused cached scores logic (simpler, more accurate)
2026-01-25 17:39:54 +04:00
Elie Habib 5bbdbaea1f Fix CII: always use local scores when forceLocal (focal points) 2026-01-25 17:37:55 +04:00
Elie Habib 53e3da6c0b Fix CII not using focal point data during learning mode
Problem: Once cached scores loaded, usedCachedScores=true forever,
blocking recalculation even when focal points became ready.

Fix: Add forceLocal parameter to refresh() - when focal-points-ready
fires, it now calls refresh(true) to force local recalculation with
focal point urgency boosts applied.
2026-01-25 17:36:34 +04:00
Elie Habib 19e7d9105d Fix LLM knowledge cutoff: add current date context to prompts
LLMs have outdated knowledge (pre-Jan 2025), causing errors like
"former President Trump" when Trump is current president.

Added dateContext to all summarization prompts with:
- Current date
- Trump is current president (second term, Jan 2025)
2026-01-25 17:34:30 +04:00
Elie Habib 3dbf60466b Fix circular dependency: move TIER1_COUNTRIES to config
Moved TIER1_COUNTRIES to src/config/countries.ts to break the cycle:
  country-instability → focal-point-detector → signal-aggregator → country-instability

Now:
  - config/countries.ts (no dependencies)
  - signal-aggregator imports from config/countries
  - country-instability re-exports for backwards compatibility
2026-01-25 17:28:20 +04:00
Elie Habib da16a41053 Fix: add focalBoost to getCountryScore() for consistency
getCountryScore() was missing the focalBoost that calculateCII() has.
This inconsistency would cause single-country lookups to return
different scores than the batch calculation.
2026-01-25 17:22:51 +04:00
Elie Habib f66a6694c1 Fix CII timing: refresh after focal points are ready
Problem: CII was calculating before FocalPointDetector.analyze() ran,
so the urgency map was empty and focal boosts weren't applied.

Fix: InsightsPanel now dispatches 'focal-points-ready' event after
analysis completes. App.ts listens and triggers CII refresh.

This ensures Iran shows elevated/high when FocalPointDetector
marks it as critical, rather than staying at 38 (green).
2026-01-25 17:20:07 +04:00
Elie Habib 13c0e9f771 Fix repetitive 'Breaking news tonight' in all summaries
Problem: Every panel summary started with 'Breaking news tonight:' because
the prompt said 'write like a news anchor opening the evening news'.

Fix: Rewrote prompts to:
- Explicitly forbid 'Breaking news', 'Good evening', 'Tonight' openings
- Remove TV news anchor framing entirely
- Instruct to start directly with subject: 'Iran's regime...', 'The US Treasury...'
- Focus on substance over style

Updated both groq-summarize.js and openrouter-summarize.js
2026-01-25 17:12:23 +04:00
Elie Habib 2ba3b9a135 Integrate FocalPointDetector intelligence into CII scoring
CII now uses focal point urgency (critical/elevated/watch) to boost scores:
- critical focal point: +20 points
- elevated focal point: +10 points

This ensures countries like Iran show 'elevated' or 'high' when:
1. AI insights detect breaking news (I:80)
2. FocalPointDetector marks them as critical (news + signal correlation)

The CII now reflects the same intelligence the AI summarizer uses,
eliminating the disconnect where AI says 'armada, bloodshed' but
CII shows green/normal.
2026-01-25 17:03:03 +04:00
Elie Habib 7d2a60cbc4 Fix CII: add news urgency boost when Information score is high
Problem: CII showed Iran as 'normal' (green, 38) despite AI insights
showing breaking news about 'US armada to Iran, deadly crackdown'.
This happened because S:0 (no military detections) diluted the I:80
(high news velocity).

Fix: Add newsUrgencyBoost that elevates scores when Information is high:
- I >= 70: +15 points (breaking news)
- I >= 50: +10 points (significant news)
- I >= 30: +5 points (moderate news)

Example with fix: Iran with I:80 now scores ~53 (elevated/orange)
instead of 38 (normal/green)
2026-01-25 16:51:45 +04:00
Elie Habib 637bba7273 Reorder panels: live-news, insights, cii, strategic-risk first
- New users get this default order automatically
- Existing users get one-time migration to new layout
- Migration key: worldmonitor-panel-order-v1.8
2026-01-25 16:45:45 +04:00
Elie Habib 49e766b21d Fix RSS proxy corrupting gzip-compressed upstream responses
UN News and other servers return gzip-compressed RSS. The proxy was
concatenating binary chunks as strings, corrupting non-UTF8 bytes.

Fix: Use Buffer.concat() and decompress gzip/deflate responses.
2026-01-25 16:38:47 +04:00
Elie Habib 55fd6cfda3 Add FocalPointDetector intelligence synthesis layer
Correlates news entities with map signals to identify "main characters"
appearing across multiple intelligence streams:

- Extract entities from all 80+ news sources via NER
- Cross-reference with map signals (flights, vessels, outages, protests)
- Score focal points where news + signals converge
- Generate rich AI context for correlation-aware summarization

New files:
- focal-point-detector.ts: Core detection and scoring service
- signal-aggregator.ts: Geographic signal aggregation

UI shows top focal points with urgency badges and signal icons.
AI prompts updated to leverage intelligence synthesis context.
2026-01-25 16:28:29 +04:00
Elie Habib 8348a6782b Fix repetitive summary openings
- Rewrite prompts to vary output (no more "The dominant narrative...")
- Instruct model to lead with substance: location, action, impact
- Add explicit rule: NEVER start with meta-commentary
2026-01-25 15:52:03 +04:00
Elie Habib 6e170d9c6c Add parallel ML analysis system with multi-perspective scoring
- New parallel-analysis.ts: runs browser ML alongside API summarization
- Uses 6 perspectives: keywords, sentiment, entities (NER), novelty, velocity, sources
- Detects disagreement between perspectives (flags potential missed stories)
- Console dashboard logs analysis comparison for debugging
- NER model (previously unused) now extracts entities for geopolitical scoring
- Shows "ML DETECTED" section for stories keywords missed but ML flagged
- Fix CII 15-min learning mode: bypasses when cached scores available
2026-01-25 15:51:06 +04:00
Elie Habib 1a60bdeee6 Add resizable panels, fix Railway crash, improve AI Insights
- Panel resize: drag bottom edge to span 1-4 grid rows, persisted to localStorage
- Fix HTML5 drag conflict with resize using capture-phase listeners
- Fix memory leaks in Panel.ts (document listeners now cleaned up)
- Fix Railway ERR_HTTP_HEADERS_SENT crash with response flag pattern
- Improve AI summarization prompts to focus on ONE dominant narrative
- Add VIOLENCE_KEYWORDS and UNREST_KEYWORDS for better story discovery
- Add combo bonus for flashpoint + unrest stories (e.g., Iran protests)
- Relax 2-source filter for high-scoring critical stories (score > 100)
2026-01-25 15:39:53 +04:00
Elie Habib 0c2a272bf5 Add backend caching for CII and Strategic Risk scores
- New /api/risk-scores endpoint computes and caches scores in Redis
- Uses ACLED protest data + baseline geopolitical risk factors
- 10-minute cache TTL shared across all users
- CIIPanel and StrategicRiskPanel fetch cached scores on load
- Learning Mode banner hidden when cached scores available
- Eliminates 15-minute warmup for users
2026-01-25 14:39:19 +04:00
Elie Habib 53a8a0a851 Add datacenter clustering at low zoom levels
- Cluster datacenters at zoom < 5, show IconLayer at higher zoom
- Add renderDatacenterClusters() with HTML overlays
- Add datacenterCluster popup type showing total chips/power stats
- Add CSS styles for datacenter markers matching protest/techHQ pattern
- Show cluster badge with count, expand to individual on click
2026-01-25 14:28:14 +04:00
Elie Habib 86590cc8b2 Fix AI summarization quality and InsightsPanel prioritization
- Add headline deduplication (60% word similarity threshold)
- Prevent prompt instructions from leaking into output
- Increase max_tokens from 80 to 150 to prevent truncation
- Add multi-tier keyword scoring: military (+80), flashpoint (+60), crisis (+30)
- Demote business/tech news (0.3 penalty) when mixed with conflict keywords
2026-01-25 14:22:16 +04:00
Elie Habib 9ebafb82b1 Remove redundant Country Labels feature
Base map tiles already display country names, so the custom
Country Labels layer was duplicating functionality.

Removed:
- COUNTRY_LABELS array (~80 countries) from geo.ts
- Toggle from layer controls in both Map.ts and DeckGLMap.ts
- renderCountryLabels() and createCountryLabelsLayer() methods
- 'countries' property from MapLayers type
- All countries config entries from panels and variants
- Related CSS styles
2026-01-25 14:16:34 +04:00
Elie Habib 2294f32daa Fix panel summary blocking scroll on news panels
Add max-height, overflow-y auto, and flex-shrink to .panel-summary
to prevent long summaries from pushing content out of view.
2026-01-25 14:05:51 +04:00
Elie Habib 3280a9e616 Improve AI Insights prioritization and shorten summaries
- Add keyword boosting for critical geopolitical terms (war, armada,
  military, iran, russia, ukraine, etc.) - +40 base + 10 per keyword
- Shorten summaries: max 2 sentences, 40 words, max_tokens=80
- Critical terms now outrank generic "alert" stories
2026-01-25 14:00:14 +04:00
Elie Habib 158e285a39 Update Al Arabiya and Arab News RSS feed URLs 2026-01-25 13:56:27 +04:00
Elie Habib 7e1dfcb160 Improve search results and fix RSS feeds
- Double MAX_RESULTS from 12 to 24
- Prioritize news over static infrastructure in search
- Update News24 URL to post-redirect destination (feeds.capi24.com)
- Add trailing slash to SCMP URL
- Add feeds.capi24.com to both proxy allowlists
2026-01-25 13:51:54 +04:00
Elie Habib d62923ee37 Add redirect following (301/302) to Railway RSS proxy 2026-01-25 13:50:00 +04:00
Elie Habib 6f65bcece7 Improve summarization prompts to avoid repetitive opening phrases 2026-01-25 13:47:34 +04:00
Elie Habib efc8e9886b Add debug logging for search news indexing 2026-01-25 13:45:58 +04:00
Elie Habib becf8d381e Fix panel summarize button: use inherited header, improve visibility 2026-01-25 13:30:31 +04:00
Elie Habib 01f5af89b8 Add missing domains to Railway RSS proxy allowlist
Railway's RSS proxy had only 5 domains in allowlist, causing 403 errors.
Added all domains that use railwayRss() routing in feeds.ts:
- Al Arabiya, Arab News, Times of Israel, SCMP
- UN News, CISA, News24
- Plus IAEA, WHO, Crisis Group, Kyiv Independent, Moscow Times for future use
2026-01-25 13:27:19 +04:00
Elie Habib b92c501582 Add 13 missing domains to RSS proxy allowlist
Fixes 403 errors for:
- Middle East: Al Arabiya, Arab News, Times of Israel, SCMP
- Regional: Kyiv Independent, Moscow Times, News24
- Int'l Orgs: UN News, IAEA, WHO, CISA, Crisis Group
- Other: Hacker News (news.ycombinator.com)
2026-01-25 13:13:30 +04:00
Elie Habib 37073b15da Add click-to-summarize button on news panel headers
- Sparkle button () in panel header triggers AI summary
- Summary cached in localStorage for 10 minutes
- Shows loading spinner while generating
- Dismissable summary banner below header
- Uses existing summarization fallback chain (Groq → OpenRouter → Browser T5)
2026-01-25 13:10:01 +04:00
Elie Habib b1b126cd04 Increase news search index from 200 to 500 items 2026-01-25 13:03:22 +04:00
Elie Habib f40e61c528 Add better error handling for Groq Redis initialization 2026-01-25 12:53:57 +04:00
Elie Habib 1245cac966 Fix bugs found in code review
- CORS regex: now matches root domain (worldmonitor.app) not just subdomains
- Alert keywords: re-add important terms (military, drone strike, terror/cyber attack, evacuation order)
2026-01-25 12:40:10 +04:00
Elie Habib 25cfb06bad Remove per-headline summarization (sparkle button)
World Brief in AI Insights already provides panel-level summarization.
Also reduced ML batch size and timeout to prevent embedding timeouts.
2026-01-25 12:38:17 +04:00
Elie Habib ded9663156 Fix false alert detection: remove generic keywords, add lifestyle exclusions
- Remove 'breaking', 'emergency' from alert keywords (too generic)
- Add ALERT_EXCLUSIONS for lifestyle/entertainment content
- Remove confusing 'Major' tier label (was showing for T2 sources)
- Tier badge now just shows dot for T2, 'Wire' for T1
2026-01-25 12:35:23 +04:00
Elie Habib 3e5e6f3dbf Add CORS support for Vercel preview domains in RSS proxy 2026-01-25 12:32:27 +04:00
Elie Habib e9cccf875b Trigger redeploy with preview env vars 2026-01-25 12:22:23 +04:00
Elie Habib fcdf94de62 Add server-side Redis caching for AI summaries + improve story ranking
- Add Upstash Redis caching to groq-summarize.js and openrouter-summarize.js
- Switch to llama-3.1-8b-instant (14.4K/day vs 1K for 70b)
- Cross-user cache deduplication with 24h TTL
- Remove client-side cache (server handles all caching now)

Improve AI Insights story selection:
- Composite importance score: sources × velocity × recency + alert bonus
- Source diversity cap: max 3 stories from same source
- Recency decay: newer stories rank higher (12h half-life)

Scalability: ~144x headroom (was hitting 1K/day limit)
2026-01-25 12:14:16 +04:00
Elie Habib a8e42c8944 Add Groq/OpenRouter fallback chain for AI summaries
- Add /api/groq-summarize.js - Llama 3.3 70B (1000 req/day free)
- Add /api/openrouter-summarize.js - Fallback (50 req/day free)
- Create summarization service with fallback: Groq -> OpenRouter -> Browser T5
- Browser T5 only loads when cloud APIs fail (lazy loading)
- Add detailed progress bar with Step X/Y indicator
- Show provider badge (groq/openrouter) on World Brief
- Cache summaries for 30 min to respect rate limits
- Cooldown increased to 2 min between brief generations

Required env vars for Vercel:
- GROQ_API_KEY (get from console.groq.com)
- OPENROUTER_API_KEY (get from openrouter.ai)
2026-01-25 11:38:52 +04:00
Elie Habib 0f407666da Add loading status messages to AI Insights panel
- Show "Initializing ML models..." when worker not ready
- Show "Analyzing sentiment..." during sentiment classification
- Show "Generating world brief..." during summarization
- Add animated spinner and pulsing text for visual feedback
2026-01-25 11:29:42 +04:00
Elie Habib 45baf5637f Fix ML Worker: re-enable worker import and bundle transformers
- Re-enable the ML worker import that was disabled
- Remove @xenova/transformers from rollup externals so it gets bundled
- Previous config had MLWorkerClass set to null, causing "not a constructor" error
2026-01-25 11:28:02 +04:00
Elie Habib 4415362658 Upgrade to Flan-T5-base and add World Brief summarization
- Switch summarization model from T5-small (45MB) to Flan-T5-base (250MB)
  for significantly better quality summaries
- Add "World Brief" section to InsightsPanel that synthesizes top stories
  into a coherent 2-sentence summary (vs useless per-headline summaries)
- Brief generation has 60s cooldown to avoid excessive model calls
- Add styled World Brief section with accent gradient
2026-01-25 11:22:17 +04:00
Claude 5e77433aca Add missing layers to DeckGLMap for feature parity with D3 Map
Major additions:
- AIS Disruptions layer (spoofing/jamming events)
- Cable Advisories layer (fault/maintenance markers)
- Repair Ships layer
- Country Labels layer
- Flight Delays toggle in layer panel
- Countries toggle in layer panel

Data storage fixes:
- setAisData now stores disruptions (was ignoring them)
- setCableActivity now stores advisories and repair ships

Also:
- Import COUNTRY_LABELS from config
- Add tooltips and click handlers for all new layers
- Temporarily disable ML worker (dependency unavailable)

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 07:21:33 +00:00
Elie Habib fe1ce20bf6 Redesign AI Insights panel for usefulness
- Remove broken T5 summaries (model too weak for abstractive summarization)
- Focus on multi-source confirmed stories (2+ sources)
- Show fast-moving and alert stories
- Add stats bar: multi-source count, fast-moving count, alerts
- Improve sentiment visualization with bar and overall tone
- Filter out single-source noise
2026-01-25 11:14:08 +04:00
Elie Habib 28240d4c94 Fix wingbits API routing for subpaths (/health, /details) 2026-01-25 11:08:50 +04:00
Elie Habib 1c5c4d841f Fix ML worker concurrent model loading and entity null checks
- Add loadingPromises map to prevent duplicate model loads
- Guard against undefined entity type/text in groupEntities
2026-01-25 11:01:00 +04:00
Claude 1b6d95f6f5 Add missing Irradiators and Spaceports toggles to layer panel
These layers existed in buildLayers() with mapLayers.X checks but had
no UI toggles, making them inaccessible. Added toggles for both in the
geopolitical variant layer panel.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:59:09 +00:00
Elie Habib 5c01ff41e6 Add client-side ML features with ONNX Runtime
- Add ML worker infrastructure (@xenova/transformers)
- Implement semantic news clustering (hybrid Jaccard + embeddings)
- Add InsightsPanel with themes, entities, sentiment analysis
- Add click-to-summarize feature in NewsPanel
- Wire up ML-enhanced velocity/sentiment scoring
- Desktop-only activation (mobile excluded)
- Fix division by zero in cosineSimilarity
- Fix dead Promise code in ml-worker.ts
2026-01-25 10:48:55 +04:00
Claude fc7476b26e Add APT Groups and Critical Minerals layers to DeckGLMap
- Import APT_GROUPS and CRITICAL_MINERALS from config
- Add createAPTGroupsLayer() with red markers and yellow outline for cyber threat actors
- Add createMineralsLayer() with color-coded markers by mineral type (Lithium, Cobalt, Rare Earths, Nickel)
- APT Groups always visible in geopolitical variant (no toggle)
- Add minerals toggle to layer panel for geopolitical variant
- Add tooltips and click handlers for both layers

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:43:13 +00:00
Claude 18a7d255bb Add missing layers: irradiators, spaceports, ports, flight delays
- Add gamma irradiators layer (IAEA DIIF facilities)
- Add spaceports layer (launch sites)
- Add strategic ports layer (61 ports, shown with AIS)
- Add flight delays layer (FAA airport delays/ground stops)
- Fix setFlightDelays to store data properly
- Add tooltips and click handlers for all new layers

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:36:44 +00:00
Claude e8a71be570 Add clustering support for Tech HQs, Tech Events, and Protests
- Add clusterMarkers method for grouping nearby markers
- Create HTML overlay container for cluster badges
- Render Tech HQs with emoji icons and cluster count badges
- Render Tech Events with calendar icons and clustering
- Render Protests with severity icons and clustering
- Clusters expand on click to show popup with all items
- Single items show individual popup on click
- Clustering radius adjusts based on zoom level

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:19:52 +00:00
Claude 55a121e5ae Remove unused MapView import
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:09:37 +00:00
Claude d039fcd055 Remove duplicate FOCUS dropdown and improve map legend
- Remove FOCUS region selector from header (keep only map's view selector)
- Redesign legend as compact horizontal bar at bottom center
- Add proper shapes: triangles for bases, squares for datacenters, hexagons for nuclear
- Reduce whitespace in legend layout

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 06:06:59 +00:00
Claude d60bf0579e Increase marker sizes for better visibility while keeping transparency
- Datacenters: size 10-14px, opacity ~55% (was 6-10px, ~30%)
- Bases: size 11-16px, opacity ~63%
- Nuclear: size 11-15px, opacity ~78%
- Better balance between visibility and layering

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:59:31 +00:00
Claude d11e60b1d7 Reduce datacenter and base marker opacity for better layering
- Datacenter squares: size 6-10px (was 10-14), opacity ~30% (was ~63%)
- Planned datacenters: opacity ~20% for subtle indication
- Military bases: size 8-14px (was 12-20), opacity ~63% (was 100%)
- Improves map readability when multiple layers are overlaid

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:46:52 +00:00
Claude 1a15262d05 Use IconLayer for distinct marker shapes
Replaced ScatterplotLayer circles with IconLayer for distinct shapes:
- Military bases: TRIANGULAR icons (color-coded by operator)
- Nuclear facilities: HEXAGONAL icons (yellow/orange)
- Datacenters: SQUARE icons (purple)
- Hotspots: Remain as circles (appropriate for threat indicators)

Each marker type now has a unique geometric shape for easy identification.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:24:19 +00:00
Claude 974e5c34be Improve visual distinction for map markers
- Nuclear: Yellow/orange hollow RING with inner dot (radiation symbol look)
- Datacenters: Purple filled circles with light border
- Bases: Color-coded by operator (US-NATO blue, Russia red, China orange, etc.)
- Removed non-working TextLayer emoji approach

Each marker type now has a distinct visual style beyond just color.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-25 05:20:34 +00:00
Claude ff56d705cd Add emoji icons to datacenter and nuclear markers
Restored visual distinction for markers that had icons in the
original D3/SVG implementation:
- Datacenters: 🖥️ icon
- Nuclear facilities: ☢️ icon

Uses TextLayer overlaid on ScatterplotLayer to render emoji icons.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 15:27:52 +00:00
Claude f1c4cd8950 Add @deck.gl/mapbox dependency
Required for MapboxOverlay integration with MapLibre.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 14:10:53 +00:00
Claude 744ae82333 Fix deck.gl/MapLibre sync using MapboxOverlay
Replaced separate Deck instance with MapboxOverlay which properly
integrates deck.gl into MapLibre's WebGL context. This fixes:
- Points moving when zooming/panning (view state sync)
- Coordinate offset issues (London appearing in France)
- Cursor issues (MapLibre now handles all interactions)

MapboxOverlay renders deck.gl layers directly into MapLibre's canvas,
eliminating all view state synchronization issues.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 14:10:25 +00:00
Claude 3af5a5ebf1 Fix deck.gl/MapLibre view state sync causing offset
The circular sync between deck.gl and MapLibre was causing the
deck.gl layer to be offset from the base map. Fixed by:
- Making deck.gl the single source of truth (has controller enabled)
- Removing MapLibre->deck.gl sync that caused circular updates
- Adding initial sync on map load to ensure alignment

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 14:04:30 +00:00
Claude affbf0cf21 Fix incorrect datacenter coordinates in US
The source dataset had placeholder coordinates clustering all US
datacenters around Kansas/Oklahoma. Fixed actual locations for:
- Mt Pleasant, Wisconsin (42.7°N, -87.9°W)
- Meta New Albany, Ohio (40.1°N, -82.8°W)
- OpenAI/Microsoft Atlanta (33.7°N, -84.4°W)
- Applied Digital Ellendale, ND (46.0°N, -98.5°W)
- Nebius New Jersey (40.1°N, -74.4°W)
- CoreWeave Denton, TX (33.2°N, -97.1°W)

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:59:58 +00:00
Claude 4ca11560af Remove auto-panning from map interactions
The map now stays fixed on the user's selected region when:
- Clicking items from lists (conflicts, bases, pipelines, etc.)
- Flash location animations
- New data flowing in

Popups now appear at the projected screen position of items
rather than panning the map to center on them. If an item is
off-screen, the popup still appears at its projected position.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:15:11 +00:00
Claude 968a402a53 Fix cursor style on deck.gl canvas
Add CSS to override deck.gl's default grab cursor on its canvas,
using the default pointer cursor and only grabbing when dragging.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:03:42 +00:00
Claude 0ec72613ba Enable deck.gl controller for mouse panning/zooming
The deck.gl canvas was blocking mouse events from reaching MapLibre
because it had pointer-events: auto for picking but controller: false.
Enable deck.gl's controller and sync view state changes back to MapLibre.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 13:00:53 +00:00
Claude d47c2ec5e4 Fix cursor style on deck.gl map to use default pointer
Override MapLibre GL's default grab cursor to use the standard
pointer cursor, only switching to grabbing when actively dragging.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:59:31 +00:00
Claude 728742a31e Fix dark overlay caused by timestamp element stretching
Root cause: The timestamp element had both classes 'map-timestamp' and
'deckgl-timestamp'. These classes set conflicting positioning:
- .map-timestamp: bottom: 8px; right: 10px;
- .deckgl-timestamp: top: 10px; left: 50%;

When all four positioning values (top/bottom/left/right) are set on an
absolutely positioned element without explicit width/height, it stretches
to fill the space between those edges - creating a 724x315px dark overlay.

Fixes:
- Remove 'map-timestamp' class from timestamp element in DeckGLMap.ts
- Add explicit bottom: auto; right: auto; width: auto; height: auto;
  to .deckgl-timestamp CSS to prevent any accidental stretching

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:56:02 +00:00
Claude 05ff0cd658 Attempt fix for dark overlay with z-index and CSS transparency
Changes:
- Add explicit z-index to MapLibre (1) and deck.gl overlay (2)
- Add overflow: hidden to wrapper
- Reverted to letting deck.gl create its own canvas
- Added effects: [] to disable any default deck.gl effects
- Set canvas background to transparent after deck initializes
- Added comprehensive CSS rules to ensure all canvas elements
  and containers have transparent backgrounds

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:54:07 +00:00
Claude 18e6306a31 Fix deck.gl canvas transparency by creating WebGL context with alpha
Instead of relying on deck.gl's default canvas/context creation,
manually create canvas and WebGL context with explicit alpha support:

- Create canvas element with transparent background style
- Get WebGL2/WebGL context with alpha: true, premultipliedAlpha: true
- Set gl.clearColor(0, 0, 0, 0) for transparent clear
- Pass pre-created canvas and gl context to Deck constructor

This ensures the WebGL context is properly configured for alpha
transparency from the start, allowing the MapLibre base map to
show through the deck.gl overlay.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:47:53 +00:00
Claude fb6aacef26 Fix dark overlay with WebGL transparency settings
Two-pronged approach to fix the dark semi-transparent overlay:

1. Add MapLibre background layer:
   - Added 'background' layer with dark color (#0a0f0c) to MapLibre style
   - This ensures no transparent gaps while tiles are loading

2. Set WebGL clear color to transparent:
   - Added onWebGLInitialized callback to Deck initialization
   - Explicitly set gl.clearColor(0, 0, 0, 0) for transparent background
   - Enable proper alpha blending with gl.blendFunc

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:39:52 +00:00
Claude c51dd569c5 Fix dark overlay issue in DeckGLMap
Root cause: CONFLICT_ZONES config had inconsistent coordinate formats.
Red Sea Crisis and South Lebanon entries were in [lat, lon] format
while other entries were in [lon, lat] format (GeoJSON standard).
This caused polygons to render in completely wrong locations.

Fixes:
- Fix Red Sea Crisis coords: [[12,42]...] → [[42,12]...] (swap to [lon,lat])
- Fix Red Sea Crisis center: [14,43] → [43,14]
- Fix South Lebanon coords: [[33.0,35.1]...] → [[35.1,33.0]...]
- Fix South Lebanon center: [33.2,35.4] → [35.4,33.2]
- Add explicit transparent background to #deckgl-overlay CSS

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:32:15 +00:00
Claude b75bf4e6a5 Fix popup system in DeckGLMap to use proper PopupData format
The MapPopup.show() method expects a PopupData object with:
- type: PopupType string identifying the popup renderer
- data: the actual data object to display
- x, y: click coordinates for popup positioning

Previously was passing raw objects which resulted in broken/missing popups.

Changes:
- Import PopupType from MapPopup
- Update handleClick to map layer IDs to PopupType and create proper PopupData
- Update all trigger methods (triggerConflictClick, triggerBaseClick, etc.)
  to create proper PopupData with centered x/y coordinates
- Add getContainerCenter() helper for programmatic popup positioning

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:25:49 +00:00
Claude cd8097cc8a Fix coordinate bugs and add missing tooltips in DeckGLMap
- Fix coordinate swap bugs in createCablesLayer, createPipelinesLayer,
  and createConflictZonesLayer - data is already [lon, lat] format
- Fix coordinate order in triggerConflictClick, triggerPipelineClick,
  and triggerCableClick to pass (lat, lon) to setCenter correctly
- Fix handleClick to not show popup for hotspots (they have their own handler)
- Add missing tooltips for all layer types (cables, pipelines, conflicts,
  natural events, weather, outages, AIS density, military flights,
  waterways, economic centers, tech HQs, accelerators, cloud regions,
  tech events)

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 12:06:17 +00:00
Claude bfe31675ed Add deck.gl WebGL visualization for desktop
Implement Palantir-like interactive map experience using deck.gl with
MapLibre GL as the base map. Key changes:

- Add deck.gl (@deck.gl/core, @deck.gl/layers, @deck.gl/geo-layers) and
  maplibre-gl dependencies for GPU-accelerated rendering
- Create DeckGLMap component with WebGL layers for:
  - Undersea cables and pipelines (PathLayer)
  - Military bases, nuclear facilities, datacenters (ScatterplotLayer)
  - Conflict zones (GeoJsonLayer with polygons)
  - Hotspots, earthquakes, weather, outages, protests
  - AIS density, military vessels and flights
  - Strategic waterways, economic centers
  - Tech variant: startup hubs, tech HQs, accelerators, cloud regions
- Create MapContainer wrapper that conditionally renders:
  - DeckGLMap (WebGL) on desktop with WebGL support
  - Existing D3/SVG MapComponent on mobile for graceful degradation
- Add dark theme CSS styles for deck.gl controls, legend, layer toggles
- Import maplibre-gl CSS in main.ts

Desktop users now get smooth 60fps interactions with large datasets
while mobile users retain the optimized SVG experience.

https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 11:48:20 +00:00
771 changed files with 182177 additions and 8802 deletions
+32
View File
@@ -0,0 +1,32 @@
# Sentry Triage — 2026-02-19
Commit: `09174fd` on `main`
## Issues Triaged (5)
### ACTIONABLE — Fixed in Code
| ID | Title | Events | Users | Fix |
|---|---|---|---|---|
| WORLDMONITOR-1G | `Error: ML request unload-model timed out after 120000ms` | 30 | 27 | Wrapped `unloadModel()` in try/catch; timeout no longer leaks as unhandled rejection. Cleans up `loadedModels` set on failure. |
| WORLDMONITOR-1F | `Error: ML request unload-model timed out after 120000ms` | 9 | 9 | Same root cause as 1G (different release build hash). |
| WORLDMONITOR-1K | `TypeError: this.player.playVideo is not a function` | 1 | 1 | Added optional chaining (`playVideo?.()`, `pauseVideo?.()`) in `LiveNewsPanel.ts`. YT IFrame API player object may not have methods ready during initialization race. |
### NOISE — Filtered
| ID | Title | Events | Users | Filter |
|---|---|---|---|---|
| WORLDMONITOR-1J | `InternalError: too much recursion` | 1 | 1 | i18next internal `translate -> extractFromKey` cycle on Firefox 147. Added `/too much recursion/` to `ignoreErrors`. |
| WORLDMONITOR-1H | `TypeError: Cannot read properties of null (reading 'id')` | 1 | 1 | maplibre-gl internal render crash (`_drawLayers -> renderLayers`). Extended `beforeSend` regex to suppress null `id`/`type` when stack is in map chunk. |
## Files Modified
| File | Change |
|---|---|
| `src/services/ml-worker.ts` | `unloadModel()`: try/catch around `this.request()`, clean `loadedModels` on failure |
| `src/components/LiveNewsPanel.ts` | Optional chaining on `playVideo?.()` and `pauseVideo?.()` |
| `src/main.ts` | Added `/too much recursion/` to `ignoreErrors`; extended maplibre `beforeSend` filter for null `id`/`type` |
## Sentry Status
All 5 issues marked **resolved (in next release)** via API. They will auto-reopen if errors recur after deployment.
+30
View File
@@ -0,0 +1,30 @@
# Build the image from source only — never copy host-built artifacts or deps.
#
# The web stage runs `npm ci` + `npm run build` itself and the go stage copies
# go.mod/cmd/internal explicitly (see Dockerfile), so the host must NOT ship its
# own node_modules/dist into the context: at `COPY . .` a host node_modules
# clobbers the image's fresh `npm ci` tree and poisons BuildKit's node_modules
# cache-mount ("cannot replace directory with file"), which breaks every build.
node_modules
**/node_modules
dist
**/dist
build
out
.next
.git
.gitignore
.github
*.log
npm-debug.log*
.env
.env.*
!.env.example
.DS_Store
.vscode
.idea
coverage
*.tsbuildinfo
+120
View File
@@ -0,0 +1,120 @@
# ============================================
# World Monitor — Environment Variables
# ============================================
# Copy this file to .env.local and fill in the values you need.
# All keys are optional — the dashboard works without them,
# but the corresponding features will be disabled.
#
# cp .env.example .env.local
#
# ============================================
# ------ AI Summarization (Vercel) ------
# Groq API (primary — 14,400 req/day on free tier)
# Get yours at: https://console.groq.com/
GROQ_API_KEY=
# OpenRouter API (fallback — 50 req/day on free tier)
# Get yours at: https://openrouter.ai/
OPENROUTER_API_KEY=
# ------ Cross-User Cache (Vercel — Upstash Redis) ------
# Used to deduplicate AI calls and cache risk scores across visitors.
# Create a free Redis database at: https://upstash.com/
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# ------ Market Data (Vercel) ------
# Finnhub (primary stock quotes — free tier available)
# Register at: https://finnhub.io/
FINNHUB_API_KEY=
# ------ Energy Data (Vercel) ------
# U.S. Energy Information Administration (oil prices, production, inventory)
# Register at: https://www.eia.gov/opendata/
EIA_API_KEY=
# ------ Economic Data (Vercel) ------
# FRED (Federal Reserve Economic Data)
# Register at: https://fred.stlouisfed.org/docs/api/api_key.html
FRED_API_KEY=
# ------ Aircraft Tracking (Vercel) ------
# Wingbits aircraft enrichment (owner, operator, type)
# Contact: https://wingbits.com/
WINGBITS_API_KEY=
# ------ Conflict & Protest Data (Vercel) ------
# ACLED (Armed Conflict Location & Event Data — free for researchers)
# Register at: https://acleddata.com/
ACLED_ACCESS_TOKEN=
# ------ Internet Outages (Vercel) ------
# Cloudflare Radar API (requires free Cloudflare account with Radar access)
CLOUDFLARE_API_TOKEN=
# ------ Satellite Fire Detection (Vercel) ------
# NASA FIRMS (Fire Information for Resource Management System)
# Register at: https://firms.modaps.eosdis.nasa.gov/
NASA_FIRMS_API_KEY=
# ------ Railway Relay (scripts/ais-relay.cjs) ------
# The relay server handles AIS vessel tracking and OpenSky aircraft data.
# Deploy on Railway with: node scripts/ais-relay.cjs
# AISStream API key for live vessel positions
# Get yours at: https://aisstream.io/
AISSTREAM_API_KEY=
# OpenSky Network OAuth2 credentials (higher rate limits for cloud IPs)
# Register at: https://opensky-network.org/
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
# ------ Railway Relay Connection (Vercel → Railway) ------
# Server-side URL (https://) — used by Vercel edge functions to reach the relay
WS_RELAY_URL=
# Client-side URL (wss://) — used by the browser to connect via WebSocket
VITE_WS_RELAY_URL=
# ------ Public Data Sources (no keys required) ------
# UCDP (Uppsala Conflict Data Program) — public API, no auth
# UNHCR (UN Refugee Agency) — public API, no auth (CC BY 4.0)
# Open-Meteo — public API, no auth (processes Copernicus ERA5)
# WorldPop — public API, optional key for higher rate limits
# WORLDPOP_API_KEY=
# ------ Site Configuration ------
# Site variant: "full" (worldmonitor.app) or "tech" (tech.worldmonitor.app)
VITE_VARIANT=full
# Map interaction mode:
# - "flat" keeps pitch/rotation disabled (2D interaction)
# - "3d" enables pitch/rotation interactions (default)
VITE_MAP_INTERACTION_MODE=3d
+85
View File
@@ -0,0 +1,85 @@
name: Bug Report
description: Report a bug in World Monitor
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix it.
- type: dropdown
id: variant
attributes:
label: Variant
description: Which variant are you using?
options:
- worldmonitor.app (Full / Geopolitical)
- tech.worldmonitor.app (Tech / Startup)
- finance.worldmonitor.app (Finance)
- Desktop app (Windows)
- Desktop app (macOS)
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
description: Which part of the app is affected?
options:
- Map / Globe
- News panels / RSS feeds
- AI Insights / World Brief
- Market Radar / Crypto
- Service Status
- Trending Keywords
- Country Brief pages
- Live video streams
- Desktop app (Tauri)
- Settings / API keys
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug description
description: A clear description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots / Console errors
description: If applicable, add screenshots or paste browser console errors.
- type: input
id: browser
attributes:
label: Browser & OS
description: e.g. Chrome 120 on Windows 11, Safari 17 on macOS Sonoma
placeholder: Chrome 120 on Windows 11
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/koala73/worldmonitor/blob/main/docs/DOCUMENTATION.md
about: Read the full documentation before opening an issue
- name: Discussions
url: https://github.com/koala73/worldmonitor/discussions
about: Ask questions and share ideas in Discussions
@@ -0,0 +1,55 @@
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have an idea for World Monitor? We'd love to hear it!
- type: dropdown
id: area
attributes:
label: Feature area
description: Which area does this feature relate to?
options:
- Map / Globe / Data layers
- News panels / RSS feeds
- AI / Intelligence analysis
- Market data / Crypto
- Desktop app
- UI / UX
- API / Backend
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: A clear description of the feature you'd like.
placeholder: I'd like to see...
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem it solves
description: What problem does this feature address? What's the use case?
placeholder: This would help with...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Have you considered any alternative solutions or workarounds?
- type: textarea
id: context
attributes:
label: Additional context
description: Any mockups, screenshots, links, or references that help illustrate the idea.
@@ -0,0 +1,69 @@
name: New Data Source
description: Suggest a new RSS feed, API, or map layer
labels: ["data-source"]
body:
- type: markdown
attributes:
value: |
World Monitor aggregates 100+ feeds and data layers. Suggest a new one!
- type: dropdown
id: type
attributes:
label: Source type
description: What kind of data source is this?
options:
- RSS / News feed
- API integration
- Map layer (geospatial data)
- Live video stream
- Status page
- Other
validations:
required: true
- type: dropdown
id: variant
attributes:
label: Target variant
description: Which variant should this appear in?
options:
- Full (Geopolitical)
- Tech (Startup)
- Finance
- All variants
validations:
required: true
- type: input
id: source-name
attributes:
label: Source name
description: Name of the source or organization.
placeholder: e.g. RAND Corporation, CoinDesk, USGS
validations:
required: true
- type: input
id: url
attributes:
label: Feed / API URL
description: Direct URL to the RSS feed, API endpoint, or data source.
placeholder: https://example.com/rss
validations:
required: true
- type: textarea
id: description
attributes:
label: Why add this source?
description: What value does this source bring? What does it cover that existing sources don't?
placeholder: This source provides coverage of...
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional notes
description: Any details about rate limits, authentication requirements, data format, or category placement.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="world">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">world</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">World Monitor</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+36
View File
@@ -0,0 +1,36 @@
## Summary
<!-- Brief description of what this PR does -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure
## Affected areas
- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->
## Checklist
- [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app) variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)
## Screenshots
<!-- If applicable, add screenshots or screen recordings -->
+341
View File
@@ -0,0 +1,341 @@
name: 'Build Desktop App'
on:
workflow_dispatch:
inputs:
variant:
description: 'App variant'
required: true
default: 'full'
type: choice
options:
- full
- tech
draft:
description: 'Create as draft release'
required: false
default: true
type: boolean
push:
tags:
- 'v*'
concurrency:
group: desktop-build-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
jobs:
build-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-14'
args: '--target aarch64-apple-darwin'
node_target: 'aarch64-apple-darwin'
label: 'macOS-ARM64'
timeout: 180
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
node_target: 'x86_64-apple-darwin'
label: 'macOS-x64'
timeout: 180
- platform: 'windows-latest'
args: ''
node_target: 'x86_64-pc-windows-msvc'
label: 'Windows-x64'
timeout: 120
- platform: 'ubuntu-22.04'
args: ''
node_target: 'x86_64-unknown-linux-gnu'
label: 'Linux-x64'
timeout: 120
runs-on: ${{ matrix.platform }}
name: Build (${{ matrix.label }})
timeout-minutes: ${{ matrix.timeout }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Start job timer
shell: bash
run: echo "JOB_START_EPOCH=$(date +%s)" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '22'
cache: 'npm'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7
with:
toolchain: stable
targets: ${{ contains(matrix.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db
with:
workspaces: './src-tauri -> target'
cache-on-failure: true
- name: Install Linux system dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: npm ci
- name: Bundle Node.js runtime
shell: bash
env:
NODE_VERSION: '22.14.0'
NODE_TARGET: ${{ matrix.node_target }}
run: bash scripts/download-node.sh --target "$NODE_TARGET"
- name: Verify bundled Node.js payload
shell: bash
run: |
if [ "${{ matrix.node_target }}" = "x86_64-pc-windows-msvc" ]; then
test -f src-tauri/sidecar/node/node.exe
ls -lh src-tauri/sidecar/node/node.exe
else
test -f src-tauri/sidecar/node/node
test -x src-tauri/sidecar/node/node
ls -lh src-tauri/sidecar/node/node
fi
# ── Detect whether Apple signing secrets are configured ──
- name: Check Apple signing secrets
if: contains(matrix.platform, 'macos')
id: apple-signing
shell: bash
run: |
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ] && [ -n "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" ] && [ -n "${{ secrets.KEYCHAIN_PASSWORD }}" ]; then
echo "available=true" >> $GITHUB_OUTPUT
echo "Apple signing secrets detected"
else
echo "available=false" >> $GITHUB_OUTPUT
echo "No Apple signing secrets — building unsigned"
fi
# ── macOS Code Signing (only when secrets are valid) ──
- name: Import Apple Developer Certificate
if: contains(matrix.platform, 'macos') && steps.apple-signing.outputs.available == 'true'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
CERT_SIZE=$(wc -c < certificate.p12 | tr -d ' ')
if [ "$CERT_SIZE" -lt 100 ]; then
echo "::warning::Certificate file too small ($CERT_SIZE bytes) — likely invalid. Skipping signing."
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
exit 0
fi
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign || {
echo "::warning::Certificate import failed — building unsigned"
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
exit 0
}
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
CERT_INFO=$(security find-identity -v -p codesigning build.keychain \
| grep "Developer ID Application" || true)
if [ -n "$CERT_INFO" ]; then
CERT_ID=$(echo "$CERT_INFO" | head -1 | awk -F'"' '{print $2}')
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported: $CERT_ID"
else
echo "::warning::No Developer ID certificate found in keychain — building unsigned"
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
fi
# ── Determine variant ──
- name: Set build variant
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "BUILD_VARIANT=${{ github.event.inputs.variant }}" >> $GITHUB_ENV
else
echo "BUILD_VARIANT=full" >> $GITHUB_ENV
fi
# ── Build with tauri-action ──
# Signed builds: only when Apple signing secrets are valid and imported
# Unsigned builds: fallback when no signing (Windows always uses this path)
# ── Build: Full variant (signed) ──
- name: Build Tauri app (full, signed)
if: env.BUILD_VARIANT == 'full' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: full
VITE_DESKTOP_RUNTIME: '1'
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: v__VERSION__
releaseName: 'World Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
args: ${{ matrix.args }}
retryAttempts: 1
# ── Build: Full variant (unsigned — no Apple certs) ──
- name: Build Tauri app (full, unsigned)
if: env.BUILD_VARIANT == 'full' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true')
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: full
VITE_DESKTOP_RUNTIME: '1'
with:
tagName: v__VERSION__
releaseName: 'World Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
args: ${{ matrix.args }}
retryAttempts: 1
# ── Build: Tech variant (signed) ──
- name: Build Tauri app (tech, signed)
if: env.BUILD_VARIANT == 'tech' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: tech
VITE_DESKTOP_RUNTIME: '1'
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: v__VERSION__-tech
releaseName: 'Tech Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
tauriScript: npx tauri
args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }}
retryAttempts: 1
# ── Build: Tech variant (unsigned — no Apple certs) ──
- name: Build Tauri app (tech, unsigned)
if: env.BUILD_VARIANT == 'tech' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true')
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: tech
VITE_DESKTOP_RUNTIME: '1'
with:
tagName: v__VERSION__-tech
releaseName: 'Tech Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
tauriScript: npx tauri
args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }}
retryAttempts: 1
- name: Verify signed macOS bundle + embedded runtime
if: contains(matrix.platform, 'macos') && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
shell: bash
run: |
APP_PATH=$(find src-tauri/target -type d -path '*/bundle/macos/*.app' | head -1)
if [ -z "$APP_PATH" ]; then
echo "::error::No macOS .app bundle found after build."
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
NODE_PATH=$(find "$APP_PATH/Contents/Resources" -type f -path '*/sidecar/node/node' | head -1)
if [ -z "$NODE_PATH" ]; then
echo "::error::Bundled Node runtime missing from app resources."
exit 1
fi
echo "Verified signed app bundle and embedded Node runtime: $NODE_PATH"
- name: Cleanup Apple signing materials
if: always() && contains(matrix.platform, 'macos')
shell: bash
run: |
rm -f certificate.p12
security delete-keychain build.keychain || true
- name: Report build duration
if: always()
shell: bash
run: |
if [ -z "${JOB_START_EPOCH:-}" ]; then
echo "::warning::JOB_START_EPOCH missing; duration unavailable."
exit 0
fi
END_EPOCH=$(date +%s)
ELAPSED=$((END_EPOCH - JOB_START_EPOCH))
MINUTES=$((ELAPSED / 60))
SECONDS=$((ELAPSED % 60))
echo "Build duration for ${{ matrix.label }}: ${MINUTES}m ${SECONDS}s"
# ── Update release notes with changelog after all builds complete ──
update-release-notes:
needs: build-tauri
if: always() && contains(needs.build-tauri.result, 'success')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- name: Generate and update release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
VERSION=$(jq -r .version src-tauri/tauri.conf.json)
TAG="v${VERSION}"
PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
COMMITS="Initial release"
else
COMMITS=$(git log "${PREV_TAG}..${TAG}" --oneline --no-merges | sed 's/^[a-f0-9]*//' | sed 's/^ /- /')
fi
BODY=$(cat <<NOTES
## What's Changed
${COMMITS}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG:-initial}...${TAG}
NOTES
)
gh release edit "$TAG" --notes "$BODY"
echo "Updated release notes for $TAG"
+22
View File
@@ -0,0 +1,22 @@
# Canonical CI/CD — imports the shared hanzoai/ci reusable workflow, which
# reads hanzo.yml (images + deploy + kms). No per-repo build logic, no
# GitHub-hosted runners (defaults to the Hanzo cloud arc pool).
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
cicd:
uses: hanzoai/ci/.github/workflows/build.yml@v1
# Target the Hanzo arc scale set by its installation name. ARC scale sets
# are matched by name, not by the [self-hosted,linux,amd64] label triple
# that ci@v1 defaults to — without this override the job never gets a
# runner and sits queued.
with:
runner: '["hanzo-build-linux-amd64"]'
secrets: inherit
+19
View File
@@ -0,0 +1,19 @@
name: Lint
on:
pull_request:
paths:
- '**/*.md'
- '.markdownlint-cli2.jsonc'
jobs:
markdown:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint:md
+29
View File
@@ -1,4 +1,6 @@
node_modules/
.idea/
.planning/
dist/
.DS_Store
*.log
@@ -8,3 +10,30 @@ dist/
.vercel
.claude/
.cursor/
CLAUDE.md
.env.vercel-backup
.env.vercel-export
.agent/
.factory/
.windsurf/
skills/
ideas/
test-results/
src-tauri/sidecar/node/*
!src-tauri/sidecar/node/.gitkeep
# world-model runtime snapshot + history ring (regenerated on boot)
data/world-model.json
data/world-model.json.tmp
data/world-model-history.json.gz
data/world-model-history.json.gz.tmp
# embedded datastore lake + settings (SQLite, WAL mode) — regenerated on boot,
# lands here only when WORLD_DATA_DIR points at the repo (local dev)
world.db
world.db-wal
world.db-shm
data/world.db
data/world.db-wal
data/world.db-shm
/world
+10
View File
@@ -0,0 +1,10 @@
{
// Only enforce the 3 rules from PR #72. Everything else is off.
"config": {
"default": false,
"MD012": true,
"MD022": true,
"MD032": true
},
"ignores": ["node_modules/**", "dist/**", "src-tauri/target/**"]
}
+45
View File
@@ -0,0 +1,45 @@
# Hanzo World — agent guide
Vite + TypeScript SPA (`world-monitor`). Real-time global-intelligence dashboard
served at `world.hanzo.ai`, shipped by `hanzo.yml` CI/CD onto the `world`
operator Service CR. Same-origin data plane under `/v1/world/*`.
## Browser control — prefer the Hanzo MCP extension over Playwright
When the **Hanzo browser MCP** (the `mcp__hanzo__browser` / `mcp__claude-in-chrome__*`
tools, backed by the local extension in `~/work/hanzo/extension`) is available,
use it by default to drive a real browser for interactive UI work — inspecting
the globe, tweaking layers, checking live feeds, capturing screenshots against a
running dev server. It talks to a real Chrome/Firefox with the real WebGL/deck.gl
context, so what you see is what a user sees.
Fall back to **Playwright only when** the MCP extension is not connected, or for
the deterministic **e2e suite** (`e2e/*.spec.ts`) — those run headless in CI with
mocked `/v1/world/cloud/*` feeds and must stay reproducible offline. E2e is not
interactive editing; keep the two lanes separate.
Order of preference for "look at / poke the UI": Hanzo MCP browser → Playwright.
## The map / globe
- Basemaps: `dark` · `dot` · `satellite` · `terrain`. **`dot` is the default**
for every variant (`DEFAULT_BASEMAP_STYLE` in `src/config/variant.ts`) — the
Kaspersky-style cybermap: land drawn only as a glowing dot-lattice over a black
ocean sphere, no country fills/borders/imagery.
- The lattice is one pure value — `getLandDots()` in `src/services/land-dots.ts`
consumed by BOTH the 2D mercator map (`DeckGLMap`) and the 3D globe
(`GlobeNative`). One source, two projections. Don't fork it.
- Cloud view layers (default-on, `?variant=cloud`): live request-origin dots +
animated traffic arcs (`AnimatedArcLayer`, a travelling pulse advanced on RAF),
validator chain-nodes, BYO-GPU rings, datacenter clusters. Feeds are best-effort
and degrade to honest empty states — never fabricate volume.
- `satellite`/`terrain` need `VITE_MAPBOX_TOKEN` (from KMS `hanzo/deploy/`, never
in git); `dark`/`dot` are keyless CartoDB.
## Release
Bump `package.json` PATCH (x.y.z → x.y.z+1, never a lazy major), tag `v<version>`,
`workflow_dispatch` the `cicd` workflow. The image tag is the version WITHOUT the
`v`. CI's "Deploy" step can false-negative while the operator finishes rolling —
verify the live version, not just the CI square. Test/doc-only changes need no
release (the image is byte-identical).
+315
View File
@@ -0,0 +1,315 @@
# Changelog
All notable changes to World Monitor are documented here.
## [2.4.19]
### Fixed
- **Moving/resizing a panel no longer shifts the others** (the "when I shift the 3D map it shifts all other components" report). The dashboard now DEFAULTS to the free layout: every panel owns its own {x,y,w,h}, so a drag or resize leaves every sibling exactly where it was — no more grid reflow. The switch is invisible: the current grid arrangement is frozen as the starting geometry (`grid-config.applyDefaultLayout`), and Grid stays one dropdown-click away for anyone who wants snap-to-grid back.
- **Panels resize granularly** — pixel-exact width AND height from any edge or corner, instead of jumping between coarse column/row spans.
- **Panels can be made much narrower/smaller** ("constrained on min width"): the free-mode floors drop to 96×40px (map 140px), and the grid widget-size slider now reaches a 120px column track (was 140).
### Changed
- A panel shown from the Panels menu / "+ Add widget" while in free mode seeds a tidy default slot below the placed panels (never a full-width block under its absolute siblings). Hidden panels no longer persist 0×0 geometry.
## [2.4.13]
### Fixed
- **Globe overlays now sit ON the sphere with correct occlusion** (the "map spazzes" report). On the native deck.gl GlobeView, data layers (shared with the 2D map) carried no depth parameters, so the far hemisphere showed through and count badges floated above the limb. GlobeNative now seats every data layer against the depth-writing ocean sphere (test, don't write — no inter-marker z-fight), and the far-side billboard cull (`occludeFarSide`) faces the globe's OWN live camera instead of the parked (frozen) mapbox center — extended to `TextLayer` so a back-side "36" count badge disappears instead of hovering over the planet.
- **Terrain striping on the globe**: the draped ESRI imagery tiles each wrote depth, so coplanar neighbours z-fought at the tile seams (horizontal stripes / partial render). Tiles now depth-TEST only; the ocean sphere owns the depth buffer.
- **Live request-geo dots kept polling on the 3D globe**: the `/v1/world/cloud/*` poll was gated on `renderPaused`, which the native GlobeView sets on activation — so the realtime dots froze the moment you entered the default 3D view. It now gates on tab visibility, so the dots stay live (and fade) on the globe.
### Changed
- **Request-origin → serving-region arcs on by default** in the Cloud view: the `/cloud/traffic` arcs derive from the same real native request-geo points as the dots (origin country → nearest Hanzo region) and degrade to empty — never demo.
## [2.4.6]
### Added
- **Enso Flywheel panel (AI variant)**: the router self-improvement loop made visible. New `/v1/world/enso-training` folds the routing-decision ledger tail + reward tail (`export-routing-ledger` / `export-routing-rewards`, super-admin) into ledger growth, engine-vs-heuristic mix, a routing-confidence histogram, and task/model distributions, alongside the latest enso-bench eval scores (an embedded `results/summary.json` snapshot, overridable live via `ENSO_BENCH_URL`). The response is event-typed so future retrain/deploy milestones slot into the same timeline. Eval scores render even signed-out; `state` (live/partial/demo) says which live sources resolved.
## [2.4.5]
### Added
- **AI Compute panel (AI variant)**: live Hanzo inference-plane telemetry over SSE — tokens/s, requests/s, 24h spend, top models by real spend, and the serving fleet (GPUs, machines online, models served). New `/v1/world/ai-pulse` pushes typed `usage`/`fleet`/`status` frames (EventSource) and answers a plain GET with one JSON snapshot as the poll fallback. Honest "connecting"/"unavailable" states — never a zero dressed up as live; the service bearer stays server-side.
## [2.4.4]
### Changed
- **Cloud Pulse is real when a service token is wired**: `/v1/world/cloud-pulse` now folds MEASURED platform-wide 24h request/token volume from the ClickHouse-backed usage ledger (`get-cloud-usages ?org=all`, super-admin) on top of the live model/node/GPU/region counts — dropping both `demo:true` and `volumeModeled:true`. Top models come from real ledger spend. Without a token, or with a non-admin token, it stays honestly demo/modeled — platform numbers are never faked silently. The service bearer stays server-side (never sent to the browser).
## [2.4.2]
### Added
- **Streaming analyst**: answers flow in live over SSE — reasoning shows as dim thinking text, tool calls appear as chips the moment they run, the reply types itself in; the final render and command dispatch are unchanged (done event = the old JSON contract)
- **Model menu**: the composer pill opens a grouped popover (Auto / Zen / GPT / Llama / Claude / Agents) with per-family marks and an active check
### Fixed
- **Model identity**: the Zen ring appears only on zen* models — gpt-oss/llama/claude get their own marks on the pill, menu rows, avatars, and the thinking row
- **Dev server**: `npm run dev` proxies /v1 to production by default (VITE_DEV_API_PROXY overrides)
## [2.4.1]
### Added
- **Western Pacific cyclones**: cross-agency tropical-cyclone attribution (GDACS + HKO warnings via new `/v1/world/hko-warnings` proxy) with per-agency wind observations, canonical dedup, and map popup detail rows
- **China macro snapshot**: `/v1/world/china-macro` — CPI/CLI (OECD), policy rate, USD/CNY (FRED), HKMA context, NBS release calendar + PBoC LPR dates, surfaced with staleness-honest indicator tiles
- **Model roster**: `Best (auto)` leads the analyst model picker — the gateway routing alias that always resolves
### Changed
- **AI default model**: `zen5``best`; a pinned family id goes dark when the inference plane's claim catalog shifts, the routing alias never does
- **Server cache is now stale-while-revalidate**: `cachedJSON`/`passthrough` serve stale instantly and refresh in the background (single-flight); GDELT/theater-posture no longer stall requests ~10s on TTL expiry
- **GDELT cache warmers**: hot keys (analyst grounding, protests layer) refreshed every ~4min so no user ever eats a cold miss
- **Sparkline payloads**: close arrays rounded to 7 significant digits (float32-widening noise stripped, ~40% smaller; scalars untouched)
### Fixed
- **News first paint**: panels no longer gate their first DOM write on a 65MB ML sentiment model download — headlines paint immediately, sentiment refines in place
- **Analyst grounding snapshot**: context fetches bounded at 2.5s so a cold endpoint can't hold the chat send hostage
- **AI errors are honest**: upstream error codes (`insufficient_balance`, `spend_cap_exceeded`, …) surface in the chat instead of a bare `status 402`
## [2.4.0] - 2026-02-19
### Added
- **Live Webcams Panel**: 2x2 grid of live YouTube webcam feeds from global hotspots with region filters (Middle East, Europe, Asia-Pacific, Americas), grid/single view toggle, idle detection, and full i18n support (#111)
- **Linux download**: added `.AppImage` option to download banner
### Changed
- **Mobile detection**: use viewport width only for mobile detection; touch-capable notebooks (e.g. ROG Flow X13) now get desktop layout (#113)
- **Webcam feeds**: curated Tel Aviv, Mecca, LA, Miami; replaced dead Tokyo feed; diverse ALL grid with Jerusalem, Tehran, Kyiv, Washington
### Fixed
- **Le Monde RSS**: English feed URL updated (`/en/rss/full.xml``/en/rss/une.xml`) to fix 404
- **Workbox precache**: added `html` to `globPatterns` so `navigateFallback` works for offline PWA
- **Panel ordering**: one-time migration ensures Live Webcams follows Live News for existing users
- **Mobile popups**: improved sheet/touch/controls layout (#109)
- **Intelligence alerts**: disabled on mobile to reduce noise (#110)
- **RSS proxy**: added 8 missing domains to allowlist
- **HTML tags**: repaired malformed tags in panel template literals
- **ML worker**: wrapped `unloadModel()` in try/catch to prevent unhandled timeout rejections
- **YouTube player**: optional chaining on `playVideo?.()` / `pauseVideo?.()` for initialization race
- **Panel drag**: guarded `.closest()` on non-Element event targets
- **Beta mode**: resolved race condition and timeout failures
- **Sentry noise**: added filters for Firefox `too much recursion`, maplibre `_layers`/`id`/`type` null crashes
## [2.3.9] - 2026-02-18
### Added
- **Full internationalization (14 locales)**: English, French, German, Spanish, Italian, Polish, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese Simplified, Japanese — each with 1100+ translated keys
- **RTL support**: Arabic locale with `dir="rtl"`, dedicated RTL CSS overrides, regional language code normalization (e.g. `ar-SA` correctly triggers RTL)
- **Language switcher**: in-app locale picker with flag icons, persists to localStorage
- **i18n infrastructure**: i18next with browser language detection and English fallback
- **Community discussion widget**: floating pill linking to GitHub Discussions with delayed appearance and permanent dismiss
- **Linux AppImage**: added `ubuntu-22.04` to CI build matrix with webkit2gtk/appindicator dependencies
- **NHK World and Nikkei Asia**: added RSS feeds for Japan news coverage
- **Intelligence Findings badge toggle**: option to disable the findings badge in the UI
### Changed
- **Zero hardcoded English**: all UI text routed through `t()` — panels, modals, tooltips, popups, map legends, alert templates, signal descriptions
- **Trending proper-noun detection**: improved mid-sentence capitalization heuristic with all-caps fallback when ML classifier is unavailable
- **Stopword suppression**: added missing English stopwords to trending keyword filter
### Fixed
- **Dead UTC clock**: removed `#timeDisplay` element that permanently displayed `--:--:-- UTC`
- **Community widget duplicates**: added DOM idempotency guard preventing duplicate widgets on repeated news refresh cycles
- **Settings help text**: suppressed raw i18n key paths rendering when translation is missing
- **Intelligence Findings badge**: fixed toggle state and listener lifecycle
- **Context menu styles**: restored intel-findings context menu styles
- **CSS theme variables**: defined missing `--panel-bg` and `--panel-border` variables
## [2.3.8] - 2026-02-17
### Added
- **Finance variant**: Added a dedicated market-first variant (`finance.worldmonitor.app`) with finance/trading-focused feeds, panels, and map defaults
- **Finance desktop profile**: Added finance-specific desktop config and build profile for Tauri packaging
### Changed
- **Variant feed loading**: `loadNews` now enumerates categories dynamically and stages category fetches with bounded concurrency across variants
- **Feed resilience**: Replaced direct MarketWatch RSS usage in finance/full/tech paths with Google News-backed fallback queries
- **Classification pressure controls**: Tightened AI classification budgets for tech/full and tuned per-feed caps to reduce startup burst pressure
- **Timeline behavior**: Wired timeline filtering consistently across map and news panels
- **AI summarization defaults**: Switched OpenRouter summarization to auto-routed free-tier model selection
### Fixed
- **Finance panel parity**: Kept data-rich panels while adding news panels for finance instead of removing core data surfaces
- **Desktop finance map parity**: Finance variant now runs first-class Deck.GL map/layer behavior on desktop runtime
- **Polymarket fallback**: Added one-time direct connectivity probe and memoized fallback to prevent repeated `ERR_CONNECTION_RESET` storms
- **FRED fallback behavior**: Missing `FRED_API_KEY` now returns graceful empty payloads instead of repeated hard 500s
- **Preview CSP tooling**: Allowed `https://vercel.live` script in CSP so Vercel preview feedback injection is not blocked
- **Trending quality**: Suppressed noisy generic finance terms in keyword spike detection
- **Mobile UX**: Hidden desktop download prompt on mobile devices
## [2.3.7] - 2026-02-16
### Added
- **Full light mode theme**: Complete light/dark theme system with CSS custom properties, ThemeManager module, FOUC prevention, and `getCSSColor()` utility for theme-aware inline styles
- **Theme-aware maps and charts**: Deck.GL basemap, overlay layers, and CountryTimeline charts respond to theme changes in real time
- **Dark/light mode header toggle**: Sun/moon icon in the header bar for quick theme switching, replacing the duplicate UTC clock
- **Desktop update checker**: Architecture-aware download links for macOS (ARM/Intel) and Windows
- **Node.js bundled in Tauri installer**: Sidecar no longer requires system Node.js
- **Markdown linting**: Added markdownlint config and CI workflow
### Changed
- **Panels modal**: Reverted from "Settings" back to "Panels" — removed redundant Appearance section now that header has theme toggle
- **Default panels**: Enabled UCDP Conflict Events, UNHCR Displacement, Climate Anomalies, and Population Exposure panels by default
### Fixed
- **CORS for Tauri desktop**: Fixed CORS issues for desktop app requests
- **Markets panel**: Keep Yahoo-backed data visible when Finnhub API key is skipped
- **Windows UNC paths**: Preserve extended-length path prefix when sanitizing sidecar script path
- **Light mode readability**: Darkened neon semantic colors and overlay backgrounds for light mode contrast
## [2.3.6] - 2026-02-16
### Fixed
- **Windows console window**: Hide the `node.exe` console window that appeared alongside the desktop app on Windows
## [2.3.5] - 2026-02-16
### Changed
- **Panel error messages**: Differentiated error messages per panel so users see context-specific guidance instead of generic failures
- **Desktop config auto-hide**: Desktop configuration panel automatically hides on web deployments where it is not relevant
## [2.3.4] - 2026-02-16
### Fixed
- **Windows sidecar crash**: Strip `\\?\` UNC extended-length prefix from paths before passing to Node.js — Tauri `resource_dir()` on Windows returns UNC-prefixed paths that cause `EISDIR: lstat 'C:'` in Node.js module resolution
- **Windows sidecar CWD**: Set explicit `current_dir` on the Node.js Command to prevent bare drive-letter working directory issues from NSIS shortcut launcher
- **Sidecar package scope**: Add `package.json` with `"type": "module"` to sidecar directory, preventing Node.js from walking up the entire directory tree during ESM scope resolution
## [2.3.3] - 2026-02-16
### Fixed
- **Keychain persistence**: Enable `apple-native` (macOS) and `windows-native` (Windows) features for the `keyring` crate — v3 ships with no default platform backends, so API keys were stored in-memory only and lost on restart
- **Settings key verification**: Soft-pass network errors during API key verification so transient sidecar failures don't block saving
- **Resilient keychain reads**: Use `Promise.allSettled` in `loadDesktopSecrets` so a single key failure doesn't discard all loaded secrets
- **Settings window capabilities**: Add `"settings"` to Tauri capabilities window list for core plugin permissions
- **Input preservation**: Capture unsaved input values before DOM re-render in settings panel
## [2.3.0] - 2026-02-15
### Security
- **CORS hardening**: Tighten Vercel preview deployment regex to block origin spoofing (`worldmonitorEVIL.vercel.app`)
- **Sidecar auth bypass**: Move `/api/local-env-update` behind `LOCAL_API_TOKEN` auth check
- **Env key allowlist**: Restrict sidecar env mutations to 18 known secret keys (matching `SUPPORTED_SECRET_KEYS`)
- **postMessage validation**: Add `origin` and `source` checks on incoming messages in LiveNewsPanel
- **postMessage targetOrigin**: Replace wildcard `'*'` with specific embed origin
- **CORS enforcement**: Add `isDisallowedOrigin()` check to 25+ API endpoints that were missing it
- **Custom CORS migration**: Migrate `gdelt-geo` and `eia` from custom CORS to shared `_cors.js` module
- **New CORS coverage**: Add CORS headers + origin check to `firms-fires`, `stock-index`, `youtube/live`
- **YouTube embed origins**: Tighten `ALLOWED_ORIGINS` regex in `youtube/embed.js`
- **CSP hardening**: Remove `'unsafe-inline'` from `script-src` in both `index.html` and `tauri.conf.json`
- **iframe sandbox**: Add `sandbox="allow-scripts allow-same-origin allow-presentation"` to YouTube embed iframe
- **Meta tag validation**: Validate URL query params with regex allowlist in `parseStoryParams()`
### Fixed
- **Service worker stale assets**: Add `skipWaiting`, `clientsClaim`, and `cleanupOutdatedCaches` to workbox config — fixes `NS_ERROR_CORRUPTED_CONTENT` / MIME type errors when users have a cached SW serving old HTML after redeployment
## [2.2.6] - 2026-02-14
### Fixed
- Filter trending noise and fix sidecar auth
- Restore tech variant panels
- Remove Market Radar and Economic Data panels from tech variant
### Docs
- Add developer X/Twitter link to Support section
- Add cyber threat API keys to `.env.example`
## [2.2.5] - 2026-02-13
### Security
- Migrate all Vercel edge functions to CORS allowlist
- Restrict Railway relay CORS to allowed origins only
### Fixed
- Hide desktop config panel on web
- Route World Bank & Polymarket via Railway relay
## [2.2.3] - 2026-02-12
### Added
- Cyber threat intelligence map layer (Feodo Tracker, URLhaus, C2IntelFeeds, OTX, AbuseIPDB)
- Trending keyword spike detection with end-to-end flow
- Download desktop app slide-in banner for web visitors
- Country briefs in Cmd+K search
### Changed
- Redesign 4 panels with table layouts and scoped styles
- Redesign population exposure panel and reorder UCDP columns
- Dramatically increase cyber threat map density
### Fixed
- Resolve z-index conflict between pinned map and panels grid
- Cap geo enrichment at 12s timeout, prevent duplicate download banners
- Replace ipwho.is/ipapi.co with ipinfo.io/freeipapi.com for geo enrichment
- Harden trending spike processing and optimize hot paths
- Improve cyber threat tooltip/popup UX and dot visibility
## [2.2.2] - 2026-02-10
### Added
- Full-page Country Brief Page replacing modal overlay
- Download redirect API for platform-specific installers
### Fixed
- Normalize country name from GeoJSON to canonical TIER1 name
- Tighten headline relevance, add Top News section, compact markets
- Hide desktop config panel on web, fix irrelevant prediction markets
- Tone down climate anomalies heatmap to stop obscuring other layers
- macOS: hide window on close instead of quitting
### Performance
- Reduce idle CPU from pulse animation loop
- Harden regression guardrails in CI, cache, and map clustering
## [2.2.1] - 2026-02-08
### Fixed
- Consolidate variant naming and fix PWA tile caching
- Windows settings window: async command, no menu bar, no white flash
- Constrain layers menu height in DeckGLMap
- Allow Cloudflare Insights script in CSP
- macOS build failures when Apple signing secrets are missing
## [2.2.0] - 2026-02-07
Initial v2.2 release with multi-variant support (World + Tech), desktop app (Tauri), and comprehensive geopolitical intelligence features.
-75
View File
@@ -1,75 +0,0 @@
# WorldMonitor Development Notes
## Critical: RSS Proxy Allowlist
When adding new RSS feeds in `src/config/feeds.ts`, you **MUST** also add the feed domains to the allowlist in `api/rss-proxy.js`.
### Why
The RSS proxy has a security allowlist (`ALLOWED_DOMAINS`) that blocks requests to domains not explicitly listed. Feeds from unlisted domains will return HTTP 403 "Domain not allowed" errors.
### How to Add New Feeds
1. Add the feed to `src/config/feeds.ts`
2. Extract the domain from the feed URL (e.g., `https://www.ycombinator.com/blog/rss/``www.ycombinator.com`)
3. Add the domain to `ALLOWED_DOMAINS` array in `api/rss-proxy.js`
4. Deploy changes to Vercel
### Example
```javascript
// In api/rss-proxy.js
const ALLOWED_DOMAINS = [
// ... existing domains
'www.ycombinator.com', // Add new domain here
];
```
### Debugging Feed Issues
If a panel shows "No news available":
1. Open browser DevTools → Console
2. Look for `HTTP 403` or "Domain not allowed" errors
3. Check if the domain is in `api/rss-proxy.js` allowlist
## Site Variants
Two variants controlled by `VITE_VARIANT` environment variable:
- `full` (default): Geopolitical focus - worldmonitor.app
- `tech`: Tech/startup focus - startups.worldmonitor.app
### Running Locally
```bash
npm run dev # Full variant
npm run dev:tech # Tech variant
```
### Building
```bash
npm run build:full # Production build for worldmonitor.app
npm run build:tech # Production build for startups.worldmonitor.app
```
## Custom Feed Scrapers
Some sources don't provide RSS feeds. Custom scrapers are in `/api/`:
| Endpoint | Source | Notes |
|----------|--------|-------|
| `/api/fwdstart` | FwdStart Newsletter (Beehiiv) | Scrapes archive page, 30min cache |
### Adding New Scrapers
1. Create `/api/source-name.js` edge function
2. Scrape source, return RSS XML format
3. Add to feeds.ts: `{ name: 'Source', url: '/api/source-name' }`
4. No need to add to rss-proxy allowlist (direct API, not proxied)
## Service Status Panel
Status page URLs in `api/service-status.js` must match the actual status page endpoint. Common formats:
- Statuspage.io: `https://status.example.com/api/v2/status.json`
- Atlassian: `https://example.status.atlassian.com/api/v2/status.json`
- incident.io: Same endpoint but returns HTML, handled by `incidentio` parser
Current known URLs:
- Anthropic: `https://status.claude.com/api/v2/status.json`
- Zoom: `https://www.zoomstatus.com/api/v2/status.json`
- Notion: `https://www.notion-status.com/api/v2/status.json`
+73
View File
@@ -0,0 +1,73 @@
# world.hanzo.ai — Vite SPA + same-origin /api/* data backend, one Go binary.
#
# The SPA fetches SAME-ORIGIN /api/* (runtime.ts resolves to the current origin),
# so world.hanzo.ai (and every *.hanzo.app fork) must serve /api/* itself. The
# old static-only image (hanzoai/static) had no /api, so every data + live-video
# request fell through to the SPA index.html — the app showed no data and no
# video. This image fixes that: cmd/world serves BOTH the static build (with SPA
# fallback for client routes) AND the ~48 /api/* endpoints (internal/world),
# each a faithful Go port of the original edge function.
#
# Built on Hanzo's own hardware (platform.hanzo.ai -> arcd / in-cluster
# BuildKit), never on GitHub builders.
#
# Build (BuildKit, on-cluster):
# --opt=context=https://github.com/hanzoai/world.git#<sha>
# --opt=filename=Dockerfile
# --output=type=image,name=ghcr.io/hanzoai/world:<tag>,push=true
#
# Data-source API keys (all optional; a missing key degrades that endpoint to a
# clean empty payload, never a 5xx) are injected as env at deploy time from KMS:
# YOUTUBE_API_KEY (live-video reliability; scrape fallback needs no key),
# FRED_API_KEY, FINNHUB_API_KEY, NASA_FIRMS_API_KEY, EIA_API_KEY,
# ACLED_ACCESS_TOKEN, CLOUDFLARE_API_TOKEN, WINGBITS_API_KEY, WS_RELAY_URL,
# HANZO_AI_KEY (+ HANZO_AI_BASE / HANZO_AI_MODEL) for the AI endpoints.
# ---- web stage: Vite static build (-> /app/dist) -------------------------
FROM node:20-bookworm-slim AS web
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# vite.config.ts: default base '/', default outDir 'dist'. VITE_VARIANT defaults
# to the full layer set; no build-time secrets are required (the runtime API base
# is same-origin, resolved in the browser).
ARG VITE_MAPBOX_TOKEN
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
RUN npm run build
# ---- go stage: build the static server binary (CGO-free) -----------------
# go 1.26: go.mod requires >= 1.26.4 (github.com/hanzoai/sqlite drop-in). The
# binary stays CGO-free — with CGO_ENABLED=0, hanzoai/sqlite selects its vendored
# pure-Go engine (zero modernc.org/* in the module graph). That engine gates FTS5
# behind the `sqlite_fts5` build tag, which the store's items_fts virtual table
# needs — so the build below MUST carry `-tags sqlite_fts5` or Open degrades.
FROM golang:1.26-alpine AS gobuild
WORKDIR /src
# git: go resolves the PRIVATE indirect dep github.com/hanzoai/csqlite 'direct'
# (not via the module proxy), which needs the git binary + an https credential.
# alpine ships neither, so add git and mount the gh_token BuildKit secret that
# hanzoai/ci passes (--secret id=gh_token); the mount is a no-op for public builds.
RUN apk add --no-cache git
ENV GOPRIVATE=github.com/hanzoai,github.com/luxfi,github.com/zooai
# Deps: hanzo-kv client (go-redis) + embedded SQLite (modernc). Download once for
# a cached layer before the source is copied.
COPY go.mod go.sum ./
RUN --mount=type=secret,id=gh_token \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi; \
go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build -tags sqlite_fts5 -trimpath -ldflags="-s -w" -o /out/world ./cmd/world
# ---- final stage: minimal image running the Go binary --------------------
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -H -u 10001 world
COPY --from=gobuild /out/world /usr/local/bin/world
COPY --from=web /app/dist /srv
USER world
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/world", "--root=/srv", "--addr=:3000"]
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2024-2026 Elie Habib
Copyright (c) 2026 Hanzo AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
World Monitor — Hanzo fork
Copyright (c) 2026 Hanzo AI. Licensed under the MIT License (see LICENSE).
Forked from koala73/worldmonitor (https://github.com/koala73/worldmonitor) at
v2.4.0 (commit 572f3808) — the last release under the MIT License. This fork
tracks only the MIT-era code and develops forward independently.
Original work © 2024-2026 Elie Habib, used under the MIT License.
+937 -2952
View File
File diff suppressed because it is too large Load Diff
-408
View File
@@ -1,408 +0,0 @@
# World Monitor Roadmap: Intelligence Correlation Enhancements
This document outlines the top 5 features a geopolitical intelligence analyst would want, focusing on **correlation between existing data points** and leveraging **free APIs/RSS feeds only**.
---
## Current Correlation Capabilities
### What We Already Do Well
| Signal Type | Description | Data Sources |
|------------|-------------|--------------|
| **Convergence** | 3+ source types report same story within 30min | News feeds |
| **Triangulation** | Wire + Gov + Intel sources align on topic | News feeds |
| **Velocity Spike** | Topic mention rate doubles with 6+ sources/hr | News feeds |
| **Prediction Leading** | Polymarket moves 5%+ with low news coverage | Polymarket + News |
| **Silent Divergence** | Market moves 2%+ with minimal related news | Yahoo/Finnhub + News |
| **Flow/Price Divergence** | Energy price spike without pipeline news | Markets + News |
| **Related Assets** | News stories enriched with nearby infrastructure | Hotspots + All assets |
| **GDELT Tensions** | Country-pair tension scores with 7-day trends | GDELT GPR API |
### What's Missing
1. **No cross-layer correlation** - Protests, military movements, and economic data don't talk to each other
2. **No temporal pattern detection** - Can't detect "unusual for this time of year"
3. **No geographic clustering** - Multiple event types in same region not flagged
4. **No country-level aggregation** - No unified risk view per country
5. **No infrastructure dependency mapping** - Don't show cascade effects
---
## Top 5 Priority Features
### 1. Multi-Signal Geographic Convergence
**What:** When 3+ independent data types converge on the same geographic region within 24-48 hours, generate a high-priority alert.
**Why:** The most valuable I&W (Indications & Warning) signals come from multiple independent sources detecting activity in the same area. A protest + military flight activity + shipping disruption in the same region is far more significant than any single event.
**Data Sources (Already Have):**
- Protests (ACLED/GDELT) → lat/lon
- Military flights (OpenSky) → lat/lon
- Military vessels (AIS) → lat/lon
- Earthquakes/natural events → lat/lon
- News hotspots → lat/lon
- Chokepoint congestion → lat/lon
- Pipeline incidents → lat/lon (inferred)
**Implementation:**
```
1. Define 50km grid cells globally
2. Each refresh cycle, tag events to grid cells
3. Track event counts by type per cell over 24h window
4. Alert when: cell has events from 3+ distinct data types
5. Confidence = function(event_count, type_diversity, time_clustering)
```
**Example Alert:**
> ⚠️ **Geographic Convergence: Taiwan Strait**
> - Military flights: 12 (3x normal)
> - Naval vessels: 8 (2x normal)
> - News velocity: Spike (+340%)
> - Confidence: 87%
---
### 2. Country Instability Index
**What:** Real-time composite risk score for each country, aggregating all available signals into a single 0-100 index.
**Why:** Analysts need a quick way to answer "how stable is Country X right now?" without manually checking 10 different data sources.
**Components (Already Have Data):**
| Component | Source | Weight |
|-----------|--------|--------|
| Protest frequency | ACLED/GDELT | 20% |
| Protest severity | ACLED fatalities | 15% |
| Conflict proximity | Conflict zones | 15% |
| News sentiment | Clustered news | 10% |
| News velocity | RSS feeds | 10% |
| GDELT tension (as target) | GDELT GPR | 10% |
| Sanctions status | Static config | 10% |
| Infrastructure incidents | Cables/pipelines | 10% |
**Free Data to Add:**
- **World Bank Governance Indicators** (annual, free API)
- **UN Refugee Data** (UNHCR, RSS feeds)
- **Election proximity** (static calendar)
**Implementation:**
```
1. Map all events to ISO country codes
2. Maintain rolling 7-day and 30-day baselines per country
3. Calculate Z-scores for each component
4. Weight and sum to 0-100 index
5. Track index changes for trend detection
```
**UI:**
- Choropleth map layer showing index by color
- Sortable country list panel
- Click country → drill-down to component breakdown
- Alert when country moves 10+ points in 24h
---
### 3. Trade Route Risk Scoring
**What:** Real-time risk assessment for major shipping routes, showing which supply chains are most vulnerable right now.
**Why:** Supply chain disruptions are the primary economic consequence of geopolitical events. An analyst needs to quickly assess "if X happens, what trade is affected?"
**Major Routes to Score:**
| Route | Chokepoints | Commodities |
|-------|-------------|-------------|
| Asia → Europe (Suez) | Suez, Bab el-Mandeb, Malacca | Containers, oil |
| Asia → US West Coast | Malacca, Taiwan Strait, Panama | Containers, electronics |
| Middle East → Europe | Hormuz, Suez, Bosphorus | Oil, LNG |
| Russia → Europe | Baltic, Bosphorus | Oil, gas, grain |
| South America → Asia | Panama, Magellan | Commodities, grain |
**Risk Components:**
| Factor | Source | Notes |
|--------|--------|-------|
| Chokepoint congestion | AIS density | Real-time |
| Dark ship activity | AIS gaps | Real-time |
| Weather/storms | NASA EONET | Real-time |
| Conflict proximity | Conflict zones | Static + news |
| Piracy indicators | News keywords | Real-time |
| Sanctions impact | Config | Which ports blocked |
| Port delays | Inferred from AIS | Real-time |
**Implementation:**
```
1. Define route polylines with chokepoint waypoints
2. For each chokepoint, calculate: density_change + gap_rate + weather_alerts + conflict_distance
3. Weight by chokepoint criticality (Hormuz > Malacca > Panama for oil)
4. Sum to 0-100 risk score per route
5. Compare to 30-day baseline for trend
```
**UI:**
- Route lines on map colored by risk (green → yellow → red)
- Panel showing route rankings with trends
- Click route → show chokepoint breakdown
- Alert when route risk jumps 20+ points
---
### 4. Infrastructure Cascade Visualization
**What:** When you click any infrastructure asset, show what depends on it and what would be affected by its disruption.
**Why:** Critical infrastructure is interconnected. A submarine cable fault affects countries downstream. A pipeline disruption affects refineries and ports. Analysts need to see the "so what."
**Dependency Mappings:**
**Ports → dependent on:**
- Pipelines (oil/LNG terminals)
- Submarine cables (data for port operations)
- Nearby naval bases (protection)
- Chokepoints (access routes)
**Cables → serve:**
- Countries (list from cable data)
- Data centers (proximity)
- Financial centers (criticality)
**Pipelines → connect:**
- Origin countries
- Transit countries
- Destination ports/refineries
- Alternate routes
**Implementation:**
```
1. Build static dependency graph in config
2. For cables: map landing points to countries
3. For pipelines: map to origin/transit/destination
4. For ports: map to pipelines that terminate there
5. On asset click: traverse graph, highlight dependents on map
6. Show impact panel: "Disruption would affect: X countries, Y trade volume"
```
**Data Enhancement (Free):**
- **TeleGeography** submarine cable landing points (public)
- **Global Energy Monitor** pipeline database (public)
- **UN COMTRADE** for trade flow volumes (free API)
---
### 5. Temporal Anomaly Detection
**What:** Detect when current activity levels deviate significantly from historical norms for the same time period (day of week, month, season).
**Why:** "Unusual activity" only makes sense in context. Military flights on a Tuesday might be normal; the same level on a Sunday might be significant. Activity in December might be normal for end-of-year exercises but unusual in March.
**What to Track:**
| Data Type | Baseline Period | Anomaly Threshold |
|-----------|-----------------|-------------------|
| Military flights per region | Same weekday, 4-week rolling | Z > 2.0 |
| Naval vessels per chokepoint | Same weekday, 4-week rolling | Z > 2.0 |
| Protest count per country | Same month, 3-year average | Z > 1.5 |
| News velocity per topic | Same weekday, 4-week rolling | Z > 2.5 |
| AIS gaps per region | Same weekday, 4-week rolling | Z > 2.0 |
**Implementation:**
```
1. Store hourly/daily counts by category in IndexedDB
2. Maintain separate baselines by: weekday, month, region
3. On refresh: compare current to same-period baseline
4. Calculate Z-score accounting for seasonal patterns
5. Alert format: "Military flights in Baltic 3.2x normal for Tuesday"
```
**Example Alerts:**
> 📊 **Temporal Anomaly: Baltic Region**
> - Military flights: 47 (normal Tuesday avg: 15)
> - Z-score: 2.8 (highly unusual)
> - Last similar: March 2024 (NATO exercise)
> 📊 **Temporal Anomaly: Iran Protests**
> - Events this week: 23 (normal January avg: 8)
> - Z-score: 1.9 (elevated)
> - Note: Anniversary of 2023 protests approaching
---
## Additional Free Data Sources to Integrate
### Economic/Trade APIs (No Key Required)
| Source | Endpoint | Data | Rate Limit |
|--------|----------|------|------------|
| **World Bank API** | `api.worldbank.org/v2/` | 16,000+ indicators, GDP, trade, FDI | None |
| **IMF Data API** | `dataservices.imf.org/REST/SDMX_JSON.svc/` | IFS, trade flows, balance of payments | None |
| **UN Comtrade** | `comtradeapi.un.org/public/v1/` | Bilateral trade flows by HS code | 100/day free |
| **BIS Statistics** | `stats.bis.org/api/v1/` | Global liquidity, cross-border banking | None |
| **OECD Data** | `stats.oecd.org/SDMX-JSON/` | OECD country indicators | None |
### Food Security (Critical for Instability Correlation)
| Source | Endpoint | Data | Notes |
|--------|----------|------|-------|
| **FAO GIEWS RSS** | `fao.org/giews/english/shortnews/rss.xml` | Food price alerts, country briefs | Add to feeds.ts |
| **FAO Food Price Monitor** | `fpma.fao.org/giews/fpmat4/` | Real-time commodity prices | JSON API |
| **FAO STAT API** | `fenixservices.fao.org/faostat/api/v1/` | Food Price Index, production | REST |
### Sanctions Lists (Critical for Risk Scoring)
| Source | Endpoint | Data | Update Frequency |
|--------|----------|------|------------------|
| **OFAC SDN List** | `sanctionslistservice.ofac.treas.gov/api/` | US sanctions | Daily |
| **EU Sanctions** | `webgate.ec.europa.eu/fsd/fsf/public/files/` | EU restrictive measures | Weekly |
| **UN Sanctions** | `scsanctions.un.org/resources/xml/` | Al-Qaida, DPRK, Iran, etc. | Real-time |
| **OpenSanctions** | `api.opensanctions.org/` | Unified 100+ sources | Free tier: 1000/day |
### Migration/Humanitarian (Instability Indicators)
| Source | Endpoint | Data | Notes |
|--------|----------|------|-------|
| **UNHCR API** | `api.unhcr.org/` | Refugee populations, IDPs, asylum | No key |
| **IOM DTM** | `dtm.iom.int/` | Displacement tracking, migration flows | Free registration |
| **ReliefWeb API** | `api.reliefweb.int/v1/` | Humanitarian reports, disasters | No key |
| **INFORM Risk** | `drmkc.jrc.ec.europa.eu/inform-index/` | Hazard/vulnerability scores | CSV download |
### Think Tank RSS Feeds (Add to feeds.ts)
**Security/Defense:**
- RUSI: `rusi.org/rss.xml`
- Chatham House: `chathamhouse.org/rss.xml`
- ECFR: `ecfr.eu/feed/`
- CFR: `cfr.org/rss`
- Wilson Center: `wilsoncenter.org/rss.xml`
- GMF: `gmfus.org/feed`
- Stimson: `stimson.org/feed/`
- CNAS: `cnas.org/rss`
**Nuclear/Arms Control:**
- Arms Control Association: `armscontrol.org/rss/all`
- FAS: `fas.org/feed/`
- NTI: `nti.org/rss/`
- Bulletin of Atomic Scientists: `thebulletin.org/feed/`
**Regional:**
- Middle East Institute: `mei.edu/rss.xml`
- Lowy Institute (Asia-Pacific): `lowyinstitute.org/feed`
- EU ISS: `iss.europa.eu/rss.xml`
### Static Data (Annual/Quarterly Updates)
| Source | Data | Format | Use Case |
|--------|------|--------|----------|
| **SIPRI Arms Transfers** | Weapons exports by country | CSV | Military capability assessment |
| **SIPRI MILEX** | Military spending | CSV | Defense budget trends |
| **V-Dem** | 400+ democracy indicators | CSV | Governance quality |
| **Fragile States Index** | Country risk scores | CSV | Baseline instability |
| **Freedom House** | Democracy/freedom scores | CSV | Political environment |
| **Global Terrorism Database** | Historical incidents | Registration | Pattern analysis |
### Election Calendar (Static Config)
Maintain election calendar in `src/config/elections.ts`. When election date approaches:
- **30 days**: Add to "upcoming events" panel
- **7 days**: Boost country news correlation
- **1 day**: Increase instability index weighting
- **Election day**: Maximum alert sensitivity
```typescript
interface Election {
country: string;
countryCode: string;
type: 'presidential' | 'parliamentary' | 'referendum' | 'local';
date: Date;
significance: 'high' | 'medium' | 'low';
notes?: string;
}
```
---
## Implementation Priority
| Feature | Complexity | Impact | Priority |
|---------|------------|--------|----------|
| Multi-Signal Geographic Convergence | Medium | Very High | 1 |
| Country Instability Index | Medium | High | 2 |
| Temporal Anomaly Detection | Medium | High | 3 |
| Trade Route Risk Scoring | High | High | 4 |
| Infrastructure Cascade Viz | High | Medium | 5 |
**Recommended approach:** Implement features 1-3 first as they primarily leverage existing data with new correlation logic. Features 4-5 require additional data mapping and UI work.
---
## Technical Notes
### IndexedDB Schema Extensions
```typescript
interface TemporalBaseline {
type: 'military_flights' | 'vessels' | 'protests' | 'news' | 'ais_gaps';
region: string;
weekday: number; // 0-6
month: number; // 1-12
hourlyAvg: number[];
dailyAvg: number;
stdDev: number;
sampleCount: number;
lastUpdated: Date;
}
interface CountryRiskSnapshot {
countryCode: string;
timestamp: Date;
components: {
protests: number;
conflict: number;
sentiment: number;
velocity: number;
tension: number;
sanctions: number;
infrastructure: number;
};
index: number;
trend: 'rising' | 'stable' | 'falling';
}
interface GeographicCell {
lat: number;
lon: number;
eventTypes: Set<string>;
eventCount: number;
firstSeen: Date;
lastUpdated: Date;
}
```
### New Signal Types
```typescript
type SignalType =
// Existing
| 'prediction_leads_news'
| 'news_leads_markets'
| 'silent_divergence'
| 'velocity_spike'
| 'convergence'
| 'triangulation'
| 'flow_drop'
| 'flow_price_divergence'
// New
| 'geographic_convergence'
| 'country_risk_spike'
| 'trade_route_risk'
| 'temporal_anomaly'
| 'infrastructure_cascade';
```
---
## Conclusion
The most valuable enhancements for a geopolitical analyst focus on **correlation, not accumulation**. The dashboard already aggregates vast amounts of data; the next step is making that data talk to each other.
Priority 1 (Geographic Convergence) alone would significantly elevate the tool's I&W capability by detecting when multiple independent signals point to the same location—the hallmark of significant events.
All proposed features use **existing data sources** or **free APIs/RSS feeds**, keeping with the project's accessible, open-source philosophy.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

+1
View File
@@ -0,0 +1 @@
2.4.37
+53
View File
@@ -0,0 +1,53 @@
const statsByEndpoint = new Map();
const MAX_ENDPOINTS = 128;
const LOG_EVERY = Math.max(0, Number(process.env.CACHE_TELEMETRY_LOG_EVERY || 200));
function cleanupOldEndpoints() {
if (statsByEndpoint.size <= MAX_ENDPOINTS) return;
const entries = Array.from(statsByEndpoint.entries())
.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
const overflow = statsByEndpoint.size - MAX_ENDPOINTS;
for (let i = 0; i < overflow; i++) {
statsByEndpoint.delete(entries[i][0]);
}
}
export function recordCacheTelemetry(endpoint, outcome) {
if (!endpoint || !outcome) return;
const now = Date.now();
const current = statsByEndpoint.get(endpoint) || {
total: 0,
outcomes: {},
firstSeen: now,
lastSeen: now,
};
current.total += 1;
current.outcomes[outcome] = (current.outcomes[outcome] || 0) + 1;
current.lastSeen = now;
statsByEndpoint.set(endpoint, current);
cleanupOldEndpoints();
if (LOG_EVERY > 0 && current.total % LOG_EVERY === 0) {
console.log(`[CacheTelemetry] ${endpoint} total=${current.total} outcomes=${JSON.stringify(current.outcomes)}`);
}
}
export function getCacheTelemetrySnapshot() {
const endpoints = Array.from(statsByEndpoint.entries())
.sort((a, b) => b[1].lastSeen - a[1].lastSeen)
.map(([endpoint, stats]) => ({
endpoint,
total: stats.total,
outcomes: stats.outcomes,
firstSeen: new Date(stats.firstSeen).toISOString(),
lastSeen: new Date(stats.lastSeen).toISOString(),
}));
return {
generatedAt: new Date().toISOString(),
endpointCount: endpoints.length,
endpoints,
note: 'In-memory per instance telemetry (resets on cold start).',
};
}
+32
View File
@@ -0,0 +1,32 @@
const ALLOWED_ORIGIN_PATTERNS = [
/^https:\/\/(.*\.)?worldmonitor\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+-elie-habib-projects\.vercel\.app$/,
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
/^https?:\/\/tauri\.localhost(:\d+)?$/,
/^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
/^tauri:\/\/localhost$/,
/^asset:\/\/localhost$/,
];
function isAllowedOrigin(origin) {
return Boolean(origin) && ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
}
export function getCorsHeaders(req, methods = 'GET, OPTIONS') {
const origin = req.headers.get('origin') || '';
const allowOrigin = isAllowedOrigin(origin) ? origin : 'https://worldmonitor.app';
return {
'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Methods': methods,
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
};
}
export function isDisallowedOrigin(req) {
const origin = req.headers.get('origin');
if (!origin) return false;
return !isAllowedOrigin(origin);
}
+40
View File
@@ -0,0 +1,40 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
function makeRequest(origin) {
const headers = new Headers();
if (origin !== null) {
headers.set('origin', origin);
}
return new Request('https://worldmonitor.app/api/test', { headers });
}
test('allows desktop Tauri origins', () => {
const origins = [
'https://tauri.localhost',
'https://abc123.tauri.localhost',
'tauri://localhost',
'asset://localhost',
'http://127.0.0.1:46123',
];
for (const origin of origins) {
const req = makeRequest(origin);
assert.equal(isDisallowedOrigin(req), false, `origin should be allowed: ${origin}`);
const cors = getCorsHeaders(req);
assert.equal(cors['Access-Control-Allow-Origin'], origin);
}
});
test('rejects unrelated external origins', () => {
const req = makeRequest('https://evil.example.com');
assert.equal(isDisallowedOrigin(req), true);
const cors = getCorsHeaders(req);
assert.equal(cors['Access-Control-Allow-Origin'], 'https://worldmonitor.app');
});
test('requests without origin remain allowed', () => {
const req = makeRequest(null);
assert.equal(isDisallowedOrigin(req), false);
});
+61
View File
@@ -0,0 +1,61 @@
export function createIpRateLimiter({
limit,
windowMs,
maxEntries = 5000,
cleanupIntervalMs = 30 * 1000,
}) {
const records = new Map();
let lastCleanupAt = 0;
function cleanup(now) {
if (now - lastCleanupAt < cleanupIntervalMs && records.size <= maxEntries) {
return;
}
lastCleanupAt = now;
const cutoff = now - windowMs;
for (const [ip, record] of records) {
if (record.windowStart < cutoff) {
records.delete(ip);
}
}
if (records.size <= maxEntries) {
return;
}
const overflow = records.size - maxEntries;
const oldest = Array.from(records.entries())
.sort((a, b) => a[1].windowStart - b[1].windowStart);
for (let i = 0; i < overflow; i++) {
const entry = oldest[i];
if (!entry) break;
records.delete(entry[0]);
}
}
function check(ip) {
const now = Date.now();
cleanup(now);
const key = (ip || 'unknown').trim() || 'unknown';
const record = records.get(key);
if (!record || now - record.windowStart > windowMs) {
records.set(key, { count: 1, windowStart: now });
return true;
}
if (record.count >= limit) {
return false;
}
record.count += 1;
return true;
}
return {
check,
size: () => records.size,
};
}
+185
View File
@@ -0,0 +1,185 @@
const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar');
// ── In-memory cache (desktop/sidecar) ──
const mem = new Map();
let persistPath = null;
let persistTimer = null;
let persistInFlight = false;
let persistQueued = false;
let loaded = false;
const MAX_PERSIST_ENTRIES = Math.max(100, Number(process.env.LOCAL_API_CACHE_PERSIST_MAX || 5000));
async function ensureDesktopCache() {
if (loaded) return;
loaded = true;
try {
const { join } = await import('node:path');
const { readFileSync } = await import('node:fs');
const dir = process.env.LOCAL_API_RESOURCE_DIR || '.';
persistPath = join(dir, 'api-cache.json');
const data = JSON.parse(readFileSync(persistPath, 'utf8'));
const now = Date.now();
for (const [k, entry] of Object.entries(data)) {
if (entry.expiresAt > now) mem.set(k, entry);
}
console.log(`[Cache] Loaded ${mem.size} entries from disk`);
} catch {
// File doesn't exist yet
}
setInterval(() => {
const now = Date.now();
for (const [k, v] of mem) {
if (v.expiresAt <= now) mem.delete(k);
}
}, 60_000).unref?.();
}
function buildPersistSnapshot() {
const now = Date.now();
const payload = Object.create(null);
let kept = 0;
for (const [key, entry] of mem) {
if (!entry || entry.expiresAt <= now) continue;
payload[key] = entry;
kept += 1;
if (kept >= MAX_PERSIST_ENTRIES) break;
}
return payload;
}
async function persistToDisk() {
if (!persistPath) return;
if (persistInFlight) {
persistQueued = true;
return;
}
persistInFlight = true;
try {
const snapshot = buildPersistSnapshot();
const json = JSON.stringify(snapshot);
const { writeFile, rename } = await import('node:fs/promises');
const tmp = persistPath + '.tmp';
await writeFile(tmp, json, 'utf8');
await rename(tmp, persistPath);
} catch (err) {
console.warn('[Cache] Persist error:', err.message);
} finally {
persistInFlight = false;
if (persistQueued) {
persistQueued = false;
void persistToDisk();
}
}
}
function debouncedPersist() {
if (!persistPath) return;
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
void persistToDisk();
}, 2000);
if (persistTimer?.unref) persistTimer.unref();
}
// ── Redis (cloud/Vercel) ──
let RedisClass = null;
let redis = null;
let redisInitFailed = false;
export async function getRedis() {
if (isSidecar) return null;
if (redis) return redis;
if (redisInitFailed) return null;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
try {
if (!RedisClass) {
const mod = await import('@upstash/redis');
RedisClass = mod.Redis;
}
redis = new RedisClass({ url, token });
return redis;
} catch (err) {
redisInitFailed = true;
console.warn('[Cache] Redis init failed:', err.message);
return null;
}
}
// ── Shared API ──
export async function getCachedJson(key) {
if (isSidecar) {
await ensureDesktopCache();
const entry = mem.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
mem.delete(key);
return null;
}
return entry.value;
}
const r = await getRedis();
if (!r) return null;
try {
return await r.get(key);
} catch (err) {
console.warn('[Cache] Read failed:', err.message);
return null;
}
}
export async function setCachedJson(key, value, ttlSeconds) {
if (isSidecar) {
await ensureDesktopCache();
mem.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
debouncedPersist();
return true;
}
const r = await getRedis();
if (!r) return false;
try {
await r.set(key, value, { ex: ttlSeconds });
return true;
} catch (err) {
console.warn('[Cache] Write failed:', err.message);
return false;
}
}
export async function mget(...keys) {
if (isSidecar) {
await ensureDesktopCache();
const now = Date.now();
return keys.map(k => {
const entry = mem.get(k);
if (!entry || entry.expiresAt <= now) return null;
return entry.value;
});
}
const r = await getRedis();
if (!r) return keys.map(() => null);
try {
return await r.mget(...keys);
} catch (err) {
console.warn('[Cache] mget failed:', err.message);
return keys.map(() => null);
}
}
export function hashString(input) {
let hash = 5381;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) + hash) + input.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
+188
View File
@@ -0,0 +1,188 @@
// ACLED Conflict Events API proxy - battles, explosions, violence against civilians
// Separate from protest proxy to avoid mixing data flows
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
export const config = { runtime: 'edge' };
const CACHE_KEY = 'acled:conflict:v2';
const CACHE_TTL_SECONDS = 10 * 60;
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
let fallbackCache = { data: null, timestamp: 0 };
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 1000;
const rateLimiter = createIpRateLimiter({
limit: RATE_LIMIT,
windowMs: RATE_WINDOW_MS,
maxEntries: 5000,
});
function getClientIp(req) {
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed', data: [] }, {
status: 405,
headers: corsHeaders,
});
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed', data: [] }, {
status: 403,
headers: corsHeaders,
});
}
const ip = getClientIp(req);
if (!rateLimiter.check(ip)) {
return Response.json({ error: 'Rate limited', data: [] }, {
status: 429,
headers: {
...corsHeaders,
'Retry-After': '60',
},
});
}
const token = process.env.ACLED_ACCESS_TOKEN;
if (!token) {
return Response.json({ error: 'ACLED not configured', data: [], configured: false }, {
status: 200,
headers: corsHeaders,
});
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (cached && typeof cached === 'object' && Array.isArray(cached.data)) {
recordCacheTelemetry('/api/acled-conflict', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'REDIS-HIT',
},
});
}
if (fallbackCache.data && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/acled-conflict', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'MEMORY-HIT',
},
});
}
try {
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const params = new URLSearchParams({
event_type: 'Battles|Explosions/Remote violence|Violence against civilians',
event_date: `${startDate}|${endDate}`,
event_date_where: 'BETWEEN',
limit: '500',
_format: 'json',
});
const response = await fetch(`https://acleddata.com/api/acled/read?${params}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const text = await response.text();
return Response.json({ error: `ACLED API error: ${response.status}`, details: text.substring(0, 200), data: [] }, {
status: response.status,
headers: corsHeaders,
});
}
const rawData = await response.json();
const events = Array.isArray(rawData?.data) ? rawData.data : [];
const sanitizedEvents = events.map((e) => ({
event_id_cnty: e.event_id_cnty,
event_date: e.event_date,
event_type: e.event_type,
sub_event_type: e.sub_event_type,
actor1: e.actor1,
actor2: e.actor2,
country: e.country,
admin1: e.admin1,
location: e.location,
latitude: e.latitude,
longitude: e.longitude,
fatalities: e.fatalities,
notes: typeof e.notes === 'string' ? e.notes.substring(0, 500) : undefined,
source: e.source,
tags: e.tags,
}));
const result = {
success: true,
count: sanitizedEvents.length,
data: sanitizedEvents,
cached_at: new Date().toISOString(),
};
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/acled-conflict', 'MISS');
return Response.json(result, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'MISS',
},
});
} catch (error) {
if (fallbackCache.data) {
recordCacheTelemetry('/api/acled-conflict', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
'X-Cache': 'STALE',
},
});
}
recordCacheTelemetry('/api/acled-conflict', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, data: [] }, {
status: 500,
headers: corsHeaders,
});
}
}
+97 -53
View File
@@ -1,82 +1,114 @@
// ACLED API proxy - keeps token server-side only
// Token is stored in ACLED_ACCESS_TOKEN (no VITE_ prefix)
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
export const config = { runtime: 'edge' };
// In-memory cache (edge function instance)
let cache = { data: null, timestamp: 0 };
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
const CACHE_KEY = 'acled:protests:v2';
const CACHE_TTL_SECONDS = 10 * 60;
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
// In-memory fallback cache when Redis is unavailable.
let fallbackCache = { data: null, timestamp: 0 };
// Rate limiting - track requests per IP
const rateLimits = new Map();
const RATE_LIMIT = 10; // requests per minute
const RATE_WINDOW = 60 * 1000;
const RATE_WINDOW_MS = 60 * 1000;
const rateLimiter = createIpRateLimiter({
limit: RATE_LIMIT,
windowMs: RATE_WINDOW_MS,
maxEntries: 5000,
});
function checkRateLimit(ip) {
const now = Date.now();
const record = rateLimits.get(ip);
function getClientIp(req) {
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
}
if (!record || now - record.windowStart > RATE_WINDOW) {
rateLimits.set(ip, { count: 1, windowStart: now });
return true;
}
if (record.count >= RATE_LIMIT) {
return false;
}
record.count++;
return true;
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
export default async function handler(req) {
// Get client IP for rate limiting
const ip = req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
// Check rate limit
if (!checkRateLimit(ip)) {
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed', data: [] }, {
status: 405,
headers: corsHeaders,
});
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed', data: [] }, {
status: 403,
headers: corsHeaders,
});
}
const ip = getClientIp(req);
if (!rateLimiter.check(ip)) {
return Response.json({ error: 'Rate limited', data: [] }, {
status: 429,
headers: {
'Access-Control-Allow-Origin': '*',
...corsHeaders,
'Retry-After': '60',
},
});
}
// Get token from server-side env (no VITE_ prefix)
const token = process.env.ACLED_ACCESS_TOKEN;
if (!token) {
return Response.json({
error: 'ACLED not configured',
data: [],
configured: false
configured: false,
}, {
status: 200,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: corsHeaders,
});
}
// Check cache
const now = Date.now();
if (cache.data && now - cache.timestamp < CACHE_TTL) {
return Response.json(cache.data, {
const cached = await getCachedJson(CACHE_KEY);
if (cached && typeof cached === 'object' && Array.isArray(cached.data)) {
recordCacheTelemetry('/api/acled', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
'X-Cache': 'HIT',
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'REDIS-HIT',
},
});
}
if (fallbackCache.data && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/acled', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'MEMORY-HIT',
},
});
}
try {
// Calculate date range (last 30 days)
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const params = new URLSearchParams({
event_type: 'Protests',
event_date: `${startDate}|${endDate}`,
@@ -87,8 +119,8 @@ export default async function handler(req) {
const response = await fetch(`https://acleddata.com/api/acled/read?${params}`, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${token}`,
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
@@ -100,15 +132,13 @@ export default async function handler(req) {
data: [],
}, {
status: response.status,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: corsHeaders,
});
}
const rawData = await response.json();
const events = rawData.data || [];
// Return only needed fields to reduce payload and protect any sensitive data
const sanitizedEvents = events.map(e => ({
const events = Array.isArray(rawData?.data) ? rawData.data : [];
const sanitizedEvents = events.map((e) => ({
event_id_cnty: e.event_id_cnty,
event_date: e.event_date,
event_type: e.event_type,
@@ -121,7 +151,7 @@ export default async function handler(req) {
latitude: e.latitude,
longitude: e.longitude,
fatalities: e.fatalities,
notes: e.notes?.substring(0, 500), // Truncate long notes
notes: typeof e.notes === 'string' ? e.notes.substring(0, 500) : undefined,
source: e.source,
tags: e.tags,
}));
@@ -133,24 +163,38 @@ export default async function handler(req) {
cached_at: new Date().toISOString(),
};
// Update cache
cache = { data: result, timestamp: now };
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/acled', 'MISS');
return Response.json(result, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
...corsHeaders,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'MISS',
},
});
} catch (error) {
if (fallbackCache.data) {
recordCacheTelemetry('/api/acled', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
'X-Cache': 'STALE',
},
});
}
recordCacheTelemetry('/api/acled', 'ERROR');
return Response.json({
error: `Fetch failed: ${error.message}`,
error: `Fetch failed: ${toErrorMessage(error)}`,
data: [],
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: corsHeaders,
});
}
}
+206
View File
@@ -0,0 +1,206 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
const CACHE_TTL_SECONDS = 8;
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
const CACHE_VERSION = 'v1';
const MEMORY_CACHE_MAX_ENTRIES = 8;
const MEMORY_FALLBACK_MAX_AGE_MS = 60 * 1000;
const memoryCache = new Map();
const inFlightByKey = new Map();
function getErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'Failed to fetch AIS snapshot');
}
function getMemoryCachedSnapshot(cacheKey, allowStale = false) {
const entry = memoryCache.get(cacheKey);
if (!entry) return null;
const now = Date.now();
const age = now - entry.timestamp;
if (age > MEMORY_FALLBACK_MAX_AGE_MS) {
memoryCache.delete(cacheKey);
return null;
}
if (!allowStale && age > CACHE_TTL_MS) {
return null;
}
entry.lastSeen = now;
return entry.data;
}
function setMemoryCachedSnapshot(cacheKey, data) {
const now = Date.now();
memoryCache.set(cacheKey, {
data,
timestamp: now,
lastSeen: now,
});
if (memoryCache.size <= MEMORY_CACHE_MAX_ENTRIES) return;
const overflow = memoryCache.size - MEMORY_CACHE_MAX_ENTRIES;
const oldestEntries = Array.from(memoryCache.entries())
.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
for (let i = 0; i < overflow; i++) {
const entry = oldestEntries[i];
if (!entry) break;
memoryCache.delete(entry[0]);
}
}
function getRelayBaseUrl() {
const relayUrl = process.env.WS_RELAY_URL;
if (!relayUrl) return null;
return relayUrl
.replace('wss://', 'https://')
.replace('ws://', 'http://')
.replace(/\/$/, '');
}
function isValidSnapshot(data) {
return Boolean(
data &&
typeof data === 'object' &&
data.status &&
typeof data.status === 'object' &&
Array.isArray(data.disruptions) &&
Array.isArray(data.density)
);
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const requestUrl = new URL(req.url);
const includeCandidates = requestUrl.searchParams.get('candidates') === 'true';
const cacheKey = `ais-snapshot:${CACHE_VERSION}:${includeCandidates ? 'full' : 'lite'}`;
const redisCached = await getCachedJson(cacheKey);
if (isValidSnapshot(redisCached)) {
setMemoryCachedSnapshot(cacheKey, redisCached);
recordCacheTelemetry('/api/ais-snapshot', 'REDIS-HIT');
return new Response(JSON.stringify(redisCached), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=${CACHE_TTL_SECONDS}, stale-while-revalidate=5`,
'X-Cache': 'REDIS-HIT',
...corsHeaders,
},
});
}
const memoryCached = getMemoryCachedSnapshot(cacheKey);
if (isValidSnapshot(memoryCached)) {
recordCacheTelemetry('/api/ais-snapshot', 'MEMORY-HIT');
return new Response(JSON.stringify(memoryCached), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=${CACHE_TTL_SECONDS}, stale-while-revalidate=5`,
'X-Cache': 'MEMORY-HIT',
...corsHeaders,
},
});
}
const relayBaseUrl = getRelayBaseUrl();
if (!relayBaseUrl) {
recordCacheTelemetry('/api/ais-snapshot', 'NO-RELAY-CONFIG');
return new Response(JSON.stringify({ vessels: [], skipped: true, reason: 'AIS relay not configured' }), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
try {
let requestPromise = inFlightByKey.get(cacheKey);
if (!requestPromise) {
requestPromise = (async () => {
const upstreamUrl = `${relayBaseUrl}/ais/snapshot?candidates=${includeCandidates ? 'true' : 'false'}`;
const response = await fetch(upstreamUrl, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(`AIS relay HTTP ${response.status}`);
}
const data = await response.json();
if (!isValidSnapshot(data)) {
throw new Error('Invalid AIS snapshot payload');
}
return data;
})();
inFlightByKey.set(cacheKey, requestPromise);
}
const data = await requestPromise;
if (!isValidSnapshot(data)) {
throw new Error('Invalid AIS snapshot payload');
}
setMemoryCachedSnapshot(cacheKey, data);
void setCachedJson(cacheKey, data, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/ais-snapshot', 'MISS');
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=${CACHE_TTL_SECONDS}, stale-while-revalidate=5`,
'X-Cache': 'MISS',
...corsHeaders,
},
});
} catch (error) {
const staleMemory = getMemoryCachedSnapshot(cacheKey, true);
if (isValidSnapshot(staleMemory)) {
recordCacheTelemetry('/api/ais-snapshot', 'MEMORY-ERROR-FALLBACK');
return new Response(JSON.stringify(staleMemory), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}, s-maxage=${CACHE_TTL_SECONDS}, stale-while-revalidate=5`,
'X-Cache': 'MEMORY-ERROR-FALLBACK',
...corsHeaders,
},
});
}
recordCacheTelemetry('/api/ais-snapshot', 'ERROR');
return new Response(JSON.stringify({ error: getErrorMessage(error) }), {
status: 502,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} finally {
inFlightByKey.delete(cacheKey);
}
}
+9 -4
View File
@@ -1,8 +1,13 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Fetch AI/ML papers from ArXiv
// Categories: cs.AI, cs.LG (Machine Learning), cs.CL (Computation and Language)
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category') || 'cs.AI'; // cs.AI, cs.LG, cs.CL
@@ -12,7 +17,7 @@ export default async function handler(request) {
// ArXiv API search query
// Search for papers in specified category, sorted by date
const query = `cat:${category}`;
const apiUrl = `http://export.arxiv.org/api/query?search_query=${encodeURIComponent(query)}&start=0&max_results=${maxResults}&sortBy=${sortBy}&sortOrder=descending`;
const apiUrl = `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(query)}&start=0&max_results=${maxResults}&sortBy=${sortBy}&sortOrder=descending`;
const response = await fetch(apiUrl, {
headers: {
@@ -32,8 +37,8 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=3600', // 1 hour cache
...cors,
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', // 1 hour cache
},
});
} catch (error) {
@@ -46,7 +51,7 @@ export default async function handler(request) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
...cors
},
}
);
+38
View File
@@ -0,0 +1,38 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCacheTelemetrySnapshot } from './_cache-telemetry.js';
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
return new Response(JSON.stringify(getCacheTelemetrySnapshot()), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...corsHeaders,
},
});
}
+198
View File
@@ -0,0 +1,198 @@
import { getCachedJson, setCachedJson, mget, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions';
const MODEL = 'llama-3.1-8b-instant';
const CACHE_TTL_SECONDS = 86400;
const CACHE_VERSION = 'v1';
const MAX_BATCH_SIZE = 20;
const VALID_LEVELS = ['critical', 'high', 'medium', 'low', 'info'];
const VALID_CATEGORIES = [
'conflict', 'protest', 'disaster', 'diplomatic', 'economic',
'terrorism', 'cyber', 'health', 'environmental', 'military',
'crime', 'infrastructure', 'tech', 'general',
];
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const apiKey = process.env.GROQ_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ results: [], fallback: true, skipped: true, reason: 'GROQ_API_KEY not configured' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { titles, variant = 'full' } = body;
if (!Array.isArray(titles) || titles.length === 0) {
return new Response(JSON.stringify({ error: 'titles array required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const batch = titles.slice(0, MAX_BATCH_SIZE);
const results = new Array(batch.length).fill(null);
const uncachedIndices = [];
const cacheKeys = batch.map(
(t) => `classify:${CACHE_VERSION}:${hashString(t.toLowerCase() + ':' + variant)}`
);
const cached = await mget(...cacheKeys);
for (let i = 0; i < cached.length; i++) {
const val = cached[i];
if (val && typeof val === 'object' && val.level) {
results[i] = { level: val.level, category: val.category, cached: true };
} else {
uncachedIndices.push(i);
}
}
if (uncachedIndices.length === 0) {
return new Response(JSON.stringify({ results }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
const uncachedTitles = uncachedIndices.map((i) => batch[i]);
const isTech = variant === 'tech';
const numberedList = uncachedTitles.map((t, i) => `${i + 1}. ${t}`).join('\n');
const systemPrompt = `You classify news headlines into threat level and category. Return ONLY a valid JSON array, no other text.
Levels: critical, high, medium, low, info
Categories: conflict, protest, disaster, diplomatic, economic, terrorism, cyber, health, environmental, military, crime, infrastructure, tech, general
${isTech ? 'Focus: technology, startups, AI, cybersecurity. Most tech news is "low" or "info" unless it involves outages, breaches, or major disruptions.' : 'Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact.'}
Return a JSON array with one object per headline in order: [{"level":"...","category":"..."},...]`;
try {
const response = await fetch(GROQ_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: numberedList },
],
temperature: 0,
max_tokens: uncachedTitles.length * 60,
}),
});
if (!response.ok) {
console.error('[ClassifyBatch] Groq error:', response.status);
return new Response(JSON.stringify({ results, fallback: true }), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
}
const data = await response.json();
const raw = data.choices?.[0]?.message?.content?.trim();
if (!raw) {
return new Response(JSON.stringify({ results, fallback: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
const match = raw.match(/\[[\s\S]*\]/);
if (match) {
try { parsed = JSON.parse(match[0]); } catch { /* fall through */ }
}
}
if (!Array.isArray(parsed)) {
return new Response(JSON.stringify({ results, fallback: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const cacheWrites = [];
for (let i = 0; i < uncachedIndices.length; i++) {
const classification = parsed[i];
if (!classification) continue;
const level = VALID_LEVELS.includes(classification.level) ? classification.level : null;
const category = VALID_CATEGORIES.includes(classification.category) ? classification.category : null;
if (!level || !category) continue;
const idx = uncachedIndices[i];
results[idx] = { level, category, cached: false };
const cacheKey = `classify:${CACHE_VERSION}:${hashString(batch[idx].toLowerCase() + ':' + variant)}`;
cacheWrites.push(
setCachedJson(cacheKey, { level, category, timestamp: Date.now() }, CACHE_TTL_SECONDS)
);
}
if (cacheWrites.length > 0) {
await Promise.allSettled(cacheWrites);
}
return new Response(JSON.stringify({ results }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
} catch (error) {
console.error('[ClassifyBatch] Error:', error.message);
return new Response(JSON.stringify({ results, fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
+164
View File
@@ -0,0 +1,164 @@
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions';
const MODEL = 'llama-3.1-8b-instant';
const CACHE_TTL_SECONDS = 86400;
const CACHE_VERSION = 'v1';
const VALID_LEVELS = ['critical', 'high', 'medium', 'low', 'info'];
const VALID_CATEGORIES = [
'conflict', 'protest', 'disaster', 'diplomatic', 'economic',
'terrorism', 'cyber', 'health', 'environmental', 'military',
'crime', 'infrastructure', 'tech', 'general',
];
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'GET, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const apiKey = process.env.GROQ_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ fallback: true, skipped: true, reason: 'GROQ_API_KEY not configured' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const url = new URL(request.url);
const title = url.searchParams.get('title');
const variant = url.searchParams.get('variant') || 'full';
if (!title) {
return new Response(JSON.stringify({ error: 'title param required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const cacheKey = `classify:${CACHE_VERSION}:${hashString(title.toLowerCase() + ':' + variant)}`;
try {
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.level) {
return new Response(JSON.stringify({
level: cached.level,
category: cached.category,
confidence: 0.9,
source: 'llm',
cached: true,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const isTech = variant === 'tech';
const systemPrompt = `You classify news headlines into threat level and category. Return ONLY valid JSON, no other text.
Levels: critical, high, medium, low, info
Categories: conflict, protest, disaster, diplomatic, economic, terrorism, cyber, health, environmental, military, crime, infrastructure, tech, general
${isTech ? 'Focus: technology, startups, AI, cybersecurity. Most tech news is "low" or "info" unless it involves outages, breaches, or major disruptions.' : 'Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact.'}
Return: {"level":"...","category":"..."}`;
const response = await fetch(GROQ_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: title },
],
temperature: 0,
max_tokens: 50,
}),
});
if (!response.ok) {
console.error('[Classify] Groq error:', response.status);
return new Response(JSON.stringify({ fallback: true }), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
}
const data = await response.json();
const raw = data.choices?.[0]?.message?.content?.trim();
if (!raw) {
return new Response(JSON.stringify({ fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
console.warn('[Classify] Invalid JSON from LLM:', raw);
return new Response(JSON.stringify({ fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const level = VALID_LEVELS.includes(parsed.level) ? parsed.level : null;
const category = VALID_CATEGORIES.includes(parsed.category) ? parsed.category : null;
if (!level || !category) {
return new Response(JSON.stringify({ fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
await setCachedJson(cacheKey, { level, category, timestamp: Date.now() }, CACHE_TTL_SECONDS);
return new Response(JSON.stringify({
level,
category,
confidence: 0.9,
source: 'llm',
cached: false,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600',
},
});
} catch (error) {
console.error('[Classify] Error:', error.message);
return new Response(JSON.stringify({ fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
+206
View File
@@ -0,0 +1,206 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
export const config = { runtime: 'edge' };
const CACHE_KEY = 'climate:anomalies:v1';
const CACHE_TTL_SECONDS = 6 * 60 * 60;
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
let fallbackCache = { data: null, timestamp: 0 };
const rateLimiter = createIpRateLimiter({
limit: 15,
windowMs: 60 * 1000,
maxEntries: 5000,
});
function getClientIp(req) {
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
function isValidResult(data) {
return Boolean(data && typeof data === 'object' && Array.isArray(data.anomalies));
}
const MONITORED_ZONES = [
{ name: 'Ukraine', lat: 48.4, lon: 31.2 },
{ name: 'Middle East', lat: 33.0, lon: 44.0 },
{ name: 'Sahel', lat: 14.0, lon: 0.0 },
{ name: 'Horn of Africa', lat: 8.0, lon: 42.0 },
{ name: 'South Asia', lat: 25.0, lon: 78.0 },
{ name: 'California', lat: 36.8, lon: -119.4 },
{ name: 'Amazon', lat: -3.4, lon: -60.0 },
{ name: 'Australia', lat: -25.0, lon: 134.0 },
{ name: 'Mediterranean', lat: 38.0, lon: 20.0 },
{ name: 'Taiwan Strait', lat: 24.0, lon: 120.0 },
{ name: 'Myanmar', lat: 19.8, lon: 96.7 },
{ name: 'Central Africa', lat: 4.0, lon: 22.0 },
{ name: 'Southern Africa', lat: -25.0, lon: 28.0 },
{ name: 'Central Asia', lat: 42.0, lon: 65.0 },
{ name: 'Caribbean', lat: 19.0, lon: -72.0 },
];
function classifySeverity(tempDelta, precipDelta) {
const absTemp = Math.abs(tempDelta);
const absPrecip = Math.abs(precipDelta);
if (absTemp >= 5 || absPrecip >= 80) return 'extreme';
if (absTemp >= 3 || absPrecip >= 40) return 'moderate';
return 'normal';
}
function classifyType(tempDelta, precipDelta) {
const absTemp = Math.abs(tempDelta);
const absPrecip = Math.abs(precipDelta);
if (absTemp >= absPrecip / 20) {
if (tempDelta > 0 && precipDelta < -20) return 'mixed';
if (tempDelta > 3) return 'warm';
if (tempDelta < -3) return 'cold';
}
if (precipDelta > 40) return 'wet';
if (precipDelta < -40) return 'dry';
if (tempDelta > 0) return 'warm';
return 'cold';
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) return new Response(null, { status: 403, headers: corsHeaders });
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed' }, { status: 403, headers: corsHeaders });
}
const ip = getClientIp(req);
if (!rateLimiter.check(ip)) {
return Response.json({ error: 'Rate limited' }, {
status: 429, headers: { ...corsHeaders, 'Retry-After': '60' },
});
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (isValidResult(cached)) {
recordCacheTelemetry('/api/climate-anomalies', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'REDIS-HIT' },
});
}
if (isValidResult(fallbackCache.data) && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/climate-anomalies', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'MEMORY-HIT' },
});
}
try {
const endDate = new Date();
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const start = startDate.toISOString().split('T')[0];
const end = endDate.toISOString().split('T')[0];
const fetchZone = async (zone) => {
try {
const params = new URLSearchParams({
latitude: String(zone.lat),
longitude: String(zone.lon),
start_date: start,
end_date: end,
daily: 'temperature_2m_mean,precipitation_sum',
timezone: 'UTC',
});
const resp = await fetch(`https://archive-api.open-meteo.com/v1/archive?${params}`, {
headers: { Accept: 'application/json' },
});
if (!resp.ok) return null;
const data = await resp.json();
const temps = data.daily?.temperature_2m_mean || [];
const precips = data.daily?.precipitation_sum || [];
if (temps.length < 14) return null;
const validTemps = temps.filter(t => t !== null);
const validPrecips = precips.filter(p => p !== null);
const last7Temps = validTemps.slice(-7);
const baseline30Temps = validTemps.slice(0, -7);
const last7Precips = validPrecips.slice(-7);
const baseline30Precips = validPrecips.slice(0, -7);
const avg = arr => arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
const tempDelta = avg(last7Temps) - avg(baseline30Temps);
const precipDelta = avg(last7Precips) - avg(baseline30Precips);
const severity = classifySeverity(tempDelta, precipDelta);
return {
zone: zone.name,
lat: zone.lat,
lon: zone.lon,
tempDelta: Math.round(tempDelta * 10) / 10,
precipDelta: Math.round(precipDelta * 10) / 10,
severity,
type: classifyType(tempDelta, precipDelta),
period: `${start} to ${end}`,
};
} catch {
return null;
}
};
const results = await Promise.allSettled(MONITORED_ZONES.map(fetchZone));
const anomalies = results
.filter(r => r.status === 'fulfilled' && r.value)
.map(r => r.value);
const result = {
success: true,
anomalies,
timestamp: new Date().toISOString(),
};
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/climate-anomalies', 'MISS');
return Response.json(result, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'MISS' },
});
} catch (error) {
if (isValidResult(fallbackCache.data)) {
recordCacheTelemetry('/api/climate-anomalies', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=600, s-maxage=600, stale-while-revalidate=120', 'X-Cache': 'STALE' },
});
}
recordCacheTelemetry('/api/climate-anomalies', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, anomalies: [] }, {
status: 500, headers: corsHeaders,
});
}
}
+34 -4
View File
@@ -1,16 +1,46 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
function clampLimit(rawLimit) {
const parsed = Number.parseInt(rawLimit || '', 10);
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(100, parsed));
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const url = new URL(req.url);
const dateRange = url.searchParams.get('dateRange') || '7d';
const limit = url.searchParams.get('limit') || '50';
const limit = clampLimit(url.searchParams.get('limit'));
const token = process.env.CLOUDFLARE_API_TOKEN;
if (!token) {
// Signal to client that outages feature is not configured
return new Response(JSON.stringify({ configured: false }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
@@ -22,13 +52,13 @@ export default async function handler(req) {
const data = await response.text();
return new Response(data, {
status: response.status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=120, s-maxage=120, stale-while-revalidate=60', ...corsHeaders },
});
} catch (error) {
// Return empty result on error so client circuit breaker doesn't trigger unnecessarily
return new Response(JSON.stringify({ success: true, result: { annotations: [] } }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
+86 -40
View File
@@ -1,12 +1,20 @@
export const config = { runtime: 'edge' };
import { getCachedJson, hashString, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const ALLOWED_CURRENCIES = ['usd', 'eur', 'gbp', 'jpy', 'cny', 'btc', 'eth'];
const MAX_COIN_IDS = 20;
const COIN_ID_PATTERN = /^[a-z0-9-]+$/;
// Simple in-memory cache for edge function (reset on cold start)
let cache = { data: null, timestamp: 0 };
const CACHE_TTL = 120 * 1000; // 2 minutes
const CACHE_TTL_SECONDS = 120; // 2 minutes
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
const RESPONSE_CACHE_CONTROL = 'public, max-age=120, s-maxage=120, stale-while-revalidate=60';
const CACHE_VERSION = 'v2';
// In-memory fallback cache for the current instance.
let fallbackCache = { key: '', payload: null, timestamp: 0 };
function validateCoinIds(idsParam) {
if (!idsParam) return 'bitcoin,ethereum,solana';
@@ -29,29 +37,69 @@ function validateBoolean(val, defaultVal) {
return defaultVal;
}
function getHeaders(cors, xCache, cacheControl = RESPONSE_CACHE_CONTROL) {
return {
'Content-Type': 'application/json',
...cors,
'Cache-Control': cacheControl,
'X-Cache': xCache,
};
}
function isValidPayload(payload) {
return Boolean(
payload &&
typeof payload === 'object' &&
typeof payload.body === 'string' &&
Number.isFinite(payload.status)
);
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const ids = validateCoinIds(url.searchParams.get('ids'));
const vsCurrencies = validateCurrency(url.searchParams.get('vs_currencies'));
const include24hrChange = validateBoolean(url.searchParams.get('include_24hr_change'), 'true');
// Return cached data if fresh
const now = Date.now();
const cacheKey = `${ids}:${vsCurrencies}:${include24hrChange}`;
if (cache.data && cache.key === cacheKey && Date.now() - cache.timestamp < CACHE_TTL) {
return new Response(cache.data, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=120, stale-while-revalidate=60',
'X-Cache': 'HIT',
},
const redisKey = `coingecko:${CACHE_VERSION}:${hashString(cacheKey)}`;
const redisCached = await getCachedJson(redisKey);
if (isValidPayload(redisCached)) {
recordCacheTelemetry('/api/coingecko', 'REDIS-HIT');
return new Response(redisCached.body, {
status: redisCached.status,
headers: getHeaders(cors, 'REDIS-HIT'),
});
}
if (
isValidPayload(fallbackCache.payload) &&
fallbackCache.key === cacheKey &&
now - fallbackCache.timestamp < CACHE_TTL_MS
) {
recordCacheTelemetry('/api/coingecko', 'MEMORY-HIT');
return new Response(fallbackCache.payload.body, {
status: fallbackCache.payload.status,
headers: getHeaders(cors, 'MEMORY-HIT'),
});
}
const endpoint = url.searchParams.get('endpoint');
try {
const geckoUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=${vsCurrencies}&include_24hr_change=${include24hrChange}`;
let geckoUrl;
if (endpoint === 'markets') {
geckoUrl = `https://api.coingecko.com/api/v3/coins/markets?vs_currency=${vsCurrencies}&ids=${ids}&order=market_cap_desc&sparkline=true&price_change_percentage=24h`;
} else {
geckoUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=${vsCurrencies}&include_24hr_change=${include24hrChange}`;
}
const response = await fetch(geckoUrl, {
headers: {
'Accept': 'application/json',
@@ -59,15 +107,15 @@ export default async function handler(req) {
});
// If rate limited, return cached data if available
if (response.status === 429 && cache.data && cache.key === cacheKey) {
return new Response(cache.data, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=120, stale-while-revalidate=60',
'X-Cache': 'STALE',
},
if (
response.status === 429 &&
isValidPayload(fallbackCache.payload) &&
fallbackCache.key === cacheKey
) {
recordCacheTelemetry('/api/coingecko', 'STALE');
return new Response(fallbackCache.payload.body, {
status: fallbackCache.payload.status,
headers: getHeaders(cors, 'STALE'),
});
}
@@ -75,34 +123,32 @@ export default async function handler(req) {
// Cache successful responses
if (response.ok) {
cache = { data, key: cacheKey, timestamp: Date.now() };
const payload = { body: data, status: response.status };
fallbackCache = { key: cacheKey, payload, timestamp: Date.now() };
void setCachedJson(redisKey, payload, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/coingecko', 'MISS');
} else {
recordCacheTelemetry('/api/coingecko', 'UPSTREAM-ERROR');
}
return new Response(data, {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=120, stale-while-revalidate=60',
'X-Cache': 'MISS',
},
headers: getHeaders(cors, 'MISS'),
});
} catch (error) {
// Return cached data on error if available
if (cache.data && cache.key === cacheKey) {
return new Response(cache.data, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=120',
'X-Cache': 'ERROR-FALLBACK',
},
if (isValidPayload(fallbackCache.payload) && fallbackCache.key === cacheKey) {
recordCacheTelemetry('/api/coingecko', 'ERROR-FALLBACK');
return new Response(fallbackCache.payload.body, {
status: fallbackCache.payload.status,
headers: getHeaders(cors, 'ERROR-FALLBACK', 'public, max-age=120'),
});
}
recordCacheTelemetry('/api/coingecko', 'ERROR');
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Country Intelligence Brief Endpoint
* Generates AI-powered country situation briefs using Groq
* Redis cached (2h TTL) for cross-user deduplication
*/
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions';
const MODEL = 'llama-3.1-8b-instant';
const CACHE_TTL_SECONDS = 7200; // 2 hours
const CACHE_VERSION = 'ci-v2';
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const apiKey = process.env.GROQ_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ intel: null, fallback: true, skipped: true, reason: 'GROQ_API_KEY not configured' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const { country, code, context } = await request.json();
if (!country || !code) {
return new Response(JSON.stringify({ error: 'country and code required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Cache key includes country code + context hash (context changes as data updates)
const contextHash = context ? hashString(JSON.stringify(context)).slice(0, 8) : 'no-ctx';
const cacheKey = `${CACHE_VERSION}:${code}:${contextHash}`;
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.brief) {
console.log('[CountryIntel] Cache hit:', code);
return new Response(JSON.stringify({ ...cached, cached: true }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
// Build data context section
const dataLines = [];
if (context?.score != null) {
const changeStr = context.change24h ? ` (${context.change24h > 0 ? '+' : ''}${context.change24h} in 24h)` : '';
dataLines.push(`Instability Score: ${context.score}/100 (${context.level || 'unknown'}) — trend: ${context.trend || 'unknown'}${changeStr}`);
}
if (context?.components) {
const c = context.components;
dataLines.push(`Score Components: Unrest ${c.unrest ?? '?'}/100, Security ${c.security ?? '?'}/100, Information ${c.information ?? '?'}/100`);
}
if (context?.protests != null) dataLines.push(`Active protests in/near country (7d): ${context.protests}`);
if (context?.militaryFlights != null) dataLines.push(`Military aircraft detected in/near country: ${context.militaryFlights}`);
if (context?.militaryVessels != null) dataLines.push(`Military vessels detected in/near country: ${context.militaryVessels}`);
if (context?.outages != null) dataLines.push(`Internet outages: ${context.outages}`);
if (context?.earthquakes != null) dataLines.push(`Recent earthquakes: ${context.earthquakes}`);
if (context?.stockIndex) dataLines.push(`Stock Market Index: ${context.stockIndex}`);
if (context?.convergenceScore != null) {
dataLines.push(`Signal convergence score: ${context.convergenceScore}/100 (multiple signal types detected: ${(context.signalTypes || []).join(', ')})`);
}
if (context?.regionalConvergence?.length > 0) {
dataLines.push(`\nRegional convergence alerts:`);
context.regionalConvergence.forEach(r => dataLines.push(`- ${r}`));
}
if (context?.headlines?.length > 0) {
dataLines.push(`\nRecent headlines mentioning ${country} (${context.headlines.length} found):`);
context.headlines.slice(0, 15).forEach((h, i) => dataLines.push(`${i + 1}. ${h}`));
}
const dataSection = dataLines.length > 0
? `\nCURRENT SENSOR DATA:\n${dataLines.join('\n')}`
: '\nNo real-time sensor data available for this country.';
const dateStr = new Date().toISOString().split('T')[0];
const systemPrompt = `You are a senior intelligence analyst providing comprehensive country situation briefs. Current date: ${dateStr}. Donald Trump is the current US President (second term, inaugurated Jan 2025).
Write a thorough, data-driven intelligence brief for the requested country. Structure:
1. **Current Situation** What is happening right now. Reference specific data: instability scores, protest counts, military presence, outages. Explain what the numbers mean in context.
2. **Military & Security Posture** Analyze military activity in/near the country. What forces are present? What does the positioning suggest? What are foreign nations doing in this theater?
3. **Key Risk Factors** What drives instability or stability. Connect the dots between different signals (protests + outages = potential crackdown? military buildup + diplomatic tensions = escalation risk?). Reference specific headlines.
4. **Regional Context** How does this country's situation affect or relate to its neighbors and the broader region? Reference any convergence alerts.
5. **Outlook & Watch Items** What to monitor in the near term. Be specific about indicators that would signal escalation or de-escalation.
Rules:
- Be specific and analytical. Reference the data provided (scores, counts, headlines, convergence).
- If data shows low activity, say so don't manufacture threats.
- Connect signals: explain what combinations of data points suggest.
- 5-6 paragraphs, 300-400 words.
- No speculation beyond what the data supports.
- Use plain language, not jargon.
- If military assets are 0, don't speculate about military presence say monitoring shows no current military activity.
- When referencing a specific headline from the numbered list, cite it as [N] where N is the headline number (e.g. "tensions escalated [3]"). Only cite headlines you directly reference.`;
const userPrompt = `Country: ${country} (${code})${dataSection}`;
const groqRes = await fetch(GROQ_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.4,
max_tokens: 900,
}),
});
if (!groqRes.ok) {
const errText = await groqRes.text();
console.error('[CountryIntel] Groq error:', groqRes.status, errText);
return new Response(JSON.stringify({ error: 'AI service error', fallback: true }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
const groqData = await groqRes.json();
const brief = groqData.choices?.[0]?.message?.content || '';
const result = {
brief,
country,
code,
model: MODEL,
generatedAt: new Date().toISOString(),
};
if (brief) {
await setCachedJson(cacheKey, result, CACHE_TTL_SECONDS);
}
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
} catch (err) {
console.error('[CountryIntel] Error:', err);
return new Response(JSON.stringify({ error: 'Internal error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
+1061
View File
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import handler, {
__resetCyberThreatsState,
__testDedupeThreats,
__testParseFeodoRecords,
} from './cyber-threats.js';
const ORIGINAL_FETCH = globalThis.fetch;
const ORIGINAL_URLHAUS_KEY = process.env.URLHAUS_AUTH_KEY;
const ORIGINAL_OTX_KEY = process.env.OTX_API_KEY;
const ORIGINAL_ABUSEIPDB_KEY = process.env.ABUSEIPDB_API_KEY;
function makeRequest(path = '/api/cyber-threats', ip = '198.51.100.10') {
const headers = new Headers();
headers.set('x-forwarded-for', ip);
return new Request(`https://worldmonitor.app${path}`, { headers });
}
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function textResponse(body, status = 200) {
return new Response(body, {
status,
headers: { 'content-type': 'text/plain' },
});
}
// Mock that handles all 5 source URLs + geo enrichment
function createMockFetch({ feodo, urlhaus, c2intel, otx, abuseipdb, geo } = {}) {
return async (url) => {
const target = String(url);
if (target.includes('feodotracker.abuse.ch') && feodo) return feodo(target);
if (target.includes('urlhaus-api.abuse.ch') && urlhaus) return urlhaus(target);
if (target.includes('raw.githubusercontent.com') && target.includes('C2IntelFeeds') && c2intel) return c2intel(target);
if (target.includes('otx.alienvault.com') && otx) return otx(target);
if (target.includes('api.abuseipdb.com') && abuseipdb) return abuseipdb(target);
if ((target.includes('ipwho.is') || target.includes('ipapi.co')) && geo) return geo(target);
// Default: return 404 for unconfigured sources
return new Response('not found', { status: 404 });
};
}
test.afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
process.env.URLHAUS_AUTH_KEY = ORIGINAL_URLHAUS_KEY;
process.env.OTX_API_KEY = ORIGINAL_OTX_KEY;
process.env.ABUSEIPDB_API_KEY = ORIGINAL_ABUSEIPDB_KEY;
__resetCyberThreatsState();
});
test('Feodo parser accepts online and recent offline entries, filters stale', () => {
const nowMs = Date.parse('2026-02-15T12:00:00.000Z');
const records = [
{
ip_address: '1.2.3.4',
status: 'online',
first_seen: '2026-02-14 10:00:00 UTC',
last_online: '2026-02-15 10:00:00 UTC',
malware: 'QakBot',
},
{
ip_address: '5.6.7.8',
status: 'offline',
first_seen: '2026-02-14 10:00:00 UTC',
last_online: '2026-02-15 10:00:00 UTC',
malware: 'Emotet',
},
{
ip_address: '9.9.9.9',
status: 'online',
first_seen: '2025-10-01 10:00:00 UTC',
last_online: '2025-10-02 10:00:00 UTC',
malware: 'generic',
},
{
ip_address: '2.2.2.2',
first_seen: '2026-02-14 10:00:00 UTC',
last_online: '2026-02-15 10:00:00 UTC',
malware: 'generic',
},
];
const parsed = __testParseFeodoRecords(records, { nowMs, days: 14 });
// online + offline (recent) + no-status (recent) = 3; stale (9.9.9.9) filtered
assert.equal(parsed.length, 3);
assert.equal(parsed[0].indicator, '1.2.3.4');
assert.equal(parsed[0].severity, 'critical');
assert.equal(parsed[1].indicator, '5.6.7.8');
assert.equal(parsed[1].severity, 'medium');
assert.equal(parsed[0].firstSeen?.endsWith('Z'), true);
assert.equal(parsed[0].lastSeen?.endsWith('Z'), true);
});
test('dedupes by source + indicatorType + indicator', () => {
const deduped = __testDedupeThreats([
{
id: 'a',
source: 'feodo',
type: 'c2_server',
indicatorType: 'ip',
indicator: '1.2.3.4',
severity: 'high',
tags: ['a'],
firstSeen: '2026-02-10T00:00:00.000Z',
lastSeen: '2026-02-11T00:00:00.000Z',
},
{
id: 'b',
source: 'feodo',
type: 'c2_server',
indicatorType: 'ip',
indicator: '1.2.3.4',
severity: 'critical',
tags: ['b'],
firstSeen: '2026-02-12T00:00:00.000Z',
lastSeen: '2026-02-13T00:00:00.000Z',
},
{
id: 'c',
source: 'urlhaus',
type: 'malicious_url',
indicatorType: 'domain',
indicator: 'bad.example',
severity: 'medium',
tags: [],
firstSeen: '2026-02-11T00:00:00.000Z',
lastSeen: '2026-02-11T01:00:00.000Z',
},
]);
assert.equal(deduped.length, 2);
const feodo = deduped.find((item) => item.source === 'feodo');
assert.equal(feodo?.severity, 'critical');
assert.equal(feodo?.tags.includes('a'), true);
assert.equal(feodo?.tags.includes('b'), true);
});
test('API aggregates from all 5 sources', async () => {
process.env.URLHAUS_AUTH_KEY = 'test-key';
process.env.OTX_API_KEY = 'test-otx';
process.env.ABUSEIPDB_API_KEY = 'test-abuse';
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
urlhaus: () => jsonResponse({
urls: [{
url: 'http://5.5.5.5/malware.exe',
host: '5.5.5.5',
url_status: 'online',
threat: 'malware_download',
tags: ['malware'],
dateadded: '2026-02-14T08:00:00.000Z',
latitude: 48.86,
longitude: 2.35,
country: 'FR',
}],
}),
c2intel: () => textResponse(
'#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP\n10.10.10.11,Possible Metasploit C2 IP',
),
otx: () => jsonResponse({
results: [{
indicator: '20.20.20.20',
title: 'APT threat',
tags: ['apt', 'c2'],
created: '2026-02-13T00:00:00.000Z',
modified: '2026-02-14T00:00:00.000Z',
}],
}),
abuseipdb: () => jsonResponse({
data: [{
ipAddress: '30.30.30.30',
abuseConfidenceScore: 98,
lastReportedAt: '2026-02-15T06:00:00.000Z',
countryCode: 'CN',
latitude: 39.9,
longitude: 116.4,
}],
}),
geo: () => jsonResponse({ success: true, latitude: 40.0, longitude: -74.0, country_code: 'US' }),
});
const response = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.20'));
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.success, true);
assert.equal(body.sources.feodo.ok, true);
assert.equal(body.sources.urlhaus.ok, true);
assert.equal(body.sources.c2intel.ok, true);
assert.equal(body.sources.otx.ok, true);
assert.equal(body.sources.abuseipdb.ok, true);
// 5 sources, all with coords (3 native + 3 via geo enrichment mock)
assert.equal(body.data.length >= 5, true);
});
test('API works with only free sources when keys missing', async () => {
delete process.env.URLHAUS_AUTH_KEY;
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const response = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.11'));
assert.equal(response.status, 200);
assert.equal(response.headers.get('X-Cache'), 'MISS');
const body = await response.json();
assert.equal(body.success, true);
assert.equal(body.partial, false);
assert.equal(body.sources.feodo.ok, true);
assert.equal(body.sources.c2intel.ok, true);
assert.equal(body.sources.urlhaus.ok, false);
assert.equal(body.sources.urlhaus.reason, 'missing_auth_key');
assert.equal(body.sources.otx.ok, false);
assert.equal(body.sources.otx.reason, 'missing_api_key');
assert.equal(body.sources.abuseipdb.ok, false);
assert.equal(body.sources.abuseipdb.reason, 'missing_api_key');
assert.equal(Array.isArray(body.data), true);
});
test('API marks partial=true when URLhaus is enabled but fails', async () => {
process.env.URLHAUS_AUTH_KEY = 'test-key';
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
urlhaus: () => new Response('boom', { status: 500 }),
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const response = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.12'));
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.success, true);
assert.equal(body.partial, true);
assert.equal(body.sources.urlhaus.ok, false);
assert.equal(body.sources.urlhaus.reason, 'urlhaus_http_500');
});
test('API returns memory cache hit on repeated request', async () => {
delete process.env.URLHAUS_AUTH_KEY;
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
let feodoCalls = 0;
globalThis.fetch = createMockFetch({
feodo: () => {
feodoCalls += 1;
return jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]);
},
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const first = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.13'));
assert.equal(first.status, 200);
assert.equal(first.headers.get('X-Cache'), 'MISS');
assert.equal(feodoCalls, 1);
globalThis.fetch = async () => {
throw new Error('network should not be hit for memory cache');
};
const second = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.13'));
assert.equal(second.status, 200);
assert.equal(second.headers.get('X-Cache'), 'MEMORY-HIT');
assert.equal(feodoCalls, 1);
});
test('API returns stale fallback when upstream fails after fresh cache TTL', async () => {
delete process.env.URLHAUS_AUTH_KEY;
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
const baseNow = Date.parse('2026-02-15T12:00:00.000Z');
const originalDateNow = Date.now;
Date.now = () => baseNow;
try {
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const first = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.14'));
assert.equal(first.status, 200);
assert.equal(first.headers.get('X-Cache'), 'MISS');
Date.now = () => baseNow + (11 * 60 * 1000);
globalThis.fetch = async () => {
throw new Error('forced upstream failure');
};
const stale = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.14'));
assert.equal(stale.status, 200);
assert.equal(stale.headers.get('X-Cache'), 'STALE');
const body = await stale.json();
assert.equal(body.success, true);
assert.equal(Array.isArray(body.data), true);
assert.equal(body.data.length >= 1, true);
} finally {
Date.now = originalDateNow;
}
});
File diff suppressed because one or more lines are too long
+4 -16
View File
@@ -1,23 +1,11 @@
// Debug endpoint to check environment variables (remove after testing)
export const config = { runtime: 'edge' };
export default async function handler(req) {
const envStatus = {
FINNHUB_API_KEY: process.env.FINNHUB_API_KEY ? '✓ SET' : '✗ MISSING',
FRED_API_KEY: process.env.FRED_API_KEY ? '✓ SET' : '✗ MISSING',
EIA_API_KEY: process.env.EIA_API_KEY ? '✓ SET' : '✗ MISSING',
ACLED_ACCESS_TOKEN: process.env.ACLED_ACCESS_TOKEN ? '✓ SET' : '✗ MISSING',
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ? '✓ SET' : '✗ MISSING',
WINGBITS_API_KEY: process.env.WINGBITS_API_KEY ? '✓ SET' : '✗ MISSING',
NODE_ENV: process.env.NODE_ENV || 'not set',
VERCEL_ENV: process.env.VERCEL_ENV || 'not set',
};
return new Response(JSON.stringify(envStatus, null, 2), {
status: 200,
export default async function handler() {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
}
+52
View File
@@ -0,0 +1,52 @@
export const config = { runtime: 'edge' };
const RELEASES_URL = 'https://api.github.com/repos/koala73/worldmonitor/releases/latest';
const RELEASES_PAGE = 'https://github.com/koala73/worldmonitor/releases/latest';
const PLATFORM_PATTERNS = {
'windows-exe': (name) => name.endsWith('_x64-setup.exe'),
'windows-msi': (name) => name.endsWith('_x64_en-US.msi'),
'macos-arm64': (name) => name.endsWith('_aarch64.dmg'),
'macos-x64': (name) => name.endsWith('_x64.dmg') && !name.includes('setup'),
'linux-appimage': (name) => name.endsWith('_amd64.AppImage'),
};
export default async function handler(req) {
const url = new URL(req.url);
const platform = url.searchParams.get('platform');
if (!platform || !PLATFORM_PATTERNS[platform]) {
return Response.redirect(RELEASES_PAGE, 302);
}
try {
const res = await fetch(RELEASES_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'WorldMonitor-Download-Redirect',
},
});
if (!res.ok) {
return Response.redirect(RELEASES_PAGE, 302);
}
const release = await res.json();
const matcher = PLATFORM_PATTERNS[platform];
const asset = release.assets?.find((a) => matcher(a.name));
if (!asset) {
return Response.redirect(RELEASES_PAGE, 302);
}
return new Response(null, {
status: 302,
headers: {
'Location': asset.browser_download_url,
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
},
});
} catch {
return Response.redirect(RELEASES_PAGE, 302);
}
}
+10 -4
View File
@@ -1,6 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler() {
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch(
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson',
@@ -16,14 +22,14 @@ export default async function handler() {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+19 -36
View File
@@ -1,28 +1,22 @@
// EIA (Energy Information Administration) API proxy
// Keeps API key server-side
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
export const config = { runtime: 'edge' };
function getCorsOrigin(req) {
const origin = req.headers.get('origin') || '';
// Allow *.worldmonitor.app and localhost
if (
origin.endsWith('.worldmonitor.app') ||
origin === 'https://worldmonitor.app' ||
origin.startsWith('http://localhost:')
) {
return origin;
}
return 'https://worldmonitor.app';
}
export default async function handler(req) {
const corsOrigin = getCorsOrigin(req);
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
// Only allow GET and OPTIONS methods
if (req.method !== 'GET' && req.method !== 'OPTIONS') {
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, {
status: 405,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
@@ -33,30 +27,19 @@ export default async function handler(req) {
if (!apiKey) {
return Response.json({
error: 'EIA API not configured',
configured: false,
skipped: true,
reason: 'EIA_API_KEY not configured',
}, {
status: 503,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
});
}
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': corsOrigin,
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
status: 200,
headers: cors,
});
}
// Health check
if (path === '/health' || path === '') {
return Response.json({ configured: true }, {
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
@@ -112,8 +95,8 @@ export default async function handler(req) {
return Response.json(results, {
headers: {
'Access-Control-Allow-Origin': corsOrigin,
'Cache-Control': 'public, max-age=1800', // 30 min cache
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
} catch (error) {
@@ -122,13 +105,13 @@ export default async function handler(req) {
error: 'Failed to fetch EIA data',
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
}
return Response.json({ error: 'Not found' }, {
status: 404,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
+163
View File
@@ -0,0 +1,163 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_TTL = 900;
let cachedResponse = null;
let cacheTimestamp = 0;
const ETF_LIST = [
{ ticker: 'IBIT', issuer: 'BlackRock' },
{ ticker: 'FBTC', issuer: 'Fidelity' },
{ ticker: 'ARKB', issuer: 'ARK/21Shares' },
{ ticker: 'BITB', issuer: 'Bitwise' },
{ ticker: 'GBTC', issuer: 'Grayscale' },
{ ticker: 'HODL', issuer: 'VanEck' },
{ ticker: 'BRRR', issuer: 'Valkyrie' },
{ ticker: 'EZBC', issuer: 'Franklin' },
{ ticker: 'BTCO', issuer: 'Invesco' },
{ ticker: 'BTCW', issuer: 'WisdomTree' },
];
async function fetchChart(ticker) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?range=5d&interval=1d`;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
} finally {
clearTimeout(id);
}
}
function parseChartData(chart, ticker, issuer) {
try {
const result = chart?.chart?.result?.[0];
if (!result) return null;
const quote = result.indicators?.quote?.[0];
const closes = quote?.close || [];
const volumes = quote?.volume || [];
const validCloses = closes.filter(p => p != null);
const validVolumes = volumes.filter(v => v != null);
if (validCloses.length < 2) return null;
const latestPrice = validCloses[validCloses.length - 1];
const prevPrice = validCloses[validCloses.length - 2];
const priceChange = prevPrice ? ((latestPrice - prevPrice) / prevPrice * 100) : 0;
const latestVolume = validVolumes.length > 0 ? validVolumes[validVolumes.length - 1] : 0;
const avgVolume = validVolumes.length > 1
? validVolumes.slice(0, -1).reduce((a, b) => a + b, 0) / (validVolumes.length - 1)
: latestVolume;
// Estimate flow direction from price change + volume
const volumeRatio = avgVolume > 0 ? latestVolume / avgVolume : 1;
const direction = priceChange > 0.1 ? 'inflow' : priceChange < -0.1 ? 'outflow' : 'neutral';
const estFlowMagnitude = latestVolume * latestPrice * (priceChange > 0 ? 1 : -1) * 0.1;
return {
ticker,
issuer,
price: +latestPrice.toFixed(2),
priceChange: +priceChange.toFixed(2),
volume: latestVolume,
avgVolume: Math.round(avgVolume),
volumeRatio: +volumeRatio.toFixed(2),
direction,
estFlow: Math.round(estFlowMagnitude),
};
} catch {
return null;
}
}
function buildFallbackResult() {
return {
timestamp: new Date().toISOString(),
summary: {
etfCount: 0,
totalVolume: 0,
totalEstFlow: 0,
netDirection: 'UNAVAILABLE',
inflowCount: 0,
outflowCount: 0,
},
etfs: [],
unavailable: true,
};
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: cors });
}
return new Response(null, { status: 204, headers: cors });
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } });
}
const now = Date.now();
if (cachedResponse && now - cacheTimestamp < CACHE_TTL * 1000) {
return new Response(JSON.stringify(cachedResponse), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=1800` },
});
}
try {
const charts = await Promise.allSettled(
ETF_LIST.map(etf => fetchChart(etf.ticker))
);
const etfs = [];
for (let i = 0; i < ETF_LIST.length; i++) {
const chart = charts[i].status === 'fulfilled' ? charts[i].value : null;
if (chart) {
const parsed = parseChartData(chart, ETF_LIST[i].ticker, ETF_LIST[i].issuer);
if (parsed) etfs.push(parsed);
}
}
const totalVolume = etfs.reduce((sum, e) => sum + e.volume, 0);
const totalEstFlow = etfs.reduce((sum, e) => sum + e.estFlow, 0);
const inflowCount = etfs.filter(e => e.direction === 'inflow').length;
const outflowCount = etfs.filter(e => e.direction === 'outflow').length;
const result = {
timestamp: new Date().toISOString(),
summary: {
etfCount: etfs.length,
totalVolume,
totalEstFlow,
netDirection: totalEstFlow > 0 ? 'NET INFLOW' : totalEstFlow < 0 ? 'NET OUTFLOW' : 'NEUTRAL',
inflowCount,
outflowCount,
},
etfs: etfs.sort((a, b) => b.volume - a.volume),
};
cachedResponse = result;
cacheTimestamp = now;
return new Response(JSON.stringify(result), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=1800` },
});
} catch (err) {
const fallback = cachedResponse || buildFallbackResult();
cachedResponse = fallback;
cacheTimestamp = now;
return new Response(JSON.stringify(fallback), {
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=30' },
});
}
}
+8 -1
View File
@@ -1,6 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch('https://nasstatus.faa.gov/api/airport-status-information', {
headers: { 'Accept': 'application/xml' },
@@ -10,7 +16,8 @@ export default async function handler(req) {
status: response.status,
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
} catch (error) {
+31 -7
View File
@@ -1,3 +1,4 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const SYMBOL_PATTERN = /^[A-Za-z0-9.^]+$/;
@@ -49,12 +50,35 @@ async function fetchQuote(symbol, apiKey) {
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const apiKey = process.env.FINNHUB_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ error: 'Finnhub API key not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
return new Response(JSON.stringify({ quotes: [], skipped: true, reason: 'FINNHUB_API_KEY not configured' }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30', ...corsHeaders },
});
}
@@ -64,7 +88,7 @@ export default async function handler(req) {
if (!symbols) {
return new Response(JSON.stringify({ error: 'Invalid or missing symbols parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
@@ -78,14 +102,14 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=30',
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
...corsHeaders,
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
+145
View File
@@ -0,0 +1,145 @@
/**
* NASA FIRMS Satellite Fire Detection API
* Proxies requests to NASA FIRMS to avoid CORS and protect API key
* Returns parsed fire data for monitored conflict regions
*
* GET ?region=Ukraine&days=1 fires for one region
* GET ?days=1 fires for all monitored regions
*/
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const FIRMS_API_KEY = process.env.NASA_FIRMS_API_KEY || process.env.FIRMS_API_KEY || '';
const FIRMS_BASE = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv';
const SOURCE = 'VIIRS_SNPP_NRT';
// Bounding boxes as west,south,east,north
const MONITORED_REGIONS = {
'Ukraine': { bbox: '22,44,40,53' },
'Russia': { bbox: '20,50,180,82' },
'Iran': { bbox: '44,25,63,40' },
'Israel/Gaza': { bbox: '34,29,36,34' },
'Syria': { bbox: '35,32,42,37' },
'Taiwan': { bbox: '119,21,123,26' },
'North Korea': { bbox: '124,37,131,43' },
'Saudi Arabia': { bbox: '34,16,56,32' },
'Turkey': { bbox: '26,36,45,42' },
};
// Map VIIRS confidence letters to numeric
function parseConfidence(c) {
if (c === 'h') return 95;
if (c === 'n') return 50;
if (c === 'l') return 20;
return parseInt(c) || 0;
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map(h => h.trim());
const results = [];
for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(',').map(v => v.trim());
if (vals.length < headers.length) continue;
const row = {};
headers.forEach((h, idx) => { row[h] = vals[idx]; });
results.push({
lat: parseFloat(row.latitude),
lon: parseFloat(row.longitude),
brightness: parseFloat(row.bright_ti4) || 0,
scan: parseFloat(row.scan) || 0,
track: parseFloat(row.track) || 0,
acq_date: row.acq_date || '',
acq_time: row.acq_time || '',
satellite: row.satellite || '',
confidence: parseConfidence(row.confidence),
bright_t31: parseFloat(row.bright_ti5) || 0,
frp: parseFloat(row.frp) || 0,
daynight: row.daynight || '',
});
}
return results;
}
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
if (!FIRMS_API_KEY) {
return json({ regions: {}, totalCount: 0, skipped: true, reason: 'NASA_FIRMS_API_KEY not configured', source: SOURCE, days: 0, timestamp: new Date().toISOString() });
}
try {
const { searchParams } = new URL(request.url);
const regionName = searchParams.get('region');
const days = Math.min(parseInt(searchParams.get('days')) || 1, 5);
const regions = regionName
? { [regionName]: MONITORED_REGIONS[regionName] }
: MONITORED_REGIONS;
if (regionName && !MONITORED_REGIONS[regionName]) {
return json({ error: `Unknown region: ${regionName}` }, 400);
}
const allFires = {};
let totalCount = 0;
// Fetch regions in parallel (max 10)
const entries = Object.entries(regions);
const results = await Promise.allSettled(
entries.map(async ([name, { bbox }]) => {
const url = `${FIRMS_BASE}/${FIRMS_API_KEY}/${SOURCE}/${bbox}/${days}`;
const res = await fetch(url, {
headers: { 'Accept': 'text/csv' },
});
if (!res.ok) throw new Error(`FIRMS ${res.status} for ${name}`);
const csv = await res.text();
return { name, fires: parseCSV(csv) };
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
const { name, fires } = result.value;
allFires[name] = fires;
totalCount += fires.length;
} else {
console.error('[FIRMS]', result.reason?.message);
}
}
return json({
regions: allFires,
totalCount,
source: SOURCE,
days,
timestamp: new Date().toISOString(),
});
} catch (err) {
console.error('[FIRMS] Error:', err);
return json({ error: 'Failed to fetch fire data' }, 500);
}
}
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=600, s-maxage=600, stale-while-revalidate=120', // 10 min cache
},
});
}
+43 -5
View File
@@ -1,18 +1,56 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const url = new URL(req.url);
const seriesId = url.searchParams.get('series_id');
const observationStart = url.searchParams.get('observation_start');
const observationEnd = url.searchParams.get('observation_end');
if (!seriesId) {
return new Response('Missing series_id parameter', { status: 400 });
return new Response(JSON.stringify({ error: 'Missing series_id parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const apiKey = process.env.FRED_API_KEY;
if (!apiKey) {
return new Response('FRED_API_KEY not configured', { status: 500 });
return new Response(JSON.stringify({
observations: [],
skipped: true,
reason: 'FRED_API_KEY not configured',
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
...corsHeaders,
},
});
}
try {
@@ -38,14 +76,14 @@ export default async function handler(req) {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=3600',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600',
...corsHeaders,
},
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
+8 -3
View File
@@ -1,7 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Scrape FwdStart newsletter archive and return as RSS
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch('https://www.fwdstart.me/archive', {
headers: {
@@ -84,8 +89,8 @@ export default async function handler(req) {
return new Response(rss, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=1800',
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
} catch (error) {
@@ -97,7 +102,7 @@ export default async function handler(req) {
status: 502,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
},
});
}
+7 -2
View File
@@ -1,9 +1,14 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const MAX_RECORDS = 20;
const DEFAULT_RECORDS = 10;
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const query = url.searchParams.get('query');
const maxrecords = Math.min(
@@ -50,8 +55,8 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
+15 -34
View File
@@ -1,4 +1,5 @@
// GDELT Geo API proxy with security hardening
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const ALLOWED_FORMATS = ['geojson', 'json', 'csv'];
@@ -6,19 +7,6 @@ const MAX_RECORDS = 500;
const MIN_RECORDS = 1;
const ALLOWED_TIMESPANS = ['1d', '7d', '14d', '30d', '60d', '90d'];
function getCorsOrigin(req) {
const origin = req.headers.get('origin') || '';
// Allow *.worldmonitor.app and localhost
if (
origin.endsWith('.worldmonitor.app') ||
origin === 'https://worldmonitor.app' ||
origin.startsWith('http://localhost:')
) {
return origin;
}
return 'https://worldmonitor.app';
}
function validateMaxRecords(val) {
const num = parseInt(val, 10);
if (isNaN(num)) return 250;
@@ -39,26 +27,19 @@ function sanitizeQuery(val) {
}
export default async function handler(req) {
const corsOrigin = getCorsOrigin(req);
if (req.method !== 'GET' && req.method !== 'OPTIONS') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
},
});
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': corsOrigin,
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
@@ -78,7 +59,7 @@ export default async function handler(req) {
status: 502,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
...cors,
},
});
}
@@ -88,8 +69,8 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': format === 'csv' ? 'text/csv' : 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
'Cache-Control': 'public, max-age=300',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
@@ -98,7 +79,7 @@ export default async function handler(req) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
...cors,
},
});
}
+11 -5
View File
@@ -1,8 +1,14 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
// Fetch trending GitHub repositories
// Uses unofficial GitHub trending scraper API
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const { searchParams } = new URL(request.url);
const language = searchParams.get('language') || 'python'; // python, javascript, typescript, etc.
@@ -50,8 +56,8 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=1800', // 30 min cache
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300', // 30 min cache
},
});
}
@@ -62,8 +68,8 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=1800', // 30 min cache
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300', // 30 min cache
},
});
} catch (error) {
@@ -76,7 +82,7 @@ export default async function handler(request) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
...cors,
},
}
);
+296
View File
@@ -0,0 +1,296 @@
/**
* Groq API Summarization Endpoint with Redis Caching
* Uses Llama 3.1 8B Instant for high-throughput summarization
* Free tier: 14,400 requests/day (14x more than 70B model)
* Server-side Redis cache for cross-user deduplication
*/
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions';
const MODEL = 'llama-3.1-8b-instant'; // 14.4K RPD vs 1K for 70b
const CACHE_TTL_SECONDS = 86400; // 24 hours
const CACHE_VERSION = 'v3';
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
const normalizedVariant = typeof variant === 'string' && variant ? variant.toLowerCase() : 'full';
const normalizedLang = typeof lang === 'string' && lang ? lang.toLowerCase() : 'en';
if (mode === 'translate') {
const targetLang = normalizedVariant || normalizedLang;
return `summary:${CACHE_VERSION}:${mode}:${targetLang}:${hash}${geoHash}`;
}
return `summary:${CACHE_VERSION}:${mode}:${normalizedVariant}:${normalizedLang}:${hash}${geoHash}`;
}
// Deduplicate similar headlines (same story from different sources)
function deduplicateHeadlines(headlines) {
const seen = new Set();
const unique = [];
for (const headline of headlines) {
// Normalize: lowercase, remove punctuation, collapse whitespace
const normalized = headline.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
// Extract key words (4+ chars) for similarity check
const words = new Set(normalized.split(' ').filter(w => w.length >= 4));
// Check if this headline is too similar to any we've seen
let isDuplicate = false;
for (const seenWords of seen) {
const intersection = [...words].filter(w => seenWords.has(w));
const similarity = intersection.length / Math.min(words.size, seenWords.size);
if (similarity > 0.6) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
seen.add(words);
unique.push(headline);
}
}
return unique;
}
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const apiKey = process.env.GROQ_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ summary: null, fallback: true, skipped: true, reason: 'GROQ_API_KEY not configured' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
try {
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Check cache first
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[Groq] Cache hit:', cacheKey);
return new Response(JSON.stringify({
summary: cached.summary,
model: cached.model || MODEL,
provider: 'cache',
cached: true,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// Deduplicate similar headlines (same story from multiple sources)
const uniqueHeadlines = deduplicateHeadlines(headlines.slice(0, 8));
const headlineText = uniqueHeadlines.map((h, i) => `${i + 1}. ${h}`).join('\n');
let systemPrompt, userPrompt;
// Include intelligence synthesis context in prompt if available
const intelSection = geoContext ? `\n\n${geoContext}` : '';
// Current date context for LLM (models may have outdated knowledge)
const isTechVariant = variant === 'tech';
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
if (mode === 'brief') {
if (isTechVariant) {
// Tech variant: focus on startups, AI, funding, product launches
systemPrompt = `${dateContext}
Summarize the key tech/startup development in 2-3 sentences.
Rules:
- Focus ONLY on technology, startups, AI, funding, product launches, or developer news
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
- Lead with the company/product/technology name
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
- No bullet points, no meta-commentary${langInstruction}`;
} else {
// Full variant: geopolitical focus
systemPrompt = `${dateContext}
Summarize the key development in 2-3 sentences.
Rules:
- Lead with WHAT happened and WHERE - be specific
- NEVER start with "Breaking news", "Good evening", "Tonight", or TV-style openings
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary${langInstruction}`;
}
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
if (isTechVariant) {
systemPrompt = `${dateContext}
Analyze the tech/startup trend in 2-3 sentences.
Rules:
- Focus ONLY on technology implications: funding trends, AI developments, market shifts, product strategy
- IGNORE political implications, trade wars, government unless directly about tech policy
- Lead with the insight for tech industry
- Connect to startup ecosystem, VC trends, or technical implications`;
} else {
systemPrompt = `${dateContext}
Provide analysis in 2-3 sentences. Be direct and specific.
Rules:
- Lead with the insight - what's significant and why
- NEVER start with "Breaking news", "Tonight", "The key/dominant narrative is"
- Start with substance: "Iran faces...", "The escalation in...", "Multiple signals suggest..."
- CRITICAL FOCAL POINTS are your main actors - explain WHY they matter
- If focal points show news-signal correlation, flag as escalation
- Connect dots, be specific about implications`;
}
userPrompt = isTechVariant
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else if (mode === 'translate') {
const targetLang = variant; // In translate mode, variant param holds the target language code (e.g., 'fr', 'es')
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
Rules:
- Maintain the original tone and journalistic style.
- Do NOT add any conversational filler (e.g., "Here is the translation").
- Output ONLY the translated text.
- If the text is already in ${targetLang}, return it as is.`;
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
} else {
systemPrompt = isTechVariant
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
const response = await fetch(GROQ_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.3,
max_tokens: 150,
top_p: 0.9,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Groq] API error:', response.status, errorText);
// Return fallback signal for rate limiting
if (response.status === 429) {
return new Response(JSON.stringify({ error: 'Rate limited', fallback: true }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'Groq API error', fallback: true }), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
}
const data = await response.json();
const summary = data.choices?.[0]?.message?.content?.trim();
if (!summary) {
return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Store in cache
await setCachedJson(cacheKey, {
summary,
model: MODEL,
timestamp: Date.now(),
}, CACHE_TTL_SECONDS);
return new Response(JSON.stringify({
summary,
model: MODEL,
provider: 'groq',
cached: false,
tokens: data.usage?.total_tokens || 0,
}), {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
} catch (error) {
console.error('[Groq] Error:', error.name, error.message, error.stack?.split('\n')[1]);
return new Response(JSON.stringify({
error: error.message,
errorType: error.name,
fallback: true
}), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
+47 -22
View File
@@ -1,12 +1,30 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
// Fetch Hacker News front page stories
// Uses official HackerNews Firebase API
const ALLOWED_STORY_TYPES = new Set(['top', 'new', 'best', 'ask', 'show', 'job']);
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 60;
const MAX_CONCURRENCY = 10;
function parseLimit(rawLimit) {
const parsed = Number.parseInt(rawLimit || '', 10);
if (!Number.isFinite(parsed)) return DEFAULT_LIMIT;
return Math.max(1, Math.min(MAX_LIMIT, parsed));
}
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const { searchParams } = new URL(request.url);
const storyType = searchParams.get('type') || 'top'; // top, new, best, ask, show, job
const limit = parseInt(searchParams.get('limit') || '30', 10);
const requestedType = searchParams.get('type') || 'top';
const storyType = ALLOWED_STORY_TYPES.has(requestedType) ? requestedType : 'top';
const limit = parseLimit(searchParams.get('limit'));
// HackerNews official Firebase API
const storiesUrl = `https://hacker-news.firebaseio.com/v0/${storyType}stories.json`;
@@ -21,26 +39,33 @@ export default async function handler(request) {
}
const storyIds = await storiesResponse.json();
if (!Array.isArray(storyIds)) {
throw new Error('HackerNews API returned unexpected payload');
}
const limitedIds = storyIds.slice(0, limit);
// Fetch story details in parallel (batch of 30)
const storyPromises = limitedIds.map(async (id) => {
const storyUrl = `https://hacker-news.firebaseio.com/v0/item/${id}.json`;
try {
const response = await fetch(storyUrl, {
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
return await response.json();
// Fetch story details in bounded batches to avoid unbounded fan-out.
const stories = [];
for (let i = 0; i < limitedIds.length; i += MAX_CONCURRENCY) {
const batchIds = limitedIds.slice(i, i + MAX_CONCURRENCY);
const storyPromises = batchIds.map(async (id) => {
const storyUrl = `https://hacker-news.firebaseio.com/v0/item/${id}.json`;
try {
const response = await fetch(storyUrl, {
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error(`Failed to fetch story ${id}:`, error);
return null;
}
return null;
} catch (error) {
console.error(`Failed to fetch story ${id}:`, error);
return null;
}
});
const stories = (await Promise.all(storyPromises)).filter(story => story !== null);
});
const batchResults = await Promise.all(storyPromises);
stories.push(...batchResults.filter((story) => story !== null));
}
return new Response(JSON.stringify({
type: storyType,
@@ -51,8 +76,8 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300', // 5 min cache
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60', // 5 min cache
},
});
} catch (error) {
@@ -65,7 +90,7 @@ export default async function handler(request) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
...cors,
},
}
);
+148
View File
@@ -0,0 +1,148 @@
// HDX HAPI (Humanitarian API) proxy
// Returns aggregated conflict event counts per country
// Source: ACLED data aggregated monthly by HDX
export const config = { runtime: 'edge' };
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_KEY = 'hapi:conflict-events:v2';
const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6 hours
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
const RESPONSE_CACHE_CONTROL = 'public, max-age=1800';
// In-memory fallback when Redis is unavailable.
let fallbackCache = { data: null, timestamp: 0 };
function isValidResult(data) {
return Boolean(
data &&
typeof data === 'object' &&
Array.isArray(data.countries)
);
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (isValidResult(cached)) {
recordCacheTelemetry('/api/hapi', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: {
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'REDIS-HIT',
},
});
}
if (isValidResult(fallbackCache.data) && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/hapi', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MEMORY-HIT',
},
});
}
try {
const appId = btoa('worldmonitor:monitor@worldmonitor.app');
const response = await fetch(
`https://hapi.humdata.org/api/v2/coordination-context/conflict-events?output_format=json&limit=1000&offset=0&app_identifier=${appId}`,
{
headers: {
'Accept': 'application/json',
},
}
);
if (!response.ok) {
throw new Error(`HAPI API error: ${response.status}`);
}
const rawData = await response.json();
const records = rawData.data || [];
// Each record is (country, event_type, month) — aggregate across event types per country
// Keep only the most recent month per country
const byCountry = {};
for (const r of records) {
const iso3 = r.location_code || '';
if (!iso3) continue;
const month = r.reference_period_start || '';
const eventType = (r.event_type || '').toLowerCase();
const events = r.events || 0;
const fatalities = r.fatalities || 0;
if (!byCountry[iso3]) {
byCountry[iso3] = { iso3, locationName: r.location_name || '', month, eventsTotal: 0, eventsPoliticalViolence: 0, eventsCivilianTargeting: 0, eventsDemonstrations: 0, fatalitiesTotalPoliticalViolence: 0, fatalitiesTotalCivilianTargeting: 0 };
}
const c = byCountry[iso3];
if (month > c.month) {
// Newer month — reset
c.month = month;
c.eventsTotal = 0; c.eventsPoliticalViolence = 0; c.eventsCivilianTargeting = 0; c.eventsDemonstrations = 0; c.fatalitiesTotalPoliticalViolence = 0; c.fatalitiesTotalCivilianTargeting = 0;
}
if (month === c.month) {
c.eventsTotal += events;
if (eventType.includes('political_violence')) { c.eventsPoliticalViolence += events; c.fatalitiesTotalPoliticalViolence += fatalities; }
if (eventType.includes('civilian_targeting')) { c.eventsCivilianTargeting += events; c.fatalitiesTotalCivilianTargeting += fatalities; }
if (eventType.includes('demonstration')) { c.eventsDemonstrations += events; }
}
}
const result = {
success: true,
count: Object.keys(byCountry).length,
countries: Object.values(byCountry),
cached_at: new Date().toISOString(),
};
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/hapi', 'MISS');
return Response.json(result, {
status: 200,
headers: {
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MISS',
},
});
} catch (error) {
if (isValidResult(fallbackCache.data)) {
recordCacheTelemetry('/api/hapi', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'STALE',
},
});
}
recordCacheTelemetry('/api/hapi', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, countries: [] }, {
status: 500,
headers: { ...cors },
});
}
}
+284
View File
@@ -0,0 +1,284 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_TTL = 300;
let cachedResponse = null;
let cacheTimestamp = 0;
async function fetchJSON(url, timeout = 8000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(id);
}
}
function rateOfChange(prices, days) {
if (!prices || prices.length < days + 1) return null;
const recent = prices[prices.length - 1];
const past = prices[prices.length - 1 - days];
if (!past || past === 0) return null;
return ((recent - past) / past) * 100;
}
function sma(prices, period) {
if (!prices || prices.length < period) return null;
const slice = prices.slice(-period);
return slice.reduce((a, b) => a + b, 0) / period;
}
function extractClosePrices(chart) {
try {
const result = chart?.chart?.result?.[0];
return result?.indicators?.quote?.[0]?.close?.filter(p => p != null) || [];
} catch {
return [];
}
}
function extractVolumes(chart) {
try {
const result = chart?.chart?.result?.[0];
return result?.indicators?.quote?.[0]?.volume?.filter(v => v != null) || [];
} catch {
return [];
}
}
function extractAlignedPriceVolume(chart) {
try {
const result = chart?.chart?.result?.[0];
const closes = result?.indicators?.quote?.[0]?.close || [];
const volumes = result?.indicators?.quote?.[0]?.volume || [];
const pairs = [];
for (let i = 0; i < closes.length; i++) {
if (closes[i] != null && volumes[i] != null) {
pairs.push({ price: closes[i], volume: volumes[i] });
}
}
return pairs;
} catch {
return [];
}
}
function buildFallbackResult() {
return {
timestamp: new Date().toISOString(),
verdict: 'UNKNOWN',
bullishCount: 0,
totalCount: 0,
signals: {
liquidity: { status: 'UNKNOWN', value: null, sparkline: [] },
flowStructure: { status: 'UNKNOWN', btcReturn5: null, qqqReturn5: null },
macroRegime: { status: 'UNKNOWN', qqqRoc20: null, xlpRoc20: null },
technicalTrend: {
status: 'UNKNOWN',
btcPrice: null,
sma50: null,
sma200: null,
vwap30d: null,
mayerMultiple: null,
sparkline: [],
},
hashRate: { status: 'UNKNOWN', change30d: null },
miningCost: { status: 'UNKNOWN' },
fearGreed: { status: 'UNKNOWN', value: null, history: [] },
},
meta: { qqqSparkline: [] },
unavailable: true,
};
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: cors });
}
return new Response(null, { status: 204, headers: cors });
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } });
}
const now = Date.now();
if (cachedResponse && now - cacheTimestamp < CACHE_TTL * 1000) {
return new Response(JSON.stringify(cachedResponse), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=600` },
});
}
try {
const yahooBase = 'https://query1.finance.yahoo.com/v8/finance/chart';
const [jpyChart, btcChart, qqqChart, xlpChart, fearGreed, mempoolHash] = await Promise.allSettled([
fetchJSON(`${yahooBase}/JPY=X?range=1y&interval=1d`),
fetchJSON(`${yahooBase}/BTC-USD?range=1y&interval=1d`),
fetchJSON(`${yahooBase}/QQQ?range=1y&interval=1d`),
fetchJSON(`${yahooBase}/XLP?range=1y&interval=1d`),
fetchJSON('https://api.alternative.me/fng/?limit=30&format=json'),
fetchJSON('https://mempool.space/api/v1/mining/hashrate/1m'),
]);
const jpyPrices = jpyChart.status === 'fulfilled' ? extractClosePrices(jpyChart.value) : [];
const btcPrices = btcChart.status === 'fulfilled' ? extractClosePrices(btcChart.value) : [];
const btcVolumes = btcChart.status === 'fulfilled' ? extractVolumes(btcChart.value) : [];
const btcAligned = btcChart.status === 'fulfilled' ? extractAlignedPriceVolume(btcChart.value) : [];
const qqqPrices = qqqChart.status === 'fulfilled' ? extractClosePrices(qqqChart.value) : [];
const xlpPrices = xlpChart.status === 'fulfilled' ? extractClosePrices(xlpChart.value) : [];
// 1. Liquidity Signal (JPY 30d ROC)
const jpyRoc30 = rateOfChange(jpyPrices, 30);
const liquidityStatus = jpyRoc30 !== null
? (jpyRoc30 < -2 ? 'SQUEEZE' : 'NORMAL')
: 'UNKNOWN';
// 2. Flow Structure (BTC vs QQQ 5d return)
const btcReturn5 = rateOfChange(btcPrices, 5);
const qqqReturn5 = rateOfChange(qqqPrices, 5);
let flowStatus = 'UNKNOWN';
if (btcReturn5 !== null && qqqReturn5 !== null) {
const gap = btcReturn5 - qqqReturn5;
flowStatus = Math.abs(gap) > 5 ? 'PASSIVE GAP' : 'ALIGNED';
}
// 3. Macro Regime (QQQ/XLP 20d ROC)
const qqqRoc20 = rateOfChange(qqqPrices, 20);
const xlpRoc20 = rateOfChange(xlpPrices, 20);
let regimeStatus = 'UNKNOWN';
if (qqqRoc20 !== null && xlpRoc20 !== null) {
regimeStatus = qqqRoc20 > xlpRoc20 ? 'RISK-ON' : 'DEFENSIVE';
}
// 4. Technical Trend (BTC vs SMA50 + VWAP)
const btcSma50 = sma(btcPrices, 50);
const btcSma200 = sma(btcPrices, 200);
const btcCurrent = btcPrices.length > 0 ? btcPrices[btcPrices.length - 1] : null;
// Compute VWAP from aligned price/volume pairs (30d)
let btcVwap = null;
if (btcAligned.length >= 30) {
const last30 = btcAligned.slice(-30);
let sumPV = 0, sumV = 0;
for (const { price, volume } of last30) {
sumPV += price * volume;
sumV += volume;
}
if (sumV > 0) btcVwap = +(sumPV / sumV).toFixed(0);
}
let trendStatus = 'UNKNOWN';
let mayerMultiple = null;
if (btcCurrent && btcSma50) {
const aboveSma = btcCurrent > btcSma50 * 1.02;
const belowSma = btcCurrent < btcSma50 * 0.98;
const aboveVwap = btcVwap ? btcCurrent > btcVwap : null;
if (aboveSma && aboveVwap !== false) trendStatus = 'BULLISH';
else if (belowSma && aboveVwap !== true) trendStatus = 'BEARISH';
else trendStatus = 'NEUTRAL';
}
if (btcCurrent && btcSma200) {
mayerMultiple = +(btcCurrent / btcSma200).toFixed(2);
}
// 5. Hash Rate
let hashStatus = 'UNKNOWN';
let hashChange = null;
if (mempoolHash.status === 'fulfilled') {
const hr = mempoolHash.value?.hashrates || mempoolHash.value;
if (Array.isArray(hr) && hr.length >= 2) {
const recent = hr[hr.length - 1]?.avgHashrate || hr[hr.length - 1];
const older = hr[0]?.avgHashrate || hr[0];
if (recent && older && older > 0) {
hashChange = +((recent - older) / older * 100).toFixed(1);
hashStatus = hashChange > 3 ? 'GROWING' : hashChange < -3 ? 'DECLINING' : 'STABLE';
}
}
}
// 6. Mining Cost (hashrate-based model)
let miningStatus = 'UNKNOWN';
if (btcCurrent && hashChange !== null) {
miningStatus = btcCurrent > 60000 ? 'PROFITABLE' : btcCurrent > 40000 ? 'TIGHT' : 'SQUEEZE';
}
// 7. Fear & Greed
let fgValue = null;
let fgLabel = 'UNKNOWN';
let fgHistory = [];
if (fearGreed.status === 'fulfilled' && fearGreed.value?.data) {
const data = fearGreed.value.data;
const parsed = parseInt(data[0]?.value, 10);
fgValue = Number.isFinite(parsed) ? parsed : null;
fgLabel = data[0]?.value_classification || 'UNKNOWN';
fgHistory = data.slice(0, 30).map(d => ({
value: parseInt(d.value, 10),
date: new Date(parseInt(d.timestamp, 10) * 1000).toISOString().slice(0, 10),
})).reverse();
}
// Sparkline data
const btcSparkline = btcPrices.slice(-30);
const qqqSparkline = qqqPrices.slice(-30);
const jpySparkline = jpyPrices.slice(-30);
// Overall Verdict
let bullishCount = 0;
let totalCount = 0;
const signals = [
{ name: 'Liquidity', status: liquidityStatus, bullish: liquidityStatus === 'NORMAL' },
{ name: 'Flow Structure', status: flowStatus, bullish: flowStatus === 'ALIGNED' },
{ name: 'Macro Regime', status: regimeStatus, bullish: regimeStatus === 'RISK-ON' },
{ name: 'Technical Trend', status: trendStatus, bullish: trendStatus === 'BULLISH' },
{ name: 'Hash Rate', status: hashStatus, bullish: hashStatus === 'GROWING' },
{ name: 'Mining Cost', status: miningStatus, bullish: miningStatus === 'PROFITABLE' },
{ name: 'Fear & Greed', status: fgLabel, bullish: fgValue !== null && fgValue > 50 },
];
for (const s of signals) {
if (s.status !== 'UNKNOWN') {
totalCount++;
if (s.bullish) bullishCount++;
}
}
const verdict = totalCount === 0 ? 'UNKNOWN' : (bullishCount / totalCount >= 0.57 ? 'BUY' : 'CASH');
const result = {
timestamp: new Date().toISOString(),
verdict,
bullishCount,
totalCount,
signals: {
liquidity: { status: liquidityStatus, value: jpyRoc30 !== null ? +jpyRoc30.toFixed(2) : null, sparkline: jpySparkline },
flowStructure: { status: flowStatus, btcReturn5: btcReturn5 !== null ? +btcReturn5.toFixed(2) : null, qqqReturn5: qqqReturn5 !== null ? +qqqReturn5.toFixed(2) : null },
macroRegime: { status: regimeStatus, qqqRoc20: qqqRoc20 !== null ? +qqqRoc20.toFixed(2) : null, xlpRoc20: xlpRoc20 !== null ? +xlpRoc20.toFixed(2) : null },
technicalTrend: { status: trendStatus, btcPrice: btcCurrent, sma50: btcSma50 ? +btcSma50.toFixed(0) : null, sma200: btcSma200 ? +btcSma200.toFixed(0) : null, vwap30d: btcVwap, mayerMultiple, sparkline: btcSparkline },
hashRate: { status: hashStatus, change30d: hashChange },
miningCost: { status: miningStatus },
fearGreed: { status: fgLabel, value: fgValue, history: fgHistory },
},
meta: { qqqSparkline },
};
cachedResponse = result;
cacheTimestamp = now;
return new Response(JSON.stringify(result), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=600` },
});
} catch (err) {
const fallback = cachedResponse || buildFallbackResult();
cachedResponse = fallback;
cacheTimestamp = now;
return new Response(JSON.stringify(fallback), {
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=30' },
});
}
}
+7 -1
View File
@@ -1,6 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch(
'https://msi.nga.mil/api/publications/broadcast-warn?output=json&status=A'
@@ -8,7 +14,7 @@ export default async function handler(req) {
const data = await response.text();
return new Response(data, {
status: response.status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors, 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' },
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
+224
View File
@@ -0,0 +1,224 @@
/**
* Dynamic OG Image Generator for Story Sharing
* Returns an SVG image (1200x630) rich intelligence card for social previews.
*/
const COUNTRY_NAMES = {
UA: 'Ukraine', RU: 'Russia', CN: 'China', US: 'United States',
IR: 'Iran', IL: 'Israel', TW: 'Taiwan', KP: 'North Korea',
SA: 'Saudi Arabia', TR: 'Turkey', PL: 'Poland', DE: 'Germany',
FR: 'France', GB: 'United Kingdom', IN: 'India', PK: 'Pakistan',
SY: 'Syria', YE: 'Yemen', MM: 'Myanmar', VE: 'Venezuela',
};
const LEVEL_COLORS = {
critical: '#ef4444', high: '#f97316', elevated: '#eab308',
normal: '#22c55e', low: '#3b82f6',
};
const LEVEL_LABELS = {
critical: 'CRITICAL INSTABILITY',
high: 'HIGH INSTABILITY',
elevated: 'ELEVATED INSTABILITY',
normal: 'STABLE',
low: 'LOW RISK',
};
export default function handler(req, res) {
const url = new URL(req.url, `https://${req.headers.host}`);
const countryCode = (url.searchParams.get('c') || '').toUpperCase();
const type = url.searchParams.get('t') || 'ciianalysis';
const score = url.searchParams.get('s');
const level = url.searchParams.get('l') || 'normal';
const countryName = COUNTRY_NAMES[countryCode] || countryCode || 'Global';
const levelColor = LEVEL_COLORS[level] || '#eab308';
const levelLabel = LEVEL_LABELS[level] || 'MONITORING';
const parsedScore = score ? Number.parseInt(score, 10) : Number.NaN;
const scoreNum = Number.isFinite(parsedScore)
? Math.max(0, Math.min(100, parsedScore))
: null;
const dateStr = new Date().toISOString().slice(0, 10);
// Score arc (semicircle gauge)
const arcRadius = 90;
const arcCx = 960;
const arcCy = 340;
const scoreAngle = scoreNum !== null ? (scoreNum / 100) * Math.PI : 0;
const arcEndX = arcCx - arcRadius * Math.cos(scoreAngle);
const arcEndY = arcCy - arcRadius * Math.sin(scoreAngle);
const largeArc = scoreNum > 50 ? 1 : 0;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0c0c18"/>
<stop offset="100%" stop-color="#0a0a12"/>
</linearGradient>
<linearGradient id="sidebar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${levelColor}"/>
<stop offset="100%" stop-color="${levelColor}88"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bg)"/>
<!-- Left accent sidebar -->
<rect x="0" y="0" width="8" height="630" fill="url(#sidebar)"/>
<!-- Top accent line -->
<rect x="8" y="0" width="1192" height="3" fill="${levelColor}" opacity="0.4"/>
<!-- Subtle grid -->
<g opacity="0.03">
${Array.from({length: 30}, (_, i) => `<line x1="${i*40}" y1="0" x2="${i*40}" y2="630" stroke="#fff" stroke-width="1"/>`).join('\n ')}
${Array.from({length: 16}, (_, i) => `<line x1="0" y1="${i*40}" x2="1200" y2="${i*40}" stroke="#fff" stroke-width="1"/>`).join('\n ')}
</g>
<!-- WORLDMONITOR brand -->
<text x="60" y="56" font-family="system-ui, -apple-system, sans-serif" font-size="18" font-weight="700" fill="${levelColor}" letter-spacing="6"
>WORLDMONITOR</text>
<!-- Status pill -->
<rect x="290" y="38" width="${levelLabel.length * 9 + 24}" height="26" rx="13" fill="${levelColor}" opacity="0.15"/>
<text x="${290 + (levelLabel.length * 9 + 24) / 2}" y="56" font-family="system-ui, sans-serif" font-size="13" font-weight="700" fill="${levelColor}" text-anchor="middle"
>${levelLabel}</text>
<!-- Date -->
<text x="1140" y="56" font-family="system-ui, sans-serif" font-size="16" fill="#666" text-anchor="end"
>${dateStr}</text>
<!-- Separator -->
<line x1="60" y1="76" x2="1140" y2="76" stroke="#222" stroke-width="1"/>
<!-- Country name (large) -->
<text x="60" y="160" font-family="system-ui, -apple-system, sans-serif" font-size="82" font-weight="800" fill="#ffffff" letter-spacing="-1"
>${escapeXml(countryName.toUpperCase())}</text>
<!-- Country code badge -->
<rect x="1060" y="120" width="80" height="44" rx="8" fill="rgba(255,255,255,0.08)" stroke="${levelColor}" stroke-width="1" stroke-opacity="0.3"/>
<text x="1100" y="150" font-family="system-ui, sans-serif" font-size="24" font-weight="700" fill="#aaa" text-anchor="middle"
>${escapeXml(countryCode)}</text>
<!-- Subtitle -->
<text x="60" y="200" font-family="system-ui, sans-serif" font-size="22" fill="#666" letter-spacing="3"
>INTELLIGENCE BRIEF</text>
${scoreNum !== null ? `
<!-- LEFT COLUMN: Data cards -->
<!-- CII Score large display -->
<text x="60" y="310" font-family="system-ui, -apple-system, sans-serif" font-size="120" font-weight="800" fill="${levelColor}"
>${scoreNum}</text>
<text x="${60 + String(scoreNum).length * 68}" y="310" font-family="system-ui, sans-serif" font-size="48" fill="#555"
>/100</text>
<text x="60" y="345" font-family="system-ui, sans-serif" font-size="18" fill="#777" letter-spacing="4"
>INSTABILITY INDEX</text>
<!-- Score bar (full width left column) -->
<rect x="60" y="370" width="560" height="12" rx="6" fill="#1a1a2e"/>
<rect x="60" y="370" width="${Math.min(scoreNum, 100) * 5.6}" height="12" rx="6" fill="${levelColor}"/>
<!-- Tick marks -->
<line x1="200" y1="370" x2="200" y2="382" stroke="#333" stroke-width="1"/>
<line x1="340" y1="370" x2="340" y2="382" stroke="#333" stroke-width="1"/>
<line x1="480" y1="370" x2="480" y2="382" stroke="#333" stroke-width="1"/>
<text x="60" y="402" font-family="system-ui, sans-serif" font-size="12" fill="#555">0</text>
<text x="197" y="402" font-family="system-ui, sans-serif" font-size="12" fill="#555">25</text>
<text x="334" y="402" font-family="system-ui, sans-serif" font-size="12" fill="#555">50</text>
<text x="474" y="402" font-family="system-ui, sans-serif" font-size="12" fill="#555">75</text>
<text x="600" y="402" font-family="system-ui, sans-serif" font-size="12" fill="#555">100</text>
<!-- RIGHT COLUMN: Score arc gauge -->
<!-- Arc background -->
<path d="M ${arcCx - arcRadius},${arcCy} A ${arcRadius} ${arcRadius} 0 1 1 ${arcCx + arcRadius},${arcCy}"
fill="none" stroke="#1a1a2e" stroke-width="16" stroke-linecap="round"/>
<!-- Arc fill -->
${scoreNum > 0 ? `<path d="M ${arcCx + arcRadius},${arcCy} A ${arcRadius} ${arcRadius} 0 ${largeArc} 0 ${arcEndX.toFixed(1)},${arcEndY.toFixed(1)}"
fill="none" stroke="${levelColor}" stroke-width="16" stroke-linecap="round"/>` : ''}
<!-- Score in center of arc -->
<text x="${arcCx}" y="${arcCy - 20}" font-family="system-ui, -apple-system, sans-serif" font-size="52" font-weight="800" fill="${levelColor}" text-anchor="middle"
>${scoreNum}</text>
<text x="${arcCx}" y="${arcCy + 10}" font-family="system-ui, sans-serif" font-size="18" fill="#888" text-anchor="middle"
>/100</text>
<!-- Level badge under arc -->
<rect x="${arcCx - (level.length * 10 + 20) / 2}" y="${arcCy + 24}" width="${level.length * 10 + 20}" height="30" rx="6" fill="${levelColor}"/>
<text x="${arcCx}" y="${arcCy + 45}" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#fff" text-anchor="middle"
>${level.toUpperCase()}</text>
<!-- Data indicators row -->
<line x1="60" y1="430" x2="1140" y2="430" stroke="#222" stroke-width="1"/>
<rect x="60" y="448" width="10" height="10" rx="2" fill="#ef4444"/>
<text x="80" y="458" font-family="system-ui, sans-serif" font-size="15" fill="#aaa">Threat Classification</text>
<rect x="260" y="448" width="10" height="10" rx="2" fill="#f97316"/>
<text x="280" y="458" font-family="system-ui, sans-serif" font-size="15" fill="#aaa">Military Posture</text>
<rect x="440" y="448" width="10" height="10" rx="2" fill="#eab308"/>
<text x="460" y="458" font-family="system-ui, sans-serif" font-size="15" fill="#aaa">Prediction Markets</text>
<rect x="650" y="448" width="10" height="10" rx="2" fill="#8b5cf6"/>
<text x="670" y="458" font-family="system-ui, sans-serif" font-size="15" fill="#aaa">Signal Convergence</text>
<rect x="860" y="448" width="10" height="10" rx="2" fill="#3b82f6"/>
<text x="880" y="458" font-family="system-ui, sans-serif" font-size="15" fill="#aaa">Active Signals</text>
` : `
<!-- No score available show feature overview -->
<text x="60" y="290" font-family="system-ui, -apple-system, sans-serif" font-size="40" fill="#ddd" font-weight="600"
>Real-time intelligence analysis</text>
<line x1="60" y1="320" x2="1140" y2="320" stroke="#222" stroke-width="1"/>
<!-- Feature cards -->
<rect x="60" y="345" width="250" height="80" rx="8" fill="#111" stroke="#222" stroke-width="1"/>
<text x="80" y="375" font-family="system-ui, sans-serif" font-size="16" fill="${levelColor}" font-weight="700">Instability Index</text>
<text x="80" y="400" font-family="system-ui, sans-serif" font-size="13" fill="#888">20 countries monitored</text>
<rect x="330" y="345" width="250" height="80" rx="8" fill="#111" stroke="#222" stroke-width="1"/>
<text x="350" y="375" font-family="system-ui, sans-serif" font-size="16" fill="#f97316" font-weight="700">Military Tracking</text>
<text x="350" y="400" font-family="system-ui, sans-serif" font-size="13" fill="#888">Live flights &amp; vessels</text>
<rect x="600" y="345" width="250" height="80" rx="8" fill="#111" stroke="#222" stroke-width="1"/>
<text x="620" y="375" font-family="system-ui, sans-serif" font-size="16" fill="#eab308" font-weight="700">Prediction Markets</text>
<text x="620" y="400" font-family="system-ui, sans-serif" font-size="13" fill="#888">Polymarket integration</text>
<rect x="870" y="345" width="270" height="80" rx="8" fill="#111" stroke="#222" stroke-width="1"/>
<text x="890" y="375" font-family="system-ui, sans-serif" font-size="16" fill="#8b5cf6" font-weight="700">Signal Convergence</text>
<text x="890" y="400" font-family="system-ui, sans-serif" font-size="13" fill="#888">Multi-source correlation</text>
`}
<!-- Bottom bar -->
<rect x="0" y="490" width="1200" height="140" fill="#080810"/>
<line x1="0" y1="490" x2="1200" y2="490" stroke="#222" stroke-width="1"/>
<!-- Logo area -->
<circle cx="92" cy="545" r="24" fill="none" stroke="${levelColor}" stroke-width="2"/>
<text x="92" y="551" font-family="system-ui, sans-serif" font-size="18" font-weight="800" fill="${levelColor}" text-anchor="middle"
>W</text>
<text x="130" y="538" font-family="system-ui, -apple-system, sans-serif" font-size="22" font-weight="700" fill="#ddd" letter-spacing="3"
>WORLDMONITOR</text>
<text x="130" y="562" font-family="system-ui, sans-serif" font-size="15" fill="#777"
>Real-time global intelligence monitoring</text>
<!-- CTA -->
<rect x="920" y="524" width="220" height="42" rx="21" fill="${levelColor}"/>
<text x="1030" y="551" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#fff" text-anchor="middle"
>VIEW FULL BRIEF </text>
<!-- URL + date -->
<text x="60" y="610" font-family="system-ui, sans-serif" font-size="14" fill="#555"
>worldmonitor.app · ${dateStr} · Free &amp; open source</text>
</svg>`;
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600');
res.status(200).send(svg);
}
function escapeXml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+294
View File
@@ -0,0 +1,294 @@
/**
* OpenRouter API Summarization Endpoint with Redis Caching
* Fallback when Groq is rate-limited
* Uses OpenRouter auto-routed free model
* Free tier: 50 requests/day (20/min)
* Server-side Redis cache for cross-user deduplication
*/
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'openrouter/free';
const CACHE_TTL_SECONDS = 86400; // 24 hours
const CACHE_VERSION = 'v3';
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
const normalizedVariant = typeof variant === 'string' && variant ? variant.toLowerCase() : 'full';
const normalizedLang = typeof lang === 'string' && lang ? lang.toLowerCase() : 'en';
if (mode === 'translate') {
const targetLang = normalizedVariant || normalizedLang;
return `summary:${CACHE_VERSION}:${mode}:${targetLang}:${hash}${geoHash}`;
}
return `summary:${CACHE_VERSION}:${mode}:${normalizedVariant}:${normalizedLang}:${hash}${geoHash}`;
}
// Deduplicate similar headlines (same story from different sources)
function deduplicateHeadlines(headlines) {
const seen = new Set();
const unique = [];
for (const headline of headlines) {
// Normalize: lowercase, remove punctuation, collapse whitespace
const normalized = headline.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
// Extract key words (4+ chars) for similarity check
const words = new Set(normalized.split(' ').filter(w => w.length >= 4));
// Check if this headline is too similar to any we've seen
let isDuplicate = false;
for (const seenWords of seen) {
const intersection = [...words].filter(w => seenWords.has(w));
const similarity = intersection.length / Math.min(words.size, seenWords.size);
if (similarity > 0.6) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
seen.add(words);
unique.push(headline);
}
}
return unique;
}
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ summary: null, fallback: true, skipped: true, reason: 'OPENROUTER_API_KEY not configured' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
try {
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Check cache first (shared with Groq endpoint)
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[OpenRouter] Cache hit:', cacheKey);
return new Response(JSON.stringify({
summary: cached.summary,
model: cached.model || MODEL,
provider: 'cache',
cached: true,
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// Deduplicate similar headlines (same story from different sources)
const uniqueHeadlines = deduplicateHeadlines(headlines.slice(0, 8));
const headlineText = uniqueHeadlines.map((h, i) => `${i + 1}. ${h}`).join('\n');
let systemPrompt, userPrompt;
// Include intelligence synthesis context in prompt if available
const intelSection = geoContext ? `\n\n${geoContext}` : '';
// Current date context for LLM (models may have outdated knowledge)
const isTechVariant = variant === 'tech';
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
if (mode === 'brief') {
if (isTechVariant) {
// Tech variant: focus on startups, AI, funding, product launches
systemPrompt = `${dateContext}
Summarize the key tech/startup development in 2-3 sentences.
Rules:
- Focus ONLY on technology, startups, AI, funding, product launches, or developer news
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
- Lead with the company/product/technology name
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
- No bullet points, no meta-commentary${langInstruction}`;
} else {
// Full variant: geopolitical focus
systemPrompt = `${dateContext}
Summarize the key development in 2-3 sentences.
Rules:
- Lead with WHAT happened and WHERE - be specific
- NEVER start with "Breaking news", "Good evening", "Tonight", or TV-style openings
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary${langInstruction}`;
}
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
if (isTechVariant) {
systemPrompt = `${dateContext}
Analyze the tech/startup trend in 2-3 sentences.
Rules:
- Focus ONLY on technology implications: funding trends, AI developments, market shifts, product strategy
- IGNORE political implications, trade wars, government unless directly about tech policy
- Lead with the insight for tech industry
- Connect to startup ecosystem, VC trends, or technical implications`;
} else {
systemPrompt = `${dateContext}
Provide analysis in 2-3 sentences. Be direct and specific.
Rules:
- Lead with the insight - what's significant and why
- NEVER start with "Breaking news", "Tonight", "The key/dominant narrative is"
- Start with substance: "Iran faces...", "The escalation in...", "Multiple signals suggest..."
- CRITICAL FOCAL POINTS are your main actors - explain WHY they matter
- If focal points show news-signal correlation, flag as escalation
- Connect dots, be specific about implications`;
}
userPrompt = isTechVariant
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else if (mode === 'translate') {
const targetLang = variant; // In translate mode, variant param holds the target language code
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
Rules:
- Maintain the original tone and journalistic style.
- Do NOT add any conversational filler.
- Output ONLY the translated text.`;
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
} else {
systemPrompt = isTechVariant
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://worldmonitor.app',
'X-Title': 'WorldMonitor',
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.3,
max_tokens: 150,
top_p: 0.9,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[OpenRouter] API error:', response.status, errorText);
// Return fallback signal for rate limiting
if (response.status === 429) {
return new Response(JSON.stringify({ error: 'Rate limited', fallback: true }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'OpenRouter API error', fallback: true }), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
}
const data = await response.json();
const summary = data.choices?.[0]?.message?.content?.trim();
if (!summary) {
return new Response(JSON.stringify({ error: 'Empty response', fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
// Store in cache (shared with Groq endpoint)
await setCachedJson(cacheKey, {
summary,
model: MODEL,
timestamp: Date.now(),
}, CACHE_TTL_SECONDS);
return new Response(JSON.stringify({
summary,
model: MODEL,
provider: 'openrouter',
cached: false,
tokens: data.usage?.total_tokens || 0,
}), {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
} catch (error) {
console.error('[OpenRouter] Error:', error);
return new Response(JSON.stringify({ error: error.message, fallback: true }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
+11 -5
View File
@@ -1,8 +1,14 @@
// OpenSky Network API proxy - v3
// Note: OpenSky seems to block some cloud provider IPs
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
// Build OpenSky API URL with bounding box params
@@ -29,7 +35,7 @@ export default async function handler(req) {
if (response.status === 429) {
return Response.json({ error: 'Rate limited', time: Date.now(), states: null }, {
status: 429,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: cors,
});
}
@@ -42,7 +48,7 @@ export default async function handler(req) {
states: null
}, {
status: response.status,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: cors,
});
}
@@ -50,8 +56,8 @@ export default async function handler(req) {
return Response.json(data, {
status: response.status,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=30',
...cors,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
},
});
} catch (error) {
@@ -61,7 +67,7 @@ export default async function handler(req) {
states: null
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: cors,
});
}
}
+9 -4
View File
@@ -1,6 +1,11 @@
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
export const config = { runtime: 'edge' };
export default async function handler() {
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch('https://www.pizzint.watch/api/dashboard-data', {
headers: {
@@ -18,14 +23,14 @@ export default async function handler() {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=60',
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch PizzINT data', details: error.message }), {
status: 502,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+8 -3
View File
@@ -1,6 +1,11 @@
import { getCorsHeaders, isDisallowedOrigin } from '../../_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const pairs = url.searchParams.get('pairs') || 'usa_russia,russia_ukraine,usa_china,china_taiwan,usa_iran,usa_venezuela';
const dateStart = url.searchParams.get('dateStart');
@@ -28,14 +33,14 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch GDELT data', details: error.message }), {
status: 502,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+70 -15
View File
@@ -1,5 +1,9 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const GAMMA_BASE = 'https://gamma-api.polymarket.com';
const ALLOWED_ORDER = ['volume', 'liquidity', 'startDate', 'endDate', 'spread'];
const MAX_LIMIT = 100;
const MIN_LIMIT = 1;
@@ -19,35 +23,86 @@ function validateOrder(val) {
return ALLOWED_ORDER.includes(val) ? val : 'volume';
}
function sanitizeTagSlug(val) {
if (!val) return null;
return val.replace(/[^a-z0-9-]/gi, '').slice(0, 100) || null;
}
async function tryFetch(url, timeoutMs = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.text();
} catch (err) {
clearTimeout(timer);
throw err;
}
}
function buildUrl(base, endpoint, params) {
if (endpoint === 'events') {
return `${base}/events?${params}`;
}
return `${base}/markets?${params}`;
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const endpoint = url.searchParams.get('endpoint') || 'markets';
const closed = validateBoolean(url.searchParams.get('closed'), 'false');
const order = validateOrder(url.searchParams.get('order'));
const ascending = validateBoolean(url.searchParams.get('ascending'), 'false');
const limit = validateLimit(url.searchParams.get('limit'));
try {
const polyUrl = `https://gamma-api.polymarket.com/markets?closed=${closed}&order=${order}&ascending=${ascending}&limit=${limit}`;
const response = await fetch(polyUrl, {
headers: {
'Accept': 'application/json',
},
});
const params = new URLSearchParams({
closed,
order,
ascending,
limit: String(limit),
});
const data = await response.text();
if (endpoint === 'events') {
const tag = sanitizeTagSlug(url.searchParams.get('tag'));
if (tag) params.set('tag_slug', tag);
}
// Gamma API is behind Cloudflare which blocks server-side TLS connections
// (JA3 fingerprint detection). Only browser-originated requests succeed.
// We still try in case Cloudflare policy changes, but gracefully return empty on failure.
try {
const data = await tryFetch(buildUrl(GAMMA_BASE, endpoint, params));
return new Response(data, {
status: response.status,
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=120',
...cors,
'Cache-Control': 'public, max-age=120, s-maxage=120, stale-while-revalidate=60',
'X-Polymarket-Source': 'gamma',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
} catch (err) {
// Expected: Cloudflare blocks non-browser TLS connections
return new Response(JSON.stringify([]), {
status: 200,
headers: {
'Content-Type': 'application/json',
...cors,
'X-Polymarket-Error': err.message,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
}
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Risk Scores API - Cached CII and Strategic Risk computation
* Eliminates 15-minute "learning mode" for users by pre-computing scores
* Uses Upstash Redis for cross-user caching (10-minute TTL)
*/
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const CACHE_TTL_SECONDS = 600; // 10 minutes
const STALE_CACHE_TTL_SECONDS = 3600; // 1 hour - serve stale when API fails
const CACHE_KEY = 'risk:scores:v2';
const STALE_CACHE_KEY = 'risk:scores:stale:v2';
// Tier 1 countries for CII
const TIER1_COUNTRIES = {
US: 'United States', RU: 'Russia', CN: 'China', UA: 'Ukraine', IR: 'Iran',
IL: 'Israel', TW: 'Taiwan', KP: 'North Korea', SA: 'Saudi Arabia', TR: 'Turkey',
PL: 'Poland', DE: 'Germany', FR: 'France', GB: 'United Kingdom', IN: 'India',
PK: 'Pakistan', SY: 'Syria', YE: 'Yemen', MM: 'Myanmar', VE: 'Venezuela',
};
// Baseline geopolitical risk (0-50)
const BASELINE_RISK = {
US: 5, RU: 35, CN: 25, UA: 50, IR: 40, IL: 45, TW: 30, KP: 45,
SA: 20, TR: 25, PL: 10, DE: 5, FR: 10, GB: 5, IN: 20, PK: 35,
SY: 50, YE: 50, MM: 45, VE: 40,
};
// Event significance multipliers
const EVENT_MULTIPLIER = {
US: 0.3, RU: 2.0, CN: 2.5, UA: 0.8, IR: 2.0, IL: 0.7, TW: 1.5, KP: 3.0,
SA: 2.0, TR: 1.2, PL: 0.8, DE: 0.5, FR: 0.6, GB: 0.5, IN: 0.8, PK: 1.5,
SY: 0.7, YE: 0.7, MM: 1.8, VE: 1.8,
};
// Country keywords for matching
const COUNTRY_KEYWORDS = {
US: ['united states', 'usa', 'america', 'washington', 'biden', 'trump', 'pentagon'],
RU: ['russia', 'moscow', 'kremlin', 'putin'],
CN: ['china', 'beijing', 'xi jinping', 'prc'],
UA: ['ukraine', 'kyiv', 'zelensky', 'donbas'],
IR: ['iran', 'tehran', 'khamenei', 'irgc'],
IL: ['israel', 'tel aviv', 'netanyahu', 'idf', 'gaza'],
TW: ['taiwan', 'taipei'],
KP: ['north korea', 'pyongyang', 'kim jong'],
SA: ['saudi arabia', 'riyadh'],
TR: ['turkey', 'ankara', 'erdogan'],
PL: ['poland', 'warsaw'],
DE: ['germany', 'berlin'],
FR: ['france', 'paris', 'macron'],
GB: ['britain', 'uk', 'london'],
IN: ['india', 'delhi', 'modi'],
PK: ['pakistan', 'islamabad'],
SY: ['syria', 'damascus'],
YE: ['yemen', 'sanaa', 'houthi'],
MM: ['myanmar', 'burma'],
VE: ['venezuela', 'caracas', 'maduro'],
};
function normalizeCountryName(text) {
const lower = text.toLowerCase();
for (const [code, keywords] of Object.entries(COUNTRY_KEYWORDS)) {
if (keywords.some(kw => lower.includes(kw))) return code;
}
return null;
}
function getScoreLevel(score) {
if (score >= 70) return 'critical';
if (score >= 55) return 'high';
if (score >= 40) return 'elevated';
if (score >= 25) return 'normal';
return 'low';
}
async function fetchACLEDProtests() {
try {
// Fetch recent protests from ACLED (last 7 days)
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
// ACLED API now requires authentication - new endpoint as of Jan 2026
const token = process.env.ACLED_ACCESS_TOKEN;
const headers = { 'Accept': 'application/json' };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Updated endpoint: acleddata.com/api/ instead of api.acleddata.com
const response = await fetch(
`https://acleddata.com/api/acled/read?_format=json&event_type=Protests&event_type=Riots&event_date=${startDate}|${endDate}&event_date_where=BETWEEN&limit=500`,
{
headers,
signal: controller.signal,
}
);
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text().catch(() => '');
console.warn('[RiskScores] ACLED fetch failed:', response.status, text.slice(0, 200));
// Check for auth errors specifically
if (response.status === 401 || response.status === 403) {
throw new Error('ACLED API requires valid authentication token');
}
throw new Error(`ACLED API error: ${response.status}`);
}
const data = await response.json();
// Check for API-level error in response
if (data.message) {
console.warn('[RiskScores] ACLED API returned message:', data.message);
throw new Error(data.message);
}
if (data.error || data.success === false) {
console.warn('[RiskScores] ACLED API returned error:', data.error || 'unknown');
throw new Error(data.error || 'ACLED API error');
}
return data.data || [];
} catch (error) {
console.warn('[RiskScores] ACLED error:', error.message);
throw error; // Re-throw to trigger stale cache fallback
}
}
function computeCIIScores(protests) {
const countryEvents = new Map();
// Count events per country
for (const event of protests) {
const country = event.country;
const code = normalizeCountryName(country);
if (code && TIER1_COUNTRIES[code]) {
const count = countryEvents.get(code) || { protests: 0, riots: 0 };
if (event.event_type === 'Riots') {
count.riots++;
} else {
count.protests++;
}
countryEvents.set(code, count);
}
}
// Compute scores for all Tier 1 countries
const scores = [];
const now = new Date();
for (const [code, name] of Object.entries(TIER1_COUNTRIES)) {
const events = countryEvents.get(code) || { protests: 0, riots: 0 };
const baseline = BASELINE_RISK[code] || 20;
const multiplier = EVENT_MULTIPLIER[code] || 1.0;
// Unrest component: protests + riots (riots weighted 2x)
const unrestRaw = (events.protests + events.riots * 2) * multiplier;
const unrest = Math.min(100, Math.round(unrestRaw * 2));
// Security component: baseline + riot contribution
const security = Math.min(100, baseline + events.riots * multiplier * 5);
// Information component: based on event count (proxy for news coverage)
const totalEvents = events.protests + events.riots;
const information = Math.min(100, totalEvents * multiplier * 3);
// Composite score: weighted average + baseline
const composite = Math.min(100, Math.round(
baseline +
(unrest * 0.4 + security * 0.35 + information * 0.25) * 0.5
));
scores.push({
code,
name,
score: composite,
level: getScoreLevel(composite),
trend: 'stable', // Would need historical data for real trend
change24h: 0,
components: { unrest, security, information },
lastUpdated: now.toISOString(),
});
}
// Sort by score descending
scores.sort((a, b) => b.score - a.score);
return scores;
}
function computeStrategicRisk(ciiScores) {
// Top 5 CII scores weighted average
const top5 = ciiScores.slice(0, 5);
const weights = top5.map((_, i) => 1 - (i * 0.15)); // [1.0, 0.85, 0.70, 0.55, 0.40]
const totalWeight = weights.reduce((sum, w) => sum + w, 0); // 3.5
const weightedSum = top5.reduce((sum, s, i) => sum + s.score * weights[i], 0);
const ciiComponent = weightedSum / totalWeight;
// Overall strategic risk
const overallScore = Math.round(ciiComponent * 0.7 + 15); // 30% baseline
return {
score: Math.min(100, overallScore),
level: getScoreLevel(overallScore),
trend: 'stable',
lastUpdated: new Date().toISOString(),
contributors: top5.map(s => ({
country: s.name,
code: s.code,
score: s.score,
level: s.level,
})),
};
}
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'GET, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
if (!process.env.ACLED_ACCESS_TOKEN) {
const baselineScores = computeCIIScores([]);
const baselineStrategic = computeStrategicRisk(baselineScores);
return new Response(JSON.stringify({
cii: baselineScores,
strategicRisk: baselineStrategic,
protestCount: 0,
computedAt: new Date().toISOString(),
baseline: true,
error: 'ACLED token not configured - showing baseline risk assessments',
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
}
// Check cache first
const cached = await getCachedJson(CACHE_KEY);
if (cached && typeof cached === 'object') {
console.log('[RiskScores] Cache hit');
return new Response(JSON.stringify({
...cached,
cached: true,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
}
try {
// Fetch ACLED protests
console.log('[RiskScores] Computing scores...');
const protests = await fetchACLEDProtests();
// Compute CII scores
const ciiScores = computeCIIScores(protests);
// Compute strategic risk
const strategicRisk = computeStrategicRisk(ciiScores);
const result = {
cii: ciiScores,
strategicRisk,
protestCount: protests.length,
computedAt: new Date().toISOString(),
};
// Cache (both regular and stale backup)
await Promise.all([
setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS),
setCachedJson(STALE_CACHE_KEY, result, STALE_CACHE_TTL_SECONDS),
]);
return new Response(JSON.stringify({
...result,
cached: false,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
console.error('[RiskScores] Error:', error);
// Try to return stale cached data
const stale = await getCachedJson(STALE_CACHE_KEY);
if (stale && typeof stale === 'object') {
console.log('[RiskScores] Returning stale cache due to error');
return new Response(JSON.stringify({
...stale,
cached: true,
stale: true,
error: 'Using cached data - ACLED temporarily unavailable',
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
}
// Final fallback: return baseline scores without unrest data
console.log('[RiskScores] Returning baseline scores (no ACLED data)');
const baselineScores = computeCIIScores([]); // Empty protests = baseline only
const baselineStrategic = computeStrategicRisk(baselineScores);
return new Response(JSON.stringify({
cii: baselineScores,
strategicRisk: baselineStrategic,
protestCount: 0,
computedAt: new Date().toISOString(),
baseline: true,
error: 'ACLED unavailable - showing baseline risk assessments',
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
}
}
+109 -5
View File
@@ -1,3 +1,5 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Fetch with timeout
@@ -54,6 +56,7 @@ const ALLOWED_DOMAINS = [
'www.reutersagency.com',
'feeds.reuters.com',
'rsshub.app',
'asia.nikkei.com',
'www.cfr.org',
'www.csis.org',
'www.politico.com',
@@ -117,16 +120,80 @@ const ALLOWED_DOMAINS = [
'www.cbinsights.com',
// Accelerators
'www.techstars.com',
// Middle East & Regional News
'english.alarabiya.net',
'www.arabnews.com',
'www.timesofisrael.com',
'www.haaretz.com',
'www.scmp.com',
'kyivindependent.com',
'www.themoscowtimes.com',
'feeds.24.com',
'feeds.capi24.com', // News24 redirect destination
// International News Sources
'www.france24.com',
'www.euronews.com',
'www.lemonde.fr',
'rss.dw.com',
'www.africanews.com',
'www.lasillavacia.com',
'www.channelnewsasia.com',
'www.thehindu.com',
// International Organizations
'news.un.org',
'www.iaea.org',
'www.who.int',
'www.cisa.gov',
'www.crisisgroup.org',
// Think Tanks & Research (Added 2026-01-29)
'rusi.org',
'warontherocks.com',
'www.aei.org',
'responsiblestatecraft.org',
'www.fpri.org',
'jamestown.org',
'www.chathamhouse.org',
'ecfr.eu',
'www.gmfus.org',
'www.wilsoncenter.org',
'www.lowyinstitute.org',
'www.mei.edu',
'www.stimson.org',
'www.cnas.org',
'carnegieendowment.org',
'www.rand.org',
'fas.org',
'www.armscontrol.org',
'www.nti.org',
'thebulletin.org',
'www.iss.europa.eu',
// Economic & Food Security
'www.fao.org',
'worldbank.org',
'www.imf.org',
// Additional
'news.ycombinator.com',
// Finance variant
'seekingalpha.com',
'www.coindesk.com',
'cointelegraph.com',
];
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
const requestUrl = new URL(req.url);
const feedUrl = requestUrl.searchParams.get('url');
if (!feedUrl) {
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
@@ -137,7 +204,7 @@ export default async function handler(req) {
if (!ALLOWED_DOMAINS.includes(parsedUrl.hostname)) {
return new Response(JSON.stringify({ error: 'Domain not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
@@ -151,15 +218,52 @@ export default async function handler(req) {
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
'Accept-Language': 'en-US,en;q=0.9',
},
redirect: 'manual',
}, timeout);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
try {
const redirectUrl = new URL(location, feedUrl);
if (!ALLOWED_DOMAINS.includes(redirectUrl.hostname)) {
return new Response(JSON.stringify({ error: 'Redirect to disallowed domain' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const redirectResponse = await fetchWithTimeout(redirectUrl.href, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
'Accept-Language': 'en-US,en;q=0.9',
},
}, timeout);
const data = await redirectResponse.text();
return new Response(data, {
status: redirectResponse.status,
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
...corsHeaders,
},
});
} catch {
return new Response(JSON.stringify({ error: 'Invalid redirect' }), {
status: 502,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
}
const data = await response.text();
return new Response(data, {
status: response.status,
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
...corsHeaders,
},
});
} catch (error) {
@@ -171,7 +275,7 @@ export default async function handler(req) {
url: feedUrl
}), {
status: isTimeout ? 504 : 502,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
+7 -2
View File
@@ -1,3 +1,4 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Major tech services and their status page endpoints
@@ -248,6 +249,10 @@ async function checkStatusPage(service) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const category = url.searchParams.get('category'); // cloud, dev, comm, ai, saas, or all
@@ -284,8 +289,8 @@ export default async function handler(req) {
}), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=60', // 1 min cache
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30', // 1 min cache
},
});
}
+130
View File
@@ -0,0 +1,130 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_TTL = 120;
let cachedResponse = null;
let cacheTimestamp = 0;
const DEFAULT_COINS = 'tether,usd-coin,dai,first-digital-usd,ethena-usde';
function buildFallbackResult() {
return {
timestamp: new Date().toISOString(),
summary: {
totalMarketCap: 0,
totalVolume24h: 0,
coinCount: 0,
depeggedCount: 0,
healthStatus: 'UNAVAILABLE',
},
stablecoins: [],
unavailable: true,
};
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: cors });
}
return new Response(null, { status: 204, headers: cors });
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } });
}
const now = Date.now();
if (cachedResponse && now - cacheTimestamp < CACHE_TTL * 1000) {
return new Response(JSON.stringify(cachedResponse), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=300` },
});
}
const url = new URL(req.url);
const rawCoins = url.searchParams.get('coins') || DEFAULT_COINS;
const coins = rawCoins.split(',').filter(c => /^[a-z0-9-]+$/.test(c)).join(',') || DEFAULT_COINS;
try {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 10000);
const apiUrl = `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${coins}&order=market_cap_desc&sparkline=false&price_change_percentage=7d`;
const res = await fetch(apiUrl, {
signal: controller.signal,
headers: { 'Accept': 'application/json' },
});
clearTimeout(id);
if (res.status === 429) {
if (cachedResponse) {
return new Response(JSON.stringify(cachedResponse), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=30' },
});
}
return new Response(JSON.stringify({ error: 'Rate limited', timestamp: new Date().toISOString() }), {
status: 429,
headers: { ...cors, 'Content-Type': 'application/json' },
});
}
if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`);
const data = await res.json();
const stablecoins = data.map(coin => {
const price = coin.current_price || 0;
const deviation = Math.abs(price - 1.0);
let pegStatus;
if (deviation <= 0.005) pegStatus = 'ON PEG';
else if (deviation <= 0.01) pegStatus = 'SLIGHT DEPEG';
else pegStatus = 'DEPEGGED';
return {
id: coin.id,
symbol: (coin.symbol || '').toUpperCase(),
name: coin.name,
price,
deviation: +(deviation * 100).toFixed(3),
pegStatus,
marketCap: coin.market_cap || 0,
volume24h: coin.total_volume || 0,
change24h: coin.price_change_percentage_24h || 0,
change7d: coin.price_change_percentage_7d_in_currency || 0,
image: coin.image,
};
});
const totalMarketCap = stablecoins.reduce((sum, c) => sum + c.marketCap, 0);
const totalVolume24h = stablecoins.reduce((sum, c) => sum + c.volume24h, 0);
const depeggedCount = stablecoins.filter(c => c.pegStatus === 'DEPEGGED').length;
const result = {
timestamp: new Date().toISOString(),
summary: {
totalMarketCap,
totalVolume24h,
coinCount: stablecoins.length,
depeggedCount,
healthStatus: depeggedCount === 0 ? 'HEALTHY' : depeggedCount === 1 ? 'CAUTION' : 'WARNING',
},
stablecoins,
};
cachedResponse = result;
cacheTimestamp = now;
return new Response(JSON.stringify(result), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=300` },
});
} catch (err) {
const fallback = cachedResponse || buildFallbackResult();
cachedResponse = fallback;
cacheTimestamp = now;
return new Response(JSON.stringify(fallback), {
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=30' },
});
}
}
+172
View File
@@ -0,0 +1,172 @@
/**
* Stock Market Index Endpoint
* Fetches weekly % change for a country's primary stock index via Yahoo Finance
* Redis cached (1h TTL)
*/
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
export const config = {
runtime: 'edge',
};
const CACHE_TTL_SECONDS = 3600; // 1 hour
const CACHE_VERSION = 'stock-v1';
const COUNTRY_INDEX = {
US: { symbol: '^GSPC', name: 'S&P 500' },
GB: { symbol: '^FTSE', name: 'FTSE 100' },
DE: { symbol: '^GDAXI', name: 'DAX' },
FR: { symbol: '^FCHI', name: 'CAC 40' },
JP: { symbol: '^N225', name: 'Nikkei 225' },
CN: { symbol: '000001.SS', name: 'SSE Composite' },
HK: { symbol: '^HSI', name: 'Hang Seng' },
IN: { symbol: '^BSESN', name: 'BSE Sensex' },
KR: { symbol: '^KS11', name: 'KOSPI' },
TW: { symbol: '^TWII', name: 'TAIEX' },
AU: { symbol: '^AXJO', name: 'ASX 200' },
BR: { symbol: '^BVSP', name: 'Bovespa' },
CA: { symbol: '^GSPTSE', name: 'TSX Composite' },
MX: { symbol: '^MXX', name: 'IPC Mexico' },
AR: { symbol: '^MERV', name: 'MERVAL' },
RU: { symbol: 'IMOEX.ME', name: 'MOEX' },
ZA: { symbol: '^J203.JO', name: 'JSE All Share' },
SA: { symbol: '^TASI.SR', name: 'Tadawul' },
AE: { symbol: 'DFMGI.AE', name: 'DFM General' },
IL: { symbol: '^TA125.TA', name: 'TA-125' },
TR: { symbol: 'XU100.IS', name: 'BIST 100' },
PL: { symbol: '^WIG20', name: 'WIG 20' },
NL: { symbol: '^AEX', name: 'AEX' },
CH: { symbol: '^SSMI', name: 'SMI' },
ES: { symbol: '^IBEX', name: 'IBEX 35' },
IT: { symbol: 'FTSEMIB.MI', name: 'FTSE MIB' },
SE: { symbol: '^OMX', name: 'OMX Stockholm 30' },
NO: { symbol: '^OSEAX', name: 'Oslo All Share' },
SG: { symbol: '^STI', name: 'STI' },
TH: { symbol: '^SET.BK', name: 'SET' },
MY: { symbol: '^KLSE', name: 'KLCI' },
ID: { symbol: '^JKSE', name: 'Jakarta Composite' },
PH: { symbol: 'PSEI.PS', name: 'PSEi' },
NZ: { symbol: '^NZ50', name: 'NZX 50' },
EG: { symbol: '^EGX30.CA', name: 'EGX 30' },
CL: { symbol: '^IPSA', name: 'IPSA' },
PE: { symbol: '^SPBLPGPT', name: 'S&P Lima' },
AT: { symbol: '^ATX', name: 'ATX' },
BE: { symbol: '^BFX', name: 'BEL 20' },
FI: { symbol: '^OMXH25', name: 'OMX Helsinki 25' },
DK: { symbol: '^OMXC25', name: 'OMX Copenhagen 25' },
IE: { symbol: '^ISEQ', name: 'ISEQ Overall' },
PT: { symbol: '^PSI20', name: 'PSI 20' },
CZ: { symbol: '^PX', name: 'PX Prague' },
HU: { symbol: '^BUX', name: 'BUX' },
};
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
const url = new URL(request.url);
const code = (url.searchParams.get('code') || '').toUpperCase();
if (!code) {
return new Response(JSON.stringify({ error: 'code parameter required' }), {
status: 400,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
const index = COUNTRY_INDEX[code];
if (!index) {
return new Response(JSON.stringify({ error: 'No stock index for country', code, available: false }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
const cacheKey = `${CACHE_VERSION}:${code}`;
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.indexName) {
return new Response(JSON.stringify({ ...cached, cached: true }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
try {
const encodedSymbol = encodeURIComponent(index.symbol);
// Use 1mo range to handle markets with different trading weeks (e.g. Sun-Thu Middle East)
const yahooUrl = `https://query1.finance.yahoo.com/v8/finance/chart/${encodedSymbol}?range=1mo&interval=1d`;
const res = await fetch(yahooUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
},
});
if (!res.ok) {
console.error('[StockIndex] Yahoo error:', res.status, index.symbol);
return new Response(JSON.stringify({ error: 'Upstream error', available: false }), {
status: 502,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
const data = await res.json();
const result = data?.chart?.result?.[0];
if (!result) {
return new Response(JSON.stringify({ error: 'No data', available: false }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
const allCloses = result.indicators?.quote?.[0]?.close?.filter(v => v != null);
if (!allCloses || allCloses.length < 2) {
return new Response(JSON.stringify({ error: 'Insufficient data', available: false }), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
// Take last ~5 trading days worth of data
const closes = allCloses.slice(-6);
const latest = closes[closes.length - 1];
const oldest = closes[0];
const weekChange = ((latest - oldest) / oldest) * 100;
const meta = result.meta || {};
const payload = {
available: true,
code,
symbol: index.symbol,
indexName: index.name,
price: latest.toFixed(2),
weekChangePercent: weekChange.toFixed(2),
currency: meta.currency || 'USD',
fetchedAt: new Date().toISOString(),
};
await setCachedJson(cacheKey, payload, CACHE_TTL_SECONDS);
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
} catch (err) {
console.error('[StockIndex] Error:', err);
return new Response(JSON.stringify({ error: 'Internal error', available: false }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Story Page for Social Crawlers
* Returns HTML with proper og:image and twitter:card meta tags.
* Twitter/Facebook/LinkedIn crawlers hit this, real users get redirected to the SPA.
*/
const COUNTRY_NAMES = {
UA: 'Ukraine', RU: 'Russia', CN: 'China', US: 'United States',
IR: 'Iran', IL: 'Israel', TW: 'Taiwan', KP: 'North Korea',
SA: 'Saudi Arabia', TR: 'Turkey', PL: 'Poland', DE: 'Germany',
FR: 'France', GB: 'United Kingdom', IN: 'India', PK: 'Pakistan',
SY: 'Syria', YE: 'Yemen', MM: 'Myanmar', VE: 'Venezuela',
};
const BOT_UA = /twitterbot|facebookexternalhit|linkedinbot|slackbot|telegrambot|whatsapp|discordbot|redditbot|googlebot/i;
export default function handler(req, res) {
const url = new URL(req.url, `https://${req.headers.host}`);
const countryCode = (url.searchParams.get('c') || '').toUpperCase();
const type = url.searchParams.get('t') || 'ciianalysis';
const ts = url.searchParams.get('ts') || '';
const score = url.searchParams.get('s') || '';
const level = url.searchParams.get('l') || '';
const ua = req.headers['user-agent'] || '';
const isBot = BOT_UA.test(ua);
const baseUrl = `https://${req.headers.host}`;
const spaUrl = `${baseUrl}/?c=${countryCode}&t=${type}${ts ? `&ts=${ts}` : ''}`;
// Real users → redirect to SPA
if (!isBot) {
res.writeHead(302, { Location: spaUrl });
res.end();
return;
}
// Bots → serve meta tags
const countryName = COUNTRY_NAMES[countryCode] || countryCode || 'Global';
const title = `${countryName} Intelligence Brief | World Monitor`;
const description = `Real-time instability analysis for ${countryName}. Country Instability Index, military posture, threat classification, and prediction markets. Free, open-source geopolitical intelligence.`;
const imageParams = `c=${countryCode}&t=${type}${score ? `&s=${score}` : ''}${level ? `&l=${level}` : ''}`;
const imageUrl = `${baseUrl}/api/og-story?${imageParams}`;
const storyUrl = `${baseUrl}/api/story?c=${countryCode}&t=${type}${ts ? `&ts=${ts}` : ''}`;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>${esc(title)}</title>
<meta name="description" content="${esc(description)}"/>
<meta property="og:type" content="article"/>
<meta property="og:title" content="${esc(title)}"/>
<meta property="og:description" content="${esc(description)}"/>
<meta property="og:image" content="${esc(imageUrl)}"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>
<meta property="og:url" content="${esc(storyUrl)}"/>
<meta property="og:site_name" content="World Monitor"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:site" content="@WorldMonitorApp"/>
<meta name="twitter:title" content="${esc(title)}"/>
<meta name="twitter:description" content="${esc(description)}"/>
<meta name="twitter:image" content="${esc(imageUrl)}"/>
<link rel="canonical" href="${esc(storyUrl)}"/>
</head>
<body>
<h1>${esc(title)}</h1>
<p>${esc(description)}</p>
<p><a href="${esc(spaUrl)}">View live analysis</a></p>
</body>
</html>`;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=60');
res.status(200).send(html);
}
function esc(str) {
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
+83 -3
View File
@@ -1,9 +1,75 @@
// Tech Events API - Parses Techmeme ICS feed and dev.events RSS, returns structured events
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const ICS_URL = 'https://www.techmeme.com/newsy_events.ics';
const DEV_EVENTS_RSS = 'https://dev.events/rss.xml';
// Curated major tech events that may fall off limited RSS feeds
// These are manually maintained for important conferences
const CURATED_EVENTS = [
{
id: 'step-dubai-2026',
title: 'STEP Dubai 2026',
type: 'conference',
location: 'Dubai Internet City, Dubai',
coords: { lat: 25.0956, lng: 55.1548, country: 'UAE', original: 'Dubai Internet City, Dubai' },
startDate: '2026-02-11',
endDate: '2026-02-12',
url: 'https://dubai.stepconference.com',
source: 'curated',
description: 'Intelligence Everywhere: The AI Economy - 8,000+ attendees, 400+ startups',
},
{
id: 'gitex-global-2026',
title: 'GITEX Global 2026',
type: 'conference',
location: 'Dubai World Trade Centre, Dubai',
coords: { lat: 25.2285, lng: 55.2867, country: 'UAE', original: 'Dubai World Trade Centre, Dubai' },
startDate: '2026-12-07',
endDate: '2026-12-11',
url: 'https://www.gitex.com',
source: 'curated',
description: 'World\'s largest tech & startup show',
},
{
id: 'token2049-dubai-2026',
title: 'TOKEN2049 Dubai 2026',
type: 'conference',
location: 'Dubai, UAE',
coords: { lat: 25.2048, lng: 55.2708, country: 'UAE', original: 'Dubai, UAE' },
startDate: '2026-04-29',
endDate: '2026-04-30',
url: 'https://www.token2049.com',
source: 'curated',
description: 'Premier crypto event in Dubai',
},
{
id: 'collision-2026',
title: 'Collision 2026',
type: 'conference',
location: 'Toronto, Canada',
coords: { lat: 43.6532, lng: -79.3832, country: 'Canada', original: 'Toronto, Canada' },
startDate: '2026-06-22',
endDate: '2026-06-25',
url: 'https://collisionconf.com',
source: 'curated',
description: 'North America\'s fastest growing tech conference',
},
{
id: 'web-summit-2026',
title: 'Web Summit 2026',
type: 'conference',
location: 'Lisbon, Portugal',
coords: { lat: 38.7223, lng: -9.1393, country: 'Portugal', original: 'Lisbon, Portugal' },
startDate: '2026-11-02',
endDate: '2026-11-05',
url: 'https://websummit.com',
source: 'curated',
description: 'The world\'s premier tech conference',
},
];
// Comprehensive city geocoding database (500+ cities worldwide)
const CITY_COORDS = {
// North America - USA
@@ -552,6 +618,10 @@ function parseDevEventsRSS(rssText) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const type = url.searchParams.get('type'); // 'all', 'conferences', 'earnings', 'ipo'
const mappable = url.searchParams.get('mappable') === 'true'; // Only return events with coords
@@ -588,6 +658,16 @@ export default async function handler(req) {
console.warn('Failed to fetch dev.events RSS');
}
// Add curated events (major conferences that may fall off limited RSS feeds)
const now = new Date();
now.setHours(0, 0, 0, 0);
for (const curated of CURATED_EVENTS) {
const eventDate = new Date(curated.startDate);
if (eventDate >= now) {
events.push(curated);
}
}
// Deduplicate by title similarity (rough match)
const seen = new Set();
events = events.filter(e => {
@@ -637,8 +717,8 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, s-maxage=1800', // Cache for 30 minutes
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
} catch (error) {
@@ -650,7 +730,7 @@ export default async function handler(req) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
},
});
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Temporal Baseline Anomaly Detection API
* Stores and queries activity baselines using Welford's online algorithm
* Backed by Upstash Redis for cross-user persistence
*
* GET ?type=military_flights&region=global&count=47 check anomaly
* POST { updates: [{ type, region, count }] } batch update baselines
*/
import { getCachedJson, setCachedJson, mget } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const BASELINE_TTL = 7776000; // 90 days in seconds
const MIN_SAMPLES = 10;
const Z_THRESHOLD_LOW = 1.5;
const Z_THRESHOLD_MEDIUM = 2.0;
const Z_THRESHOLD_HIGH = 3.0;
const VALID_TYPES = ['military_flights', 'vessels', 'protests', 'news', 'ais_gaps', 'satellite_fires'];
function makeKey(type, region, weekday, month) {
return `baseline:${type}:${region}:${weekday}:${month}`;
}
function getSeverity(zScore) {
if (zScore >= Z_THRESHOLD_HIGH) return 'critical';
if (zScore >= Z_THRESHOLD_MEDIUM) return 'high';
if (zScore >= Z_THRESHOLD_LOW) return 'medium';
return 'normal';
}
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'GET, POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
try {
if (request.method === 'GET') {
return await handleGet(request);
} else if (request.method === 'POST') {
return await handlePost(request);
}
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (err) {
console.error('[TemporalBaseline] Error:', err);
return new Response(JSON.stringify({ error: 'Internal error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
async function handleGet(request) {
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
const region = searchParams.get('region') || 'global';
const count = parseFloat(searchParams.get('count'));
if (!type || !VALID_TYPES.includes(type) || isNaN(count)) {
return json({ error: 'Missing or invalid params: type, count required' }, 400);
}
const now = new Date();
const weekday = now.getUTCDay();
const month = now.getUTCMonth() + 1;
const key = makeKey(type, region, weekday, month);
const baseline = await getCachedJson(key);
if (!baseline || baseline.sampleCount < MIN_SAMPLES) {
return json({
anomaly: null,
learning: true,
sampleCount: baseline?.sampleCount || 0,
samplesNeeded: MIN_SAMPLES,
});
}
const variance = Math.max(0, baseline.m2 / (baseline.sampleCount - 1));
const stdDev = Math.sqrt(variance);
const zScore = stdDev > 0 ? Math.abs((count - baseline.mean) / stdDev) : 0;
const severity = getSeverity(zScore);
const multiplier = baseline.mean > 0
? Math.round((count / baseline.mean) * 100) / 100
: count > 0 ? 999 : 1;
return json({
anomaly: zScore >= Z_THRESHOLD_LOW ? {
zScore: Math.round(zScore * 100) / 100,
severity,
multiplier,
} : null,
baseline: {
mean: Math.round(baseline.mean * 100) / 100,
stdDev: Math.round(stdDev * 100) / 100,
sampleCount: baseline.sampleCount,
},
learning: false,
});
}
async function handlePost(request) {
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return json({ error: 'Payload too large' }, 413);
}
const body = await request.json();
const updates = body?.updates;
if (!Array.isArray(updates) || updates.length === 0) {
return json({ error: 'Body must have updates array' }, 400);
}
const batch = updates.slice(0, 20);
const now = new Date();
const weekday = now.getUTCDay();
const month = now.getUTCMonth() + 1;
const keys = batch.map(u => makeKey(u.type, u.region || 'global', weekday, month));
const existing = await mget(...keys);
const writes = [];
for (let i = 0; i < batch.length; i++) {
const { type, region = 'global', count } = batch[i];
if (!VALID_TYPES.includes(type) || typeof count !== 'number' || isNaN(count)) continue;
const prev = existing[i] || { mean: 0, m2: 0, sampleCount: 0 };
const n = prev.sampleCount + 1;
const delta = count - prev.mean;
const newMean = prev.mean + delta / n;
const delta2 = count - newMean;
const newM2 = prev.m2 + delta * delta2;
writes.push(setCachedJson(keys[i], {
mean: newMean,
m2: newM2,
sampleCount: n,
lastUpdated: now.toISOString(),
}, BASELINE_TTL));
}
if (writes.length > 0) {
await Promise.all(writes);
}
return json({ updated: writes.length });
}
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
+611
View File
@@ -0,0 +1,611 @@
/**
* Theater Posture API - Aggregates military aircraft by theater
* Caches results in Upstash Redis for cross-user efficiency
* TTL: 5 minutes (matches OpenSky refresh rate)
*/
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const CACHE_TTL_SECONDS = 300; // 5 minutes
const STALE_CACHE_TTL_SECONDS = 86400; // 24 hours - serve stale data when API is down
const BACKUP_CACHE_TTL_SECONDS = 604800; // 7 days - last resort backup
const CACHE_KEY = 'theater-posture:v4';
const STALE_CACHE_KEY = 'theater-posture:stale:v4';
const BACKUP_CACHE_KEY = 'theater-posture:backup:v4';
// Theater definitions (matches client-side POSTURE_THEATERS)
const POSTURE_THEATERS = [
{
id: 'iran-theater',
name: 'Iran Theater',
shortName: 'IRAN',
targetNation: 'Iran',
bounds: { north: 42, south: 20, east: 65, west: 30 },
thresholds: { elevated: 8, critical: 20 },
strikeIndicators: { minTankers: 2, minAwacs: 1, minFighters: 5 },
},
{
id: 'taiwan-theater',
name: 'Taiwan Strait',
shortName: 'TAIWAN',
targetNation: 'Taiwan',
bounds: { north: 30, south: 18, east: 130, west: 115 },
thresholds: { elevated: 6, critical: 15 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 4 },
},
{
id: 'baltic-theater',
name: 'Baltic Theater',
shortName: 'BALTIC',
targetNation: null,
bounds: { north: 65, south: 52, east: 32, west: 10 },
thresholds: { elevated: 5, critical: 12 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'blacksea-theater',
name: 'Black Sea',
shortName: 'BLACK SEA',
targetNation: null,
bounds: { north: 48, south: 40, east: 42, west: 26 },
thresholds: { elevated: 4, critical: 10 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'korea-theater',
name: 'Korean Peninsula',
shortName: 'KOREA',
targetNation: 'North Korea',
bounds: { north: 43, south: 33, east: 132, west: 124 },
thresholds: { elevated: 5, critical: 12 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'south-china-sea',
name: 'South China Sea',
shortName: 'SCS',
targetNation: null,
bounds: { north: 25, south: 5, east: 121, west: 105 },
thresholds: { elevated: 6, critical: 15 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 4 },
},
{
id: 'east-med-theater',
name: 'Eastern Mediterranean',
shortName: 'E.MED',
targetNation: null,
bounds: { north: 37, south: 33, east: 37, west: 25 },
thresholds: { elevated: 4, critical: 10 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'israel-gaza-theater',
name: 'Israel/Gaza',
shortName: 'GAZA',
targetNation: 'Gaza',
bounds: { north: 33, south: 29, east: 36, west: 33 },
thresholds: { elevated: 3, critical: 8 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
{
id: 'yemen-redsea-theater',
name: 'Yemen/Red Sea',
shortName: 'RED SEA',
targetNation: 'Yemen',
bounds: { north: 22, south: 11, east: 54, west: 32 },
thresholds: { elevated: 4, critical: 10 },
strikeIndicators: { minTankers: 1, minAwacs: 1, minFighters: 3 },
},
];
// Military hex database from ADS-B Exchange (updated daily at adsbexchange.com)
// Contains ~20k verified military aircraft hex IDs
import { MILITARY_HEX_LIST } from './data/military-hex-db.js';
// Create Set for O(1) lookup
const MILITARY_HEX_SET = new Set(MILITARY_HEX_LIST.map(h => h.toLowerCase()));
console.log(`[TheaterPosture] Loaded ${MILITARY_HEX_SET.size} military hex IDs from ADS-B Exchange`);
// Check if ICAO hex is in military database
function isMilitaryHex(hexId) {
if (!hexId) return false;
// Handle both string and number, remove ~ prefix if present
const cleanHex = String(hexId).replace(/^~/, '').toLowerCase();
return MILITARY_HEX_SET.has(cleanHex);
}
// Military callsign prefixes for identification
const MILITARY_PREFIXES = [
// US Military
'RCH', 'REACH', 'MOOSE', 'EVAC', 'DUSTOFF', 'PEDRO', // Transport/medevac
'DUKE', 'HAVOC', 'KNIFE', 'WARHAWK', 'VIPER', 'RAGE', 'FURY', // Fighters
'SHELL', 'TEXACO', 'ARCO', 'ESSO', 'PETRO', // Tankers
'SENTRY', 'AWACS', 'MAGIC', 'DISCO', 'DARKSTAR', // AWACS/ISR
'COBRA', 'PYTHON', 'RAPTOR', 'EAGLE', 'HAWK', 'TALON', // Various
'BOXER', 'OMNI', 'TOPCAT', 'SKULL', 'REAPER', 'HUNTER', // More callsigns
'ARMY', 'NAVY', 'USAF', 'USMC', 'USCG', // Service prefixes
'AE', 'CNV', 'PAT', 'SAM', 'EXEC', // Special missions
'OPS', 'CTF', 'TF', // Operations/Task Force
// NATO
'NATO', 'GAF', 'RRF', 'RAF', 'FAF', 'IAF', 'RNLAF', 'BAF', 'DAF', 'HAF', 'PAF',
'SWORD', 'LANCE', 'ARROW', 'SPARTAN', // NATO tactical
// Middle East (avoid UAE - conflicts with Emirates airline)
'RSAF', 'EMIRI', 'UAEAF', 'KAF', 'QAF', 'BAHAF', 'OMAAF', // Gulf states
'IRIAF', 'IRG', 'IRGC', // Iran (IAF already in NATO section covers Israel)
'TAF', 'TUAF', // Turkey
// Russia
'RSD', 'RF', 'RFF', 'VKS',
// China (NOTE: CCA is Air China airline, not military)
'CHN', 'PLAAF', 'PLA',
];
// Airline ICAO codes to exclude from military detection (Set for O(1) lookup)
const AIRLINE_CODES = new Set([
// Middle East
'SVA', 'QTR', 'THY', 'UAE', 'ETD', 'GFA', 'MEA', 'RJA', 'KAC', 'ELY',
'IAW', 'IRA', 'MSR', 'SYR', 'PGT', 'AXB', 'FDB', 'KNE', 'FAD', 'ADY', 'OMA',
'ABQ', 'ABY', 'NIA', 'FJA', 'SWR', 'HZA', 'OMS', 'EGF', 'NOS', 'SXD',
// Europe
'BAW', 'AFR', 'DLH', 'KLM', 'AUA', 'SAS', 'FIN', 'LOT', 'AZA', 'TAP', 'IBE',
'VLG', 'RYR', 'EZY', 'WZZ', 'NOZ', 'BEL', 'AEE', 'ROT',
// Asia
'AIC', 'CPA', 'SIA', 'MAS', 'THA', 'VNM', 'JAL', 'ANA', 'KAL', 'AAR', 'EVA',
'CAL', 'CCA', 'CES', 'CSN', 'HDA', 'CHH', 'CXA', 'GIA', 'PAL', 'SLK',
// Americas
'AAL', 'DAL', 'UAL', 'SWA', 'JBU', 'FFT', 'ASA', 'NKS', 'WJA', 'ACA',
// Cargo
'FDX', 'UPS', 'GTI', 'ABW', 'CLX', 'MPH',
// Generic
'AIR', 'SKY', 'JET',
]);
// Aircraft type detection from callsign patterns
function detectAircraftType(callsign) {
if (!callsign) return 'unknown';
const cs = callsign.toUpperCase().trim();
// Tankers
if (/^(SHELL|TEXACO|ARCO|ESSO|PETRO)/.test(cs)) return 'tanker';
if (/^(KC|STRAT)/.test(cs)) return 'tanker';
// AWACS
if (/^(SENTRY|AWACS|MAGIC|DISCO|DARKSTAR)/.test(cs)) return 'awacs';
if (/^(E3|E8|E6)/.test(cs)) return 'awacs';
// Transport
if (/^(RCH|REACH|MOOSE|EVAC|DUSTOFF)/.test(cs)) return 'transport';
if (/^(C17|C5|C130|C40)/.test(cs)) return 'transport';
// Reconnaissance
if (/^(HOMER|OLIVE|JAKE|PSEUDO|GORDO)/.test(cs)) return 'reconnaissance';
if (/^(RC|U2|SR)/.test(cs)) return 'reconnaissance';
// Drones/UAVs
if (/^(RQ|MQ|REAPER|PREDATOR|GLOBAL)/.test(cs)) return 'drone';
// Bombers
if (/^(DEATH|BONE|DOOM)/.test(cs)) return 'bomber';
if (/^(B52|B1|B2)/.test(cs)) return 'bomber';
// Default to unknown for unrecognized military aircraft
return 'unknown';
}
// Check if callsign is military
function isMilitaryCallsign(callsign) {
if (!callsign) return false;
const cs = callsign.toUpperCase().trim();
// Check prefixes
for (const prefix of MILITARY_PREFIXES) {
if (cs.startsWith(prefix)) return true;
}
// Check patterns - tactical callsigns (word + small number)
// DUKE01, VIPER12, RAGE1 but NOT airline codes like PGT5873, IAW9011
if (/^[A-Z]{4,}\d{1,3}$/.test(cs)) return true;
// Short tactical: 3 letters + 1-2 digits (but exclude common airlines)
// This catches OPS4, CTF01, TF12 but blocks SVA12, QTR76, etc.
if (/^[A-Z]{3}\d{1,2}$/.test(cs)) {
const prefix = cs.slice(0, 3);
if (!AIRLINE_CODES.has(prefix)) return true;
}
return false;
}
// Fetch military flights from OpenSky
async function fetchMilitaryFlights() {
const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar');
// Desktop sidecar: fetch directly from OpenSky (single user, no rate limit concern)
// Cloud: use Railway relay to avoid OpenSky rate limits across many users
const baseUrl = isSidecar
? 'https://opensky-network.org/api/states/all'
: (process.env.WS_RELAY_URL ? process.env.WS_RELAY_URL + '/opensky' : null);
if (!baseUrl) return [];
// Fetch global data with 20s timeout (Edge has 25s limit)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
console.log('[TheaterPosture] Fetching from:', baseUrl);
const response = await fetch(baseUrl, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 WorldMonitor/1.0',
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`OpenSky API error: ${response.status}`);
}
const data = await response.json();
if (!data.states) return [];
// Filter and transform to military flights
const flights = [];
for (const state of data.states) {
const [icao24, callsign, , , , lon, lat, altitude, onGround, velocity, heading] = state;
// Skip if no position
if (lat == null || lon == null) continue;
// Skip if on ground
if (onGround) continue;
// Check if military (by callsign OR hex range)
const isMilitary = isMilitaryCallsign(callsign) || isMilitaryHex(icao24);
if (!isMilitary) continue;
flights.push({
id: icao24,
callsign: callsign?.trim() || '',
lat,
lon,
altitude: altitude || 0,
heading: heading || 0,
speed: velocity || 0,
aircraftType: detectAircraftType(callsign),
operator: 'unknown',
militaryHex: isMilitaryHex(icao24),
});
}
return flights;
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('OpenSky API timeout - try again');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
// Fetch military flights from Wingbits (fallback when OpenSky fails)
async function fetchMilitaryFlightsFromWingbits() {
const apiKey = process.env.WINGBITS_API_KEY;
if (!apiKey) {
console.log('[TheaterPosture] Wingbits not configured, skipping fallback');
return null;
}
console.log('[TheaterPosture] Trying Wingbits fallback...');
// Build batch request for all theaters
const areas = POSTURE_THEATERS.map(theater => ({
alias: theater.id,
by: 'box',
la: (theater.bounds.north + theater.bounds.south) / 2,
lo: (theater.bounds.east + theater.bounds.west) / 2,
w: Math.abs(theater.bounds.east - theater.bounds.west) * 60, // degrees to nm
h: Math.abs(theater.bounds.north - theater.bounds.south) * 60,
unit: 'nm',
}));
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch('https://customer-api.wingbits.com/v1/flights', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(areas),
signal: controller.signal,
});
if (!response.ok) {
console.warn('[TheaterPosture] Wingbits API error:', response.status);
return null;
}
const data = await response.json();
console.log('[TheaterPosture] Wingbits returned', data.length, 'theater results');
// Transform Wingbits data to our format
// Wingbits uses short field names: h=icao24, f=flight, la=lat, lo=lon, ab=alt, th=heading, gs=speed
const flights = [];
const seenIds = new Set();
for (const areaResult of data) {
// Batch response: each area result has flights in various possible formats
const areaFlights = areaResult.flights || areaResult.data || areaResult || [];
const flightList = Array.isArray(areaFlights) ? areaFlights : [];
for (const f of flightList) {
// Get icao24 - Wingbits uses 'h' for hex ID
const icao24 = f.h || f.icao24 || f.id;
if (!icao24) continue;
// Skip duplicates (aircraft may appear in multiple theaters)
if (seenIds.has(icao24)) continue;
seenIds.add(icao24);
// Get callsign - Wingbits uses 'f' for flight
const callsign = f.f || f.callsign || f.flight || '';
// Skip if not military (by callsign OR hex range)
const isMilitary = isMilitaryCallsign(callsign) || isMilitaryHex(icao24);
if (!isMilitary) continue;
flights.push({
id: icao24,
callsign: callsign.trim(),
lat: f.la || f.latitude || f.lat,
lon: f.lo || f.longitude || f.lon || f.lng,
altitude: f.ab || f.altitude || f.alt || 0,
heading: f.th || f.heading || f.track || 0,
speed: f.gs || f.groundSpeed || f.speed || f.velocity || 0,
aircraftType: detectAircraftType(callsign),
operator: f.operator || 'unknown',
source: 'wingbits',
militaryHex: isMilitaryHex(icao24),
});
}
}
console.log('[TheaterPosture] Wingbits: found', flights.length, 'military flights');
return flights;
} catch (err) {
console.error('[TheaterPosture] Wingbits fetch error:', err.message);
return null;
} finally {
clearTimeout(timeoutId);
}
}
// Calculate theater postures
function calculatePostures(flights) {
const summaries = [];
for (const theater of POSTURE_THEATERS) {
// Filter flights within theater bounds
const theaterFlights = flights.filter(f =>
f.lat >= theater.bounds.south &&
f.lat <= theater.bounds.north &&
f.lon >= theater.bounds.west &&
f.lon <= theater.bounds.east
);
// Count by type
const byType = {
fighters: theaterFlights.filter(f => f.aircraftType === 'fighter').length,
tankers: theaterFlights.filter(f => f.aircraftType === 'tanker').length,
awacs: theaterFlights.filter(f => f.aircraftType === 'awacs').length,
reconnaissance: theaterFlights.filter(f => f.aircraftType === 'reconnaissance').length,
transport: theaterFlights.filter(f => f.aircraftType === 'transport').length,
bombers: theaterFlights.filter(f => f.aircraftType === 'bomber').length,
drones: theaterFlights.filter(f => f.aircraftType === 'drone').length,
unknown: theaterFlights.filter(f => f.aircraftType === 'unknown').length,
};
const total = Object.values(byType).reduce((a, b) => a + b, 0);
// Determine posture level
const postureLevel = total >= theater.thresholds.critical ? 'critical' :
total >= theater.thresholds.elevated ? 'elevated' : 'normal';
// Check strike capability
const strikeCapable =
byType.tankers >= theater.strikeIndicators.minTankers &&
byType.awacs >= theater.strikeIndicators.minAwacs &&
byType.fighters >= theater.strikeIndicators.minFighters;
// Build summary string
const parts = [];
if (byType.fighters > 0) parts.push(`${byType.fighters} fighters`);
if (byType.tankers > 0) parts.push(`${byType.tankers} tankers`);
if (byType.awacs > 0) parts.push(`${byType.awacs} AWACS`);
if (byType.reconnaissance > 0) parts.push(`${byType.reconnaissance} recon`);
if (byType.bombers > 0) parts.push(`${byType.bombers} bombers`);
if (byType.transport > 0) parts.push(`${byType.transport} transport`);
if (byType.drones > 0) parts.push(`${byType.drones} drones`);
if (byType.unknown > 0) parts.push(`${byType.unknown} other`);
const summary = parts.join(', ') || 'No military aircraft';
// Build headline
const headline = postureLevel === 'critical'
? `Critical military buildup - ${theater.name}`
: postureLevel === 'elevated'
? `Elevated military activity - ${theater.name}`
: `Normal activity - ${theater.name}`;
// Build byOperator map for aircraft
const byOperator = {};
for (const f of theaterFlights) {
const op = f.operator || 'unknown';
byOperator[op] = (byOperator[op] || 0) + 1;
}
summaries.push({
theaterId: theater.id,
theaterName: theater.name,
shortName: theater.shortName,
targetNation: theater.targetNation,
// Aircraft
fighters: byType.fighters,
tankers: byType.tankers,
awacs: byType.awacs,
reconnaissance: byType.reconnaissance,
transport: byType.transport,
bombers: byType.bombers,
drones: byType.drones,
unknown: byType.unknown,
totalAircraft: total,
// Vessels (populated client-side)
destroyers: 0,
frigates: 0,
carriers: 0,
submarines: 0,
patrol: 0,
auxiliaryVessels: 0,
totalVessels: 0,
// By operator (aircraft + vessels added client-side)
byOperator,
// Metadata
postureLevel,
strikeCapable,
trend: 'stable',
changePercent: 0,
summary,
headline,
centerLat: (theater.bounds.north + theater.bounds.south) / 2,
centerLon: (theater.bounds.east + theater.bounds.west) / 2,
bounds: theater.bounds,
});
}
return summaries;
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: corsHeaders });
}
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
// Try to get from cache first
const cached = await getCachedJson(CACHE_KEY);
if (cached) {
console.log('[TheaterPosture] Cache hit');
return Response.json({
...cached,
cached: true,
}, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
}
// Fetch and calculate - try OpenSky first, then Wingbits fallback
console.log('[TheaterPosture] Fetching fresh data...');
let flights;
let source = 'opensky';
try {
flights = await fetchMilitaryFlights();
} catch (openskyError) {
console.warn('[TheaterPosture] OpenSky failed:', openskyError.message);
console.log('[TheaterPosture] Trying Wingbits fallback...');
flights = await fetchMilitaryFlightsFromWingbits();
if (flights && flights.length > 0) {
source = 'wingbits';
console.log('[TheaterPosture] Wingbits fallback succeeded:', flights.length, 'flights');
} else {
// Both failed, re-throw OpenSky error to trigger cache fallback
throw openskyError;
}
}
const postures = calculatePostures(flights);
const result = {
postures,
totalFlights: flights.length,
timestamp: new Date().toISOString(),
cached: false,
source, // 'opensky' or 'wingbits'
};
// Cache the result (regular, stale, and long-term backup)
await Promise.all([
setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS),
setCachedJson(STALE_CACHE_KEY, result, STALE_CACHE_TTL_SECONDS),
setCachedJson(BACKUP_CACHE_KEY, result, BACKUP_CACHE_TTL_SECONDS),
]);
return Response.json(result, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
} catch (error) {
console.warn('[TheaterPosture] Error:', error.message);
// Try to return cached data when API fails (stale first, then backup)
const stale = await getCachedJson(STALE_CACHE_KEY);
if (stale) {
console.log('[TheaterPosture] Returning stale cached data (24h) due to API error');
return Response.json({
...stale,
cached: true,
stale: true,
error: 'Using cached data - live feed temporarily unavailable',
}, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
},
});
}
const backup = await getCachedJson(BACKUP_CACHE_KEY);
if (backup) {
console.log('[TheaterPosture] Returning backup cached data (7d) due to API error');
return Response.json({
...backup,
cached: true,
stale: true,
error: 'Using backup data - live feed temporarily unavailable',
}, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
},
});
}
// No cached data available - return error
return Response.json({
error: error.message,
postures: [],
timestamp: new Date().toISOString(),
}, {
status: 500,
headers: corsHeaders,
});
}
}
+237
View File
@@ -0,0 +1,237 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
export const config = { runtime: 'edge' };
const CACHE_KEY = 'ucdp:gedevents:v2';
const CACHE_TTL_SECONDS = 6 * 60 * 60;
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
const UCDP_PAGE_SIZE = 1000;
const MAX_PAGES = 12;
const TRAILING_WINDOW_MS = 365 * 24 * 60 * 60 * 1000;
let fallbackCache = { data: null, timestamp: 0 };
const rateLimiter = createIpRateLimiter({
limit: 15,
windowMs: 60 * 1000,
maxEntries: 5000,
});
function getClientIp(req) {
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
function isValidResult(data) {
return Boolean(data && typeof data === 'object' && Array.isArray(data.data));
}
const VIOLENCE_TYPE_MAP = {
1: 'state-based',
2: 'non-state',
3: 'one-sided',
};
function parseDateMs(value) {
if (!value) return NaN;
return Date.parse(String(value));
}
function getMaxDateMs(events) {
let maxMs = NaN;
for (const event of events) {
const ms = parseDateMs(event?.date_start);
if (!Number.isFinite(ms)) continue;
if (!Number.isFinite(maxMs) || ms > maxMs) {
maxMs = ms;
}
}
return maxMs;
}
function buildVersionCandidates() {
const year = new Date().getFullYear() - 2000;
return Array.from(new Set([
`${year}.1`,
`${year - 1}.1`,
'25.1',
'24.1',
]));
}
async function fetchGedPage(version, page) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(
`https://ucdpapi.pcr.uu.se/api/gedevents/${version}?pagesize=${UCDP_PAGE_SIZE}&page=${page}`,
{ headers: { Accept: 'application/json' }, signal: controller.signal }
);
if (!response.ok) {
throw new Error(`UCDP GED API error (${version}, page ${page}): ${response.status}`);
}
return response.json();
} finally {
clearTimeout(timeout);
}
}
async function discoverGedVersion() {
const candidates = buildVersionCandidates();
for (const version of candidates) {
try {
const page0 = await fetchGedPage(version, 0);
if (Array.isArray(page0?.Result)) {
return { version, page0 };
}
} catch {
// Try the next version candidate.
}
}
throw new Error('Unable to fetch UCDP GED metadata from known API versions');
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed', data: [] }, {
status: 405, headers: corsHeaders,
});
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed', data: [] }, {
status: 403, headers: corsHeaders,
});
}
const ip = getClientIp(req);
if (!rateLimiter.check(ip)) {
return Response.json({ error: 'Rate limited', data: [] }, {
status: 429,
headers: { ...corsHeaders, 'Retry-After': '60' },
});
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (isValidResult(cached)) {
recordCacheTelemetry('/api/ucdp-events', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'REDIS-HIT' },
});
}
if (isValidResult(fallbackCache.data) && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/ucdp-events', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'MEMORY-HIT' },
});
}
try {
const { version, page0 } = await discoverGedVersion();
const totalPages = Math.max(1, Number(page0?.TotalPages) || 1);
const newestPage = totalPages - 1;
let allEvents = [];
let latestDatasetMs = NaN;
for (let offset = 0; offset < MAX_PAGES && (newestPage - offset) >= 0; offset++) {
const page = newestPage - offset;
const rawData = page === 0 ? page0 : await fetchGedPage(version, page);
const events = Array.isArray(rawData?.Result) ? rawData.Result : [];
allEvents = allEvents.concat(events);
const pageMaxMs = getMaxDateMs(events);
if (!Number.isFinite(latestDatasetMs) && Number.isFinite(pageMaxMs)) {
latestDatasetMs = pageMaxMs;
}
// Pages are ordered oldest->newest; once we are fully outside trailing window, stop.
if (Number.isFinite(latestDatasetMs) && Number.isFinite(pageMaxMs)) {
const cutoffMs = latestDatasetMs - TRAILING_WINDOW_MS;
if (pageMaxMs < cutoffMs) {
break;
}
}
}
const sanitized = allEvents
.filter((event) => {
if (!Number.isFinite(latestDatasetMs)) return true;
const eventMs = parseDateMs(event?.date_start);
if (!Number.isFinite(eventMs)) return false;
return eventMs >= (latestDatasetMs - TRAILING_WINDOW_MS);
})
.map(e => ({
id: String(e.id || ''),
date_start: e.date_start || '',
date_end: e.date_end || '',
latitude: Number(e.latitude) || 0,
longitude: Number(e.longitude) || 0,
country: e.country || '',
side_a: (e.side_a || '').substring(0, 200),
side_b: (e.side_b || '').substring(0, 200),
deaths_best: Number(e.best) || 0,
deaths_low: Number(e.low) || 0,
deaths_high: Number(e.high) || 0,
type_of_violence: VIOLENCE_TYPE_MAP[e.type_of_violence] || 'state-based',
source_original: (e.source_original || '').substring(0, 300),
}))
.sort((a, b) => {
const bMs = parseDateMs(b.date_start);
const aMs = parseDateMs(a.date_start);
return (Number.isFinite(bMs) ? bMs : 0) - (Number.isFinite(aMs) ? aMs : 0);
});
const result = {
success: true,
count: sanitized.length,
data: sanitized,
version,
cached_at: new Date().toISOString(),
};
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/ucdp-events', 'MISS');
return Response.json(result, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'MISS' },
});
} catch (error) {
if (isValidResult(fallbackCache.data)) {
recordCacheTelemetry('/api/ucdp-events', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=600, s-maxage=600, stale-while-revalidate=120', 'X-Cache': 'STALE' },
});
}
recordCacheTelemetry('/api/ucdp-events', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, data: [] }, {
status: 500, headers: corsHeaders,
});
}
}
+150
View File
@@ -0,0 +1,150 @@
// UCDP (Uppsala Conflict Data Program) proxy
// Returns conflict classification per country with intensity levels
// No auth required - public API
export const config = { runtime: 'edge' };
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_KEY = 'ucdp:country-conflicts:v2';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (annual data)
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
const RESPONSE_CACHE_CONTROL = 'public, max-age=3600';
// In-memory fallback when Redis is unavailable.
let fallbackCache = { data: null, timestamp: 0 };
function isValidResult(data) {
return Boolean(
data &&
typeof data === 'object' &&
Array.isArray(data.conflicts)
);
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (isValidResult(cached)) {
recordCacheTelemetry('/api/ucdp', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: {
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'REDIS-HIT',
},
});
}
if (isValidResult(fallbackCache.data) && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/ucdp', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MEMORY-HIT',
},
});
}
try {
// Fetch all pages of conflicts
let allConflicts = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const response = await fetch(`https://ucdpapi.pcr.uu.se/api/ucdpprioconflict/24.1?pagesize=100&page=${page}`, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(`UCDP API error: ${response.status}`);
}
const rawData = await response.json();
totalPages = rawData.TotalPages || 1;
const conflicts = rawData.Result || [];
allConflicts = allConflicts.concat(conflicts);
page++;
}
// Fields are snake_case: conflict_id, location, side_a, side_b, year, intensity_level, type_of_conflict
const countryConflicts = {};
for (const c of allConflicts) {
const name = c.location || '';
const year = parseInt(c.year, 10) || 0;
const intensity = parseInt(c.intensity_level, 10) || 0;
const entry = {
conflictId: parseInt(c.conflict_id, 10) || 0,
conflictName: c.side_b || '',
location: name,
year,
intensityLevel: intensity,
typeOfConflict: parseInt(c.type_of_conflict, 10) || 0,
startDate: c.start_date,
startDate2: c.start_date2,
sideA: c.side_a,
sideB: c.side_b,
region: c.region,
};
// Keep most recent / highest intensity per location
if (!countryConflicts[name] || year > countryConflicts[name].year ||
(year === countryConflicts[name].year && intensity > countryConflicts[name].intensityLevel)) {
countryConflicts[name] = entry;
}
}
const result = {
success: true,
count: Object.keys(countryConflicts).length,
conflicts: Object.values(countryConflicts),
cached_at: new Date().toISOString(),
};
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/ucdp', 'MISS');
return Response.json(result, {
status: 200,
headers: {
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MISS',
},
});
} catch (error) {
if (isValidResult(fallbackCache.data)) {
recordCacheTelemetry('/api/ucdp', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: {
...cors,
'Cache-Control': 'public, max-age=600, s-maxage=600, stale-while-revalidate=120',
'X-Cache': 'STALE',
},
});
}
recordCacheTelemetry('/api/ucdp', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, conflicts: [] }, {
status: 500,
headers: { ...cors },
});
}
}
+270
View File
@@ -0,0 +1,270 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
export const config = { runtime: 'edge' };
const CACHE_KEY = 'unhcr:population:v2';
const CACHE_TTL_SECONDS = 24 * 60 * 60;
const CACHE_TTL_MS = CACHE_TTL_SECONDS * 1000;
let fallbackCache = { data: null, timestamp: 0 };
const rateLimiter = createIpRateLimiter({
limit: 20,
windowMs: 60 * 1000,
maxEntries: 5000,
});
function getClientIp(req) {
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
function isValidResult(data) {
return Boolean(data && typeof data === 'object' && Array.isArray(data.countries));
}
const COUNTRY_CENTROIDS = {
AFG: [33.9, 67.7], SYR: [35.0, 38.0], UKR: [48.4, 31.2], SDN: [15.5, 32.5],
SSD: [6.9, 31.3], SOM: [5.2, 46.2], COD: [-4.0, 21.8], MMR: [19.8, 96.7],
YEM: [15.6, 48.5], ETH: [9.1, 40.5], VEN: [6.4, -66.6], IRQ: [33.2, 43.7],
COL: [4.6, -74.1], NGA: [9.1, 7.5], PSE: [31.9, 35.2], TUR: [39.9, 32.9],
DEU: [51.2, 10.4], PAK: [30.4, 69.3], UGA: [1.4, 32.3], BGD: [23.7, 90.4],
KEN: [0.0, 38.0], TCD: [15.5, 19.0], JOR: [31.0, 36.0], LBN: [33.9, 35.5],
EGY: [26.8, 30.8], IRN: [32.4, 53.7], TZA: [-6.4, 34.9], RWA: [-1.9, 29.9],
CMR: [7.4, 12.4], MLI: [17.6, -4.0], BFA: [12.3, -1.6], NER: [17.6, 8.1],
CAF: [6.6, 20.9], MOZ: [-18.7, 35.5], USA: [37.1, -95.7], FRA: [46.2, 2.2],
GBR: [55.4, -3.4], IND: [20.6, 79.0], CHN: [35.9, 104.2], RUS: [61.5, 105.3],
};
async function fetchUnhcrYearItems(year) {
const limit = 10000;
const maxPageGuard = 25;
const items = [];
for (let page = 1; page <= maxPageGuard; page++) {
const response = await fetch(
`https://api.unhcr.org/population/v1/population/?year=${year}&limit=${limit}&page=${page}`,
{ headers: { Accept: 'application/json' } }
);
if (!response.ok) return null;
const data = await response.json();
const pageItems = Array.isArray(data.items) ? data.items : [];
if (pageItems.length === 0) break;
items.push(...pageItems);
const maxPages = Number(data.maxPages);
if (Number.isFinite(maxPages) && maxPages > 0) {
if (page >= maxPages) break;
continue;
}
if (pageItems.length < limit) break;
}
return items;
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) return new Response(null, { status: 403, headers: corsHeaders });
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed' }, { status: 403, headers: corsHeaders });
}
const ip = getClientIp(req);
if (!rateLimiter.check(ip)) {
return Response.json({ error: 'Rate limited' }, {
status: 429, headers: { ...corsHeaders, 'Retry-After': '60' },
});
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (isValidResult(cached)) {
recordCacheTelemetry('/api/unhcr-population', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'REDIS-HIT' },
});
}
if (isValidResult(fallbackCache.data) && now - fallbackCache.timestamp < CACHE_TTL_MS) {
recordCacheTelemetry('/api/unhcr-population', 'MEMORY-HIT');
return Response.json(fallbackCache.data, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'MEMORY-HIT' },
});
}
try {
const currentYear = new Date().getFullYear();
let rawItems = [];
let dataYearUsed = null;
for (let year = currentYear; year >= currentYear - 2; year--) {
const yearItems = await fetchUnhcrYearItems(year);
if (!yearItems) {
continue;
}
rawItems = yearItems;
if (rawItems.length > 0) {
dataYearUsed = year;
break;
}
}
const byOrigin = {};
const byAsylum = {};
const flowMap = {};
let totalRefugees = 0, totalAsylumSeekers = 0, totalIdps = 0, totalStateless = 0;
for (const item of rawItems) {
const originCode = item.coo_iso || '';
const asylumCode = item.coa_iso || '';
const refugees = Number(item.refugees) || 0;
const asylumSeekers = Number(item.asylum_seekers) || 0;
const idps = Number(item.idps) || 0;
const stateless = Number(item.stateless) || 0;
totalRefugees += refugees;
totalAsylumSeekers += asylumSeekers;
totalIdps += idps;
totalStateless += stateless;
if (originCode) {
if (!byOrigin[originCode]) byOrigin[originCode] = { refugees: 0, asylumSeekers: 0, idps: 0, stateless: 0, name: item.coo_name || originCode };
byOrigin[originCode].refugees += refugees;
byOrigin[originCode].asylumSeekers += asylumSeekers;
byOrigin[originCode].idps += idps;
byOrigin[originCode].stateless += stateless;
}
if (asylumCode) {
if (!byAsylum[asylumCode]) byAsylum[asylumCode] = { refugees: 0, asylumSeekers: 0, idps: 0, stateless: 0, name: item.coa_name || asylumCode };
byAsylum[asylumCode].refugees += refugees;
byAsylum[asylumCode].asylumSeekers += asylumSeekers;
}
if (originCode && asylumCode && refugees > 0) {
const flowKey = `${originCode}->${asylumCode}`;
if (!flowMap[flowKey]) {
flowMap[flowKey] = {
originCode, originName: item.coo_name || originCode,
asylumCode, asylumName: item.coa_name || asylumCode,
refugees: 0,
};
}
flowMap[flowKey].refugees += refugees;
}
}
const countries = {};
for (const [code, data] of Object.entries(byOrigin)) {
const centroid = COUNTRY_CENTROIDS[code];
countries[code] = {
code, name: data.name,
refugees: data.refugees, asylumSeekers: data.asylumSeekers,
idps: data.idps, stateless: data.stateless,
totalDisplaced: data.refugees + data.asylumSeekers + data.idps + data.stateless,
hostRefugees: 0,
hostAsylumSeekers: 0,
hostTotal: 0,
lat: centroid?.[0], lon: centroid?.[1],
};
}
for (const [code, data] of Object.entries(byAsylum)) {
const hostRefugees = data.refugees;
const hostAsylumSeekers = data.asylumSeekers;
const hostTotal = hostRefugees + hostAsylumSeekers;
if (!countries[code]) {
const centroid = COUNTRY_CENTROIDS[code];
countries[code] = {
code, name: data.name,
refugees: 0, asylumSeekers: 0, idps: 0, stateless: 0, totalDisplaced: 0,
hostRefugees,
hostAsylumSeekers,
hostTotal,
lat: centroid?.[0], lon: centroid?.[1],
};
} else {
countries[code].hostRefugees = hostRefugees;
countries[code].hostAsylumSeekers = hostAsylumSeekers;
countries[code].hostTotal = hostTotal;
}
}
const topFlows = Object.values(flowMap)
.sort((a, b) => b.refugees - a.refugees)
.slice(0, 50)
.map(f => {
const oC = COUNTRY_CENTROIDS[f.originCode];
const aC = COUNTRY_CENTROIDS[f.asylumCode];
return {
...f,
originLat: oC?.[0], originLon: oC?.[1],
asylumLat: aC?.[0], asylumLon: aC?.[1],
};
});
const result = {
success: true,
year: dataYearUsed ?? currentYear,
globalTotals: {
refugees: totalRefugees,
asylumSeekers: totalAsylumSeekers,
idps: totalIdps,
stateless: totalStateless,
total: totalRefugees + totalAsylumSeekers + totalIdps + totalStateless,
},
countries: Object.values(countries).sort((a, b) => {
const aSize = Math.max(a.totalDisplaced || 0, a.hostTotal || 0);
const bSize = Math.max(b.totalDisplaced || 0, b.hostTotal || 0);
return bSize - aSize;
}),
topFlows,
cached_at: new Date().toISOString(),
};
fallbackCache = { data: result, timestamp: now };
void setCachedJson(CACHE_KEY, result, CACHE_TTL_SECONDS);
recordCacheTelemetry('/api/unhcr-population', 'MISS');
return Response.json(result, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', 'X-Cache': 'MISS' },
});
} catch (error) {
if (isValidResult(fallbackCache.data)) {
recordCacheTelemetry('/api/unhcr-population', 'STALE');
return Response.json(fallbackCache.data, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=600, s-maxage=600, stale-while-revalidate=120', 'X-Cache': 'STALE' },
});
}
recordCacheTelemetry('/api/unhcr-population', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, countries: [], topFlows: [] }, {
status: 500, headers: corsHeaders,
});
}
}
+44
View File
@@ -0,0 +1,44 @@
export const config = { runtime: 'edge' };
const RELEASES_URL = 'https://api.github.com/repos/koala73/worldmonitor/releases/latest';
export default async function handler() {
try {
const res = await fetch(RELEASES_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'WorldMonitor-Version-Check',
},
});
if (!res.ok) {
return new Response(JSON.stringify({ error: 'upstream' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
const release = await res.json();
const tag = release.tag_name ?? '';
const version = tag.replace(/^v/, '');
return new Response(JSON.stringify({
version,
tag,
url: release.html_url,
prerelease: release.prerelease ?? false,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
'Access-Control-Allow-Origin': '*',
},
});
} catch {
return new Response(JSON.stringify({ error: 'fetch_failed' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}
-168
View File
@@ -1,168 +0,0 @@
// Wingbits API proxy - keeps API key server-side
// Note: Edge runtime is stateless - caching happens client-side and via HTTP Cache-Control
export const config = { runtime: 'edge' };
export default async function handler(req) {
const url = new URL(req.url);
const path = url.pathname.replace('/api/wingbits', '');
// Get API key from server-side env
const apiKey = process.env.WINGBITS_API_KEY;
if (!apiKey) {
return Response.json({
error: 'Wingbits not configured',
configured: false
}, {
status: 200,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
// Route: GET /details/:icao24 - Aircraft details
const detailsMatch = path.match(/^\/details\/([a-fA-F0-9]+)$/);
if (detailsMatch) {
const icao24 = detailsMatch[1].toLowerCase();
try {
const response = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, {
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
},
});
if (!response.ok) {
return Response.json({
error: `Wingbits API error: ${response.status}`,
icao24,
}, {
status: response.status,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
const data = await response.json();
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=86400', // 24h - aircraft details rarely change
},
});
} catch (error) {
return Response.json({
error: `Fetch failed: ${error.message}`,
icao24,
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
}
// Route: POST /details/batch - Batch lookup multiple aircraft (parallel)
if (path === '/details/batch' && req.method === 'POST') {
try {
const body = await req.json();
const icao24List = body.icao24s || [];
if (!Array.isArray(icao24List) || icao24List.length === 0) {
return Response.json({ error: 'icao24s array required' }, {
status: 400,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
// Limit batch size to avoid overwhelming the API
const limitedList = icao24List.slice(0, 20).map(id => id.toLowerCase());
const results = {};
// Fetch all in parallel
const fetchPromises = limitedList.map(async (icao24) => {
try {
const response = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, {
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
return { icao24, data };
}
} catch {
// Skip failed lookups
}
return null;
});
const fetchResults = await Promise.all(fetchPromises);
for (const result of fetchResults) {
if (result) {
results[result.icao24] = result.data;
}
}
return Response.json({
results,
fetched: Object.keys(results).length,
requested: limitedList.length,
}, {
headers: {
'Access-Control-Allow-Origin': '*',
},
});
} catch (error) {
return Response.json({
error: `Batch lookup failed: ${error.message}`,
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
}
// Route: GET /health - Check Wingbits status
if (path === '/health' || path === '') {
try {
const response = await fetch('https://customer-api.wingbits.com/health', {
headers: { 'x-api-key': apiKey },
});
const data = await response.json();
return Response.json({
...data,
configured: true,
}, {
headers: { 'Access-Control-Allow-Origin': '*' },
});
} catch (error) {
return Response.json({
error: error.message,
configured: true,
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
}
return Response.json({ error: 'Not found' }, {
status: 404,
headers: { 'Access-Control-Allow-Origin': '*' },
});
}
+304
View File
@@ -0,0 +1,304 @@
// Wingbits API proxy - keeps API key server-side
// Note: Edge runtime is stateless - caching happens client-side and via HTTP Cache-Control
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const url = new URL(req.url);
const path = url.pathname.replace('/api/wingbits', '');
const corsHeaders = getCorsHeaders(req, 'GET, POST, OPTIONS');
// Handle CORS preflight
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, {
status: 403,
headers: corsHeaders,
});
}
return new Response(null, {
status: 204,
headers: corsHeaders,
});
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed' }, {
status: 403,
headers: corsHeaders,
});
}
// Get API key from server-side env
const apiKey = process.env.WINGBITS_API_KEY;
if (!apiKey) {
return Response.json({
error: 'Wingbits not configured',
configured: false
}, {
status: 200,
headers: corsHeaders,
});
}
// Route: GET /details/:icao24 - Aircraft details
const detailsMatch = path.match(/^\/details\/([a-fA-F0-9]+)$/);
if (detailsMatch) {
const icao24 = detailsMatch[1].toLowerCase();
try {
const response = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, {
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
},
});
if (!response.ok) {
return Response.json({
error: `Wingbits API error: ${response.status}`,
icao24,
}, {
status: response.status,
headers: corsHeaders,
});
}
const data = await response.json();
return Response.json(data, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600', // 24h - aircraft details rarely change
},
});
} catch (error) {
return Response.json({
error: `Fetch failed: ${error.message}`,
icao24,
}, {
status: 500,
headers: corsHeaders,
});
}
}
// Route: POST /details/batch - Batch lookup multiple aircraft (parallel)
if (path === '/details/batch' && req.method === 'POST') {
try {
const body = await req.json();
const icao24List = body.icao24s || [];
if (!Array.isArray(icao24List) || icao24List.length === 0) {
return Response.json({ error: 'icao24s array required' }, {
status: 400,
headers: corsHeaders,
});
}
// Limit batch size to avoid overwhelming the API
const limitedList = icao24List.slice(0, 20).map(id => id.toLowerCase());
const results = {};
// Fetch all in parallel
const fetchPromises = limitedList.map(async (icao24) => {
try {
const response = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, {
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
return { icao24, data };
}
} catch {
// Skip failed lookups
}
return null;
});
const fetchResults = await Promise.all(fetchPromises);
for (const result of fetchResults) {
if (result) {
results[result.icao24] = result.data;
}
}
return Response.json({
results,
fetched: Object.keys(results).length,
requested: limitedList.length,
}, {
headers: {
...corsHeaders,
},
});
} catch (error) {
return Response.json({
error: `Batch lookup failed: ${error.message}`,
}, {
status: 500,
headers: corsHeaders,
});
}
}
// Route: GET /flights - Get live flight positions in a geographic area
// Query params: la (lat), lo (lon), w (width), h (height), unit (km|nm)
if (path === '/flights' && req.method === 'GET') {
try {
const params = new URLSearchParams(url.search);
const la = params.get('la') || params.get('lat');
const lo = params.get('lo') || params.get('lon');
const w = params.get('w') || params.get('width') || '500';
const h = params.get('h') || params.get('height') || '500';
const unit = params.get('unit') || 'nm';
if (!la || !lo) {
return Response.json({ error: 'lat (la) and lon (lo) required' }, {
status: 400,
headers: corsHeaders,
});
}
const wingbitsUrl = `https://customer-api.wingbits.com/v1/flights?by=box&la=${la}&lo=${lo}&w=${w}&h=${h}&unit=${unit}`;
console.log('[Wingbits] Fetching flights:', wingbitsUrl);
const response = await fetch(wingbitsUrl, {
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('[Wingbits] API error:', response.status, errorText);
return Response.json({
error: `Wingbits API error: ${response.status}`,
details: errorText,
}, {
status: response.status,
headers: corsHeaders,
});
}
const data = await response.json();
console.log('[Wingbits] Got', Array.isArray(data) ? data.length : 0, 'flights');
return Response.json(data, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15', // 30 seconds - live data
},
});
} catch (error) {
console.error('[Wingbits] Flights fetch error:', error);
return Response.json({
error: `Fetch failed: ${error.message}`,
}, {
status: 500,
headers: corsHeaders,
});
}
}
// Route: POST /flights/batch - Get flights for multiple areas (for theater posture)
if (path === '/flights/batch' && req.method === 'POST') {
try {
const body = await req.json();
const areas = body.areas || [];
if (!Array.isArray(areas) || areas.length === 0) {
return Response.json({ error: 'areas array required' }, {
status: 400,
headers: corsHeaders,
});
}
// Wingbits batch endpoint format
const wingbitsAreas = areas.map(area => ({
alias: area.id || area.alias,
by: 'box',
la: (area.north + area.south) / 2,
lo: (area.east + area.west) / 2,
w: Math.abs(area.east - area.west) * 60, // degrees to nautical miles (approx)
h: Math.abs(area.north - area.south) * 60,
unit: 'nm',
}));
const response = await fetch('https://customer-api.wingbits.com/v1/flights', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(wingbitsAreas),
});
if (!response.ok) {
const errorText = await response.text();
return Response.json({
error: `Wingbits API error: ${response.status}`,
details: errorText,
}, {
status: response.status,
headers: corsHeaders,
});
}
const data = await response.json();
console.log('[Wingbits] Batch got', data.length, 'area results');
return Response.json(data, {
headers: {
...corsHeaders,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
},
});
} catch (error) {
console.error('[Wingbits] Batch flights error:', error);
return Response.json({
error: `Fetch failed: ${error.message}`,
}, {
status: 500,
headers: corsHeaders,
});
}
}
// Route: GET /health - Check Wingbits status
if (path === '/health' || path === '') {
try {
const response = await fetch('https://customer-api.wingbits.com/health', {
headers: { 'x-api-key': apiKey },
});
const data = await response.json();
return Response.json({
...data,
configured: true,
}, {
headers: corsHeaders,
});
} catch (error) {
return Response.json({
error: error.message,
configured: true,
}, {
status: 500,
headers: corsHeaders,
});
}
}
return Response.json({ error: 'Not found' }, {
status: 404,
headers: corsHeaders,
});
}
+57
View File
@@ -0,0 +1,57 @@
// Wingbits single aircraft details
import { getCorsHeaders, isDisallowedOrigin } from '../../_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req, { params }) {
const icao24 = params.icao24?.toLowerCase();
const apiKey = process.env.WINGBITS_API_KEY;
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed' }, { status: 403, headers: corsHeaders });
}
if (!apiKey) {
return Response.json({ error: 'Wingbits not configured', configured: false }, {
headers: corsHeaders,
});
}
if (!icao24 || !/^[a-f0-9]+$/i.test(icao24)) {
return Response.json({ error: 'Invalid icao24' }, { status: 400, headers: corsHeaders });
}
try {
const response = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, {
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
},
});
if (!response.ok) {
return Response.json({ error: `Wingbits API error: ${response.status}`, icao24 }, {
status: response.status,
headers: corsHeaders,
});
}
const data = await response.json();
return Response.json(data, {
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600' },
});
} catch (error) {
return Response.json({ error: error.message, icao24 }, { status: 500, headers: corsHeaders });
}
}
+74
View File
@@ -0,0 +1,74 @@
// Wingbits batch aircraft details
import { getCorsHeaders, isDisallowedOrigin } from '../../_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const apiKey = process.env.WINGBITS_API_KEY;
const corsHeaders = getCorsHeaders(req, 'POST, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: corsHeaders });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed' }, { status: 403, headers: corsHeaders });
}
if (req.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
}
if (!apiKey) {
return Response.json({ error: 'Wingbits not configured', configured: false }, {
headers: corsHeaders,
});
}
try {
const body = await req.json();
const icao24List = body.icao24s || [];
if (!Array.isArray(icao24List) || icao24List.length === 0) {
return Response.json({ error: 'icao24s array required' }, { status: 400, headers: corsHeaders });
}
// Limit batch size
const limitedList = icao24List.slice(0, 20).map(id => id.toLowerCase());
const results = {};
// Fetch all in parallel
const fetchPromises = limitedList.map(async (icao24) => {
try {
const response = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, {
headers: {
'x-api-key': apiKey,
'Accept': 'application/json',
},
});
if (response.ok) {
return { icao24, data: await response.json() };
}
} catch {
// Skip failed lookups
}
return null;
});
const fetchResults = await Promise.all(fetchPromises);
for (const result of fetchResults) {
if (result) results[result.icao24] = result.data;
}
return Response.json({
results,
fetched: Object.keys(results).length,
requested: limitedList.length,
}, { headers: { 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60', ...corsHeaders } });
} catch (error) {
return Response.json({ error: error.message }, { status: 500, headers: corsHeaders });
}
}
+38 -66
View File
@@ -1,4 +1,9 @@
// Node.js serverless function (Edge gets 403 from World Bank)
// World Bank API proxy (Web API handler for Edge + sidecar compatibility)
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const TECH_INDICATORS = {
'IT.NET.USER.ZS': 'Internet Users (% of population)',
@@ -20,61 +25,56 @@ const TECH_INDICATORS = {
};
const TECH_COUNTRIES = [
// Major tech economies
'USA', 'CHN', 'JPN', 'DEU', 'KOR', 'GBR', 'IND', 'ISR', 'SGP', 'TWN',
'FRA', 'CAN', 'SWE', 'NLD', 'CHE', 'FIN', 'IRL', 'AUS', 'BRA', 'IDN',
// Middle East & emerging tech hubs
'ARE', 'SAU', 'QAT', 'BHR', 'EGY', 'TUR',
// Additional Asia
'MYS', 'THA', 'VNM', 'PHL',
// Europe
'ESP', 'ITA', 'POL', 'CZE', 'DNK', 'NOR', 'AUT', 'BEL', 'PRT', 'EST',
// Americas
'MEX', 'ARG', 'CHL', 'COL',
// Africa
'ZAF', 'NGA', 'KEN',
];
export default async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
return res.status(200).end();
export default async function handler(request) {
const CORS = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: CORS });
}
const { indicator, country, countries, years = '5', action } = req.query;
function json(data, status = 200, extra = {}) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', ...CORS, ...extra },
});
}
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: CORS });
}
const url = new URL(request.url);
const indicator = url.searchParams.get('indicator');
const country = url.searchParams.get('country');
const countries = url.searchParams.get('countries');
const years = url.searchParams.get('years') || '5';
const action = url.searchParams.get('action');
// Return available indicators
if (action === 'indicators') {
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.json({
indicators: TECH_INDICATORS,
defaultCountries: TECH_COUNTRIES,
});
return json({ indicators: TECH_INDICATORS, defaultCountries: TECH_COUNTRIES }, 200, { 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600' });
}
// Validate indicator
if (!indicator) {
return res.status(400).json({
error: 'Missing indicator parameter',
availableIndicators: Object.keys(TECH_INDICATORS),
});
return json({ error: 'Missing indicator parameter', availableIndicators: Object.keys(TECH_INDICATORS) }, 400);
}
try {
// Build country list
let countryList = country || countries || TECH_COUNTRIES.join(';');
if (countries) {
countryList = countries.split(',').join(';');
}
// Calculate date range
const currentYear = new Date().getFullYear();
const startYear = currentYear - parseInt(years);
// World Bank API v2
const wbUrl = `https://api.worldbank.org/v2/country/${countryList}/indicator/${indicator}?format=json&date=${startYear}:${currentYear}&per_page=1000`;
const response = await fetch(wbUrl, {
@@ -90,30 +90,23 @@ export default async function handler(req, res) {
const data = await response.json();
// World Bank returns [metadata, data] array
if (!data || !Array.isArray(data) || data.length < 2 || !data[1]) {
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.json({
return json({
indicator,
indicatorName: TECH_INDICATORS[indicator] || indicator,
metadata: { page: 1, pages: 1, total: 0 },
byCountry: {},
latestByCountry: {},
timeSeries: [],
});
}, 200, { 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' });
}
const [metadata, records] = data;
// Transform data for easier frontend consumption
const transformed = {
indicator,
indicatorName: TECH_INDICATORS[indicator] || (records[0]?.indicator?.value || indicator),
metadata: {
page: metadata.page,
pages: metadata.pages,
total: metadata.total,
},
metadata: { page: metadata.page, pages: metadata.pages, total: metadata.total },
byCountry: {},
latestByCountry: {},
timeSeries: [],
@@ -128,46 +121,25 @@ export default async function handler(req, res) {
if (!countryCode || value === null) continue;
if (!transformed.byCountry[countryCode]) {
transformed.byCountry[countryCode] = {
code: countryCode,
name: countryName,
values: [],
};
transformed.byCountry[countryCode] = { code: countryCode, name: countryName, values: [] };
}
transformed.byCountry[countryCode].values.push({ year, value });
if (!transformed.latestByCountry[countryCode] ||
year > transformed.latestByCountry[countryCode].year) {
transformed.latestByCountry[countryCode] = {
code: countryCode,
name: countryName,
year,
value,
};
if (!transformed.latestByCountry[countryCode] || year > transformed.latestByCountry[countryCode].year) {
transformed.latestByCountry[countryCode] = { code: countryCode, name: countryName, year, value };
}
transformed.timeSeries.push({
countryCode,
countryName,
year,
value,
});
transformed.timeSeries.push({ countryCode, countryName, year, value });
}
// Sort each country's values by year
for (const c of Object.values(transformed.byCountry)) {
c.values.sort((a, b) => a.year - b.year);
}
// Sort time series by year descending
transformed.timeSeries.sort((a, b) => b.year - a.year || a.countryCode.localeCompare(b.countryCode));
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.json(transformed);
return json(transformed, 200, { 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' });
} catch (error) {
return res.status(500).json({
error: error.message,
indicator,
});
return json({ error: error.message, indicator }, 500);
}
}
+171
View File
@@ -0,0 +1,171 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { createIpRateLimiter } from './_ip-rate-limit.js';
export const config = { runtime: 'edge' };
const COUNTRIES_CACHE_KEY = 'worldpop:countries:v1';
const COUNTRIES_TTL_SECONDS = 7 * 24 * 60 * 60;
const COUNTRIES_TTL_MS = COUNTRIES_TTL_SECONDS * 1000;
const EXPOSURE_TTL_SECONDS = 24 * 60 * 60;
let countriesFallback = { data: null, timestamp: 0 };
const rateLimiter = createIpRateLimiter({
limit: 30,
windowMs: 60 * 1000,
maxEntries: 5000,
});
function getClientIp(req) {
return req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';
}
function toErrorMessage(error) {
if (error instanceof Error) return error.message;
return String(error || 'unknown error');
}
const PRIORITY_COUNTRIES = {
UKR: { name: 'Ukraine', pop: 37000000, area: 603550 },
RUS: { name: 'Russia', pop: 144100000, area: 17098242 },
ISR: { name: 'Israel', pop: 9800000, area: 22072 },
PSE: { name: 'Palestine', pop: 5400000, area: 6020 },
SYR: { name: 'Syria', pop: 22100000, area: 185180 },
IRN: { name: 'Iran', pop: 88600000, area: 1648195 },
TWN: { name: 'Taiwan', pop: 23600000, area: 36193 },
ETH: { name: 'Ethiopia', pop: 126500000, area: 1104300 },
SDN: { name: 'Sudan', pop: 48100000, area: 1861484 },
SSD: { name: 'South Sudan', pop: 11400000, area: 619745 },
SOM: { name: 'Somalia', pop: 18100000, area: 637657 },
YEM: { name: 'Yemen', pop: 34400000, area: 527968 },
AFG: { name: 'Afghanistan', pop: 42200000, area: 652230 },
PAK: { name: 'Pakistan', pop: 240500000, area: 881913 },
IND: { name: 'India', pop: 1428600000, area: 3287263 },
MMR: { name: 'Myanmar', pop: 54200000, area: 676578 },
COD: { name: 'DR Congo', pop: 102300000, area: 2344858 },
NGA: { name: 'Nigeria', pop: 223800000, area: 923768 },
MLI: { name: 'Mali', pop: 22600000, area: 1240192 },
BFA: { name: 'Burkina Faso', pop: 22700000, area: 274200 },
};
function isValidCountries(data) {
return Boolean(data && typeof data === 'object' && Array.isArray(data.countries));
}
async function handleCountries(corsHeaders, now) {
const cached = await getCachedJson(COUNTRIES_CACHE_KEY);
if (isValidCountries(cached)) {
recordCacheTelemetry('/api/worldpop-exposure?countries', 'REDIS-HIT');
return Response.json(cached, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600', 'X-Cache': 'REDIS-HIT' },
});
}
if (isValidCountries(countriesFallback.data) && now - countriesFallback.timestamp < COUNTRIES_TTL_MS) {
recordCacheTelemetry('/api/worldpop-exposure?countries', 'MEMORY-HIT');
return Response.json(countriesFallback.data, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600', 'X-Cache': 'MEMORY-HIT' },
});
}
const countries = Object.entries(PRIORITY_COUNTRIES).map(([code, info]) => ({
code,
name: info.name,
population: info.pop,
densityPerKm2: Math.round(info.pop / info.area),
}));
const result = { success: true, countries, cached_at: new Date().toISOString() };
countriesFallback = { data: result, timestamp: now };
void setCachedJson(COUNTRIES_CACHE_KEY, result, COUNTRIES_TTL_SECONDS);
recordCacheTelemetry('/api/worldpop-exposure?countries', 'MISS');
return Response.json(result, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=3600', 'X-Cache': 'MISS' },
});
}
function handleExposure(corsHeaders, lat, lon, radius) {
let bestMatch = null;
let bestDist = Infinity;
const CENTROIDS = {
UKR: [48.4, 31.2], RUS: [61.5, 105.3], ISR: [31.0, 34.8], PSE: [31.9, 35.2],
SYR: [35.0, 38.0], IRN: [32.4, 53.7], TWN: [23.7, 121.0], ETH: [9.1, 40.5],
SDN: [15.5, 32.5], SSD: [6.9, 31.3], SOM: [5.2, 46.2], YEM: [15.6, 48.5],
AFG: [33.9, 67.7], PAK: [30.4, 69.3], IND: [20.6, 79.0], MMR: [19.8, 96.7],
COD: [-4.0, 21.8], NGA: [9.1, 7.5], MLI: [17.6, -4.0], BFA: [12.3, -1.6],
};
for (const [code, [cLat, cLon]] of Object.entries(CENTROIDS)) {
const dist = Math.sqrt(Math.pow(lat - cLat, 2) + Math.pow(lon - cLon, 2));
if (dist < bestDist) {
bestDist = dist;
bestMatch = code;
}
}
const info = PRIORITY_COUNTRIES[bestMatch] || { pop: 50000000, area: 500000 };
const density = info.pop / info.area;
const areaKm2 = Math.PI * radius * radius;
const exposed = Math.round(density * areaKm2);
return Response.json({
success: true,
exposedPopulation: exposed,
exposureRadiusKm: radius,
nearestCountry: bestMatch,
densityPerKm2: Math.round(density),
}, {
status: 200,
headers: { ...corsHeaders, 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' },
});
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) return new Response(null, { status: 403, headers: corsHeaders });
return new Response(null, { status: 204, headers: corsHeaders });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
}
if (isDisallowedOrigin(req)) {
return Response.json({ error: 'Origin not allowed' }, { status: 403, headers: corsHeaders });
}
const ip = getClientIp(req);
if (!rateLimiter.check(ip)) {
return Response.json({ error: 'Rate limited' }, {
status: 429, headers: { ...corsHeaders, 'Retry-After': '60' },
});
}
const url = new URL(req.url);
const mode = url.searchParams.get('mode') || 'countries';
if (mode === 'exposure') {
const lat = Number(url.searchParams.get('lat'));
const lon = Number(url.searchParams.get('lon'));
const radius = Number(url.searchParams.get('radius')) || 50;
if (isNaN(lat) || isNaN(lon)) {
return Response.json({ error: 'lat and lon required' }, { status: 400, headers: corsHeaders });
}
return handleExposure(corsHeaders, lat, lon, radius);
}
return handleCountries(corsHeaders, Date.now());
}
+10 -4
View File
@@ -1,5 +1,7 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const SYMBOL_PATTERN = /^[A-Za-z0-9.^=\-]+$/;
const MAX_SYMBOL_LENGTH = 20;
@@ -12,13 +14,17 @@ function validateSymbol(symbol) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const symbol = validateSymbol(url.searchParams.get('symbol'));
if (!symbol) {
return new Response(JSON.stringify({ error: 'Invalid or missing symbol parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
@@ -35,14 +41,14 @@ export default async function handler(req) {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=60',
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+128
View File
@@ -0,0 +1,128 @@
export const config = { runtime: 'edge' };
function parseFlag(value, fallback = '1') {
if (value === '0' || value === '1') return value;
return fallback;
}
function sanitizeVideoId(value) {
if (typeof value !== 'string') return null;
return /^[A-Za-z0-9_-]{11}$/.test(value) ? value : null;
}
const ALLOWED_ORIGINS = [
/^https:\/\/(.*\.)?worldmonitor\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+-elie-habib-projects\.vercel\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+\.vercel\.app$/,
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
/^tauri:\/\/localhost$/,
];
function sanitizeOrigin(raw) {
if (!raw) return 'https://worldmonitor.app';
try {
const parsed = new URL(raw);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:' && parsed.protocol !== 'tauri:') {
return 'https://worldmonitor.app';
}
const origin = parsed.origin !== 'null' ? parsed.origin : raw;
if (ALLOWED_ORIGINS.some(p => p.test(origin))) return origin;
} catch { /* invalid URL */ }
return 'https://worldmonitor.app';
}
export default async function handler(request) {
const url = new URL(request.url);
const videoId = sanitizeVideoId(url.searchParams.get('videoId'));
if (!videoId) {
return new Response('Missing or invalid videoId', {
status: 400,
headers: { 'content-type': 'text/plain; charset=utf-8' },
});
}
const autoplay = parseFlag(url.searchParams.get('autoplay'), '1');
const mute = parseFlag(url.searchParams.get('mute'), '1');
const origin = sanitizeOrigin(url.searchParams.get('origin'));
const embedSrc = new URL(`https://www.youtube-nocookie.com/embed/${videoId}`);
embedSrc.searchParams.set('autoplay', autoplay);
embedSrc.searchParams.set('mute', mute);
embedSrc.searchParams.set('playsinline', '1');
embedSrc.searchParams.set('rel', '0');
embedSrc.searchParams.set('controls', '1');
embedSrc.searchParams.set('enablejsapi', '1');
embedSrc.searchParams.set('origin', origin);
embedSrc.searchParams.set('widget_referrer', origin);
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<style>
html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}
#player{width:100%;height:100%}
#play-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;cursor:pointer;background:rgba(0,0,0,0.4)}
#play-overlay svg{width:72px;height:72px;opacity:0.9;filter:drop-shadow(0 2px 8px rgba(0,0,0,0.5))}
#play-overlay.hidden{display:none}
</style>
</head>
<body>
<div id="player"></div>
<div id="play-overlay"><svg viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24L27 14v20" fill="#fff"/></svg></div>
<script>
var tag=document.createElement('script');
tag.src='https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
var player,overlay=document.getElementById('play-overlay'),started=false;
function hideOverlay(){overlay.classList.add('hidden')}
function onYouTubeIframeAPIReady(){
player=new YT.Player('player',{
videoId:'${videoId}',
host:'https://www.youtube-nocookie.com',
playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:${JSON.stringify(origin)},widget_referrer:${JSON.stringify(origin)}},
events:{
onReady:function(){
window.parent.postMessage({type:'yt-ready'},'*');
if(${autoplay}===1){player.playVideo()}
},
onError:function(e){window.parent.postMessage({type:'yt-error',code:e.data},'*')},
onStateChange:function(e){
window.parent.postMessage({type:'yt-state',state:e.data},'*');
if(e.data===1||e.data===3){hideOverlay();started=true}
}
}
});
}
overlay.addEventListener('click',function(){
if(player&&player.playVideo){player.playVideo();player.unMute();hideOverlay()}
});
setTimeout(function(){if(!started)overlay.classList.remove('hidden')},3000);
window.addEventListener('message',function(e){
if(!player||!player.getPlayerState)return;
var m=e.data;if(!m||!m.type)return;
switch(m.type){
case'play':player.playVideo();break;
case'pause':player.pauseVideo();break;
case'mute':player.mute();break;
case'unmute':player.unMute();break;
case'loadVideo':if(m.videoId)player.loadVideoById(m.videoId);break;
}
});
</script>
</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'public, s-maxage=60, stale-while-revalidate=300',
},
});
}
+35
View File
@@ -0,0 +1,35 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import handler from './embed.js';
function makeRequest(query = '') {
return new Request(`https://worldmonitor.app/api/youtube/embed${query}`);
}
test('rejects missing or invalid video ids', async () => {
const missing = await handler(makeRequest());
assert.equal(missing.status, 400);
const invalid = await handler(makeRequest('?videoId=bad'));
assert.equal(invalid.status, 400);
});
test('returns embeddable html for valid video id', async () => {
const response = await handler(makeRequest('?videoId=iEpJwprxDdk&autoplay=0&mute=1'));
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type')?.includes('text/html'), true);
const html = await response.text();
assert.equal(html.includes("videoId:'iEpJwprxDdk'"), true);
assert.equal(html.includes("host:'https://www.youtube-nocookie.com'"), true);
assert.equal(html.includes('autoplay:0'), true);
assert.equal(html.includes('mute:1'), true);
assert.equal(html.includes('origin:"https://worldmonitor.app"'), true);
assert.equal(html.includes('postMessage'), true);
});
test('accepts custom origin parameter', async () => {
const response = await handler(makeRequest('?videoId=iEpJwprxDdk&origin=http://127.0.0.1:46123'));
const html = await response.text();
assert.equal(html.includes('origin:"http://127.0.0.1:46123"'), true);
});
+9 -2
View File
@@ -1,11 +1,18 @@
// YouTube Live Stream Detection API
// Uses YouTube's oembed endpoint to check for live streams
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
export const config = {
runtime: 'edge',
};
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(request.url);
const channel = url.searchParams.get('channel');
@@ -46,7 +53,7 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300', // Cache for 5 minutes
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60', // Cache for 5 minutes
},
});
}
@@ -56,7 +63,7 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"compress/gzip"
"io"
"net/http"
"strings"
"sync"
)
// gzip on the fly for the static SPA. The hanzoai/static image this binary
// replaced compressed responses for us; serving the Vite bundle raw (main.js
// 1.5MB, map.js 2.7MB) is the whole reason a cold load felt slow. Restoring
// gzip here cuts the JS/CSS wire size ~75%. Paired with immutable cache headers
// on hashed assets (see setCacheHeaders), each client pays the transfer once.
//
// Scope: wraps ONLY the SPA handler, never /v1/world/* — those include SSE /
// streaming endpoints that must not be buffered through a gzip.Writer.
var gzipPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
return w
},
}
// gzipStatic compresses compressible responses when the client accepts gzip.
// Range requests pass through untouched (compressing a byte-range is invalid).
func gzipStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") != "" || !acceptsGzip(r) {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
func acceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if strings.EqualFold(strings.TrimSpace(strings.SplitN(enc, ";", 2)[0]), "gzip") {
return true
}
}
return false
}
// compressible reports whether a content type shrinks under gzip. Already-
// compressed media (png/jpg/woff2/…) is skipped so we never waste CPU or grow
// the payload.
func compressible(ct string) bool {
ct = strings.ToLower(ct)
switch {
case strings.HasPrefix(ct, "text/"),
strings.Contains(ct, "javascript"),
strings.Contains(ct, "json"),
strings.Contains(ct, "svg"),
strings.Contains(ct, "xml"),
strings.Contains(ct, "wasm"),
strings.Contains(ct, "manifest"):
return true
}
return false
}
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
compress bool
}
func (g *gzipResponseWriter) WriteHeader(status int) {
g.decide(status)
g.ResponseWriter.WriteHeader(status)
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
if !g.decided {
g.decide(http.StatusOK) // FileServer may Write without an explicit WriteHeader
}
if g.compress {
return g.gz.Write(b)
}
return g.ResponseWriter.Write(b)
}
// decide inspects the headers the wrapped handler set (Content-Type is already
// populated by http.ServeContent at this point) and commits to compress-or-not
// exactly once.
func (g *gzipResponseWriter) decide(status int) {
if g.decided {
return
}
g.decided = true
h := g.Header()
if status == http.StatusOK && h.Get("Content-Encoding") == "" && compressible(h.Get("Content-Type")) {
g.compress = true
h.Del("Content-Length") // length changes after compression
h.Set("Content-Encoding", "gzip")
h.Add("Vary", "Accept-Encoding")
g.gz = gzipPool.Get().(*gzip.Writer)
g.gz.Reset(g.ResponseWriter)
}
}
func (g *gzipResponseWriter) close() {
if g.gz != nil {
_ = g.gz.Close()
gzipPool.Put(g.gz)
g.gz = nil
}
}
+191
View File
@@ -0,0 +1,191 @@
// Command world is the single process baked into the world image. It serves
// BOTH the static Vite SPA (world.hanzo.ai and every *.hanzo.app fork) and the
// same-origin /v1/world/* data + live-video backend the SPA fetches.
//
// One binary, two responsibilities kept orthogonal:
// - /v1/world/* → the Go data backend (internal/world), each endpoint a
// faithful port of the original edge function.
// - everything → static files from --root, with SPA fallback to index.html
// else for client-routed paths (never for /api or asset misses).
//
// It listens on :3000 (the container/CR port); override with --addr or PORT.
package main
import (
"context"
"errors"
"flag"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/hanzoai/world/internal/world"
)
func main() {
var (
addr = flag.String("addr", envOr("WORLD_ADDR", ":"+envOr("PORT", "3000")), "listen address")
root = flag.String("root", envOr("WORLD_STATIC_ROOT", "dist"), "static SPA root directory")
)
flag.Parse()
// Root context cancelled on SIGINT/SIGTERM: drives the world-model ingest
// loop and triggers graceful HTTP shutdown from one signal source.
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Fetch secrets from KMS and inject them into the environment BEFORE the
// server reads any config. Fail-open: no creds / unreachable KMS logs one
// line and continues on plain env (see internal/world/kms.go).
world.LoadKMSSecrets(rootCtx)
srv := world.NewServer()
defer srv.Close() // release hanzo-kv + embedded datastore handles
srv.StartModel(rootCtx) // continuously-folded world-state engine
srv.StartDatastore(rootCtx) // shared feed warmer + lake write-behind/prune
mux := http.NewServeMux()
srv.Mount(mux) // /v1/world/* routes
// Static SPA + fallback handles everything not matched by an /api route.
// gzipStatic wraps ONLY this handler — /v1/world/* keeps its streaming
// endpoints unbuffered.
mux.Handle("/", gzipStatic(newSPAHandler(*root)))
httpSrv := &http.Server{
Addr: *addr,
Handler: logRequests(mux),
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Printf("world: serving SPA from %q and /v1/world/* on %s", *root, *addr)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("world: server error: %v", err)
}
}()
<-rootCtx.Done()
log.Printf("world: shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
}
// spaHandler serves files from root and falls back to index.html for any GET
// that doesn't resolve to a real file (client-side routing). It never serves the
// SPA shell for /api paths (those are handled by the mux) and returns 404 for
// missing static assets so a broken asset URL is visible, not masked by HTML.
type spaHandler struct {
root string
indexHTML []byte
csp string
fileSrv http.Handler
}
func newSPAHandler(root string) http.Handler {
// Honor the same CSP env the prior hanzoai/static image used, so the world
// CR needs no change on cutover; WORLD_CSP is an explicit alias.
h := &spaHandler{
root: root,
fileSrv: http.FileServer(http.Dir(root)),
csp: envOr("HANZO_STATIC_CSP", envOr("WORLD_CSP", "")),
}
if b, err := os.ReadFile(filepath.Join(root, "index.html")); err == nil {
h.indexHTML = b
} else {
log.Printf("world: warning: no index.html under %q: %v", root, err)
}
return h
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clean := filepath.Clean(r.URL.Path)
// Resolve against root; reject traversal.
rel := strings.TrimPrefix(clean, "/")
full := filepath.Join(h.root, rel)
if !strings.HasPrefix(full, filepath.Clean(h.root)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if rel == "" || rel == "." {
h.serveIndex(w, r)
return
}
info, err := os.Stat(full)
switch {
case err == nil && !info.IsDir():
setCacheHeaders(w, rel)
h.fileSrv.ServeHTTP(w, r) // real file
case errors.Is(err, fs.ErrNotExist) && !hasExt(rel):
h.serveIndex(w, r) // client-routed path → SPA shell
default:
http.NotFound(w, r) // missing asset (has extension) or dir
}
}
func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
if h.indexHTML == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
if h.csp != "" {
w.Header().Set("Content-Security-Policy", h.csp)
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(h.indexHTML)
}
}
// setCacheHeaders makes Vite's content-hashed bundles cacheable forever while
// keeping every unhashed file (favicons, manifest, service worker) revalidated
// so SW updates and icon swaps are never stuck behind a stale cache.
func setCacheHeaders(w http.ResponseWriter, rel string) {
if strings.HasPrefix(rel, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
// hasExt reports whether the last path segment has a file extension, used to
// distinguish an asset miss (foo.js → 404) from a client route (/country/US →
// SPA shell).
func hasExt(rel string) bool {
base := filepath.Base(rel)
return strings.Contains(base, ".")
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v1/world/") {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("api %s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
return
}
next.ServeHTTP(w, r)
})
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeTree lays out a minimal Vite-style dist for the SPA handler tests.
func writeTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatal(err)
}
// A hashed JS bundle large enough that gzip clearly wins.
js := strings.Repeat("export const answer = 42;\n", 4000)
files := map[string]string{
"index.html": "<!doctype html><title>world</title>",
"assets/main-abc123.js": js,
"assets/main-abc123.css": strings.Repeat(".panel{display:flex}\n", 2000),
"favicon.ico": "\x00\x00binary",
"manifest.webmanifest": `{"name":"world"}`,
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
func TestStaticGzipAndCache(t *testing.T) {
root := writeTree(t)
srv := httptest.NewServer(gzipStatic(newSPAHandler(root)))
defer srv.Close()
get := func(path, ae string) *http.Response {
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
req.Header.Set("Accept-Encoding", ae)
resp, err := http.DefaultTransport.RoundTrip(req) // no transparent gunzip
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
rawLen := func(root, name string) int {
b, _ := os.ReadFile(filepath.Join(root, name))
return len(b)
}
t.Run("hashed js is gzipped, immutable, and smaller on the wire", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "gzip")
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Fatalf("Cache-Control = %q, want immutable", got)
}
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", resp.Header.Get("Vary"))
}
body, _ := io.ReadAll(resp.Body)
raw := rawLen(root, "assets/main-abc123.js")
if len(body) >= raw {
t.Fatalf("gzip wire size %d not smaller than raw %d", len(body), raw)
}
// And it must actually decode back to the original bytes.
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
dec, _ := io.ReadAll(zr)
if len(dec) != raw {
t.Fatalf("decoded %d bytes, want %d", len(dec), raw)
}
})
t.Run("no gzip when client does not accept it", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "identity")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") != "" {
t.Fatalf("unexpected Content-Encoding %q", resp.Header.Get("Content-Encoding"))
}
})
t.Run("unhashed files revalidate, never immutable", func(t *testing.T) {
for _, p := range []string{"/favicon.ico", "/manifest.webmanifest"} {
resp := get(p, "gzip")
resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("%s Cache-Control = %q, want no-cache", p, cc)
}
}
})
t.Run("already-compressed media is not re-gzipped", func(t *testing.T) {
resp := get("/favicon.ico", "gzip")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") == "gzip" {
t.Fatal("favicon.ico should not be gzipped")
}
})
t.Run("index shell served with no-cache and gzip", func(t *testing.T) {
resp := get("/", "gzip")
defer resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("index Cache-Control = %q, want no-cache", cc)
}
if resp.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("index should be gzipped, got %q", resp.Header.Get("Content-Encoding"))
}
})
}
+53
View File
@@ -0,0 +1,53 @@
# Desktop Runtime Configuration Schema
World Monitor desktop now uses a runtime configuration schema with per-feature toggles and secret-backed credentials.
## Secret keys
The desktop vault schema supports the following 17 keys used by services and relays:
- `GROQ_API_KEY`
- `OPENROUTER_API_KEY`
- `FRED_API_KEY`
- `EIA_API_KEY`
- `FINNHUB_API_KEY`
- `CLOUDFLARE_API_TOKEN`
- `ACLED_ACCESS_TOKEN`
- `URLHAUS_AUTH_KEY`
- `OTX_API_KEY`
- `ABUSEIPDB_API_KEY`
- `NASA_FIRMS_API_KEY`
- `WINGBITS_API_KEY`
- `VITE_OPENSKY_RELAY_URL`
- `OPENSKY_CLIENT_ID`
- `OPENSKY_CLIENT_SECRET`
- `AISSTREAM_API_KEY`
- `VITE_WS_RELAY_URL`
## Feature schema
Each feature includes:
- `id`: stable feature identifier.
- `requiredSecrets`: list of keys that must be present and valid.
- `enabled`: user-toggle state from runtime settings panel.
- `available`: computed (`enabled && requiredSecrets valid`).
- `fallback`: user-facing degraded behavior description.
## Desktop secret storage
Desktop builds persist secrets in OS credential storage through Tauri command bindings backed by Rust `keyring` entries (`world-monitor` service namespace).
Secrets are **not stored in plaintext files** by the frontend.
## Degradation behavior
If required secrets are missing/disabled:
- Summarization: Groq/OpenRouter disabled, browser model fallback.
- FRED / EIA / Finnhub: economic, oil analytics, and stock data return empty state.
- Cloudflare / ACLED: outages/conflicts return empty state.
- Cyber threat feeds (URLhaus, OTX, AbuseIPDB): cyber threat layer returns empty state.
- NASA FIRMS: satellite fire detection returns empty state.
- Wingbits: flight enrichment disabled, heuristic-only flight classification remains.
- AIS / OpenSky relay: live tracking features are disabled cleanly.
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
# News Translation Analysis
## Current Architecture
The application fetches news via `src/services/rss.ts`.
- **Mechanism**: Direct HTTP requests (via proxy) to RSS/Atom XML feeds.
- **Processing**: `DOMParser` parses XML client-side.
- **Storage**: Items are stored in-memory in `App.ts` (`allNews`, `newsByCategory`).
## The Challenge
Legacy RSS feeds are static XML files in their original language. There is no built-in "negotiation" for language. To display French news, we must either:
1. Fetch French feeds.
2. Translate English feeds on the fly.
## Proposed Solutions
### Option 1: Localized Feed Discovery (Recommended for "Major" Support)
Instead of forcing translation, we switch the *source* based on the selected language.
- **Implementation**:
- In `src/config/feeds.ts`, change the simple URL string to an object: `url: { en: '...', fr: '...' }` or separate constant lists `FEEDS_EN`, `FEEDS_FR`.
- **Pros**: Zero latency, native content quality, no API costs.
- **Cons**: Hard to find equivalent feeds for niche topics (e.g., specific mil-tech blogs) in all languages.
- **Strategy**: Creating a curated list of international feeds for major categories (World, Politics, Finance) is the most robust & scalable approach.
### Option 2: On-Demand Client-Side Translation
Add a "Translate" button to each news card.
- **Implementation**:
- Click triggers a call to a translation API (Google/DeepL/LLM).
- Store result in a local cache (Map).
- **Pros**: Low cost (only used when needed), preserves original context.
- **Cons**: User friction (click to read).
### Option 3: Automatic Auto-Translation (Not Recommended)
Translating 500+ headlines on every load.
- **Cons**:
- **Cost**: Prohibitive for free/low-cost APIs.
- **Latency**: Massive slowdown on startup.
- **Quality**: Short headlines often translate poorly without context.
## Recommendation
**Hybrid Approach**:
1. **Primary**: Source localized feeds where possible (e.g., Le Monde for FR, Spiegel for DE). This requires a community effort to curate `feeds.json` for each locale.
2. **Fallback**: Keep English feeds for niche tech/intel sources where no alternative exists.
3. **Feature**: Add a "Summarize & Translate" button using the existing LLM worker. The prompt to the LLM (currently used for summaries) can be adjusted to "Summarize this in [Current Language]".
## Next Steps
1. Audit `src/config/feeds.ts` to structure it for multi-language support.
2. Update `rss.ts` to select the correct URL based on `i18n.language`.
+225
View File
@@ -0,0 +1,225 @@
# Desktop Release Packaging Guide (Local, Reproducible)
This guide provides reproducible local packaging steps for both desktop variants:
- **full**`World Monitor`
- **tech**`Tech Monitor`
Variant identity is controlled by Tauri config:
- full: `src-tauri/tauri.conf.json`
- tech: `src-tauri/tauri.tech.conf.json`
## Prerequisites
- Node.js + npm
- Rust toolchain
- OS-native Tauri build prerequisites:
- macOS: Xcode command-line tools
- Windows: Visual Studio Build Tools + NSIS + WiX
Install dependencies (this also installs the pinned Tauri CLI used by desktop scripts):
```bash
npm ci
```
All desktop scripts call the local `tauri` binary from `node_modules/.bin`; no runtime `npx` package download is required after `npm ci`.
If the local CLI is missing, `scripts/desktop-package.mjs` now fails fast with an explicit `npm ci` remediation message.
## Network preflight and remediation
Before running desktop packaging in CI or managed networks, verify connectivity and proxy config:
```bash
npm ping
curl -I https://index.crates.io/
env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY)='
```
If these fail, use one of the supported remediations:
- Internal npm mirror/proxy.
- Internal Cargo sparse index/registry mirror.
- Pre-vendored Rust crates (`src-tauri/vendor/`) + Cargo offline mode.
- CI artifact/caching strategy that restores required package inputs before build.
See `docs/TAURI_VALIDATION_REPORT.md` for failure classification labels and troubleshooting flow.
## Packaging commands
To view script usage/help:
```bash
npm run desktop:package -- --help
```
### macOS (`.app` + `.dmg`)
```bash
npm run desktop:package:macos:full
npm run desktop:package:macos:tech
# or generic runner
npm run desktop:package -- --os macos --variant full
```
### Windows (`.exe` + `.msi`)
```bash
npm run desktop:package:windows:full
npm run desktop:package:windows:tech
# or generic runner
npm run desktop:package -- --os windows --variant tech
```
Bundler targets are pinned in both Tauri configs and enforced by packaging scripts:
- macOS: `app,dmg`
- Windows: `nsis,msi`
## Rust dependency modes (online vs restricted network)
From `src-tauri/`, the project supports two packaging paths:
### 1) Standard online build (default)
Use normal Cargo behavior (crates.io):
```bash
cd src-tauri
cargo generate-lockfile
cargo tauri build --config tauri.conf.json
```
### 2) Restricted-network build (pre-vendored or internal mirror)
An optional vendored source is defined in `src-tauri/.cargo/config.toml`. To use it, first prepare vendored crates on a machine that has registry access:
```bash
# from repository root
cargo vendor --manifest-path src-tauri/Cargo.toml src-tauri/vendor
```
Then enable offline mode using either method:
- One-off CLI override (no file changes):
```bash
cd src-tauri
cargo generate-lockfile --offline --config 'source.crates-io.replace-with="vendored-sources"'
cargo tauri build --offline --config 'source.crates-io.replace-with="vendored-sources"' --config tauri.conf.json
```
- Local override file (recommended for CI/repeatable offline jobs):
```bash
cp src-tauri/.cargo/config.local.toml.example src-tauri/.cargo/config.local.toml
cd src-tauri
cargo generate-lockfile --offline
cargo tauri build --offline --config tauri.conf.json
```
For CI or internal mirrors, publish `src-tauri/vendor/` as an artifact and restore it before the restricted-network build. If your organization uses an internal crates mirror instead of vendoring, point `source.crates-io.replace-with` to that mirror in CI-specific Cargo config and run the same build commands.
## Optional signing/notarization hooks
Unsigned packaging works by default.
If signing credentials are present in environment variables, Tauri will sign/notarize automatically during the same packaging commands.
### macOS Apple Developer signing + notarization
Set before packaging (Developer ID signature):
```bash
export TAURI_BUNDLE_MACOS_SIGNING_IDENTITY="Developer ID Application: Your Company (TEAMID)"
export TAURI_BUNDLE_MACOS_PROVIDER_SHORT_NAME="TEAMID"
# optional alternate key accepted by Tauri tooling:
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Company (TEAMID)"
```
For notarization, choose one auth method:
```bash
# Apple ID + app-specific password
export APPLE_ID="you@example.com"
export APPLE_PASSWORD="app-specific-password"
export APPLE_TEAM_ID="TEAMID"
# OR App Store Connect API key
export APPLE_API_KEY="ABC123DEFG"
export APPLE_API_ISSUER="00000000-0000-0000-0000-000000000000"
export APPLE_API_KEY_PATH="$HOME/.keys/AuthKey_ABC123DEFG.p8"
```
Then run either standard or explicit sign script aliases:
```bash
npm run desktop:package:macos:full
# or
npm run desktop:package:macos:full:sign
```
### Windows Authenticode signing
Set before packaging (PowerShell):
```powershell
$env:TAURI_BUNDLE_WINDOWS_CERTIFICATE_THUMBPRINT="<CERT_THUMBPRINT>"
$env:TAURI_BUNDLE_WINDOWS_TIMESTAMP_URL="https://timestamp.digicert.com"
# optional: if using cert file + password instead of cert store
$env:TAURI_BUNDLE_WINDOWS_CERTIFICATE="C:\path\to\codesign.pfx"
$env:TAURI_BUNDLE_WINDOWS_CERTIFICATE_PASSWORD="<PFX_PASSWORD>"
```
Then run either standard or explicit sign script aliases:
```powershell
npm run desktop:package:windows:full
# or
npm run desktop:package:windows:full:sign
```
## Variant-aware outputs (names/icons)
- Full variant: `World Monitor` / `world-monitor`
- Tech variant: `Tech Monitor` / `tech-monitor`
Distinct names are configured in Tauri:
- `src-tauri/tauri.conf.json``World Monitor` / `world-monitor`
- `src-tauri/tauri.tech.conf.json``Tech Monitor` / `tech-monitor`
If you want variant-specific icons, set `bundle.icon` separately in each config and point each variant to dedicated icon assets.
## Output locations
Artifacts are produced under:
```text
src-tauri/target/release/bundle/
```
Common subfolders:
- `app/` → macOS `.app`
- `dmg/` → macOS `.dmg`
- `nsis/` → Windows `.exe` installer
- `msi/` → Windows `.msi` installer
## Release checklist (clean machine)
1. Build required OS + variant package(s).
2. Move artifacts to a clean machine (or fresh VM).
3. Install/launch:
- macOS: mount `.dmg`, drag app to Applications, launch.
- Windows: run `.exe` or `.msi`, launch from Start menu.
4. Validate startup:
- App window opens without crash.
- Map view renders.
- Initial data loading path does not fatal-error.
5. Validate variant identity:
- Window title and product name match expected variant.
6. If signing was enabled:
- Verify code-signing metadata in OS dialogs/properties.
- Verify notarization/Gatekeeper acceptance on macOS.
+69
View File
@@ -0,0 +1,69 @@
# Tauri Validation Report
## Scope
Validated desktop build readiness for the World Monitor Tauri app by checking frontend compilation, TypeScript integrity, and Tauri/Rust build execution.
## Preflight checks before desktop validation
Run these checks first so failures are classified quickly:
1. npm registry reachability
- `npm ping`
2. crates.io sparse index reachability
- `curl -I https://index.crates.io/`
3. proxy configuration present when required by your network
- `env | grep -E '^(HTTP_PROXY|HTTPS_PROXY|NO_PROXY)='`
If any of these checks fail, treat downstream desktop build failures as environment-level until the network path is fixed.
## Commands run
1. `npm ci` — failed because the environment blocks downloading the pinned `@tauri-apps/cli` package from npm (`403 Forbidden`).
2. `npm run typecheck` — succeeded.
3. `npm run build:full` — succeeded (warnings only).
4. `npm run desktop:build:full` — not runnable in this environment because `npm ci` failed, so the local `tauri` binary was unavailable (desktop scripts now fail fast with a clear `npm ci` remediation message when this occurs).
5. `cargo check` (from `src-tauri/`) — failed because the environment blocks downloading crates from `https://index.crates.io` (`403 CONNECT tunnel failed`).
## Assessment
- The web app portion compiles successfully.
- Full Tauri desktop validation in this run is blocked by an **external environment outage/restriction** (registry access denied with HTTP 403).
- No code/runtime defects were observed in project sources during this validation pass.
## Failure classification for future QA
Use these labels in future reports so outcomes are actionable:
1. **External environment outage**
- Symptoms: npm/crates registry requests fail with transport/auth/network errors (403/5xx/timeout/DNS/proxy), independent of repository state.
- Action: retry in a healthy network or fix credentials/proxy/mirror availability.
2. **Expected failure: offline mode not provisioned**
- Symptoms: build is intentionally run without internet, but required offline inputs are missing (for Rust: no `vendor/` artifact, no internal mirror mapping, or offline override not enabled; for JS: no prepared package cache).
- Action: provision offline artifacts/mirror config first, enable offline override (`config.local.toml` or CLI `--config`), then rerun.
## Next action to validate desktop end-to-end
Choose one supported path:
- Online path:
- `npm ci`
- `npm run desktop:build:full`
- Restricted-network path:
- Restore prebuilt offline artifacts (including `src-tauri/vendor/` or internal mirror mapping).
- Run Cargo with `source.crates-io.replace-with` mapped to vendored/internal source and `--offline` where applicable.
After `npm ci`, desktop build uses the local `tauri` binary and does not rely on runtime `npx` package retrieval.
## Remediation options for restricted environments
If preflight fails, use one of these approved remediations:
- Configure an internal npm mirror/proxy for Node packages.
- Configure an internal Cargo registry/sparse index mirror for Rust crates.
- Pre-vendor Rust crates (`src-tauri/vendor/`) and run Cargo in offline mode.
- Use CI runners that restore package/cache artifacts from a trusted internal store before builds.
For release packaging details, see `docs/RELEASE_PACKAGING.md` (section: **Network preflight and remediation**).
+44
View File
@@ -0,0 +1,44 @@
# Local backend parity matrix (desktop sidecar)
This matrix tracks desktop parity by mapping `src/services/*.ts` consumers to `api/*.js` handlers and classifying each feature as:
- **Fully local**: works from desktop sidecar without user credentials.
- **Requires user-provided API key**: local endpoint exists, but capability depends on configured secrets.
- **Requires cloud fallback**: sidecar exists, but operational behavior depends on a cloud relay path.
## Priority closure order
1. **Priority 1 (core panels + map):** LiveNewsPanel, MonitorPanel, StrategicRiskPanel, critical map layers.
2. **Priority 2 (intelligence continuity):** summaries and market panel.
3. **Priority 3 (enhancements):** enrichment and relay-dependent tracking extras.
## Feature parity matrix
| Priority | Feature / Panel | Service source(s) (`src/services/*.ts`) | API route(s) | API handler(s) (`api/*.js`) | Classification | Closure status |
|---|---|---|---|---|---|---|
| P1 | LiveNewsPanel | `src/services/live-news.ts` | `/api/youtube/live` | `api/youtube/live.js` | Fully local | ✅ Local endpoint available; channel-level video fallback already implemented. |
| P1 | MonitorPanel | _None (panel-local keyword matching)_ | _None_ | _None_ | Fully local | ✅ Client-side only (no backend dependency). |
| P1 | StrategicRiskPanel cached overlays | `src/services/cached-risk-scores.ts` | `/api/risk-scores` | `api/risk-scores.js` | Requires user-provided API key | ✅ Explicit fallback: panel continues with local aggregate scoring when cache feed is unavailable. |
| P1 | Map layers (conflicts, outages, AIS, military flights) | `src/services/conflicts.ts`, `src/services/outages.ts`, `src/services/ais.ts`, `src/services/military-flights.ts` | `/api/acled-conflict`, `/api/cloudflare-outages`, `/api/ais-snapshot`, `/api/opensky` | `api/acled-conflict.js`, `api/cloudflare-outages.js`, `api/ais-snapshot.js`, `api/opensky.js` | Requires user-provided API key | ✅ Explicit fallback: unavailable feeds are disabled while map rendering remains active for local/static layers. |
| P2 | Summaries | `src/services/summarization.ts` | `/api/groq-summarize`, `/api/openrouter-summarize` | `api/groq-summarize.js`, `api/openrouter-summarize.js` | Requires user-provided API key | ✅ Explicit fallback chain: Groq → OpenRouter → browser model. |
| P2 | MarketPanel | `src/services/markets.ts`, `src/services/polymarket.ts` | `/api/coingecko`, `/api/polymarket`, `/api/finnhub`, `/api/yahoo-finance` | `api/coingecko.js`, `api/polymarket.js`, `api/finnhub.js`, `api/yahoo-finance.js` | Fully local | ✅ Multi-provider and cache-aware fetch behavior maintained in sidecar mode. |
| P3 | Flight enrichment | `src/services/wingbits.ts` | `/api/wingbits` | `api/wingbits/[[...path]].js` | Requires user-provided API key | ✅ Explicit fallback: heuristic-only classification mode. |
| P3 | OpenSky relay fallback path | `src/services/military-flights.ts` | `/api/opensky` | `api/opensky.js` | Requires cloud fallback | ✅ Relay fallback documented; no hard failure when relay is unavailable. |
## Non-parity closure actions completed
- Added **desktop readiness + non-parity fallback visibility** in `ServiceStatusPanel` so operators can see acceptance status and per-feature fallback behavior in desktop runtime.
- Kept local-sidecar strategy as the default path: desktop sidecar executes `api/*.js` handlers locally and only uses cloud fallback when handler execution or relay path fails.
## Desktop-ready acceptance criteria
A desktop build is considered **ready** when all checks below are green:
1. **Startup:** app launches and local sidecar health reports enabled.
2. **Map rendering:** map loads with local/static layers even when optional feeds are unavailable.
3. **Core intelligence panels:** LiveNewsPanel, MonitorPanel, StrategicRiskPanel render without fatal errors.
4. **Summaries:** at least one summary path works (provider-backed or browser fallback).
5. **Market panel:** panel renders and returns data from at least one market provider.
6. **Live tracking:** at least one live mode (AIS or OpenSky) is available.
These checks are now surfaced in the Service Status UI as “Desktop readiness”.
+125
View File
@@ -0,0 +1,125 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Analyst control-surface e2e: signed-out shows the sign-in prompt (unchanged
* behaviour), and a signed-in command round-trip a stubbed AI response carrying
* a {type:"move_panel"} command flows transport dispatcher AppHost and the
* panel visibly moves, with a action-log entry in the chat. No real inference:
* the analyst + models routes are stubbed so the test is deterministic offline.
*/
const SCREENS = 'e2e/screens';
// Stub every /v1/world/* call the chat touches so the flow is deterministic.
async function stubWorldApi(page: Page): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
reply: 'Done — moved Markets to the top and switched to the light theme.',
actions: [
{ type: 'move_panel', key: 'markets', position: 'top' },
{ type: 'set_theme', theme: 'light' },
],
model: 'zen5',
tokens: 42,
}),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 'zen5', label: 'Zen 5', group: 'Zen' },
{ id: 'zen5-flash', label: 'Zen 5 Flash', group: 'Zen' },
{ id: 'zen3-omni', label: 'Zen 3 Omni', group: 'Zen' },
],
default: 'zen5',
}),
});
}
// Everything else (context feeds) → empty, so collectContext degrades quietly.
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
await page.waitForSelector('[data-panel="markets"]', { timeout: 60_000 });
}
async function panelOrder(page: Page): Promise<string[]> {
return page.$$eval('#panelsGrid [data-panel]', (els) =>
els.map((e) => (e as HTMLElement).dataset.panel || ''),
);
}
test.describe('AI analyst control surface', () => {
test('signed-out: opening the dock shows the sign-in prompt (unchanged)', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
await page.click('.ai-dock-fab');
const prompt = page.locator('.ai-dock-body .ai-analyst-signedout');
await expect(prompt).toBeVisible();
await expect(prompt.locator('.ai-analyst-signin')).toHaveText(/sign in/i);
// No chat composer for anonymous users.
await expect(page.locator('.ai-dock-body .ai-analyst-input')).toHaveCount(0);
await page.screenshot({ path: `${SCREENS}/analyst-signedout.png` });
});
test('signed-in: a stubbed command round-trip moves a panel + logs the action', async ({ page }) => {
await signIn(page);
await stubWorldApi(page);
await appReady(page);
const before = await panelOrder(page);
const beforeIdx = before.indexOf('markets');
expect(beforeIdx).toBeGreaterThan(1); // markets starts well down the grid
await page.screenshot({ path: `${SCREENS}/round-trip-before.png` });
// Open the analyst dock and confirm the model dropdown populated.
await page.click('.ai-dock-fab');
const composer = page.locator('.ai-dock-body .ai-analyst-input');
await expect(composer).toBeVisible();
const modelSelect = page.locator('.ai-dock-body .ai-analyst-model');
await expect(modelSelect).toBeVisible();
await expect(modelSelect.locator('option')).toHaveCount(3);
await expect(modelSelect).toHaveValue('zen5');
// Send a command-style message; the stub returns the move+theme commands.
await composer.fill('Move markets to the top and go light');
await page.click('.ai-dock-body .ai-analyst-send');
// The prose reply renders…
await expect(page.locator('.ai-dock-body .ai-analyst-msg.assistant')).toContainText('moved Markets');
// …and the per-command action log shows a ✓ for the executed move.
const log = page.locator('.ai-dock-body .ai-analyst-actionlog .ai-analyst-action.ok');
await expect(log.first()).toBeVisible();
await expect(log.first()).toContainText(/moved markets/i);
// The panel VISIBLY moved: markets is now near the top of the grid.
await expect
.poll(async () => (await panelOrder(page)).indexOf('markets'), { timeout: 15_000 })
.toBeLessThan(beforeIdx);
// And the theme command took effect (whole-app visible change).
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.screenshot({ path: `${SCREENS}/round-trip-after.png` });
});
});

Some files were not shown because too many files have changed in this diff Show More