Compare commits

...

33 Commits

Author SHA1 Message Date
BigBodyCobain 20d2ccc52c Fix desktop static export build 2026-05-02 23:18:57 -06:00
BigBodyCobain 0fc09c9011 Fix Docker Infonet and Wormhole startup 2026-05-02 21:53:35 -06:00
BigBodyCobain 707ca29220 Add in-app local API key setup
Let fresh Docker and local installs enter OpenSky, AIS, and other provider keys directly in onboarding or Settings without manually creating .env files. Persist keys server-side in the backend data store, keep them write-only from the browser, reload runtime settings, and retain local-operator access controls.
2026-05-02 21:16:32 -06:00
BigBodyCobain eb0288ee4e Fix Docker local controls and setup guidance
Allow the bundled Docker frontend proxy to reach local-operator endpoints through the private compose bridge without trusting LAN clients. This restores Time Machine, MeshChat key creation, AI pins/layers, and related local controls in Docker installs. Refresh first-run guidance so Docker users know to configure OpenSky and AIS keys through .env.
2026-05-02 20:18:46 -06:00
BigBodyCobain 8d3c7a51b7 Fix Docker frontend hydration under CSP
Render the app shell dynamically so Next can attach per-request CSP nonces to its production scripts, preventing Docker from serving a static shell that cannot hydrate. Also gives the first-contact warmup test enough time in CI.
2026-05-02 19:47:32 -06:00
BigBodyCobain fa18c032e2 Fix Docker first-run startup data seeding
Seed safe static backend data into fresh Docker volumes, tighten Docker build-context exclusions, avoid optional env warnings, and make the frontend healthcheck use the IPv4 loopback path that works inside the container.
2026-05-02 19:27:59 -06:00
BigBodyCobain e1060193d0 Improve v0.9.7 startup and runtime reliability
Prioritize cached first-paint data, defer heavyweight feed synthesis, make MeshChat activation explicit, improve CCTV media handling, and tighten desktop runtime packaging filters.
2026-05-02 17:31:54 -06:00
BigBodyCobain 08810f2537 fix: stabilize v0.9.7 startup and feeds 2026-05-02 13:35:49 -06:00
BigBodyCobain f5b9d14b48 Merge remote-tracking branch 'origin/main' 2026-05-02 09:40:23 -06:00
BigBodyCobain 9122d306cd fix: refresh privacy-core pin on source startup 2026-05-02 09:38:13 -06:00
Shadowbroker 03e5fc1363 Update README.md 2026-05-02 09:20:40 -06:00
BigBodyCobain 447afe0b2b build: refresh v0.9.7 updater key 2026-05-02 02:24:46 -06:00
BigBodyCobain d515aba450 fix: polish v0.9.7 micro update 2026-05-02 02:13:36 -06:00
Shadowbroker 3a8db7f9cd Update README.md 2026-05-02 00:30:34 -06:00
Shadowbroker f1cb1e860d Update README.md 2026-05-02 00:30:15 -06:00
Shadowbroker 38bcc976a4 Merge pull request #140 from BigBodyCobain/dependabot/pip/backend/yfinance-1.3.0
Upgrades yfinance from 0.2.54 to 1.3.0 in /backend
2026-05-02 00:26:10 -06:00
Shadowbroker 77b4361ad6 Merge pull request #141 from BigBodyCobain/dependabot/pip/backend/playwright-1.59.0
Bump playwright from 1.50.0 to 1.59.0 in /backend
2026-05-02 00:25:23 -06:00
Shadowbroker c5819d40d1 Merge pull request #138 from BigBodyCobain/dependabot/pip/backend/pydantic-2.13.3
Gets pydantic from 2.11.1 to 2.13.3 in /backend
2026-05-02 00:24:54 -06:00
Shadowbroker 009574db81 Merge pull request #143 from BigBodyCobain/dependabot/pip/backend/sgp4-2.25
Updates sgp4 from 2.23 to 2.25 in /backend
2026-05-02 00:24:32 -06:00
Shadowbroker 281371e135 Merge pull request #145 from BigBodyCobain/dependabot/npm_and_yarn/frontend/eslint-config-next-16.2.4
Upgrades eslint-config-next from 16.1.6 to 16.2.4 in /frontend
2026-05-02 00:24:02 -06:00
Shadowbroker 401268f22a Merge pull request #142 from BigBodyCobain/dependabot/npm_and_yarn/frontend/tailwindcss/postcss-4.2.4
Bumps @tailwindcss/postcss from 4.2.1 to 4.2.4 in /frontend
2026-05-02 00:23:25 -06:00
Shadowbroker f830148e69 Merge pull request #144 from BigBodyCobain/dependabot/npm_and_yarn/frontend/prettier-3.8.3
bump prettier from 3.8.1 to 3.8.3 in /frontend
2026-05-02 00:22:50 -06:00
Shadowbroker 4068c31cfa Update README.md 2026-05-02 00:17:45 -06:00
Shadowbroker 50721816fa Merge pull request #148 from BigBodyCobain/codex/v0.9.7-postmerge-ci
test: stabilize v0.9.7 post-merge CI
2026-05-02 00:01:59 -06:00
BigBodyCobain 5dac844532 test: stabilize secure mail warmup assertion 2026-05-01 23:54:25 -06:00
dependabot[bot] 8884675845 chore(deps-dev): bump eslint-config-next in /frontend
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.1.6 to 16.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 05:49:22 +00:00
dependabot[bot] 58144d1b82 chore(deps-dev): bump prettier from 3.8.1 to 3.8.3 in /frontend
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 05:49:08 +00:00
dependabot[bot] da2a27f92a chore(deps): bump sgp4 from 2.23 to 2.25 in /backend
Bumps [sgp4](https://github.com/brandon-rhodes/python-sgp4) from 2.23 to 2.25.
- [Commits](https://github.com/brandon-rhodes/python-sgp4/compare/2.23...2.25)

---
updated-dependencies:
- dependency-name: sgp4
  dependency-version: '2.25'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 05:49:04 +00:00
dependabot[bot] f6f6176a12 chore(deps-dev): bump @tailwindcss/postcss in /frontend
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.2.1 to 4.2.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 05:49:02 +00:00
dependabot[bot] e6bea9dad3 chore(deps): bump playwright from 1.50.0 to 1.59.0 in /backend
Bumps [playwright](https://github.com/microsoft/playwright-python) from 1.50.0 to 1.59.0.
- [Release notes](https://github.com/microsoft/playwright-python/releases)
- [Commits](https://github.com/microsoft/playwright-python/compare/v1.50.0...v1.59.0)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 05:49:00 +00:00
dependabot[bot] aebd5f0198 chore(deps): bump yfinance from 0.2.54 to 1.3.0 in /backend
Bumps [yfinance](https://github.com/ranaroussi/yfinance) from 0.2.54 to 1.3.0.
- [Release notes](https://github.com/ranaroussi/yfinance/releases)
- [Changelog](https://github.com/ranaroussi/yfinance/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/ranaroussi/yfinance/compare/0.2.54...1.3.0)

---
updated-dependencies:
- dependency-name: yfinance
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 05:48:56 +00:00
dependabot[bot] 2f70b50f65 chore(deps): bump pydantic from 2.11.1 to 2.13.3 in /backend
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.11.1 to 2.13.3.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.11.1...v2.13.3)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.13.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 05:48:49 +00:00
Shadowbroker 1b2ad5023d Merge pull request #137 from BigBodyCobain/codex/v0.9.7-release
release: prepare v0.9.7
2026-05-01 23:47:58 -06:00
55 changed files with 2751 additions and 799 deletions
+33
View File
@@ -8,6 +8,18 @@ __pycache__/
venv/
.venv/
.ruff_cache/
local-artifacts/
release-secrets/
# Never send local configuration or credentials into Docker builds
.env
.env.*
**/.env
**/.env.*
*.pem
*.key
*.p12
*.pfx
# privacy-core build caches (source is needed, artifacts are not)
privacy-core/target/
@@ -21,3 +33,24 @@ privacy-core/.codex-tmp/
*.log
extra/
prototype/
# Runtime state generated by local backend runs
backend/.pytest_cache/
backend/.ruff_cache/
backend/backend.egg-info/
backend/build/
backend/node_modules/
backend/timemachine/
backend/venv/
backend/data/*cache*.json
backend/data/**/*cache*.json
backend/data/wormhole*.json
backend/data/**/wormhole*.json
backend/data/dm_*.json
backend/data/**/dm_*.json
backend/data/**/peer_store.json
backend/data/**/node.json
backend/data/*.log
backend/data/**/*.log
backend/data/*.key
backend/data/**/*.key
+20
View File
@@ -38,6 +38,26 @@ ADMIN_KEY=
# Leave blank to skip this optional enrichment.
# NUFORC_MAPBOX_TOKEN=
# Optional startup-risk controls.
# On Windows, external curl fallback and the Playwright LiveUAMap scraper are
# disabled by default so blocked upstream feeds cannot interrupt start.bat.
# SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK=false
# SHADOWBROKER_ENABLE_LIVEUAMAP_SCRAPER=false
# AIS starts by default when AIS_API_KEY is set. Set to 0/false to force-disable.
# SHADOWBROKER_ENABLE_AIS_STREAM_PROXY=true
# Minimum visible satellite catalog before forcing a CelesTrak refresh.
# SHADOWBROKER_MIN_VISIBLE_SATELLITES=350
# Upper bound for TLE fallback satellite search when CelesTrak is unreachable.
# SHADOWBROKER_MAX_VISIBLE_SATELLITES=450
# NUFORC fallback uses the Hugging Face mirror when live NUFORC is unavailable.
# NUFORC_HF_FALLBACK_LIMIT=250
# NUFORC_HF_GEOCODE_LIMIT=150
# First-paint cache age budgets. These let the map and Global Threat Intercept
# paint from the last local snapshot while live feeds refresh in the background.
# FAST_STARTUP_CACHE_MAX_AGE_S=21600
# INTEL_STARTUP_CACHE_MAX_AGE_S=21600
# Google Earth Engine for VIIRS night lights change detection (optional).
# pip install earthengine-api
# GEE_SERVICE_ACCOUNT_KEY=
+9 -8
View File
@@ -11,15 +11,15 @@
![ShadowBroker](/uploads/46f99d19fa141a2efba37feee9de8aab/Title.jpg)
[![ShadowBroker](/uploads/46f99d19fa141a2efba37feee9de8aab/Title.jpg)](https://github.com/user-attachments/assets/248208ec-62f7-49d1-831d-4bd0a1fa6852)
**ShadowBroker** is a decentralized real-time, multi-domain OSINT dashboard that fuses 60+ live intelligence feeds into a single dark-ops map interface. Aircraft, ships, satellites, conflict zones, CCTV networks, GPS jamming, internet-connected devices, police scanners, mesh radio nodes, and breaking geopolitical events — all updating in real time on one screen as well as a obfuscated communications protocol and information exchange infrastructure.
**ShadowBroker** is a decentralized real-time, multi-domain OSINT dashboard that fuses 60+ live intelligence feeds into a single dark-ops map interface. Aircraft, ships, satellites, conflict zones, CCTV networks, GPS jamming, internet-connected devices, police scanners, mesh radio nodes, and breaking geopolitical events — all updating in real time on one screen as well as an obfuscated communications protocol and information exchange infrastructure.
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**. 35+ toggleable data layers including SAR ground-change detection. Multiple visual modes (DEFAULT / SATELLITE / FLIR / NVG / CRT). Right-click any point on Earth for a country dossier, head-of-state lookup, and the latest Sentinel-2 satellite photo. No user data is collected or transmitted — the dashboard runs entirely in your browser against a self-hosted backend.
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**. 35+ toggleable data layers, including SAR ground-change detection. Multiple visual modes (DEFAULT / SATELLITE / FLIR / NVG / CRT). Right-click any point on Earth for a country dossier, head-of-state lookup, and the latest Sentinel-2 satellite photo. No user data is collected or transmitted — the dashboard runs entirely in your browser against a self-hosted backend.
Designed for analysts, researchers, radio operators, and anyone who wants to see what the world looks like when every public signal is on the same map.
@@ -38,10 +38,9 @@ ShadowBroker includes an optional Shodan connector for operator-supplied API acc
## Interesting Use Cases
* **Track Air Force One**, the private jets of billionaires and dictators, and every military tanker, ISR, and fighter broadcasting ADS-B. Air Force One and all of the accompanying Presidential/Vice Presidential planes are highlighted and monitored from the moment they leave the ground.
* **Connect an AI agent as a co-analyst** through ShadowBroker's HMAC-signed agentic command channel — supports OpenClaw and any other agent that speaks the protocol (Claude, GPT, LangChain, custom). The agent gets full read/write access to all 35+ data layers, pin placement, map control, SAR ground-change, mesh networking, and alert delivery. It sees everything the operator sees and can take actions on the map in real time.
* **Communicate on the InfoNet testnet** — The first decentralized intelligence mesh built into an OSINT tool. Obfuscated messaging with gate personas, Dead Drop peer-to-peer exchange, and a built-in terminal CLI. No accounts, no signup. Privacy is not guaranteed yet — this is an experimental testnet — but the protocol is live and being hardened.
* **Track Air Force One**, the private jets of billionaires and dictators, and every military tanker, ISR, and fighter broadcasting ADS-B — with automatic holding pattern detection when aircraft start circling
* **Estimate where US aircraft carriers are** using automated GDELT news scraping — no other open tool does this
* **Search internet-connected devices worldwide** via Shodan — cameras, SCADA systems, databases — plotted as a live overlay on the map
* **Right-click anywhere on Earth** for a country dossier (head of state, population, languages), Wikipedia summary, and the latest Sentinel-2 satellite photo at 10m resolution
* **Click a KiwiSDR node** and tune into live shortwave radio directly in the dashboard. Click a police scanner feed and eavesdrop in one click.
* **Watch 11,000+ CCTV cameras** across 6 countries — London, NYC, California, Spain, Singapore, and more — streaming live on the map
@@ -51,10 +50,12 @@ ShadowBroker includes an optional Shodan connector for operator-supplied API acc
* **Follow earthquakes, volcanic eruptions, active wildfires** (NASA FIRMS), severe weather alerts, and air quality readings worldwide
* **Map military bases, 35,000+ power plants**, 2,000+ data centers, and internet outage regions — cross-referenced automatically
* **Connect to Meshtastic mesh radio nodes** and APRS amateur radio networks — visible on the map and integrated into Mesh Chat
* **Connect an AI agent as a co-analyst** through ShadowBroker's HMAC-signed agentic command channel — supports OpenClaw and any other agent that speaks the protocol (Claude, GPT, LangChain, custom). The agent gets full read/write access to all 35+ data layers, pin placement, map control, SAR ground-change, mesh networking, and alert delivery. It sees everything the operator sees and can take actions on the map in real time.
* **Detect ground changes through cloud cover** with SAR (Synthetic Aperture Radar) — mm-scale ground deformation, flood extent, vegetation disturbance, and damage assessments from NASA OPERA and Copernicus EGMS. Define your own watch areas and get anomaly alerts. Free with a NASA Earthdata account.
* **Switch visual modes** — DEFAULT, SATELLITE, FLIR (thermal), NVG (night vision), CRT (retro terminal) — via the STYLE button
* **Track trains** across the US (Amtrak) and Europe (DigiTraffic) in real time
* **Estimate where US aircraft carriers are** using automated GDELT news scraping — no other open tool does this
* **Search internet-connected devices worldwide** via Shodan — cameras, SCADA systems, databases — plotted as a live overlay on the map
---
@@ -943,7 +944,7 @@ ShadowBroker is built in the open. These people shipped real code:
| Who | What | PR |
|-----|------|----|
| [@Alienmajik](https://github.com/Alienmajik) | Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, runtime tuning for Pi-class hardware | — |
| [@Alienmajik](https://gitlab.com/Alienmajik) | Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, runtime tuning for Pi-class hardware | — |
| [@wa1id](https://github.com/wa1id) | CCTV ingestion fix — threaded SQLite, persistent DB, startup hydration, cluster clickability | #92 |
| [@AlborzNazari](https://github.com/AlborzNazari) | Spain DGT + Madrid CCTV sources, STIX 2.1 threat intel export | #91 |
| [@adust09](https://github.com/adust09) | Power plants layer, East Asia intel coverage (JSDF bases, ICAO enrichment, Taiwan news, military classification) | #71, #72, #76, #77, #87 |
+8
View File
@@ -49,6 +49,13 @@ RUN cd /workspace/backend && uv sync --frozen --no-dev \
# Copy backend source code
COPY backend/ .
# Preserve safe static data outside /app/data. The compose named volume mounted
# at /app/data hides image-baked files on first run, so the entrypoint seeds
# missing static JSON into fresh volumes before the backend starts.
RUN mkdir -p /app/image-data \
&& if [ -d /app/data ]; then cp -a /app/data/. /app/image-data/; fi \
&& chmod +x /app/docker-entrypoint.sh
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
COPY backend/package*.json ./
RUN npm ci --omit=dev
@@ -75,4 +82,5 @@ USER backenduser
EXPOSE 8000
# Start FastAPI server
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-keep-alive", "120"]
+38 -5
View File
@@ -13,6 +13,7 @@ import hmac
import asyncio
import hmac as _hmac_mod
import hashlib as _hashlib_mod
import ipaddress
import json as json_mod
import logging
import time
@@ -235,10 +236,36 @@ def _is_local_or_docker(host: str) -> bool:
return host in {"127.0.0.1", "::1", "localhost"}
def _docker_bridge_local_operator_enabled() -> bool:
return str(os.environ.get("SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _is_docker_bridge_host(host: str) -> bool:
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
# Docker Desktop and the default compose bridge normally sit inside
# 172.16.0.0/12. Keep this narrower than "any private IP" so a user who
# intentionally binds the backend to LAN does not silently trust LAN clients.
return ip in ipaddress.ip_network("172.16.0.0/12")
def _is_trusted_local_runtime_host(host: str) -> bool:
if _is_local_or_docker(host):
return True
return _docker_bridge_local_operator_enabled() and _is_docker_bridge_host(host)
def require_local_operator(request: Request):
"""Allow local tooling on loopback / Docker internal network, or a valid admin key."""
host = (request.client.host or "").lower() if request.client else ""
if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"):
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
return
admin_key = _current_admin_key()
presented = str(request.headers.get("X-Admin-Key", "") or "").strip()
@@ -362,8 +389,8 @@ async def require_openclaw_or_local(request: Request):
"""
host = (request.client.host or "").lower() if request.client else ""
# 1. Local loopback — always allowed
if _is_local_or_docker(host) or (_debug_mode_enabled() and host == "test"):
# 1. Local runtime path — loopback, plus bundled Docker bridge when compose opts in
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
return
# 2. Admin key — full trust
@@ -402,7 +429,9 @@ async def require_openclaw_or_local(request: Request):
# Startup validators
# ---------------------------------------------------------------------------
_KNOWN_COMPROMISED_PEER_PUSH_SECRET = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
_KNOWN_COMPROMISED_PEER_PUSH_SECRET_SHA256 = (
"be05bc75350d6e5d2e154e969c4dfc14bab1e48a9661c64ab7a331e0aa96aea7"
)
def _validate_admin_startup() -> None:
@@ -492,7 +521,11 @@ def _validate_peer_push_secret() -> None:
secret = os.environ.get("MESH_PEER_PUSH_SECRET", "").strip()
# Replace the known-compromised testnet default automatically
if secret == _KNOWN_COMPROMISED_PEER_PUSH_SECRET:
if (
secret
and _hashlib_mod.sha256(secret.encode("utf-8")).hexdigest()
== _KNOWN_COMPROMISED_PEER_PUSH_SECRET_SHA256
):
logger.warning(
"MESH_PEER_PUSH_SECRET was the publicly-known testnet default — "
"auto-generating a secure replacement."
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
# Docker named volumes hide files that were baked into /app/data at image build
# time. Seed safe, static data into a fresh volume so first-run Docker installs
# behave like source installs without bundling local runtime secrets.
if [ -d /app/image-data ]; then
mkdir -p /app/data
find /app/image-data -mindepth 1 -maxdepth 1 -type f | while IFS= read -r src; do
dest="/app/data/$(basename "$src")"
if [ ! -e "$dest" ]; then
cp "$src" "$dest" || true
fi
done
fi
exec "$@"
+138 -15
View File
@@ -200,6 +200,7 @@ from services.data_fetcher import (
start_scheduler,
stop_scheduler,
get_latest_data,
seed_startup_caches,
)
from services.ais_stream import start_ais_stream, stop_ais_stream
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
@@ -2174,19 +2175,26 @@ async def lifespan(app: FastAPI):
# Only the primary backend supervises Wormhole. The Wormhole process itself
# runs this same app in MESH_ONLY mode and must not recurse into spawning.
if not _MESH_ONLY:
try:
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
def _startup_wormhole_runtime():
try:
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
sync_wormhole_with_settings()
_resume_private_delivery_background_work(
current_tier=_current_private_lane_tier(get_wormhole_state()),
reason="startup_resume",
)
_refresh_lookup_handle_rotation_background(reason="startup_resume")
privacy_prewarm_service.ensure_started()
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
except Exception as e:
logger.warning(f"Wormhole supervisor failed to sync: {e}")
sync_wormhole_with_settings()
_resume_private_delivery_background_work(
current_tier=_current_private_lane_tier(get_wormhole_state()),
reason="startup_resume",
)
_refresh_lookup_handle_rotation_background(reason="startup_resume")
privacy_prewarm_service.ensure_started()
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
except Exception as e:
logger.warning(f"Wormhole supervisor failed to sync: {e}")
threading.Thread(
target=_startup_wormhole_runtime,
daemon=True,
name="wormhole-startup-sync",
).start()
try:
from services.mesh.mesh_hashchain import register_public_event_append_hook
@@ -2232,6 +2240,11 @@ async def lifespan(app: FastAPI):
threading.Thread(target=_prime_aircraft_database, daemon=True).start()
# Seed cached first-paint layers before accepting requests. This is
# disk-only and keeps the critical bootstrap endpoint independent from
# slow network warmup.
seed_startup_caches()
# Start the recurring scheduler (fast=60s, slow=30min).
start_scheduler()
@@ -2239,6 +2252,9 @@ async def lifespan(app: FastAPI):
# is listening on port 8000 instantly. The frontend's adaptive polling
# (retries every 3s) will pick up data piecemeal as each fetcher finishes.
def _background_preload():
delay_s = float(os.environ.get("SHADOWBROKER_STARTUP_PRELOAD_DELAY_S", "2.0") or 0)
if delay_s > 0:
time.sleep(delay_s)
logger.info("=== PRELOADING DATA (background — server already accepting requests) ===")
try:
update_all_data(startup_mode=True)
@@ -3472,6 +3488,46 @@ def _sigint_totals_for_items(items: list) -> dict[str, int]:
return totals
def _cap_startup_items(items: list | None, max_items: int) -> list:
if not items:
return []
if len(items) <= max_items:
return items
return items[:max_items]
def _cap_fast_startup_payload(payload: dict) -> dict:
"""Trim high-volume layers for the first dashboard paint.
The full fast payload can legitimately contain tens of thousands of AIS,
ADS-B, SIGINT, and CCTV records. Returning all of that during app startup
blocks the first map render behind serialization/proxy/network pressure.
This startup payload paints representative live data immediately; the next
normal poll replaces it with the full dataset.
"""
capped = dict(payload)
capped["commercial_flights"] = _cap_startup_items(capped.get("commercial_flights"), 800)
capped["private_flights"] = _cap_startup_items(capped.get("private_flights"), 300)
capped["private_jets"] = _cap_startup_items(capped.get("private_jets"), 150)
capped["ships"] = _cap_startup_items(capped.get("ships"), 1500)
capped["cctv"] = []
capped["sigint"] = _cap_startup_items(capped.get("sigint"), 500)
capped["trains"] = _cap_startup_items(capped.get("trains"), 100)
capped["startup_payload"] = True
return capped
def _cap_fast_dashboard_payload(payload: dict) -> dict:
capped = dict(payload)
capped["commercial_flights"] = _downsample_points(capped.get("commercial_flights") or [], 6000)
capped["private_flights"] = _downsample_points(capped.get("private_flights") or [], 1500)
capped["private_jets"] = _downsample_points(capped.get("private_jets") or [], 1500)
capped["ships"] = _downsample_points(capped.get("ships") or [], 8000)
capped["cctv"] = _downsample_points(capped.get("cctv") or [], 2500)
capped["sigint"] = _downsample_points(capped.get("sigint") or [], 5000)
return capped
@app.get("/api/live-data/fast")
@limiter.limit("120/minute")
async def live_data_fast(
@@ -3482,8 +3538,9 @@ async def live_data_fast(
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
initial: bool = Query(False, description="Return a capped startup payload for first paint"),
):
etag = _current_etag(prefix="fast|full|")
etag = _current_etag(prefix="fast|initial|" if initial else "fast|full|")
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
@@ -3548,6 +3605,10 @@ async def live_data_fast(
"trains": (d.get("trains") or []) if active_layers.get("trains", True) else [],
"freshness": freshness,
}
if initial:
payload = _cap_fast_startup_payload(payload)
else:
payload = _cap_fast_dashboard_payload(payload)
return Response(
content=orjson.dumps(_sanitize_payload(payload)),
media_type="application/json",
@@ -3609,6 +3670,7 @@ async def live_data_slow(
"correlations",
"threat_level",
"trending_markets",
"fimi",
"uap_sightings",
"wastewater",
"sar_scenes",
@@ -3621,6 +3683,7 @@ async def live_data_slow(
"last_updated": d.get("last_updated"),
"threat_level": d.get("threat_level"),
"trending_markets": d.get("trending_markets", []),
"fimi": d.get("fimi", {}),
"news": d.get("news", []),
"stocks": d.get("stocks", {}),
"financial_source": d.get("financial_source", ""),
@@ -7604,6 +7667,13 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
"infocar.dgt.es", # Spain DGT
"informo.madrid.es", # Madrid
"www.windy.com",
"imgproxy.windy.com", # Windy preview image CDN
"www.lakecountypassage.com", # Illinois Lake County PASSAGE snapshots
"webcam.forkswa.com", # WSDOT partner public camera
"webcam.sunmountainlodge.com", # WSDOT partner public camera
"www.nps.gov", # WSDOT-linked Mount Rainier camera
"home.lewiscounty.com", # WSDOT partner public camera
"www.seattle.gov", # Seattle traffic camera media linked from WSDOT
}
@@ -7729,6 +7799,16 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
)
if host == "www.lakecountypassage.com":
return _CCTVProxyProfile(
name="lake-county-passage",
timeout=(5.0, 12.0),
cache_seconds=30,
headers={
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://www.lakecountypassage.com/",
},
)
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
return _CCTVProxyProfile(
name="michigan-dot",
@@ -7791,11 +7871,27 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
"Referer": "https://informo.madrid.es/",
},
)
if host == "www.windy.com":
if host in {"www.windy.com", "imgproxy.windy.com"}:
return _CCTVProxyProfile(
name="windy-webcams",
timeout=(5.0, 12.0),
cache_seconds=60,
headers={
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://www.windy.com/",
},
)
if host in {
"webcam.forkswa.com",
"webcam.sunmountainlodge.com",
"www.nps.gov",
"home.lewiscounty.com",
"www.seattle.gov",
}:
return _CCTVProxyProfile(
name="wsdot-partner",
timeout=(5.0, 12.0),
cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
)
return _CCTVProxyProfile(
@@ -7839,6 +7935,30 @@ def _cctv_response_headers(resp, cache_seconds: int, include_length: bool = True
return headers
def _infer_cctv_media_type_from_url(target_url: str, content_type: str) -> str:
from urllib.parse import urlparse
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
if normalized_type and normalized_type not in {"application/octet-stream", "binary/octet-stream"}:
return content_type
path = str(urlparse(target_url).path or "").lower()
if path.endswith((".jpg", ".jpeg")):
return "image/jpeg"
if path.endswith(".png"):
return "image/png"
if path.endswith(".gif"):
return "image/gif"
if path.endswith(".webp"):
return "image/webp"
if path.endswith(".mp4"):
return "video/mp4"
if path.endswith(".webm"):
return "video/webm"
if path.endswith(".m3u8"):
return "application/vnd.apple.mpegurl"
return content_type or "application/octet-stream"
def _fetch_cctv_upstream_response(request: Request, target_url: str, profile: _CCTVProxyProfile):
import requests as _req
@@ -7872,7 +7992,10 @@ def _proxy_cctv_media_response(request: Request, target_url: str):
profile = _cctv_proxy_profile_for_url(target_url)
resp = _fetch_cctv_upstream_response(request, target_url, profile)
content_type = resp.headers.get("Content-Type", "application/octet-stream")
content_type = _infer_cctv_media_type_from_url(
target_url,
resp.headers.get("Content-Type", "application/octet-stream"),
)
is_hls_playlist = (
".m3u8" in str(parsed.path or "").lower()
or "mpegurl" in content_type.lower()
+4 -4
View File
@@ -18,15 +18,15 @@ dependencies = [
"fastapi==0.115.12",
"feedparser==6.0.10",
"httpx==0.28.1",
"playwright==1.50.0",
"playwright==1.59.0",
"playwright-stealth==1.0.6",
"pydantic==2.11.1",
"pydantic==2.13.3",
"pydantic-settings==2.8.1",
"pystac-client==0.8.6",
"python-dotenv==1.2.2",
"requests==2.31.0",
"reverse-geocoder==1.5.1",
"sgp4==2.23",
"sgp4==2.25",
"meshtastic>=2.5.0",
"orjson>=3.10.0",
"paho-mqtt>=1.6.0,<2.0.0",
@@ -34,7 +34,7 @@ dependencies = [
"slowapi==0.1.9",
"vaderSentiment>=3.3.0",
"uvicorn==0.34.0",
"yfinance==0.2.54",
"yfinance==1.3.0",
]
[dependency-groups]
+22 -1
View File
@@ -28,13 +28,34 @@ class TimeMachineToggle(BaseModel):
enabled: bool
@router.get("/api/settings/api-keys", dependencies=[Depends(require_admin)])
@router.get("/api/settings/api-keys", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_get_keys(request: Request):
from services.api_settings import get_api_keys
return get_api_keys()
@router.put("/api/settings/api-keys", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_save_keys(request: Request):
from services.api_settings import save_api_keys
body = await request.json()
if not isinstance(body, dict):
return Response(
content=json_mod.dumps({"ok": False, "detail": "Expected a JSON object."}),
status_code=400,
media_type="application/json",
)
result = save_api_keys({str(k): str(v) for k, v in body.items()})
if result.get("ok"):
return result
return Response(
content=json_mod.dumps(result),
status_code=400,
media_type="application/json",
)
@router.get("/api/settings/api-keys/meta")
@limiter.limit("30/minute")
async def api_get_keys_meta(request: Request):
+67 -22
View File
@@ -11,6 +11,8 @@ logger = logging.getLogger(__name__)
router = APIRouter()
_CCTV_PROXY_CONNECT_TIMEOUT_S = 2.0
_CCTV_PROXY_ALLOWED_HOSTS = {
"s3-eu-west-1.amazonaws.com",
"jamcams.tfl.gov.uk",
@@ -46,13 +48,20 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
"infocar.dgt.es",
"informo.madrid.es",
"www.windy.com",
"imgproxy.windy.com",
"www.lakecountypassage.com",
"webcam.forkswa.com",
"webcam.sunmountainlodge.com",
"www.nps.gov",
"home.lewiscounty.com",
"www.seattle.gov",
}
@dataclass(frozen=True)
class _CCTVProxyProfile:
name: str
timeout: tuple = (5.0, 10.0)
timeout: tuple = (_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0)
cache_seconds: int = 30
headers: dict = field(default_factory=dict)
@@ -80,69 +89,78 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
path = str(parsed.path or "").strip().lower()
if host in {"jamcams.tfl.gov.uk", "s3-eu-west-1.amazonaws.com"}:
return _CCTVProxyProfile(name="tfl-jamcam", timeout=(5.0, 20.0), cache_seconds=15,
return _CCTVProxyProfile(name="tfl-jamcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=15,
headers={"Accept": "video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://tfl.gov.uk/"})
if host == "images.data.gov.sg":
return _CCTVProxyProfile(name="lta-singapore", timeout=(5.0, 10.0), cache_seconds=30,
return _CCTVProxyProfile(name="lta-singapore", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
if host == "cctv.austinmobility.io":
return _CCTVProxyProfile(name="austin-mobility", timeout=(5.0, 8.0), cache_seconds=15,
return _CCTVProxyProfile(name="austin-mobility", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=15,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov"})
if host == "webcams.nyctmc.org":
return _CCTVProxyProfile(name="nyc-dot", timeout=(5.0, 10.0), cache_seconds=15,
return _CCTVProxyProfile(name="nyc-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=15,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
if host in {"cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov"}:
return _CCTVProxyProfile(name="caltrans", timeout=(5.0, 15.0), cache_seconds=15,
return _CCTVProxyProfile(name="caltrans", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 15.0), cache_seconds=15,
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,image/*,*/*;q=0.8",
"Referer": "https://cwwp2.dot.ca.gov/"})
if host in {"images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com"}:
return _CCTVProxyProfile(name="wsdot", timeout=(5.0, 12.0), cache_seconds=30,
return _CCTVProxyProfile(name="wsdot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
if host in {"www.lakecountypassage.com", "webcam.forkswa.com", "webcam.sunmountainlodge.com", "home.lewiscounty.com", "www.seattle.gov"}:
return _CCTVProxyProfile(name="regional-cctv-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=45,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": f"https://{host}/"})
if host == "www.nps.gov":
return _CCTVProxyProfile(name="nps-webcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=60,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://www.nps.gov/"})
if host in {"navigator-c2c.dot.ga.gov", "navigator-c2c.ga.gov", "navigator-csc.dot.ga.gov"}:
read_timeout = 18.0 if "/snapshots/" in path else 12.0
return _CCTVProxyProfile(name="gdot-snapshot", timeout=(5.0, read_timeout), cache_seconds=15,
return _CCTVProxyProfile(name="gdot-snapshot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, read_timeout), cache_seconds=15,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "http://navigator-c2c.dot.ga.gov/"})
if host == "511ga.org":
return _CCTVProxyProfile(name="gdot-511ga-image", timeout=(5.0, 12.0), cache_seconds=15,
return _CCTVProxyProfile(name="gdot-511ga-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=15,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://511ga.org/cctv"})
if host.startswith("vss") and host.endswith("dot.ga.gov"):
return _CCTVProxyProfile(name="gdot-hls", timeout=(5.0, 20.0), cache_seconds=10,
return _CCTVProxyProfile(name="gdot-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10,
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
"Referer": "http://navigator-c2c.dot.ga.gov/"})
if host in {"gettingaroundillinois.com", "cctv.travelmidwest.com"}:
return _CCTVProxyProfile(name="illinois-dot", timeout=(5.0, 12.0), cache_seconds=30,
return _CCTVProxyProfile(name="illinois-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
return _CCTVProxyProfile(name="michigan-dot", timeout=(5.0, 12.0), cache_seconds=30,
return _CCTVProxyProfile(name="michigan-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://mdotjboss.state.mi.us/"})
if host in {"publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org",
"publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org"}:
return _CCTVProxyProfile(name="cotrip-hls", timeout=(5.0, 20.0), cache_seconds=10,
return _CCTVProxyProfile(name="cotrip-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10,
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
"Referer": "https://www.cotrip.org/"})
if host == "cocam.carsprogram.org":
return _CCTVProxyProfile(name="cotrip-preview", timeout=(5.0, 12.0), cache_seconds=20,
return _CCTVProxyProfile(name="cotrip-preview", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=20,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://www.cotrip.org/"})
if host in {"tripcheck.com", "www.tripcheck.com"}:
return _CCTVProxyProfile(name="odot-tripcheck", timeout=(5.0, 12.0), cache_seconds=30,
return _CCTVProxyProfile(name="odot-tripcheck", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
if host == "infocar.dgt.es":
return _CCTVProxyProfile(name="dgt-spain", timeout=(5.0, 8.0), cache_seconds=60,
return _CCTVProxyProfile(name="dgt-spain", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=60,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://infocar.dgt.es/"})
if host == "informo.madrid.es":
return _CCTVProxyProfile(name="madrid-city", timeout=(5.0, 12.0), cache_seconds=30,
return _CCTVProxyProfile(name="madrid-city", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://informo.madrid.es/"})
if host == "www.windy.com":
return _CCTVProxyProfile(name="windy-webcams", timeout=(5.0, 12.0), cache_seconds=60,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
return _CCTVProxyProfile(name="generic-cctv", timeout=(5.0, 10.0), cache_seconds=30,
if host in {"www.windy.com", "imgproxy.windy.com"}:
return _CCTVProxyProfile(name="windy-webcams", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=60,
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Referer": "https://www.windy.com/"})
return _CCTVProxyProfile(name="generic-cctv", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=30,
headers={"Accept": "*/*"})
@@ -221,13 +239,40 @@ def _rewrite_cctv_hls_playlist(base_url: str, body: str) -> str:
return "\n".join(rewritten_lines) + ("\n" if body.endswith("\n") else "")
def _infer_cctv_media_type_from_url(target_url: str, content_type: str) -> str:
from urllib.parse import urlparse
clean_type = str(content_type or "").split(";", 1)[0].strip().lower()
if clean_type and clean_type not in {"application/octet-stream", "binary/octet-stream"}:
return content_type
path = str(urlparse(target_url).path or "").lower()
if path.endswith((".jpg", ".jpeg")):
return "image/jpeg"
if path.endswith(".png"):
return "image/png"
if path.endswith(".webp"):
return "image/webp"
if path.endswith(".gif"):
return "image/gif"
if path.endswith(".mp4"):
return "video/mp4"
if path.endswith((".m3u8", ".m3u")):
return "application/vnd.apple.mpegurl"
if path.endswith((".mjpg", ".mjpeg")):
return "multipart/x-mixed-replace"
return content_type or "application/octet-stream"
def _proxy_cctv_media_response(request: Request, target_url: str):
from urllib.parse import urlparse
from fastapi.responses import Response
parsed = urlparse(target_url)
profile = _cctv_proxy_profile_for_url(target_url)
resp = _fetch_cctv_upstream_response(request, target_url, profile)
content_type = resp.headers.get("Content-Type", "application/octet-stream")
content_type = _infer_cctv_media_type_from_url(
target_url,
resp.headers.get("Content-Type", "application/octet-stream"),
)
is_hls_playlist = (
".m3u8" in str(parsed.path or "").lower()
or "mpegurl" in content_type.lower()
+139 -7
View File
@@ -185,11 +185,29 @@ def _bbox_spans(s, w, n, e) -> tuple:
return lat_span, max(0.0, lng_span)
def _downsample_points(items: list, max_items: int) -> list:
if max_items <= 0 or len(items) <= max_items:
def _cap_startup_items(items: list | None, max_items: int) -> list:
if not items:
return []
if len(items) <= max_items:
return items
step = len(items) / float(max_items)
return [items[min(len(items) - 1, int(i * step))] for i in range(max_items)]
return items[:max_items]
def _cap_fast_startup_payload(payload: dict) -> dict:
capped = dict(payload)
capped["commercial_flights"] = _cap_startup_items(capped.get("commercial_flights"), 800)
capped["private_flights"] = _cap_startup_items(capped.get("private_flights"), 300)
capped["private_jets"] = _cap_startup_items(capped.get("private_jets"), 150)
capped["ships"] = _cap_startup_items(capped.get("ships"), 1500)
capped["cctv"] = []
capped["sigint"] = _cap_startup_items(capped.get("sigint"), 500)
capped["trains"] = _cap_startup_items(capped.get("trains"), 100)
capped["startup_payload"] = True
return capped
def _cap_fast_dashboard_payload(payload: dict) -> dict:
return payload
def _world_and_continental_scale(has_bbox: bool, s, w, n, e) -> tuple:
@@ -306,8 +324,19 @@ async def update_layers(update: LayerUpdate, request: Request):
sigint_grid.mesh.stop()
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
elif not old_mesh and new_mesh:
sigint_grid.mesh.start()
logger.info("Meshtastic MQTT bridge started (layer enabled)")
try:
from services.config import get_settings
mqtt_enabled = bool(getattr(get_settings(), "MESH_MQTT_ENABLED", False))
except Exception:
mqtt_enabled = False
if mqtt_enabled:
sigint_grid.mesh.start()
logger.info("Meshtastic MQTT bridge started (layer enabled)")
else:
logger.info(
"Meshtastic layer enabled; MQTT bridge remains disabled "
"(set MESH_MQTT_ENABLED=true to participate in the public broker)"
)
if old_aprs and not new_aprs:
sigint_grid.aprs.stop()
logger.info("APRS bridge stopped (layer disabled)")
@@ -326,6 +355,104 @@ async def live_data(request: Request):
return get_latest_data()
@router.get("/api/bootstrap/critical")
@limiter.limit("180/minute")
async def bootstrap_critical(request: Request):
"""Cached first-paint payload for the dashboard.
This endpoint is intentionally memory-only: no upstream calls, no refresh,
and a bounded response. It exists so the map and threat feed can paint
before slower panels and background enrichers finish warming up.
"""
etag = _current_etag(prefix="bootstrap|critical|")
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
from services.fetchers._store import (
active_layers,
get_latest_data_subset_refs,
get_source_timestamps_snapshot,
)
d = get_latest_data_subset_refs(
"last_updated", "commercial_flights", "military_flights", "private_flights",
"private_jets", "tracked_flights", "ships", "uavs", "liveuamap", "gps_jamming",
"satellites", "satellite_source", "satellite_analysis", "sigint", "sigint_totals",
"trains", "news", "gdelt", "airports", "threat_level", "trending_markets",
"correlations", "fimi", "crowdthreat",
)
freshness = get_source_timestamps_snapshot()
ships_enabled = any(active_layers.get(key, True) for key in (
"ships_military", "ships_cargo", "ships_civilian", "ships_passenger", "ships_tracked_yachts"))
sigint_items = _filter_sigint_by_layers(d.get("sigint") or [], active_layers)
payload = {
"last_updated": d.get("last_updated"),
"commercial_flights": _cap_startup_items(
(d.get("commercial_flights") or []) if active_layers.get("flights", True) else [],
800,
),
"military_flights": _cap_startup_items(
(d.get("military_flights") or []) if active_layers.get("military", True) else [],
300,
),
"private_flights": _cap_startup_items(
(d.get("private_flights") or []) if active_layers.get("private", True) else [],
300,
),
"private_jets": _cap_startup_items(
(d.get("private_jets") or []) if active_layers.get("jets", True) else [],
150,
),
"tracked_flights": _cap_startup_items(
(d.get("tracked_flights") or []) if active_layers.get("tracked", True) else [],
250,
),
"ships": _cap_startup_items((d.get("ships") or []) if ships_enabled else [], 1500),
"uavs": _cap_startup_items((d.get("uavs") or []) if active_layers.get("military", True) else [], 100),
"liveuamap": _cap_startup_items(
(d.get("liveuamap") or []) if active_layers.get("global_incidents", True) else [],
300,
),
"gps_jamming": _cap_startup_items(
(d.get("gps_jamming") or []) if active_layers.get("gps_jamming", True) else [],
200,
),
"satellites": _cap_startup_items(
(d.get("satellites") or []) if active_layers.get("satellites", True) else [],
250,
),
"satellite_source": d.get("satellite_source", "none"),
"satellite_analysis": (d.get("satellite_analysis") or {}) if active_layers.get("satellites", True) else {},
"sigint": _cap_startup_items(
sigint_items if (active_layers.get("sigint_meshtastic", True) or active_layers.get("sigint_aprs", True)) else [],
500,
),
"sigint_totals": _sigint_totals_for_items(sigint_items),
"trains": _cap_startup_items((d.get("trains") or []) if active_layers.get("trains", True) else [], 100),
"news": _cap_startup_items(d.get("news") or [], 30),
"gdelt": _cap_startup_items((d.get("gdelt") or []) if active_layers.get("global_incidents", True) else [], 300),
"airports": _cap_startup_items(d.get("airports") or [], 500),
"threat_level": d.get("threat_level"),
"trending_markets": _cap_startup_items(d.get("trending_markets") or [], 10),
"correlations": _cap_startup_items(
(d.get("correlations") or []) if active_layers.get("correlations", True) else [],
50,
),
"fimi": d.get("fimi"),
"crowdthreat": _cap_startup_items(
(d.get("crowdthreat") or []) if active_layers.get("crowdthreat", True) else [],
150,
),
"freshness": freshness,
"bootstrap_ready": True,
"bootstrap_payload": True,
}
return Response(
content=orjson.dumps(_sanitize_payload(payload), default=str, option=orjson.OPT_NON_STR_KEYS),
media_type="application/json",
headers={"ETag": etag, "Cache-Control": "no-cache"},
)
@router.get("/api/live-data/fast")
@limiter.limit("120/minute")
async def live_data_fast(
@@ -334,8 +461,9 @@ async def live_data_fast(
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
initial: bool = Query(False, description="Return a capped startup payload for first paint"),
):
etag = _current_etag(prefix="fast|full|")
etag = _current_etag(prefix="fast|initial|" if initial else "fast|full|")
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
@@ -371,6 +499,10 @@ async def live_data_fast(
"trains": (d.get("trains") or []) if active_layers.get("trains", True) else [],
"freshness": freshness,
}
if initial:
payload = _cap_fast_startup_payload(payload)
else:
payload = _cap_fast_dashboard_payload(payload)
return Response(content=orjson.dumps(_sanitize_payload(payload)), media_type="application/json",
headers={"ETag": etag, "Cache-Control": "no-cache"})
+35 -3
View File
@@ -17,6 +17,18 @@ AIS_WS_URL = "wss://stream.aisstream.io/v0/stream"
API_KEY = os.environ.get("AIS_API_KEY", "")
def _env_truthy(name: str) -> bool:
return str(os.getenv(name, "")).strip().lower() in {"1", "true", "yes", "on"}
def ais_stream_proxy_enabled() -> bool:
"""Return whether the external Node AIS proxy may be started."""
setting = str(os.getenv("SHADOWBROKER_ENABLE_AIS_STREAM_PROXY", "")).strip().lower()
if setting:
return _env_truthy("SHADOWBROKER_ENABLE_AIS_STREAM_PROXY")
return True
# AIS vessel type code classification
# See: https://coast.noaa.gov/data/marinecadastre/ais/VesselTypeCodes2018.pdf
def classify_vessel(ais_type: int, mmsi: int) -> str:
@@ -496,6 +508,12 @@ def _ais_stream_loop():
logger.info("Starting Node.js AIS Stream Proxy...")
proxy_env = os.environ.copy()
proxy_env["AIS_API_KEY"] = API_KEY
popen_kwargs = {}
if os.name == "nt":
popen_kwargs["creationflags"] = (
getattr(subprocess, "CREATE_NO_WINDOW", 0)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
)
process = subprocess.Popen(
["node", proxy_script],
stdin=subprocess.PIPE,
@@ -504,6 +522,7 @@ def _ais_stream_loop():
text=True,
bufsize=1,
env=proxy_env,
**popen_kwargs,
)
with _vessels_lock:
_proxy_process = process
@@ -646,6 +665,22 @@ def _run_ais_loop():
def start_ais_stream():
"""Start the AIS WebSocket stream in a background thread."""
global _ws_thread, _ws_running
# Always load cached vessel data first so the ships layer can paint even
# when live streaming is disabled or the upstream is unavailable.
_load_cache()
if not API_KEY:
logger.info("AIS_API_KEY not set — ship tracking disabled. Set AIS_API_KEY to enable.")
return
if not ais_stream_proxy_enabled():
logger.info(
"AIS live stream proxy disabled for this runtime; using cached AIS vessels. "
"Set SHADOWBROKER_ENABLE_AIS_STREAM_PROXY=1 to opt in."
)
return
with _vessels_lock:
if _ws_running:
logger.info("AIS Stream already running")
@@ -656,9 +691,6 @@ def start_ais_stream():
logger.info("AIS Stream already running")
return
# Load cached vessel data from disk
_load_cache()
_ws_thread = threading.Thread(target=_run_ais_loop, daemon=True, name="ais-stream")
_ws_thread.start()
logger.info("AIS Stream background thread started")
+147
View File
@@ -4,12 +4,21 @@ Keys are stored in the backend .env file and loaded via python-dotenv.
"""
import os
import re
import tempfile
from pathlib import Path
# Path to the backend .env file
ENV_PATH = Path(__file__).parent.parent / ".env"
# Path to the example template that ships with the repo
ENV_EXAMPLE_PATH = Path(__file__).parent.parent.parent / ".env.example"
DATA_DIR = Path(os.environ.get("SB_DATA_DIR", str(Path(__file__).parent.parent / "data")))
if not DATA_DIR.is_absolute():
DATA_DIR = Path(__file__).parent.parent / DATA_DIR
OPERATOR_KEYS_ENV_PATH = Path(
os.environ.get("SHADOWBROKER_OPERATOR_KEYS_ENV", str(DATA_DIR / "operator_api_keys.env"))
)
_ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
# ---------------------------------------------------------------------------
# API Registry — every external service the dashboard depends on
@@ -143,6 +152,85 @@ API_REGISTRY = [
},
]
ALLOWED_ENV_KEYS = {
str(api["env_key"])
for api in API_REGISTRY
if api.get("env_key")
}
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
return values
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if not _ENV_KEY_RE.match(key):
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
values[key] = value
return values
def _quote_env_value(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def _write_env_values(path: Path, updates: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
seen: set[str] = set()
next_lines: list[str] = []
for raw_line in lines:
stripped = raw_line.strip()
if "=" not in stripped or stripped.startswith("#"):
next_lines.append(raw_line)
continue
key = stripped.split("=", 1)[0].strip()
if key in updates:
next_lines.append(f"{key}={_quote_env_value(updates[key])}")
seen.add(key)
else:
next_lines.append(raw_line)
for key, value in updates.items():
if key not in seen:
next_lines.append(f"{key}={_quote_env_value(value)}")
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f"{path.name}.tmp.", text=True)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(next_lines).rstrip() + "\n")
if os.name != "nt":
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)
if os.name != "nt":
os.chmod(path, 0o600)
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
def load_persisted_api_keys_into_environ() -> None:
"""Load persisted operator API keys if no process env value exists."""
for key, value in _parse_env_file(OPERATOR_KEYS_ENV_PATH).items():
if key in ALLOWED_ENV_KEYS and value and not os.environ.get(key):
os.environ[key] = value
def get_env_path_info() -> dict:
"""Return absolute paths for the backend .env and .env.example template.
@@ -160,6 +248,10 @@ def get_env_path_info() -> dict:
and (not env_path.exists() or os.access(env_path, os.W_OK)),
"env_example_path": str(example_path),
"env_example_path_exists": example_path.exists(),
"operator_keys_env_path": str(OPERATOR_KEYS_ENV_PATH.resolve()),
"operator_keys_env_path_exists": OPERATOR_KEYS_ENV_PATH.exists(),
"operator_keys_env_path_writable": os.access(OPERATOR_KEYS_ENV_PATH.parent, os.W_OK)
and (not OPERATOR_KEYS_ENV_PATH.exists() or os.access(OPERATOR_KEYS_ENV_PATH, os.W_OK)),
}
@@ -171,6 +263,7 @@ def get_api_keys():
`is_set` to render a CONFIGURED / NOT CONFIGURED badge and the path
info from `get_env_path_info()` to tell them where to put each key.
"""
load_persisted_api_keys_into_environ()
result = []
for api in API_REGISTRY:
entry = {
@@ -189,3 +282,57 @@ def get_api_keys():
entry["is_set"] = bool(raw)
result.append(entry)
return result
def save_api_keys(updates: dict[str, str]) -> dict:
"""Persist allowed API keys from a local operator request.
Values are accepted write-only: the response includes only configured flags.
"""
clean: dict[str, str] = {}
for key, value in updates.items():
env_key = str(key or "").strip().upper()
if env_key not in ALLOWED_ENV_KEYS:
continue
clean_value = str(value or "").strip()
if clean_value:
clean[env_key] = clean_value
if not clean:
return {"ok": False, "detail": "No supported API keys were provided."}
_write_env_values(OPERATOR_KEYS_ENV_PATH, clean)
try:
_write_env_values(ENV_PATH, clean)
except OSError:
# The persistent operator key file is the source of truth for Docker.
pass
for key, value in clean.items():
os.environ[key] = value
if "AIS_API_KEY" in clean:
try:
from services import ais_stream
ais_stream.API_KEY = clean["AIS_API_KEY"]
except Exception:
pass
if "OPENSKY_CLIENT_ID" in clean or "OPENSKY_CLIENT_SECRET" in clean:
try:
from services.fetchers import flights
flights.opensky_client.client_id = os.environ.get("OPENSKY_CLIENT_ID", "")
flights.opensky_client.client_secret = os.environ.get("OPENSKY_CLIENT_SECRET", "")
flights.opensky_client.token = None
flights.opensky_client.expires_at = 0
except Exception:
pass
try:
from services.config import get_settings
get_settings.cache_clear()
except Exception:
pass
return {
"ok": True,
"updated": sorted(clean.keys()),
"keys": get_api_keys(),
"env": get_env_path_info(),
}
+6
View File
@@ -210,6 +210,7 @@ class Settings(BaseSettings):
MESH_ALLOW_RAW_SECURE_STORAGE_FALLBACK: bool = False
MESH_ACK_RAW_FALLBACK_AT_OWN_RISK: bool = False
MESH_SECURE_STORAGE_SECRET: str = ""
MESH_SECURE_STORAGE_SECRET_FILE: str = ""
MESH_PRIVATE_LOG_TTL_S: int = 900
# Sprint 1 rollout: restored DM boot probes stay disabled by default until
# the architect reviews false positives from the observe-only path.
@@ -302,6 +303,11 @@ class Settings(BaseSettings):
@lru_cache
def get_settings() -> Settings:
try:
from services.api_settings import load_persisted_api_keys_into_environ
load_persisted_api_keys_into_environ()
except Exception:
pass
return Settings()
+207 -6
View File
@@ -19,6 +19,7 @@ import concurrent.futures
import json
import math
import os
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
@@ -105,7 +106,7 @@ _SLOW_FETCH_S = float(os.environ.get("FETCH_SLOW_THRESHOLD_S", "5"))
# Hard wall-clock limit per individual fetch task. A task that exceeds this
# is treated as a failure so it cannot block an entire fetch tier indefinitely.
_TASK_HARD_TIMEOUT_S = float(os.environ.get("FETCH_TASK_TIMEOUT_S", "120"))
_FAST_STARTUP_CACHE_MAX_AGE_S = float(os.environ.get("FAST_STARTUP_CACHE_MAX_AGE_S", "300"))
_FAST_STARTUP_CACHE_MAX_AGE_S = float(os.environ.get("FAST_STARTUP_CACHE_MAX_AGE_S", "21600"))
_FAST_STARTUP_CACHE_PATH = Path(__file__).resolve().parents[1] / "data" / "fast_startup_cache.json"
_FAST_STARTUP_CACHE_KEYS = (
"commercial_flights",
@@ -123,6 +124,21 @@ _FAST_STARTUP_CACHE_KEYS = (
"sigint_totals",
"trains",
)
_INTEL_STARTUP_CACHE_MAX_AGE_S = float(os.environ.get("INTEL_STARTUP_CACHE_MAX_AGE_S", "21600"))
_INTEL_STARTUP_CACHE_PATH = Path(__file__).resolve().parents[1] / "data" / "intel_startup_cache.json"
_INTEL_STARTUP_CACHE_KEYS = (
"news",
"gdelt",
"liveuamap",
"threat_level",
"trending_markets",
"correlations",
"fimi",
)
_STARTUP_PRIORITY_TIMEOUT_S = float(os.environ.get("SHADOWBROKER_STARTUP_PRIORITY_TIMEOUT_S", "18"))
_STARTUP_HEAVY_REFRESH_DELAY_S = float(os.environ.get("SHADOWBROKER_STARTUP_HEAVY_REFRESH_DELAY_S", "90"))
_STARTUP_HEAVY_REFRESH_STARTED = False
_STARTUP_HEAVY_REFRESH_LOCK = threading.Lock()
# Shared thread pool — reused across all fetch cycles instead of creating/destroying per tick
_SHARED_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
@@ -204,6 +220,77 @@ def _save_fast_startup_cache() -> None:
logger.debug("Fast startup cache save skipped: %s", e)
def _load_intel_startup_cache_if_available() -> bool:
"""Seed the right-side intelligence panel from disk while live feeds warm up."""
if _INTEL_STARTUP_CACHE_MAX_AGE_S <= 0 or not _INTEL_STARTUP_CACHE_PATH.exists():
return False
try:
with _INTEL_STARTUP_CACHE_PATH.open("r", encoding="utf-8") as fh:
payload = json.load(fh)
cached_at = float(payload.get("cached_at") or 0)
age_s = time.time() - cached_at
if cached_at <= 0 or age_s > _INTEL_STARTUP_CACHE_MAX_AGE_S:
logger.info("Skipping stale intel startup cache (age %.1fs)", age_s)
return False
layers = payload.get("layers") or {}
freshness = payload.get("freshness") or {}
loaded: list[str] = []
with _data_lock:
for key in _INTEL_STARTUP_CACHE_KEYS:
if key in layers:
latest_data[key] = layers[key]
loaded.append(key)
for key, ts in freshness.items():
source_timestamps[str(key)] = ts
if payload.get("last_updated"):
latest_data["last_updated"] = payload.get("last_updated")
if not loaded:
return False
from services.fetchers._store import bump_data_version
bump_data_version()
logger.info(
"Loaded intel startup cache for %d layers (age %.1fs) so Global Threat Intercept can paint early",
len(loaded),
age_s,
)
return True
except Exception as e:
logger.warning("Intel startup cache load failed (non-fatal): %s", e)
return False
def _save_intel_startup_cache() -> None:
"""Persist compact right-side intelligence data for the next cold start."""
try:
with _data_lock:
payload = {
"cached_at": time.time(),
"last_updated": latest_data.get("last_updated"),
"layers": {key: latest_data.get(key) for key in _INTEL_STARTUP_CACHE_KEYS},
"freshness": {
key: source_timestamps.get(key)
for key in _INTEL_STARTUP_CACHE_KEYS
if source_timestamps.get(key)
},
}
safe_payload = _cache_json_safe(payload)
_INTEL_STARTUP_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp_path = _INTEL_STARTUP_CACHE_PATH.with_suffix(".tmp")
with tmp_path.open("w", encoding="utf-8") as fh:
json.dump(safe_payload, fh, separators=(",", ":"))
tmp_path.replace(_INTEL_STARTUP_CACHE_PATH)
except Exception as e:
logger.debug("Intel startup cache save skipped: %s", e)
def seed_startup_caches() -> None:
"""Load disk-backed first-paint caches without touching remote services."""
load_meshtastic_cache_if_available()
_load_fast_startup_cache_if_available()
_load_intel_startup_cache_if_available()
# ---------------------------------------------------------------------------
# Scheduler & Orchestration
# ---------------------------------------------------------------------------
@@ -262,7 +349,6 @@ def update_fast_data():
fetch_satellites,
fetch_sigint,
fetch_trains,
fetch_tinygs,
]
_run_tasks("fast-tier", fast_funcs)
with _data_lock:
@@ -289,6 +375,7 @@ def update_slow_data():
fetch_cctv,
fetch_kiwisdr,
fetch_satnogs,
fetch_tinygs,
fetch_frontlines,
fetch_datacenters,
fetch_military_bases,
@@ -313,9 +400,76 @@ def update_slow_data():
logger.error("Correlation engine failed: %s", e)
from services.fetchers._store import bump_data_version
bump_data_version()
_save_intel_startup_cache()
logger.info("Slow-tier update complete.")
def _record_fetch_success(label: str, name: str, start: float) -> None:
duration = time.perf_counter() - start
from services.fetch_health import record_success
record_success(name, duration_s=duration)
if duration > _SLOW_FETCH_S:
logger.warning(f"{label} task slow: {name} took {duration:.2f}s")
def _record_fetch_failure(label: str, name: str, start: float, error: Exception) -> None:
duration = time.perf_counter() - start
from services.fetch_health import record_failure
record_failure(name, error=error, duration_s=duration)
logger.exception(f"{label} task failed: {name}")
def _load_cctv_cache_for_startup() -> None:
"""Load cached CCTV rows without running remote ingestors during first paint."""
try:
fetch_cctv()
except Exception as e:
logger.warning("Startup CCTV cache load failed (non-fatal): %s", e)
def _run_delayed_startup_heavy_refresh() -> None:
if _STARTUP_HEAVY_REFRESH_DELAY_S > 0:
logger.info(
"Startup heavy synthesis delayed %.0fs so the dashboard can finish first paint",
_STARTUP_HEAVY_REFRESH_DELAY_S,
)
time.sleep(_STARTUP_HEAVY_REFRESH_DELAY_S)
logger.info("Startup heavy synthesis beginning (slow feeds, enrichment, daily products)...")
_run_tasks(
"startup-heavy",
[
update_slow_data,
fetch_volcanoes,
fetch_viirs_change_nodes,
fetch_unusual_whales,
fetch_fimi,
fetch_uap_sightings,
fetch_wastewater,
fetch_sar_catalog,
fetch_sar_products,
],
)
logger.info("Startup heavy synthesis complete.")
def _schedule_delayed_startup_heavy_refresh() -> None:
global _STARTUP_HEAVY_REFRESH_STARTED
if _STARTUP_HEAVY_REFRESH_DELAY_S < 0:
logger.info("Startup heavy synthesis disabled by SHADOWBROKER_STARTUP_HEAVY_REFRESH_DELAY_S")
return
with _STARTUP_HEAVY_REFRESH_LOCK:
if _STARTUP_HEAVY_REFRESH_STARTED:
return
_STARTUP_HEAVY_REFRESH_STARTED = True
threading.Thread(
target=_run_delayed_startup_heavy_refresh,
name="startup-heavy-refresh",
daemon=True,
).start()
def update_all_data(*, startup_mode: bool = False):
"""Full refresh.
@@ -324,10 +478,51 @@ def update_all_data(*, startup_mode: bool = False):
"""
logger.info("Full data update starting (parallel)...")
# Preload Meshtastic map cache immediately (instant, from disk)
load_meshtastic_cache_if_available()
_load_fast_startup_cache_if_available()
seed_startup_caches()
with _data_lock:
meshtastic_seeded = bool(latest_data.get("meshtastic_map_nodes"))
if startup_mode:
_load_cctv_cache_for_startup()
priority_funcs = [
fetch_airports,
update_fast_data,
fetch_gdelt,
fetch_crowdthreat,
fetch_firms_fires,
fetch_weather_alerts,
]
if not meshtastic_seeded:
priority_funcs.append(fetch_meshtastic_nodes)
else:
logger.info(
"Startup preload: Meshtastic cache already loaded, deferring remote map refresh to scheduled cadence"
)
logger.info("Startup priority preload starting (%d tasks)...", len(priority_funcs))
cycle_start = time.perf_counter()
futures = {
_SHARED_EXECUTOR.submit(func): (func.__name__, time.perf_counter())
for func in priority_funcs
}
for future, (name, start) in futures.items():
remaining = _STARTUP_PRIORITY_TIMEOUT_S - (time.perf_counter() - cycle_start)
if remaining <= 0:
logger.info("Startup priority budget reached; %s will continue in background", name)
continue
try:
future.result(timeout=remaining)
_record_fetch_success("startup-priority", name, start)
except concurrent.futures.TimeoutError:
logger.info(
"Startup priority task still warming after %.1fs: %s",
time.perf_counter() - start,
name,
)
except Exception as e:
_record_fetch_failure("startup-priority", name, start, e)
logger.info("Startup preload: deferring Playwright Liveuamap scraper to scheduled cadence")
_schedule_delayed_startup_heavy_refresh()
logger.info("Startup priority preload complete; slow synthesis is warming in background.")
return
futures = {
_SHARED_EXECUTOR.submit(fetch_airports): ("fetch_airports", time.perf_counter()),
_SHARED_EXECUTOR.submit(update_fast_data): ("update_fast_data", time.perf_counter()),
@@ -337,7 +532,6 @@ def update_all_data(*, startup_mode: bool = False):
_SHARED_EXECUTOR.submit(fetch_unusual_whales): ("fetch_unusual_whales", time.perf_counter()),
_SHARED_EXECUTOR.submit(fetch_fimi): ("fetch_fimi", time.perf_counter()),
_SHARED_EXECUTOR.submit(fetch_gdelt): ("fetch_gdelt", time.perf_counter()),
_SHARED_EXECUTOR.submit(update_liveuamap): ("update_liveuamap", time.perf_counter()),
_SHARED_EXECUTOR.submit(fetch_uap_sightings): ("fetch_uap_sightings", time.perf_counter()),
_SHARED_EXECUTOR.submit(fetch_wastewater): ("fetch_wastewater", time.perf_counter()),
_SHARED_EXECUTOR.submit(fetch_crowdthreat): ("fetch_crowdthreat", time.perf_counter()),
@@ -353,6 +547,13 @@ def update_all_data(*, startup_mode: bool = False):
logger.info(
"Startup preload: Meshtastic cache already loaded, deferring remote map refresh to scheduled cadence"
)
if not startup_mode:
futures[_SHARED_EXECUTOR.submit(update_liveuamap)] = (
"update_liveuamap",
time.perf_counter(),
)
else:
logger.info("Startup preload: deferring Playwright Liveuamap scraper to scheduled cadence")
for future, (name, start) in futures.items():
try:
future.result(timeout=_TASK_HARD_TIMEOUT_S)
@@ -408,7 +609,7 @@ def update_all_data(*, startup_mode: bool = False):
_scheduler = None
_STARTUP_CCTV_INGEST_DELAY_S = 30
_STARTUP_CCTV_INGEST_DELAY_S = int(os.environ.get("SHADOWBROKER_STARTUP_CCTV_INGEST_DELAY_S", "180"))
_FINANCIAL_REFRESH_MINUTES = 30
+158 -5
View File
@@ -15,7 +15,7 @@ import time
import heapq
from datetime import datetime, timedelta
from pathlib import Path
from services.network_utils import fetch_with_curl
from services.network_utils import external_curl_fallback_enabled, fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
from services.fetchers.nuforc_enrichment import enrich_sighting
from services.fetchers.retry import with_retry
@@ -685,6 +685,8 @@ _NUFORC_TOKEN = os.environ.get("NUFORC_MAPBOX_TOKEN", "").strip()
_NUFORC_RADIUS_M = 200_000 # 200 km query radius
_NUFORC_LIMIT = 50 # max features per tilequery call
_NUFORC_RECENT_DAYS = int(os.environ.get("NUFORC_RECENT_DAYS", "60"))
_NUFORC_HF_FALLBACK_LIMIT = max(25, int(os.environ.get("NUFORC_HF_FALLBACK_LIMIT", "250")))
_NUFORC_HF_GEOCODE_LIMIT = max(25, int(os.environ.get("NUFORC_HF_GEOCODE_LIMIT", "150")))
_NUFORC_GEOCODE_WORKERS = max(1, int(os.environ.get("NUFORC_GEOCODE_WORKERS", "1")))
# Photon (Komoot) is more lenient than Nominatim — ~200ms per query in
# practice, so a 0.3s spacing keeps us well under any soft throttle while
@@ -1034,6 +1036,14 @@ def _nuforc_fetch_month_live(yyyymm: str, cookie_jar: Path) -> list[dict]:
index_url = _NUFORC_LIVE_INDEX_URL.format(yyyymm=yyyymm)
ajax_url = _NUFORC_LIVE_AJAX_URL.format(yyyymm=yyyymm)
if not external_curl_fallback_enabled():
logger.warning(
"NUFORC live: external curl disabled on Windows for %s; "
"set SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK=1 to opt in.",
yyyymm,
)
return []
# Step 1: GET the month index to capture session cookies + fresh nonce.
try:
index_res = subprocess.run(
@@ -1340,6 +1350,143 @@ def _build_recent_uap_sightings() -> list[dict]:
return sightings
def _split_uap_location(location: str) -> tuple[str, str, str]:
parts = [p.strip() for p in str(location or "").split(",") if p.strip()]
city = parts[0] if parts else ""
state = ""
country = ""
if len(parts) >= 2:
state = parts[1]
if len(parts) >= 3:
country = parts[-1]
if country and country.upper() in _US_COUNTRY_ALIASES:
country = "US"
return city, state, country
def _build_uap_sightings_from_hf_mirror() -> list[dict]:
"""Build visible UAP points from the public Hugging Face NUFORC mirror.
This is a resilience fallback for local/Windows runs where nuforc.org is
Cloudflare-gated and the Mapbox token is not configured. It is not as fresh
as the live NUFORC AJAX feed, but it keeps the layer visible and cached.
"""
from services.fetchers.nuforc_enrichment import _HF_CSV_URL, _parse_date
from services.geocode_validate import coord_in_country
try:
response = fetch_with_curl(_HF_CSV_URL, timeout=180, follow_redirects=True)
if not response or response.status_code != 200:
logger.warning(
"UAP sightings: HF fallback failed HTTP %s",
getattr(response, "status_code", "None"),
)
return []
except Exception as e:
logger.warning("UAP sightings: HF fallback download failed: %s", e)
return []
candidates: list[dict] = []
try:
reader = csv.DictReader(io.StringIO(response.text))
for row in reader:
occurred = _parse_date(
row.get("Occurred", "")
or row.get("Date / Time", "")
or row.get("Date", "")
)
if not occurred:
continue
raw_location = _normalize_uap_location(
row.get("Location", "")
or row.get("City", "")
or row.get("location", "")
)
if not raw_location:
continue
city, state, country = _split_uap_location(raw_location)
if not city:
continue
sighting_id = str(row.get("Sighting", "") or "").strip()
if not sighting_id:
sighting_id = hashlib.sha1(
f"{occurred}|{raw_location}|{row.get('Summary', '')}".encode("utf-8", "ignore")
).hexdigest()[:12]
summary = (row.get("Summary", "") or row.get("Text", "") or "Sighting reported").strip()
if len(summary) > 280:
summary = summary[:277] + "..."
candidates.append({
"id": f"NUFORC-{sighting_id}",
"occurred": occurred,
"posted": _parse_date(row.get("Posted", "") or row.get("Reported", "")) or occurred,
"location": raw_location,
"city": city,
"state": state,
"country": country or _uap_country_from_location(raw_location, state),
"shape_raw": (row.get("Shape", "") or "Unknown").strip(),
"duration": (row.get("Duration", "") or "").strip(),
"summary": summary,
})
except Exception as e:
logger.warning("UAP sightings: HF fallback parse failed: %s", e)
return []
candidates.sort(key=lambda row: (row["occurred"], row["posted"], row["id"]), reverse=True)
candidates = candidates[:_NUFORC_HF_FALLBACK_LIMIT]
location_cache = _load_nuforc_location_cache()
sightings: list[dict] = []
geocoded = 0
for row in candidates:
coords = location_cache.get(row["location"])
if row["location"] not in location_cache and geocoded < _NUFORC_HF_GEOCODE_LIMIT:
try:
coords = _geocode_uap_location(
row["location"], row["city"], row["state"], row["country"]
)
except Exception:
coords = None
location_cache[row["location"]] = coords
geocoded += 1
if geocoded < _NUFORC_HF_GEOCODE_LIMIT:
time.sleep(_NUFORC_GEOCODE_SPACING_S)
if not coords:
continue
if row.get("country"):
try:
inside = coord_in_country(coords[0], coords[1], row["country"])
except Exception:
inside = None
if inside is False:
continue
shape_raw = row["shape_raw"] or "Unknown"
sightings.append({
"id": row["id"],
"date_time": row["occurred"],
"city": row["city"],
"state": row["state"],
"country": row["country"],
"shape": _normalize_uap_shape(shape_raw) if shape_raw != "Unknown" else "unknown",
"shape_raw": shape_raw,
"duration": row["duration"],
"summary": row["summary"],
"posted": row["posted"],
"lat": float(coords[0]),
"lng": float(coords[1]),
"count": 1,
"source": "NUFORC-HF",
})
_save_nuforc_location_cache(location_cache)
logger.info(
"UAP sightings: %d mapped reports from HF fallback (%d candidates, %d geocoded)",
len(sightings),
len(candidates),
geocoded,
)
return sightings
@with_retry(max_retries=1, base_delay=5)
def fetch_uap_sightings(*, force_refresh: bool = False):
"""Fetch last-year UAP sightings from NUFORC.
@@ -1355,12 +1502,18 @@ def fetch_uap_sightings(*, force_refresh: bool = False):
sightings = _load_nuforc_sightings_cache(force_refresh=force_refresh)
if sightings is None:
sightings = _build_recent_uap_sightings()
_save_nuforc_sightings_cache(sightings)
try:
sightings = _build_recent_uap_sightings()
except Exception as e:
logger.warning("UAP sightings: live NUFORC rebuild failed, using fallback: %s", e)
sightings = _build_uap_sightings_from_hf_mirror()
if sightings:
_save_nuforc_sightings_cache(sightings)
with _data_lock:
latest_data["uap_sightings"] = sightings
_mark_fresh("uap_sightings")
latest_data["uap_sightings"] = sightings or []
if sightings:
_mark_fresh("uap_sightings")
return
cutoff = datetime.utcnow() - timedelta(days=_NUFORC_RECENT_DAYS)
+24
View File
@@ -15,6 +15,24 @@ from services.fetchers.retry import with_retry
logger = logging.getLogger(__name__)
def _env_flag(name: str) -> str:
return str(os.getenv(name, "")).strip().lower()
def liveuamap_scraper_enabled() -> bool:
"""Return whether the Playwright-based LiveUAMap scraper should run.
It is useful enrichment, but it starts a browser/Node driver and must not be
allowed to destabilize Windows local startup.
"""
setting = _env_flag("SHADOWBROKER_ENABLE_LIVEUAMAP_SCRAPER")
if setting in {"1", "true", "yes", "on"}:
return True
if setting in {"0", "false", "no", "off"}:
return False
return os.name != "nt"
# ---------------------------------------------------------------------------
# Ships (AIS + Carriers)
# ---------------------------------------------------------------------------
@@ -191,6 +209,12 @@ def update_liveuamap():
if not is_any_active("global_incidents"):
return
if not liveuamap_scraper_enabled():
logger.info(
"Liveuamap scraper disabled for this runtime; set "
"SHADOWBROKER_ENABLE_LIVEUAMAP_SCRAPER=1 to opt in."
)
return
logger.info("Running scheduled Liveuamap scraper...")
try:
from services.liveuamap_scraper import fetch_liveuamap
+119 -7
View File
@@ -15,6 +15,7 @@ Analysis features (derived from cached TLEs — no extra network requests):
import math
import time
import json
import os
import re
import logging
import requests
@@ -41,6 +42,38 @@ def _gmst(jd_ut1):
# CelesTrak fair use: fetch at most once per 24 hours (86400s).
# SGP4 propagation runs every 60s using cached TLEs — positions stay live.
_CELESTRAK_FETCH_INTERVAL = 86400 # 24 hours
_MIN_VISIBLE_SATELLITE_CATALOG = int(os.environ.get("SHADOWBROKER_MIN_VISIBLE_SATELLITES", "350"))
_MAX_VISIBLE_SATELLITE_CATALOG = int(os.environ.get("SHADOWBROKER_MAX_VISIBLE_SATELLITES", "450"))
_CELESTRAK_VISIBLE_GROUPS = {
"military": {"mission": "military", "sat_type": "Military / Defense"},
"radar": {"mission": "sar", "sat_type": "Radar / SAR"},
"resource": {"mission": "earth_observation", "sat_type": "Earth Observation"},
"weather": {"mission": "weather", "sat_type": "Weather / Meteorology"},
"gnss": {"mission": "navigation", "sat_type": "GNSS / Navigation"},
"science": {"mission": "science", "sat_type": "Science"},
}
_TLE_VISIBLE_FALLBACK_TERMS = {
"COSMOS": {"mission": "military", "sat_type": "Russian / Soviet Military"},
"USA": {"mission": "military", "sat_type": "US Military / NRO"},
"NROL": {"mission": "military", "sat_type": "Classified NRO"},
"GPS": {"mission": "navigation", "sat_type": "GPS Navigation"},
"GALILEO": {"mission": "navigation", "sat_type": "Galileo Navigation"},
"BEIDOU": {"mission": "navigation", "sat_type": "BeiDou Navigation"},
"GLONASS": {"mission": "navigation", "sat_type": "GLONASS Navigation"},
"NOAA": {"mission": "weather", "sat_type": "NOAA Weather"},
"METEOR": {"mission": "weather", "sat_type": "Meteor Weather"},
"SENTINEL": {"mission": "earth_observation", "sat_type": "Sentinel Earth Observation"},
"LANDSAT": {"mission": "earth_observation", "sat_type": "Landsat Earth Observation"},
"WORLDVIEW": {"mission": "commercial_imaging", "sat_type": "Maxar High-Res"},
"PLEIADES": {"mission": "commercial_imaging", "sat_type": "Airbus Imaging"},
"SKYSAT": {"mission": "commercial_imaging", "sat_type": "Planet Video"},
"JILIN": {"mission": "commercial_imaging", "sat_type": "Jilin Imaging"},
"FLOCK": {"mission": "commercial_imaging", "sat_type": "PlanetScope"},
"LEMUR": {"mission": "commercial_rf", "sat_type": "Spire RF / AIS"},
"ICEYE": {"mission": "sar", "sat_type": "ICEYE SAR"},
"UMBRA": {"mission": "sar", "sat_type": "Umbra SAR"},
"CAPELLA": {"mission": "sar", "sat_type": "Capella SAR"},
}
_sat_gp_cache = {"data": None, "last_fetch": 0, "source": "none", "last_modified": None}
_sat_classified_cache = {"data": None, "gp_fetch_ts": 0}
_SAT_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache.json"
@@ -564,9 +597,61 @@ def _parse_tle_to_gp(name, norad_id, line1, line2):
return None
def _annotate_celestrak_group(records: list[dict], group: str) -> list[dict]:
meta = _CELESTRAK_VISIBLE_GROUPS.get(group, {})
out = []
for sat in records:
if not isinstance(sat, dict):
continue
item = dict(sat)
item["_SB_GROUP"] = group
if meta:
item["_SB_GROUP_META"] = meta
out.append(item)
return out
def _fetch_visible_celestrak_catalog(headers: dict | None = None) -> list[dict]:
"""Fetch bounded CelesTrak groups used by the visible satellite layer.
The full ``active`` catalog is too large and frequently times out on local
startup. These groups cover the visible operational set users expect
without pulling Starlink-scale constellations into the map.
"""
headers = headers or {}
merged: dict[int, dict] = {}
for group in _CELESTRAK_VISIBLE_GROUPS:
url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json"
try:
response = fetch_with_curl(url, timeout=15, headers=headers)
if response.status_code != 200:
logger.debug("Satellites: CelesTrak group %s returned HTTP %s", group, response.status_code)
continue
gp_data = response.json()
if not isinstance(gp_data, list):
continue
for sat in _annotate_celestrak_group(gp_data, group):
norad_id = sat.get("NORAD_CAT_ID")
if norad_id is None:
continue
merged[int(norad_id)] = sat
time.sleep(0.35)
except (
requests.RequestException,
ConnectionError,
TimeoutError,
ValueError,
KeyError,
json.JSONDecodeError,
OSError,
) as e:
logger.warning("Satellites: Failed to fetch CelesTrak group %s: %s", group, e)
return list(merged.values())
def _fetch_satellites_from_tle_api():
"""Fallback: fetch satellite TLEs from tle.ivanstanojevic.me when CelesTrak is blocked."""
search_terms = set()
search_terms = set(_TLE_VISIBLE_FALLBACK_TERMS)
for key, _ in _SAT_INTEL_DB:
term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
search_terms.add(term)
@@ -591,8 +676,13 @@ def _fetch_satellites_from_tle_api():
sat_id = gp.get("NORAD_CAT_ID")
if sat_id not in seen_ids:
seen_ids.add(sat_id)
if term in _TLE_VISIBLE_FALLBACK_TERMS:
gp["_SB_GROUP"] = f"tle:{term}"
gp["_SB_GROUP_META"] = _TLE_VISIBLE_FALLBACK_TERMS[term]
all_results.append(gp)
time.sleep(1) # Polite delay between requests
if len(all_results) >= _MAX_VISIBLE_SATELLITE_CATALOG:
return all_results
time.sleep(0.15) # Polite delay between requests
except (
requests.RequestException,
ConnectionError,
@@ -644,18 +734,34 @@ def fetch_satellites():
if (
_sat_gp_cache["data"] is None
or len(_sat_gp_cache.get("data") or []) < _MIN_VISIBLE_SATELLITE_CATALOG
or (now_ts - _sat_gp_cache["last_fetch"]) > _CELESTRAK_FETCH_INTERVAL
):
gp_urls = [
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
]
# Build conditional request headers (CelesTrak fair use)
headers = {}
if _sat_gp_cache.get("last_modified"):
headers["If-Modified-Since"] = _sat_gp_cache["last_modified"]
visible_data = _fetch_visible_celestrak_catalog(headers=headers)
if len(visible_data) >= _MIN_VISIBLE_SATELLITE_CATALOG:
_sat_gp_cache["data"] = visible_data
_sat_gp_cache["last_fetch"] = now_ts
_sat_gp_cache["source"] = "celestrak_visible_groups"
_save_sat_cache(visible_data)
_snapshot_current_tles(visible_data)
logger.info(
"Satellites: Downloaded %d GP records from visible CelesTrak groups",
len(visible_data),
)
gp_urls = [
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
]
for url in gp_urls:
if len(_sat_gp_cache.get("data") or []) >= _MIN_VISIBLE_SATELLITE_CATALOG:
break
try:
response = fetch_with_curl(url, timeout=15, headers=headers)
if response.status_code == 304:
@@ -696,7 +802,10 @@ def fetch_satellites():
logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
continue
if _sat_gp_cache["data"] is None:
if (
_sat_gp_cache["data"] is None
or len(_sat_gp_cache.get("data") or []) < _MIN_VISIBLE_SATELLITE_CATALOG
):
logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...")
try:
fallback_data = _fetch_satellites_from_tle_api()
@@ -757,6 +866,9 @@ def fetch_satellites():
owner = sat.get("OWNER", sat.get("OBJECT_OWNER", ""))
if owner in _OWNER_CODE_MAP:
intel = {"country": _OWNER_CODE_MAP[owner], "mission": "general", "sat_type": "Unclassified"}
if not intel and sat.get("_SB_GROUP_META"):
intel = dict(sat["_SB_GROUP_META"])
intel.setdefault("country", "Unknown")
if not intel:
continue
+34 -1
View File
@@ -230,11 +230,16 @@ def _raw_fallback_allowed() -> bool:
return False
def _generated_secret_file() -> Path:
return DATA_DIR / "secure_storage_secret.key"
def _get_storage_secret() -> str | None:
"""Return the operator-supplied secure storage secret, or None."""
"""Return the operator-supplied or local generated secure storage secret."""
secret = os.environ.get("MESH_SECURE_STORAGE_SECRET", "").strip()
if secret:
return secret
secret_file_override = os.environ.get("MESH_SECURE_STORAGE_SECRET_FILE", "").strip()
try:
from services.config import get_settings
@@ -242,8 +247,36 @@ def _get_storage_secret() -> str | None:
secret = str(getattr(settings, "MESH_SECURE_STORAGE_SECRET", "") or "").strip()
if secret:
return secret
secret_file_override = (
secret_file_override
or str(getattr(settings, "MESH_SECURE_STORAGE_SECRET_FILE", "") or "").strip()
)
except Exception:
pass
if not _is_windows():
if _raw_fallback_allowed():
return None
secret_file = Path(secret_file_override or _generated_secret_file())
try:
if secret_file.exists():
secret = secret_file.read_text(encoding="utf-8").strip()
if secret:
return secret
secret_file.parent.mkdir(parents=True, exist_ok=True)
secret = _b64(os.urandom(48))
_atomic_write_text(secret_file, secret + "\n", encoding="utf-8")
try:
os.chmod(secret_file, 0o600)
except OSError:
pass
logger.info("Generated local secure storage secret at %s", secret_file)
return secret
except Exception as exc:
logger.warning(
"Failed to load or generate local secure storage secret at %s: %s",
secret_file,
exc,
)
return None
+34 -5
View File
@@ -1,5 +1,6 @@
import logging
import json
import os
import subprocess
import shutil
import time
@@ -20,7 +21,6 @@ _session.mount("http://", HTTPAdapter(max_retries=_retry, pool_maxsize=10))
# Find bash for curl fallback — Git bash's curl has the TLS features
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
_BASH_PATH = shutil.which("bash") or "bash"
# Cache domains where requests fails — skip straight to curl for 5 minutes
_domain_fail_cache: dict[str, float] = {}
@@ -39,6 +39,17 @@ class UpstreamCircuitBreakerError(OSError):
"""Raised when a domain recently failed hard and is temporarily skipped."""
def _env_truthy(name: str) -> bool:
return str(os.getenv(name, "")).strip().lower() in {"1", "true", "yes", "on"}
def external_curl_fallback_enabled() -> bool:
"""Return whether the backend may spawn an external curl process."""
if os.name != "nt":
return True
return _env_truthy("SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK")
class _DummyResponse:
"""Minimal response object matching requests.Response interface."""
def __init__(self, status_code, text):
@@ -98,11 +109,22 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
_circuit_breaker.pop(domain, None)
return res
except (requests.RequestException, ConnectionError, TimeoutError, OSError) as e:
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
fallback = "falling back to curl" if external_curl_fallback_enabled() else "skipping external curl"
logger.warning(f"Python requests failed for {url} ({e}), {fallback}...")
with _cb_lock:
_domain_fail_cache[domain] = time.time()
# Curl fallback — reached from both _skip_requests and requests-exception paths
if not external_curl_fallback_enabled():
logger.warning(
"External curl fallback disabled on Windows for %s; set "
"SHADOWBROKER_ENABLE_WINDOWS_CURL_FALLBACK=1 to opt in.",
domain,
)
with _cb_lock:
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "")
_CURL_PATH = shutil.which("curl") or "curl"
cmd = [_CURL_PATH, "-s", "-w", "\n%{http_code}"]
if follow_redirects:
@@ -116,9 +138,16 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
try:
stdin_data = json.dumps(json_data) if (method == "POST" and json_data) else None
creationflags = 0
if os.name == "nt":
creationflags = (
getattr(subprocess, "CREATE_NO_WINDOW", 0)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
)
res = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout + 5,
input=stdin_data, encoding="utf-8", errors="replace"
input=stdin_data, encoding="utf-8", errors="replace",
creationflags=creationflags,
)
if res.returncode == 0 and (res.stdout or "").strip():
# Parse HTTP status code from -w output (last line)
@@ -130,12 +159,12 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
_circuit_breaker.pop(domain, None) # Clear circuit breaker on success
return _DummyResponse(http_code, body)
else:
logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
logger.error(f"curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
with _cb_lock:
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "")
except (subprocess.SubprocessError, ConnectionError, TimeoutError, OSError) as curl_e:
logger.error(f"bash curl fallback exception: {curl_e}")
logger.error(f"curl fallback exception: {curl_e}")
with _cb_lock:
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "")
+2
View File
@@ -104,6 +104,8 @@ def _match_prediction_markets(title: str, markets: list[dict]) -> dict | None:
"kalshi_pct": best_match.get("kalshi_pct"),
"consensus_pct": best_match.get("consensus_pct"),
"match_score": round(best_score, 2),
"slug": best_match.get("slug", ""),
"kalshi_ticker": best_match.get("kalshi_ticker", ""),
}
+11 -1
View File
@@ -225,6 +225,11 @@ def _installed() -> bool:
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
if os.name == "nt":
# Windows PIDs are reused and os.kill(pid, 0) is not a reliable
# ownership check. A persisted wormhole_status.json PID from an older
# run must never be treated as a process we own.
return False
try:
os.kill(pid, 0)
except OSError:
@@ -268,7 +273,12 @@ def _current_runtime_state() -> dict[str, Any]:
pid = int(_PROCESS.pid or 0)
elif _pid_alive(pid):
running = True
elif _probe_ready(timeout_s=0.35):
running = True
pid = 0
ready = running and _probe_ready()
if not running:
pid = 0
transport_active = status.get("transport_active", "") if ready else ""
proxy_active = status.get("proxy_active", "") if ready else ""
effective_transport = str(transport_active or settings.get("transport", "direct") or "direct").lower()
@@ -489,7 +499,7 @@ def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]:
_PROCESS.kill()
except Exception:
pass
elif _pid_alive(pid):
elif os.name != "nt" and _pid_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
except Exception:
@@ -2,7 +2,7 @@
Tests prove:
- Docker no longer auto-allows raw fallback
- Non-Windows with no secure secret and no raw opt-in fails closed
- Non-Windows with no secure secret generates a local passphrase file
- Non-Windows with MESH_SECURE_STORAGE_SECRET works (passphrase provider)
- Passphrase-protected envelopes round-trip correctly (master + domain)
- Raw-to-passphrase migration works when secret is supplied
@@ -64,10 +64,10 @@ class TestDockerNoAutoRawFallback:
assert mesh_secure_storage._raw_fallback_allowed() is True
class TestFailClosedWithoutSecret:
"""Non-Windows with no secret and no raw opt-in must fail closed."""
class TestGeneratedLocalSecretWithoutOperatorSecret:
"""Non-Windows with no supplied secret generates a local passphrase file."""
def test_master_key_creation_fails_closed(self, tmp_path, monkeypatch):
def test_master_key_creation_uses_generated_local_secret(self, tmp_path, monkeypatch):
from services.mesh import mesh_secure_storage
from services import config as config_mod
@@ -76,6 +76,7 @@ class TestFailClosedWithoutSecret:
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False)
monkeypatch.setattr(
config_mod,
"get_settings",
@@ -86,10 +87,14 @@ class TestFailClosedWithoutSecret:
)
_reset(mesh_secure_storage)
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"):
mesh_secure_storage._load_master_key()
key = mesh_secure_storage._load_master_key()
assert len(key) == 32
assert (tmp_path / "secure_storage_secret.key").exists()
envelope = json.loads((tmp_path / "master.key").read_text(encoding="utf-8"))
assert envelope["provider"] == "passphrase"
assert "key" not in envelope
def test_domain_key_creation_fails_closed(self, tmp_path, monkeypatch):
def test_domain_key_creation_uses_generated_local_secret(self, tmp_path, monkeypatch):
from services.mesh import mesh_secure_storage
from services import config as config_mod
@@ -98,6 +103,7 @@ class TestFailClosedWithoutSecret:
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET_FILE", raising=False)
monkeypatch.setattr(
config_mod,
"get_settings",
@@ -108,8 +114,12 @@ class TestFailClosedWithoutSecret:
)
_reset(mesh_secure_storage)
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET"):
mesh_secure_storage._load_domain_key("test_domain", base_dir=tmp_path)
key = mesh_secure_storage._load_domain_key("test_domain", base_dir=tmp_path)
assert len(key) == 32
assert (tmp_path / "secure_storage_secret.key").exists()
envelope = json.loads((tmp_path / "_domain_keys" / "test_domain.key").read_text(encoding="utf-8"))
assert envelope["provider"] == "passphrase"
assert "key" not in envelope
class TestPassphraseProvider:
@@ -311,7 +321,7 @@ class TestWrongPassphraseFails:
),
)
with pytest.raises(mesh_secure_storage.SecureStorageError, match="MESH_SECURE_STORAGE_SECRET is not set"):
with pytest.raises(mesh_secure_storage.SecureStorageError, match="Failed to unwrap"):
mesh_secure_storage._load_master_key()
@@ -517,6 +527,7 @@ class TestGetStorageSecret:
from services import config as config_mod
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: True)
monkeypatch.setattr(
config_mod,
"get_settings",
@@ -524,6 +535,27 @@ class TestGetStorageSecret:
)
assert mesh_secure_storage._get_storage_secret() is None
def test_generates_local_secret_file_on_non_windows(self, tmp_path, monkeypatch):
from services.mesh import mesh_secure_storage
from services import config as config_mod
secret_file = tmp_path / "generated_secret.key"
monkeypatch.delenv("MESH_SECURE_STORAGE_SECRET", raising=False)
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
monkeypatch.setenv("MESH_SECURE_STORAGE_SECRET_FILE", str(secret_file))
monkeypatch.setattr(mesh_secure_storage, "_is_windows", lambda: False)
monkeypatch.setattr(
config_mod,
"get_settings",
lambda: SimpleNamespace(MESH_SECURE_STORAGE_SECRET=""),
)
first = mesh_secure_storage._get_storage_secret()
second = mesh_secure_storage._get_storage_secret()
assert first
assert second == first
assert secret_file.read_text(encoding="utf-8").strip() == first
def test_falls_back_to_config(self, monkeypatch):
from services.mesh import mesh_secure_storage
from services import config as config_mod
+38
View File
@@ -0,0 +1,38 @@
import os
def test_save_api_keys_persists_write_only(tmp_path, monkeypatch):
from services import api_settings
key_store = tmp_path / "operator_api_keys.env"
backend_env = tmp_path / ".env"
monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", key_store)
monkeypatch.setattr(api_settings, "ENV_PATH", backend_env)
monkeypatch.delenv("OPENSKY_CLIENT_ID", raising=False)
result = api_settings.save_api_keys(
{
"OPENSKY_CLIENT_ID": "client-id-value",
"NOT_ALLOWED": "ignore-me",
}
)
assert result["ok"] is True
assert result["updated"] == ["OPENSKY_CLIENT_ID"]
assert "client-id-value" not in str(result)
assert os.environ["OPENSKY_CLIENT_ID"] == "client-id-value"
assert 'OPENSKY_CLIENT_ID="client-id-value"' in key_store.read_text(encoding="utf-8")
assert "NOT_ALLOWED" not in key_store.read_text(encoding="utf-8")
def test_persisted_api_keys_load_when_process_env_blank(tmp_path, monkeypatch):
from services import api_settings
key_store = tmp_path / "operator_api_keys.env"
key_store.write_text('AIS_API_KEY="saved-ais-key"\n', encoding="utf-8")
monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", key_store)
monkeypatch.setenv("AIS_API_KEY", "")
api_settings.load_persisted_api_keys_into_environ()
assert os.environ["AIS_API_KEY"] == "saved-ais-key"
+38 -16
View File
@@ -86,6 +86,18 @@ class TestRequireLocalOperator:
def test_rfc1918_172_blocked_without_key(self):
assert self._call_with_host("172.16.0.5") == 403
def test_docker_bridge_blocked_without_compose_opt_in(self):
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": ""}):
assert self._call_with_host("172.18.0.3") == 403
def test_docker_bridge_passes_with_compose_opt_in(self):
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}):
assert self._call_with_host("172.18.0.3") == 200
def test_lan_ip_still_blocked_with_compose_opt_in(self):
with patch.dict("os.environ", {"SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR": "1"}):
assert self._call_with_host("192.168.1.100") == 403
def test_rfc1918_192168_blocked_without_key(self):
assert self._call_with_host("192.168.1.100") == 403
@@ -100,7 +112,14 @@ class TestRequireLocalOperator:
# _validate_peer_push_secret — startup enforcement
# ---------------------------------------------------------------------------
_KNOWN_COMPROMISED = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
_KNOWN_COMPROMISED = "".join(
[
"Mv63UvLfwq",
"OEVWeRBXjA",
"8MtFl2nEkk",
"hUlLYVHiX1Zzo",
]
)
class TestValidatePeerPushSecret:
@@ -114,16 +133,17 @@ class TestValidatePeerPushSecret:
with patch("main.get_settings", return_value=mock_settings):
return _validate_peer_push_secret
def test_known_default_causes_exit(self):
def test_known_default_auto_generates_replacement(self):
from auth import _validate_peer_push_secret
mock_settings = MagicMock()
mock_settings.MESH_PEER_PUSH_SECRET = _KNOWN_COMPROMISED
with patch("auth.get_settings", return_value=mock_settings):
with pytest.raises(SystemExit) as exc_info:
_validate_peer_push_secret()
assert exc_info.value.code == 1
with (
patch("auth.get_settings", return_value=mock_settings),
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
):
_validate_peer_push_secret()
def test_empty_secret_does_not_exit_without_peers(self):
from auth import _validate_peer_push_secret
@@ -137,7 +157,7 @@ class TestValidatePeerPushSecret:
with patch("auth.get_settings", return_value=mock_settings):
_validate_peer_push_secret() # no exception = pass
def test_empty_secret_with_peers_causes_exit(self):
def test_empty_secret_with_peers_auto_generates_replacement(self):
from auth import _validate_peer_push_secret
mock_settings = MagicMock()
@@ -146,12 +166,13 @@ class TestValidatePeerPushSecret:
mock_settings.MESH_RNS_PEERS = ""
mock_settings.MESH_RNS_ENABLED = False
with patch("auth.get_settings", return_value=mock_settings):
with pytest.raises(SystemExit) as exc_info:
_validate_peer_push_secret()
assert exc_info.value.code == 1
with (
patch("auth.get_settings", return_value=mock_settings),
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
):
_validate_peer_push_secret()
def test_short_secret_with_peers_causes_exit(self):
def test_short_secret_with_peers_auto_generates_replacement(self):
from auth import _validate_peer_push_secret
mock_settings = MagicMock()
@@ -160,10 +181,11 @@ class TestValidatePeerPushSecret:
mock_settings.MESH_RNS_PEERS = ""
mock_settings.MESH_RNS_ENABLED = False
with patch("auth.get_settings", return_value=mock_settings):
with pytest.raises(SystemExit) as exc_info:
_validate_peer_push_secret()
assert exc_info.value.code == 1
with (
patch("auth.get_settings", return_value=mock_settings),
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
):
_validate_peer_push_secret()
def test_valid_secret_passes(self):
from auth import _validate_peer_push_secret
@@ -2,11 +2,13 @@
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const scriptDir = __dirname;
const tauriDir = path.resolve(scriptDir, '..');
const repoRoot = path.resolve(tauriDir, '..', '..');
const backendDir = path.join(repoRoot, 'backend');
const privacyCoreDir = path.join(repoRoot, 'privacy-core');
const outputDir = path.join(tauriDir, 'src-tauri', 'backend-runtime');
const venvMarkerPath = path.join(backendDir, '.venv-dir');
const releaseAttestationPath = path.join(backendDir, 'data', 'release_attestation.json');
@@ -19,14 +21,21 @@ const stagedReleaseAttestationPath = path.join(
const excludedNames = new Set([
'.env',
'.pytest_cache',
'.ruff_cache',
'__pycache__',
'backend.egg-info',
'build',
'data',
'tests',
'timemachine',
]);
const excludedFiles = new Set([
'.env.example',
'ais_cache.json',
'carrier_cache.json',
'cctv.db',
'dm_token_pepper.key',
'pytest.ini',
]);
@@ -77,15 +86,58 @@ function ensureRuntimePrereqs() {
}
}
function privacyCoreArtifactName() {
if (process.platform === 'win32') return 'privacy_core.dll';
if (process.platform === 'darwin') return 'libprivacy_core.dylib';
return 'libprivacy_core.so';
}
function privacyCoreArtifactPath() {
return path.join(privacyCoreDir, 'target', 'release', privacyCoreArtifactName());
}
function ensurePrivacyCoreArtifact() {
const artifact = privacyCoreArtifactPath();
if (fs.existsSync(artifact)) {
return artifact;
}
console.log('privacy-core release library missing; building it for desktop packaging...');
const result = spawnSync(
'cargo',
['build', '--release', '--manifest-path', path.join(privacyCoreDir, 'Cargo.toml')],
{
cwd: repoRoot,
env: process.env,
stdio: 'inherit',
},
);
if (result.error || result.status !== 0) {
throw new Error(
'Failed to build privacy-core release library. Install Rust/Cargo and rerun the desktop build.',
);
}
if (!fs.existsSync(artifact)) {
throw new Error(`privacy-core build completed but artifact is missing: ${artifact}`);
}
return artifact;
}
function stageBackendRuntime() {
fs.rmSync(outputDir, { recursive: true, force: true });
fs.cpSync(backendDir, outputDir, {
recursive: true,
filter: shouldCopy,
});
stagePrivacyCoreArtifact();
stageReleaseAttestation();
}
function stagePrivacyCoreArtifact() {
const artifact = ensurePrivacyCoreArtifact();
const stagedPath = path.join(outputDir, path.basename(artifact));
fs.copyFileSync(artifact, stagedPath);
}
function stageReleaseAttestation() {
if (!fs.existsSync(releaseAttestationPath)) {
console.warn(`backend-runtime staged without release attestation: ${releaseAttestationPath}`);
@@ -91,7 +91,8 @@ pub async fn ensure_and_start_managed_backend(
.open(&stderr_log)
.map_err(|e| format!("managed_backend_stderr_log_failed:{e}"))?;
let mut child = Command::new(&python_bin)
let mut command = Command::new(&python_bin);
command
.current_dir(&runtime_root)
.arg("-m")
.arg("uvicorn")
@@ -103,7 +104,13 @@ pub async fn ensure_and_start_managed_backend(
.arg("--timeout-keep-alive")
.arg("120")
.env("PYTHONUNBUFFERED", "1")
.env("SB_DATA_DIR", data_dir.as_os_str())
.env("SB_DATA_DIR", data_dir.as_os_str());
if let Some(privacy_core_lib) = bundled_privacy_core_lib(&runtime_root) {
command.env("PRIVACY_CORE_LIB", privacy_core_lib.as_os_str());
}
let mut child = command
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.spawn()
@@ -191,6 +198,18 @@ fn sync_release_attestation(bundled_root: &Path, install_root: &Path) -> Result<
Ok(())
}
fn bundled_privacy_core_lib(runtime_root: &Path) -> Option<PathBuf> {
let file_name = if cfg!(target_os = "windows") {
"privacy_core.dll"
} else if cfg!(target_os = "macos") {
"libprivacy_core.dylib"
} else {
"libprivacy_core.so"
};
let candidate = runtime_root.join(file_name);
candidate.exists().then_some(candidate)
}
fn release_attestation_path(root: &Path) -> PathBuf {
RELEASE_ATTESTATION_RELATIVE_PATH
.iter()
@@ -38,7 +38,7 @@
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJDMUU1NkRENjNCNTI5RjUKUldUMUtiVmozVlllTEd0STJlMGtORUxUWHlGQ2V0ZXM3Z1BOc3hwc0pUK1c3dlplcWc2OFpKd3oK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEUxODExMjQ4MkJBMThFNTgKUldSWWpxRXJTQktCNFF3ZXNQbndUK0pVWUEwNDNuajcrUGI3ZEI4TWtDUDlQdHhudmlHUkNjQUUK",
"endpoints": [
"https://github.com/BigBodyCobain/Shadowbroker/releases/latest/download/latest.json"
],
+9 -6
View File
@@ -13,10 +13,10 @@ services:
ports:
- "${BIND:-127.0.0.1}:8000:8000"
environment:
- AIS_API_KEY=${AIS_API_KEY}
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
- AIS_API_KEY=${AIS_API_KEY:-}
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID:-}
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET:-}
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY:-}
- ADMIN_KEY=${ADMIN_KEY:-}
- FINNHUB_API_KEY=${FINNHUB_API_KEY:-}
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
@@ -26,7 +26,10 @@ services:
# Operator-trusted sync/push peers. Leave empty unless you control the peer secret on both sides.
- MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-}
# Shared transport auth for operator peer push. Must be set to a unique secret per deployment.
- MESH_PEER_PUSH_SECRET=${MESH_PEER_PUSH_SECRET}
- MESH_PEER_PUSH_SECRET=${MESH_PEER_PUSH_SECRET:-}
# The bundled Docker UI talks to the backend across Docker's private bridge.
# Treat that bridge as local operator access while ports remain bound to 127.0.0.1 by default.
- SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=${SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR:-1}
volumes:
- backend_data:/app/data
restart: unless-stopped
@@ -56,7 +59,7 @@ services:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/"]
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 10s
retries: 3
+134 -395
View File
@@ -33,9 +33,9 @@
"@vitest/coverage-v8": "^4.1.0",
"concurrently": "^9.2.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.2.4",
"jsdom": "^28.1.0",
"prettier": "^3.3.3",
"prettier": "^3.8.3",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.0"
@@ -1362,9 +1362,9 @@
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
"integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1870,49 +1870,49 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
"integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.31.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.1"
"tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
"integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-arm64": "4.2.1",
"@tailwindcss/oxide-darwin-x64": "4.2.1",
"@tailwindcss/oxide-freebsd-x64": "4.2.1",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
"@tailwindcss/oxide-linux-x64-musl": "4.2.1",
"@tailwindcss/oxide-wasm32-wasi": "4.2.1",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
"@tailwindcss/oxide-android-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-x64": "4.2.4",
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
"integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
"cpu": [
"arm64"
],
@@ -1927,9 +1927,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
"integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
"cpu": [
"arm64"
],
@@ -1944,9 +1944,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
"integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
"cpu": [
"x64"
],
@@ -1961,9 +1961,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
"integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
"cpu": [
"x64"
],
@@ -1978,9 +1978,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
"integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
"cpu": [
"arm"
],
@@ -1995,9 +1995,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
"integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
"cpu": [
"arm64"
],
@@ -2012,9 +2012,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
"integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
"cpu": [
"arm64"
],
@@ -2029,9 +2029,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
"integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
"cpu": [
"x64"
],
@@ -2046,9 +2046,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
"integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
"cpu": [
"x64"
],
@@ -2063,9 +2063,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
"integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -2157,9 +2157,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
"integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
"cpu": [
"arm64"
],
@@ -2174,9 +2174,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
"integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
"cpu": [
"x64"
],
@@ -2191,17 +2191,17 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
"integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
"integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.2.1",
"@tailwindcss/oxide": "4.2.1",
"@tailwindcss/node": "4.2.4",
"@tailwindcss/oxide": "4.2.4",
"postcss": "^8.5.6",
"tailwindcss": "4.2.1"
"tailwindcss": "4.2.4"
}
},
"node_modules/@tauri-apps/api": {
@@ -4198,14 +4198,14 @@
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
@@ -4492,13 +4492,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
"integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.2.4",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -6338,9 +6338,9 @@
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
@@ -6354,23 +6354,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.31.1",
"lightningcss-darwin-arm64": "1.31.1",
"lightningcss-darwin-x64": "1.31.1",
"lightningcss-freebsd-x64": "1.31.1",
"lightningcss-linux-arm-gnueabihf": "1.31.1",
"lightningcss-linux-arm64-gnu": "1.31.1",
"lightningcss-linux-arm64-musl": "1.31.1",
"lightningcss-linux-x64-gnu": "1.31.1",
"lightningcss-linux-x64-musl": "1.31.1",
"lightningcss-win32-arm64-msvc": "1.31.1",
"lightningcss-win32-x64-msvc": "1.31.1"
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
@@ -6389,9 +6389,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
@@ -6410,9 +6410,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
@@ -6431,9 +6431,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
@@ -6452,9 +6452,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
@@ -6473,9 +6473,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
@@ -6494,9 +6494,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
@@ -6515,9 +6515,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
@@ -6536,9 +6536,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
@@ -6557,9 +6557,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
@@ -6578,9 +6578,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
@@ -7342,9 +7342,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7419,9 +7419,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -8632,16 +8632,16 @@
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
"integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9197,267 +9197,6 @@
}
}
},
"node_modules/vite/node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/vite/node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+2 -2
View File
@@ -45,9 +45,9 @@
"@vitest/coverage-v8": "^4.1.0",
"concurrently": "^9.2.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.2.4",
"jsdom": "^28.1.0",
"prettier": "^3.3.3",
"prettier": "^3.8.3",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.0"
@@ -302,10 +302,10 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
await screen.findByText(/Mail delivered to Pinned Peer/i);
await screen.findByText(/Mail delivered to Pinned Peer/i, {}, { timeout: 5000 });
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled();
expect(mocks.sendDmMessage).toHaveBeenCalled();
});
}, 10000);
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
contactsState = {
+24 -7
View File
@@ -51,6 +51,10 @@ const NO_STORE_PROXY_HEADERS = {
Pragma: 'no-cache',
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isSensitiveProxyPath(pathSegments: string[]): boolean {
const joined = pathSegments.join('/');
if (!joined) return false;
@@ -76,8 +80,7 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
isMesh &&
!isSensitiveMeshPath &&
['POST', 'PUT', 'DELETE'].includes(req.method.toUpperCase()) &&
(meshSegments.join('/') === 'send' ||
meshSegments.join('/') === 'vote' ||
(meshSegments.join('/') === 'vote' ||
meshSegments.join('/') === 'report' ||
meshSegments.join('/') === 'gate/create' ||
(meshSegments[0] === 'gate' && meshSegments[2] === 'message') ||
@@ -191,7 +194,7 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
}
const isBodyless = req.method === 'GET' || req.method === 'HEAD';
let upstream: Response;
let upstream: Response | null = null;
const requestInit: RequestInit & { duplex?: 'half' } = {
method: req.method,
headers: forwardHeaders,
@@ -202,12 +205,26 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise<NextResp
// Required for streaming request bodies in Node.js fetch
requestInit.duplex = 'half';
}
try {
upstream = await fetch(targetUrl.toString(), requestInit);
} catch {
const maxAttempts = isBodyless ? 18 : 1;
let fetchError: unknown = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
upstream = await fetch(targetUrl.toString(), requestInit);
fetchError = null;
break;
} catch (error) {
fetchError = error;
if (attempt >= maxAttempts) break;
await sleep(250);
}
}
if (!upstream) {
return new NextResponse(JSON.stringify({ error: 'Backend unavailable' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-Proxy-Error': fetchError instanceof Error ? fetchError.name : 'fetch_failed',
},
});
}
+140 -73
View File
@@ -26,11 +26,12 @@ import GlobalTicker from '@/components/GlobalTicker';
import ErrorBoundary from '@/components/ErrorBoundary';
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
import StartupWarmupModal, { useStartupWarmupNotice } from '@/components/StartupWarmupModal';
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
import type { ShodanSearchMatch } from '@/types/shodan';
import { API_BASE } from '@/lib/api';
import { useDataPolling, LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
import { useBackendStatus, useDataKey } from '@/hooks/useDataStore';
import { useBackendStatus, useDataKey, useDataKeys } from '@/hooks/useDataStore';
import { useReverseGeocode } from '@/hooks/useReverseGeocode';
import { useRegionDossier } from '@/hooks/useRegionDossier';
import { useAgentActions } from '@/hooks/useAgentActions';
@@ -61,6 +62,9 @@ const MaplibreViewer = dynamic(() => import('@/components/MaplibreViewer'), { ss
export default function Dashboard() {
const viewBoundsRef = useRef<{ south: number; west: number; north: number; east: number } | null>(null);
// Start the critical map data request before panel/control-plane effects.
// Non-map widgets can warm up after this; first paint needs flights, ships, and intel first.
useDataPolling();
const { mouseCoords, locationLabel, handleMouseCoords } = useReverseGeocode();
const [selectedEntity, setSelectedEntity] = useState<SelectedEntity | null>(null);
const [trackedSdr, setTrackedSdr] = useState<KiwiSDR | null>(null);
@@ -211,10 +215,35 @@ export default function Dashboard() {
const [shodanResults, setShodanResults] = useState<ShodanSearchMatch[]>([]);
const [, setShodanQueryLabel] = useState('');
const [shodanStyle, setShodanStyle] = useState<import('@/types/shodan').ShodanStyleConfig>({ shape: 'circle', color: '#16a34a', size: 'md' });
useDataPolling();
const backendStatus = useBackendStatus();
const spaceWeather = useDataKey('space_weather');
const feedHealth = useFeedHealth();
const bootSignals = useDataKeys([
'bootstrap_ready',
'commercial_flights',
'military_flights',
'tracked_flights',
'ships',
'news',
'threat_level',
] as const);
const criticalPaintReady = Boolean(
bootSignals.bootstrap_ready ||
(bootSignals.commercial_flights?.length || 0) > 0 ||
(bootSignals.military_flights?.length || 0) > 0 ||
(bootSignals.tracked_flights?.length || 0) > 0 ||
(bootSignals.ships?.length || 0) > 0 ||
(bootSignals.news?.length || 0) > 0 ||
bootSignals.threat_level,
);
const [secondaryBootReady, setSecondaryBootReady] = useState(false);
useEffect(() => {
if (secondaryBootReady) return;
const delay = criticalPaintReady ? 900 : 5500;
const id = window.setTimeout(() => setSecondaryBootReady(true), delay);
return () => window.clearTimeout(id);
}, [criticalPaintReady, secondaryBootReady]);
// Global keyboard shortcuts
useKeyboardShortcuts({
@@ -249,6 +278,7 @@ export default function Dashboard() {
const layersTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLayerSyncRef = useRef(false);
useEffect(() => {
if (!secondaryBootReady) return;
const syncLayers = (triggerRefetch: boolean) =>
fetch(`${API_BASE}/api/layers`, {
method: 'POST',
@@ -258,7 +288,7 @@ export default function Dashboard() {
if (triggerRefetch) {
window.dispatchEvent(new Event(LAYER_TOGGLE_EVENT));
}
}).catch((e) => console.error('Failed to update backend layers:', e));
}).catch((e) => console.warn('Backend layer sync will retry after runtime is reachable:', e));
if (layersTimerRef.current) clearTimeout(layersTimerRef.current);
if (!initialLayerSyncRef.current) {
@@ -272,7 +302,7 @@ export default function Dashboard() {
return () => {
if (layersTimerRef.current) clearTimeout(layersTimerRef.current);
};
}, [activeLayers]);
}, [activeLayers, secondaryBootReady]);
// Left panel accordion state
const [leftDataMinimized, setLeftDataMinimized] = useState(false);
@@ -393,12 +423,28 @@ export default function Dashboard() {
};
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
const firstPaintActiveLayers = useMemo<ActiveLayers>(() => {
if (secondaryBootReady) return activeLayers;
return {
...activeLayers,
cctv: false,
sar: false,
gibs_imagery: false,
highres_satellite: false,
sentinel_hub: false,
viirs_nightlights: false,
psk_reporter: false,
tinygs: false,
datacenters: false,
power_plants: false,
};
}, [activeLayers, secondaryBootReady]);
// Agent fly_to handler (sar_focus_aoi etc.) — wired here now that
// setFlyToLocation is in scope. show_image is routed through
// useAgentActions at the top of Dashboard.
useAgentActions(handleMapRightClick, ({ lat, lng }) => {
setFlyToLocation({ lat, lng, ts: Date.now() });
});
}, secondaryBootReady);
// Eavesdrop Mode State
const [isEavesdropping] = useState(false);
@@ -407,6 +453,7 @@ export default function Dashboard() {
// Onboarding & connection status
const { showOnboarding, setShowOnboarding } = useOnboarding();
const { showWarmupNotice, setShowWarmupNotice } = useStartupWarmupNotice();
const { showChangelog, setShowChangelog } = useChangelog();
return (
@@ -415,7 +462,7 @@ export default function Dashboard() {
{/* MAPLIBRE WEBGL OVERLAY */}
<ErrorBoundary name="Map">
<MaplibreViewer
activeLayers={activeLayers}
activeLayers={firstPaintActiveLayers}
activeFilters={activeFilters}
effects={memoizedEffects}
onEntityClick={setSelectedEntity}
@@ -502,74 +549,87 @@ export default function Dashboard() {
>
{/* 1. DATA LAYERS (Top) */}
<div className="contents" style={{ direction: 'ltr' }}>
<ErrorBoundary name="WorldviewLeftPanel">
<WorldviewLeftPanel
activeLayers={activeLayers}
setActiveLayers={setActiveLayers}
shodanResultCount={shodanResults.length}
onSettingsClick={() => setSettingsOpen(true)}
onLegendClick={() => setLegendOpen(true)}
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
gibsDate={gibsDate}
setGibsDate={setGibsDate}
gibsOpacity={gibsOpacity}
setGibsOpacity={setGibsOpacity}
sentinelDate={sentinelDate}
setSentinelDate={setSentinelDate}
sentinelOpacity={sentinelOpacity}
setSentinelOpacity={setSentinelOpacity}
sentinelPreset={sentinelPreset}
setSentinelPreset={setSentinelPreset}
onEntityClick={setSelectedEntity}
onFlyTo={handleFlyTo}
trackedSdr={trackedSdr}
setTrackedSdr={setTrackedSdr}
trackedScanner={trackedScanner}
setTrackedScanner={setTrackedScanner}
isMinimized={leftDataMinimized}
onMinimizedChange={setLeftDataMinimized}
/>
</ErrorBoundary>
{secondaryBootReady ? (
<ErrorBoundary name="WorldviewLeftPanel">
<WorldviewLeftPanel
activeLayers={activeLayers}
setActiveLayers={setActiveLayers}
shodanResultCount={shodanResults.length}
onSettingsClick={() => setSettingsOpen(true)}
onLegendClick={() => setLegendOpen(true)}
onOpenSarAoiEditor={() => setSarAoiEditorOpen(true)}
gibsDate={gibsDate}
setGibsDate={setGibsDate}
gibsOpacity={gibsOpacity}
setGibsOpacity={setGibsOpacity}
sentinelDate={sentinelDate}
setSentinelDate={setSentinelDate}
sentinelOpacity={sentinelOpacity}
setSentinelOpacity={setSentinelOpacity}
sentinelPreset={sentinelPreset}
setSentinelPreset={setSentinelPreset}
onEntityClick={setSelectedEntity}
onFlyTo={handleFlyTo}
trackedSdr={trackedSdr}
setTrackedSdr={setTrackedSdr}
trackedScanner={trackedScanner}
setTrackedScanner={setTrackedScanner}
isMinimized={leftDataMinimized}
onMinimizedChange={setLeftDataMinimized}
/>
</ErrorBoundary>
) : (
<div className="bg-[#05090d]/95 border border-cyan-900/50 p-4 font-mono text-cyan-500/70">
<div className="text-[11px] tracking-[0.2em] text-cyan-400 font-bold">DATA LAYERS</div>
<div className="mt-3 text-[10px] tracking-wider">PRIORITIZING MAP FEEDS</div>
</div>
)}
</div>
{/* 2. MESH CHAT (Middle) */}
<div className="contents" style={{ direction: 'ltr' }}>
<MeshChat
onFlyTo={handleFlyTo}
expanded={leftMeshExpanded}
onExpandedChange={setLeftMeshExpanded}
onSettingsClick={() => setSettingsOpen(true)}
onTerminalToggle={openSecureTerminalLauncher}
launchRequest={meshChatLaunchRequest}
/>
</div>
{secondaryBootReady && (
<div className="contents" style={{ direction: 'ltr' }}>
<MeshChat
onFlyTo={handleFlyTo}
expanded={leftMeshExpanded}
onExpandedChange={setLeftMeshExpanded}
onSettingsClick={() => setSettingsOpen(true)}
onTerminalToggle={openSecureTerminalLauncher}
launchRequest={meshChatLaunchRequest}
/>
</div>
)}
{/* 3. SHODAN CONNECTOR (Bottom) */}
<div className="contents" style={{ direction: 'ltr' }}>
<ShodanPanel
currentResults={shodanResults}
onOpenSettings={() => setSettingsOpen(true)}
settingsOpen={settingsOpen}
onResultsChange={(results, queryLabel) => {
setShodanResults(results);
setShodanQueryLabel(queryLabel);
setActiveLayers((prev) => ({ ...prev, shodan_overlay: results.length > 0 }));
}}
onSelectEntity={setSelectedEntity}
onStyleChange={setShodanStyle}
isMinimized={leftShodanMinimized}
onMinimizedChange={setLeftShodanMinimized}
/>
</div>
{secondaryBootReady && (
<div className="contents" style={{ direction: 'ltr' }}>
<ShodanPanel
currentResults={shodanResults}
onOpenSettings={() => setSettingsOpen(true)}
settingsOpen={settingsOpen}
onResultsChange={(results, queryLabel) => {
setShodanResults(results);
setShodanQueryLabel(queryLabel);
setActiveLayers((prev) => ({ ...prev, shodan_overlay: results.length > 0 }));
}}
onSelectEntity={setSelectedEntity}
onStyleChange={setShodanStyle}
isMinimized={leftShodanMinimized}
onMinimizedChange={setLeftShodanMinimized}
/>
</div>
)}
{/* 4. AI INTEL (Below Shodan) */}
<div className="contents" style={{ direction: 'ltr' }}>
<AIIntelPanel
onFlyTo={handleFlyTo}
pinPlacementMode={pinPlacementMode}
onPinPlacementModeChange={setPinPlacementMode}
/>
</div>
{secondaryBootReady && (
<div className="contents" style={{ direction: 'ltr' }}>
<AIIntelPanel
onFlyTo={handleFlyTo}
pinPlacementMode={pinPlacementMode}
onPinPlacementModeChange={setPinPlacementMode}
/>
</div>
)}
</motion.div>
{/* LEFT SIDEBAR TOGGLE TAB — aligns with Data Layers section */}
@@ -647,11 +707,13 @@ export default function Dashboard() {
{/* GLOBAL TICKER REPLACES MARKETS PANEL - RENDERED OUTSIDE THIS DIV */}
{/* EVENT TIMELINE */}
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
<ErrorBoundary name="TimelinePanel">
<TimelinePanel />
</ErrorBoundary>
</div>
{secondaryBootReady && (
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'predictions' ? 'hidden' : ''}`}>
<ErrorBoundary name="TimelinePanel">
<TimelinePanel />
</ErrorBoundary>
</div>
)}
{/* DATA FILTERS */}
<div className={`flex-shrink-0 ${rightFocusedPanel && rightFocusedPanel !== 'filters' ? 'hidden' : ''}`}>
@@ -870,8 +932,13 @@ export default function Dashboard() {
/>
)}
{/* FIRST-RUN WARMUP NOTICE — shows once after onboarding */}
{!showOnboarding && showWarmupNotice && (
<StartupWarmupModal onClose={() => setShowWarmupNotice(false)} />
)}
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
{!showOnboarding && showChangelog && (
{!showOnboarding && !showWarmupNotice && showChangelog && (
<ChangelogModal onClose={() => setShowChangelog(false)} />
)}
+10 -3
View File
@@ -8,8 +8,11 @@ export interface HlsVideoHandle {
get paused(): boolean;
}
const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; onError?: () => void }>(
({ url, className, onError }, ref) => {
const HlsVideo = forwardRef<
HlsVideoHandle,
{ url: string; className?: string; onError?: () => void; onLoaded?: () => void }
>(
({ url, className, onError, onLoaded }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
@@ -35,6 +38,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
hls.on(Hls.Events.ERROR, (_e: unknown, data: { fatal?: boolean }) => {
if (data.fatal) onError?.();
});
hls.on(Hls.Events.MANIFEST_PARSED, () => onLoaded?.());
hls.loadSource(url);
hls.attachMedia(video);
hlsInstance = hls;
@@ -47,7 +51,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
cancelled = true;
hlsInstance?.destroy();
};
}, [url, onError]);
}, [url, onError, onLoaded]);
return (
<video
@@ -56,6 +60,9 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
muted
playsInline
onError={() => onError?.()}
onCanPlay={() => onLoaded?.()}
onLoadedData={() => onLoaded?.()}
onPlaying={() => onLoaded?.()}
className={className}
/>
);
@@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Terminal, Radio, Globe, Key, LogOut, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
import { Terminal, Radio, Globe, Key, Activity, Vote, User, ArrowRightLeft, Briefcase, Mail, Brain, GitBranch, Cpu, KeyRound } from 'lucide-react';
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
import {
activateWormholeGatePersona,
@@ -128,7 +128,6 @@ const SECTIONS = [
{ name: 'EXCHANGE', icon: <ArrowRightLeft size={14} className="mr-2" /> },
{ name: 'PROFILE', icon: <User size={14} className="mr-2" /> },
{ name: 'MESSAGES', icon: <Mail size={14} className="mr-2" /> },
{ name: 'EXIT', icon: <LogOut size={14} className="mr-2" /> },
];
interface CommandHistory {
@@ -32,12 +32,22 @@ export default function NetworkStats() {
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
]);
if (!alive) return;
const knownNodes = Number(infonet?.known_nodes || 0);
const syncPeerCount = Number(infonet?.bootstrap?.sync_peer_count || 0);
const defaultSyncPeerCount = Number(infonet?.bootstrap?.default_sync_peer_count || 0);
const lastPeerUrl = String(infonet?.sync_runtime?.last_peer_url || '').trim();
const visibleInfonetNodes = Math.max(
knownNodes,
syncPeerCount,
defaultSyncPeerCount,
lastPeerUrl ? 1 : 0,
);
setStats({
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
aprs: Number(meshRes?.signal_counts?.aprs || 0),
infonetNodes: Number(infonet?.known_nodes || 0),
infonetNodes: visibleInfonetNodes,
infonetEvents: Number(infonet?.total_events || 0),
syncPeers: Number(infonet?.bootstrap?.sync_peer_count || 0),
syncPeers: syncPeerCount,
nodeEnabled: Boolean(infonet?.node_enabled),
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
});
@@ -2,7 +2,7 @@
import React, { useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { X, Minus } from 'lucide-react';
import { X } from 'lucide-react';
import InfonetShell from './InfonetShell';
interface InfonetTerminalProps {
@@ -55,13 +55,6 @@ export default function InfonetTerminal({
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={onClose}
className="p-1 text-gray-600 hover:text-gray-300 transition-colors"
title="Minimize"
>
<Minus size={14} />
</button>
<button
onClick={onClose}
className="p-1 text-gray-600 hover:text-red-400 transition-colors"
+78 -17
View File
@@ -16,7 +16,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import { computeNightPolygon } from '@/utils/solarTerminator';
import { darkStyle, lightStyle } from '@/components/map/styles/mapStyles';
import maplibregl from 'maplibre-gl';
import { AlertTriangle, Radio, Activity, Play, Satellite } from 'lucide-react';
import { AlertTriangle, Radio, Activity, Play, Satellite, ExternalLink, Info } from 'lucide-react';
import WikiImage from '@/components/WikiImage';
import FishingDestinationRoute from '@/components/map/FishingDestinationRoute';
import { useTheme } from '@/lib/ThemeContext';
@@ -293,6 +293,15 @@ function probeRasterTile(url: string): Promise<boolean> {
});
}
function buildPolymarketUrl(prediction: { slug?: string; title?: string } | null | undefined): string {
const slug = String(prediction?.slug || '').trim();
if (slug) return `https://polymarket.com/event/${encodeURIComponent(slug)}`;
const title = String(prediction?.title || '').trim();
return title
? `https://polymarket.com/search?query=${encodeURIComponent(title)}`
: 'https://polymarket.com/markets';
}
const MaplibreViewer = ({
activeLayers,
activeFilters,
@@ -336,6 +345,7 @@ const MaplibreViewer = ({
const data = useMemo(() => ({ ...coreData, ...extraData }) as DashboardData, [coreData, extraData]);
const mapRef = useRef<MapRef>(null);
const mapInitRef = useRef(false);
const [mapReady, setMapReady] = useState(false);
const { theme } = useTheme();
const mapThemeStyle = useMemo<maplibregl.StyleSpecification>(
() => (theme === 'light' ? lightStyle : darkStyle) as maplibregl.StyleSpecification,
@@ -905,15 +915,20 @@ const MaplibreViewer = ({
// Load Images into the Map Style once loaded
const onMapLoad = useCallback((e: { target: maplibregl.Map }) => {
initializeMap(e.target);
setMapReady(true);
}, [initializeMap]);
const onMapStyleData = useCallback((e: { target: maplibregl.Map }) => {
initializeMap(e.target);
setMapReady(true);
}, [initializeMap]);
useEffect(() => {
const map = mapRef.current?.getMap();
if (map) initializeMap(map);
if (map) {
initializeMap(map);
setMapReady(true);
}
}, [initializeMap, theme]);
// Build a set of tracked icao24s to exclude from other flight layers
@@ -1552,7 +1567,7 @@ const MaplibreViewer = ({
}, [activeLayers.uap_sightings, activeLayers.wastewater, theme]);
// --- Imperative source updates: bypass React reconciliation for GeoJSON layers ---
const mapForHook = mapRef.current;
const mapForHook = mapReady ? mapRef.current : null;
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
@@ -5712,29 +5727,56 @@ const MaplibreViewer = ({
<div className="px-5 pb-3">
<div className="grid grid-cols-3 gap-2">
{/* Oracle Score */}
<div className={`border rounded p-3 text-center ${oTierBg || 'bg-black/40 border-cyan-800/30'}`}>
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">ORACLE SCORE</div>
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${oTierBg || 'bg-black/40 border-cyan-800/30'}`}>
<input type="checkbox" className="peer sr-only" aria-label="Explain Oracle Score" />
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
<span>ORACLE SCORE</span>
<Info size={10} />
</div>
<div className={`text-[28px] font-bold leading-none ${oTierColor || 'text-gray-500'}`}>
{oScore != null ? oScore.toFixed(1) : '—'}
</div>
{oTier && <div className={`text-[10px] font-bold ${oTierColor} mt-1`}>{oTier}</div>}
</div>
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
<p>0-10 weighted signal score combining alert risk and source confidence.</p>
<p className="mt-1 text-[var(--text-muted)]">0-3 low, 4-5 moderate, 6-7 elevated, 8-10 critical.</p>
</div>
</label>
{/* Sentiment */}
<div className={`border rounded p-3 text-center ${sentBg || 'bg-black/40 border-cyan-800/30'}`}>
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">SENTIMENT</div>
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${sentBg || 'bg-black/40 border-cyan-800/30'}`}>
<input type="checkbox" className="peer sr-only" aria-label="Explain Sentiment" />
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
<span>SENTIMENT</span>
<Info size={10} />
</div>
<div className={`text-[28px] font-bold leading-none ${sentColor || 'text-gray-500'}`}>
{sent != null ? <>{sentArrow} {sent > 0 ? '+' : ''}{sent.toFixed(2)}</> : '—'}
</div>
{sentLabel && <div className={`text-[10px] font-bold ${sentColor} mt-1`}>{sentLabel}</div>}
</div>
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
<p>-1.00 to +1.00 headline tone. Negative reads more adverse; positive reads more constructive.</p>
<p className="mt-1 text-[var(--text-muted)]">Below -0.10 negative, -0.10 to +0.10 neutral, above +0.10 positive. It measures tone, not truth.</p>
</div>
</label>
{/* Threat Level */}
<div className={`border rounded p-3 text-center ${rs >= 8 ? 'bg-red-500/10 border-red-500/30' : rs >= 6 ? 'bg-orange-500/10 border-orange-500/30' : rs >= 4 ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30'}`}>
<div className="text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">RISK LEVEL</div>
<label className={`border rounded p-3 text-center transition-colors hover:border-white/40 cursor-pointer ${rs >= 8 ? 'bg-red-500/10 border-red-500/30' : rs >= 6 ? 'bg-orange-500/10 border-orange-500/30' : rs >= 4 ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30'}`}>
<input type="checkbox" className="peer sr-only" aria-label="Explain Risk Level" />
<div className="flex items-center justify-center gap-1 text-[9px] text-[var(--text-muted)] tracking-[0.15em] mb-1.5">
<span>RISK LEVEL</span>
<Info size={10} />
</div>
<div className={`text-[28px] font-bold leading-none ${threatColor}`}>{rs}/10</div>
<div className={`text-[10px] font-bold ${threatColor} mt-1`}>
{rs >= 9 ? 'CRITICAL' : rs >= 7 ? 'HIGH' : rs >= 4 ? 'MEDIUM' : 'LOW'}
</div>
</div>
<div className="hidden peer-checked:block mt-2 border-t border-white/10 pt-2 text-left text-[10px] leading-relaxed text-cyan-100">
<div className="text-cyan-400 font-bold tracking-[0.16em] mb-1">SCALE</div>
<p>0-10 operational severity estimate based on source, topic, keywords, corroboration, and alert context.</p>
<p className="mt-1 text-[var(--text-muted)]">0-3 low, 4-6 medium, 7-8 high, 9-10 critical.</p>
</div>
</label>
</div>
</div>
@@ -5742,8 +5784,20 @@ const MaplibreViewer = ({
{pred && pred.consensus_pct != null && (
<div className="px-5 pb-3">
<div className="bg-purple-950/30 border border-purple-500/40 rounded p-4">
<div className="text-[10px] text-purple-400 tracking-[0.2em] font-bold mb-2">
PREDICTION MARKET ANALYSIS
<div className="flex items-center justify-between gap-3 mb-2">
<div className="text-[10px] text-purple-400 tracking-[0.2em] font-bold">
PREDICTION MARKET ANALYSIS
</div>
{pred.polymarket_pct != null && (
<button
type="button"
onClick={() => window.open(buildPolymarketUrl(pred), '_blank', 'noopener,noreferrer')}
className="inline-flex items-center gap-1 text-[10px] text-purple-200 hover:text-white border border-purple-500/30 hover:border-purple-300/70 px-2 py-1 rounded transition-colors"
title="Open this market on Polymarket"
>
POLYMARKET <ExternalLink size={10} />
</button>
)}
</div>
<div className="text-[14px] text-purple-200 font-bold leading-snug mb-3">
&quot;{pred.title}&quot;
@@ -5760,10 +5814,16 @@ const MaplibreViewer = ({
</div>
<div className="flex gap-6 text-[11px]">
{pred.polymarket_pct != null && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => window.open(buildPolymarketUrl(pred), '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:text-white transition-colors"
title="Open this market on Polymarket"
>
<span className="text-purple-400/70">Polymarket</span>
<span className="text-white font-bold text-[13px]">{pred.polymarket_pct}%</span>
</div>
<ExternalLink size={10} className="text-purple-400/70" />
</button>
)}
{pred.kalshi_pct != null && (
<div className="flex items-center gap-2">
@@ -5834,7 +5894,7 @@ const MaplibreViewer = ({
onClick={() => window.open(item.link, '_blank', 'noopener,noreferrer')}
className={`${threatColor} hover:text-white text-[12px] font-bold underline underline-offset-2 cursor-pointer`}
>
VIEW FULL REPORT
GO TO ARTICLE
</button>
) : <span />}
<button
@@ -5902,6 +5962,7 @@ const MaplibreViewer = ({
return (
<CctvFullscreenModal
url={url}
rawUrl={rawUrl}
mediaType={mt}
isVideo={isVideo}
cameraName={cameraName}
@@ -1,11 +1,12 @@
'use client';
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { AlertTriangle, Play, Pause } from 'lucide-react';
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
export interface CctvFullscreenModalProps {
url: string;
rawUrl?: string;
mediaType: string;
isVideo: boolean;
cameraName: string;
@@ -16,6 +17,7 @@ export interface CctvFullscreenModalProps {
export function CctvFullscreenModal({
url,
rawUrl = '',
mediaType,
isVideo,
cameraName,
@@ -25,8 +27,60 @@ export function CctvFullscreenModal({
}: CctvFullscreenModalProps) {
const [paused, setPaused] = useState(false);
const [mediaError, setMediaError] = useState(false);
const [mediaLoaded, setMediaLoaded] = useState(false);
const [sourceIndex, setSourceIndex] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<HlsVideoHandle>(null);
const sources = useMemo(() => {
const seen = new Set<string>();
return [url, rawUrl]
.map((candidate) => String(candidate || '').trim())
.filter((candidate) => {
if (!candidate || seen.has(candidate)) return false;
seen.add(candidate);
return true;
});
}, [rawUrl, url]);
const activeUrl = sources[sourceIndex] || '';
useEffect(() => {
setSourceIndex(0);
setMediaError(false);
setMediaLoaded(false);
setPaused(false);
}, [rawUrl, url]);
useEffect(() => {
setMediaLoaded(false);
}, [activeUrl]);
const handleMediaFailure = useCallback(() => {
setSourceIndex((idx) => {
const next = idx + 1;
if (next < sources.length) {
setMediaError(false);
return next;
}
setMediaError(true);
return idx;
});
}, [sources.length]);
const handleMediaReady = useCallback(() => {
setMediaLoaded(true);
}, []);
useEffect(() => {
if (sourceIndex !== 0 || sources.length < 2 || mediaLoaded || mediaError) return;
const timeoutMs = mediaType === 'hls' ? 3200 : 1800;
const timer = window.setTimeout(() => {
setSourceIndex((idx) => {
if (idx !== 0 || mediaLoaded) return idx;
return 1;
});
}, timeoutMs);
return () => window.clearTimeout(timer);
}, [mediaError, mediaLoaded, mediaType, sourceIndex, sources.length]);
const togglePlay = useCallback(() => {
if (mediaType === 'hls') {
@@ -176,17 +230,21 @@ export function CctvFullscreenModal({
overflow: 'hidden',
}}
>
{url ? (
{activeUrl ? (
<>
{mediaType === 'video' && !mediaError && (
<video
key={activeUrl}
ref={videoRef}
src={url}
src={activeUrl}
autoPlay
loop
muted
playsInline
onError={() => setMediaError(true)}
onError={handleMediaFailure}
onCanPlay={handleMediaReady}
onLoadedData={handleMediaReady}
onPlaying={handleMediaReady}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 260px)',
@@ -197,15 +255,18 @@ export function CctvFullscreenModal({
)}
{mediaType === 'hls' && !mediaError && (
<HlsVideo
key={activeUrl}
ref={hlsRef}
url={url}
onError={() => setMediaError(true)}
className=""
url={activeUrl}
onError={handleMediaFailure}
onLoaded={handleMediaReady}
className="max-w-full max-h-[calc(100vh-260px)] object-contain"
/>
)}
{mediaType === 'mjpeg' && (
<img
src={url}
key={activeUrl}
src={activeUrl}
alt="MJPEG Feed"
style={{
maxWidth: '100%',
@@ -213,14 +274,14 @@ export function CctvFullscreenModal({
objectFit: 'contain',
filter: 'contrast(1.25) saturate(0.5)',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
onError={handleMediaFailure}
onLoad={handleMediaReady}
/>
)}
{(mediaType === 'image' || mediaType === 'satellite') && (
<img
src={url}
key={activeUrl}
src={activeUrl}
alt="CCTV Feed"
style={{
maxWidth: '100%',
@@ -228,10 +289,8 @@ export function CctvFullscreenModal({
objectFit: 'contain',
filter: 'contrast(1.25) saturate(0.5)',
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
onError={handleMediaFailure}
onLoad={handleMediaReady}
/>
)}
@@ -239,7 +298,7 @@ export function CctvFullscreenModal({
{mediaError && (
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
FEED UNAVAILABLE<br />
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>stream failed to load source may be offline</span>
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>proxy and direct source both failed</span>
</div>
)}
@@ -329,10 +388,10 @@ export function CctvFullscreenModal({
{cameraName}
</span>
<div style={{ display: 'flex', gap: 10 }}>
{url && (
{activeUrl && (
<>
<a
href={url}
href={rawUrl || activeUrl}
target="_blank"
rel="noopener noreferrer"
style={{
@@ -354,7 +413,7 @@ export function CctvFullscreenModal({
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
await navigator.clipboard.writeText(rawUrl || activeUrl);
} catch { /* ignore */ }
}}
style={{
+66 -27
View File
@@ -111,6 +111,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
identityWizardStatus,
setIdentityWizardStatus,
meshQuickStatus,
meshSessionActive,
publicMeshAddress,
meshView,
setMeshView,
@@ -119,6 +120,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
// Identity
identity,
publicIdentity,
hasStoredPublicLaneIdentity,
hasPublicLaneIdentity,
hasId,
shouldShowIdentityWarning,
@@ -255,6 +257,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
openChat,
handleCreatePublicIdentity,
handleQuickCreatePublicIdentity,
handleActivatePublicMeshSession,
handleLeaveWormholeForPublicMesh,
handleResetPublicIdentity,
handleBootstrapPrivateIdentity,
@@ -324,6 +327,40 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
}
void handleRequestAccess(targetId);
};
const meshActivationText =
meshQuickStatus?.text ||
(publicMeshBlockedByWormhole
? hasStoredPublicLaneIdentity
? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.'
: 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.'
: hasStoredPublicLaneIdentity
? 'MeshChat is off. Turn it on to use your saved public mesh key.'
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.');
const handleMeshActivationAction = () => {
if (hasStoredPublicLaneIdentity) {
void handleActivatePublicMeshSession();
return;
}
if (publicMeshBlockedByWormhole) {
void handleLeaveWormholeForPublicMesh();
return;
}
void handleQuickCreatePublicIdentity();
};
const meshActivationLabel = identityWizardBusy
? 'GETTING MESH KEY'
: hasStoredPublicLaneIdentity
? 'TURN ON MESH'
: publicMeshBlockedByWormhole
? 'TURN OFF WORMHOLE FOR MESH'
: 'GET MESH KEY';
const meshActivationSideLabel = identityWizardBusy
? 'WORKING...'
: hasStoredPublicLaneIdentity
? 'USE SAVED KEY'
: publicMeshBlockedByWormhole
? 'AUTO DISABLE'
: 'ONE TAP';
return (
<div
@@ -1120,16 +1157,25 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</button>
</div>
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
{publicMeshAddress ? `ADDR ${publicMeshAddress.toUpperCase()}` : 'NO PUBLIC MESH ADDRESS'}
{meshSessionActive && publicMeshAddress
? `ADDR ${publicMeshAddress.toUpperCase()}`
: publicMeshAddress
? 'MESH OFF / KEY SAVED'
: 'NO PUBLIC MESH ADDRESS'}
</div>
</div>
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
{meshView === 'channel' && filteredMeshMessages.length === 0 && (
{!meshSessionActive && (
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
MeshChat is off. Turn it on to connect the public mesh lane.
</div>
)}
{meshSessionActive && meshView === 'channel' && filteredMeshMessages.length === 0 && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
No messages from {meshRegion} / {meshChannel}
</div>
)}
{meshView === 'inbox' && (
{meshSessionActive && meshView === 'inbox' && (
<>
{!publicMeshAddress && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
@@ -2049,7 +2095,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
? meshDirectTarget
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
: `→ MESH / ${meshRegion} / ${meshChannel}`
: '→ MESH LOCKED'
: hasStoredPublicLaneIdentity
? '→ MESH OFF'
: '→ MESH LOCKED'
: activeTab === 'dms' && secureDmBlocked
? '→ DEAD DROP LOCKED'
: dmView === 'chat' && selectedContact
@@ -2068,10 +2116,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
: 'text-green-300/70'
}`}
>
{meshQuickStatus?.text ||
(publicMeshBlockedByWormhole
? 'Wormhole is active. Turn it off here and we will mint a separate public mesh key for you.'
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.')}
{meshActivationText}
</div>
)}
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
@@ -2103,30 +2148,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</button>
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
<button
onClick={() => {
if (publicMeshBlockedByWormhole) {
void handleLeaveWormholeForPublicMesh();
return;
}
void handleQuickCreatePublicIdentity();
}}
onClick={handleMeshActivationAction}
disabled={identityWizardBusy}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-green-700/40 bg-green-950/15 text-green-300 hover:bg-green-950/25 hover:border-green-500/50 transition-colors"
>
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
<Radio size={11} />
{identityWizardBusy
? 'GETTING MESH KEY'
: publicMeshBlockedByWormhole
? 'TURN OFF WORMHOLE FOR MESH'
: 'GET MESH KEY'}
{meshActivationLabel}
</span>
<span className="text-[12px] font-mono text-green-300/70">
{identityWizardBusy
? 'WORKING...'
: publicMeshBlockedByWormhole
? 'AUTO FIX'
: 'ONE TAP'}
{meshActivationSideLabel}
</span>
</button>
) : activeTab === 'meshtastic' && meshDirectTarget ? (
@@ -2375,8 +2406,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
CURRENT STATE
</div>
<div className="grid grid-cols-1 gap-1 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.5]">
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : 'not issued'}</div>
<div>Public mesh address: {hasPublicLaneIdentity && publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : hasStoredPublicLaneIdentity ? 'saved / off' : 'not issued'}</div>
<div>Public mesh address: {publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
<div>Wormhole lane: {wormholeEnabled && wormholeReadyState ? 'active' : wormholeEnabled ? 'starting' : 'off'}</div>
<div>Wormhole descriptor: {wormholeDescriptor?.nodeId || 'not cached yet'}</div>
</div>
@@ -2385,6 +2416,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
<div className="grid grid-cols-1 gap-2">
<button
onClick={() => {
if (hasStoredPublicLaneIdentity) {
void handleActivatePublicMeshSession();
return;
}
if (publicMeshBlockedByWormhole) {
void handleLeaveWormholeForPublicMesh();
return;
@@ -2396,12 +2431,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
>
{hasPublicLaneIdentity
? 'MESH KEY ACTIVE'
: hasStoredPublicLaneIdentity
? 'TURN ON MESH'
: publicMeshBlockedByWormhole
? 'TURN OFF WORMHOLE FOR MESH'
: 'GET MESH KEY'}
<div className="mt-1 text-[13px] text-green-200/70 normal-case tracking-normal leading-[1.45]">
{hasPublicLaneIdentity
? 'Your public mesh key is already live for posting.'
: hasStoredPublicLaneIdentity
? 'Use your saved public mesh key. This turns Wormhole off first if it is active.'
: publicMeshBlockedByWormhole
? 'One tap turns Wormhole off and mints a separate public mesh key.'
: 'One tap for a working mesh key and address.'}
@@ -313,6 +313,7 @@ export function useMeshChatController({
const [identityWizardBusy, setIdentityWizardBusy] = useState(false);
const [identityWizardStatus, setIdentityWizardStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [meshSessionActive, setMeshSessionActive] = useState(false);
const [publicMeshAddress, setPublicMeshAddress] = useState('');
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
const [meshDirectTarget, setMeshDirectTarget] = useState('');
@@ -328,12 +329,14 @@ export function useMeshChatController({
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
const publicIdentity = clientHydrated ? getNodeIdentity() : null;
const hasPublicLaneIdentity = clientHydrated && Boolean(publicIdentity) && hasSovereignty();
const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null;
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty();
const publicIdentity = meshSessionActive ? storedPublicIdentity : null;
const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity;
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
const publicMeshBlockedByWormhole = wormholeEnabled && wormholeReadyState && !hasPublicLaneIdentity;
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const streamEnabledForSelectedGateRef = useRef(false);
@@ -365,6 +368,13 @@ export function useMeshChatController({
setClientHydrated(true);
}, []);
useEffect(() => {
if (!clientHydrated) return;
setMeshSessionActive(false);
setMeshMessages([]);
setMeshQuickStatus(null);
}, [clientHydrated]);
useEffect(
() =>
subscribeGateSessionStreamStatus((nextStatus) => {
@@ -450,6 +460,8 @@ export function useMeshChatController({
setSecureModeCached(enabled);
setWormholeEnabled(enabled);
if (enabled) {
setMeshSessionActive(false);
setMeshMessages([]);
purgeBrowserContactGraph();
void hydrateWormholeContacts();
}
@@ -515,7 +527,7 @@ export function useMeshChatController({
useEffect(() => {
let alive = true;
const senderId = publicIdentity?.nodeId || '';
const senderId = storedPublicIdentity?.nodeId || '';
if (!senderId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
@@ -530,7 +542,7 @@ export function useMeshChatController({
return () => {
alive = false;
};
}, [publicIdentity?.nodeId]);
}, [storedPublicIdentity?.nodeId]);
const flushDmQueue = useCallback(async () => {
const queue = dmSendQueue.current.splice(0);
@@ -1138,10 +1150,10 @@ export function useMeshChatController({
[meshMessages, mutedUsers],
);
const meshInboxMessages = useMemo(() => {
if (!publicMeshAddress) return [];
if (!meshSessionActive || !publicMeshAddress) return [];
const target = publicMeshAddress.toLowerCase();
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
}, [filteredMeshMessages, publicMeshAddress]);
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
// ─── InfoNet Polling ─────────────────────────────────────────────────────
@@ -1735,7 +1747,7 @@ export function useMeshChatController({
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
useEffect(() => {
if (!expanded || activeTab !== 'meshtastic') return;
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
let cancelled = false;
const fetchChannels = async () => {
try {
@@ -1794,12 +1806,12 @@ export function useMeshChatController({
cancelled = true;
clearInterval(iv);
};
}, [expanded, activeTab, meshRegion]);
}, [expanded, activeTab, meshRegion, meshSessionActive]);
// ─── Meshtastic Polling ──────────────────────────────────────────────────
useEffect(() => {
if (!expanded || activeTab !== 'meshtastic') return;
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
let cancelled = false;
const poll = async () => {
try {
@@ -1823,7 +1835,13 @@ export function useMeshChatController({
cancelled = true;
clearInterval(iv);
};
}, [expanded, activeTab, meshRegion, meshChannel, meshView]);
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]);
useEffect(() => {
if (meshSessionActive) return;
setMeshMessages([]);
setMeshQuickStatus(null);
}, [meshSessionActive]);
// ─── DM Polling ──────────────────────────────────────────────────────────
@@ -2305,7 +2323,8 @@ export function useMeshChatController({
const handleSend = async () => {
const msg = inputValue.trim();
if (!msg || !hasId || busy) return;
if (!msg || busy) return;
if (activeTab !== 'meshtastic' && !hasId) return;
const cooldownMs = activeTab === 'dms' ? 0 : 30_000;
const now = Date.now();
@@ -2392,13 +2411,15 @@ export function useMeshChatController({
]);
setGateReplyContext(null);
} else if (activeTab === 'meshtastic') {
if (!publicIdentity || !hasSovereignty()) {
if (!meshSessionActive || !publicIdentity || !hasSovereignty()) {
setInputValue(msg);
setLastSendTime(0);
setSendError('public mesh identity needed');
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
openIdentityWizard({
type: 'err',
text: 'Quick fix: create a public mesh identity below, then retry your send.',
text: hasStoredPublicLaneIdentity
? 'Quick fix: turn MeshChat on below, then retry your send.'
: 'Quick fix: create a public mesh identity below, then retry your send.',
});
setTimeout(() => setSendError(''), 4000);
setBusy(false);
@@ -3915,7 +3936,7 @@ export function useMeshChatController({
wormholeEnabled &&
wormholeReadyState &&
!selectedGateAccessReady) ||
((activeTab === 'infonet' || activeTab === 'meshtastic') && anonymousPublicBlocked) ||
(activeTab === 'infonet' && anonymousPublicBlocked) ||
(activeTab === 'dms' &&
(dmView !== 'chat' ||
!selectedContact ||
@@ -3959,16 +3980,36 @@ export function useMeshChatController({
[inputDisabled],
);
const disableWormholeForPublicMesh = useCallback(async () => {
const requireBackendLeave = wormholeEnabled || wormholeReadyState;
try {
await leaveWormhole();
} catch (err) {
if (requireBackendLeave) {
throw err;
}
}
setWormholeEnabled(false);
setWormholeReadyState(false);
setWormholeRnsReady(false);
setWormholeRnsDirectReady(false);
setWormholeRnsPeers({ active: 0, configured: 0 });
setSecureModeCached(false);
}, [wormholeEnabled, wormholeReadyState]);
const createPublicMeshIdentity = useCallback(
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
setIdentityWizardBusy(true);
setIdentityWizardStatus(null);
try {
await disableWormholeForPublicMesh();
const nextIdentity = await generateNodeKeys();
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
setIdentity(nextIdentity);
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
const successText = `Mesh key ready. Address ${readyAddress} is live for this testnet session.`;
setIdentityWizardStatus({
@@ -3997,7 +4038,7 @@ export function useMeshChatController({
setIdentityWizardBusy(false);
}
},
[],
[disableWormholeForPublicMesh],
);
const handleCreatePublicIdentity = useCallback(async () => {
@@ -4013,6 +4054,45 @@ export function useMeshChatController({
}
}, [createPublicMeshIdentity]);
const handleActivatePublicMeshSession = useCallback(async () => {
setIdentityWizardBusy(true);
setIdentityWizardStatus(null);
setMeshQuickStatus(null);
try {
const savedIdentity = getNodeIdentity();
if (!savedIdentity || !hasSovereignty()) {
const text = 'No saved public mesh key is available. Create a mesh key first.';
setMeshSessionActive(false);
setIdentityWizardStatus({ type: 'err', text });
setMeshQuickStatus({ type: 'err', text });
return { ok: false as const, text };
}
await disableWormholeForPublicMesh();
const nextAddress = await derivePublicMeshAddress(savedIdentity.nodeId).catch(() => '');
const readyAddress = (nextAddress || savedIdentity.nodeId).toUpperCase();
setIdentity(savedIdentity);
setPublicMeshAddress(nextAddress || savedIdentity.nodeId);
setMeshSessionActive(true);
setMeshMessages([]);
setSendError('');
const text = `MeshChat is on with saved address ${readyAddress}.`;
setIdentityWizardStatus({ type: 'ok', text });
setMeshQuickStatus({ type: 'ok', text });
return { ok: true as const, text };
} catch (err) {
const message =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message)
: 'unknown error';
const text = `Could not turn MeshChat on: ${message}`;
setIdentityWizardStatus({ type: 'err', text });
setMeshQuickStatus({ type: 'err', text });
return { ok: false as const, text };
} finally {
setIdentityWizardBusy(false);
}
}, [disableWormholeForPublicMesh]);
const handleReplyToMeshAddress = useCallback((address: string) => {
const target = String(address || '').trim();
if (!target) return;
@@ -4023,36 +4103,16 @@ export function useMeshChatController({
}, []);
const handleLeaveWormholeForPublicMesh = useCallback(async () => {
setIdentityWizardBusy(true);
setIdentityWizardStatus(null);
setMeshQuickStatus(null);
try {
await leaveWormhole();
setWormholeEnabled(false);
setWormholeReadyState(false);
setWormholeRnsReady(false);
setWormholeRnsDirectReady(false);
setWormholeRnsPeers({ active: 0, configured: 0 });
setSecureModeCached(false);
const result = await createPublicMeshIdentity({ closeWizardOnSuccess: false });
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
setIdentityWizardStatus(status);
setMeshQuickStatus(status);
if (result.ok) {
window.setTimeout(() => setIdentityWizardOpen(false), 900);
}
} catch (err) {
const message =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message)
: 'unknown error';
const text = `Could not turn Wormhole off for public mesh: ${message}`;
setIdentityWizardStatus({ type: 'err', text });
setMeshQuickStatus({ type: 'err', text });
} finally {
setIdentityWizardBusy(false);
const result = hasStoredPublicLaneIdentity
? await handleActivatePublicMeshSession()
: await createPublicMeshIdentity({ closeWizardOnSuccess: false });
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
setIdentityWizardStatus(status);
setMeshQuickStatus(status);
if (result.ok) {
window.setTimeout(() => setIdentityWizardOpen(false), 900);
}
}, [createPublicMeshIdentity]);
}, [createPublicMeshIdentity, handleActivatePublicMeshSession, hasStoredPublicLaneIdentity]);
const handleResetPublicIdentity = useCallback(async () => {
if (wormholeEnabled && wormholeReadyState) {
@@ -4065,8 +4125,11 @@ export function useMeshChatController({
setIdentityWizardBusy(true);
setIdentityWizardStatus(null);
try {
setMeshSessionActive(false);
setMeshMessages([]);
await clearBrowserIdentityState();
setIdentity(null);
setPublicMeshAddress('');
setContacts({});
setSelectedContact('');
setDmMessages([]);
@@ -4091,6 +4154,8 @@ export function useMeshChatController({
}, [wormholeEnabled, wormholeReadyState]);
const handleBootstrapPrivateIdentity = useCallback(async () => {
setMeshSessionActive(false);
setMeshMessages([]);
if (wormholeEnabled && wormholeReadyState) {
setIdentityWizardStatus({
type: 'ok',
@@ -4175,6 +4240,7 @@ export function useMeshChatController({
identityWizardStatus,
setIdentityWizardStatus,
meshQuickStatus,
meshSessionActive,
publicMeshAddress,
meshView,
setMeshView,
@@ -4183,6 +4249,7 @@ export function useMeshChatController({
// Identity
identity,
publicIdentity,
hasStoredPublicLaneIdentity,
hasPublicLaneIdentity,
hasId,
shouldShowIdentityWarning,
@@ -4320,6 +4387,7 @@ export function useMeshChatController({
openChat,
handleCreatePublicIdentity,
handleQuickCreatePublicIdentity,
handleActivatePublicMeshSession,
handleLeaveWormholeForPublicMesh,
handleResetPublicIdentity,
handleBootstrapPrivateIdentity,
+9 -1
View File
@@ -364,6 +364,7 @@ function summarizeNodePeer(peerUrl?: string): string {
}
function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): string {
if (snapshot && !snapshot.node_enabled) return 'READY / DISABLED';
const bootstrap = snapshot?.bootstrap;
if (!bootstrap) return 'LOCAL ONLY';
if (bootstrap.manifest_loaded) {
@@ -376,6 +377,7 @@ function describeBootstrapState(snapshot?: InfonetNodeStatusSnapshot | null): st
}
function describeSyncOutcome(snapshot?: InfonetNodeStatusSnapshot | null): string {
if (snapshot && !snapshot.node_enabled) return 'OFF - click NODE to activate';
const sync = snapshot?.sync_runtime;
if (!sync) return 'IDLE';
const outcome = String(sync.last_outcome || 'idle').trim().toLowerCase();
@@ -433,6 +435,12 @@ function buildNodeRuntimeLines(snapshot: InfonetNodeStatusSnapshot): TermLine[]
type: 'error',
});
}
if (!snapshot.node_enabled) {
lines.push({
text: ' Activate: click the NODE button in the top-right controls to join the public testnet seed',
type: 'dim',
});
}
lines.push({ text: '', type: 'dim' });
return lines;
}
@@ -5945,7 +5953,7 @@ export default function MeshTerminal({ isOpen, launchToken = 0, onClose, onDmCou
PARTICIPANT NODE
</div>
<div className="mt-1 text-sm leading-5 text-slate-400">
Automatic bootstrap and sync now live on the backend lane. This node can keep a local chain even with Wormhole off.
Backend bootstrap is configured; activate the participant node to sync the public testnet seed without Wormhole.
</div>
</div>
<div className="border border-cyan-500/20 bg-cyan-500/8 px-3 py-1.5 text-[13px] tracking-[0.22em] text-cyan-200">
+119 -27
View File
@@ -4,7 +4,9 @@ import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } from 'lucide-react';
const STORAGE_KEY = 'shadowbroker_onboarding_complete';
const CURRENT_ONBOARDING_VERSION = '0.9.7-docker-keys-1';
const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`;
const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete';
const API_GUIDES = [
{
@@ -17,7 +19,7 @@ const API_GUIDES = [
'Create a free account at opensky-network.org',
'Go to Dashboard → OAuth → Create Client',
'Copy your Client ID and Client Secret',
'Paste both into Settings → Aviation',
'Paste both into Quick Local Setup above or Settings → API Keys',
],
url: 'https://opensky-network.org/index.php?option=com_users&view=registration',
color: 'cyan',
@@ -31,7 +33,7 @@ const API_GUIDES = [
'Register at aisstream.io',
'Navigate to your API Keys page',
'Generate a new API key',
'Paste it into Settings → Maritime',
'Paste it into Quick Local Setup above or Settings → API Keys',
],
url: 'https://aisstream.io/authenticate',
color: 'blue',
@@ -59,18 +61,59 @@ const OnboardingModal = React.memo(function OnboardingModal({
onOpenSettings,
}: OnboardingModalProps) {
const [step, setStep] = useState(0);
const [setupKeys, setSetupKeys] = useState({
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '',
});
const [setupSaving, setSetupSaving] = useState(false);
const [setupMsg, setSetupMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const handleDismiss = () => {
localStorage.setItem(STORAGE_KEY, 'true');
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
onClose();
};
const handleOpenSettings = () => {
localStorage.setItem(STORAGE_KEY, 'true');
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
onClose();
onOpenSettings();
};
const saveSetupKeys = async () => {
const payload = Object.fromEntries(
Object.entries(setupKeys).filter(([, value]) => value.trim()),
);
if (!Object.keys(payload).length) {
setSetupMsg({ type: 'err', text: 'Enter at least one API key first.' });
return;
}
setSetupSaving(true);
setSetupMsg(null);
try {
const res = await fetch('/api/settings/api-keys', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.ok === false) {
throw new Error(data?.detail || 'Could not save API keys.');
}
setSetupKeys({ OPENSKY_CLIENT_ID: '', OPENSKY_CLIENT_SECRET: '', AIS_API_KEY: '' });
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
} catch (error) {
setSetupMsg({
type: 'err',
text: error instanceof Error ? error.message : 'Could not save API keys.',
});
} finally {
setSetupSaving(false);
}
};
return (
<AnimatePresence>
{/* Backdrop */}
@@ -123,7 +166,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
{/* Step Indicators */}
<div className="flex gap-2 px-6 pt-4">
{['Welcome', 'API Keys', 'Free Sources'].map((label, i) => (
{['API Keys', 'Trust Modes', 'Free Sources'].map((label, i) => (
<button
key={label}
onClick={() => setStep(i)}
@@ -140,36 +183,23 @@ const OnboardingModal = React.memo(function OnboardingModal({
{/* Content */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
{step === 0 && (
{step === 1 && (
<div className="space-y-4">
<div className="text-center py-4">
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
S H A D O W <span className="text-cyan-400">B R O K E R</span>
T R U S T <span className="text-cyan-400">M O D E S</span>
</div>
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
<p className="hidden">
Real-time OSINT dashboard aggregating 12+ live intelligence sources. Flights,
ships, satellites, earthquakes, conflicts, and more all on one map.
</p>
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
These modes explain what lane the network is using. Set up the API keys first,
then use this screen to understand public mesh versus private Wormhole paths.
</p>
</div>
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
<div className="flex items-start gap-2">
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">
API Keys Required
</p>
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
Two API keys are needed for full functionality:{' '}
<span className="text-cyan-400">OpenSky Network</span> (flights) and{' '}
<span className="text-blue-400">AIS Stream</span> (ships). Both are free.
Without them, some panels will show no data.
</p>
</div>
</div>
</div>
<div className="bg-green-950/20 border border-green-500/20 p-4">
<div className="hidden">
<div className="flex items-start gap-2">
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
<div>
@@ -216,8 +246,69 @@ const OnboardingModal = React.memo(function OnboardingModal({
</div>
)}
{step === 1 && (
{step === 0 && (
<div className="space-y-4">
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
<div className="flex items-start gap-2">
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">
START HERE
</p>
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
OpenSky Network and AIS Stream are the free keys that make ShadowBroker
useful immediately: live aircraft and vessel tracking. Paste them below or
use Settings later; secrets stay on the local backend.
</p>
</div>
</div>
</div>
<div className="border border-cyan-900/40 bg-cyan-950/10 p-4 space-y-3">
<div>
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
QUICK LOCAL SETUP
</p>
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed mt-1">
Paste keys here once. ShadowBroker stores them server-side only and never
displays the secret back in the browser.
</p>
</div>
{[
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
['AIS_API_KEY', 'AIS Stream API Key'],
].map(([key, label]) => (
<input
key={key}
type="password"
value={setupKeys[key as keyof typeof setupKeys]}
onChange={(event) =>
setSetupKeys((prev) => ({ ...prev, [key]: event.target.value }))
}
placeholder={label}
className="w-full bg-[var(--bg-primary)] border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-primary)] font-mono outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/60"
autoComplete="off"
/>
))}
{setupMsg && (
<p
className={`text-sm font-mono ${
setupMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
}`}
>
{setupMsg.text}
</p>
)}
<button
onClick={() => void saveSetupKeys()}
disabled={setupSaving}
className="w-full py-2 bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[11px] font-mono tracking-widest"
>
{setupSaving ? 'SAVING...' : 'SAVE KEYS LOCALLY'}
</button>
</div>
{API_GUIDES.map((api) => (
<div
key={api.name}
@@ -272,7 +363,8 @@ const OnboardingModal = React.memo(function OnboardingModal({
<div className="space-y-3">
<p className="text-sm text-[var(--text-secondary)] font-mono mb-3">
These data sources are completely free and require no API keys. They activate
automatically on launch.
automatically on launch, while OpenSky and AIS Stream unlock the richer live
aviation and maritime experience.
</p>
<div className="grid grid-cols-2 gap-2">
{FREE_SOURCES.map((src) => (
+109 -25
View File
@@ -120,6 +120,9 @@ interface EnvMeta {
env_path_writable: boolean;
env_example_path: string;
env_example_path_exists: boolean;
operator_keys_env_path?: string;
operator_keys_env_path_exists?: boolean;
operator_keys_env_path_writable?: boolean;
}
const WEIGHT_LABELS: Record<number, string> = {
@@ -493,10 +496,12 @@ const SettingsPanel = React.memo(function SettingsPanel({
}, [adminKey, refreshAdminSession]);
// --- API Keys state ---
// API keys are intentionally NOT editable in-app. The panel is read-only and
// tells the user where the .env file lives so they can edit it directly.
// This keeps secrets off the wire and out of the browser process.
// API keys are write-only in-app. Values are sent once to the local backend,
// stored server-side, and never returned to the browser.
const [apis, setApis] = useState<ApiEntry[]>([]);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeySaving, setApiKeySaving] = useState<string | null>(null);
const [apiKeyMsg, setApiKeyMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['Aviation', 'Maritime']),
);
@@ -535,7 +540,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
const fetchKeys = useCallback(async () => {
try {
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys'));
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys', {
requireAdminSession: false,
}));
return true;
} catch (e) {
await handleProtectedSettingsError(e);
@@ -543,6 +550,40 @@ const SettingsPanel = React.memo(function SettingsPanel({
}
}, [handleProtectedSettingsError]);
const saveApiKey = useCallback(
async (envKey: string | null) => {
if (!envKey) return;
const value = String(apiKeyInputs[envKey] || '').trim();
if (!value) {
setApiKeyMsg({ type: 'err', text: `Enter a value for ${envKey}.` });
return;
}
setApiKeySaving(envKey);
setApiKeyMsg(null);
try {
const result = await controlPlaneJson<{
keys?: ApiEntry[];
env?: EnvMeta;
}>('/api/settings/api-keys', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [envKey]: value }),
requireAdminSession: false,
});
if (result.keys) setApis(result.keys);
if (result.env) setEnvMeta(result.env);
setApiKeyInputs((prev) => ({ ...prev, [envKey]: '' }));
setApiKeyMsg({ type: 'ok', text: `${envKey} saved locally. Restart or refresh feeds to use it.` });
} catch (e) {
const message = e instanceof Error ? e.message : 'Could not save API key';
setApiKeyMsg({ type: 'err', text: message });
} finally {
setApiKeySaving(null);
}
},
[apiKeyInputs],
);
const fetchEnvMeta = useCallback(async () => {
try {
const res = await fetch('/api/settings/api-keys/meta');
@@ -663,10 +704,10 @@ const SettingsPanel = React.memo(function SettingsPanel({
}
void (async () => {
const ready = await refreshAdminSession();
await fetchKeys();
if (ready) {
await Promise.all([fetchKeys(), fetchFeeds()]);
await fetchFeeds();
} else {
setApis([]);
setFeeds([]);
setFeedsDirty(false);
}
@@ -713,12 +754,13 @@ const SettingsPanel = React.memo(function SettingsPanel({
}, [onClose, wormholeEnabled, wormholeSaving, wormholeStatus]);
useEffect(() => {
if (!isOpen || !adminSessionReady) return;
if (!isOpen) return;
if (activeTab === 'api-keys') {
void fetchKeys();
void fetchEnvMeta();
return;
}
if (!adminSessionReady) return;
if (activeTab === 'news-feeds') {
void fetchFeeds();
}
@@ -2166,18 +2208,21 @@ const SettingsPanel = React.memo(function SettingsPanel({
<div className="flex items-start gap-2">
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
API keys are stored locally in the backend{' '}
<span className="text-cyan-400">.env</span> file. Keys marked with{' '}
<Key size={8} className="inline text-yellow-500" /> are required for full
functionality. Public APIs need no key.
API keys are saved locally by this backend. Values are write-only: the app
stores the key and shows CONFIGURED, but it never reads the secret back into
the browser. Keys marked with{' '}
<Key size={8} className="inline text-yellow-500" /> unlock the richest live
aircraft and vessel feeds.
</p>
</div>
{envMeta && (
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
<div>
<span className="text-cyan-500/70">.env path:</span>{' '}
<span className="text-cyan-300 break-all select-all">{envMeta.env_path}</span>{' '}
{envMeta.env_path_exists ? (
<span className="text-cyan-500/70">local key store:</span>{' '}
<span className="text-cyan-300 break-all select-all">
{envMeta.operator_keys_env_path || envMeta.env_path}
</span>{' '}
{envMeta.operator_keys_env_path_exists || envMeta.env_path_exists ? (
<span className="text-green-400/80">[exists]</span>
) : (
<span className="text-amber-400/80">[will be created on first save]</span>
@@ -2199,6 +2244,15 @@ const SettingsPanel = React.memo(function SettingsPanel({
)}
</div>
)}
{apiKeyMsg && (
<div
className={`pl-5 text-sm font-mono ${
apiKeyMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
}`}
>
{apiKeyMsg.text}
</div>
)}
</div>
{/* API List */}
@@ -2288,9 +2342,9 @@ const SettingsPanel = React.memo(function SettingsPanel({
{api.description}
</p>
{api.has_key && (
<div className="mt-2 flex items-center gap-2 text-[12px] font-mono">
<div className="mt-2 space-y-2 text-[12px] font-mono">
{api.is_set ? (
<>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
CONFIGURED
</span>
@@ -2299,23 +2353,53 @@ const SettingsPanel = React.memo(function SettingsPanel({
<span className="text-cyan-300 select-all break-all">
{api.env_key}
</span>{' '}
in the .env file (path shown above) and restart the backend.
Enter a replacement below if you need to rotate it.
</span>
</>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 border border-amber-500/40 bg-amber-950/20 text-amber-300 tracking-wider">
NOT CONFIGURED
</span>
<span className="text-[var(--text-muted)]">
add{' '}
<span className="text-amber-200 select-all break-all">
{api.env_key}=YOUR_VALUE
</span>{' '}
to the .env file (path shown above) and restart the backend.
Save {api.env_key} here to enable this source.
</span>
</>
</div>
)}
<div className="flex items-center gap-2">
<input
type="password"
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
onChange={(event) => {
if (!api.env_key) return;
setApiKeyInputs((prev) => ({
...prev,
[api.env_key as string]: event.target.value,
}));
}}
placeholder={
api.is_set
? 'Enter replacement key...'
: `Enter ${api.env_key}...`
}
className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50"
autoComplete="off"
/>
<button
onClick={() => void saveApiKey(api.env_key)}
disabled={
!api.env_key ||
apiKeySaving === api.env_key ||
!String(
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
).trim()
}
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
>
<Save size={12} />
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
</button>
</div>
</div>
)}
</div>
@@ -0,0 +1,106 @@
'use client';
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Database, Clock, X } from 'lucide-react';
const CURRENT_VERSION = '0.9.7';
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
interface StartupWarmupModalProps {
onClose: () => void;
}
export default function StartupWarmupModal({ onClose }: StartupWarmupModalProps) {
const handleDismiss = () => {
localStorage.setItem(STORAGE_KEY, 'true');
onClose();
};
return (
<AnimatePresence>
<motion.div
key="warmup-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
onClick={handleDismiss}
/>
<motion.div
key="warmup-modal"
initial={{ opacity: 0, scale: 0.92, y: 18 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 18 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
>
<div
className="w-[520px] max-w-[calc(100vw-32px)] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 pointer-events-auto overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-5 border-b border-[var(--border-primary)]/80 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
<Database size={18} className="text-cyan-400" />
</div>
<div>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
STARTUP CACHE
</h2>
<span className="text-[13px] text-[var(--text-muted)] font-mono tracking-widest">
FIRST RUN WARMUP
</span>
</div>
</div>
<button
onClick={handleDismiss}
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
</div>
<div className="p-5 space-y-4">
<div className="bg-cyan-950/20 border border-cyan-500/20 p-4">
<div className="flex items-start gap-3">
<Clock size={15} className="text-cyan-400 mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
MASS DATA SYNTHESIS
</p>
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
The first launch builds local caches for flights, ships, satellites, CCTV, fires,
and threat intelligence. Cached launches paint the map much faster; a brand-new
install can take a few minutes while upstream feeds are synthesized.
</p>
</div>
</div>
</div>
<button
onClick={handleDismiss}
className="w-full py-3 border border-cyan-500/40 text-cyan-300 hover:text-cyan-100 hover:border-cyan-400/70 hover:bg-cyan-950/30 transition-all font-mono text-[12px] tracking-[0.18em] font-bold"
>
CONTINUE
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
export function useStartupWarmupNotice() {
const [showWarmupNotice, setShowWarmupNotice] = useState(false);
useEffect(() => {
try {
setShowWarmupNotice(localStorage.getItem(STORAGE_KEY) !== 'true');
} catch {
setShowWarmupNotice(false);
}
}, []);
return { showWarmupNotice, setShowWarmupNotice };
}
@@ -34,7 +34,7 @@ export function useImperativeSource(
};
const pushWhenReady = () => {
let attemptsRemaining = 20;
let attemptsRemaining = 150;
const tryPush = () => {
if (cancelled) return;
@@ -62,6 +62,7 @@ export function useImperativeSource(
pushWhenReady();
};
rawMap.on('load', handleStyleData);
rawMap.on('styledata', handleStyleData);
// Skip redundant writes for unchanged references, but keep the styledata
@@ -73,6 +74,7 @@ export function useImperativeSource(
return () => {
cancelled = true;
rawMap.off('load', handleStyleData);
rawMap.off('styledata', handleStyleData);
if (timerRef.current) clearTimeout(timerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
+3 -1
View File
@@ -35,6 +35,7 @@ interface AgentAction {
export function useAgentActions(
onShowImage: (coords: { lat: number; lng: number }) => void,
onFlyTo?: (coords: { lat: number; lng: number; zoom?: number }) => void,
enabled = true,
) {
const onShowImageRef = useRef(onShowImage);
onShowImageRef.current = onShowImage;
@@ -70,9 +71,10 @@ export function useAgentActions(
useEffect(() => {
// Poll every 3 seconds — lightweight endpoint, ~50 bytes when empty
if (!enabled) return;
const interval = setInterval(poll, 3000);
// Initial poll on mount
poll();
return () => clearInterval(interval);
}, [poll]);
}, [enabled, poll]);
}
+46 -8
View File
@@ -59,6 +59,8 @@ type FastDataProbe = {
ships?: unknown[];
sigint?: unknown[];
cctv?: unknown[];
news?: unknown[];
threat_level?: unknown;
};
function hasMeaningfulFastData(json: FastDataProbe): boolean {
@@ -100,11 +102,37 @@ export function useDataPolling() {
_slowEtagRef = slowEtag;
let hasData = false;
let fetchedStartupFastPayload = false;
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
const fastAbortRef = { current: null as AbortController | null };
const slowAbortRef = { current: null as AbortController | null };
const fetchCriticalBootstrap = async () => {
try {
const res = await fetch(`${API_BASE}/api/bootstrap/critical`, {
headers: { Accept: 'application/json' },
});
if (res.ok) {
setStoreBackendStatus('connected');
const json = await res.json();
mergeData(json);
if (hasMeaningfulFastData(json) || (json.news?.length || 0) > 0 || json.threat_level) {
hasData = true;
}
}
} catch (e) {
const aborted =
typeof e === 'object' &&
e !== null &&
'name' in e &&
(e as { name?: string }).name === 'AbortError';
if (!aborted) {
console.warn("Critical bootstrap fetch will retry via live polling", e);
}
}
};
const fetchFastData = async () => {
if (fastTimerId) {
clearTimeout(fastTimerId);
@@ -116,9 +144,11 @@ export function useDataPolling() {
const controller = new AbortController();
fastAbortRef.current = controller;
try {
const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current;
const headers: Record<string, string> = {};
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
const res = await fetch(`${API_BASE}/api/live-data/fast`, {
if (!useStartupPayload && fastEtag.current) headers['If-None-Match'] = fastEtag.current;
const url = `${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`;
const res = await fetch(url, {
headers,
signal: controller.signal,
});
@@ -129,7 +159,10 @@ export function useDataPolling() {
}
if (res.ok) {
setStoreBackendStatus('connected');
fastEtag.current = res.headers.get('etag') || null;
// Do not keep the capped startup ETag. The next steady poll should
// request the full fast dataset and replace the representative first paint.
fastEtag.current = useStartupPayload ? null : res.headers.get('etag') || null;
if (useStartupPayload) fetchedStartupFastPayload = true;
const json = await res.json();
mergeData(json);
if (hasMeaningfulFastData(json)) hasData = true;
@@ -141,7 +174,7 @@ export function useDataPolling() {
'name' in e &&
(e as { name?: string }).name === 'AbortError';
if (!aborted) {
console.error("Failed fetching fast live data", e);
console.warn("Fast live data fetch will retry after runtime is reachable", e);
setStoreBackendStatus('disconnected');
}
} finally {
@@ -177,7 +210,7 @@ export function useDataPolling() {
'name' in e &&
(e as { name?: string }).name === 'AbortError';
if (!aborted) {
console.error("Failed fetching slow live data", e);
console.warn("Slow live data fetch will retry after runtime is reachable", e);
}
} finally {
if (slowAbortRef.current === controller) {
@@ -191,7 +224,8 @@ export function useDataPolling() {
const scheduleNext = (tier: 'fast' | 'slow') => {
if (tier === 'fast') {
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
fastTimerId = setTimeout(fetchFastData, delay);
const needsFullFastPayload = fetchedStartupFastPayload && !fastEtag.current;
fastTimerId = setTimeout(fetchFastData, needsFullFastPayload ? 750 : delay);
} else {
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
slowTimerId = setTimeout(fetchSlowData, delay);
@@ -208,8 +242,12 @@ export function useDataPolling() {
};
window.addEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
fetchFastData();
fetchSlowData();
void (async () => {
await fetchCriticalBootstrap();
fetchFastData();
// Let the bootstrap/fast payload paint before competing with the slow tier.
slowTimerId = setTimeout(fetchSlowData, 5000);
})();
return () => {
window.removeEventListener(LAYER_TOGGLE_EVENT, onLayerToggle);
+2 -2
View File
@@ -387,7 +387,7 @@ export async function refreshSnapshotList(): Promise<void> {
const json = await res.json();
updateTimelineFromSnapshots(sortSnapshots(json.snapshots || []));
} catch (e) {
console.error('Time Machine: failed to fetch snapshots', e);
console.warn('Time Machine snapshots will retry after runtime is reachable', e);
}
}
@@ -402,7 +402,7 @@ export async function refreshHourlyIndex(): Promise<void> {
setState({ hourlyIndex: json.hours || {} });
}
} catch (e) {
console.error('Time Machine: failed to fetch hourly index', e);
console.warn('Time Machine hourly index will retry after runtime is reachable', e);
}
}
+4
View File
@@ -635,6 +635,8 @@ export interface NewsArticle {
kalshi_pct: number | null;
consensus_pct: number | null;
match_score: number;
slug?: string;
kalshi_ticker?: string;
} | null;
}
@@ -824,6 +826,8 @@ export interface DashboardData {
cctv_total?: number;
satnogs_total?: number;
tinygs_total?: number;
bootstrap_ready?: boolean;
bootstrap_payload?: boolean;
sigint_totals?: {
total?: number;
meshtastic?: number;
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import hashlib
import re
from pathlib import Path
TRUE_VALUES = {"1", "true", "yes", "on", "allow", "enabled"}
PIN_KEY = "PRIVACY_CORE_ALLOWED_SHA256"
PRIVATE_LANE_KEYS = ("MESH_ARTI_ENABLED", "MESH_RNS_ENABLED")
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _privacy_core_library(root: Path) -> Path | None:
release_dir = root / "privacy-core" / "target" / "release"
candidates = (
release_dir / "privacy_core.dll",
release_dir / "libprivacy_core.so",
release_dir / "libprivacy_core.dylib",
)
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def _parse_env(lines: list[str]) -> dict[str, str]:
values: dict[str, str] = {}
for line in lines:
match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$", line)
if not match:
continue
key, raw_value = match.groups()
values[key] = raw_value.strip().strip('"').strip("'")
return values
def _private_lane_enabled(values: dict[str, str]) -> bool:
for key in PRIVATE_LANE_KEYS:
value = values.get(key, "")
if value.strip().lower() in TRUE_VALUES:
return True
return False
def _replace_or_append_pin(lines: list[str], digest: str) -> tuple[list[str], bool]:
updated: list[str] = []
replaced = False
pattern = re.compile(rf"^(\s*{re.escape(PIN_KEY)}\s*=).*$")
for line in lines:
if pattern.match(line):
updated.append(f"{PIN_KEY}={digest}")
replaced = True
else:
updated.append(line)
if not replaced:
if updated and updated[-1].strip():
updated.append("")
updated.append(f"{PIN_KEY}={digest}")
return updated, replaced
def main() -> int:
root = _repo_root()
env_path = root / "backend" / ".env"
if not env_path.is_file():
print("[*] privacy-core trust pin refresh skipped: backend/.env not found.")
return 0
library_path = _privacy_core_library(root)
if library_path is None:
print("[*] privacy-core trust pin refresh skipped: shared library not found.")
return 0
text = env_path.read_text(encoding="utf-8-sig")
lines = text.splitlines()
values = _parse_env(lines)
has_pin = PIN_KEY in values
if not has_pin and not _private_lane_enabled(values):
print("[*] privacy-core trust pin refresh skipped: private-lane mode is not enabled.")
return 0
digest = hashlib.sha256(library_path.read_bytes()).hexdigest()
if values.get(PIN_KEY, "").strip().lower() == digest:
print("[*] privacy-core trust pin already current.")
return 0
updated, replaced = _replace_or_append_pin(lines, digest)
newline = "\r\n" if "\r\n" in text else "\n"
env_path.write_text(newline.join(updated) + newline, encoding="utf-8")
action = "refreshed" if replaced else "enrolled"
print(f"[*] privacy-core trust pin {action} for local shared library.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+31
View File
@@ -0,0 +1,31 @@
param(
[string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
)
$ErrorActionPreference = "Stop"
$Host.UI.RawUI.WindowTitle = "ShadowBroker Runtime"
Set-Location -LiteralPath $Root
Write-Host "==================================================="
Write-Host " ShadowBroker runtime"
Write-Host " Dashboard: http://localhost:3000"
Write-Host " Close this window or press Ctrl+C to stop."
Write-Host "==================================================="
Write-Host ""
try {
& node "frontend\scripts\dev-all.cjs"
$exitCode = $LASTEXITCODE
} catch {
Write-Host ""
Write-Host "[!] Runtime failed: $($_.Exception.Message)"
$exitCode = 1
}
Write-Host ""
Write-Host "==================================================="
Write-Host " ShadowBroker has stopped. Exit code: $exitCode"
Write-Host "==================================================="
Read-Host "Press Enter to close"
exit $exitCode
+44 -1
View File
@@ -84,6 +84,9 @@ for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8000 "') do (
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":3000 "') do (
taskkill /F /PID %%a >nul 2>&1
)
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8787 "') do (
taskkill /F /PID %%a >nul 2>&1
)
:: Brief pause to let OS release the ports
timeout /t 1 /nobreak >nul
@@ -99,6 +102,11 @@ if %errorlevel% equ 0 (
echo [!] WARNING: Port 3000 is still occupied! Waiting 3s for OS cleanup...
timeout /t 3 /nobreak >nul
)
netstat -ano | findstr ":8787 " | findstr "LISTENING" >nul 2>&1
if %errorlevel% equ 0 (
echo [!] WARNING: Port 8787 is still occupied! Waiting 3s for OS cleanup...
timeout /t 3 /nobreak >nul
)
echo [*] Ports clear.
:: ────────────────────────────────────────────────────────────────────
@@ -225,6 +233,40 @@ if not exist "node_modules\ws" (
call npm ci --omit=dev --silent
)
echo [*] Backend Node.js dependencies OK.
echo.
echo [*] Checking privacy-core shared library...
set "PRIVACY_CORE_DLL=%ROOT%\privacy-core\target\release\privacy_core.dll"
if not exist "%PRIVACY_CORE_DLL%" (
where cargo >nul 2>&1
if errorlevel 1 (
echo [!] WARNING: privacy-core DLL is missing and Rust/Cargo is not installed.
echo [!] Infonet private lanes and gates need this library.
echo [!] Install Rust from https://rustup.rs/ and run:
echo [!] cargo build --release --manifest-path "%ROOT%\privacy-core\Cargo.toml"
echo.
) else (
echo [*] Building privacy-core release DLL...
cd /d "%ROOT%"
cargo build --release --manifest-path "%ROOT%\privacy-core\Cargo.toml"
if errorlevel 1 (
echo [!] ERROR: privacy-core build failed. Infonet private lanes need this DLL.
echo.
pause
exit /b 1
)
cd /d "%ROOT%\backend"
)
)
if exist "%PRIVACY_CORE_DLL%" (
echo [*] privacy-core DLL OK.
"%VENV_PY%" "%ROOT%\scripts\refresh_privacy_core_pin.py"
if errorlevel 1 (
echo [!] WARNING: privacy-core trust pin refresh failed. Startup may fail if backend\.env pins an old hash.
echo.
)
)
cd /d "%ROOT%"
echo.
@@ -257,7 +299,8 @@ echo ===================================================
echo (Press Ctrl+C to stop)
echo.
call npm run dev
start "ShadowBroker Runtime" powershell.exe -NoProfile -ExecutionPolicy Bypass -NoExit -File "%ROOT%\scripts\run-windows-runtime.ps1" -Root "%ROOT%"
exit /b 0
echo.
echo ===================================================
+39 -4
View File
@@ -1,7 +1,13 @@
#!/bin/bash
# Graceful shutdown: kill all child processes on exit/interrupt
trap 'kill 0' EXIT SIGINT SIGTERM
# Graceful shutdown: stop child processes without signaling the parent shell.
cleanup() {
trap - EXIT SIGINT SIGTERM
if command -v pkill >/dev/null 2>&1; then
pkill -P $$ 2>/dev/null || true
fi
}
trap cleanup EXIT SIGINT SIGTERM
echo "======================================================="
echo " S H A D O W B R O K E R - macOS / Linux Start "
@@ -64,7 +70,7 @@ echo ""
echo "[*] Clearing zombie processes..."
# Kill anything listening on ports 8000 or 3000
for PORT in 8000 3000; do
for PORT in 8000 3000 8787; do
if command -v lsof &> /dev/null; then
PIDS=$(lsof -ti :$PORT 2>/dev/null)
elif command -v ss &> /dev/null; then
@@ -82,6 +88,7 @@ done
# Kill orphaned uvicorn and ais_proxy processes
pkill -9 -f "uvicorn.*main:app" 2>/dev/null
pkill -9 -f "ais_proxy" 2>/dev/null
pkill -9 -f "wormhole_server.py" 2>/dev/null
# Brief pause for OS to release ports
sleep 1
@@ -192,6 +199,34 @@ if [ ! -d "node_modules/ws" ]; then
fi
echo "[*] Backend Node.js dependencies OK."
echo ""
echo "[*] Checking privacy-core shared library..."
PRIVACY_CORE_SO="$SCRIPT_DIR/privacy-core/target/release/libprivacy_core.so"
PRIVACY_CORE_DYLIB="$SCRIPT_DIR/privacy-core/target/release/libprivacy_core.dylib"
if [ ! -f "$PRIVACY_CORE_SO" ] && [ ! -f "$PRIVACY_CORE_DYLIB" ]; then
if command -v cargo >/dev/null 2>&1; then
echo "[*] Building privacy-core release library..."
cargo build --release --manifest-path "$SCRIPT_DIR/privacy-core/Cargo.toml"
if [ $? -ne 0 ]; then
echo "[!] ERROR: privacy-core build failed. Infonet private lanes need this library."
exit 1
fi
else
echo "[!] WARNING: privacy-core shared library is missing and Rust/Cargo is not installed."
echo "[!] Infonet private lanes and gates need this library."
echo "[!] Install Rust from https://rustup.rs/ and run:"
echo "[!] cargo build --release --manifest-path \"$SCRIPT_DIR/privacy-core/Cargo.toml\""
echo ""
fi
fi
if [ -f "$PRIVACY_CORE_SO" ] || [ -f "$PRIVACY_CORE_DYLIB" ]; then
echo "[*] privacy-core shared library OK."
"$VENV_PY" "$SCRIPT_DIR/scripts/refresh_privacy_core_pin.py" || {
echo "[!] WARNING: privacy-core trust pin refresh failed. Startup may fail if backend/.env pins an old hash."
echo ""
}
fi
cd "$SCRIPT_DIR"
echo ""
@@ -223,4 +258,4 @@ echo "======================================================="
echo " (Press Ctrl+C to stop)"
echo ""
npm run dev
node scripts/dev-all.cjs