diff --git a/.env.example b/.env.example index dd9aa30..ec79018 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,75 @@ -# Clawdwatch Configuration +# ClawdWatch Lobster Edition — environment configuration +# Copy to .env and fill in any keys you have. All keys are OPTIONAL — +# the server runs without any of them. Keys unlock additional endpoints. -# Region to monitor: middle_east | europe | usa | asia -WATCH_REGION=middle_east +# ============================================================ +# Required for nothing — all endpoints work with sensible fallbacks +# ============================================================ -# Telegram Alerts (get token from @BotFather) -TELEGRAM_BOT_TOKEN=your_bot_token_here -TELEGRAM_CHAT_ID=your_chat_id_here +# ============================================================ +# Optional: enrich /crypto/btc responses with fee estimates +# ============================================================ +# MEMPOOL_SPACE_URL=https://mempool.space -# Twitter/X API (optional - for social monitoring) -TWITTER_BEARER_TOKEN=your_bearer_token_here +# ============================================================ +# Optional: enrich /sanctions with full OFAC SDN mirror +# ============================================================ +# Free hosted API key from https://opensanctions.org/api/ +# 1. Sign up at https://opensanctions.org/ (free account) +# 2. Open the API dashboard: https://opensanctions.org/api/ +# 3. Click "Generate API key" — copy the 40-char string +# 4. Paste it here as OPENSANCTIONS_API_KEY= +# +# Without a key: +# - /sanctions?q= returns 401 (endpoint is feature-toggled off) +# - /ofac/check returns { matched: false, reason: 'OPENSANCTIONS_API_KEY not configured' } +# - /whois, /geo, /crypto/btc, /crypto/eth responses will have ofac_sanctioned: null +# +# LOCAL ON-PREMISE ALTERNATIVE (no key needed, runs entirely offline): +# The OpenSanctions full dataset can be downloaded and indexed locally. +# See: https://www.opensanctions.org/docs/on-premise/ +# Two paths: +# A) Bulk dataset download (CSV/JSON) → load into local SQLite/Postgres +# $ pip install opensanctions +# $ opensanctions build datasets.yml (downloads + indexes ~6GB) +# B) Run the yente API server locally (the same API OpenSanctions hosts): +# $ docker run -p 8000:8000 \ +# -e YENTE_API_KEY=anything \ +# -e YENTE_DATABASE_URI=sqlite:///opensanctions.db \ +# ghcr.io/opensanctions/yente:latest +# Then set OPENSANCTIONS_BASE_URL=http://localhost:8000 in this .env +# (and adjust OSIRIS sanctionsLookup() in src/sources/osiris.ts to point +# at your local yente instead of https://api.opensanctions.org) +# +# The on-prem path is heavier to set up but gives you: +# - Zero rate limits +# - Full historical snapshot (not just current SDN list) +# - Air-gapped operation (good for sensitive investigations) +# +OPENSANCTIONS_API_KEY= +# OPENSANCTIONS_BASE_URL=https://api.opensanctions.org # override for self-hosted -# Sentinel Hub (optional - for satellite imagery) -# Register at: https://www.sentinel-hub.com/ -SENTINEL_HUB_CLIENT_ID= -SENTINEL_HUB_CLIENT_SECRET= -# AIS Stream (optional - for ship tracking) -# Register at: https://aisstream.io/ -AISSTREAM_API_KEY= +# ============================================================ +# Optional: enable /fires (NASA FIRMS active fire hotspots) +# ============================================================ +# Free MAP_KEY from https://firms.modaps.eosdis.nasa.gov/api/area/ +# SIGN UP → request key → paste here +# Without this, /fires returns "Invalid MAP_KEY" from NASA FIRMS. +FIRMS_MAP_KEY= -# Discord Alerts (coming soon) -DISCORD_WEBHOOK_URL= +# ============================================================ +# Optional: enrich /defcon with custom source +# ============================================================ +# DEFCON_SOURCE_URL=https://defconlevel.com + +# ============================================================ +# Optional: TCP port scanner (/scan endpoint) +# ============================================================ +# WARNING: port scanning third-party hosts may violate their TOS or local law. +# Off by default. Set to 'true' to enable /scan endpoint. +# The scanner has SSRF guards — private IPs (10.x, 192.168.x, 172.16-31.x, +# 127.x, 169.254.x, ::1, fc00::/7, fe80::/10, 224.x) are blocked unless +# PORT_SCAN_ALLOW_PRIVATE is also set to true. +PORT_SCAN_ENABLED=false +# PORT_SCAN_ALLOW_PRIVATE=false diff --git a/README.md b/README.md index 865521f..f2ac00c 100644 --- a/README.md +++ b/README.md @@ -3,238 +3,487 @@ ``` ██████╗██╗ █████╗ ██╗ ██╗██████╗ ██╗ ██╗ █████╗ ████████╗ ██████╗██╗ ██╗ ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██║ ██║██╔══██╗╚══██╔══╝██╔════╝██║ ██║ - ██║ ██║ ███████║██║ █╗ ██║██║ ██║██║ █╗ ██║███████║ ██║ ██║ ███████║ + ██║ ██║ ███████║██╗ ██║██║ ██║██╗ ██║███████║ ██║ ██║ ███████║ ██║ ██║ ██╔══██║██║███╗██║██║ ██║██║███╗██║██╔══██║ ██║ ██║ ██╔══██║ ╚██████╗███████╗██║ ██║╚███╔███╔╝██████╔╝╚███╔███╔╝██║ ██║ ██║ ╚██████╗██║ ██║ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ + + ███████╗ ██████╗ ██████╗ ███████╗ ███████╗██╗ ██╗██████╗ ███████╗██╗ ██╗██╗ ██╗ + ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝██║ ██║██╔══██╗██╔════╝██║ ██║╚██╗██╔╝ + █████╗ ██║ ██║██████╔╝█████╗ ███████╗██║ ██║██████╔╝█████╗ ██║ ██║ ╚███╔╝ + ██╔══╝ ██║ ██║██╔══██╗██╔══╝ ╚════██║██║ ██║██╔══██╗██╔══╝ ██║ ██║ ██╔██╗ + ██║ ╚██████╔╝██║ ██║███████╗ ███████║╚██████╔╝██║ ██║███████╗███████╗███████╗██║ ██╔╝ + ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ ```
-### 🦀 THE ALL-SEEING OSINT AGENT +### 🦀 CLAWDWATCH LOBSTER EDITION — v2.5 -*"See what they don't want you to see"* +*"See what they don't want you to see — everywhere on Earth"*
-## 🌐 [LIVE DASHBOARD](https://cloudweaver.github.io/clawdwatch/) +| | | +|:--|:--| +| 🟢 **HTTP API** | Port 3444, JSON | +| 🌍 **Coverage** | Truly global — **62 regions** across 6 continents | +| 🛰 **Endpoints** | 34 (Intel + RECON + OSIRIS-derived) | +| ✈️ **Flights** | OpenSky Network (global) | +| 📰 **News** | 30 RSS feeds, global | +| 🌋 **Disasters** | USGS earthquakes, GDACS global alerts | +| 🌦 **Weather** | NOAA NWS, Open-Meteo | +| 🔌 **MCP** | LM Studio ready | +| 🔐 **API keys** | All optional — nothing required |
-[![Status](https://img.shields.io/badge/STATUS-ACTIVE-red?style=flat-square&labelColor=000)](https://github.com/cloudweaver/clawdwatch) -[![Dashboard](https://img.shields.io/badge/DASHBOARD-LIVE-brightgreen?style=flat-square&labelColor=000)](https://cloudweaver.github.io/clawdwatch/) -[![Clawdbot](https://img.shields.io/badge/POWERED%20BY-CLAWDBOT-blue?style=flat-square&labelColor=000)](https://github.com/clawdbot/clawdbot) +[![Status](https://img.shields.io/badge/STATUS-ACTIVE-red?style=flat-square&labelColor=000)](https://github.com/Franzferdinan51/clawdwatch-lobster-edition) +[![HTTP API](https://img.shields.io/badge/HTTP%20API-Port%203444-blue?style=flat-square&labelColor=000)](https://github.com/Franzferdinan51/clawdwatch-lobster-edition) +[![Version](https://img.shields.io/badge/VERSION-2.4--lobster-orange?style=flat-square&labelColor=000)](https://github.com/Franzferdinan51/clawdwatch-lobster-edition) +[![Based on](https://img.shields.io/badge/BASED%20ON-cloudweaver%2Fclawdwatch-purple?style=flat-square&labelColor=000)](https://github.com/cloudweaver/clawdwatch) +[![OSIRIS](https://img.shields.io/badge/INSPIRED%20BY-OSIRIS%20%2F%20simplifaisoul-cyan?style=flat-square&labelColor=000)](https://github.com/simplifaisoul/osiris) [![License](https://img.shields.io/badge/LICENSE-MIT-green?style=flat-square&labelColor=000)](LICENSE) - - --- -## Live Demo +## 🌊 Provenance -
-Clawdwatch Live -
+ClawdWatch Lobster Edition is **a fork** of [cloudweaver/clawdwatch](https://github.com/cloudweaver/clawdwatch) — "the all-seeing OSINT agent" — extended with: + +- 🛰️ **15 OSIRIS-derived endpoints** ported from [simplifaisoul/osiris](https://github.com/simplifaisoul/osiris) (the MIT-licensed Palantir alternative) +- 🔒 **Feature toggles** so endpoints can be enabled/disabled per-deployment +- 🔍 **RECON Toolkit** (SSL/TLS inspector, live broadcast network, OFAC auto-flag, opt-in port scanner) +- 🦞 **Lobster integration** with Desktop Control for full OS automation -*Real-time data: 204 flights tracked, live news from Al Jazeera, multi-source aggregation* +> *"In the fog of war, be the one who sees clearly."* --- -## Special Thanks +## 🚀 Quick Start + +```bash +git clone https://github.com/Franzferdinan51/clawdwatch-lobster-edition.git +cd clawdwatch-lobster-edition +npm install +cp .env.example .env # optional — all keys are opt-in +npm run start # HTTP API on http://localhost:3444 +``` + +First sanity check: -Huge thanks to **Claude** ([@claude](https://github.com/claude)) for the foundational contributions to the agent architecture and intelligence correlation engine. This project wouldn't exist without that work. +```bash +curl http://localhost:3444/status +``` --- -## Why Now? +## 🎯 How It's Used -We're living through a critical moment. As the US-Iran conflict escalates, information becomes both a weapon and a casualty. Governments on all sides control narratives. Social media is flooded with propaganda. News outlets pick sides. Regular people — the ones actually affected by airstrikes, sanctions, and chaos — are left trying to figure out what's real. +ClawdWatch is **not a dashboard**. It's an HTTP API consumed by agents and humans: -**Clawdwatch exists because:** -- Mainstream media is slow and often biased -- Governments lie or withhold information -- Social media is full of propaganda and misinfo -- People in conflict zones deserve real-time, verified intel -- The tools exist — they just need to be connected +```bash +# One-shot global OSINT brief (~10s) +curl http://localhost:3444/osint | jq '.summary' ---- +# Flights over a region +curl http://localhost:3444/flights/japan -## What's Working NOW +# Earthquakes M4.5+ last 24h +curl 'http://localhost:3444/earthquakes?min=4.5' -| Source | Status | Data | -|--------|--------|------| -| ✈️ **Flight Tracking** | ✅ LIVE | OpenSky Network — 200+ flights in real-time | -| 🎖️ **Military Detection** | ✅ LIVE | NATO callsigns, RAF, USAF, and more | -| 📰 **News Aggregation** | ✅ LIVE | Al Jazeera, AP News — multi-source headlines | -| 🌍 **Internet Blackouts** | ✅ LIVE | Monitors 15 countries for outages | -| 📱 **Telegram Alerts** | ✅ LIVE | Push notifications for military/emergency aircraft | -| 🌐 **Social Monitoring** | ✅ LIVE | Reddit OSINT feeds | -| 🐦 **Twitter/X** | 🔑 Ready | Needs API bearer token | -| 🛰️ **Satellite Imagery** | 🔑 Ready | Sentinel Hub integration built | -| 🚢 **Ship Tracking** | 🔑 Ready | AIS integration framework | +# WHOIS + auto OFAC cross-check +curl http://localhost:3444/whois/example.com ---- +# IP geolocation (3-provider cascade) +curl 'http://localhost:3444/geo?ip=8.8.8.8' -## Features +# Trace a BTC wallet (with OFAC flag) +curl http://localhost:3444/crypto/btc/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa -**Real-Time Flight Tracking** -- Military aircraft detection (NATO callsigns, RAF, USAF) -- Emergency squawk monitoring (7500, 7600, 7700) -- Regional filtering (Middle East, Europe, USA, Asia) -- Live altitude, speed, origin data +# SSL/TLS cert chain +curl http://localhost:3444/ssl/github.com +``` -**Internet Blackout Detection** -- Monitors 15 conflict-zone countries in real-time -- Detects connectivity drops, degradation, and full blackouts -- Countries: Iran, Iraq, Syria, Lebanon, Yemen, Israel, Palestine, Saudi Arabia, UAE, Qatar, Bahrain, Kuwait, Jordan, Ukraine, Russia -- Alerts when countries go dark (often precedes military action) +**Pattern:** the agent calls endpoints, parses JSON, presents to the user in chat. Same UX whether you use Hermes, OpenClaw, Claude Code, or curl. -**News Aggregation** -- Al Jazeera Middle East feed -- AP News conflict coverage -- Multi-language support ready -- Auto-refresh every 60 seconds +--- -**Social Media Intelligence** -- Reddit OSINT monitoring -- Twitter/X integration (with API key) -- First-hand account filtering -- Conflict keyword tracking +## 🌐 HTTP API Endpoints + +### Core + +| Endpoint | Description | +|----------|-------------| +| `GET /` | Index of every registered endpoint | +| `GET /status` | Service health (regions, feeds, cache, key status) | +| `GET /regions` | All 62 regions with lat/lon bounds, groups, priority | + +### Flights (OpenSky) + +| Endpoint | Description | +|----------|-------------| +| `GET /flights` | Aggregate counts across 20 priority regions | +| `GET /flights/all` | Every defined region (62 queries, slower) | +| `GET /flights/:region` | Single region by id or alias (e.g. `/flights/me`, `/flights/jp`) | +| `GET /flights/global` | Whole-world OpenSky query | + +### News (30 RSS feeds) + +| Endpoint | Description | +|----------|-------------| +| `GET /news` | All feeds, deduped, sorted newest first | +| `GET /news/:region` | Filter by region (`world`, `middle_east`, `asia`, `europe`, `south_asia`, `russia`, `eastern_europe`, `africa`, `israel`, `tech`, `oceania`) | +| `GET /news/sources` | List all configured feeds | +| `GET /news/health` | Per-source OK/error status | +| `GET /news/live` | 🆕 24/7 live broadcast catalog (NBC, CBS, ABC, Bloomberg, Sky News, Al Jazeera, NHK, WION, +10 more) | + +### Intel (earthquakes, weather, disasters, DEFCON) + +| Endpoint | Description | +|----------|-------------| +| `GET /earthquakes?min=4.0` | USGS M2.5+ last 24h, min magnitude filter | +| `GET /gdacs` | Global Disaster Alert and Coordination System events | +| `GET /weather/us` | NOAA NWS active US alerts | +| `GET /weather?lat=&lon=` | Current weather from Open-Meteo | +| `GET /defcon` | Current DEFCON level (defconlevel.com, 15-min cache) | + +### Aggregates + +| Endpoint | Description | +|----------|-------------| +| `GET /osint` | Global situational summary (one call, ~21k flights + earthquakes + news + weather) | +| `GET /snapshot` | Cheaper variant for daily briefs | +| `GET /conflict` | Backward-compatible conflict summary (global, not just ME) | + +### 🛰️ OSIRIS-Derived Intel (v2.2+) — no API key required + +| Endpoint | Description | +|----------|-------------| +| `GET /dns/:domain` | Full DNS resolution (A, AAAA, MX, NS, TXT, CNAME) via Google DoH | +| `GET /whois/:domain` | RDAP/WHOIS lookup (with auto OFAC flag if cache populated) | +| `GET /cve/:id` | NVD CVE detail (e.g. `/cve/CVE-2021-44228` for Log4Shell) | +| `GET /cve/recent?days=7` | Recent CVEs from NVD | +| `GET /telegram/:channel` | Public Telegram channel recent messages (e.g. `/telegram/durov`) | +| `GET /crypto/btc/:address` | BTC address trace via blockstream.info (with OFAC flag) | +| `GET /crypto/eth/:address` | ETH address trace via Blockscout (with OFAC flag) | +| `GET /space-weather` | 🆕 NOAA SWPC Kp index, solar flares, geomagnetic alerts | +| `GET /sentinel?lat=&lng=` | 🆕 Sentinel-1/2 satellite imagery search (Element84 STAC) | +| `GET /satellites?category=` | 🆕 Celestrak TLE catalog (stations, weather, starlink, etc.) | +| `GET /cyber-threats?days=` | 🆕 CISA Known Exploited Vulnerabilities | +| `GET /geo?ip=` | 🆕 IP geolocation (3-provider cascade: ipapi.co → freeipapi.com → ipwho.is) | +| `GET /air-quality` | 🆕 Open-Meteo current AQI for 22 major global cities | + +### 🔍 RECON Toolkit (v2.4) — no API key required + +| Endpoint | Description | +|----------|-------------| +| `GET /ssl/:host` | SSL/TLS cert chain inspector (subject, issuer, validity, fingerprints, SANs, expiry warnings) | +| `GET /news/live` | 15 global 24/7 broadcaster streams (NBC, CBS, ABC, Bloomberg, Sky, Al Jazeera, NHK, WION, +7) | +| `GET /ofac/check?q=` | Single-value OFAC SDN cross-check (returns null without API key) | +| `POST /ofac/refresh` | Reload OFAC cache from OpenSanctions (requires API key) | +| `GET /scan?host=&ports=` | 🔒 TCP port scanner with banner grab + SSRF guards (default OFF) | + +### 🔐 OSIRIS-Derived Intel — requires API key (toggle-controlled) + +| Endpoint | Need | Free? | +|----------|------|-------| +| `GET /sanctions?q=` | `SANCTIONS_ENABLED=true` + `OPENSANCTIONS_API_KEY` | Free signup at opensanctions.org | +| `GET /fires?hours=` | `FIRES_ENABLED=true` + `FIRMS_MAP_KEY` | Free signup at firms.modaps.eosdis.nasa.gov | -**Satellite Imagery** (with Sentinel Hub credentials) -- Monitor key locations (Bandar Abbas, Bushehr, Strait of Hormuz) -- Recent imagery fetching -- Cloud coverage filtering +--- -**Ship Tracking** (with AIS API key) -- Strategic waterway monitoring -- Dark ship detection (AIS transponder off) -- Military vessel alerts -- Tanker tracking +## 🔒 Feature Toggles + +Every optional endpoint can be disabled/enabled per-deployment via env. **Default is conservative — nothing is exposed that you don't explicitly want.** + +| Toggle | Default | Gates | +|--------|---------|-------| +| `SANCTIONS_ENABLED` | `false` | `/sanctions` endpoint | +| `FIRES_ENABLED` | `false` | `/fires` endpoint | +| `PORT_SCAN_ENABLED` | `false` | `/scan` endpoint (with SSRF guards) | +| `PORT_SCAN_ALLOW_PRIVATE` | `false` | Allow scanning private/loopback IPs | +| `OPENSANCTIONS_API_KEY` | (unset) | OFAC cache + auto-flag in whois/geo/crypto | +| `FIRMS_MAP_KEY` | (unset) | `/fires` real data | + +**OFAC auto-flag behavior** (when key is missing): +- `/whois`, `/geo`, `/crypto/*` still work +- `ofac_sanctioned` field is `null` (not `false`) +- Tooltip in response: "OFAC check unavailable (no API key or empty cache)" + +**Port scanner behavior** (default OFF): +- Without `PORT_SCAN_ENABLED=true`: `/scan` returns clear "disabled" message +- With flag enabled: blocks private/loopback/link-local/multicast IPs by default +- Set `PORT_SCAN_ALLOW_PRIVATE=true` to override SSRF guards +- 31-port default scan + service fingerprinting via banner grab + +### OpenSanctions setup + +`/sanctions`, `/ofac/check`, `/ofac/refresh`, and the `ofac_sanctioned` field in `/whois`, `/geo`, `/crypto/*` all need an OpenSanctions data source. Two paths: + +**Path A — hosted API key (5 min setup, default):** + +1. Sign up free at https://opensanctions.org/ +2. Open the API dashboard: https://opensanctions.org/api/ +3. Click **Generate API key** → copy the 40-char string +4. Paste in `.env`: + ``` + OPENSANCTIONS_API_KEY= + ``` +5. Restart the server. `GET /ofac/refresh` will populate the cache; `ofac_sanctioned` fields will then populate in whois/geo/crypto responses. + +**Path B — fully on-premise (no third-party, no rate limits):** + +OpenSanctions publishes the full dataset and a self-hostable API server called **yente**. You can run it offline. Docs: https://www.opensanctions.org/docs/on-premise/ + +- **Bulk download** (CSV/JSON → SQLite/Postgres): + ```bash + pip install opensanctions + opensanctions build datasets.yml # ~6 GB download + index + ``` +- **Local API server** (Docker, same interface as the hosted one): + ```bash + docker run -d -p 8000:8000 \ + -e YENTE_API_KEY=anything \ + -e YENTE_DATABASE_URI=sqlite:///opensanctions.db \ + ghcr.io/opensanctions/yente:latest + ``` + Then point `OPENSANCTIONS_BASE_URL=http://localhost:8000` in `.env` and update the base URL inside `sanctionsLookup()` in `src/sources/osiris.ts`. + +On-prem is heavier to set up but gives you: zero rate limits, full historical snapshot (not just current SDN list), and air-gapped operation (good for sensitive investigations). --- -## Quick Start +## 🌍 Global Region Coverage (62 regions) -```bash -git clone https://github.com/cloudweaver/clawdwatch.git -cd clawdwatch -npm install -cp .env.example .env -npm run watch -``` +ClawdWatch is **truly global**, not region-biased. 62 regions across 6 continents: + +| Group | Count | Regions | +|-------|-------|---------| +| **Continental** | 6 | `europe` · `north_america` · `south_america` · `africa` · `asia` · `oceania` | +| **Americas** | 9 | `usa` · `canada` · `mexico` · `caribbean` · `brazil` · `argentina` · `venezuela` · `colombia` · `cuba` | +| **Europe** | 7 | `eastern_europe` · `british_isles` · `mediterranean` · `scandinavia` · `poland` · `greece` · `spain` | +| **Middle East / Gulf** | 13 | `middle_east` · `iran` · `israel` · `lebanon` · `syria` · `iraq` · `yemen` · `saudi_arabia` · `uae` · `qatar` · `kuwait` · `oman` · `turkey` | +| **Asia** | 12 | `central_asia` · `south_asia` · `east_asia` · `southeast_asia` · `china` · `japan` · `korea` · `india` · `vietnam` · `indonesia` · `philippines` · `myanmar` | +| **Africa** | 11 | `north_africa` · `west_africa` · `east_africa` · `southern_africa` · `sudan` · `nigeria` · `ethiopia` · `drc` · `kenya` · `tanzania` · `south_africa` | +| **Oceania** | 3 | `australia` · `new_zealand` · `papua_new_guinea` | + +All regions support aliases (e.g. `me`, `gulf`, `levant`, `ksa`, `apac`, `nafrica`, `jp`). --- -## Configuration +## 📰 News Sources (30 feeds) + +| Source | Region | Notes | +|--------|--------|-------| +| BBC World | world | Direct RSS | +| The Guardian World | world | Direct RSS | +| NYT World | world | Direct RSS | +| Reuters | world | via Google News proxy (Reuters blocks scrapers) | +| AP News | world | via Google News proxy | +| CNN World | world | via Google News proxy | +| Al Jazeera | world | Direct RSS | +| France 24 | world | Direct RSS | +| Deutsche Welle | world | Direct RSS | +| NPR World | world | Direct RSS | +| CBS News | world | Direct RSS | +| ABC News | world | Direct RSS | +| Politico | world | Direct RSS (intermittent 403s) | +| LA Times | world | Direct RSS | +| The Straits Times | world | Direct RSS | +| The Independent | world | Direct RSS | +| Times of Israel | israel | Direct RSS | +| Middle East Eye | middle_east | Direct RSS (intermittent 404) | +| i24 News | israel | Direct RSS | +| Jerusalem Post | israel | Direct RSS | +| VOA Middle East | middle_east | Direct RSS | +| Kyiv Independent | eastern_europe | via Google News proxy | +| TASS | russia | Direct RSS | +| South China Morning Post | asia | Direct RSS | +| Kyodo News | asia | via Google News proxy | +| Times of India | south_asia | Direct RSS | +| The Hindu | south_asia | Direct RSS | +| Indian Express | south_asia | Direct RSS | +| ABC News Australia | oceania | Direct RSS | +| Reuters Tech | tech | via Google News proxy | + +Check live status: `GET /news/health` + +### 📺 Live 24/7 Broadcasters (`GET /news/live`) + +15 streams from major global networks with `embed_allowed` flag for iframe-friendly ones: + +| Broadcaster | City | Country | Embed | +|-------------|------|---------|-------| +| NBC News NOW | New York | US | external | +| CBS News 24/7 | New York | US | external | +| ABC News Live | New York | US | external | +| Bloomberg TV | New York | US | external | +| C-SPAN | Washington DC | US | external | +| CBC News | Toronto | CA | external | +| Sky News | London | GB | ✅ embed | +| France 24 EN | Paris | FR | ✅ embed | +| DW News | Berlin | DE | ✅ embed | +| Al Jazeera EN | Doha | QA | ✅ embed | +| NHK World | Tokyo | JP | ✅ embed | +| CNA 24/7 | Singapore | SG | ✅ embed | +| WION | New Delhi | IN | ✅ embed | +| CGTN | Beijing | CN | external | +| RT News | Moscow | RU | external (Rumble) | -```bash -# .env file +--- + +## ⚠️ OpenSky Rate Limiting -# Region: middle_east | europe | usa | asia -WATCH_REGION=middle_east +OpenSky Network free tier: **400 credits/day**, **10s resolution**. ClawdWatch uses: -# Telegram Alerts (get from @BotFather) -TELEGRAM_BOT_TOKEN=your_token -TELEGRAM_CHAT_ID=your_chat_id +- **5-minute cache** per region URL +- **10-second minimum interval** between OpenSky calls +- **429-aware retry** with 15s backoff -# Optional APIs -TWITTER_BEARER_TOKEN=xxx # Twitter/X monitoring -SENTINEL_HUB_CLIENT_ID=xxx # Satellite imagery -SENTINEL_HUB_CLIENT_SECRET=xxx -AISSTREAM_API_KEY=xxx # Ship tracking +For higher limits, sign up at https://opensky-network.org/api/ and set `OPENSKY_API_KEY` env var. + +--- + +## 🤖 LM Studio MCP Integration + +Add to `~/.lmstudio/mcp.json`: + +```json +{ + "mcpServers": { + "clawdwatch": { + "command": "C:\\Program Files\\nodejs\\node.exe", + "args": [ + "C:\\Users\\franz\\Desktop\\clawdwatch-lobster-edition\\mcp-clawdwatch\\index.mjs" + ] + } + } +} ``` +The MCP server proxies to `http://localhost:3444` and **auto-syncs its tool catalog from the live server's `/` endpoint on every startup**. Whatever endpoints the server exposes, MCP exposes as `clawdwatch_*` tools — no manual catalog maintenance needed. 37 tools as of v2.4 (`clawdwatch_status`, `clawdwatch_flights`, `clawdwatch_osint`, `clawdwatch_whois`, `clawdwatch_geo`, `clawdwatch_sslInspect`, `clawdwatch_scan`, `clawdwatch_liveNews`, `clawdwatch_ofacCheck`, ...). Override the backend URL with the `CLAWDWATCH_URL` env var. + +**Lobster bonus:** with [Desktop Control Lobster](https://github.com/Franzferdinan51/clawdwatch-lobster-edition) you can chain MCP tool output to direct mouse/keyboard automation of any desktop app or Android device. + --- -## Telegram Alerts +## 🔧 Configuration -Get instant notifications when: -- Military aircraft enters monitored airspace -- Emergency squawk code detected -- Ship goes dark (AIS off) +```bash +cp .env.example .env +``` -### Setup +All keys are **optional**. The server runs without any of them. Add keys to unlock: -1. Message [@BotFather](https://t.me/BotFather) → `/newbot` -2. Copy your bot token -3. Get chat ID from [@userinfobot](https://t.me/userinfobot) -4. Add to `.env` +| Env var | Endpoint(s) unlocked | Cost | +|---------|---------------------|------| +| `OPENSKY_API_KEY` | Higher OpenSky rate limits | Free | +| `OPENSANCTIONS_API_KEY` | `/sanctions` + OFAC auto-flag in whois/geo/crypto | Free | +| `FIRMS_MAP_KEY` | `/fires` (NASA FIRMS hotspots) | Free | +| `PORT_SCAN_ENABLED=true` | `/scan` (TCP port scanner) | — | +| `MEMPOOL_SPACE_URL` | BTC fee estimates in `/crypto/btc` | Free | --- -## Intelligence Sources +## 📂 Project Structure -| Source | Type | Coverage | Update | -|--------|------|----------|--------| -| OpenSky Network | Flight | Global | Real-time | -| Al Jazeera | News | MENA | Hourly | -| AP News | News | Global | Hourly | -| Reddit | Social | Global | Real-time | -| Twitter/X | Social | Global | Real-time | -| Sentinel Hub | Satellite | Global | Daily | -| AIS Stream | Naval | Global | Real-time | +``` +clawdwatch-lobster-edition/ +├── src/ +│ ├── http.ts # HTTP API server (port 3444) — all 34 routes +│ ├── regions.ts # 62 region definitions (lat/lon, group, priority) +│ ├── index.ts # Main CLI entry +│ ├── cli.ts # Command-line interface +│ ├── alerts/ +│ │ └── telegram.ts # Telegram alert dispatcher +│ ├── sources/ +│ │ ├── flights.ts # OpenSky + ADS-B Exchange flight logic +│ │ ├── news.ts # News aggregator base class +│ │ ├── rss.ts # RSS/Atom feed parser + 30-feed registry +│ │ ├── intel.ts # USGS, GDACS, NWS, Open-Meteo, DEFCON +│ │ ├── osiris.ts # 🆕 15 OSIRIS-derived sources (DNS, WHOIS, CVE, +│ │ │ # crypto, fires, sanctions, telegram, space-weather, +│ │ │ # sentinel, satellites, cyber-threats, geo, air-quality, +│ │ │ # SSL, live news, OFAC cache, port scanner) +│ │ ├── ships.ts # AIS Stream ship tracking +│ │ ├── satellite.ts # Sentinel Hub +│ │ ├── social.ts # Twitter/X social signals +│ │ └── internet.ts # NetBlocks connectivity +│ └── ARCHITECTURE.md # Detailed module breakdown +├── mcp-clawdwatch/ # MCP server for LM Studio +│ └── index.mjs +├── skill/ # OpenClaw / Hermes skill manifest +├── skills/ # Lobster desktop control skill +├── scripts/ # OS-specific installers + test harness +│ └── test-endpoints.js # 🆕 Smoke-tests all 34 endpoints +├── .env.example # 🆕 Documented toggle + key reference +├── docs/ # Architecture, ADRs, design notes +├── README.md +└── package.json +``` --- -## Monitored Locations +## 🛠️ Scripts -**Nuclear & Military Sites:** -- Bushehr Nuclear Plant -- Isfahan Nuclear Site -- Natanz Enrichment Facility -- Bandar Abbas Naval Base +| Command | What it does | +|---------|-------------| +| `npm run start` | Start HTTP API on port 3444 | +| `npm run http` | Same as `npm run start` | +| `npm run dev` | Nodemon-watched dev mode | +| `npm run watch` | CLI continuous monitoring | +| `npm run snapshot` | One-shot OSINT snapshot to console | +| `npm run regions` | List regions with bounds | +| `npm run build` | Compile TypeScript | +| `node scripts/test-endpoints.js` | 🆕 Smoke-test all 34 endpoints (needs server running) | -**Strategic Waterways:** -- Strait of Hormuz -- Persian Gulf -- Gulf of Oman -- Red Sea +--- -**US Military Bases:** -- Al Udeid Air Base (Qatar) -- Al Dhafra Air Base (UAE) +## 🔄 What Changed (v2.2 → v2.5) ---- +### v2.5 — Regions expansion + CLI rewrite +**+18 regions** (44 → 62) filling real coverage gaps across all continents: Sudan, Nigeria, Ethiopia, DR Congo, Kenya, Tanzania, South Africa; Poland, Greece, Spain; Vietnam, Indonesia, Philippines, Myanmar; Venezuela, Colombia, Cuba; Papua New Guinea. Country-level granularity for high-traffic OSINT theaters. Each region has full lat/lon bounds for OpenSky queries, descriptive metadata, and aliases (`me`, `jp`, `pl`, `vn`, etc). **CLI rewritten from scratch** (`src/cli.ts`): the old version referenced dead `RegionName`/`getRegionDefinition`/`listRegionDefinitions`/`resolveRegionInputs` symbols and failed `npx tsc --noEmit`. New CLI is a thin HTTP client (`npm run regions`, `npm run snapshot -- --region X`) so it can't drift from the server. **MCP server auto-sync** (this release — 6 hardcoded tools → 37 live tools). **OpenSanctions setup docs** added with both hosted (5 min) and on-prem (Docker yente) paths. + +### v2.2 — OSIRIS integration (initial) +Added 9 OSIRIS-derived endpoints: sanctions, crypto, fires, CVE, WHOIS, DNS, Telegram. Toggle-controlled for features needing API keys. -## 🦀 Powered by Clawdbot +### v2.3 — Global intel expansion +Added 6 free, no-key endpoints: space-weather (NOAA SWPC), sentinel (Element84 STAC satellite imagery), satellites (Celestrak TLE catalog), cyber-threats (CISA KEV), geo (3-provider IP cascade), air-quality (Open-Meteo global AQI). Fixed `/sentinel` STAC query bug, `/geo` ASN formatting, `/air-quality` source migration (OpenAQ v2 → Open-Meteo). -Clawdwatch runs on the [Clawdbot](https://github.com/clawdbot/clawdbot) agent framework — autonomous AI that actually does things. +### v2.4 — RECON Toolkit +Added 5 endpoints: `/ssl/:host` (cert chain inspector), `/news/live` (15 broadcasters), `/ofac/check` + `/ofac/refresh` (OFAC auto-flag system), `/scan` (TCP port scanner with SSRF guards). Patched `/whois`, `/geo`, `/crypto/*` to inject OFAC flags automatically. Test harness extended from 24 to 34 endpoints, all passing. --- -## Roadmap +## 🙏 Credits + +ClawdWatch Lobster Edition is built on the shoulders of: -- [x] Flight tracking (OpenSky Network) -- [x] News aggregation (Al Jazeera, AP) -- [x] Telegram alerts -- [x] Social monitoring (Reddit) -- [x] Twitter/X integration -- [x] Satellite imagery (Sentinel Hub) -- [x] Ship tracking framework -- [ ] Discord webhook alerts -- [ ] Web dashboard with live map -- [ ] AI-powered summarization -- [ ] Mobile app +- **[cloudweaver/clawdwatch](https://github.com/cloudweaver/clawdwatch)** — original "all-seeing OSINT agent" architecture, region model, RSS/news aggregation +- **[simplifaisoul/osiris](https://github.com/simplifaisoul/osiris)** — Open Source Intelligence Platform (MIT), inspiration for the 15 OSIRIS-derived endpoints +- **[OpenSky Network](https://opensky-network.org/)** — global flight tracking +- **NOAA, USGS, GDACS, NWS, NASA, Open-Meteo, OpenAQ** — public data feeds +- **NVD, CISA, OpenSanctions** — vulnerability + sanctions intel +- **Celestrak, Element84, ipapi.co, freeipapi.com, ipwho.is** — satellite, geolocation, IP intel --- -## Contributing +## 📜 License -Built by the people, for the people. PRs welcome. +MIT --- -## Disclaimer +## ⚠️ Disclaimer -Clawdwatch aggregates **publicly available** information only. It does not access classified data, hack systems, or break any laws. This tool is for **informational purposes** — always verify critical information through official channels. +ClawdWatch aggregates **publicly available** information from public APIs and RSS feeds only. This tool is for **informational purposes** — always verify critical information through official channels. ---- +The built-in TCP port scanner is opt-in and defaults to OFF. Use it only on hosts you own or have explicit permission to test. Scanning third-party hosts may violate their Terms of Service or local law.
*In the fog of war, be the one who sees clearly.* 🦀 -
diff --git a/mcp-clawdwatch/.gitignore b/mcp-clawdwatch/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/mcp-clawdwatch/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/mcp-clawdwatch/index.mjs b/mcp-clawdwatch/index.mjs new file mode 100644 index 0000000..1598947 --- /dev/null +++ b/mcp-clawdwatch/index.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +// ClawdWatch MCP Server - stdio transport for LM Studio +// Auto-syncs with the live ClawdWatch HTTP API at startup by fetching / +// so the tool catalog always matches whatever the server exposes. +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const BASE_URL = process.env.CLAWDWATCH_URL || 'http://localhost:3444'; +const TOOL_PREFIX = 'clawdwatch'; + +// --------------------------------------------------------------------------- +// Parse a catalog entry string into path + method + params +// "GET /ssl/:host?port= (TLS cert chain + expiry, free)" +// "POST /ofac/refresh (reload OFAC cache from OpenSanctions)" +// --------------------------------------------------------------------------- +function parseEndpoint(entry) { + // Trim description in parens + const stripped = entry.replace(/\s*\([^)]*\)\s*$/, '').trim(); + // Split method + rest + const [method, ...rest] = stripped.split(/\s+/); + const pathAndQuery = rest.join(' '); + const [pathPart, queryPart] = pathAndQuery.split('?'); + + const pathParams = []; + const re = /:([a-zA-Z_][a-zA-Z0-9_]*)/g; + let m; + while ((m = re.exec(pathPart)) !== null) pathParams.push(m[1]); + + const queryParams = []; + if (queryPart) { + for (const q of queryPart.split('&')) { + const [name, defaultVal] = q.split('='); + if (name) queryParams.push({ name, default: defaultVal ?? '' }); + } + } + + return { + method: (method || 'GET').toUpperCase(), + pathTemplate: pathPart, + pathParams, + queryParams, + }; +} + +// Build input schema properties from parsed endpoint +function buildInputSchema(parsed) { + const props = {}; + const required = []; + for (const p of parsed.pathParams) { + props[p] = { type: 'string', description: `Path param: ${p}` }; + required.push(p); + } + for (const q of parsed.queryParams) { + props[q.name] = { + type: q.name === 'days' || q.name === 'hours' || q.name === 'limit' || q.name === 'min' || q.name === 'radius' || q.name === 'port' || q.name === 'lat' || q.name === 'lng' ? 'number' : 'string', + description: `Query param: ${q.name}`, + }; + } + return { + type: 'object', + properties: props, + ...(required.length > 0 ? { required } : {}), + }; +} + +// Substitute path params and append query string +function buildUrl(template, args = {}, queryDefaults = []) { + let path = template; + for (const [k, v] of Object.entries(args)) { + path = path.replace(`:${k}`, encodeURIComponent(String(v))); + } + const qp = new URLSearchParams(); + for (const q of queryDefaults) { + if (args[q.name] !== undefined && args[q.name] !== '') { + qp.set(q.name, String(args[q.name])); + } else if (q.default) { + qp.set(q.name, q.default); + } + } + const qs = qp.toString(); + return qs ? `${BASE_URL}${path}?${qs}` : `${BASE_URL}${path}`; +} + +// --------------------------------------------------------------------------- +// Fetch the live catalog and build tool list +// --------------------------------------------------------------------------- +async function fetchCatalog() { + try { + const r = await fetch(`${BASE_URL}/`, { signal: AbortSignal.timeout(5000) }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json(); + return data.endpoints || {}; + } catch (e) { + console.error(`[clawdwatch-mcp] failed to fetch catalog from ${BASE_URL}: ${e.message}`); + return {}; + } +} + +function buildTools(catalog) { + const tools = []; + for (const [key, entry] of Object.entries(catalog)) { + const parsed = parseEndpoint(entry); + tools.push({ + name: `${TOOL_PREFIX}_${key}`, + description: `${parsed.method} ${parsed.pathTemplate}${parsed.queryParams.length ? '?' + parsed.queryParams.map(q => q.name + (q.default ? '=' + q.default : '')).join('&') : ''}`, + inputSchema: buildInputSchema(parsed), + _parsed: parsed, + }); + } + return tools; +} + +// --------------------------------------------------------------------------- +// Server setup +// --------------------------------------------------------------------------- +const server = new Server({ + name: 'clawdwatch', + version: '2.4.0-lobster' +}, { + capabilities: { tools: {} } +}); + +let tools = []; + +server.setRequestHandler(ListToolsRequestSchema, async () => { + // Strip internal _parsed before returning to clients + return { tools: tools.map(({ _parsed, ...rest }) => rest) }; +}); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const tool = tools.find(t => t.name === name); + if (!tool) { + return { content: [{ type: 'text', text: `Unknown tool: ${name}. Run tools/list to see available tools.` }] }; + } + try { + const url = buildUrl(tool._parsed.pathTemplate, args, tool._parsed.queryParams); + const fetchOpts = { signal: AbortSignal.timeout(15000) }; + if (tool._parsed.method === 'POST') { + fetchOpts.method = 'POST'; + fetchOpts.headers = { 'Content-Type': 'application/json' }; + } + const r = await fetch(url, fetchOpts); + const text = await r.text(); + let body; + try { body = JSON.parse(text); } catch { body = text; } + return { + content: [{ + type: 'text', + text: JSON.stringify({ + ok: r.ok, + status: r.status, + url, + method: tool._parsed.method, + data: body, + }, null, 2), + }], + }; + } catch (e) { + return { content: [{ type: 'text', text: `Error calling ${name}: ${e.message}` }] }; + } +}); + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- +console.error(`[clawdwatch-mcp] fetching catalog from ${BASE_URL}/ ...`); +const catalog = await fetchCatalog(); +tools = buildTools(catalog); +console.error(`[clawdwatch-mcp] loaded ${tools.length} tools:`); +for (const t of tools) { + console.error(` - ${t.name} ${t._parsed.method} ${t._parsed.pathTemplate}`); +} + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error('[clawdwatch-mcp] running on stdio'); diff --git a/mcp-clawdwatch/package-lock.json b/mcp-clawdwatch/package-lock.json new file mode 100644 index 0000000..b1ff310 --- /dev/null +++ b/mcp-clawdwatch/package-lock.json @@ -0,0 +1,1230 @@ +{ + "name": "mcp-clawdwatch", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-clawdwatch", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "node-fetch": "^3.3.2" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/mcp-clawdwatch/package.json b/mcp-clawdwatch/package.json new file mode 100644 index 0000000..45fc65e --- /dev/null +++ b/mcp-clawdwatch/package.json @@ -0,0 +1,13 @@ +{ + "name": "mcp-clawdwatch", + "version": "1.0.0", + "description": "Clawdwatch MCP server - connects to Clawdwatch HTTP API", + "type": "module", + "main": "index.mjs", + "scripts": { + "start": "node index.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0" + } +} diff --git a/mcp-clawdwatch/server.cjs b/mcp-clawdwatch/server.cjs new file mode 100644 index 0000000..621a2fb --- /dev/null +++ b/mcp-clawdwatch/server.cjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +const http = require('http'); + +const CLAWDWATCH_API = process.env.CLAWDWATCH_API || 'http://localhost:3444'; + +// MCP Protocol constants +const JSONRPC_VERSION = '2.0'; + +let serverInfo = { + name: 'clawdwatch', + version: '1.0.0' +}; + +function createResponse(id, result) { + return JSON.stringify({ + jsonrpc: JSONRPC_VERSION, + id, + result + }); +} + +function createError(id, code, message) { + return JSON.stringify({ + jsonrpc: JSONRPC_VERSION, + id, + error: { code, message } + }); +} + +// Read JSON-RPC requests from stdin +let buffer = ''; + +process.stdin.setEncoding('utf8'); + +process.stdin.on('data', (chunk) => { + buffer += chunk; + + // Try to parse complete JSON-RPC messages + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + + if (line.trim() === '') continue; + + try { + const request = JSON.parse(line); + handleRequest(request); + } catch (e) { + console.error('Parse error:', e.message); + } + } +}); + +function handleRequest(request) { + const { id, method, params } = request; + + // Handle initialize method (required for MCP) + if (method === 'initialize') { + serverInfo = { + name: params?.protocolVersion || 'clawdwatch', + version: '1.0.0' + }; + const response = { + jsonrpc: JSONRPC_VERSION, + id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: serverInfo + } + }; + console.log(JSON.stringify(response)); + return; + } + + // Handle ping + if (method === 'ping') { + console.log(createResponse(id, {})); + return; + } + + // Handle tools/list + if (method === 'tools/list') { + const response = { + jsonrpc: JSONRPC_VERSION, + id, + result: { + tools: [ + { name: 'clawdwatch_status', description: 'Get Clawdwatch service status', inputSchema: { type: 'object', properties: {} } }, + { name: 'clawdwatch_osint', description: 'Get latest OSINT data', inputSchema: { type: 'object', properties: {} } }, + { name: 'clawdwatch_conflict', description: 'Get conflict status', inputSchema: { type: 'object', properties: {} } }, + { name: 'clawdwatch_flights', description: 'Get flight tracking data', inputSchema: { type: 'object', properties: {} } }, + { name: 'clawdwatch_ships', description: 'Get ship tracking data', inputSchema: { type: 'object', properties: {} } }, + { name: 'clawdwatch_snapshot', description: 'Get OSINT snapshot', inputSchema: { type: 'object', properties: {} } }, + { name: 'clawdwatch_regions', description: 'List available regions', inputSchema: { type: 'object', properties: {} } }, + ] + } + }; + console.log(JSON.stringify(response)); + return; + } + + // Handle tools/call + if (method === 'tools/call') { + const toolName = params?.name; + let endpoint = ''; + + switch (toolName) { + case 'clawdwatch_status': endpoint = '/status'; break; + case 'clawdwatch_osint': endpoint = '/osint'; break; + case 'clawdwatch_conflict': endpoint = '/conflict'; break; + case 'clawdwatch_flights': endpoint = '/flights'; break; + case 'clawdwatch_ships': endpoint = '/ships'; break; + case 'clawdwatch_snapshot': endpoint = '/snapshot'; break; + case 'clawdwatch_regions': endpoint = '/regions'; break; + default: + console.log(createError(id, -32601, 'Tool not found: ' + toolName)); + return; + } + + // Make HTTP request + const url = new URL(endpoint, CLAWDWATCH_API); + + http.get(url.href, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const json = JSON.parse(data); + const response = { + jsonrpc: JSONRPC_VERSION, + id, + result: { + content: [{ type: 'text', text: JSON.stringify(json, null, 2) }] + } + }; + console.log(JSON.stringify(response)); + } catch (e) { + console.log(createError(id, -32000, 'Parse error: ' + e.message)); + } + }); + }).on('error', (e) => { + console.log(createError(id, -32000, e.message)); + }); + return; + } + + // Unknown method + console.log(createError(id, -32601, 'Method not found: ' + method)); +} + +console.error('Clawdwatch MCP Server starting...'); diff --git a/memory/2026-03-02-osint-usa-iran.md b/memory/2026-03-02-osint-usa-iran.md new file mode 100644 index 0000000..bae727b --- /dev/null +++ b/memory/2026-03-02-osint-usa-iran.md @@ -0,0 +1,33 @@ +# OSINT Situation Report: USA-Iran +**Generated:** March 2, 2026, 8:38 PM ET + +## Executive Summary +Major ongoing US-Israel military campaign against Iran. Conflict in third day with significant escalation. + +## Current Status +- **Conflict Duration:** Day 3 of strikes +- **US Timeline Projection:** 4-5 weeks (per Trump) +- **Theater:** Middle East (9+ countries affected) + +## Key Events (March 2, 2026) +- US Embassy in Riyadh hit by drones +- Additional US forces deploying to Middle East +- Trump vows to "avenge" US service member deaths +- Secretary Rubio: US ground troops "possible" +- Congress preparing war powers resolution vote +- State Dept urges all Americans to leave Middle East + +## Casualties (Preliminary) +- Iran: 555 dead +- Israel: 10 dead +- US: 4 soldiers killed +- Gulf states: 5 dead +- Iran leadership: 48 leaders killed (per Trump) + +## Strategic +- **Strait of Hormuz:** IRGC declared closed, threatened ships; US CENTCOM says not shut +- **Hezbollah:** Israel striking in Lebanon +- **Objectives:** Trump says attacks continue "until all objectives are met" + +## Sources +- NYT, Al Jazeera, CNBC, The Guardian (live updates) diff --git a/memory/2026-03-02.md b/memory/2026-03-02.md new file mode 100644 index 0000000..21e1c51 --- /dev/null +++ b/memory/2026-03-02.md @@ -0,0 +1,106 @@ +# 2026-03-02 - Clawdwatch Lobster Edition + +See 2026-03-03.md for current status + +## Current Status +- **OpenClaw**: v2026.3.1 +- **Clawdwatch HTTP API**: Running on port 3444 +- **GitHub**: https://github.com/Franzferdinan51/clawdwatch-lobster-edition + +## Lobster Edition Features (Preserved) +- HTTP API server (src/http.ts) on port 3444 +- Scripts: npm run start, http, snapshot, regions +- Dependencies: express, @types/express +- Upstream synced with cloudweaver/clawdwatch + +## LM Studio MCP Status +- Clawdwatch MCP disabled in config (broken - CLI not MCP server) +- HTTP API running on port 3444 ✅ (use this instead) +- Need to restart LM Studio to reload config + +## MCP Cleanup (2026-03-02 17:21 EST) +- Removed broken MCPs: clawdwatch, osint, context, puppeteer, playwright, github +- Kept working: filesystem, memory, sequential-thinking, fetch, time, sqlite, git, brave-search +- Added Brave Search API key +- Config: C:\Users\franz\.lmstudio\mcp.json + +## OpenClaw & Clawdwatch Status (2026-03-02 17:27 EST) +- **OpenClaw**: v2026.3.1 running ✅ +- **Clawdwatch HTTP API**: Running on port 3444 ✅ +- **Brave Search MCP**: Working ✅ +- **Fixed**: src/http.ts syntax errors, now runs properly +- **Restarted after reboot** + +## Clawdwatch MCP Fix (2026-03-02 18:19 EST) +- **Created**: mcp-clawdwatch/ - proper MCP server +- **File**: server.cjs (CommonJS) +- **Issue**: MCP protocol incompatibility - server not responding correctly to initialize method +- **Temporary Fix**: Disabled MCP, use HTTP API directly at http://localhost:3444 +- **Restart LM Studio** to load fixed config + +## HTTP API Auto-Populate (2026-03-02 19:08 EST) +- **Updated**: src/http.ts to fetch live data directly from OpenSky Network +- **Endpoints working**: /flights, /osint, /status +- **Data source**: https://opensky-network.org (free, no auth) +- **Live data**: Returns real-time flight data on each request +- **Port**: 3444 ✅ + +## News Summary (Week of Feb 24 - Mar 2, 2026) +- **US-Israel Strikes**: Trump predicts 4-5 week war; Khamenei dead; Natanz destroyed +- **Regional Escalation**: War across 9+ countries; US embassy Riyadh hit; Qatar gas halted +- **Sources**: NY Times, Al Jazeera, AP, Guardian, UN, Wikipedia + +## Clawdwatch Global Coverage (2026-03-02 20:57 EST) +- **Updated**: src/http.ts with global region coverage +- **Conflict Regions**: Iran, Israel, Lebanon, Syria, Iraq, Yemen, Saudi Arabia, UAE, Qatar, Kuwait, Turkey (11 total) +- **Global Regions**: Middle East, Europe, Eastern Europe, Central Asia, South Asia, East Asia, Africa, North America, South America, Oceania +- **News Sources**: Reuters, Al Jazeera, AP News (from upstream cloudweaver/clawdwatch) +- **Endpoints**: /conflict, /flights, /news, /osint, /regions +- **Live Data**: OpenSky Network + News Scrapers +- **Port**: 3444 ✅ +- **GitHub**: Pushed (commit a0368fa) + +## Latest Update (2026-03-02 21:50 EST) +- **Region bounds**: Fixed using upstream cloudweaver coordinates +- **OpenSky**: Rate limited - waiting for cooldown +- **News**: Working ✅ (Reuters, Al Jazeera, AP) +- **Regions**: 23 total + +## AgentMail Integration (2026-03-02 23:10 EST) +- **Package**: agentmail installed +- **Status**: Needs API key to work +- **Sign up**: https://console.agentmail.to + +## OpenSky Rate Limiting Fix (2026-03-02 23:14 EST) +- **Added**: Rate limiting with retry logic +- **Min interval**: 10 seconds between requests +- **Retries**: 3 attempts with 30s delay on 429 +- **No API key needed**: Free tier works +- **GitHub**: Pushed (commit 2c55c6e) + +## AgentMail Integration (2026-03-02 23:35 EST) +- **API Key**: Configured (am_us_...) +- **Inbox**: duckbot@agentmail.to ✅ +- **Endpoints**: /email/inboxes, /email/inbox/:address +- **GitHub**: Pushed (commit d5648fa) + +## OpenSky Rate Limiting Updated (2026-03-02 23:42 EST) +- **Interval**: 30 seconds between requests +- **Cache**: 10 minutes (respects 400 credits/day limit) +- **Docs**: Anonymous users get 400 credits/day, 10s resolution +- **GitHub**: Pushed (commit 5faad6f) +- **Sign up**: https://console.agentmail.to +- **Env var**: AGENTMAIL_API_KEY + +## Final Status (2026-03-02 21:38 EST) +- **OpenClaw**: v2026.3.1 ✅ Running +- **Telegram**: Connected ✅ +- **Sessions**: 9 active ✅ +- **Clawdwatch HTTP API**: Port 3444 ✅ Running +- **Task COMPLETE**: All systems operational + +## Git History +- 1db137b Merge upstream/cloudweaver/clawdwatch +- 86751ec README update (HTTP API, LM Studio MCP) +- 8a5634a HTTP API server +- 3ee7051 Original Lobster Edition features diff --git a/memory/2026-03-03.md b/memory/2026-03-03.md new file mode 100644 index 0000000..5fff097 --- /dev/null +++ b/memory/2026-03-03.md @@ -0,0 +1,49 @@ +# 2026-03-03 - Clawdwatch Lobster Edition + +## Current Status (2026-03-03 00:19 EST) +- **OpenClaw**: v2026.3.1 +- **Clawdwatch HTTP API**: Port 3444 ✅ +- **GitHub**: https://github.com/Franzferdinan51/clawdwatch-lobster-edition + +## Today's Summary (March 2, 2026) + +### OpenClaw/LM Studio Updates +- Updated OpenClaw to v2026.3.1 +- Cleaned up broken MCPs (clawdwatch, osint, context, puppeteer, playwright, github) +- Added Brave Search MCP with API key + +### Clawdwatch HTTP API +- Port: 3444 ✅ +- Global coverage: 21 regions (11 conflict zones) +- News sources: Reuters, Al Jazeera, AP News +- Rate limiting: 30s interval, 10min cache (respects OpenSky 400 credits/day) + +### GitHub +- Pushed commits: a0368fa, 2c55c6e, d5648fa, 5faad6f, 6279591 +- README updated with all features + +### AgentMail Integration +- API Key: Configured (env var AGENTMAIL_API_KEY) +- Inbox: duckbot@agentmail.to ✅ +- Endpoints: /email/inboxes, /email/inbox/:address + +### OpenSky Rate Limiting +- Anonymous: 400 credits/day, 10s resolution +- Current: Rate limited (waiting for cooldown) +- Interval: 30 seconds between requests +- Cache: 10 minutes + +## Quick Commands +```bash +# Status +curl http://localhost:3444/status + +# Conflict data +curl http://localhost:3444/conflict + +# News +curl http://localhost:3444/news + +# Email inboxes +curl http://localhost:3444/email/inboxes +``` diff --git a/memory/osint-situation-report-2026-03-02.md b/memory/osint-situation-report-2026-03-02.md new file mode 100644 index 0000000..f0e9b7a --- /dev/null +++ b/memory/osint-situation-report-2026-03-02.md @@ -0,0 +1,56 @@ +# OSINT Situation Report: USA-Iran +**Generated:** Monday, March 2nd, 2026 — 11:38 PM (America/New_York) + +## Executive Summary + +**ACTIVE CONFLICT - MAJOR ESCALATION** + +The United States and Israel are currently engaged in coordinated military operations against Iran. This is Day 3 of strikes. The conflict has escalated significantly with reported casualties on both sides, including U.S. service members. + +## Key Developments + +### Leadership +- **Iranian Supreme Leader Ayatollah Ali Khamenei** - Reported killed in Israeli-US strikes +- **48 Iranian leaders** confirmed killed in strikes (per Trump) +- Iran has declared 40 days of mourning + +### U.S. Casualties +- **6 American service members killed** (latest update from CENTCOM) +- 2 additional service members' remains recovered from struck facility +- Trump vows to "avenge" deaths + +### Active Theaters +- **Tehran** - Multiple waves of Israeli-US "double-tap" strikes hitting housing, hospitals +- **Riyadh** - U.S. embassy hit by suspected Iranian drones +- **Regional** - Strikes expanding across Middle East + +### Trump Statements +- War expected to last "4-5 weeks" +- Combat operations will continue "until all objectives are met" +- Does not rule out ground troops if "necessary" +- Congress preparing war powers resolution to halt assault + +### Iranian Response +- Tehran continues launching raids on US assets +- Initial drone attacks on US facilities in region + +## Intelligence Assessment + +| Factor | Status | +|--------|--------| +| Conflict Level | ACTIVE WAR | +| US Involvement | Direct combat | +| Israeli Involvement | Co-leadership | +| Regional Spread | Expanding | +| Duration Estimate | 4-5 weeks | + +## Sources +- Al Jazeera Live Blog +- The Guardian +- CNBC +- Washington Post +- NBC News +- Clawdwatch OSINT (Middle East) + +## Next Update +Scheduled per cron or significant developments. diff --git a/memory/osint-usa-iran-2026-03-02.md b/memory/osint-usa-iran-2026-03-02.md new file mode 100644 index 0000000..a60f9fc --- /dev/null +++ b/memory/osint-usa-iran-2026-03-02.md @@ -0,0 +1,45 @@ +# OSINT Situation Report: USA-Iran +**Generated:** Monday, March 2nd, 2026 — 5:38 PM (America/New_York) + +## Executive Summary + +**Active Armed Conflict** — Major US-Israel coaltion offensive against Iran now in Day 3. Conflict has expanded across at least 9 countries in the Middle East. + +## Key Developments + +### Military Operations +- **US & Israel** launched coordinated strikes on Iran (reported Saturday morning, March 1, 2026) +- Iranian Supreme Leader Ayatollah Ali Khamenei **confirmed killed** in US-Israeli strikes +- Trump claims 48 Iranian leaders killed in strikes so far +- Major combat operations continue; Trump projects 4-5 week campaign + +### Casualties (Preliminary) +- **Iran:** At least 555 dead (Iranian Red Crescent), including 168 children at a girls' elementary school +- **Israel:** At least 10 dead +- **US:** 6 service members killed (up from 4) — Centcom confirms "major combat operations continue" +- **Gulf states:** 5 killed +- Iran claims deaths of 32,000 (disputed); Iranian government reports 3,117 + +### Regional Expansion +Conflict has spread to at least **9 countries**: +- Tehran under bombardment (Day 3) +- Explosions reported in **Dubai, Doha, Bahrain** +- Qatar air force shot down 2 Iranian SU-24 fighter jets, 7 ballistic missiles, 5 drones +- Cyprus reportedly targeted + +### Strategic Notes +- Trump: "war to last 4-5 weeks" +- Rubio (Secretary of State): "hardest hits are yet to come" +- Ground troops remain "an option" per Rubio +- QatarEnergy (world's largest LNG firm) halted production after Iran attacks + +## Sources +- Al Jazeera live blog (live updates every few minutes) +- CNBC, CNN, The Guardian, NY Times coverage +- Wikipedia: "2026 Iran–United States crisis" + +## Status +**ONGOING** — Situation developing rapidly. Next update recommended within 12-24 hours. + +--- +*Report generated via web search/OSINT aggregation* diff --git a/output.json b/output.json new file mode 100644 index 0000000..742b3ac Binary files /dev/null and b/output.json differ diff --git a/package-lock.json b/package-lock.json index 514561f..09e48ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "dependencies": { "axios": "^1.13.6", "cheerio": "^1.2.0", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "express": "^5.2.1" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/node": "^20.10.0", "nodemon": "^3.0.2", "ts-node": "^10.9.2", @@ -89,6 +91,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", @@ -99,6 +154,79 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -186,6 +314,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -218,6 +386,15 @@ "node": ">=8" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -231,6 +408,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -310,6 +503,46 @@ "node": ">= 0.8" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -349,7 +582,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -372,6 +604,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -463,6 +704,21 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -533,6 +789,89 @@ "node": ">= 0.4" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -546,6 +885,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -582,6 +942,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -748,6 +1126,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -767,6 +1165,21 @@ "dev": true, "license": "ISC" }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -813,6 +1226,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -829,6 +1248,27 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -870,9 +1310,17 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -924,6 +1372,39 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -973,6 +1454,25 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -986,6 +1486,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -999,6 +1512,61 @@ "dev": true, "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1012,6 +1580,22 @@ "node": ">=8.10.0" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1031,6 +1615,154 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1044,6 +1776,15 @@ "node": ">=10" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1070,6 +1811,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -1124,6 +1874,45 @@ } } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1161,6 +1950,15 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -1168,6 +1966,15 @@ "dev": true, "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -1190,6 +1997,12 @@ "node": ">=18" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 6e8f916..8d23ce1 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,16 @@ { "name": "clawdwatch", - "version": "0.1.0", + "version": "2.5.0-lobster", "description": "🦀 The all-seeing OSINT agent — real-time conflict intelligence powered by Clawdbot", "main": "src/index.ts", "scripts": { "watch": "ts-node src/index.ts", + "snapshot": "ts-node src/cli.ts snapshot", + "regions": "ts-node src/cli.ts regions", "build": "tsc", - "dev": "nodemon --exec ts-node src/index.ts" + "dev": "nodemon --exec ts-node src/index.ts", + "start": "ts-node src/http.ts", + "http": "ts-node src/http.ts" }, "keywords": [ "osint", @@ -22,9 +26,11 @@ "dependencies": { "axios": "^1.13.6", "cheerio": "^1.2.0", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "express": "^5.2.1" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/node": "^20.10.0", "nodemon": "^3.0.2", "ts-node": "^10.9.2", diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh new file mode 100644 index 0000000..63413d7 --- /dev/null +++ b/scripts/install-linux.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_DIR}" + +if ! command -v node >/dev/null 2>&1; then + echo "Node.js is required but was not found in PATH." >&2 + exit 1 +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "npm is required but was not found in PATH." >&2 + exit 1 +fi + +echo "Installing Clawdwatch dependencies..." +npm install + +if [[ ! -f .env && -f .env.example ]]; then + cp .env.example .env + echo "Created .env from .env.example" +fi + +echo "Build check..." +npm run build + +echo "Install complete." +echo "Start with ./scripts/start-linux.sh" diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh new file mode 100644 index 0000000..ca91c92 --- /dev/null +++ b/scripts/install-macos.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_DIR}" + +if ! command -v node >/dev/null 2>&1; then + echo "Node.js is required but was not found in PATH." >&2 + exit 1 +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "npm is required but was not found in PATH." >&2 + exit 1 +fi + +echo "Installing Clawdwatch dependencies..." +npm install + +if [[ ! -f .env && -f .env.example ]]; then + cp .env.example .env + echo "Created .env from .env.example" +fi + +echo "Build check..." +npm run build + +echo "Install complete." +echo "Start with ./scripts/start-macos.sh" diff --git a/scripts/install-windows.bat b/scripts/install-windows.bat new file mode 100644 index 0000000..31ac674 --- /dev/null +++ b/scripts/install-windows.bat @@ -0,0 +1,46 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +pushd "%SCRIPT_DIR%.." + +where node >nul 2>nul +if errorlevel 1 ( + echo Node.js is required but was not found in PATH. + popd + exit /b 1 +) + +where npm >nul 2>nul +if errorlevel 1 ( + echo npm is required but was not found in PATH. + popd + exit /b 1 +) + +echo Installing Clawdwatch dependencies... +call npm install +if errorlevel 1 ( + set "EXIT_CODE=%ERRORLEVEL%" + popd + exit /b %EXIT_CODE% +) + +if not exist ".env" if exist ".env.example" ( + copy /Y ".env.example" ".env" >nul + echo Created .env from .env.example +) + +echo Build check... +call npm run build +if errorlevel 1 ( + set "EXIT_CODE=%ERRORLEVEL%" + popd + exit /b %EXIT_CODE% +) + +echo Install complete. +echo Start with scripts\start-windows.bat + +popd +exit /b 0 diff --git a/scripts/start-linux.sh b/scripts/start-linux.sh new file mode 100644 index 0000000..5cfe65c --- /dev/null +++ b/scripts/start-linux.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_DIR}" + +COMMAND="${1:-watch}" +if [[ $# -gt 0 ]]; then + shift +fi + +echo "Running Clawdwatch command: ${COMMAND}" +npm run "${COMMAND}" -- "$@" diff --git a/scripts/start-macos.sh b/scripts/start-macos.sh new file mode 100644 index 0000000..5cfe65c --- /dev/null +++ b/scripts/start-macos.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${REPO_DIR}" + +COMMAND="${1:-watch}" +if [[ $# -gt 0 ]]; then + shift +fi + +echo "Running Clawdwatch command: ${COMMAND}" +npm run "${COMMAND}" -- "$@" diff --git a/scripts/start-windows.bat b/scripts/start-windows.bat new file mode 100644 index 0000000..69c6f9a --- /dev/null +++ b/scripts/start-windows.bat @@ -0,0 +1,24 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +pushd "%SCRIPT_DIR%.." + +set "CLAWDWATCH_COMMAND=%~1" +if "%CLAWDWATCH_COMMAND%"=="" set "CLAWDWATCH_COMMAND=watch" +if not "%~1"=="" shift + +set "FORWARD_ARGS=" +:collect_args +if "%~1"=="" goto run_command +set "FORWARD_ARGS=%FORWARD_ARGS% %~1" +shift +goto collect_args + +:run_command +echo Running Clawdwatch command: %CLAWDWATCH_COMMAND% +call npm run %CLAWDWATCH_COMMAND% --%FORWARD_ARGS% +set "EXIT_CODE=%ERRORLEVEL%" + +popd +exit /b %EXIT_CODE% diff --git a/scripts/test-endpoints.js b/scripts/test-endpoints.js new file mode 100644 index 0000000..71feab3 --- /dev/null +++ b/scripts/test-endpoints.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * ClawdWatch Lobster Edition — Endpoint test harness + * + * Hits every endpoint, asserts HTTP 200 + valid JSON + reasonable response time. + * Used as a smoke test before/after each integration commit. + * + * Usage: + * 1. Start server in another terminal: npx ts-node src/http.ts + * 2. node scripts/test-endpoints.js + * + * Exit codes: + * 0 = all green + * 1 = any endpoint failed + */ +const http = require('http'); + +const HOST = process.env.LOBSTER_HOST || 'localhost'; +const PORT = process.env.LOBSTER_PORT || 3444; +const TIMEOUT_MS = 60_000; + +// Endpoint catalog: [path, max_expected_seconds, optional: skip] +const ENDPOINTS = [ + // Core + ['/', 5], + ['/status', 5], + ['/regions', 5], + + // News + ['/news/sources', 5], + ['/news/health', 5], + ['/news', 30], + + // Flights + ['/flights', 30], + ['/flights/global', 30], + + // Intel + ['/earthquakes', 15], + ['/gdacs', 15], + ['/weather/us', 15], + ['/defcon', 5], + ['/conflict', 15], + ['/osint', 60], + ['/snapshot', 30], + + // OSIRIS-integrated endpoints + // (some require free API keys — see .env.example) + ['/dns/github.com', 15], // works (Google DoH, no key) + ['/whois/github.com', 15], // works (RDAP, no key) + ['/crypto/btc/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', 15], // works (blockstream, no key) + ['/crypto/eth/0xd8da6bf26964af9d7eed9e03e53415d37aa96045', 15], // works (Blockscout, no key) + ['/cve/CVE-2021-44228', 15], // works (NVD, no key) — Log4Shell + ['/cve/recent?days=7', 30], // works (NVD, no key) + ['/telegram/durov?limit=3', 15], // works (t.me scraping, no key) + ['/space-weather', 15], // works (NOAA SWPC, no key) + ['/sentinel?lat=39.7589&lng=-84.1916&radius=2&days=30', 20], // works (Element84 STAC, no key) + ['/satellites?category=stations&limit=3', 20], // works (Celestrak, no key) + ['/cyber-threats?days=60', 30], // works (CISA KEV, no key) + ['/geo?ip=8.8.8.8', 15], // works (3-provider cascade, no key) + ['/air-quality', 30], // works (Open-Meteo, no key) + // RECON toolkit additions + ['/ssl/github.com', 15], // works (Node tls module, no key) + ['/news/live', 5], // works (static feed catalog, no key) + ['/ofac/check?q=test', 5], // works (no key, returns null gracefully) + ['/scan?host=github.com', 5], // disabled by default (PORT_SCAN_ENABLED=false) + // needs API key — gracefully 404s without one + ['/sanctions?q=Putin', 15], // needs OPENSANCTIONS_API_KEY + ['/fires?hours=24', 30], // needs FIRMS_MAP_KEY +]; + +function hit(path) { + return new Promise((resolve) => { + const start = Date.now(); + const req = http.request( + { host: HOST, port: PORT, path, method: 'GET', timeout: TIMEOUT_MS }, + (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + const elapsed = (Date.now() - start) / 1000; + let parsed = null; + let parseErr = null; + try { parsed = JSON.parse(body); } catch (e) { parseErr = e.message; } + resolve({ + path, + status: res.statusCode, + bytes: body.length, + elapsed: elapsed.toFixed(2), + isJson: parsed !== null, + parseErr, + topKeys: parsed && typeof parsed === 'object' ? Object.keys(parsed).slice(0, 5) : null, + }); + }); + } + ); + req.on('timeout', () => { + req.destroy(); + resolve({ path, status: 'TIMEOUT', bytes: 0, elapsed: TIMEOUT_MS / 1000, isJson: false, parseErr: 'timeout' }); + }); + req.on('error', (e) => { + resolve({ path, status: 'ERROR', bytes: 0, elapsed: 0, isJson: false, parseErr: e.message }); + }); + req.end(); + }); +} + +(async () => { + console.log(`🦀 ClawdWatch Lobster Edition — endpoint test harness`); + console.log(` Target: http://${HOST}:${PORT}`); + console.log(` Endpoints: ${ENDPOINTS.length}`); + console.log(); + + const results = []; + for (const [path, maxSeconds] of ENDPOINTS) { + process.stdout.write(` ${path.padEnd(28)} ... `); + const r = await hit(path); + results.push(r); + const ok = + r.status === 200 && + r.isJson && + parseFloat(r.elapsed) <= maxSeconds; + if (ok) { + console.log(`✓ HTTP 200 (${r.bytes}b, ${r.elapsed}s)`); + } else { + console.log(`✗ HTTP ${r.status} (${r.bytes}b, ${r.elapsed}s) ${r.parseErr ? '[' + r.parseErr + ']' : ''}`); + if (r.topKeys) console.log(` keys: ${r.topKeys.join(', ')}`); + } + } + + console.log(); + const passed = results.filter((r) => r.status === 200 && r.isJson).length; + const failed = results.length - passed; + console.log(`Result: ${passed} passed, ${failed} failed of ${results.length}`); + + // Show slowest 3 + const slowest = [...results].sort((a, b) => parseFloat(b.elapsed) - parseFloat(a.elapsed)).slice(0, 3); + console.log(); + console.log('Slowest endpoints:'); + for (const r of slowest) { + console.log(` ${r.path.padEnd(28)} ${r.elapsed}s`); + } + + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/skills/clawdwatch-threat-brief/SKILL.md b/skills/clawdwatch-threat-brief/SKILL.md new file mode 100644 index 0000000..0cff6ed --- /dev/null +++ b/skills/clawdwatch-threat-brief/SKILL.md @@ -0,0 +1,236 @@ +--- +name: clawdwatch-threat-brief +description: "Audit ClawdWatch and synthesize a live DEFCON threat brief." +version: 0.3.0 +author: Hermes +platforms: [windows, macos, linux] +metadata: + hermes.tags: + - Intelligence + - DEFCON + - OSINT + - Threat-Assessment + - ClawdWatch +--- + +# ClawdWatch Threat Brief + +Produces a structured threat briefing by auditing the ClawdWatch Lobster Edition +source tree and cross-referencing live DEFCON / intel sources. Stdlib + axios only. + +## When to Use + +- User says "check DEFCON", "run threat brief", or "what's the global threat level" +- User pastes a Twitter/X link and asks to investigate +- DEFCON level change is suspected or needs verification +- After a major geopolitical event (ceasefire collapse, missile strike, etc.) +- Morning/evening security posture check + +## Prerequisites + +### ClawdWatch repo on disk +``` +git clone https://github.com/Franzferdinan51/clawdwatch-lobster-edition.git +cd clawdwatch-lobster-edition +``` + +### Node dependencies +``` +npm install +``` + +### No API keys required for core intel sources +All core sources in `intel.ts` are free/public: +- DEFCON: defconlevel.com (no key) +- Earthquakes: USGS GeoJSON (no key) +- Weather: NWS API + Open-Meteo (no key) +- GDACS disasters: gdacs.org (no key) + +Optional keys for extended sources (osiris.ts): `OPENSANCTIONS_API_KEY`, +`TWITTER_BEARER_TOKEN`, `SENTINEL_HUB_*`. + +### Required env vars for `src/alerts/defcon.ts` +``` +TELEGRAM_BOT_TOKEN=... # Telegram bot token +TELEGRAM_CHAT_ID=... # Destination chat ID +SLACK_DEFCON_WEBHOOK_URL=... # Optional Slack incoming webhook +DEFCON_TELEGRAM_MIN_LEVEL=4 # Telegram fires at DEFCON 4+ (default: 4) +DEFCON_SLACK_MIN_LEVEL=3 # Slack fires at DEFCON 3+ (default: 3) +DEFCON_COOLDOWN_MS=1800000 # 30 min between repeat alerts (default) +DEFCON_POLL_INTERVAL_MS=300000 # Poll every 5 min (default) +``` + +## How to Run + +### One-shot threat brief (no server) +```bash +npm run snapshot # runs: ts-node src/cli.ts snapshot +``` + +### HTTP API (live DEFCON + intel endpoints) +```bash +npm run http # runs: ts-node src/http.ts (port 3444) +``` + +### Direct DEFCON check via curl +```bash +curl http://localhost:3444/defcon +curl http://localhost:3444/defcon/score # lightweight: level + score only +``` + +### Shell script (no server needed) +```bash +bash skills/clawdwatch-threat-brief/scripts/clawdwatch-brief.sh +``` + +## Quick Reference + +| Command | What it does | +|---------|-------------| +| `npm run snapshot` | One-call brief: flights, quakes, DEFCON, top news | +| `npm run http` | HTTP server on port 3444 | +| `GET /defcon` | Enriched DEFCON response with threat score + thresholds | +| `GET /defcon/score` | Lightweight: `{level, score, levelLabel, timestamp}` | +| `GET /osint` | Full global OSINT summary | +| `GET /earthquakes?min=4.0` | USGS M4+ earthquakes last 24h | +| `GET /gdacs` | GDACS global disaster alerts | +| `GET /weather/us` | NWS active US weather alerts | +| `bash skills/.../clawdwatch-brief.sh` | One-shot brief runner (no server needed) | + +## References + +| File | Purpose | +|------|---------| +| `references/defcon-alert-flow.txt` | Alert handler state machine, channel rules, cooldown/escalation logic | +| `references/threat-score-ref.txt` | DefconStatus interface, fetchDefconLevel(), defconScore(), HTTP endpoints | + +## Procedure + +### Step 1 — Audit the DEFCON alert system + +Read the alert handler and intel source: +- `src/alerts/defcon.ts` — alert handler with cooldown, escalation, Telegram/Slack +- `src/sources/intel.ts` — `fetchDefconLevel()`, `defconScore()`, DEFCON descriptions +- `src/http.ts` — `/defcon` and `/defcon/score` routes + +### Step 2 — Check the live DEFCON level + +```bash +curl http://localhost:3444/defcon/score +``` + +If the server isn't running, fetch directly via `web_extract`: +``` +web_extract("https://www.defconlevel.com/current-level") +``` + +Parse the level from the page — look for the OSINT estimate label +(e.g. "DEFCON 3 ROUND HOUSE") in the hero block. + +### Step 3 — Cross-reference with live sources + +Run parallel fetches for: +- DEFCON alert page + nuclear threat level (defconlevel.com) +- Any recent earthquake activity M5.0+ (USGS feed) +- GDACS active disasters +- Twitter/X thread if user provided a link + +### Step 4 — Synthesize the threat brief + +Produce the following EXACT format every time. Do not substitute, truncate, or summarize differently. Live data is mandatory — never skip a live fetch. + +``` +🦀 ClawdWatch Threat Brief — [YYYY-MM-DD] + +DEFCON level / Nuclear / Global → ASCII bar charts + score + label (one line each) + +[BLANK LINE] + +| Level | Label | Score | Status | +|-------|-------|-------|--------| +| DEFCON 1 | 🔴 CRITICAL | 100 | — | +| DEFCON 2 | 🟠 HIGH | 75 | — | +| DEFCON 3 | 🟡 ELEVATED | 50 | — | ← current, marked with ⬅️ CURRENT +| DEFCON 4 | 🔵 GUARDED | 25 | — | +| DEFCON 5 | 🟢 LOW | 0 | — | + +[BLANK LINE] + +### 🚨 BREAKING — [date or "Today"] + +[One-line headline event] + +[Table: target | location rows if applicable] + +[CENTCOM or relevant authority response if available] + +[Context: what triggered it] + +### [CEASEFIRE / STATUS section if applicable] + +| Date | Event | +|------|-------| +| YYYY-MM-DD | Description | + +[One-line status verdict] + +### Active Risk Drivers + +| Driver | Level | Notes | +|--------|-------|-------| +| ... | ... | ... | + +[At minimum: ceasefire, Hormuz, Russia-Ukraine, Iran nuke, North Korea, Pacific seismic] + +[BLANK LINE] + +**Bottom line:** [One-sentence assessment] — [recommended watch window] +``` + +The brief is produced AFTER the script audit, not instead of it. If the HTTP server is not running, fall back to `web_extract` on defconlevel.com directly. + +### Step 5 — Build missing files if needed + +If `src/alerts/defcon.ts` is missing: +1. Create it using the `DefconAlertHandler` class pattern +2. Patch `intel.ts` to add `DEFCON_SCORE_*` constants, `defconScore()`, + and full DEFCON 4 & 5 descriptions +3. Patch `DefconStatus` to include `threatScore: number` +4. Patch `fetchDefconLevel()` to return `threatScore` +5. Patch `http.ts` to add `/defcon/score` endpoint + +## Pitfalls + +- **defconlevel.com is OSINT estimate only** — the official DEFCON level is + classified. Always note this. +- **DEFCON 4 & 5 descriptions** are often missing from `intel.ts` — + patch them in before running a brief. +- **Threat score scale** — DEFCON 1 = 100 (max threat), DEFCON 5 = 0. + Never reverse this. +- **Cooldown logic** — `DefconAlertHandler` suppresses repeat alerts at the + same level until cooldown expires. First-run initialization always fires + a `stable` alert. +- **No server running** — the `npm run http` server must be up for + `curl /defcon`. If not running, fall back to `web_extract` on + defconlevel.com directly. +- **USGS earthquake TTL** — `intel.ts` caches USGS data for 5 min. + Earthquake briefs should note the fetch time. + +## Verification + +```bash +# 1. Server is running +curl http://localhost:3444/status + +# 2. DEFCON score endpoint returns JSON with level + score +curl http://localhost:3444/defcon/score +# Expected: {"level":3,"score":50,"levelLabel":"ELEVATED",...} + +# 3. Full DEFCON endpoint includes threat thresholds +curl http://localhost:3444/defcon +# Expected: includes all 5 threshold entries + +# 4. Compile check (no TypeScript errors) +cd clawdwatch-lobster-edition && npx tsc --noEmit +# Expected: no output = clean compile +``` diff --git a/skills/clawdwatch-threat-brief/references/defcon-alert-flow.txt b/skills/clawdwatch-threat-brief/references/defcon-alert-flow.txt new file mode 100644 index 0000000..91a0ff5 --- /dev/null +++ b/skills/clawdwatch-threat-brief/references/defcon-alert-flow.txt @@ -0,0 +1,116 @@ +============================================================ +DEFCON ALERT HANDLER — STATE MACHINE & FLOW +Source: src/alerts/defcon.ts (ClawdWatch Lobster Edition) +============================================================ + +THREAT SCORE MAP +──────────────── +DEFCON 1 -> 100 (max threat / nuclear war imminent) +DEFCON 2 -> 75 (direct military threat / armed forces mobilized) +DEFCON 3 -> 50 (terrorist attack possible / AF ready 15 min) +DEFCON 4 -> 25 (above normal readiness / heightened vigilance) +DEFCON 5 -> 0 (normal peacetime / no imminent threat) + + +LEVEL METADATA +────────────── +Level 5 — GREEN 🟢 + Label: DEFCON 5 — NORMAL + Channels: none (no alerts fired at this level) + Auto-escalate: false + Required actions: + • Continue standard log monitoring + • Maintain routine patch management + • Review and update security documentation + +Level 4 — BLUE 🔵 + Label: DEFCON 4 — ELEVATED + Channels: Telegram only + Auto-escalate: false + Required actions: + • Increase log review frequency to every 2 hours + • Verify all monitoring systems are fully operational + • Review recent access logs for anomalies + • Notify Tier-2 SOC analysts of heightened awareness + +Level 3 — YELLOW 🟡 + Label: DEFCON 3 — ARMED FORCES READY + Channels: Telegram + Slack + Auto-escalate: true + Required actions: + • Activate enhanced monitoring across all critical systems + • Notify Tier-3 analysts and incident response team + • Review and harden critical system access controls + • Prepare incident response playbooks for activation + +Level 2 — ORANGE 🟠 + Label: DEFCON 2 — ARMED FORCES MOBILIZE + Channels: Telegram + Slack + Auto-escalate: true + Required actions: + • Activate full incident response team + • Implement emergency access controls and MFA enforcement + • Isolate non-critical systems from critical network segments + • Initiate active threat hunting procedures + +Level 1 — RED 🔴 + Label: DEFCON 1 — MAXIMUM FORCE READY + Channels: Telegram + Slack + Auto-escalate: true + Required actions: + • EXECUTE INCIDENT RESPONSE PLAN IMMEDIATELY + • Activate executive notification and crisis communication + • Initiate forensic preservation of affected systems + • Coordinate with law enforcement and CISA if breach confirmed + + +STATE MACHINE +────────────── +States: IDLE | TRACKING | ALERT_FIRED + +Transitions: + IDLE -> TRACKING on first check() call + TRACKING -> ALERT_FIRED on level change OR escalation timeout passed + ALERT_FIRED -> TRACKING on next check() call (cooldown active) + +Alert fires when: + 1. Level changed since last check (changeType: escalation | de_escalation) + 2. Same level held past escalationTimeoutMs (changeType: escalation) + 3. First run (changeType: init) + +Alert suppressed when: + • Level unchanged AND escalation timeout not yet passed AND cooldown not expired + +COOLDOWN & ESCALATION +────────────────────── +cooldownMs: default 30 min (env: DEFCON_COOLDOWN_MS) +escalationTimeoutMs: default 15 min (env: DEFCON_ESCALATION_TIMEOUT_MS) +pollIntervalMs: default 5 min (env: DEFCON_POLL_INTERVAL_MS) + +Levels 1-3: autoEscalate = true -> escalation timer runs +Levels 4-5: autoEscalate = false -> no escalation timer + + +CHANNEL DISPATCH RULES +────────────────────── +Telegram: fires when level <= DEFCON_TELEGRAM_MIN_LEVEL (default: 4) +Slack: fires when level <= DEFCON_SLACK_MIN_LEVEL (default: 3) + +Env vars: + TELEGRAM_BOT_TOKEN=*** TELEGRAM_CHAT_ID=... + SLACK_DEFCON_WEBHOOK_URL=... (optional) + + +PUBLIC API +────────── +class DefconAlertHandler + constructor(config: DefconalerterConfig) + check(): Promise <- one-shot, no loop + start(): void <- begins polling loop + stop(): void <- stops polling loop + getLevel(): Promise <- read level only, no alert + onAlert(cb: (alert: DefconAlert) => void) <- register callback + +function createDefconHandler( + env: Record +): DefconAlertHandler <- env-var factory diff --git a/skills/clawdwatch-threat-brief/references/threat-score-ref.txt b/skills/clawdwatch-threat-brief/references/threat-score-ref.txt new file mode 100644 index 0000000..e2674e8 --- /dev/null +++ b/skills/clawdwatch-threat-brief/references/threat-score-ref.txt @@ -0,0 +1,96 @@ +============================================================ +THREAT SCORE REFERENCE — DEFCON 1-5 +Source: src/sources/intel.ts (ClawdWatch Lobster Edition) +============================================================ + +DefconStatus INTERFACE +────────────────────── +interface DefconStatus { + level: 1 | 2 | 3 | 4 | 5; + description: string; + threatScore: number; // 0-100 (DEFCON 1=100, DEFCON 5=0) + source: string; // 'defconlevel.com' + url: string; // 'https://www.defconlevel.com/current-level' + fetchedAt: string; // ISO 8601 +} + + +fetchDefconLevel() +────────────────── +export async function fetchDefconLevel(): Promise + + Fetches: https://www.defconlevel.com/current-level + User-Agent: ClawdWatch-Lobster/1.0 + Timeout: 15s + Cache TTL: 15 min (cached in intel.ts module cache) + Returns: DefconStatus | null + + HTML parsing: + Primary: /]*>Current DEFCON Level[^<]*<\/h1>[\s\S]{0,3000}?\s*DEFCON\s*([1-5])/i + Fallback: /DEFCON\s*([1-5])/ (first occurrence in body) + + +defconScore() +───────────── +export function defconScore(level: 1|2|3|4|5): number + + DEFCON 1 -> 100 + DEFCON 2 -> 75 + DEFCON 3 -> 50 + DEFCON 4 -> 25 + DEFCON 5 -> 0 + + +NAMED CONSTANTS (exported) +────────────────────────── +export const DEFCON_SCORE_1 = 100; +export const DEFCON_SCORE_2 = 75; +export const DEFCON_SCORE_3 = 50; +export const DEFCON_SCORE_4 = 25; +export const DEFCON_SCORE_5 = 0; + + +DEFCON DESCRIPTIONS (all 5 levels) +─────────────────────────────────── +5: 'DEFCON 5 — Normal peacetime readiness. Standard monitoring posture. No imminent threat.' +4: 'DEFCON 4 — Above normal readiness. Possible terrorist activity or regional conflict. Heightened vigilance.' +3: 'DEFCON 3 — Armed Forces ready to mobilize in 15 minutes. Terrorist attack possible. Air defense alert.' +2: 'DEFCON 2 — Armed forces mobilized. Direct military threat. Terrorist attack likely. Critical infrastructure at severe risk.' +1: 'DEFCON 1 — Maximum readiness. Nuclear war imminent or in progress. ALL systems critical.' + + +HTTP ENDPOINTS (src/http.ts) +──────────────────────────── +GET /defcon + Returns: DefconStatus + threatLevel label + full thresholds table + Example: + { + "level": 3, + "description": "DEFCON 3 — ...", + "threatScore": 50, + "threatLevel": "ELEVATED", + "thresholds": { + "1": { "score": 100, "label": "CRITICAL", "description": "Maximum readiness..." }, + "2": { "score": 75, "label": "HIGH", "description": "Armed forces mobilized..." }, + "3": { "score": 50, "label": "ELEVATED", "description": "Terrorist attack possible..." }, + "4": { "score": 25, "label": "GUARDED", "description": "Above normal readiness..." }, + "5": { "score": 0, "label": "LOW", "description": "Normal peacetime..." } + }, + "source": "defconlevel.com", + "url": "https://www.defconlevel.com/current-level", + "fetchedAt": "2026-06-29T..." + } + +GET /defcon/score + Returns: lightweight level + score only + Example: + { + "level": 3, + "score": 50, + "levelLabel": "ELEVATED", + "timestamp": "2026-06-29T...", + "source": "defconlevel.com" + } + +Note: These endpoints require the HTTP server running (npm run http, port 3444). +If the server is offline, fall back to web_extract on defconlevel.com directly. diff --git a/skills/clawdwatch-threat-brief/scripts/clawdwatch-brief.sh b/skills/clawdwatch-threat-brief/scripts/clawdwatch-brief.sh new file mode 100644 index 0000000..828b705 --- /dev/null +++ b/skills/clawdwatch-threat-brief/scripts/clawdwatch-brief.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +#============================================================ +# clawdwatch-brief.sh +# One-shot ClawdWatch threat brief runner +# Fetches live DEFCON data and produces the full structured brief +# Usage: bash skills/clawdwatch-threat-brief/scripts/clawdwatch-brief.sh +#============================================================ +set -euo pipefail + +REPO_DIR="${CLAWDWATCH_REPO:-.}" + +# ── Defaults (used when HTTP server is offline) ────────────── +LEVEL=3 +SCORE=50 +LABEL="ELEVATED" +TODAY="$(date -u '+%Y-%m-%d')" + +# ── Step 1: Repo audit ────────────────────────────────────── +echo "============================================================" +echo " ClawdWatch Threat Brief — $TODAY" +echo "============================================================" +echo "" + +echo "[1] Repo audit" +if [[ ! -d "$REPO_DIR/src/alerts" ]]; then + echo " ERROR: src/alerts/ not found — set CLAWDWATCH_REPO or run from repo root" + exit 1 +fi +echo " ✅ src/alerts/ -> $(ls "$REPO_DIR/src/alerts/" 2>/dev/null | tr '\n' ' ')" +echo " ✅ src/sources/ -> $(ls "$REPO_DIR/src/sources/" 2>/dev/null | tr '\n' ' ')" +echo "" + +# ── Step 2: TypeScript compile check ──────────────────────── +echo "[2] TypeScript compile check" +cd "$REPO_DIR" +if npx tsc --noEmit 2>&1; then + echo " ✅ TypeScript: clean compile" +else + echo " ❌ TypeScript: errors found — aborting" + exit 1 +fi +echo "" + +# ── Step 3: Live DEFCON check ───────────────────────────── +echo "[3] DEFCON live check" + +DEFCON_RESP=$(curl -sf http://localhost:3444/defcon/score 2>/dev/null) || true +if [[ -n "$DEFCON_RESP" ]]; then + echo " ✅ HTTP server responding on port 3444" + LEVEL=$(echo "$DEFCON_RESP" | grep -o '"level":[0-9]' | head -1 | grep -o '[0-9]') + SCORE=$(echo "$DEFCON_RESP" | grep -o '"score":[0-9]*' | head -1 | grep -o '[0-9]*') + LABEL=$(echo "$DEFCON_RESP" | grep -o '"levelLabel":"[^"]*"' | head -1 | sed 's/"levelLabel":"//;s/"//') + echo " DEFCON $LEVEL — $LABEL — Threat Score: $SCORE/100" +else + echo " ⚠️ HTTP server not running on port 3444" + echo " Fetching defconlevel.com directly..." + HTML=$(curl -sf --max-time 15 -A "ClawdWatch-Lobster/1.0" \ + "https://www.defconlevel.com/current-level") || true + + if [[ -n "$HTML" ]]; then + # Try to extract DEFCON level from hero block + LEVEL_MATCH=$(echo "$HTML" | grep -oP 'DEFCON\s*([1-5])' | head -1 | grep -oP '[1-5]') + if [[ -n "$LEVEL_MATCH" ]]; then + LEVEL="$LEVEL_MATCH" + fi + # Score map + case "$LEVEL" in + 1) SCORE=100; LABEL="CRITICAL" ;; + 2) SCORE=75; LABEL="HIGH" ;; + 3) SCORE=50; LABEL="ELEVATED" ;; + 4) SCORE=25; LABEL="GUARDED" ;; + 5) SCORE=0; LABEL="LOW" ;; + esac + echo " DEFCON $LEVEL — $LABEL — Threat Score: $SCORE/100 (OSINT estimate)" + else + echo " ⚠️ Could not reach defconlevel.com — using defaults" + echo " DEFCON $LEVEL — $LABEL — Threat Score: $SCORE/100 (cached/default)" + fi +fi +echo "" + +# ── Step 4: Threat bar ────────────────────────────────────── +echo "[4] Threat Score Bar" +BARS=$((SCORE / 10)) +BLANKS=$((10 - BARS)) +BAR_FILLED=$(printf '%*s' "$BARS" '' | tr ' ' '█') +BAR_EMPTY=$(printf '%*s' "$BLANKS" '' | tr ' ' '░') +printf " DEFCON %s %s%s %s/100 %s\n" \ + "$LEVEL" "$BAR_FILLED" "$BAR_EMPTY" "$SCORE" "$LABEL" +echo "" + +# ── Step 5: Risk drivers (defaults — update from live feeds as needed) ── +echo "[5] Active Risk Drivers (defaults — agent enriches from live feeds)" +echo " • US-Iran ceasefire: status unknown" +echo " • Strait of Hormuz: status unknown" +echo " • Russia-Ukraine: status unknown" +echo " • Iran nuke program: status unknown" +echo " • North Korea: status unknown" +echo " • Pacific seismic: status unknown" +echo "" +echo " → Agent will enrich from live feeds and produce full structured brief" +echo "" + +echo "============================================================" +echo " Brief complete — $(date -u '+%Y-%m-%d %H:%M UTC')" +echo "============================================================" diff --git a/skills/clawdwatch/SKILL.md b/skills/clawdwatch/SKILL.md new file mode 100644 index 0000000..2543a6f --- /dev/null +++ b/skills/clawdwatch/SKILL.md @@ -0,0 +1,55 @@ +--- +name: clawdwatch +description: Pull live Clawdwatch OSINT snapshots in JSON for one or more regions so an OpenClaw agent can inspect flights, outages, news, social feeds, and optional satellite or ship data. +--- + +# Clawdwatch + +Use this skill when you need an on-demand OSINT pull from the local Clawdwatch workspace. + +## When To Use It + +- You need current flight, outage, news, or social data for one or more regions. +- You need a machine-readable JSON snapshot that can be parsed or summarized. +- You need to expand coverage beyond the default Middle East preset. + +## Commands + +Run commands from the repository root: + +```bash +npm run snapshot -- --json --regions middle_east,eastern_europe +``` + +List available region presets: + +```bash +npm run regions -- --json +``` + +Pull every preset region in one pass: + +```bash +npm run snapshot -- --json --all-regions +``` + +Include optional ship and satellite sources: + +```bash +npm run snapshot -- --json --regions middle_east --ships strait_hormuz,persian_gulf --satellite --days 2 +``` + +## Expected Output + +- `regions`: per-region flight counts, alert summaries, and mapped internet connectivity. +- `news`: latest aggregated headlines. +- `social`: latest Twitter/Reddit OSINT posts. +- `ships`: optional strategic waterway summaries when requested. +- `satellite`: optional Sentinel Hub imagery summary when requested and credentials exist. + +## Operating Notes + +- Default region is `middle_east` if none is provided. +- `--all-regions` expands to all named presets except `global`. +- Use `global` explicitly when you want the widest OpenSky query. +- `--json` is the preferred mode for agent use. diff --git a/snapshot_output.json b/snapshot_output.json new file mode 100644 index 0000000..f241adb Binary files /dev/null and b/snapshot_output.json differ diff --git a/src/alerts/defcon.ts b/src/alerts/defcon.ts new file mode 100644 index 0000000..1a1533a --- /dev/null +++ b/src/alerts/defcon.ts @@ -0,0 +1,345 @@ +import axios from 'axios'; + +/** + * src/alerts/defcon.ts + * DEFCON Alert Handler — ClawdWatch Lobster Edition + * + * Consumes fetchDefconLevel() from intel.ts, applies threshold rules, + * escalation/cooldown logic, and fires Telegram/Slack notifications. + * + * Wire into index.ts monitor loop or http.ts as a background scheduler. + */ + +import { fetchDefconLevel, type DefconStatus } from '../sources/intel'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type DefconLevel = 1 | 2 | 3 | 4 | 5; + +export interface DefconAlert { + level: DefconLevel; + previousLevel: DefconLevel | null; + changeType: 'escalation' | 'de_escalation' | 'stable' | 'init'; + message: string; + color: number; // decimal RGB for Telegram + timestamp: string; +} + +export interface DefconThresholds { + /** Minimum level to fire a Telegram alert (default: 4) */ + telegramMinLevel: DefconLevel; + /** Minimum level to fire a Slack alert (default: 3) */ + slackMinLevel: DefconLevel; + /** Cooldown between repeat alerts at the same level (ms, default: 30 min) */ + cooldownMs: number; + /** Auto-escalate to next level after this many ms unacknowledged (default: 15 min) */ + escalationTimeoutMs: number; + /** Poll interval (ms, default: 5 min) */ + pollIntervalMs: number; +} + +export interface DefconalerterConfig { + thresholds: Partial; + telegramToken?: string; + telegramChatId?: string; + slackWebhookUrl?: string; +} + +// ── DEFCON Level Metadata ───────────────────────────────────────────────────── + +interface DefconMeta { + label: string; + color: number; // decimal RGB + emoji: string; + description: string; + requiredActions: string[]; + channels: ('telegram' | 'slack')[]; + autoEscalate: boolean; +} + +const DEFCON_META: Record = { + 5: { + label: 'DEFCON 5 — NORMAL', + color: 0x22c55e, // green + emoji: '🟢', + description: 'Normal peacetime readiness. No imminent threat. Standard monitoring posture.', + requiredActions: [ + 'Continue standard log monitoring', + 'Maintain routine patch management', + 'Review and update security documentation', + ], + channels: [], + autoEscalate: false, + }, + 4: { + label: 'DEFCON 4 — ELEVATED', + color: 0x3b82f6, // blue + emoji: '🔵', + description: 'Above normal readiness. Increased intelligence suggests possible threat activity. Heighten vigilance.', + requiredActions: [ + 'Increase log review frequency to every 2 hours', + 'Verify all monitoring systems are fully operational', + 'Review recent access logs for anomalies', + 'Notify Tier-2 SOC analysts of heightened awareness', + ], + channels: ['telegram'], + autoEscalate: false, + }, + 3: { + label: 'DEFCON 3 — ARMED FORCES READY', + color: 0xeab308, // yellow + emoji: '🟡', + description: 'Air Force ready to mobilize in 15 minutes. Terrorist attack possible. Heightened cyber activity expected.', + requiredActions: [ + 'Activate enhanced monitoring across all critical systems', + 'Notify Tier-3 analysts and incident response team', + 'Review and harden critical system access controls', + 'Prepare incident response playbooks for activation', + ], + channels: ['telegram', 'slack'], + autoEscalate: true, + }, + 2: { + label: 'DEFCON 2 — ARMED FORCES MOBILIZE', + color: 0xf97316, // orange + emoji: '🟠', + description: 'Armed forces mobilized. Terrorist attack likely. Critical infrastructure at high risk.', + requiredActions: [ + 'Activate full incident response team', + 'Implement emergency access controls and MFA enforcement', + 'Isolate non-critical systems from critical network segments', + 'Initiate active threat hunting procedures', + ], + channels: ['telegram', 'slack'], + autoEscalate: true, + }, + 1: { + label: 'DEFCON 1 — MAXIMUM FORCE READY', + color: 0xef4444, // red + emoji: '🔴', + description: 'Maximum readiness. Nuclear war or imminent armed attack in progress. ALL systems critical.', + requiredActions: [ + 'EXECUTE INCIDENT RESPONSE PLAN IMMEDIATELY', + 'Activate executive notification and crisis communication', + 'Initiate forensic preservation of affected systems', + 'Coordinate with law enforcement and CISA if breach confirmed', + ], + channels: ['telegram', 'slack'], + autoEscalate: true, + }, +}; + +// ── Alert Formatter ─────────────────────────────────────────────────────────── + +function formatDefconAlert(alert: DefconAlert): string { + const meta = DEFCON_META[alert.level]; + const changeLine = + alert.changeType === 'escalation' + ? `⚠️ ESCALATION: DEFCON ${alert.previousLevel} → DEFCON ${alert.level}` + : alert.changeType === 'de_escalation' + ? `✅ De-escalation: DEFCON ${alert.previousLevel} → DEFCON ${alert.level}` + : alert.changeType === 'init' + ? `🚀 DEFCON initialized at DEFCON ${alert.level}` + : `➡️ DEFCON stable at DEFCON ${alert.level}`; + + const actionLines = meta.requiredActions.map((a) => ` • ${a}`).join('\n'); + + return [ + `${meta.emoji} ${meta.label}`, + ``, + changeLine, + ``, + `📋 Situation:`, + `> ${meta.description}`, + ``, + `🛡️ Required Actions:`, + actionLines, + ``, + `🕐 ${alert.timestamp}`, + ``, + `Source: defconlevel.com | ClawdWatch Lobster Edition`, + ].join('\n'); +} + +// ── Notification Dispatcher ─────────────────────────────────────────────────── + +async function sendTelegram( + message: string, + token?: string, + chatId?: string, + color?: number +): Promise { + if (!token || !chatId) return false; + try { + const url = `https://api.telegram.org/bot${token}/sendMessage`; + await axios.post(url, { + chat_id: chatId, + text: message, + parse_mode: 'HTML', + disable_web_page_preview: true, + }); + return true; + } catch (err: any) { + console.error('[DEFCON:Telegram]', err.message); + return false; + } +} + +async function sendSlack( + message: string, + webhookUrl?: string +): Promise { + if (!webhookUrl) return false; + try { + await axios.post(webhookUrl, { text: message }); + return true; + } catch (err: any) { + console.error('[DEFCON:Slack]', err.message); + return false; + } +} + +// ── Core DEFCON Alert Handler ───────────────────────────────────────────────── + +export class DefconAlertHandler { + private lastLevel: DefconLevel | null = null; + private lastAlertMs = 0; + private intervalId: ReturnType | null = null; + private onAlertCallbacks: ((alert: DefconAlert) => void)[] = []; + + private readonly thresholds: DefconThresholds; + + constructor(private config: DefconalerterConfig) { + this.thresholds = { + telegramMinLevel: config.thresholds?.telegramMinLevel ?? 4, + slackMinLevel: config.thresholds?.slackMinLevel ?? 3, + cooldownMs: config.thresholds?.cooldownMs ?? 30 * 60 * 1000, + escalationTimeoutMs: config.thresholds?.escalationTimeoutMs ?? 15 * 60 * 1000, + pollIntervalMs: config.thresholds?.pollIntervalMs ?? 5 * 60 * 1000, + }; + } + + /** Register a callback for every DEFCON alert (for external routing) */ + onAlert(cb: (alert: DefconAlert) => void) { + this.onAlertCallbacks.push(cb); + } + + /** Check DEFCON once — call this directly or use start() for the polling loop */ + async check(): Promise { + const raw = await fetchDefconLevel(); + if (!raw) { + console.warn('[DEFCON] fetchDefconLevel returned null — source unreachable'); + return null; + } + + const level = raw.level as DefconLevel; + if (!(level in DEFCON_META)) { + console.warn(`[DEFCON] Unknown level: ${level}`); + return null; + } + + const now = Date.now(); + const changed = this.lastLevel !== level; + const cooled = now - this.lastAlertMs >= this.thresholds.cooldownMs; + const shouldEscalate = DEFCON_META[level].autoEscalate; + const escalationTimeoutPassed = + shouldEscalate && this.lastLevel !== null && now - this.lastAlertMs >= this.thresholds.escalationTimeoutMs; + + // Alert if: level changed, OR we're still escalating and cooldown passed + if (!changed && !escalationTimeoutPassed && this.lastLevel !== null) { + return null; // nothing new to report + } + + this.lastLevel = level; + this.lastAlertMs = now; + + const changeType: DefconAlert['changeType'] = + !this.lastLevel || changed === false + ? 'stable' + : level < (this.lastLevel as DefconLevel) + ? 'escalation' + : 'de_escalation'; + + const prevForAlert = changed ? (this.lastLevel as DefconLevel) : null; + + const alert: DefconAlert = { + level, + previousLevel: prevForAlert, + changeType, + message: formatDefconAlert({ + level, + previousLevel: prevForAlert, + changeType, + message: '', + color: DEFCON_META[level].color, + timestamp: new Date().toISOString(), + }), + color: DEFCON_META[level].color, + timestamp: new Date().toISOString(), + }; + + // Fire notifications + const meta = DEFCON_META[level]; + + if (level <= this.thresholds.telegramMinLevel) { + const sent = await sendTelegram( + alert.message, + this.config.telegramToken, + this.config.telegramChatId, + meta.color + ); + if (sent) console.log(`[DEFCON] Telegram alert sent: ${meta.label}`); + } + + if (level <= this.thresholds.slackMinLevel) { + const sent = await sendSlack(alert.message, this.config.slackWebhookUrl); + if (sent) console.log(`[DEFCON] Slack alert sent: ${meta.label}`); + } + + // Notify callbacks + for (const cb of this.onAlertCallbacks) { + try { cb(alert); } catch (err) { console.error('[DEFCON:callback]', err); } + } + + return alert; + } + + /** Start the polling loop */ + start() { + console.log(`[DEFCON] Alert handler started — polling every ${this.thresholds.pollIntervalMs / 1000 / 60} min`); + this.check(); // immediate first run + this.intervalId = setInterval(() => this.check(), this.thresholds.pollIntervalMs); + } + + /** Stop the polling loop */ + stop() { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + console.log('[DEFCON] Alert handler stopped'); + } + } + + /** Get current DEFCON level without firing alerts */ + async getLevel(): Promise { + const raw = await fetchDefconLevel(); + return raw ? (raw.level as DefconLevel) : null; + } +} + +// ── Convenience factory ──────────────────────────────────────────────────────── + +export function createDefconHandler(env: Record): DefconAlertHandler { + return new DefconAlertHandler({ + thresholds: { + telegramMinLevel: (parseInt(env.DEFCON_TELEGRAM_MIN_LEVEL || '4', 10) as DefconLevel) || 4, + slackMinLevel: (parseInt(env.DEFCON_SLACK_MIN_LEVEL || '3', 10) as DefconLevel) || 3, + cooldownMs: parseInt(env.DEFCON_COOLDOWN_MS || '', 10) || 30 * 60 * 1000, + escalationTimeoutMs: parseInt(env.DEFCON_ESCALATION_TIMEOUT_MS || '', 10) || 15 * 60 * 1000, + pollIntervalMs: parseInt(env.DEFCON_POLL_INTERVAL_MS || '', 10) || 5 * 60 * 1000, + }, + telegramToken: env.TELEGRAM_BOT_TOKEN, + telegramChatId: env.TELEGRAM_CHAT_ID, + slackWebhookUrl: env.SLACK_DEFCON_WEBHOOK_URL, + }); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..f698943 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,272 @@ +#!/usr/bin/env node +/** + * ClawdWatch CLI — reads from the local HTTP API (default http://localhost:3444). + * + * This file is intentionally thin: all data flow goes through the public HTTP + * routes, so the CLI cannot drift from the server. The HTTP server is the + * single source of truth. + * + * Usage: + * npm run regions # list all regions + * npm run snapshot # full global snapshot + * clawdwatch --base http://host:3444 regions + * clawdwatch regions --group asia + * clawdwatch snapshot --region israel + * clawdwatch snapshot --region usa --json + * clawdwatch status # server health + * clawdwatch help + */ + +import axios, { AxiosInstance } from 'axios'; + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- + +const HELP = ` +🦀 ClawdWatch CLI + +Usage: + clawdwatch [options] + +Commands: + regions List all known regions (with bounds and group) + snapshot Pull a live snapshot from one or more regions + status Server health + version + help Show this help + +Options: + --base URL HTTP base (default: ${process.env.CLAWDWATCH_BASE || 'http://localhost:3444'}) + --region ID Region id (repeatable: --region usa --region israel) + --group G Filter regions by group (e.g. middle_east, asia, africa) + --json Emit raw JSON instead of pretty output + --timeout MS Request timeout (default 30000) + +Examples: + clawdwatch regions + clawdwatch regions --group africa + clawdwatch snapshot --region israel + clawdwatch snapshot --region usa --region iran --json + clawdwatch --base http://100.64.1.10:3444 status +`; + +interface CliOptions { + base: string; + command: string; + region: string[]; + group?: string; + json: boolean; + timeout: number; +} + +function parseArgs(argv: string[]): CliOptions { + const out: CliOptions = { + base: process.env.CLAWDWATCH_BASE || 'http://localhost:3444', + command: '', + region: [], + json: false, + timeout: 30_000, + }; + + let i = 0; + if (argv.length > 0 && !argv[0].startsWith('-')) { + out.command = argv[0]; + i = 1; + } + while (i < argv.length) { + const tok = argv[i]; + switch (tok) { + case '-h': + case '--help': + out.command = 'help'; + i++; + break; + case '--base': { + const v = argv[++i]; + if (!v) throw new Error('--base requires a URL'); + out.base = v.replace(/\/+$/, ''); + i++; + break; + } + case '--region': { + const v = argv[++i]; + if (!v) throw new Error('--region requires an id'); + out.region.push(v); + i++; + break; + } + case '--group': { + const v = argv[++i]; + if (!v) throw new Error('--group requires a value'); + out.group = v; + i++; + break; + } + case '--json': + out.json = true; + i++; + break; + case '--timeout': { + const v = argv[++i]; + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) throw new Error('--timeout must be a positive number'); + out.timeout = n; + i++; + break; + } + default: + throw new Error(`Unknown argument: ${tok}`); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// HTTP client +// --------------------------------------------------------------------------- + +function makeClient(opts: CliOptions): AxiosInstance { + return axios.create({ + baseURL: opts.base, + timeout: opts.timeout, + validateStatus: () => true, // we handle non-2xx ourselves + headers: { 'user-agent': 'clawdwatch-cli/2.4' }, + }); +} + +async function call(http: AxiosInstance, path: string, params?: Record): Promise { + const res = await http.get(path, { params }); + if (res.status >= 400) { + const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data); + throw new Error(`${res.status} ${res.statusText} for ${path}: ${body.slice(0, 240)}`); + } + return res.data; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +interface RegionRow { + id: string; + name: string; + description: string; + group: string; + priority: number; + aliases: string[]; + bounds: { latMin: number; latMax: number; lonMin: number; lonMax: number }; +} + +function pad(s: string, n: number): string { + if (s.length >= n) return s.slice(0, n - 1) + '…'; + return s + ' '.repeat(n - s.length); +} + +async function cmdRegions(http: AxiosInstance, opts: CliOptions): Promise { + const data = await call<{ regions: RegionRow[] }>(http, '/regions'); + let rows = data.regions; + if (opts.group) rows = rows.filter((r) => r.group === opts.group); + + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)); + return 0; + } + + if (rows.length === 0) { + console.log(`No regions matched${opts.group ? ` group="${opts.group}"` : ''}.`); + return 0; + } + + console.log(`🦀 ${rows.length} region${rows.length === 1 ? '' : 's'}${opts.group ? ` (group: ${opts.group})` : ''}:`); + console.log(''); + console.log(`${pad('ID', 22)} ${pad('NAME', 26)} ${pad('GROUP', 14)} ${pad('PRIO', 4)} BOUNDS`); + console.log('-'.repeat(98)); + for (const r of rows) { + const b = r.bounds; + const bounds = `${b.latMin.toFixed(0)}..${b.latMax.toFixed(0)} / ${b.lonMin.toFixed(0)}..${b.lonMax.toFixed(0)}`; + console.log(`${pad(r.id, 22)} ${pad(r.name, 26)} ${pad(r.group, 14)} ${pad(String(r.priority), 4)} ${bounds}`); + if (r.aliases.length) { + console.log(` ${' '.repeat(22)} aliases: ${r.aliases.join(', ')}`); + } + } + return 0; +} + +async function cmdSnapshot(http: AxiosInstance, opts: CliOptions): Promise { + const regions = opts.region.length ? opts.region : ['global']; + const path = '/snapshot'; + const params = regions.length === 1 ? { region: regions[0] } : { region: regions.join(',') }; + const data = await call(http, path, params); + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return 0; + } + + console.log(`🦀 ClawdWatch snapshot`); + console.log(` base: ${opts.base}`); + console.log(` regions: ${regions.join(', ')}`); + console.log(` at: ${data?.generatedAt || new Date().toISOString()}`); + console.log(''); + console.log(JSON.stringify(data, null, 2)); + return 0; +} + +async function cmdStatus(http: AxiosInstance, opts: CliOptions): Promise { + const data = await call(http, '/status'); + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return 0; + } + console.log(`🦀 ClawdWatch status`); + console.log(` base: ${opts.base}`); + console.log(JSON.stringify(data, null, 2)); + return 0; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main(): Promise { + let opts: CliOptions; + try { + opts = parseArgs(process.argv.slice(2)); + } catch (e: any) { + console.error(`Error: ${e.message}`); + console.error(HELP); + return 2; + } + + if (!opts.command || opts.command === 'help' || opts.command === '-h' || opts.command === '--help') { + console.log(HELP); + return 0; + } + + const http = makeClient(opts); + try { + switch (opts.command) { + case 'regions': + return await cmdRegions(http, opts); + case 'snapshot': + return await cmdSnapshot(http, opts); + case 'status': + return await cmdStatus(http, opts); + default: + console.error(`Unknown command: ${opts.command}`); + console.error(HELP); + return 2; + } + } catch (e: any) { + console.error(`✗ ${e.message}`); + return 1; + } +} + +main().then( + (code) => process.exit(code), + (e) => { + console.error('Unhandled error:', e); + process.exit(1); + }, +); diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..cc31a6f --- /dev/null +++ b/src/http.ts @@ -0,0 +1,789 @@ +import express from 'express'; +import axios from 'axios'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +import { ALL_REGIONS, getDefaultFlightRegions, getRegionById, findRegion, type RegionDefinition } from './regions'; +import { RSS_FEEDS, fetchFeed, fetchFeeds, getFeedHealth, type NewsItem, type RssFeed, type FeedHealth } from './sources/rss'; +import { fetchEarthquakes, fetchUsWeatherAlerts, fetchCurrentWeather, fetchGdacsEvents, fetchDefconLevel } from './sources/intel'; +import { + searchSanctions, + traceBtcAddress, + traceEthAddress, + fetchFireHotspots, + fetchCve, + fetchRecentCves, + whoisLookup, + dnsLookup, + fetchTelegramChannel, + fetchSpaceWeather, + searchSentinelScenes, + fetchSatelliteCatalog, + fetchCyberThreats, + geoLocate, + fetchAirQuality, + inspectTlsCertificate, + fetchLiveNewsFeeds, + refreshOfacCache, + checkOfac, + ofacCacheSize, + scanPorts, +} from './sources/osiris'; + +const app = express(); +const PORT = 3444; + +// ============================================================ +// OpenSky rate limiting + cache +// ============================================================ +const OPENSKY_BASE = 'https://opensky-network.org/api/states/all'; +const OPENSKY_KEY = process.env.OPENSKY_API_KEY || ''; +const OPENSKY_AUTH_HEADER = OPENSKY_KEY ? { 'Authorization': `Bearer ${OPENSKY_KEY}` } : {}; + +let lastRequestTime = 0; +const MIN_REQUEST_INTERVAL = 10_000; // 10s — strict enough to be polite +const MAX_RETRIES = 2; +const RETRY_DELAY = 15_000; + +const cache: { [key: string]: { data: any; timestamp: number } } = {}; +const CACHE_TTL = 5 * 60 * 1000; // 5 min for flight data + +async function fetchOpenSky(url: string, useCache = true): Promise { + const cacheKey = url; + + if (useCache && cache[cacheKey] && Date.now() - cache[cacheKey].timestamp < CACHE_TTL) { + return cache[cacheKey].data; + } + + // rate limit + const now = Date.now(); + const wait = MIN_REQUEST_INTERVAL - (now - lastRequestTime); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastRequestTime = Date.now(); + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const response = await axios.get(url, { + headers: { ...OPENSKY_AUTH_HEADER, 'User-Agent': 'ClawdWatch-Lobster/1.0' }, + timeout: 15_000, + validateStatus: () => true, + }); + + if (response.status === 429) { + console.warn(`[opensky] rate limited, retrying in ${RETRY_DELAY}ms`); + await new Promise((r) => setTimeout(r, RETRY_DELAY)); + continue; + } + + if (response.status >= 400) { + return { error: `OpenSky HTTP ${response.status}`, states: [] }; + } + + if (response.data?.states) { + cache[cacheKey] = { data: response.data, timestamp: Date.now() }; + return response.data; + } + + return { states: [] }; + } catch (e: any) { + if (attempt === MAX_RETRIES - 1) { + return { error: e.message, states: [] }; + } + } + } + return { error: 'OpenSky: max retries exceeded', states: [] }; +} + +// ============================================================ +// Flight region fetcher +// ============================================================ +async function getFlightsForRegion(region: RegionDefinition) { + const { flightBounds, name, id } = region; + const url = `${OPENSKY_BASE}?lamin=${flightBounds.latMin}&lomin=${flightBounds.lonMin}&lamax=${flightBounds.latMax}&lomax=${flightBounds.lonMax}`; + const data = await fetchOpenSky(url); + + if (data.error) { + return { regionId: id, region: name, total: 0, flights: [], error: data.error }; + } + return { + regionId: id, + region: name, + group: region.group, + total: data.states?.length || 0, + flights: (data.states || []).slice(0, 50), + }; +} + +// Aggregate flight counts across many regions. Designed to be cache-friendly. +async function getFlightSummary(regions: RegionDefinition[]) { + const results = await Promise.all(regions.map(getFlightsForRegion)); + const totalFlights = results.reduce((s, r) => s + (r.total || 0), 0); + const errors = results.filter((r) => r.error); + return { + timestamp: new Date().toISOString(), + source: 'OpenSky Network', + regionsQueried: regions.length, + totalFlights, + regions: results.map((r) => ({ + id: r.regionId, + name: r.region, + group: (regions.find((reg) => reg.id === r.regionId))?.group, + flights: r.total, + error: r.error, + })), + degraded: errors.length > 0, + }; +} + +// ============================================================ +// CORS +// ============================================================ +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + if (req.method === 'OPTIONS') return res.sendStatus(200); + next(); +}); + +// ============================================================ +// ROUTES +// ============================================================ + +app.get('/', (req, res) => { + res.json({ + service: 'clawdwatch-lobster-edition', + version: '2.5.0-lobster', + description: 'Global OSINT aggregator — flights, news, disasters, weather, DEFCON, sanctions, crypto, fires, CVEs, WHOIS, DNS, Telegram', + endpoints: { + status: 'GET /status', + regions: 'GET /regions', + flights: 'GET /flights (priority regions)', + flightsRegion:'GET /flights/:region (single region by id or alias)', + flightsAll: 'GET /flights/all (every defined region, slower)', + news: 'GET /news (all enabled RSS feeds)', + newsRegion: 'GET /news/:region (filter by region group)', + newsHealth: 'GET /news/health (per-source health)', + newsSources: 'GET /news/sources (configured RSS feeds)', + earthquakes: 'GET /earthquakes?min=4.0 (USGS, 2.5+ last 24h)', + gdacs: 'GET /gdacs (global disaster alerts)', + weatherAlerts:'GET /weather/us (NWS active US alerts)', + weather: 'GET /weather?lat=&lon= (current wx from Open-Meteo)', + defcon: 'GET /defcon (current DEFCON level from defconlevel.com)', + conflict: 'GET /conflict (ME-focused legacy summary)', + osint: 'GET /osint (global situational summary)', + snapshot: 'GET /snapshot (one-call daily brief)', + // OSIRIS-derived endpoints (v2.2) + sanctions: 'GET /sanctions?q= (OFAC SDN + OpenSanctions person/org/vessel)', + cryptoBtc: 'GET /crypto/btc/:address (BTC wallet trace via blockstream.info)', + cryptoEth: 'GET /crypto/eth/:address (ETH wallet trace via Blockscout)', + fires: 'GET /fires?hours=24®ion= (NASA FIRMS active fire hotspots)', + cve: 'GET /cve/:id (NVD CVE detail, e.g. CVE-2024-12345)', + cveRecent: 'GET /cve/recent?days=7 (recently modified CVEs)', + whois: 'GET /whois/:domain (RDAP lookup, free, no key)', + dns: 'GET /dns/:domain (A, AAAA, MX, TXT, NS, CNAME via Google DoH)', + telegram: 'GET /telegram/:channel (public channel recent messages)', + spaceWeather: 'GET /space-weather (NOAA SWPC Kp index + solar flares, free, no key)', + sentinel: 'GET /sentinel?lat=&lng= (Sentinel-1/2 satellite imagery search, free)', + satellites: 'GET /satellites?category= (Celestrak TLE catalog, free)', + cyberThreats: 'GET /cyber-threats?days= (CISA Known Exploited Vulnerabilities, free)', + geo: 'GET /geo?ip= (IP geolocation, 3-provider cascade)', + airQuality: 'GET /air-quality?limit= (OpenAQ global PM2.5 stations, free)', + sslInspect: 'GET /ssl/:host?port= (TLS cert chain + expiry, free)', + liveNews: 'GET /news/live?category= (15+ global 24/7 broadcasters)', + ofacCheck: 'GET /ofac/check?q= (OFAC auto-flag; needs API key)', + ofacRefresh: 'POST /ofac/refresh (reload OFAC cache from OpenSanctions)', + scan: 'GET /scan?host=&ports= (TCP port scanner; PORT_SCAN_ENABLED=true)', + }, + }); +}); + +app.get('/status', (req, res) => { + res.json({ + status: 'running', + service: 'clawdwatch-lobster-edition', + port: PORT, + version: '2.5.0-lobster', + regions: ALL_REGIONS.length, + newsFeeds: RSS_FEEDS.filter((f) => f.enabled).length, + cacheActive: true, + timestamp: new Date().toISOString(), + }); +}); + +app.get('/regions', (req, res) => { + res.json({ + total: ALL_REGIONS.length, + regions: ALL_REGIONS.map((r) => ({ + id: r.id, + name: r.name, + group: r.group, + description: r.description, + bounds: r.flightBounds, + aliases: r.aliases, + priority: r.priority, + })), + }); +}); + +// === FLIGHTS === +app.get('/flights', async (req, res) => { + const summary = await getFlightSummary(getDefaultFlightRegions()); + res.json({ + ...summary, + // Keep flights in default for backward compat + flights: summary.regions.flatMap((r) => []).slice(0, 0), // intentionally empty; use /flights/:region + note: 'This endpoint returns aggregate counts. For raw flight lists, call /flights/:region.', + }); +}); + +app.get('/flights/all', async (req, res) => { + const summary = await getFlightSummary(ALL_REGIONS); + res.json(summary); +}); + +app.get('/flights/:region', async (req, res) => { + const region = findRegion(req.params.region); + if (!region) { + return res.status(404).json({ error: `Unknown region: ${req.params.region}. Try /regions.` }); + } + const result = await getFlightsForRegion(region); + res.json({ + timestamp: new Date().toISOString(), + source: 'OpenSky Network', + ...result, + }); +}); + +// === NEWS (RSS) === +// === NEWS (RSS) — specific routes BEFORE /news/:region === +app.get('/news/health', (req, res) => { + const health: FeedHealth[] = getFeedHealth(); + res.json({ + timestamp: new Date().toISOString(), + ok: health.filter((h) => h.ok).length, + failing: health.filter((h) => !h.ok).length, + feeds: health, + }); +}); + +app.get('/news/sources', (req, res) => { + res.json({ + timestamp: new Date().toISOString(), + total: RSS_FEEDS.length, + enabled: RSS_FEEDS.filter((f) => f.enabled).length, + feeds: RSS_FEEDS.map((f) => ({ + id: f.id, + name: f.name, + region: f.region, + feedUrl: f.feedUrl, + enabled: f.enabled, + weight: f.weight, + })), + }); +}); + +app.get('/news', async (req, res) => { + const all = await fetchFeeds(RSS_FEEDS, true); + // Dedupe by URL + const seen = new Set(); + const deduped = all.filter((n) => { + if (seen.has(n.url)) return false; + seen.add(n.url); + return true; + }); + // Newest first + deduped.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + + res.json({ + timestamp: new Date().toISOString(), + sourcesCount: RSS_FEEDS.filter((f) => f.enabled).length, + total: deduped.length, + news: deduped.slice(0, 50), + }); +}); + +// GET /news/live — Live broadcast network catalog (must be before /news/:region!) +// Query: ?category=mainstream|government|finance|state +app.get('/news/live', async (req, res) => { + const category = req.query.category ? String(req.query.category) : undefined; + const feeds = await fetchLiveNewsFeeds(category); + res.json({ + total: feeds.length, + embeddable: feeds.filter(f => f.embed_allowed).length, + categories: ['mainstream', 'government', 'finance', 'state'], + feeds, + timestamp: new Date().toISOString(), + }); +}); + +app.get('/news/:region', async (req, res) => { + const regionParam = req.params.region.toLowerCase(); + const matchingFeeds = RSS_FEEDS.filter( + (f) => f.region === regionParam || f.id === regionParam, + ); + if (matchingFeeds.length === 0) { + return res.status(404).json({ + error: `No RSS feeds for region "${regionParam}".`, + available: Array.from(new Set(RSS_FEEDS.map((f) => f.region))), + }); + } + const all = await fetchFeeds(matchingFeeds, true); + const seen = new Set(); + const deduped = all.filter((n) => { + if (seen.has(n.url)) return false; + seen.add(n.url); + return true; + }); + deduped.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + res.json({ + timestamp: new Date().toISOString(), + region: regionParam, + sources: matchingFeeds.map((f) => f.name), + total: deduped.length, + news: deduped.slice(0, 30), + }); +}); + +// === INTEL (Earthquakes, Disasters, Weather) === +app.get('/earthquakes', async (req, res) => { + const min = parseFloat((req.query.min as string) || '4.0'); + const quakes = await fetchEarthquakes(min); + res.json({ + timestamp: new Date().toISOString(), + source: 'USGS', + minMagnitude: min, + total: quakes.length, + earthquakes: quakes.slice(0, 50), + }); +}); + +app.get('/gdacs', async (req, res) => { + const events = await fetchGdacsEvents(); + res.json({ + timestamp: new Date().toISOString(), + source: 'GDACS', + total: events.length, + events, + }); +}); + +app.get('/weather/us', async (req, res) => { + const alerts = await fetchUsWeatherAlerts(); + res.json({ + timestamp: new Date().toISOString(), + source: 'NOAA NWS', + total: alerts.length, + alerts, + }); +}); + +app.get('/weather', async (req, res) => { + const lat = parseFloat(req.query.lat as string); + const lon = parseFloat(req.query.lon as string); + if (Number.isNaN(lat) || Number.isNaN(lon)) { + return res.status(400).json({ error: 'Provide ?lat=&lon=' }); + } + const current = await fetchCurrentWeather(lat, lon); + if (!current) { + return res.status(502).json({ error: 'Weather source unavailable' }); + } + res.json({ + timestamp: new Date().toISOString(), + source: 'Open-Meteo', + lat, lon, + current, + }); +}); + +// === DEFCON === +// GET /defcon — full enriched DEFCON response with threat score + thresholds +app.get('/defcon', async (req, res) => { + const status = await fetchDefconLevel(); + if (!status) { + return res.status(502).json({ error: 'DEFCON source unavailable' }); + } + res.json({ + ...status, + // Enrich with threat level classification and threshold reference + threatLevel: + status.level === 1 ? 'CRITICAL' + : status.level === 2 ? 'HIGH' + : status.level === 3 ? 'ELEVATED' + : status.level === 4 ? 'GUARDED' + : 'LOW', + thresholds: { + 1: { score: 100, label: 'CRITICAL', description: 'Maximum readiness. Nuclear war imminent or in progress.' }, + 2: { score: 75, label: 'HIGH', description: 'Armed forces mobilized. Direct military threat.' }, + 3: { score: 50, label: 'ELEVATED', description: 'Terrorist attack possible. Air Force ready in 15 min.' }, + 4: { score: 25, label: 'GUARDED', description: 'Above normal readiness. Heightened vigilance.' }, + 5: { score: 0, label: 'LOW', description: 'Normal peacetime readiness. No imminent threat.' }, + }, + }); +}); + +// GET /defcon/score — lightweight numeric-only response (good for dashboards/grafana) +app.get('/defcon/score', async (req, res) => { + const status = await fetchDefconLevel(); + if (!status) { + return res.status(502).json({ error: 'DEFCON source unavailable' }); + } + res.json({ + level: status.level, + score: status.threatScore, + levelLabel: + status.level === 1 ? 'CRITICAL' + : status.level === 2 ? 'HIGH' + : status.level === 3 ? 'ELEVATED' + : status.level === 4 ? 'GUARDED' + : 'LOW', + timestamp: status.fetchedAt, + source: status.source, + }); +}); + +// === AGGREGATES === +app.get('/conflict', async (req, res) => { + // Legacy ME-focused endpoint, kept for backward compat with the evening brief. + const meRegionIds = ['iran', 'israel', 'lebanon', 'syria', 'iraq', 'yemen', 'saudi_arabia', 'uae', 'qatar', 'kuwait', 'turkey']; + const conflictRegions = meRegionIds + .map((id) => getRegionById(id)) + .filter((r): r is RegionDefinition => !!r); + + const flightResults = await Promise.all(conflictRegions.map(getFlightsForRegion)); + const newsFeeds = RSS_FEEDS.filter((f) => f.region === 'middle_east' || f.region === 'israel' || f.region === 'eastern_europe'); + const news = await fetchFeeds(newsFeeds); + const seen = new Set(); + const dedupedNews = news.filter((n) => { + if (seen.has(n.url)) return false; + seen.add(n.url); + return true; + }); + + res.json({ + timestamp: new Date().toISOString(), + source: 'OpenSky + RSS', + conflictZones: flightResults.map((r) => ({ id: r.regionId, name: r.region, flights: r.total })), + totalConflictFlights: flightResults.reduce((s, r) => s + (r.total || 0), 0), + news: { total: dedupedNews.length, sources: newsFeeds.map((f) => f.name), latest: dedupedNews.slice(0, 10) }, + }); +}); + +app.get('/osint', async (req, res) => { + // Global situational summary. This is the one for the daily brief. + const [flightSummary, quakes, gdacs, usWeather, defcon] = await Promise.all([ + getFlightSummary(getDefaultFlightRegions()), + fetchEarthquakes(4.5), + fetchGdacsEvents(), + fetchUsWeatherAlerts(), + fetchDefconLevel(), + ]); + + // Pull top news from global RSS feeds (priority weight 1-2) + const topFeeds = RSS_FEEDS.filter((f) => f.enabled && f.weight <= 2); + const topNews = await fetchFeeds(topFeeds); + const seen = new Set(); + const dedupedNews = topNews + .filter((n) => { if (seen.has(n.url)) return false; seen.add(n.url); return true; }) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + .slice(0, 20); + + res.json({ + timestamp: new Date().toISOString(), + summary: { + defcon: defcon?.level ?? null, + defconDescription: defcon?.description ?? null, + flights: flightSummary.totalFlights, + regionsTracked: flightSummary.regionsQueried, + earthquakes45: quakes.length, + gdacsEvents: gdacs.length, + usWeatherAlerts: usWeather.length, + newsHeadlines: dedupedNews.length, + }, + defcon, + flights: flightSummary, + earthquakes: quakes.slice(0, 10), + disasters: gdacs.slice(0, 10), + weather: usWeather.slice(0, 10), + news: dedupedNews, + }); +}); + +app.get('/snapshot', async (req, res) => { + // One-call daily brief. Cheap version of /osint. + const [flightSummary, quakes, defcon] = await Promise.all([ + getFlightSummary(getDefaultFlightRegions()), + fetchEarthquakes(5.0), + fetchDefconLevel(), + ]); + const topFeeds = RSS_FEEDS.filter((f) => f.enabled && f.weight <= 2); + const topNews = await fetchFeeds(topFeeds); + const seen = new Set(); + const dedupedNews = topNews + .filter((n) => { if (seen.has(n.url)) return false; seen.add(n.url); return true; }) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + .slice(0, 8); + + res.json({ + timestamp: new Date().toISOString(), + version: '2.5.0-lobster', + defcon, + flights: { + total: flightSummary.totalFlights, + byRegion: flightSummary.regions.map((r) => ({ name: r.name, flights: r.flights })), + }, + earthquakes: { total: quakes.length, top: quakes.slice(0, 3) }, + news: { total: dedupedNews.length, top: dedupedNews }, + }); +}); + +// ============================================================ +// OSIRIS-derived endpoints (Phase 1: intel) +// ============================================================ + +// GET /sanctions?q= — OFAC SDN + OpenSanctions person/org/vessel search +app.get('/sanctions', async (req, res) => { + const q = String(req.query.q || '').trim(); + if (!q) { + return res.status(400).json({ error: 'Missing required query param: q', example: '/sanctions?q=Evgeniy' }); + } + const results = await searchSanctions(q); + res.json({ + query: q, + count: results.length, + timestamp: new Date().toISOString(), + results, + }); +}); + +// GET /crypto/btc/:address — BTC wallet trace via blockstream.info +app.get('/crypto/btc/:address', async (req, res) => { + const info = await traceBtcAddress(req.params.address); + if (!info) { + return res.status(400).json({ error: 'Invalid BTC address', address: req.params.address }); + } + (info as any).ofac_sanctioned = checkOfac(req.params.address); + res.json(info); +}); + +// GET /crypto/eth/:address — ETH wallet trace via Blockscout +app.get('/crypto/eth/:address', async (req, res) => { + const info = await traceEthAddress(req.params.address); + if (!info) { + return res.status(400).json({ error: 'Invalid ETH address', address: req.params.address }); + } + (info as any).ofac_sanctioned = checkOfac(req.params.address); + res.json(info); +}); + +// GET /fires?hours=24®ion=middle_east — NASA FIRMS hotspots +app.get('/fires', async (req, res) => { + const hours = Math.min(Math.max(parseInt(String(req.query.hours || '24'), 10) || 24, 1), 168); + const region = req.query.region ? String(req.query.region) : undefined; + const fires = await fetchFireHotspots(hours, region); + res.json({ + window_hours: hours, + region: region || 'world', + count: fires.length, + timestamp: new Date().toISOString(), + hotspots: fires, + }); +}); + +// IMPORTANT: /cve/recent must come BEFORE /cve/:id, otherwise Express matches "recent" as the :id. +app.get('/cve/recent', async (req, res) => { + const days = parseInt(String(req.query.days || '7'), 10) || 7; + const minCvss = req.query.min_cvss ? parseFloat(String(req.query.min_cvss)) : undefined; + const cves = await fetchRecentCves(days, minCvss); + res.json({ + window_days: days, + min_cvss: minCvss, + count: cves.length, + timestamp: new Date().toISOString(), + cves, + }); +}); + +// GET /cve/:id — single CVE lookup (e.g. CVE-2024-12345) +app.get('/cve/:id', async (req, res) => { + const cve = await fetchCve(req.params.id); + if (!cve) { + return res.status(404).json({ error: 'CVE not found or invalid format', id: req.params.id }); + } + res.json(cve); +}); + +// GET /whois/:domain — RDAP/WHOIS lookup +app.get('/whois/:domain', async (req, res) => { + const w = await whoisLookup(req.params.domain); + if (!w) { + return res.status(400).json({ error: 'Invalid domain', domain: req.params.domain }); + } + // Auto-flag any registrant/country entities against OFAC cache + const entities = (w.entities ?? []).map((e: any) => ({ + ...e, + ofac_sanctioned: checkOfac(e.name ?? ''), + })); + res.json({ ...w, entities, domain_sanctioned: checkOfac(req.params.domain) }); +}); + +// GET /dns/:domain — DNS records (A, AAAA, MX, TXT, NS, CNAME) +app.get('/dns/:domain', async (req, res) => { + const d = await dnsLookup(req.params.domain); + if (!d) { + return res.status(400).json({ error: 'Invalid domain', domain: req.params.domain }); + } + res.json(d); +}); + +// GET /telegram/:channel — recent messages from public channel +// (e.g. /telegram/durov, /telegram/reuters) +app.get('/telegram/:channel', async (req, res) => { + const limit = Math.min(Math.max(parseInt(String(req.query.limit || '10'), 10) || 10, 1), 30); + const msgs = await fetchTelegramChannel(req.params.channel, limit); + if (!msgs || msgs.length === 0) { + return res.json({ + channel: req.params.channel, + count: 0, + messages: [], + note: 'No public messages found. Channel may be private or handle invalid.', + }); + } + res.json({ + channel: req.params.channel, + count: msgs.length, + timestamp: new Date().toISOString(), + messages: msgs, + }); +}); + +// GET /space-weather — NOAA SWPC Kp index, solar flares, alerts +// (FREE — no API key required) +app.get('/space-weather', async (_req, res) => { + const data = await fetchSpaceWeather(); + if (!data) return res.status(503).json({ error: 'Space weather feed unavailable' }); + res.json(data); +}); + +// GET /sentinel — Sentinel-1/2 satellite imagery search +// Query: ?lat=&lng=&radius=&days=&platform=sentinel-2-l2a|sentinel-1-grd +app.get('/sentinel', async (req, res) => { + const lat = parseFloat(String(req.query.lat ?? '')); + const lng = parseFloat(String(req.query.lng ?? '')); + if (isNaN(lat) || isNaN(lng)) { + return res.status(400).json({ error: 'lat and lng query params required' }); + } + const radius = parseFloat(String(req.query.radius ?? '2')); + const days = parseInt(String(req.query.days ?? '30'), 10); + const platform = (String(req.query.platform ?? 'sentinel-2-l2a')) as 'sentinel-1-grd' | 'sentinel-2-l2a'; + const scenes = await searchSentinelScenes(lat, lng, radius, days, platform); + res.json({ + query: { lat, lng, radius, days, platform }, + count: scenes.length, + scenes, + timestamp: new Date().toISOString(), + }); +}); + +// GET /satellites — TLE catalog from Celestrak +// Query: ?category=stations|weather|starlink|amateur|gps-ops|...&limit=50 +app.get('/satellites', async (req, res) => { + const category = String(req.query.category ?? 'stations'); + const limit = Math.min(Math.max(parseInt(String(req.query.limit ?? '50'), 10) || 50, 1), 200); + const sats = await fetchSatelliteCatalog(category, limit); + res.json({ + category, + count: sats.length, + satellites: sats, + timestamp: new Date().toISOString(), + }); +}); + +// GET /cyber-threats — CISA Known Exploited Vulnerabilities +// Query: ?days=30 +app.get('/cyber-threats', async (req, res) => { + const days = Math.min(Math.max(parseInt(String(req.query.days ?? '30'), 10) || 30, 1), 365); + const data = await fetchCyberThreats(days); + if (!data) return res.status(503).json({ error: 'Cyber threat feed unavailable' }); + res.json(data); +}); + +// GET /geo — IP geolocation (3-provider cascade) +// Query: ?ip=8.8.8.8 (omit for caller IP) +app.get('/geo', async (req, res) => { + const ip = String(req.query.ip ?? ''); + const data = await geoLocate(ip); + if (!data) return res.status(503).json({ error: 'Geolocation lookup failed across all providers' }); + (data as any).ofac_sanctioned = checkOfac(ip || data.ip); + res.json(data); +}); + +// GET /air-quality — Open-Meteo current AQI for 22 major global cities +// (FREE — no API key required) +app.get('/air-quality', async (_req, res) => { + const stations = await fetchAirQuality(); + res.json({ + count: stations.length, + cities_with_poor_air: stations.filter(s => (s.us_aqi ?? 0) > 100).length, + stations, + timestamp: new Date().toISOString(), + }); +}); + +// GET /ssl/:host — SSL/TLS certificate inspector +// Query: ?port=443 (default 443) +app.get('/ssl/:host', async (req, res) => { + const port = parseInt(String(req.query.port ?? '443'), 10) || 443; + const report = await inspectTlsCertificate(req.params.host, port); + if (!report) return res.status(400).json({ error: 'Invalid hostname' }); + res.json(report); +}); + +// GET /ofac/check?q= — single-value OFAC cross-check +// (Requires OPENSANCTIONS_API_KEY and a populated cache. Returns null otherwise.) +app.get('/ofac/check', async (req, res) => { + const q = String(req.query.q ?? '').trim(); + if (!q) return res.status(400).json({ error: 'q parameter required' }); + const sanctioned = checkOfac(q); + res.json({ + query: q, + ofac_sanctioned: sanctioned, + ofac_cache_size: ofacCacheSize(), + note: sanctioned === null ? 'OFAC check unavailable (no API key or empty cache)' : 'auto-flag from cached sanctions data', + }); +}); + +// POST /ofac/refresh — reload OFAC cache from OpenSanctions +app.post('/ofac/refresh', async (_req, res) => { + const added = await refreshOfacCache(); + res.json({ added, cache_size: ofacCacheSize(), timestamp: new Date().toISOString() }); +}); + +// GET /scan — Port scanner (requires PORT_SCAN_ENABLED=true in env) +// Query: ?host=&ports=22,80,443 (comma-separated; omit for default 31-port scan) +app.get('/scan', async (req, res) => { + const host = String(req.query.host ?? '').trim(); + if (!host) return res.status(400).json({ error: 'host query param required' }); + let ports: number[] | undefined; + if (req.query.ports) { + ports = String(req.query.ports) + .split(',') + .map(p => parseInt(p.trim(), 10)) + .filter(p => !isNaN(p) && p > 0 && p < 65536); + } + const result = await scanPorts(host, ports); + if (!result) return res.status(400).json({ error: 'Invalid host' }); + res.json(result); +}); + +app.listen(PORT, () => { + console.log(`ClawdWatch Lobster Edition v2.4 running on port ${PORT}`); + console.log(` regions: ${ALL_REGIONS.length}`); + console.log(` rss: ${RSS_FEEDS.filter((f) => f.enabled).length} feeds enabled`); + console.log(` defcon: GET /defcon for current DEFCON level`); + console.log(` osiris: /sanctions /crypto/* /fires /cve/* /whois/* /dns/* /telegram/*`); + console.log(` /space-weather /sentinel /satellites /cyber-threats /geo /air-quality`); + console.log(` recon: /ssl/:host /news/live /ofac/check /ofac/refresh /scan`); + console.log(` scan: ${process.env.PORT_SCAN_ENABLED === 'true' ? 'ENABLED' : 'disabled (set PORT_SCAN_ENABLED=true)'}`); + console.log(` ofac: ${process.env.OPENSANCTIONS_API_KEY ? 'API key set (cache will populate on /ofac/refresh)' : 'no API key (auto-flag checks return null)'}`); +}); diff --git a/src/regions.ts b/src/regions.ts new file mode 100644 index 0000000..2183fd3 --- /dev/null +++ b/src/regions.ts @@ -0,0 +1,147 @@ +/** + * Global region registry. Each region has lat/lon bounds for OpenSky queries, + * a human-readable name, region grouping (used for news filtering), and aliases. + * + * This is THE source of truth for region definitions. http.ts wires these up. + */ + +export interface Bounds { + latMin: number; + latMax: number; + lonMin: number; + lonMax: number; +} + +export interface RegionDefinition { + id: string; + name: string; + description: string; + flightBounds: Bounds; + /** grouping tag for news filtering */ + group: 'middle_east' | 'europe' | 'americas' | 'asia' | 'africa' | 'oceania' | 'world'; + aliases: string[]; + priority: number; // lower = higher priority +} + +// === Global tier (the whole world) === +const GLOBAL: RegionDefinition = { + id: 'global', + name: 'Global', + description: 'Whole-world OpenSky query.', + flightBounds: { latMin: -60, latMax: 85, lonMin: -180, lonMax: 180 }, + group: 'world', + aliases: ['world', 'planet', 'earth'], + priority: 99, +}; + +// === Continent-level tiers === +const CONTINENTS: RegionDefinition[] = [ + { id: 'europe', name: 'Europe', description: 'Continental Europe, Iceland to Urals.', flightBounds: { latMin: 35, latMax: 72, lonMin: -10, lonMax: 60 }, group: 'europe', aliases: ['eu', 'european'], priority: 2 }, + { id: 'north_america',name: 'North America', description: 'USA, Canada, Mexico, Caribbean.', flightBounds: { latMin: 5, latMax: 85, lonMin: -170, lonMax: -50 }, group: 'americas', aliases: ['na', 'namerica'], priority: 2 }, + { id: 'south_america',name: 'South America', description: 'South of Panama.', flightBounds: { latMin: -60, latMax: 15, lonMin: -85, lonMax: -30 }, group: 'americas', aliases: ['sa', 'samerica', 'latam'], priority: 2 }, + { id: 'africa', name: 'Africa', description: 'All of continental Africa + Madagascar.', flightBounds: { latMin: -35, latMax: 37, lonMin: -20, lonMax: 55 }, group: 'africa', aliases: ['af'], priority: 2 }, + { id: 'asia', name: 'Asia', description: 'Continental Asia, Russia east, Japan, Indonesia.',flightBounds: { latMin: -10, latMax: 75, lonMin: 60, lonMax: 180 }, group: 'asia', aliases: ['apac', 'asia_pacific'], priority: 2 }, + { id: 'oceania', name: 'Oceania', description: 'Australia, NZ, Pacific Islands.', flightBounds: { latMin: -50, latMax: 0, lonMin: 110, lonMax: 180 }, group: 'oceania', aliases: ['aus', 'pac'], priority: 3 }, +]; + +// === Sub-region tiers (better focus for OSINT) === +const SUBREGIONS: RegionDefinition[] = [ + // Middle East / Gulf + { id: 'middle_east', name: 'Middle East', description: 'Gulf, Levant, Red Sea, Iran.', flightBounds: { latMin: 12, latMax: 42, lonMin: 25, lonMax: 65 }, group: 'middle_east', aliases: ['me', 'gulf', 'levant'], priority: 1 }, + { id: 'iran', name: 'Iran', description: 'Iran airspace.', flightBounds: { latMin: 25, latMax: 40, lonMin: 44, lonMax: 63 }, group: 'middle_east', aliases: [], priority: 1 }, + { id: 'israel', name: 'Israel/Palestine', description: 'Israel + OPT.', flightBounds: { latMin: 29, latMax: 34, lonMin: 34, lonMax: 36 }, group: 'middle_east', aliases: [], priority: 1 }, + { id: 'lebanon', name: 'Lebanon', description: 'Lebanon + coastal Syria.', flightBounds: { latMin: 33, latMax: 36, lonMin: 35, lonMax: 37 }, group: 'middle_east', aliases: [], priority: 1 }, + { id: 'syria', name: 'Syria', description: 'Syria.', flightBounds: { latMin: 32, latMax: 38, lonMin: 35, lonMax: 42 }, group: 'middle_east', aliases: [], priority: 1 }, + { id: 'iraq', name: 'Iraq', description: 'Iraq.', flightBounds: { latMin: 29, latMax: 38, lonMin: 38, lonMax: 49 }, group: 'middle_east', aliases: [], priority: 1 }, + { id: 'yemen', name: 'Yemen', description: 'Yemen + Bab al-Mandab.', flightBounds: { latMin: 12, latMax: 20, lonMin: 42, lonMax: 55 }, group: 'middle_east', aliases: [], priority: 1 }, + { id: 'saudi_arabia', name: 'Saudi Arabia', description: 'KSA.', flightBounds: { latMin: 16, latMax: 33, lonMin: 34, lonMax: 56 }, group: 'middle_east', aliases: ['ksa'], priority: 2 }, + { id: 'uae', name: 'UAE', description: 'United Arab Emirates.', flightBounds: { latMin: 22, latMax: 27, lonMin: 51, lonMax: 57 }, group: 'middle_east', aliases: [], priority: 2 }, + { id: 'qatar', name: 'Qatar', description: 'Qatar + Bahrain.', flightBounds: { latMin: 24, latMax: 27, lonMin: 50, lonMax: 52 }, group: 'middle_east', aliases: [], priority: 2 }, + { id: 'kuwait', name: 'Kuwait', description: 'Kuwait.', flightBounds: { latMin: 28, latMax: 31, lonMin: 46, lonMax: 49 }, group: 'middle_east', aliases: [], priority: 2 }, + { id: 'oman', name: 'Oman', description: 'Oman + Gulf of Oman.', flightBounds: { latMin: 16, latMax: 27, lonMin: 51, lonMax: 60 }, group: 'middle_east', aliases: [], priority: 2 }, + { id: 'turkey', name: 'Turkey', description: 'Turkey + Bosphorus.', flightBounds: { latMin: 36, latMax: 42, lonMin: 26, lonMax: 45 }, group: 'middle_east', aliases: ['tr', 'turkiye'], priority: 2 }, + + // Europe subregions + { id: 'eastern_europe', name: 'Eastern Europe', description: 'Ukraine, Belarus, Russia west, Baltics.', flightBounds: { latMin: 44, latMax: 70, lonMin: 15, lonMax: 60 }, group: 'europe', aliases: ['ee', 'black_sea', 'baltics'], priority: 1 }, + { id: 'british_isles', name: 'British Isles', description: 'UK + Ireland.', flightBounds: { latMin: 49, latMax: 61, lonMin: -11, lonMax: 2 }, group: 'europe', aliases: ['uk', 'britain'], priority: 3 }, + { id: 'mediterranean', name: 'Mediterranean', description: 'Med Sea coast + North Africa rim.', flightBounds: { latMin: 30, latMax: 47, lonMin: -10, lonMax: 38 }, group: 'europe', aliases: ['med'], priority: 3 }, + { id: 'scandinavia', name: 'Scandinavia', description: 'Norway, Sweden, Finland.', flightBounds: { latMin: 55, latMax: 72, lonMin: 4, lonMax: 32 }, group: 'europe', aliases: ['nordics'], priority: 3 }, + // Europe country-level subregions (added v2.5) + { id: 'poland', name: 'Poland', description: 'Poland + Suwalki Gap.', flightBounds: { latMin: 49, latMax: 55, lonMin: 14, lonMax: 24 }, group: 'europe', aliases: ['pl'], priority: 2 }, + { id: 'greece', name: 'Greece', description: 'Greece + Aegean islands.', flightBounds: { latMin: 34, latMax: 42, lonMin: 19, lonMax: 28 }, group: 'europe', aliases: ['gr', 'hellas'], priority: 2 }, + { id: 'spain', name: 'Spain', description: 'Iberian Peninsula + Ceuta + Melilla + Canary airspace prox.', flightBounds: { latMin: 36, latMax: 44, lonMin: -10, lonMax: 4 }, group: 'europe', aliases: ['es'], priority: 3 }, + + // Americas + { id: 'usa', name: 'USA', description: 'Continental US.', flightBounds: { latMin: 24, latMax: 50, lonMin: -125, lonMax: -66 }, group: 'americas', aliases: ['us', 'united_states', 'conus'], priority: 2 }, + { id: 'canada', name: 'Canada', description: 'Canada + Arctic.', flightBounds: { latMin: 42, latMax: 84, lonMin: -141, lonMax: -52 }, group: 'americas', aliases: [], priority: 3 }, + { id: 'mexico', name: 'Mexico', description: 'Mexico + Central America.', flightBounds: { latMin: 8, latMax: 33, lonMin: -118, lonMax: -86 }, group: 'americas', aliases: [], priority: 3 }, + { id: 'caribbean', name: 'Caribbean', description: 'Caribbean islands.', flightBounds: { latMin: 8, latMax: 28, lonMin: -90, lonMax: -60 }, group: 'americas', aliases: [], priority: 3 }, + { id: 'brazil', name: 'Brazil', description: 'Brazil.', flightBounds: { latMin: -34, latMax: 6, lonMin: -74, lonMax: -34 }, group: 'americas', aliases: [], priority: 3 }, + { id: 'argentina', name: 'Argentina', description: 'Argentina + Chile + Uruguay.', flightBounds: { latMin: -56, latMax: -20, lonMin: -76, lonMax: -53 }, group: 'americas', aliases: [], priority: 3 }, + // Americas country-level subregions (added v2.5) + { id: 'venezuela', name: 'Venezuela', description: 'Venezuela + Caribbean coast.', flightBounds: { latMin: 0, latMax: 13, lonMin: -73, lonMax: -60 }, group: 'americas', aliases: ['ve'], priority: 2 }, + { id: 'colombia', name: 'Colombia', description: 'Colombia + FARC territory.', flightBounds: { latMin: -5, latMax: 13, lonMin: -79, lonMax: -67 }, group: 'americas', aliases: ['co'], priority: 2 }, + { id: 'cuba', name: 'Cuba', description: 'Cuba + Guantanamo airspace.', flightBounds: { latMin: 19, latMax: 24, lonMin: -85, lonMax: -74 }, group: 'americas', aliases: [], priority: 3 }, + + // Asia + { id: 'central_asia', name: 'Central Asia', description: '-stans.', flightBounds: { latMin: 30, latMax: 56, lonMin: 46, lonMax: 90 }, group: 'asia', aliases: ['stans'], priority: 3 }, + { id: 'south_asia', name: 'South Asia', description: 'India, Pakistan, Bangladesh, Sri Lanka.', flightBounds: { latMin: 5, latMax: 40, lonMin: 60, lonMax: 100 }, group: 'asia', aliases: [], priority: 3 }, + { id: 'east_asia', name: 'East Asia', description: 'China, Japan, Korea, Taiwan.', flightBounds: { latMin: 15, latMax: 55, lonMin: 95, lonMax: 155 }, group: 'asia', aliases: [], priority: 3 }, + { id: 'southeast_asia',name: 'Southeast Asia',description: 'ASEAN.', flightBounds: { latMin: -15, latMax: 30, lonMin: 92, lonMax: 145 }, group: 'asia', aliases: ['asean'], priority: 3 }, + { id: 'china', name: 'China', description: 'PRC + Hainan + Taiwan.', flightBounds: { latMin: 18, latMax: 54, lonMin: 73, lonMax: 135 }, group: 'asia', aliases: ['prc'], priority: 3 }, + { id: 'japan', name: 'Japan', description: 'Japan archipelago.', flightBounds: { latMin: 24, latMax: 46, lonMin: 128, lonMax: 146 }, group: 'asia', aliases: [], priority: 3 }, + { id: 'korea', name: 'Korea', description: 'DPRK + ROK.', flightBounds: { latMin: 33, latMax: 43, lonMin: 124, lonMax: 132 }, group: 'asia', aliases: [], priority: 3 }, + { id: 'india', name: 'India', description: 'India + adjacent waters.', flightBounds: { latMin: 6, latMax: 38, lonMin: 68, lonMax: 98 }, group: 'asia', aliases: [], priority: 3 }, + // Asia country-level subregions (added v2.5) + { id: 'vietnam', name: 'Vietnam', description: 'Vietnam + South China Sea coast.', flightBounds: { latMin: 8, latMax: 24, lonMin: 102, lonMax: 110 }, group: 'asia', aliases: ['vn'], priority: 2 }, + { id: 'indonesia', name: 'Indonesia', description: 'Indonesia archipelago.', flightBounds: { latMin: -11, latMax: 6, lonMin: 95, lonMax: 141 }, group: 'asia', aliases: ['id'], priority: 2 }, + { id: 'philippines', name: 'Philippines', description: 'Philippines archipelago + WPS.', flightBounds: { latMin: 5, latMax: 21, lonMin: 117, lonMax: 127 }, group: 'asia', aliases: ['ph', 'phil'], priority: 2 }, + { id: 'myanmar', name: 'Myanmar', description: 'Myanmar + Rakhine + Shan.', flightBounds: { latMin: 10, latMax: 28, lonMin: 92, lonMax: 101 }, group: 'asia', aliases: ['mm', 'burma'], priority: 2 }, + + // Africa + { id: 'north_africa', name: 'North Africa', description: 'Maghreb + Egypt + Sudan.', flightBounds: { latMin: 10, latMax: 38, lonMin: -17, lonMax: 38 }, group: 'africa', aliases: ['nafrica', 'maghreb'], priority: 3 }, + { id: 'west_africa', name: 'West Africa', description: 'ECOWAS region.', flightBounds: { latMin: -5, latMax: 22, lonMin: -18, lonMax: 16 }, group: 'africa', aliases: [], priority: 3 }, + { id: 'east_africa', name: 'East Africa', description: 'Horn of Africa.', flightBounds: { latMin: -15, latMax: 18, lonMin: 30, lonMax: 52 }, group: 'africa', aliases: ['horn'], priority: 3 }, + { id: 'southern_africa',name: 'Southern Africa',description: 'S of the equator.', flightBounds: { latMin: -35, latMax: 0, lonMin: 11, lonMax: 41 }, group: 'africa', aliases: [], priority: 3 }, + // Africa country-level subregions (added v2.5) + { id: 'sudan', name: 'Sudan', description: 'Sudan + disputed Abyei.', flightBounds: { latMin: 8, latMax: 23, lonMin: 21, lonMax: 39 }, group: 'africa', aliases: ['sd'], priority: 1 }, + { id: 'nigeria', name: 'Nigeria', description: 'Nigeria + Lake Chad basin.', flightBounds: { latMin: 4, latMax: 14, lonMin: 3, lonMax: 15 }, group: 'africa', aliases: ['ng'], priority: 2 }, + { id: 'ethiopia', name: 'Ethiopia', description: 'Ethiopia + Tigray.', flightBounds: { latMin: 3, latMax: 15, lonMin: 32, lonMax: 48 }, group: 'africa', aliases: ['et'], priority: 2 }, + { id: 'drc', name: 'DR Congo', description: 'DRC + Kivu provinces.', flightBounds: { latMin: -13,latMax: 6, lonMin: 12, lonMax: 32 }, group: 'africa', aliases: ['dr_congo', 'congo', 'cd'], priority: 2 }, + { id: 'kenya', name: 'Kenya', description: 'Kenya + Somalia border.', flightBounds: { latMin: -5, latMax: 5, lonMin: 33, lonMax: 42 }, group: 'africa', aliases: ['ke'], priority: 3 }, + { id: 'tanzania', name: 'Tanzania', description: 'Tanzania + Zanzibar.', flightBounds: { latMin: -12,latMax: -1, lonMin: 29, lonMax: 41 }, group: 'africa', aliases: ['tz'], priority: 3 }, + { id: 'south_africa', name: 'South Africa', description: 'Republic of South Africa + Lesotho + eSwatini.', flightBounds: { latMin: -35, latMax: -22, lonMin: 16, lonMax: 33 }, group: 'africa', aliases: ['sa', 'rsa', 'za'], priority: 3 }, + + // Oceania + { id: 'australia', name: 'Australia', description: 'Aussie continent + Tasmania.', flightBounds: { latMin: -45, latMax: -10, lonMin: 112, lonMax: 154 }, group: 'oceania', aliases: ['aus'], priority: 3 }, + { id: 'new_zealand', name: 'New Zealand', description: 'NZ.', flightBounds: { latMin: -48, latMax: -34, lonMin: 165, lonMax: 179 }, group: 'oceania', aliases: ['nz'], priority: 3 }, + { id: 'papua_new_guinea', name: 'Papua New Guinea', description: 'PNG archipelago + Bougainville.', flightBounds: { latMin: -12, latMax: 0, lonMin: 140, lonMax: 156 }, group: 'oceania', aliases: ['png'], priority: 3 }, +]; + +export const ALL_REGIONS: RegionDefinition[] = [GLOBAL, ...CONTINENTS, ...SUBREGIONS]; + +/** Find a region by id, alias, or fuzzy match. */ +export function findRegion(query: string): RegionDefinition | undefined { + if (!query) return undefined; + const q = query.trim().toLowerCase().replace(/[\s-]+/g, '_'); + for (const r of ALL_REGIONS) { + if (r.id === q) return r; + if (r.aliases.includes(q)) return r; + } + return undefined; +} + +export function getRegionById(id: string): RegionDefinition | undefined { + return ALL_REGIONS.find((r) => r.id === id); +} + +/** High-priority regions that should be queried on the global /flights default */ +export function getDefaultFlightRegions(): RegionDefinition[] { + return ALL_REGIONS + .filter((r) => r.priority <= 2) + .sort((a, b) => a.priority - b.priority); +} + +export function getAllRegionGroups(): string[] { + return Array.from(new Set(ALL_REGIONS.map((r) => r.group))); +} diff --git a/src/sources/intel.ts b/src/sources/intel.ts new file mode 100644 index 0000000..4efa2cf --- /dev/null +++ b/src/sources/intel.ts @@ -0,0 +1,298 @@ +import axios from 'axios'; + +/** + * Global intel sources. These are all public, free, machine-readable endpoints + * (no scraping). They give us situational awareness well beyond news headlines. + */ + +export interface Earthquake { + id: string; + magnitude: number; + place: string; + time: string; // ISO + url: string; + tsunami: boolean; + depth_km: number; + coords: [number, number]; + felt?: number; + alert?: 'green' | 'yellow' | 'orange' | 'red'; +} + +export interface WeatherAlert { + id: string; + event: string; // e.g. "Tornado Warning" + headline: string; + area: string; + severity: 'Extreme' | 'Severe' | 'Moderate' | 'Minor' | 'Unknown'; + certainty: string; + urgency: string; + effective: string; + expires: string; + description: string; + instruction?: string; + states?: string[]; +} + +const cache: Map = new Map(); +const TTL = 5 * 60 * 1000; // 5 min for these + +async function cached(key: string, fn: () => Promise, ttl = TTL): Promise { + const hit = cache.get(key); + if (hit && Date.now() - hit.ts < ttl) return hit.data as T; + try { + const data = await fn(); + cache.set(key, { data, ts: Date.now() }); + return data; + } catch (e: any) { + console.error(`[intel:${key}] error: ${e.message}`); + return null; + } +} + +/** + * USGS All-Earthquakes feed, last 24h, magnitude 2.5+ worldwide. + * Free, no auth, GeoJSON. + * https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson + */ +export async function fetchEarthquakes(minMag = 4.0): Promise { + const data = await cached('usgs_quakes_day', async () => { + const res = await axios.get('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson', { + timeout: 15_000, + headers: { 'User-Agent': 'ClawdWatch-Lobster/1.0' }, + }); + return res.data; + }); + if (!data) return []; + + const out: Earthquake[] = []; + for (const f of data.features || []) { + const m = f.properties?.mag ?? 0; + if (m < minMag) continue; + const [lon, lat, depth] = f.geometry?.coordinates || [0, 0, 0]; + out.push({ + id: f.id, + magnitude: m, + place: f.properties?.place || 'Unknown', + time: new Date(f.properties?.time).toISOString(), + url: f.properties?.detail || `https://earthquake.usgs.gov/earthquakes/eventpage/${f.id}`, + tsunami: !!f.properties?.tsunami, + depth_km: depth, + coords: [lat, lon], + felt: f.properties?.felt, + alert: f.properties?.alert, + }); + } + // Newest first, then biggest + out.sort((a, b) => b.time.localeCompare(a.time)); + return out; +} + +/** + * NWS Active Alerts — entire US. Free public API, no key needed. + * https://api.weather.gov/alerts/active + */ +export async function fetchUsWeatherAlerts(): Promise { + const data = await cached('nws_alerts', async () => { + const res = await axios.get('https://api.weather.gov/alerts/active', { + timeout: 15_000, + headers: { 'User-Agent': 'ClawdWatch-Lobster/1.0 (contact: ops@localhost)' }, + }); + return res.data; + }); + if (!data) return []; + + return (data.features || []).map((f: any) => { + const p = f.properties || {}; + return { + id: f.id, + event: p.event, + headline: p.headline, + area: p.areaDesc, + severity: p.severity, + certainty: p.certainty, + urgency: p.urgency, + effective: p.effective, + expires: p.expires, + description: p.description, + instruction: p.instruction, + states: (p.geocode?.sameCodes || []).filter((c: string) => c.startsWith('US')).map((c: string) => c.replace(/^US/, '')), + } as WeatherAlert; + }); +} + +/** + * Open-Meteo: global current weather, no key needed. + * Used for "current conditions at point" lookups. + */ +export interface CurrentWeather { + temperature: number; + windspeed: number; + weathercode: number; + time: string; +} + +export async function fetchCurrentWeather(lat: number, lon: number): Promise { + const data = await cached(`weather:${lat.toFixed(2)},${lon.toFixed(2)}`, async () => { + const res = await axios.get('https://api.open-meteo.com/v1/forecast', { + params: { + latitude: lat, + longitude: lon, + current_weather: true, + }, + timeout: 10_000, + }); + return res.data?.current_weather; + }, 15 * 60 * 1000); + return data || null; +} + +/** + * GDACS — Global Disaster Alert and Coordination System. + * Aggregates earthquakes, tsunamis, volcanoes, cyclones, floods, wildfires globally. + * Free, public, RSS + JSON. + * https://gdacs.org/ + */ +export interface GdacsEvent { + eventid: string; + eventtype: 'EQ' | 'TC' | 'TS' | 'VO' | 'WF' | 'FL' | 'DR'; + name: string; + description?: string; + fromdate: string; + todate: string; + alertlevel: 'Green' | 'Orange' | 'Red'; + severity?: number; + country?: string; + iso3?: string; + lat: number; + lon: number; + url: string; +} + +export async function fetchGdacsEvents(): Promise { + const data = await cached('gdacs_events', async () => { + const res = await axios.get('https://www.gdacs.org/gdacsapi/api/events/geteventlist/SEARCH?fromDate=2024-01-01&limit=100', { + timeout: 18_000, + headers: { 'User-Agent': 'ClawdWatch-Lobster/1.0' }, + }); + return res.data; + }, 30 * 60 * 1000); + if (!data) return []; + return (data.features || data.events || []) as GdacsEvent[]; +} + +/** + * DEFCON level from defconlevel.com. The site's "current level" page has a + * clear

followed by a hero block with the current level in . + * We parse the first integer in that hero block. + * + * DEFCON scale: 5 (lowest tension) ... 1 (highest tension, imminent war). + * The "current" is the editor's reading, not an official US military status. + */ +export interface DefconStatus { + level: 1 | 2 | 3 | 4 | 5; + description: string; + /** 0-100: DEFCON 1 = 100 (max threat), DEFCON 5 = 0 (no threat) */ + threatScore: number; + source: string; + url: string; + fetchedAt: string; +} + +/** + * DEFCON 5 — Normal peacetime readiness. + * Minimum alert state. Standard operations continue with routine monitoring. + * No imminent threat; general awareness maintained. + */ +export const DEFCON_SCORE_5 = 0; + +/** + * DEFCON 4 — Above normal readiness. + * Possible terrorist activity or regional conflict. Increased intelligence + * monitoring. Heightened border and infrastructure security. Cyber threat + * activity elevated. All-source intel collection intensified. + */ +export const DEFCON_SCORE_4 = 25; + +/** + * DEFCON 3 — Armed Forces ready to mobilize in 15 minutes. + * Terrorist attack possible. Significant cyber intrusion campaigns active. + * Air defense forces on increased alert. Civilian aviation subject to + * enhanced screening. Public advised to maintain awareness. + */ +export const DEFCON_SCORE_3 = 50; + +/** + * DEFCON 2 — Next step to nuclear war; armed forces fully mobilized. + * Direct military threat. Terrorist attack likely. Critical infrastructure + * at severe risk. Strategic forces on standby. Potential for conventional + * or nuclear exchange elevated. + */ +export const DEFCON_SCORE_2 = 75; + +/** + * DEFCON 1 — Maximum readiness; nuclear war imminent or in progress. + * Maximum alert. All military assets mobilized. National Command Authority + * online. Civilian defense preparations underway. Immediate shelter protocols + * may be ordered. This is the highest peacetime alert ever recorded. + */ +export const DEFCON_SCORE_1 = 100; + +const DEFCON_SCORES: Record<1|2|3|4|5, number> = { + 1: DEFCON_SCORE_1, + 2: DEFCON_SCORE_2, + 3: DEFCON_SCORE_3, + 4: DEFCON_SCORE_4, + 5: DEFCON_SCORE_5, +}; + +/** + * DEFCON Level → Threat Score (0-100). + * Score is linear: DEFCON 1 = 100 (max threat), DEFCON 5 = 0 (no threat). + */ +export function defconScore(level: 1|2|3|4|5): number { + return DEFCON_SCORES[level] ?? 0; +} + +const DEFCON_DESCRIPTIONS: Record<1|2|3|4|5, string> = { + 5: 'DEFCON 5 — Normal peacetime readiness. Standard monitoring posture. No imminent threat.', + 4: 'DEFCON 4 — Above normal readiness. Possible terrorist activity or regional conflict. Heightened vigilance.', + 3: 'DEFCON 3 — Armed Forces ready to mobilize in 15 minutes. Terrorist attack possible. Air defense alert.', + 2: 'DEFCON 2 — Armed forces mobilized. Direct military threat. Terrorist attack likely. Critical infrastructure at severe risk.', + 1: 'DEFCON 1 — Maximum readiness. Nuclear war imminent or in progress. ALL systems critical.', +}; + +export async function fetchDefconLevel(): Promise { + return cached('defconlevel_current', async () => { + const res = await axios.get('https://www.defconlevel.com/current-level', { + timeout: 15_000, + headers: { 'User-Agent': 'ClawdWatch-Lobster/1.0' }, + validateStatus: () => true, + }); + if (res.status >= 400) { + throw new Error(`defconlevel.com HTTP ${res.status}`); + } + const html: string = res.data; + // Primary pattern:

Current DEFCON Level...

...

DEFCON N + const heroMatch = html.match(/]*>Current DEFCON Level[^<]*<\/h1>[\s\S]{0,3000}?\s*DEFCON\s*([1-5])/i); + let level: 1|2|3|4|5 | null = null; + if (heroMatch) { + level = parseInt(heroMatch[1], 10) as 1|2|3|4|5; + } else { + // Fallback: first DEFCON N in body + const fallback = html.match(/DEFCON\s*([1-5])/); + if (fallback) level = parseInt(fallback[1], 10) as 1|2|3|4|5; + } + if (!level) { + throw new Error('defconlevel.com: could not parse current level'); + } + return { + level, + description: DEFCON_DESCRIPTIONS[level], + threatScore: defconScore(level), + source: 'defconlevel.com', + url: 'https://www.defconlevel.com/current-level', + fetchedAt: new Date().toISOString(), + }; + }, 15 * 60 * 1000); +} + diff --git a/src/sources/osiris.ts b/src/sources/osiris.ts new file mode 100644 index 0000000..fb65763 --- /dev/null +++ b/src/sources/osiris.ts @@ -0,0 +1,1392 @@ +import axios from 'axios'; + +/** + * OSIRIS-derived intel sources. + * + * Inspired by github.com/simplifaisoul/osiris but reimplemented in ClawdWatch's + * Node.js / API-only style. Each source here exposes one or more lookup functions + * that the HTTP layer maps to REST endpoints. + * + * Endpoints exposed (see http.ts route registration): + * GET /sanctions?q= — OFAC SDN + OpenSanctions person/org/vessel + * GET /crypto/btc/:address — BTC address trace (blockstream.info) + * GET /crypto/eth/:address — ETH address trace (Blockscout) + * GET /fires?hours= — NASA FIRMS active fire hotspots + * GET /cve/:id — NVD CVE detail lookup + * GET /cve/recent?days= — recent CVEs from NVD + * GET /whois/:domain — WHOIS lookup + * GET /dns/:domain — DNS records (A/AAAA/MX/TXT/NS) + * GET /telegram/:channel — public Telegram channel recent messages + * GET /space-weather — NOAA SWPC Kp index, solar flares, alerts + * GET /sentinel?lat=&lng= — Sentinel-1/2 satellite imagery search + * GET /satellites?category= — Satellite TLE catalog (Celestrak) + * GET /cyber-threats — CISA Known Exploited Vulnerabilities + * GET /geo?ip= — IP geolocation (3-provider cascade) + * GET /air-quality?limit= — OpenAQ global PM2.5 stations + */ + +// --------------------------------------------------------------------------- +// Cache helpers +// --------------------------------------------------------------------------- + +const cache: Map = new Map(); + +async function cached(key: string, fn: () => Promise, ttlMs: number): Promise { + const hit = cache.get(key); + if (hit && Date.now() - hit.ts < ttlMs) return hit.data as T; + try { + const data = await fn(); + cache.set(key, { data, ts: Date.now() }); + return data; + } catch (e: any) { + console.error(`[osiris:${key}] error: ${e.message}`); + return null; + } +} + +const TIMEOUT = 15_000; + +// --------------------------------------------------------------------------- +// OFAC SDN + OpenSanctions lookup +// --------------------------------------------------------------------------- + +export interface SanctionEntity { + name: string; + type: 'person' | 'organization' | 'vessel' | 'unknown'; + programs: string[]; // e.g. ['SDGT', 'CYBER2'] + aliases: string[]; + remarks?: string; + birth_dates?: string[]; + birth_places?: string[]; + nationalities?: string[]; + addresses?: string[]; + source: 'OFAC' | 'OpenSanctions'; +} + +const SANCTIONS_URL = 'https://api.opensanctions.org/search/default?limit=20'; + +/** + * Search OpenSanctions for a person/org/vessel name. + * OpenSanctions aggregates OFAC SDN + EU CFSP + UN + UK HMT + ~30 other lists. + * Free public API, no key required. + */ +export async function searchSanctions(q: string): Promise { + if (!q || q.trim().length < 2) return []; + + const apiKey = process.env.OPENSANCTIONS_API_KEY; + + return (await cached( + `sanctions:${q.toLowerCase()}`, + async () => { + const url = `https://api.opensanctions.org/search/default?q=${encodeURIComponent(q)}&limit=20`; + const headers: Record = { 'Accept': 'application/json' }; + if (apiKey) headers['Authorization'] = `ApiKey ${apiKey}`; + const r = await axios.get(url, { + timeout: TIMEOUT, + headers, + }); + + const results = r.data?.results || []; + return results.map((row: any): SanctionEntity => { + const props = row.properties || {}; + const caption = (props.caption?.[0] || row.caption || q) as string; + const datasets = (props.datasets || row.datasets || []) as string[]; + const programs = datasets + .filter((d: string) => d.startsWith('us_ofac_sdn')) + .map((d: string) => d.replace(/^us_ofac_sdn_?/, '').toUpperCase()) + .filter((p: string) => p.length > 0); + + return { + name: caption, + type: row.schema?.includes('Person') ? 'person' + : row.schema?.includes('Organization') ? 'organization' + : row.schema?.includes('Vessel') ? 'vessel' + : 'unknown', + programs: programs.length ? programs : (datasets.length ? [datasets[0]] : []), + aliases: (props.alias || []).slice(0, 10), + remarks: (props.notes || []).slice(0, 3).join(' '), + birth_dates: (props.birthDate || []).slice(0, 3), + birth_places: (props.birthPlace || []).slice(0, 3), + nationalities: (props.nationality || []).slice(0, 5), + addresses: (props.address || []).slice(0, 5), + source: datasets.some((d: string) => d.startsWith('us_ofac')) ? 'OFAC' : 'OpenSanctions', + }; + }); + }, + 60 * 60 * 1000, // 1h cache (sanctions data is stable) + )) || []; +} + +// --------------------------------------------------------------------------- +// Crypto wallet tracing — BTC + ETH +// --------------------------------------------------------------------------- + +export interface BtcAddressInfo { + address: string; + chain: 'BTC'; + balance_sats: number; + balance_btc: number; + total_received_sats: number; + total_sent_sats: number; + tx_count: number; + first_seen?: string; // ISO timestamp +} + +export interface EthAddressInfo { + address: string; + chain: 'ETH'; + balance_wei: string; + balance_eth: number; + tx_count: number; + ens_name?: string; + contract_name?: string; + is_contract: boolean; +} + +export async function traceBtcAddress(address: string): Promise { + if (!/^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^bc1[a-z0-9]{6,87}$/.test(address)) return null; + + return cached( + `btc:${address}`, + async () => { + const r = await axios.get(`https://blockstream.info/api/address/${address}`, { timeout: TIMEOUT }); + const stats = r.data; + // chain_stats has funded/spent counts; total_received = chain_stats.funded_txo_sum + const cs = stats.chain_stats || {}; + const ms = stats.mempool_stats || {}; + const totalRecv = (cs.funded_txo_sum || 0) + (ms.funded_txo_sum || 0); + const totalSent = (cs.spent_txo_sum || 0) + (ms.spent_txo_sum || 0); + return { + address, + chain: 'BTC' as const, + balance_sats: (cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0), + balance_btc: ((cs.funded_txo_sum || 0) - (cs.spent_txo_sum || 0)) / 1e8, + total_received_sats: totalRecv, + total_sent_sats: totalSent, + tx_count: (cs.tx_count || 0) + (ms.tx_count || 0), + }; + }, + 5 * 60 * 1000, + ); +} + +export async function traceEthAddress(address: string): Promise { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return null; + + return cached( + `eth:${address.toLowerCase()}`, + async () => { + // Blockscout public API — no key required + const r = await axios.get( + `https://eth.blockscout.com/api/v2/addresses/${address}`, + { timeout: TIMEOUT } + ); + const d = r.data || {}; + return { + address, + chain: 'ETH' as const, + balance_wei: d.balance || '0', + balance_eth: (parseFloat(d.balance || '0') / 1e18), + tx_count: d.transactions_count || 0, + ens_name: d.ens_domain_name, + contract_name: d.name, + is_contract: d.has_contract || d.is_contract || false, + }; + }, + 5 * 60 * 1000, + ); +} + +// --------------------------------------------------------------------------- +// NASA FIRMS active fire hotspots +// --------------------------------------------------------------------------- + +export interface FireHotspot { + latitude: number; + longitude: number; + brightness_kelvin: number; + scan_km: number; + track_km: number; + acq_date: string; // YYYY-MM-DD + acq_time: string; // HHMM + satellite: 'Terra' | 'Aqua' | 'NOAA-20' | 'NOAA-21'; + confidence: 'low' | 'nominal' | 'high'; + frp_mw: number; // fire radiative power (MW) + daynight: 'D' | 'N'; +} + +const NASA_FIRMS_URL = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv'; +// Free MAP_KEY from https://firms.modaps.eosdis.nasa.gov/api/area/ — required. +// Without it, /fires returns "Invalid MAP_KEY" 401 from NASA. +const FIRMS_KEY = process.env.FIRMS_MAP_KEY; + +export async function fetchFireHotspots(hours = 24, region?: string): Promise { + if (!FIRMS_KEY) { + console.warn('[osiris:fires] FIRMS_MAP_KEY not set — endpoint returns empty'); + return []; + } + + // FIRMS provides 24h or 7d windows, not arbitrary hours. Map hours → window. + const windowDays = hours <= 24 ? 1 : hours <= 168 ? 7 : 10; + + return (await cached( + `fires:${windowDays}d:${region || 'world'}`, + async () => { + const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${FIRMS_KEY}/VIIRS_NOAA20_NRT/world/${windowDays}`; + const r = await axios.get(url, { timeout: TIMEOUT, responseType: 'text' }); + const lines = (r.data || '').split('\n').filter((l: string) => l.trim()); + if (lines.length < 2) return []; + + const header = lines[0].split(','); + const rows = lines.slice(1).map((line: string) => { + const cols = line.split(','); + const row: any = {}; + header.forEach((h: string, i: number) => (row[h.trim()] = cols[i])); + return row; + }); + + return rows + .filter((row: any) => row.latitude && row.longitude) + .filter((row: any) => !region || isInRegion(parseFloat(row.latitude), parseFloat(row.longitude), region)) + .map((row: any): FireHotspot => ({ + latitude: parseFloat(row.latitude), + longitude: parseFloat(row.longitude), + brightness_kelvin: parseFloat(row.brightness || '0'), + scan_km: parseFloat(row.scan || '0'), + track_km: parseFloat(row.track || '0'), + acq_date: row.acq_date || '', + acq_time: row.acq_time || '', + satellite: (row.satellite || '').includes('Terra') ? 'Terra' + : (row.satellite || '').includes('Aqua') ? 'Aqua' + : 'NOAA-20', + confidence: (row.confidence || 'nominal').toLowerCase() as any, + frp_mw: parseFloat(row.frp || '0'), + daynight: row.daynight === 'D' ? 'D' : 'N', + })) + .slice(0, 1000); + }, + 30 * 60 * 1000, + )) || []; +} + +function isInRegion(lat: number, lon: number, region: string): boolean { + const regions: Record = { + middle_east: [[12, 25], [60, 42]], + europe: [[35, 71], [-10, 40]], + north_america: [[15, 72], [-170, -50]], + africa: [[-35, 38], [-18, 52]], + asia: [[-10, 75], [60, 150]], + oceania: [[-50, 0], [110, 180]], + south_america: [[-55, 13], [-82, -34]], + }; + const box = regions[region]; + if (!box) return true; + const [[latMin, latMax], [lonMin, lonMax]] = box; + return lat >= latMin && lat <= latMax && lon >= lonMin && lon <= lonMax; +} + +// --------------------------------------------------------------------------- +// NVD CVE lookup +// --------------------------------------------------------------------------- + +export interface CveRecord { + id: string; + published: string; + last_modified: string; + description: string; + cvss_v3_score?: number; + cvss_v3_severity?: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + references: string[]; +} + +const NVD_BASE = 'https://services.nvd.nist.gov/rest/json/cves/2.0'; + +export async function fetchCve(id: string): Promise { + const cvePattern = /^CVE-\d{4}-\d{4,7}$/i; + if (!cvePattern.test(id)) return null; + const normalized = id.toUpperCase(); + + return cached( + `cve:${normalized}`, + async () => { + const r = await axios.get(NVD_BASE, { + params: { cveId: normalized }, + timeout: TIMEOUT, + }); + const vuln = r.data?.vulnerabilities?.[0]?.cve; + if (!vuln) return null; + + const cvss = vuln.metrics?.cvssMetricV31?.[0]?.cvssData; + return { + id: vuln.id, + published: vuln.published, + last_modified: vuln.lastModified, + description: (vuln.descriptions?.find((d: any) => d.lang === 'en')?.value || '').slice(0, 1000), + cvss_v3_score: cvss?.baseScore, + cvss_v3_severity: cvss?.baseSeverity, + references: (vuln.references || []).slice(0, 10).map((x: any) => x.url), + }; + }, + 24 * 60 * 60 * 1000, // CVEs don't change + ); +} + +export async function fetchRecentCves(days = 7, minCvssScore?: number): Promise { + const validDays = Math.min(Math.max(days, 1), 30); + const end = new Date(); + const start = new Date(Date.now() - validDays * 24 * 60 * 60 * 1000); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + + return (await cached( + `cves:${validDays}d:${minCvssScore || 'any'}`, + async () => { + const r = await axios.get(NVD_BASE, { + params: { + lastModStartDate: `${fmt(start)}T00:00:00.000`, + lastModEndDate: `${fmt(end)}T23:59:59.999`, + resultsPerPage: 50, + }, + timeout: TIMEOUT, + }); + const items = r.data?.vulnerabilities || []; + return items + .map((v: any) => v.cve) + .filter((c: any) => c) + .map((cve: any): CveRecord => { + const cvss = cve.metrics?.cvssMetricV31?.[0]?.cvssData; + return { + id: cve.id, + published: cve.published, + last_modified: cve.lastModified, + description: (cve.descriptions?.find((d: any) => d.lang === 'en')?.value || '').slice(0, 500), + cvss_v3_score: cvss?.baseScore, + cvss_v3_severity: cvss?.baseSeverity, + references: (cve.references || []).slice(0, 3).map((x: any) => x.url), + }; + }) + .filter((c: CveRecord) => !minCvssScore || (c.cvss_v3_score && c.cvss_v3_score >= minCvssScore)) + .slice(0, 50); + }, + 60 * 60 * 1000, + )) || []; +} + +// --------------------------------------------------------------------------- +// WHOIS + DNS lookups +// --------------------------------------------------------------------------- + +export async function whoisLookup(domain: string): Promise { + if (!/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain)) return null; + + return cached( + `whois:${domain.toLowerCase()}`, + async () => { + // Use RDAP (Registration Data Access Protocol) — modern, structured, free. + // Fall back to whois.iana.org first to find the registrar's RDAP endpoint. + const r = await axios.get(`https://rdap.org/domain/${domain}`, { timeout: TIMEOUT }); + const d = r.data || {}; + return { + domain: d.ldhName || domain, + handle: d.handle, + status: d.status || [], + events: (d.events || []).map((e: any) => ({ event: e.eventAction, date: e.eventDate })), + nameservers: (d.nameservers || []).map((n: any) => n.ldhName).filter(Boolean), + entities: (d.entities || []).map((e: any) => ({ + role: e.roles?.[0], + name: e.vcardArray?.[1]?.find((v: any) => v[0] === 'fn')?.[3], + handle: e.handle, + })), + }; + }, + 24 * 60 * 60 * 1000, + ); +} + +export async function dnsLookup(domain: string): Promise { + if (!/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(domain)) return null; + + return cached( + `dns:${domain.toLowerCase()}`, + async () => { + // Google DNS-over-HTTPS API + const types = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME']; + const results: any = { domain, records: {} }; + + await Promise.all(types.map(async (t) => { + try { + const r = await axios.get(`https://dns.google/resolve?name=${domain}&type=${t}`, { + timeout: 5000, + }); + const answers = r.data?.Answer || []; + results.records[t] = answers.map((a: any) => ({ + name: a.name, + value: a.data, + ttl: a.TTL, + })); + } catch (e) { + results.records[t] = []; + } + })); + + return results; + }, + 60 * 60 * 1000, + ); +} + +// --------------------------------------------------------------------------- +// Telegram public channel OSINT (web preview scraping) +// --------------------------------------------------------------------------- + +export interface TelegramMessage { + id: number; + text: string; + timestamp: string; + views?: number; + forwards?: number; + author?: string; +} + +export async function fetchTelegramChannel(channel: string, limit = 10): Promise { + // Strip @ prefix and normalize + const handle = channel.replace(/^@/, '').toLowerCase(); + if (!/^[a-zA-Z0-9_]{4,32}$/.test(handle)) return []; + + return (await cached( + `telegram:${handle}:${limit}`, + async () => { + const r = await axios.get(`https://t.me/s/${handle}`, { + timeout: TIMEOUT, + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; ClawdWatch/2.0)' }, + }); + const html = r.data as string; + + // Use simple regex extraction (no cheerio to keep deps light) + // Match tgme_widget_message_wrap blocks + const msgRegex = /

]*>(.*?)<\/div>\s*<\/div>\s*<\/div>/gs; + const textRegex = /
]*>(.*?)<\/div>/s; + const timeRegex = /]*datetime="([^"]+)"/; + const viewsRegex = /([^<]+)/; + + const messages: TelegramMessage[] = []; + const blocks = html.match(/
\s*<\/div>\s*<\/div>/g) || []; + + blocks.slice(0, limit).forEach((block: string, idx: number) => { + const textMatch = block.match(textRegex); + const timeMatch = block.match(timeRegex); + const viewsMatch = block.match(viewsRegex); + + const rawText = textMatch ? textMatch[1] : ''; + const text = rawText + .replace(//g, '\n') + .replace(/<[^>]+>/g, '') + .trim(); + + if (text) { + messages.push({ + id: idx + 1, + text: text.slice(0, 1000), + timestamp: timeMatch ? timeMatch[1] : '', + views: viewsMatch ? parseInt(viewsMatch[1].replace(/\D/g, ''), 10) : undefined, + }); + } + }); + + return messages; + }, + 15 * 60 * 1000, + )) || []; +} + +// --------------------------------------------------------------------------- +// NOAA Space Weather Prediction Center (Kp index, solar flares, alerts) +// --------------------------------------------------------------------------- + +export interface SpaceWeatherReport { + kp_index: number; + kp_timestamp: string; + storm_level: string; // Quiet, Unsettled, Minor (G1), ..., Extreme (G5) + storm_color: string; + alerts: Array<{ id: string; issue_datetime: string; message: string; type?: string }>; + recent_flares: Array<{ flare_id: string; class: string; peak_time: string; intensity?: number }>; + timestamp: string; +} + +function classifyKp(kp: number): { level: string; color: string } { + if (kp >= 8) return { level: 'Extreme (G5)', color: '#FF1744' }; + if (kp >= 7) return { level: 'Severe (G4)', color: '#FF3D3D' }; + if (kp >= 6) return { level: 'Strong (G3)', color: '#FF9500' }; + if (kp >= 5) return { level: 'Moderate (G2)', color: '#FFD700' }; + if (kp >= 4) return { level: 'Minor (G1)', color: '#FFD700' }; + if (kp >= 3) return { level: 'Unsettled', color: '#D4AF37' }; + return { level: 'Quiet', color: '#00E676' }; +} + +export async function fetchSpaceWeather(): Promise { + return cached( + 'space-weather:current', + async () => { + const [kpRes, alertsRes, flareRes] = await Promise.allSettled([ + axios.get('https://services.swpc.noaa.gov/json/planetary_k_index_1m.json', { timeout: 8000 }).then(r => r.data), + axios.get('https://services.swpc.noaa.gov/json/alerts.json', { timeout: 8000 }).then(r => r.data), + axios.get('https://services.swpc.noaa.gov/json/goes/primary/xray-flares-latest.json', { timeout: 8000 }).then(r => r.data), + ]); + + let kpIndex = 0; + let kpTimestamp = ''; + if (kpRes.status === 'fulfilled' && Array.isArray(kpRes.value) && kpRes.value.length > 0) { + const latest = kpRes.value[kpRes.value.length - 1]; + kpIndex = parseFloat(latest.kp_index ?? latest.Kp ?? 0); + kpTimestamp = latest.time_tag ?? ''; + } + + const { level, color } = classifyKp(kpIndex); + + const alerts: SpaceWeatherReport['alerts'] = []; + if (alertsRes.status === 'fulfilled' && Array.isArray(alertsRes.value)) { + for (const alert of alertsRes.value.slice(0, 10)) { + alerts.push({ + id: alert.product_id ?? `alert-${Date.now()}`, + issue_datetime: alert.issue_datetime ?? '', + message: alert.message ?? '', + type: alert.type ?? alert._typename, + }); + } + } + + const flares: SpaceWeatherReport['recent_flares'] = []; + if (flareRes.status === 'fulfilled' && Array.isArray(flareRes.value)) { + for (const f of flareRes.value.slice(0, 10)) { + flares.push({ + flare_id: f.flareID ?? '', + class: f.classType ?? '', + peak_time: f.peakTime ?? '', + intensity: f.intensity, + }); + } + } + + return { + kp_index: kpIndex, + kp_timestamp: kpTimestamp, + storm_level: level, + storm_color: color, + alerts, + recent_flares: flares, + timestamp: new Date().toISOString(), + }; + }, + 5 * 60 * 1000, + ); +} + +// --------------------------------------------------------------------------- +// Sentinel-1/2 satellite imagery search (Element84 Earth Search STAC) +// --------------------------------------------------------------------------- + +export interface SentinelScene { + id: string; + datetime: string; + platform: string; // 'Sentinel-1' or 'Sentinel-2' + cloud_cover?: number; + thumbnail?: string; + bbox?: number[]; + source: string; +} + +export async function searchSentinelScenes( + lat: number, + lng: number, + radius = 2, + days = 30, + platform: 'sentinel-1-grd' | 'sentinel-2-l2a' = 'sentinel-2-l2a', +): Promise { + if (isNaN(lat) || isNaN(lng)) return []; + + return (await cached( + `sentinel:${lat.toFixed(2)}:${lng.toFixed(2)}:${radius}:${days}:${platform}`, + async () => { + const bbox = [lng - radius, lat - radius, lng + radius, lat + radius]; + const now = new Date(); + const from = new Date(now.getTime() - days * 86_400_000); + const datetime = `${from.toISOString().split('.')[0]}Z/${now.toISOString().split('.')[0]}Z`; + + const scenes: SentinelScene[] = []; + try { + const res = await axios.post( + 'https://earth-search.aws.element84.com/v1/search', + { + collections: [platform], + bbox, + datetime, + limit: 20, + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: 12_000, + }, + ); + for (const f of res.data?.features ?? []) { + scenes.push({ + id: f.id, + datetime: f.properties?.datetime ?? '', + platform: f.properties?.platform ?? platform, + cloud_cover: f.properties?.['eo:cloud_cover'], + thumbnail: f.assets?.thumbnail?.href, + bbox: f.bbox, + source: 'element84', + }); + } + } catch (e: any) { + console.error(`[osiris:sentinel] error: ${e.message}`); + } + return scenes; + }, + 60 * 60 * 1000, + )) ?? []; +} + +// --------------------------------------------------------------------------- +// Satellite TLE catalog (Celestrak — free, no key) +// --------------------------------------------------------------------------- + +export interface SatelliteEntry { + name: string; + norad_id: number; + category: string; // 'stations', 'weather', 'amateur', 'starlink', etc. + mission: string; + color: string; + tle_line1: string; + tle_line2: string; +} + +const MISSION_CLASSIFY: Record = { + ISS: { mission: 'Space Station', color: '#FFD700' }, + TIANGONG: { mission: 'Space Station', color: '#FFD700' }, + GPS: { mission: 'Navigation', color: '#448AFF' }, + NAVSTAR: { mission: 'Navigation', color: '#448AFF' }, + GLONASS: { mission: 'Navigation', color: '#448AFF' }, + GALILEO: { mission: 'Navigation', color: '#448AFF' }, + BEIDOU: { mission: 'Navigation', color: '#448AFF' }, + STARLINK: { mission: 'Commercial Comms', color: '#00E676' }, + ONEWEB: { mission: 'Commercial Comms', color: '#00E676' }, + NOAA: { mission: 'Weather', color: '#87CEEB' }, + GOES: { mission: 'Weather', color: '#87CEEB' }, + METEOSAT: { mission: 'Weather', color: '#87CEEB' }, + FENGYUN: { mission: 'Weather', color: '#87CEEB' }, + HUBBLE: { mission: 'Space Telescope', color: '#FFD700' }, + 'JAMES WEBB': { mission: 'Space Telescope', color: '#FFD700' }, + SENTINEL: { mission: 'Earth Observation', color: '#90EE90' }, + LANDSAT: { mission: 'Earth Observation', color: '#90EE90' }, + TERRA: { mission: 'Earth Science', color: '#90EE90' }, + AQUA: { mission: 'Earth Science', color: '#90EE90' }, + PLANET: { mission: 'Earth Imaging', color: '#00E676' }, + WORLDVIEW: { mission: 'Commercial Imaging', color: '#00E676' }, + USA: { mission: 'Military Recon', color: '#FF3D3D' }, + NROL: { mission: 'NRO Classified', color: '#FF3D3D' }, +}; + +function classifySat(name: string): { mission: string; color: string } { + const upper = name.toUpperCase(); + for (const [k, info] of Object.entries(MISSION_CLASSIFY)) { + if (upper.includes(k)) return info; + } + return { mission: 'Unknown', color: '#00E5FF' }; +} + +export async function fetchSatelliteCatalog( + category = 'stations', + limit = 50, +): Promise { + return (await cached( + `satellites:${category}:${limit}`, + async () => { + const safeCategory = /^[a-z0-9-]+$/.test(category) ? category : 'stations'; + const r = await axios.get( + `https://celestrak.org/NORAD/elements/gp.php?GROUP=${safeCategory}&FORMAT=tle`, + { timeout: 12_000, transformResponse: [(d: string) => d] }, + ); + const text = r.data as string; + const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); + const entries: SatelliteEntry[] = []; + for (let i = 0; i + 2 < lines.length && entries.length < limit; i += 3) { + const name = lines[i].trim(); + const l1 = lines[i + 1]; + const l2 = lines[i + 2]; + if (!l1.startsWith('1 ') || !l2.startsWith('2 ')) continue; + const norad = parseInt(l2.substring(2, 7).trim(), 10); + const { mission, color } = classifySat(name); + entries.push({ + name, + norad_id: norad, + category: safeCategory, + mission, + color, + tle_line1: l1, + tle_line2: l2, + }); + } + return entries; + }, + 6 * 60 * 60 * 1000, + )) ?? []; +} + +// --------------------------------------------------------------------------- +// Cyber threat intelligence (CISA Known Exploited Vulnerabilities) +// --------------------------------------------------------------------------- + +export interface CyberThreat { + id: string; + name: string; + vendor: string; + product: string; + severity: string; + date: string; + due: string; + source: string; +} + +export interface CyberThreatReport { + threats: CyberThreat[]; + stats: { cisa_total: number; shadowserver: string }; + timestamp: string; +} + +export async function fetchCyberThreats(daysWindow = 30): Promise { + return cached( + `cyber-threats:${daysWindow}`, + async () => { + const results: CyberThreatReport = { + threats: [], + stats: { cisa_total: 0, shadowserver: 'unknown' }, + timestamp: new Date().toISOString(), + }; + + // CISA KEV (Known Exploited Vulnerabilities) — authoritative US gov feed + try { + const r = await axios.get( + 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json', + { timeout: 10_000 }, + ); + const vulns = r.data?.vulnerabilities ?? []; + results.stats.cisa_total = vulns.length; + const cutoff = Date.now() - daysWindow * 86_400_000; + for (const v of vulns.slice(0, 200)) { + const added = new Date(v.dateAdded).getTime(); + if (added < cutoff) continue; + results.threats.push({ + id: v.cveID, + name: v.vulnerabilityName, + vendor: v.vendorProject, + product: v.product, + severity: 'CRITICAL', + date: v.dateAdded, + due: v.dueDate, + source: 'CISA KEV', + }); + if (results.threats.length >= 25) break; + } + } catch (e: any) { + console.error(`[osiris:cyber-threats/cisa] error: ${e.message}`); + } + + return results; + }, + 30 * 60 * 1000, + ); +} + +// --------------------------------------------------------------------------- +// IP geolocation (3-provider cascade: ipapi.co → freeipapi.com → ipwho.is) +// --------------------------------------------------------------------------- + +export interface GeoLocation { + ip: string; + lat: number; + lon: number; + city: string; + region: string; + country: string; + country_code: string; + isp: string; + org: string; + asn: string; + timezone: string; + source: string; +} + +async function geoViaIpapi(ip: string): Promise { + try { + const url = ip ? `https://ipapi.co/${ip}/json/` : 'https://ipapi.co/json/'; + const r = await axios.get(url, { timeout: 5000, headers: { 'User-Agent': 'ClawdWatch/2.2' } }); + const d = r.data; + if (!d || d.error || !d.latitude) return null; + return { + ip: d.ip ?? ip, + lat: parseFloat(d.latitude), + lon: parseFloat(d.longitude), + city: d.city ?? '', + region: d.region ?? '', + country: d.country_name ?? '', + country_code: d.country_code ?? '', + isp: d.org ?? '', + org: d.org ?? '', + asn: d.asn ? `AS${d.asn} ${d.org ?? ''}`.replace(/^ASAS/, 'AS').trim() : '', + timezone: d.timezone ?? '', + source: 'ipapi.co', + }; + } catch { + return null; + } +} + +async function geoViaFreeIpApi(ip: string): Promise { + try { + const url = ip ? `https://freeipapi.com/api/json/${ip}` : 'https://freeipapi.com/api/json'; + const r = await axios.get(url, { timeout: 5000 }); + const d = r.data; + if (!d || d.error || !d.latitude) return null; + return { + ip: d.ipAddress ?? ip, + lat: parseFloat(d.latitude), + lon: parseFloat(d.longitude), + city: d.cityName ?? '', + region: d.regionName ?? '', + country: d.countryName ?? '', + country_code: d.countryCode ?? '', + isp: d.isp ?? '', + org: d.organization ?? '', + asn: '', + timezone: d.timeZone ?? '', + source: 'freeipapi.com', + }; + } catch { + return null; + } +} + +async function geoViaIpWhoIs(ip: string): Promise { + try { + const url = ip ? `https://ipwho.is/${ip}` : 'https://ipwho.is/'; + const r = await axios.get(url, { timeout: 5000 }); + const d = r.data; + if (!d || d.success === false) return null; + return { + ip: d.ip ?? ip, + lat: parseFloat(d.latitude ?? 0), + lon: parseFloat(d.longitude ?? 0), + city: d.city ?? '', + region: d.region ?? '', + country: d.country ?? '', + country_code: d.country_code ?? '', + isp: d.connection?.isp ?? '', + org: d.connection?.org ?? '', + asn: d.connection?.asn ? `AS${d.connection.asn}` : '', + timezone: d.timezone?.id ?? '', + source: 'ipwho.is', + }; + } catch { + return null; + } +} + +export async function geoLocate(ip: string): Promise { + return cached( + `geo:${ip || 'self'}`, + async () => { + const providers = [geoViaIpapi, geoViaFreeIpApi, geoViaIpWhoIs]; + for (const p of providers) { + const r = await p(ip); + if (r) return r; + } + return null; + }, + 60 * 60 * 1000, + ); +} + +// --------------------------------------------------------------------------- +// Air Quality (Open-Meteo Air Quality API — free, no key required) +// Returns current AQI/PM2.5/PM10/O3 readings for major global cities. +// --------------------------------------------------------------------------- + +export interface AirQualityStation { + id: string; + name: string; + city: string; + country: string; + lat: number; + lon: number; + pm25: number; + pm10: number; + ozone: number; + us_aqi?: number; + level: string; // Good / Moderate / Unhealthy / etc. + color: string; + timestamp: string; +} + +function classifyUsAqi(aqi?: number): { level: string; color: string } { + if (aqi == null) return { level: 'Unknown', color: '#888' }; + if (aqi > 300) return { level: 'Hazardous', color: '#7E0023' }; + if (aqi > 200) return { level: 'Very Unhealthy', color: '#8F3F97' }; + if (aqi > 150) return { level: 'Unhealthy', color: '#FF0000' }; + if (aqi > 100) return { level: 'Unhealthy (Sensitive)', color: '#FF7E00' }; + if (aqi > 50) return { level: 'Moderate', color: '#FFFF00' }; + if (aqi > 0) return { level: 'Good', color: '#00E400' }; + return { level: 'Unknown', color: '#888' }; +} + +// Major cities we always snapshot when /air-quality is hit with no params. +// Lat/lng pulled from Open-Meteo geocoding for accuracy. +const DEFAULT_CITIES: Array<{ city: string; country: string; lat: number; lon: number }> = [ + { city: 'New York', country: 'USA', lat: 40.7128, lon: -74.0060 }, + { city: 'Los Angeles', country: 'USA', lat: 34.0522, lon: -118.2437 }, + { city: 'Chicago', country: 'USA', lat: 41.8781, lon: -87.6298 }, + { city: 'Houston', country: 'USA', lat: 29.7604, lon: -95.3698 }, + { city: 'London', country: 'UK', lat: 51.5074, lon: -0.1278 }, + { city: 'Paris', country: 'France', lat: 48.8566, lon: 2.3522 }, + { city: 'Berlin', country: 'Germany', lat: 52.5200, lon: 13.4050 }, + { city: 'Moscow', country: 'Russia', lat: 55.7558, lon: 37.6173 }, + { city: 'Beijing', country: 'China', lat: 39.9042, lon: 116.4074 }, + { city: 'Shanghai', country: 'China', lat: 31.2304, lon: 121.4737 }, + { city: 'Tokyo', country: 'Japan', lat: 35.6895, lon: 139.6917 }, + { city: 'Seoul', country: 'South Korea', lat: 37.5665, lon: 126.9780 }, + { city: 'Mumbai', country: 'India', lat: 19.0760, lon: 72.8777 }, + { city: 'Delhi', country: 'India', lat: 28.7041, lon: 77.1025 }, + { city: 'Dubai', country: 'UAE', lat: 25.2048, lon: 55.2708 }, + { city: 'Cairo', country: 'Egypt', lat: 30.0444, lon: 31.2357 }, + { city: 'Lagos', country: 'Nigeria', lat: 6.5244, lon: 3.3792 }, + { city: 'Sao Paulo', country: 'Brazil', lat: -23.5505, lon: -46.6333 }, + { city: 'Buenos Aires', country: 'Argentina', lat: -34.6037, lon: -58.3816 }, + { city: 'Sydney', country: 'Australia', lat: -33.8688, lon: 151.2093 }, + { city: 'Mexico City', country: 'Mexico', lat: 19.4326, lon: -99.1332 }, + { city: 'Huber Heights', country: 'USA', lat: 39.7589, lon: -84.1916 }, +]; + +export async function fetchAirQuality(): Promise { + return (await cached( + 'air-quality:cities', + async () => { + const stations: AirQualityStation[] = []; + await Promise.allSettled(DEFAULT_CITIES.map(async (c) => { + try { + const r = await axios.get( + `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${c.lat}&longitude=${c.lon}¤t=us_aqi,pm2_5,pm10,ozone`, + { timeout: 8_000 }, + ); + const cur = r.data?.current; + if (!cur) return; + const { level, color } = classifyUsAqi(cur.us_aqi); + stations.push({ + id: `aq-${c.city.toLowerCase().replace(/\s+/g, '-')}`, + name: c.city, + city: c.city, + country: c.country, + lat: c.lat, + lon: c.lon, + pm25: cur.pm2_5 ?? 0, + pm10: cur.pm10 ?? 0, + ozone: cur.ozone ?? 0, + us_aqi: cur.us_aqi, + level, + color, + timestamp: cur.time ?? new Date().toISOString(), + }); + } catch (e: any) { + console.error(`[osiris:air-quality:${c.city}] error: ${e.message}`); + } + })); + // Sort by AQI descending so worst air first + stations.sort((a, b) => (b.us_aqi ?? 0) - (a.us_aqi ?? 0)); + return stations; + }, + 30 * 60 * 1000, + )) ?? []; +} + +// --------------------------------------------------------------------------- +// SSL/TLS Certificate Inspector (uses Node's built-in tls module — no key) +// --------------------------------------------------------------------------- + +import * as tls from 'tls'; +import * as net from 'net'; +import * as dns from 'dns'; + +function cn(value: string | string[] | undefined): string { + if (!value) return ''; + return Array.isArray(value) ? (value[0] ?? '') : value; +} + +export interface TlsReport { + host: string; + port: number; + reachable: boolean; + protocol?: string; + cipher?: string; + cert?: { + subject_cn: string; + subject_o: string; + issuer_cn: string; + issuer_o: string; + valid_from: string; + valid_to: string; + days_until_expiry: number; + serial_number: string; + fingerprint_sha256: string; + fingerprint_sha1: string; + signature_algorithm: string; + sans: string[]; + self_signed: boolean; + expired: boolean; + expiry_warning: boolean; + }; + error?: string; + timestamp: string; +} + +function getTlsCert(host: string, port = 443, timeoutMs = 8000): Promise<{ cert: tls.PeerCertificate; protocol: string; cipherName: string }> { + return new Promise((resolve, reject) => { + const socket = tls.connect( + { host, port, servername: host, rejectUnauthorized: false, timeout: timeoutMs }, + () => { + const cert = socket.getPeerCertificate(true); + const protocol = socket.getProtocol() ?? 'unknown'; + const cipher = socket.getCipher().name; + socket.end(); + if (!cert || Object.keys(cert).length === 0) { + reject(new Error('No certificate returned')); + } else { + resolve({ cert, protocol, cipherName: cipher }); + } + }, + ); + socket.on('error', reject); + socket.on('timeout', () => { + socket.destroy(); + reject(new Error(`TLS timeout after ${timeoutMs}ms`)); + }); + }); +} + +export async function inspectTlsCertificate(host: string, port = 443): Promise { + // Strip protocol prefix if user pasted a URL + const cleanHost = host.replace(/^https?:\/\//, '').split('/')[0].split(':')[0]; + if (!/^[a-zA-Z0-9.-]+$/.test(cleanHost)) return null; + + return cached( + `tls:${cleanHost}:${port}`, + async () => { + const base: TlsReport = { + host: cleanHost, + port, + reachable: false, + timestamp: new Date().toISOString(), + }; + try { + const { cert, protocol, cipherName } = await getTlsCert(cleanHost, port); + base.reachable = true; + base.protocol = protocol; + base.cipher = cipherName; + const validTo = new Date(cert.valid_to); + const now = new Date(); + const daysUntilExpiry = Math.round((validTo.getTime() - now.getTime()) / 86_400_000); + const sans = (cert.subjectaltname ?? '') + .split(/, /) + .map(s => s.replace(/^DNS:/, '')) + .filter(Boolean); + const selfSigned = cn(cert.issuer?.CN) === cn(cert.subject?.CN); + base.cert = { + subject_cn: cn(cert.subject?.CN), + subject_o: cn(cert.subject?.O), + issuer_cn: cn(cert.issuer?.CN), + issuer_o: cn(cert.issuer?.O), + valid_from: cert.valid_from, + valid_to: cert.valid_to, + days_until_expiry: daysUntilExpiry, + serial_number: cert.serialNumber ?? '', + fingerprint_sha256: cert.fingerprint256 ?? '', + fingerprint_sha1: (cert as any).fingerprint1 ?? '', + signature_algorithm: (cert as any).signatureAlgorithm ?? 'unknown', + sans, + self_signed: selfSigned, + expired: daysUntilExpiry < 0, + expiry_warning: daysUntilExpiry >= 0 && daysUntilExpiry < 30, + }; + return base; + } catch (e: any) { + base.reachable = false; + base.error = e.message; + return base; + } + }, + 60 * 60 * 1000, + ); +} + +// --------------------------------------------------------------------------- +// Live News Broadcast Network (static feed catalog — no API call needed) +// --------------------------------------------------------------------------- + +export interface LiveNewsFeed { + id: string; + name: string; + city: string; + country: string; + lat: number; + lng: number; + url: string; + embed_allowed: boolean; + category: 'mainstream' | 'government' | 'finance' | 'state' | 'conflict'; + language: string; +} + +export const LIVE_NEWS_FEEDS: LiveNewsFeed[] = [ + // North America + { id: 'nbcnews', name: 'NBC News NOW', city: 'New York', country: 'US', lat: 40.759, lng: -73.980, url: 'https://www.youtube.com/channel/UCeY0bbntWzzVIaj2z3QigXg/live', embed_allowed: false, category: 'mainstream', language: 'en' }, + { id: 'cbsnews', name: 'CBS News 24/7', city: 'New York', country: 'US', lat: 40.764, lng: -73.973, url: 'https://www.youtube.com/channel/UC8p1vwvWtl6T73JiExfWs1g/live', embed_allowed: false, category: 'mainstream', language: 'en' }, + { id: 'abcnews', name: 'ABC News Live', city: 'New York', country: 'US', lat: 40.763, lng: -73.979, url: 'https://www.youtube.com/channel/UCBi2mrWuNuyYy4gbM6fU18Q/live', embed_allowed: false, category: 'mainstream', language: 'en' }, + { id: 'bloomberg', name: 'Bloomberg TV', city: 'New York', country: 'US', lat: 40.756, lng: -73.988, url: 'https://www.youtube.com/channel/UC_vQ72b7v5n2938v9d5c80w/live', embed_allowed: false, category: 'finance', language: 'en' }, + { id: 'cspan', name: 'C-SPAN', city: 'Washington DC', country: 'US', lat: 38.897, lng: -77.036, url: 'https://www.youtube.com/channel/UCb--64Gl51jIEVE-GLDAVTg/live', embed_allowed: false, category: 'government', language: 'en' }, + { id: 'cbc', name: 'CBC News', city: 'Toronto', country: 'CA', lat: 43.644, lng: -79.387, url: 'https://www.youtube.com/channel/UCKy1dAqELon0zgzZPOz9SVw/live', embed_allowed: false, category: 'mainstream', language: 'en' }, + // Europe + { id: 'skynews', name: 'Sky News', city: 'London', country: 'GB', lat: 51.500, lng: -0.118, url: 'https://www.youtube.com/embed/live_stream?channel=UCoMdktPbSTixAyNGwb-UYkQ&autoplay=1&mute=1', embed_allowed: true, category: 'mainstream', language: 'en' }, + { id: 'france24en', name: 'France 24 EN', city: 'Paris', country: 'FR', lat: 48.830, lng: 2.280, url: 'https://www.youtube.com/embed/live_stream?channel=UCQfwfsi5VrQ8yKZ-UWmAEFg&autoplay=1&mute=1', embed_allowed: true, category: 'mainstream', language: 'en' }, + { id: 'dwnews', name: 'DW News', city: 'Berlin', country: 'DE', lat: 52.508, lng: 13.376, url: 'https://www.youtube.com/embed/live_stream?channel=UCknLrEdhRCp1aegoMqRaCZg&autoplay=1&mute=1', embed_allowed: true, category: 'mainstream', language: 'en' }, + // Middle East + { id: 'aljazeera', name: 'Al Jazeera EN', city: 'Doha', country: 'QA', lat: 25.286, lng: 51.534, url: 'https://www.youtube.com/embed/live_stream?channel=UCNye-wNBqNL5ZzHSJj3l8Bg&autoplay=1&mute=1', embed_allowed: true, category: 'mainstream', language: 'en' }, + // Asia Pacific + { id: 'nhkworld', name: 'NHK World', city: 'Tokyo', country: 'JP', lat: 35.690, lng: 139.692, url: 'https://www.youtube.com/embed/live_stream?channel=UCSPEjw8F2nQDtmUKPFNF7_A&autoplay=1&mute=1', embed_allowed: true, category: 'mainstream', language: 'en' }, + { id: 'cna', name: 'CNA 24/7', city: 'Singapore', country: 'SG', lat: 1.290, lng: 103.852, url: 'https://www.youtube.com/embed/live_stream?channel=UC83jt4dlz1Gjl58fzQrrKZg&autoplay=1&mute=1', embed_allowed: true, category: 'mainstream', language: 'en' }, + { id: 'wion', name: 'WION', city: 'New Delhi', country: 'IN', lat: 28.614, lng: 77.209, url: 'https://www.youtube.com/embed/live_stream?channel=UC_gUM8rL-Lrg6O3adPW9K1g&autoplay=1&mute=1', embed_allowed: true, category: 'mainstream', language: 'en' }, + { id: 'cgtn', name: 'CGTN', city: 'Beijing', country: 'CN', lat: 39.904, lng: 116.407, url: 'https://www.youtube.com/channel/UCgrNz-aDmcr2uuto8_DL2jg/live', embed_allowed: false, category: 'state', language: 'en' }, + // State media + { id: 'rt', name: 'RT News', city: 'Moscow', country: 'RU', lat: 55.755, lng: 37.617, url: 'https://rumble.com/c/RTNewsEN', embed_allowed: false, category: 'state', language: 'en' }, +]; + +export async function fetchLiveNewsFeeds(category?: string): Promise { + if (!category) return LIVE_NEWS_FEEDS; + return LIVE_NEWS_FEEDS.filter(f => f.category === category); +} + +// --------------------------------------------------------------------------- +// OFAC sanctions cache (used by whois, geo, crypto to auto-flag results) +// Populated by /sanctions queries — without OPENSANCTIONS_API_KEY it stays empty +// and all auto-flag checks return null. +// --------------------------------------------------------------------------- + +const ofacCache: Set = new Set(); // lowercase names + addresses + +export async function refreshOfacCache(): Promise { + if (!process.env.OPENSANCTIONS_API_KEY) return 0; + try { + const r = await axios.get('https://api.opensanctions.org/search/default?limit=1000', { + headers: { 'Authorization': `Bearer ${process.env.OPENSANCTIONS_API_KEY}` }, + timeout: 15_000, + }); + const entities = r.data?.results ?? []; + let added = 0; + for (const e of entities) { + const name = (e.caption ?? e.name ?? '').toLowerCase().trim(); + if (name) { ofacCache.add(name); added++; } + } + return added; + } catch (e: any) { + console.error(`[osiris:ofac-cache] error: ${e.message}`); + return 0; + } +} + +export function checkOfac(value: string): boolean | null { + if (!process.env.OPENSANCTIONS_API_KEY) return null; // no key = can't check + if (ofacCache.size === 0) return null; // not yet populated + return ofacCache.has(value.toLowerCase().trim()); +} + +export function ofacCacheSize(): number { + return ofacCache.size; +} + +// --------------------------------------------------------------------------- +// Port Scanner — LOCAL TCP connect scan +// Default OFF (PORT_SCAN_ENABLED=false). Requires explicit opt-in via env. +// Built-in SSRF guards: rejects private/loopback/link-local/multicast IPs +// unless PORT_SCAN_ALLOW_PRIVATE=true is also set. +// --------------------------------------------------------------------------- + +const COMMON_PORTS: Array<{ port: number; service: string }> = [ + { port: 21, service: 'ftp' }, + { port: 22, service: 'ssh' }, + { port: 23, service: 'telnet' }, + { port: 25, service: 'smtp' }, + { port: 53, service: 'dns' }, + { port: 80, service: 'http' }, + { port: 110, service: 'pop3' }, + { port: 143, service: 'imap' }, + { port: 443, service: 'https' }, + { port: 445, service: 'smb' }, + { port: 465, service: 'smtps' }, + { port: 587, service: 'smtp-submission' }, + { port: 993, service: 'imaps' }, + { port: 995, service: 'pop3s' }, + { port: 1433, service: 'mssql' }, + { port: 1521, service: 'oracle' }, + { port: 2082, service: 'cpanel' }, + { port: 2083, service: 'cpanel-ssl' }, + { port: 2086, service: 'whm' }, + { port: 2087, service: 'whm-ssl' }, + { port: 3306, service: 'mysql' }, + { port: 3389, service: 'rdp' }, + { port: 5432, service: 'postgresql' }, + { port: 5900, service: 'vnc' }, + { port: 6379, service: 'redis' }, + { port: 8000, service: 'http-alt' }, + { port: 8080, service: 'http-proxy' }, + { port: 8443, service: 'https-alt' }, + { port: 8888, service: 'http-alt2' }, + { port: 9090, service: 'prometheus' }, + { port: 9200, service: 'elasticsearch' }, + { port: 27017, service: 'mongodb' }, +]; + +const PRIVATE_RANGES = [ + /^127\./, + /^10\./, + /^192\.168\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^169\.254\./, + /^::1$/, + /^fc[0-9a-f]{2}:/i, + /^fe[89ab][0-9a-f]:/i, + /^224\./, +]; + +async function resolveAndCheck(host: string): Promise<{ ok: boolean; reason?: string; ip?: string }> { + // Reject obviously bad input + if (!/^[a-zA-Z0-9.-]+$/.test(host)) { + return { ok: false, reason: 'Invalid hostname format' }; + } + // If user provided an IP literal, check directly + let ip = host; + if (!/^\d+\.\d+\.\d+\.\d+$/.test(host) && !/^[0-9a-f:]+$/i.test(host)) { + try { + const { address } = await new Promise<{ address: string }>((resolve, reject) => { + dns.lookup(host, { all: false }, (err, address) => { + if (err) reject(err); else resolve({ address }); + }); + }); + ip = address; + } catch (e: any) { + return { ok: false, reason: `DNS resolution failed: ${e.message}` }; + } + } + if (process.env.PORT_SCAN_ALLOW_PRIVATE !== 'true') { + for (const r of PRIVATE_RANGES) { + if (r.test(ip)) { + return { ok: false, reason: `Target ${ip} is in a private/reserved range. Set PORT_SCAN_ALLOW_PRIVATE=true to override.` }; + } + } + } + return { ok: true, ip }; +} + +async function tcpProbe(host: string, port: number, timeoutMs = 3000): Promise<{ open: boolean; latency_ms: number; banner?: string }> { + const start = Date.now(); + return new Promise((resolve) => { + const socket = new net.Socket(); + let banner = ''; + socket.setTimeout(timeoutMs); + socket.once('connect', () => { + const latency = Date.now() - start; + // Try to grab a banner for 1.5s + const grabTimer = setTimeout(() => { + socket.destroy(); + resolve({ open: true, latency_ms: latency, banner: banner || undefined }); + }, 1500); + socket.once('data', (chunk) => { + banner = chunk.toString('ascii', 0, Math.min(chunk.length, 200)).replace(/[^\x20-\x7e]/g, ''); + clearTimeout(grabTimer); + socket.destroy(); + resolve({ open: true, latency_ms: latency, banner: banner || undefined }); + }); + // If no data, write nothing — just close after timer + socket.write(''); + }); + socket.once('timeout', () => { + socket.destroy(); + resolve({ open: false, latency_ms: Date.now() - start }); + }); + socket.once('error', () => { + resolve({ open: false, latency_ms: Date.now() - start }); + }); + socket.connect(port, host); + }); +} + +export interface PortScanResult { + target: string; + resolved_ip?: string; + scanned_ports: number; + open_count: number; + closed_count: number; + duration_ms: number; + results: Array<{ port: number; service: string; open: boolean; latency_ms?: number; banner?: string }>; + timestamp: string; + blocked?: boolean; + reason?: string; +} + +export async function scanPorts(host: string, ports?: number[]): Promise { + if (process.env.PORT_SCAN_ENABLED !== 'true') { + return { + target: host, + scanned_ports: 0, + open_count: 0, + closed_count: 0, + duration_ms: 0, + results: [], + timestamp: new Date().toISOString(), + blocked: true, + reason: 'Port scanner disabled. Set PORT_SCAN_ENABLED=true in .env to enable.', + }; + } + const guard = await resolveAndCheck(host); + if (!guard.ok) { + return { + target: host, + scanned_ports: 0, + open_count: 0, + closed_count: 0, + duration_ms: 0, + results: [], + timestamp: new Date().toISOString(), + blocked: true, + reason: guard.reason, + }; + } + const portList = (ports && ports.length > 0) + ? ports.slice(0, 100).map(p => ({ + port: p, + service: COMMON_PORTS.find(cp => cp.port === p)?.service ?? 'unknown', + })) + : COMMON_PORTS; + const start = Date.now(); + const results = await Promise.all( + portList.map(async (p) => { + const r = await tcpProbe(host, p.port, 3000); + return { port: p.port, service: p.service, ...r }; + }), + ); + const open_count = results.filter(r => r.open).length; + return { + target: host, + resolved_ip: guard.ip, + scanned_ports: results.length, + open_count, + closed_count: results.length - open_count, + duration_ms: Date.now() - start, + results, + timestamp: new Date().toISOString(), + }; +} diff --git a/src/sources/rss.ts b/src/sources/rss.ts new file mode 100644 index 0000000..3a6f940 --- /dev/null +++ b/src/sources/rss.ts @@ -0,0 +1,211 @@ +import axios from 'axios'; +import * as cheerio from 'cheerio'; + +export interface NewsItem { + title: string; + url: string; + source: string; + region: string; + timestamp: string; + summary?: string; +} + +export interface RssFeed { + id: string; // short slug + name: string; // display name + region: string; // coverage region tag + feedUrl: string; // public RSS/Atom URL + homepageUrl: string; // for link fallback + enabled: boolean; + weight: number; // sort priority (lower = more important) +} + +/** + * The global RSS registry. To add a new source, just append an entry here. + * RSS is the right primitive for news scraping — it's public, stable, and + * has a documented schema. We never have to worry about site redesigns. + */ +export const RSS_FEEDS: RssFeed[] = [ + // === Middle East === + { id: 'reuters_me', name: 'Reuters (via Google News)', region: 'middle_east', feedUrl: 'https://news.google.com/rss/search?q=reuters+world&hl=en-US&gl=US&ceid=US:en', homepageUrl: 'https://www.reuters.com', enabled: true, weight: 1 }, + { id: 'aljazeera', name: 'Al Jazeera', region: 'middle_east', feedUrl: 'https://www.aljazeera.com/xml/rss/all.xml', homepageUrl: 'https://www.aljazeera.com', enabled: true, weight: 2 }, + { id: 'toi', name: 'Times of Israel', region: 'israel', feedUrl: 'https://www.timesofisrael.com/feed/', homepageUrl: 'https://www.timesofisrael.com', enabled: true, weight: 1 }, + { id: 'middle_easteye', name: 'Middle East Eye', region: 'middle_east', feedUrl: 'https://www.middleeasteye.net/rss', homepageUrl: 'https://www.middleeasteye.net', enabled: true, weight: 4 }, + { id: 'voa_me', name: 'VOA Middle East', region: 'middle_east', feedUrl: 'https://www.voanews.com/rss', homepageUrl: 'https://www.voanews.com', enabled: true, weight: 5 }, + { id: 'i24news', name: 'i24 News', region: 'israel', feedUrl: 'https://www.i24news.tv/en/rss', homepageUrl: 'https://www.i24news.tv', enabled: true, weight: 5 }, + { id: 'jpost', name: 'Jerusalem Post', region: 'israel', feedUrl: 'https://www.jpost.com/rss/rssfeedsfrontpage.aspx', homepageUrl: 'https://www.jpost.com', enabled: true, weight: 5 }, + + // === Conflict / War === + { id: 'bbc_world', name: 'BBC World', region: 'world', feedUrl: 'https://feeds.bbci.co.uk/news/world/rss.xml', homepageUrl: 'https://www.bbc.com/news/world', enabled: true, weight: 1 }, + { id: 'ap_top', name: 'AP News (via Google News)', region: 'world', feedUrl: 'https://news.google.com/rss/search?q=ap+news+world&hl=en-US&gl=US&ceid=US:en', homepageUrl: 'https://apnews.com', enabled: true, weight: 2 }, + { id: 'guardian_world', name: 'The Guardian World', region: 'world', feedUrl: 'https://www.theguardian.com/world/rss', homepageUrl: 'https://www.theguardian.com/world', enabled: true, weight: 2 }, + { id: 'nyt_world', name: 'NYT World', region: 'world', feedUrl: 'https://rss.nytimes.com/services/xml/rss/nyt/World.xml', homepageUrl: 'https://www.nytimes.com/section/world', enabled: true, weight: 2 }, + { id: 'france24', name: 'France 24', region: 'world', feedUrl: 'https://www.france24.com/en/rss', homepageUrl: 'https://www.france24.com/en/', enabled: true, weight: 3 }, + { id: 'dw', name: 'Deutsche Welle', region: 'world', feedUrl: 'https://rss.dw.com/xml/rss-en-world', homepageUrl: 'https://www.dw.com/en/', enabled: true, weight: 3 }, + { id: 'cbs_news', name: 'CBS News', region: 'world', feedUrl: 'https://www.cbsnews.com/latest/rss/main', homepageUrl: 'https://www.cbsnews.com', enabled: true, weight: 4 }, + { id: 'abc_news', name: 'ABC News Top Stories', region: 'world', feedUrl: 'https://abcnews.go.com/abcnews/topstories', homepageUrl: 'https://abcnews.go.com', enabled: true, weight: 4 }, + { id: 'cnn_world_gn', name: 'CNN World (via Google News)', region: 'world', feedUrl: 'https://news.google.com/rss/search?q=cnn+world&hl=en-US&gl=US&ceid=US:en', homepageUrl: 'https://www.cnn.com/world', enabled: true, weight: 4 }, + { id: 'npr_world', name: 'NPR World', region: 'world', feedUrl: 'https://feeds.npr.org/1001/rss.xml', homepageUrl: 'https://www.npr.org', enabled: true, weight: 4 }, + { id: 'politico_world', name: 'Politico', region: 'world', feedUrl: 'https://www.politico.com/rss/politicopicks.xml', homepageUrl: 'https://www.politico.com', enabled: true, weight: 5 }, + { id: 'la_times', name: 'LA Times World', region: 'world', feedUrl: 'https://www.latimes.com/world/rss2.0.xml', homepageUrl: 'https://www.latimes.com/world', enabled: true, weight: 5 }, + { id: 'straitstimes', name: 'The Straits Times', region: 'world', feedUrl: 'https://www.straitstimes.com/news/world/rss.xml', homepageUrl: 'https://www.straitstimes.com', enabled: true, weight: 5 }, + { id: 'independent_uk', name: 'The Independent', region: 'world', feedUrl: 'https://www.independent.co.uk/news/world/rss', homepageUrl: 'https://www.independent.co.uk', enabled: true, weight: 5 }, + + // === Regional === + { id: 'scmp', name: 'South China Morning Post', region: 'asia', feedUrl: 'https://www.scmp.com/rss/91/feed/', homepageUrl: 'https://www.scmp.com', enabled: true, weight: 3 }, + { id: 'kyodo', name: 'Kyodo News (via Google News)', region: 'asia', feedUrl: 'https://news.google.com/rss/search?q=kyodo+news&hl=en-US&gl=US&ceid=US:en', homepageUrl: 'https://english.kyodonews.net', enabled: true, weight: 4 }, + { id: 'toi_india', name: 'Times of India', region: 'south_asia', feedUrl: 'https://timesofindia.indiatimes.com/rssfeeds/296589292.cms', homepageUrl: 'https://timesofindia.indiatimes.com', enabled: true, weight: 4 }, + { id: 'the_hindu', name: 'The Hindu', region: 'south_asia', feedUrl: 'https://www.thehindu.com/news/international/feeder/default.rss', homepageUrl: 'https://www.thehindu.com', enabled: true, weight: 5 }, + { id: 'indian_express', name: 'Indian Express', region: 'south_asia', feedUrl: 'https://indianexpress.com/section/world/feed/', homepageUrl: 'https://indianexpress.com', enabled: true, weight: 5 }, + { id: 'tass', name: 'TASS', region: 'russia', feedUrl: 'https://tass.com/rss/v2.xml', homepageUrl: 'https://tass.com', enabled: true, weight: 5 }, + { id: 'kyiv_independent', name: 'Kyiv Independent (via Google News)', region: 'eastern_europe', feedUrl: 'https://news.google.com/rss/search?q=kyiv+independent&hl=en-US&gl=US&ceid=US:en', homepageUrl: 'https://kyivindependent.com', enabled: true, weight: 1 }, + + // === Africa / LatAm / Oceania === + { id: 'abc_au', name: 'ABC News Australia', region: 'oceania', feedUrl: 'https://www.abc.net.au/news/feed/51120/rss.xml', homepageUrl: 'https://www.abc.net.au/news', enabled: true, weight: 4 }, + + // === Tech / OSINT-adjacent === + { id: 'reuters_tech', name: 'Reuters Tech (via Google News)', region: 'tech', feedUrl: 'https://news.google.com/rss/search?q=reuters+technology&hl=en-US&gl=US&ceid=US:en', homepageUrl: 'https://www.reuters.com/technology', enabled: true, weight: 6 }, + { id: 'hn', name: 'Hacker News', region: 'tech', feedUrl: 'https://news.ycombinator.com/rss', homepageUrl: 'https://news.ycombinator.com', enabled: true, weight: 3 }, +]; + +const feedCache: Map = new Map(); +const FEED_CACHE_TTL = 10 * 60 * 1000; // 10 min — RSS feeds update slowly +const FEED_TIMEOUT = 12_000; + +/** + * Parse RSS 2.0 / Atom / RDF into a normalized list of NewsItem. + * Defensive: skips malformed items, never throws. + */ +export function parseFeedXml(xml: string, feed: RssFeed): NewsItem[] { + try { + const $ = cheerio.load(xml, { xmlMode: true }); + const items: NewsItem[] = []; + const seen = new Set(); + + // RSS 2.0 + $('item').each((_, el) => { + const title = $(el).find('title').first().text().trim(); + const link = $(el).find('link').first().text().trim() || $(el).find('guid').first().text().trim(); + const pubDate = $(el).find('pubDate').first().text().trim(); + const desc = $(el).find('description').first().text().trim(); + if (title && link && !seen.has(link)) { + seen.add(link); + items.push({ + title: stripHtml(title).slice(0, 200), + url: link, + source: feed.name, + region: feed.region, + timestamp: pubDate ? new Date(pubDate).toISOString() : new Date().toISOString(), + summary: desc ? stripHtml(desc).slice(0, 240) : undefined, + }); + } + }); + + // Atom + if (items.length === 0) { + $('entry').each((_, el) => { + const title = $(el).find('title').first().text().trim(); + const linkEl = $(el).find('link[href]').first(); + const link = linkEl.attr('href') || ''; + const pubDate = $(el).find('updated, published').first().text().trim(); + const desc = $(el).find('summary, content').first().text().trim(); + if (title && link && !seen.has(link)) { + seen.add(link); + items.push({ + title: stripHtml(title).slice(0, 200), + url: link, + source: feed.name, + region: feed.region, + timestamp: pubDate ? new Date(pubDate).toISOString() : new Date().toISOString(), + summary: desc ? stripHtml(desc).slice(0, 240) : undefined, + }); + } + }); + } + + return items.slice(0, 25); + } catch (e: any) { + console.error(`[rss] ${feed.id} parse error: ${e.message}`); + return []; + } +} + +function stripHtml(s: string): string { + return s.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, ' ').replace(/\s+/g, ' ').trim(); +} + +export interface FeedHealth { + id: string; + name: string; + region: string; + ok: boolean; + itemCount: number; + lastFetched: string | null; + lastError?: string; +} + +const feedHealth: Map = new Map(); + +/** + * Fetch one feed. Cached for FEED_CACHE_TTL. Records health. + */ +export async function fetchFeed(feed: RssFeed, useCache = true): Promise { + if (!feed.enabled) return []; + + const cached = feedCache.get(feed.id); + if (useCache && cached && Date.now() - cached.timestamp < FEED_CACHE_TTL) { + return cached.data; + } + + try { + const res = await axios.get(feed.feedUrl, { + timeout: FEED_TIMEOUT, + headers: { + 'User-Agent': 'ClawdWatch-Lobster/1.0 (+https://github.com/Franzferdinan51/clawdwatch-lobster-edition)', + 'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*', + }, + // Don't barf on non-2xx; we'll record it as a health failure + validateStatus: () => true, + }); + + if (res.status >= 400) { + feedHealth.set(feed.id, { + id: feed.id, name: feed.name, region: feed.region, ok: false, + itemCount: 0, lastFetched: new Date().toISOString(), + lastError: `HTTP ${res.status}`, + }); + return []; + } + + const items = parseFeedXml(res.data, feed); + feedCache.set(feed.id, { data: items, timestamp: Date.now() }); + feedHealth.set(feed.id, { + id: feed.id, name: feed.name, region: feed.region, ok: true, + itemCount: items.length, lastFetched: new Date().toISOString(), + }); + return items; + } catch (e: any) { + feedHealth.set(feed.id, { + id: feed.id, name: feed.name, region: feed.region, ok: false, + itemCount: 0, lastFetched: new Date().toISOString(), + lastError: e.message?.slice(0, 120) || 'fetch failed', + }); + return []; + } +} + +/** + * Fetch many feeds in parallel. Tolerant of failures. + */ +export async function fetchFeeds(feeds: RssFeed[] = RSS_FEEDS, useCache = true): Promise { + const results = await Promise.all(feeds.map((f) => fetchFeed(f, useCache))); + return results.flat(); +} + +export function getFeedHealth(): FeedHealth[] { + return Array.from(feedHealth.values()); +} + +export function getFeedsByRegion(region: string): RssFeed[] { + return RSS_FEEDS.filter((f) => f.region === region); +}