Compare commits

...

4 Commits

Author SHA1 Message Date
BigBodyCobain 71a9d9e144 [security] Close post-#227 control-surface and fetcher gaps
PR #227 hardened most Wormhole/Infonet control surfaces behind
require_local_operator and made the CrowdThreat fetcher opt-in. An
audit of the codebase against that PR's stated goals turned up four
classes of gap that the original change missed:

1. Two operator-only endpoints were left unprotected:
   - POST /api/wormhole/join: calls bootstrap_wormhole_identity() and
     flips the node into Tor mode, exactly the surface #227 hardened
     on /api/wormhole/identity/bootstrap.
   - POST /api/sigint/transmit: relays APRS-IS packets over radio
     using operator-supplied credentials. Anything that reached the
     API could transmit on the operator's authority.

   Both now require_local_operator. test_control_surface_auth.py
   extended with regression coverage for both.

2. Five third-party fetchers were still default-on, phoning home to
   politically/commercially sensitive upstreams on every poll cycle:
   - fimi.py            -> euvsdisinfo.eu        -> FIMI_ENABLED
   - prediction_markets -> Polymarket + Kalshi   -> PREDICTION_MARKETS_ENABLED
   - financial.py       -> Finnhub / yfinance    -> FINANCIAL_ENABLED or FINNHUB_API_KEY
   - nuforc_enrichment  -> huggingface.co        -> NUFORC_ENABLED
   - news.py            -> configured RSS feeds  -> NEWS_ENABLED (default on, kill switch)

   Same CrowdThreat-style pattern: explicit env-var opt-in, empty
   the data slot and mark_fresh when disabled. New regression test
   file test_third_party_fetchers_opt_in.py asserts each fetcher's
   network entry point is not called when its gate is off.

3. The outbound User-Agent leaked both the operator's personal email
   and a fork-specific GitHub URL on every fetcher request. Consolidated
   to a single DEFAULT_USER_AGENT in network_utils.py, project-generic
   by default (no contact info), overridable via SHADOWBROKER_USER_AGENT
   for operators who want to identify themselves (e.g. for Nominatim or
   weather.gov usage-policy compliance). Six call sites updated; the
   Nominatim-specific override is preserved.

4. The same generic UA now also flows through the peer prekey lookup
   in mesh_wormhole_prekey.py, so DM first-contact requests no longer
   identify the caller as a Shadowbroker fork to the peer being
   queried.

.env.example updated to document all new opt-in env vars.

Tests: backend/tests/test_control_surface_auth.py (extended),
       backend/tests/test_crowdthreat_opt_in.py (unchanged, still passes),
       backend/tests/test_third_party_fetchers_opt_in.py (new, 7 tests).
All 31 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:53:33 -06:00
Shadowbroker de27d119f9 Merge pull request #227 from BigBodyCobain/codex/infonet-control-surface-hardening
Harden infonet control surfaces
2026-05-18 12:56:51 -06:00
BigBodyCobain b8384d6d91 Fix secure mail contact hydration race 2026-05-18 12:38:20 -06:00
BigBodyCobain 11ea345518 Harden infonet control surfaces 2026-05-18 11:22:38 -06:00
43 changed files with 2099 additions and 294 deletions
+33 -1
View File
@@ -24,8 +24,40 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# Requires MESH_DEBUG_MODE=true; do not enable this for ordinary use.
# ALLOW_INSECURE_ADMIN=false
# Default outbound User-Agent for all third-party HTTP fetchers.
# Project-generic by default — does NOT include any personal contact info or
# operator-specific identifier. Override only if you run a public relay and
# want upstreams to be able to reach you (e.g. Nominatim/OSM usage policy).
# SHADOWBROKER_USER_AGENT=ShadowBroker-OSINT/0.9 (contact: ops@example.com)
# User-Agent for Nominatim geocoding requests (per OSM usage policy).
# NOMINATIM_USER_AGENT=ShadowBroker/1.0 (https://github.com/BigBodyCobain/Shadowbroker)
# NOMINATIM_USER_AGENT=ShadowBroker/1.0
# ── Third-party fetcher opt-ins ────────────────────────────────
# These data sources phone home to politically/commercially sensitive
# upstreams. Disabled by default; set to "true" only if the operator
# explicitly wants the node's IP to contact these services.
#
# CrowdThreat — backend.crowdthreat.world (paid threat-intel aggregator).
# CROWDTHREAT_ENABLED=false
#
# EUvsDisinfo FIMI — euvsdisinfo.eu (EU disinformation tracker).
# FIMI_ENABLED=false
#
# Polymarket + Kalshi — US political/election prediction markets.
# PREDICTION_MARKETS_ENABLED=false
#
# Finnhub fallback / yfinance — financial market data.
# Set FINNHUB_API_KEY to enable Finnhub, or set FINANCIAL_ENABLED=true to allow
# the unauthenticated yfinance fallback to call Yahoo Finance.
# FINANCIAL_ENABLED=false
#
# NUFORC UAP sightings — huggingface.co dataset download.
# NUFORC_ENABLED=false
#
# News RSS aggregator — defaults ON. Set to "false" to disable all
# configured news feeds (kill switch for the news layer).
# NEWS_ENABLED=true
# LTA Singapore traffic cameras — leave blank to skip this data source.
# LTA_ACCOUNT_KEY=
+36 -14
View File
@@ -3061,6 +3061,17 @@ def _request_private_surface_warmup(*, path: str, method: str, current_tier: str
)
def _is_invite_scoped_prekey_bundle_lookup(request: Request, path: str) -> bool:
if request.method.upper() != "GET" or str(path or "").strip() != "/api/mesh/dm/prekey-bundle":
return False
try:
lookup_token = str(request.query_params.get("lookup_token", "") or "").strip()
agent_id = str(request.query_params.get("agent_id", "") or "").strip()
except Exception:
return False
return bool(lookup_token) and not agent_id
def _resume_private_delivery_background_work(*, current_tier: str, reason: str) -> None:
pending_items = private_delivery_outbox.pending_items()
if not pending_items:
@@ -3191,6 +3202,17 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
# transport has not finished coming up yet.
request.state._private_control_transport_pending = current_tier == "public_degraded"
request.state._private_lane_current_tier = current_tier
elif _is_invite_scoped_prekey_bundle_lookup(request, path):
# A copied DM address carries a high-entropy invite lookup
# handle. Returning the public prekey bundle for that
# handle is the bootstrap step that lets first contact get
# saved; blocking it behind the full private lane creates a
# circular warm-up failure. Stable agent_id lookup still
# follows the normal transport-tier policy.
request.state._invite_prekey_lookup_transport_pending = (
current_tier == "public_degraded"
)
request.state._private_lane_current_tier = current_tier
else:
# Tor-style: instead of failing, keep trying in the
# background and return an ok:True "preparing" response
@@ -3323,7 +3345,7 @@ async def force_refresh(request: Request):
return {"status": "refreshing in background"}
@app.post("/api/ais/feed")
@app.post("/api/ais/feed", dependencies=[Depends(require_local_operator)])
@limiter.limit("60/minute")
async def ais_feed(request: Request):
"""Accept AIS-catcher HTTP JSON feed (POST decoded AIS messages)."""
@@ -3418,7 +3440,7 @@ class LayerUpdate(BaseModel):
layers: dict[str, bool]
@app.post("/api/layers")
@app.post("/api/layers", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def update_layers(update: LayerUpdate, request: Request):
"""Receive frontend layer toggle state. Starts/stops streams accordingly."""
@@ -9812,7 +9834,7 @@ async def api_wormhole_leave(request: Request):
}
@app.get("/api/wormhole/identity")
@app.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_identity(request: Request):
try:
@@ -9825,7 +9847,7 @@ async def api_wormhole_identity(request: Request):
raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc
@app.post("/api/wormhole/identity/bootstrap")
@app.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_wormhole_identity_bootstrap(request: Request):
bootstrap_wormhole_identity()
@@ -10605,7 +10627,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest):
)
@app.post("/api/wormhole/gate/enter")
@app.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -10619,7 +10641,7 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
return result
@app.post("/api/wormhole/gate/leave")
@app.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
return leave_gate(str(body.gate_id or ""))
@@ -10661,7 +10683,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
return result
@app.post("/api/wormhole/gate/persona/create")
@app.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_create(
request: Request, body: WormholeGatePersonaCreateRequest
@@ -10677,7 +10699,7 @@ async def api_wormhole_gate_persona_create(
return result
@app.post("/api/wormhole/gate/persona/activate")
@app.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_activate(
request: Request, body: WormholeGatePersonaActivateRequest
@@ -10693,7 +10715,7 @@ async def api_wormhole_gate_persona_activate(
return result
@app.post("/api/wormhole/gate/persona/clear")
@app.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -10707,7 +10729,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
return result
@app.post("/api/wormhole/gate/persona/retire")
@app.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_retire(
request: Request, body: WormholeGatePersonaActivateRequest
@@ -10788,7 +10810,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate
return composed
@app.post("/api/wormhole/gate/message/sign-encrypted")
@app.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_gate_message_sign_encrypted(
request: Request,
@@ -11000,13 +11022,13 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat
return {"ok": True, "results": results}
@app.post("/api/wormhole/gate/state/export")
@app.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest):
return export_gate_state_snapshot_with_repair(str(body.gate_id or ""))
@app.post("/api/wormhole/gate/proof")
@app.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
proof = _sign_gate_access_proof(str(body.gate_id or ""))
@@ -11553,7 +11575,7 @@ async def api_wormhole_health(request: Request):
return _redact_wormhole_status(full_state, authenticated=ok)
@app.post("/api/wormhole/connect")
@app.post("/api/wormhole/connect", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_wormhole_connect(request: Request):
settings = read_wormhole_settings()
+3 -4
View File
@@ -379,14 +379,13 @@ async def api_refresh_layer_feed(request: Request, layer_id: str):
# Agent Actions endpoint — frontend polls this for UI commands from the agent
# ---------------------------------------------------------------------------
@router.get("/api/ai/agent-actions")
@router.get("/api/ai/agent-actions", dependencies=[Depends(require_local_operator)])
@limiter.limit("120/minute")
async def get_agent_actions(request: Request):
"""Frontend polls for pending agent display actions (destructive read).
No auth required — this only contains display directives (show image,
fly to location), not sensitive data. The agent authenticates when
pushing actions through the command channel.
Local operator access is required because polling destructively drains
the shared operator action queue.
"""
actions = pop_agent_actions()
return {"ok": True, "actions": actions}
+2 -2
View File
@@ -266,7 +266,7 @@ async def force_refresh(request: Request):
return {"status": "refreshing in background"}
@router.post("/api/ais/feed")
@router.post("/api/ais/feed", dependencies=[Depends(require_local_operator)])
@limiter.limit("60/minute")
async def ais_feed(request: Request):
"""Accept AIS-catcher HTTP JSON feed (POST decoded AIS messages)."""
@@ -304,7 +304,7 @@ async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001
return {"status": "ok"}
@router.post("/api/layers")
@router.post("/api/layers", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def update_layers(update: LayerUpdate, request: Request):
"""Receive frontend layer toggle state. Starts/stops streams accordingly."""
+1 -1
View File
@@ -35,7 +35,7 @@ async def thermal_verify(
return result
@router.post("/api/sigint/transmit")
@router.post("/api/sigint/transmit", dependencies=[Depends(require_local_operator)])
@limiter.limit("5/minute")
async def sigint_transmit(request: Request):
"""Send an APRS-IS message to a specific callsign. Requires ham radio credentials."""
+13 -13
View File
@@ -589,7 +589,7 @@ async def api_get_wormhole_status(request: Request):
)
@router.post("/api/wormhole/join")
@router.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_wormhole_join(request: Request):
from services.config import get_settings
@@ -663,7 +663,7 @@ async def api_wormhole_leave(request: Request):
}
@router.get("/api/wormhole/identity")
@router.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)])
@limiter.limit("240/minute")
async def api_wormhole_identity(request: Request):
try:
@@ -674,7 +674,7 @@ async def api_wormhole_identity(request: Request):
raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc
@router.post("/api/wormhole/identity/bootstrap")
@router.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_wormhole_identity_bootstrap(request: Request):
bootstrap_wormhole_identity()
@@ -773,7 +773,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest):
)
@router.post("/api/wormhole/gate/enter")
@router.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -787,7 +787,7 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
return result
@router.post("/api/wormhole/gate/leave")
@router.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
return leave_gate(str(body.gate_id or ""))
@@ -829,7 +829,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
return result
@router.post("/api/wormhole/gate/persona/create")
@router.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_create(
request: Request, body: WormholeGatePersonaCreateRequest
@@ -845,7 +845,7 @@ async def api_wormhole_gate_persona_create(
return result
@router.post("/api/wormhole/gate/persona/activate")
@router.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_activate(
request: Request, body: WormholeGatePersonaActivateRequest
@@ -861,7 +861,7 @@ async def api_wormhole_gate_persona_activate(
return result
@router.post("/api/wormhole/gate/persona/clear")
@router.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -875,7 +875,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
return result
@router.post("/api/wormhole/gate/persona/retire")
@router.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)])
@limiter.limit("20/minute")
async def api_wormhole_gate_persona_retire(
request: Request, body: WormholeGatePersonaActivateRequest
@@ -944,7 +944,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate
return await _m.api_wormhole_gate_message_compose(request, body)
@router.post("/api/wormhole/gate/message/sign-encrypted")
@router.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_gate_message_sign_encrypted(
request: Request,
@@ -1004,14 +1004,14 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat
return await _m.api_wormhole_gate_messages_decrypt(request, body)
@router.post("/api/wormhole/gate/state/export")
@router.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest):
import main as _m
return await _m.api_wormhole_gate_state_export(request, body)
@router.post("/api/wormhole/gate/proof")
@router.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
proof = _sign_gate_access_proof(str(body.gate_id or ""))
@@ -1360,7 +1360,7 @@ async def api_wormhole_health(request: Request):
return _redact_wormhole_status(full_state, authenticated=ok)
@router.post("/api/wormhole/connect")
@router.post("/api/wormhole/connect", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_wormhole_connect(request: Request):
settings = read_wormhole_settings()
+1 -1
View File
@@ -318,7 +318,7 @@ active_layers: dict[str, bool] = {
"uap_sightings": True,
"wastewater": True,
"ai_intel": True,
"crowdthreat": True,
"crowdthreat": False,
"sar": True,
}
@@ -31,11 +31,7 @@ _S3_NS = "{http://s3.amazonaws.com/doc/2006-03-01/}"
_REFRESH_INTERVAL_S = 5 * 24 * 3600
_LIST_TIMEOUT_S = 30
_DOWNLOAD_TIMEOUT_S = 600
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.79 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
from services.network_utils import DEFAULT_USER_AGENT as _USER_AGENT
_lock = threading.RLock()
_aircraft_by_hex: dict[str, dict[str, str]] = {}
+17
View File
@@ -7,6 +7,7 @@ No API key required — the /threats endpoint is unauthenticated.
"""
import logging
import os
from services.network_utils import fetch_with_curl
from services.fetchers._store import latest_data, _data_lock, _mark_fresh, is_any_active
@@ -16,6 +17,16 @@ logger = logging.getLogger("services.data_fetcher")
_CT_BASE = "https://backend.crowdthreat.world"
def crowdthreat_fetch_enabled() -> bool:
"""Return True only when the operator explicitly opts into CrowdThreat pulls."""
return str(os.environ.get("CROWDTHREAT_ENABLED", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
# CrowdThreat category_id → icon ID used on the MapLibre layer
_CATEGORY_ICON = {
1: "ct-security", # Security & Conflict (red)
@@ -43,6 +54,12 @@ _CATEGORY_COLOUR = {
@with_retry(max_retries=2, base_delay=5)
def fetch_crowdthreat():
"""Fetch verified threat reports from CrowdThreat public API."""
if not crowdthreat_fetch_enabled():
logger.debug("CrowdThreat fetch skipped; set CROWDTHREAT_ENABLED=true to opt in")
with _data_lock:
latest_data["crowdthreat"] = []
_mark_fresh("crowdthreat")
return
if not is_any_active("crowdthreat"):
return
@@ -279,9 +279,13 @@ def fetch_weather_alerts():
return
alerts = []
try:
# weather.gov requires a User-Agent per their API policy, but it
# need not identify the operator. Use a project-generic string and
# let the user override via SHADOWBROKER_USER_AGENT if needed.
from services.network_utils import DEFAULT_USER_AGENT
url = "https://api.weather.gov/alerts/active?status=actual"
headers = {
"User-Agent": "(ShadowBroker OSINT Dashboard, github.com/BigBodyCobain/Shadowbroker)",
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "application/geo+json",
}
response = fetch_with_curl(url, timeout=15, headers=headers)
+17
View File
@@ -5,6 +5,7 @@ debunked claims, threat actor mentions, and target country references.
Refreshes every 12 hours (FIMI data updates weekly).
"""
import os
import re
import logging
from datetime import datetime, timezone
@@ -18,6 +19,16 @@ logger = logging.getLogger("services.data_fetcher")
_FIMI_FEED_URL = "https://euvsdisinfo.eu/feed/"
def fimi_fetch_enabled() -> bool:
"""Return True only when the operator explicitly opts into FIMI pulls."""
return str(os.environ.get("FIMI_ENABLED", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
# ── Threat actor keywords ──────────────────────────────────────────────────
# Map of keyword → canonical actor name. Checked case-insensitively.
_THREAT_ACTORS: dict[str, str] = {
@@ -173,6 +184,12 @@ def _is_major_wave(narratives: list[dict], targets: dict[str, int]) -> bool:
@with_retry(max_retries=1, base_delay=5)
def fetch_fimi():
"""Fetch and parse the EUvsDisinfo RSS feed."""
if not fimi_fetch_enabled():
logger.debug("FIMI fetch skipped; set FIMI_ENABLED=true to opt in")
with _data_lock:
latest_data["fimi"] = []
_mark_fresh("fimi")
return
try:
resp = fetch_with_curl(_FIMI_FEED_URL, timeout=15)
feed = feedparser.parse(resp.text)
+28 -1
View File
@@ -82,10 +82,37 @@ def _fetch_yfinance_single(symbol: str, period: str = "2d"):
@with_retry(max_retries=1, base_delay=1)
def financial_fetch_enabled() -> bool:
"""Return True only when the operator explicitly opts into financial pulls.
Either ``FINANCIAL_ENABLED=true`` or the presence of ``FINNHUB_API_KEY``
counts as an explicit opt-in. Without either, the default yfinance path
is disabled to avoid silent outbound calls to finance.yahoo.com.
"""
if os.getenv("FINNHUB_API_KEY", "").strip():
return True
return str(os.environ.get("FINANCIAL_ENABLED", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def fetch_financial_markets():
"""Fetches full market list with smart throttling (3s for Finnhub, 60s for yfinance)."""
global _last_fetch_time, _last_fetch_results, _rotating_index
if not financial_fetch_enabled():
logger.debug(
"Financial fetch skipped; set FINANCIAL_ENABLED=true or supply "
"FINNHUB_API_KEY to opt in"
)
with _data_lock:
latest_data["financial"] = {}
_mark_fresh("financial")
return
finnhub_key = os.getenv("FINNHUB_API_KEY", "").strip()
use_finnhub = bool(finnhub_key)
+2 -1
View File
@@ -182,7 +182,8 @@ def fetch_meshtastic_nodes():
callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip()
except Exception:
callsign = ""
ua_base = "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)"
from services.network_utils import DEFAULT_USER_AGENT
ua_base = f"{DEFAULT_USER_AGENT}; 24h polling"
user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base
try:
+23
View File
@@ -1,4 +1,5 @@
"""News fetching, geocoding, clustering, and risk assessment."""
import os
import re
import time
import logging
@@ -11,6 +12,22 @@ from services.fetchers._store import latest_data, _data_lock, _mark_fresh
from services.fetchers.retry import with_retry
from services.oracle_service import enrich_news_items, compute_global_threat_level, detect_breaking_events
def news_fetch_enabled() -> bool:
"""Return True only when the operator explicitly opts into news RSS pulls.
Defaults to **on** for backward compatibility (this is the only fetcher
where opting out is the new behavior, not the old one). Set
``NEWS_ENABLED=false`` to disable all outbound RSS feed traffic.
"""
return str(os.environ.get("NEWS_ENABLED", "true")).strip().lower() not in {
"0",
"false",
"no",
"off",
"",
}
logger = logging.getLogger("services.data_fetcher")
# Maximum article age in seconds. Anything older than this is dropped
@@ -160,6 +177,12 @@ def _resolve_coords(text: str) -> tuple[float, float] | None:
@with_retry(max_retries=1, base_delay=2)
def fetch_news():
if not news_fetch_enabled():
logger.debug("News fetch skipped; unset NEWS_ENABLED=false to re-enable")
with _data_lock:
latest_data["news"] = []
_mark_fresh("news")
return
from services.news_feed_config import get_feeds
feed_config = get_feeds()
feeds = {f["name"]: f["url"] for f in feed_config}
@@ -49,6 +49,16 @@ _HF_CSV_URL = (
"https://huggingface.co/datasets/kcimc/NUFORC/resolve/main/nuforc_str.csv"
)
def nuforc_fetch_enabled() -> bool:
"""Return True only when the operator explicitly opts into NUFORC pulls."""
return str(os.environ.get("NUFORC_ENABLED", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
# Only keep sightings from the last N years for the enrichment index
_KEEP_YEARS = 5
@@ -160,6 +170,12 @@ def _download_and_build() -> dict | None:
Returns the index dict or None on failure.
"""
if not nuforc_fetch_enabled():
logger.debug(
"NUFORC enrichment skipped; set NUFORC_ENABLED=true to opt in"
)
return None
cutoff = datetime.utcnow() - timedelta(days=_KEEP_YEARS * 365)
cutoff_str = cutoff.strftime("%Y-%m-%d")
@@ -25,6 +25,16 @@ _provider_pace_lock = threading.Lock()
_provider_last_request_at: dict[str, float] = {}
def prediction_markets_fetch_enabled() -> bool:
"""Return True only when the operator explicitly opts into Polymarket/Kalshi pulls."""
return str(os.environ.get("PREDICTION_MARKETS_ENABLED", "")).strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _pace_provider(provider: str, min_interval_s: float) -> None:
if min_interval_s <= 0:
return
@@ -755,6 +765,16 @@ def fetch_prediction_markets():
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
global _prev_probabilities
if not prediction_markets_fetch_enabled():
logger.debug(
"Prediction markets fetch skipped; set "
"PREDICTION_MARKETS_ENABLED=true to opt in"
)
with _data_lock:
latest_data["prediction_markets"] = []
_mark_fresh("prediction_markets")
return
markets = fetch_prediction_markets_raw()
# Compute probability deltas vs previous fetch
+1 -5
View File
@@ -24,11 +24,7 @@ _AIRPORTS_URL = "https://vrs-standing-data.adsb.lol/airports.csv.gz"
_REFRESH_INTERVAL_S = 5 * 24 * 3600
_HTTP_TIMEOUT_S = 60
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.79 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
from services.network_utils import DEFAULT_USER_AGENT as _USER_AGENT
_lock = threading.RLock()
_routes_by_callsign: dict[str, dict[str, Any]] = {}
+19 -2
View File
@@ -1438,6 +1438,7 @@ class Infonet:
# Running counters — avoid O(N) scans in get_info()
self._type_counts: dict[str, int] = {}
self._active_count: int = 0
self._registered_nodes: set[str] = set()
self._chain_bytes: int = 2 # Start with "[]" empty JSON array
self._dirty = False
self._save_lock = threading.Lock()
@@ -1518,6 +1519,7 @@ class Infonet:
self._last_validated_index = 0
self._type_counts = {}
self._active_count = 0
self._registered_nodes = set()
self._chain_bytes = 2
def _rebuild_state(self) -> None:
@@ -1566,10 +1568,15 @@ class Infonet:
now = time.time()
self._type_counts = {}
self._active_count = 0
self._registered_nodes = set()
self._chain_bytes = 2 # "[]"
for evt in self.events:
t = evt.get("event_type", "unknown")
self._type_counts[t] = self._type_counts.get(t, 0) + 1
if t == "node_register":
node_id = str(evt.get("node_id", "") or "")
if node_id:
self._registered_nodes.add(node_id)
is_eph = evt.get("payload", {}).get("ephemeral") or evt.get("payload", {}).get("_ephemeral")
if not is_eph or (now - evt.get("timestamp", 0)) < EPHEMERAL_TTL:
self._active_count += 1
@@ -1579,6 +1586,10 @@ class Infonet:
"""Incrementally update counters when a new event is appended."""
t = evt.get("event_type", "unknown")
self._type_counts[t] = self._type_counts.get(t, 0) + 1
if t == "node_register":
node_id = str(evt.get("node_id", "") or "")
if node_id:
self._registered_nodes.add(node_id)
self._active_count += 1
self._chain_bytes += len(json.dumps(evt)) + 2
@@ -2247,6 +2258,7 @@ class Infonet:
self.event_index[event_id] = len(self.events) - 1
self.head_hash = event_id
self.node_sequences[node_id] = sequence
self._update_counters_for_event(evt)
accepted += 1
expected_prev = event_id
self._replay_filter.add(event_id)
@@ -2552,6 +2564,8 @@ class Infonet:
# Apply fork
self.events = prefix + ordered
self._rebuild_state()
self._rebuild_revocations()
self._rebuild_counters()
self._save()
try:
from services.mesh.mesh_metrics import increment as metrics_inc
@@ -2681,6 +2695,8 @@ class Infonet:
"head_hash_full": self.head_hash,
"chain_lock": self.chain_lock(),
"known_nodes": len(self.node_sequences),
"author_nodes": len(self.node_sequences),
"registered_nodes": len(self._registered_nodes),
"event_types": dict(self._type_counts),
"chain_size_kb": round(self._chain_bytes / 1024, 1),
"unsigned_events": 0,
@@ -2716,8 +2732,9 @@ class Infonet:
if len(new_events) != before:
self.events = new_events
# Rebuild index
self.event_index = {e["event_id"]: i for i, e in enumerate(self.events)}
self._rebuild_state()
self._rebuild_revocations()
self._rebuild_counters()
self._save()
logger.info(f"Infonet cleanup: removed {before - len(new_events)} expired events")
+125 -3
View File
@@ -17,7 +17,7 @@ import time
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
from services.mesh.mesh_crypto import (
build_signature_payload,
@@ -464,6 +464,37 @@ def _bundle_fingerprint(data: dict[str, Any]) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _ensure_dm_dh_material(data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Repair legacy/corrupt DM identities that kept signing keys but lost DH material."""
if str(data.get("dh_pub_key", "") or "").strip() and str(data.get("dh_private_key", "") or "").strip():
return data, False
dh_priv = x25519.X25519PrivateKey.generate()
dh_priv_raw = dh_priv.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
dh_pub_raw = dh_priv.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
repaired = {
**dict(data or {}),
"dh_pub_key": base64.b64encode(dh_pub_raw).decode("ascii"),
"dh_algo": "X25519",
"dh_private_key": base64.b64encode(dh_priv_raw).decode("ascii"),
"last_dh_timestamp": int(time.time()),
"bundle_fingerprint": "",
"bundle_sequence": 0,
"bundle_registered_at": 0,
"prekey_bundle_registered_at": 0,
"prekey_transparency_head": "",
"prekey_transparency_size": 0,
}
return _write_identity(repaired), True
def trust_fingerprint_for_identity_material(
*,
agent_id: str,
@@ -830,10 +861,11 @@ def _sign_dm_invite_payload(
def register_wormhole_dm_key(force: bool = False) -> dict[str, Any]:
data = read_wormhole_identity()
data, repaired_dh = _ensure_dm_dh_material(data)
timestamp = int(time.time())
fingerprint = _bundle_fingerprint(data)
if not force and fingerprint and fingerprint == data.get("bundle_fingerprint"):
if not force and not repaired_dh and fingerprint and fingerprint == data.get("bundle_fingerprint"):
return {
"ok": True,
**_public_view(data),
@@ -1525,11 +1557,101 @@ def import_wormhole_dm_invite(invite: dict[str, Any], *, alias: str = "") -> dic
"detail": "compat dm invite import disabled; ask the sender to re-export a current signed invite",
}
def _prekey_missing_or_pending(detail: str) -> bool:
lower = str(detail or "").strip().lower()
return any(
phrase in lower
for phrase in (
"prekey bundle not found",
"invite prekey bundle not found",
"peer prekey lookup unavailable",
"peer prekey lookup still preparing",
"transport tier insufficient",
"preparing_private_lane",
)
)
def _pin_pending_invite_prekey(detail: str) -> dict[str, Any]:
if invite_version < DM_INVITE_VERSION:
return {"ok": False, "detail": detail or "invite prekey bundle not found"}
invite_root_distribution = _verify_dm_invite_root_distribution(payload)
if not invite_root_distribution.get("ok"):
return invite_root_distribution
attested = _verify_dm_invite_identity_attestation(
envelope=envelope,
payload=payload,
resolved_root_node_id=str(invite_root_distribution.get("root_node_id", "") or ""),
resolved_root_public_key=str(invite_root_distribution.get("root_public_key", "") or ""),
resolved_root_public_key_algo=str(
invite_root_distribution.get("root_public_key_algo", "Ed25519") or "Ed25519"
),
resolved_root_manifest_fingerprint=str(
invite_root_distribution.get("root_manifest_fingerprint", "") or ""
).strip().lower(),
)
if not attested.get("ok"):
return attested
pending_peer_id = str(verified.get("peer_id", "") or "").strip()
trust_fingerprint = str(verified.get("trust_fingerprint", "") or "").strip().lower()
contact = pin_wormhole_dm_invite(
pending_peer_id,
invite_payload={
"trust_fingerprint": trust_fingerprint,
"public_key": "",
"public_key_algo": "Ed25519",
"identity_dh_pub_key": "",
"dh_algo": "X25519",
"prekey_lookup_handle": lookup_handle,
"issued_at": int(payload.get("issued_at", 0) or 0),
"expires_at": int(payload.get("expires_at", 0) or 0),
"label": str(payload.get("label", "") or ""),
"root_node_id": str(attested.get("root_node_id", "") or ""),
"root_public_key": str(attested.get("root_public_key", "") or ""),
"root_public_key_algo": str(attested.get("root_public_key_algo", "Ed25519") or "Ed25519"),
"root_fingerprint": str(attested.get("root_fingerprint", "") or ""),
"root_manifest_fingerprint": str(invite_root_distribution.get("root_manifest_fingerprint", "") or ""),
"root_witness_policy_fingerprint": str(
invite_root_distribution.get("root_witness_policy_fingerprint", "") or ""
),
"root_witness_threshold": _safe_int(
invite_root_distribution.get("root_witness_threshold", 0) or 0,
0,
),
"root_witness_count": _safe_int(invite_root_distribution.get("root_witness_count", 0) or 0, 0),
"root_witness_domain_count": _safe_int(
invite_root_distribution.get("root_witness_domain_count", 0) or 0,
0,
),
"root_manifest_generation": _safe_int(
invite_root_distribution.get("root_manifest_generation", 0) or 0,
0,
),
"root_rotation_proven": bool(invite_root_distribution.get("root_rotation_proven")),
},
alias=resolved_alias,
attested=True,
)
return {
"ok": True,
"peer_id": pending_peer_id,
"invite_peer_id": pending_peer_id,
"trust_fingerprint": trust_fingerprint,
"trust_level": str(contact.get("trust_level", "") or ""),
"detail": "Contact saved.",
"invite_attested": True,
"pending_prekey": True,
"prekey_detail": detail or "invite prekey bundle not found",
"contact": contact,
}
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
fetched = fetch_dm_prekey_bundle(lookup_token=lookup_handle)
if not fetched.get("ok"):
return {"ok": False, "detail": str(fetched.get("detail", "") or "invite prekey bundle not found")}
fetch_detail = str(fetched.get("detail", "") or "invite prekey bundle not found")
if _prekey_missing_or_pending(fetch_detail):
return _pin_pending_invite_prekey(fetch_detail)
return {"ok": False, "detail": fetch_detail}
resolved_peer_id = str(fetched.get("agent_id", "") or "").strip()
if not resolved_peer_id:
@@ -11,6 +11,7 @@ import os
import random
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
@@ -150,6 +151,122 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
def _configured_public_lookup_peer_urls() -> list[str]:
try:
from services.config import get_settings
from services.mesh.mesh_router import active_sync_peer_urls, parse_configured_relay_peers
settings = get_settings()
candidates: list[str] = []
for raw in (
getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""),
getattr(settings, "MESH_DEFAULT_SYNC_PEERS", ""),
):
candidates.extend(parse_configured_relay_peers(str(raw or "")))
candidates.extend(active_sync_peer_urls())
except Exception:
return []
seen: set[str] = set()
peers: list[str] = []
for candidate in candidates:
peer = str(candidate or "").strip().rstrip("/")
if not peer or peer in seen:
continue
seen.add(peer)
peers.append(peer)
return peers
def _normalize_remote_lookup_bundle(payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload or {})
bundle = dict(data.get("bundle") or {})
public_key = str(data.get("public_key", "") or bundle.get("public_key", "") or "").strip()
if not public_key:
return {"ok": False, "detail": "Prekey bundle missing signing key"}
agent_id = str(data.get("agent_id", "") or "").strip() or derive_node_id(public_key)
if not agent_id:
return {"ok": False, "detail": "Prekey bundle public key binding mismatch"}
data["agent_id"] = agent_id
data["public_key"] = public_key
data["public_key_algo"] = str(data.get("public_key_algo", "") or bundle.get("public_key_algo", "Ed25519") or "Ed25519")
data["protocol_version"] = str(data.get("protocol_version", "") or bundle.get("protocol_version", PROTOCOL_VERSION) or PROTOCOL_VERSION)
data["bundle"] = bundle
ok, reason = _validate_bundle_record(data)
if not ok:
return {"ok": False, "detail": reason}
data["ok"] = True
data["lookup_mode"] = "invite_lookup_handle"
data["public_lookup"] = True
return data
def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, Any]:
"""Fetch an invite-scoped prekey bundle from bootstrap/sync peers.
The token is high-entropy and invite-scoped. This path does not expose a
stable agent_id to the peer; if the ordinary peer response omits agent_id,
derive it from the signed identity public key and validate the bundle before
accepting it.
"""
token = str(lookup_token or "").strip()
if not token:
return {"ok": False, "detail": "lookup token required"}
peers = _configured_public_lookup_peer_urls()
if not peers:
return {"ok": False, "detail": "peer prekey lookup unavailable"}
try:
from services.config import get_settings
timeout = max(1, _safe_int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 5) or 5, 5))
except Exception:
timeout = 5
encoded = urllib.parse.urlencode({"lookup_token": token})
last_detail = ""
for peer_url in peers:
normalized_peer_url = str(peer_url or "").strip().rstrip("/")
if not normalized_peer_url:
continue
# Generic UA: any peer-facing crypto request should not carry a
# fork-specific identifier — that turns prekey lookups into a
# software-fingerprinting beacon.
from services.network_utils import DEFAULT_USER_AGENT
request = urllib.request.Request(
f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}",
headers={
"Accept": "application/json",
"User-Agent": DEFAULT_USER_AGENT,
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read(256 * 1024)
payload = json.loads(raw.decode("utf-8"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__)
last_detail = "peer prekey lookup unavailable"
continue
if not isinstance(payload, dict):
last_detail = "invalid peer response"
continue
if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane":
last_detail = "peer prekey lookup still preparing"
continue
if not payload.get("ok"):
last_detail = str(payload.get("detail", "") or last_detail or "Prekey bundle not found")
continue
if not isinstance(payload.get("bundle"), dict):
last_detail = "Prekey bundle not found"
continue
normalized = _normalize_remote_lookup_bundle(payload)
if normalized.get("ok"):
return normalized
last_detail = str(normalized.get("detail", "") or last_detail)
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
@@ -926,6 +1043,11 @@ def fetch_dm_prekey_bundle(
peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup)
if peer_found.get("ok"):
return peer_found
public_found = _fetch_dm_prekey_bundle_from_public_lookup(resolved_lookup)
if public_found.get("ok"):
return public_found
if str(public_found.get("detail", "") or "").strip():
return {"ok": False, "detail": str(public_found.get("detail", "") or "Prekey bundle not found")}
return {"ok": False, "detail": str(peer_found.get("detail", "") or "Prekey bundle not found")}
else:
return {"ok": False, "detail": "Prekey bundle not found"}
+12 -1
View File
@@ -19,6 +19,17 @@ _retry = Retry(total=1, backoff_factor=0.3, status_forcelist=[502, 503, 504])
_session.mount("https://", HTTPAdapter(max_retries=_retry, pool_maxsize=20))
_session.mount("http://", HTTPAdapter(max_retries=_retry, pool_maxsize=10))
# Default outbound User-Agent. Generic by design — does NOT include any
# personal contact info or a fork-specific repo URL. Operators who run a
# public-facing relay and want to identify themselves to upstreams (e.g.
# for Nominatim / weather.gov usage-policy compliance) can override this
# via the SHADOWBROKER_USER_AGENT env var.
DEFAULT_USER_AGENT = os.environ.get(
"SHADOWBROKER_USER_AGENT",
"ShadowBroker-OSINT/0.9",
)
# Find bash for curl fallback — Git bash's curl has the TLS features
# needed to pass CDN fingerprint checks (brotli, zstd, libpsl)
@@ -73,7 +84,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
both Python requests and the barebones Windows system curl.
"""
default_headers = {
"User-Agent": "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)",
"User-Agent": DEFAULT_USER_AGENT,
}
if headers:
default_headers.update(headers)
+57 -1
View File
@@ -5,7 +5,7 @@ from starlette.requests import Request
from starlette.responses import Response
def _request(path: str, method: str = "POST") -> Request:
def _request(path: str, method: str = "POST", query_string: bytes = b"") -> Request:
return Request(
{
"type": "http",
@@ -13,6 +13,7 @@ def _request(path: str, method: str = "POST") -> Request:
"client": ("test", 12345),
"method": method,
"path": path,
"query_string": query_string,
}
)
@@ -504,6 +505,61 @@ def test_private_infonet_gate_write_returns_preparing_state_when_wormhole_not_re
get_settings.cache_clear()
def test_invite_scoped_prekey_lookup_reaches_handler_while_lane_prepares(monkeypatch):
"""Copied-address import must not be blocked by private-lane warmup."""
import main
import auth
from services.config import get_settings
from services import wormhole_supervisor
monkeypatch.setenv("MESH_PRIVATE_CLEARNET_FALLBACK", "block")
monkeypatch.setenv("MESH_BLOCK_LEGACY_NODE_ID_COMPAT", "true")
monkeypatch.setenv("MESH_BLOCK_LEGACY_AGENT_ID_LOOKUP", "true")
monkeypatch.setenv("MESH_ALLOW_COMPAT_DM_INVITE_IMPORT", "false")
get_settings.cache_clear()
monkeypatch.setattr(
auth,
"_anonymous_mode_state",
lambda: {
"enabled": False,
"wormhole_enabled": True,
"ready": False,
"effective_transport": "direct",
},
)
monkeypatch.setattr(
wormhole_supervisor,
"get_wormhole_state",
lambda: {
"configured": True,
"ready": False,
"rns_ready": False,
"arti_ready": False,
},
)
called = {"value": False}
async def call_next(_request: Request) -> Response:
called["value"] = True
return Response(status_code=200)
response = asyncio.run(
main.enforce_high_privacy_mesh(
_request(
"/api/mesh/dm/prekey-bundle",
method="GET",
query_string=b"lookup_token=invite-handle",
),
call_next,
)
)
assert response.status_code == 200
assert called["value"] is True
get_settings.cache_clear()
def test_private_dm_send_blocks_at_transitional_tier(monkeypatch):
import main
import auth
+14 -3
View File
@@ -47,6 +47,11 @@ def test_infonet_ingest_accepts_valid_event(tmp_path, monkeypatch):
assert result["accepted"] == 1
assert inf.head_hash == evt.event_id
info = inf.get_info()
assert info["known_nodes"] == 1
assert info["author_nodes"] == 1
assert info["total_events"] == 1
assert info["event_types"]["message"] == 1
def test_verify_node_binding_accepts_current_and_compat_ids_only(monkeypatch):
@@ -64,6 +69,8 @@ def test_verify_node_binding_accepts_current_and_compat_ids_only(monkeypatch):
f"{current[len(mesh_crypto.NODE_ID_PREFIX):len(mesh_crypto.NODE_ID_PREFIX) + 8]}"
)
monkeypatch.setenv("MESH_DEV_ALLOW_LEGACY_COMPAT", "true")
monkeypatch.setenv("MESH_BLOCK_LEGACY_NODE_ID_COMPAT", "false")
monkeypatch.setenv("MESH_ALLOW_LEGACY_NODE_ID_COMPAT_UNTIL", "2099-01-01")
from services.config import get_settings
@@ -98,7 +105,7 @@ def test_infonet_append_rejects_missing_signature_fields(tmp_path, monkeypatch):
assert "signature" in str(exc).lower()
def test_infonet_load_fails_closed_on_hash_mismatch(tmp_path, monkeypatch):
def test_infonet_load_quarantines_and_resets_on_hash_mismatch(tmp_path, monkeypatch):
monkeypatch.setattr(mesh_hashchain, "DATA_DIR", tmp_path)
monkeypatch.setattr(mesh_hashchain, "CHAIN_FILE", tmp_path / "infonet.json")
@@ -135,8 +142,12 @@ def test_infonet_load_fails_closed_on_hash_mismatch(tmp_path, monkeypatch):
encoding="utf-8",
)
with pytest.raises(ValueError, match="Hash mismatch on event load"):
mesh_hashchain.Infonet()
inf = mesh_hashchain.Infonet()
assert inf.events == []
assert inf.head_hash == mesh_hashchain.GENESIS_HASH
assert not mesh_hashchain.CHAIN_FILE.exists()
assert list(tmp_path.glob("infonet.json.quarantine.*"))
def test_validate_gate_message_payload_rejects_plaintext_shape():
@@ -12,6 +12,7 @@ Tests verify:
"""
import hashlib
import json
import time
from services.config import get_settings
@@ -611,6 +612,99 @@ class TestFetchPrekeyBundleByLookup:
"peer prekey lookup unavailable",
}
def test_fetch_lookup_token_uses_bootstrap_peer_without_agent_id(self, tmp_path, monkeypatch):
"""Invite lookup can resolve through bootstrap peers without exposing agent_id."""
_isolated_relay(tmp_path, monkeypatch)
record = _valid_bundle_record("test-agent")
requested_urls: list[str] = []
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example")
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
monkeypatch.setenv("MESH_RELAY_PEERS", "")
get_settings.cache_clear()
class _Response:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _limit: int = -1):
return json.dumps(
{
"ok": True,
"identity_dh_pub_key": record["dh_pub_key"],
"dh_algo": record["dh_algo"],
"public_key": record["public_key"],
"public_key_algo": record["public_key_algo"],
"protocol_version": record["protocol_version"],
"sequence": 1,
"signed_at": int(record["bundle"].get("signed_at", 0) or 0),
"bundle": record["bundle"],
}
).encode("utf-8")
def _urlopen(request, timeout=0):
requested_urls.append(str(getattr(request, "full_url", "")))
return _Response()
monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen)
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
result = fetch_dm_prekey_bundle(agent_id="", lookup_token="bootstrap-handle")
assert result["ok"] is True
assert result["agent_id"] == record["agent_id"]
assert result["lookup_mode"] == "invite_lookup_handle"
assert result["public_lookup"] is True
assert requested_urls
assert "lookup_token=bootstrap-handle" in requested_urls[0]
assert "agent_id" not in requested_urls[0]
def test_fetch_lookup_token_does_not_parse_peer_pending_as_bundle(self, tmp_path, monkeypatch):
"""A peer's private-lane pending response is not a malformed prekey bundle."""
_isolated_relay(tmp_path, monkeypatch)
requested_urls: list[str] = []
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example")
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
monkeypatch.setenv("MESH_RELAY_PEERS", "")
get_settings.cache_clear()
class _Response:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _limit: int = -1):
return json.dumps(
{
"ok": True,
"pending": True,
"status": "preparing_private_lane",
"detail": "transport tier insufficient",
}
).encode("utf-8")
def _urlopen(request, timeout=0):
requested_urls.append(str(getattr(request, "full_url", "")))
return _Response()
monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen)
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
result = fetch_dm_prekey_bundle(agent_id="", lookup_token="bootstrap-handle")
assert requested_urls
assert result["ok"] is False
assert result["detail"] == "peer prekey lookup still preparing"
assert result["detail"] != "Prekey bundle missing signing key"
def test_fetch_agent_id_uses_pinned_contact_lookup_handle(self, tmp_path, monkeypatch):
"""Pinned invite lookup handle is used before direct agent_id lookup."""
relay = _isolated_relay(tmp_path, monkeypatch)
@@ -71,6 +71,38 @@ def _fresh_wormhole_state(tmp_path, monkeypatch):
return relay, mesh_wormhole_identity, mesh_wormhole_contacts, mesh_wormhole_prekey
def test_register_wormhole_dm_key_repairs_missing_local_dh_material(tmp_path, monkeypatch):
relay, identity_mod, _contacts_mod, _prekey_mod = _fresh_wormhole_state(tmp_path, monkeypatch)
identity = identity_mod.read_wormhole_identity()
original_node_id = identity["node_id"]
original_public_key = identity["public_key"]
original_private_key = identity["private_key"]
identity_mod.write_dm_identity(
{
**identity,
"dh_pub_key": "",
"dh_private_key": "",
"bundle_fingerprint": "",
"bundle_sequence": 0,
"bundle_registered_at": 0,
}
)
registered = identity_mod.register_wormhole_dm_key()
repaired = identity_mod.read_wormhole_identity()
assert registered["ok"] is True
assert registered["dh_pub_key"]
assert registered["dh_algo"] == "X25519"
assert repaired["dh_pub_key"] == registered["dh_pub_key"]
assert repaired["dh_private_key"]
assert repaired["node_id"] == original_node_id
assert repaired["public_key"] == original_public_key
assert repaired["private_key"] == original_private_key
assert relay.get_dh_key(original_node_id)["dh_pub_key"] == registered["dh_pub_key"]
def _export_verified_invite(identity_mod):
exported = identity_mod.export_wormhole_dm_invite()
assert exported["ok"] is True
@@ -460,6 +492,30 @@ def test_imported_dm_invite_pins_contact_as_invite_pinned(tmp_path, monkeypatch)
assert contacts_mod.list_wormhole_dm_contacts()[imported["peer_id"]]["trust_level"] == "invite_pinned"
def test_imported_dm_invite_saves_pending_contact_when_prekey_not_visible(tmp_path, monkeypatch):
_relay, identity_mod, contacts_mod, prekey_mod = _fresh_wormhole_state(tmp_path, monkeypatch)
exported, verified = _export_verified_invite(identity_mod)
monkeypatch.setattr(
prekey_mod,
"fetch_dm_prekey_bundle",
lambda **_kw: {"ok": False, "detail": "Prekey bundle not found"},
)
imported = identity_mod.import_wormhole_dm_invite(exported["invite"], alias="alice")
contact = imported["contact"]
assert imported["ok"] is True
assert imported["pending_prekey"] is True
assert imported["peer_id"] == verified["peer_id"]
assert contact["alias"] == "alice"
assert contact["trust_level"] == "invite_pinned"
assert contact["invitePinnedPrekeyLookupHandle"] == exported["invite"]["payload"]["prekey_lookup_handle"]
assert contact["remotePrekeyLookupMode"] == "invite_lookup_handle"
assert contact["remotePrekeyFingerprint"] == verified["trust_fingerprint"]
assert contact["dhPubKey"] == ""
assert contacts_mod.list_wormhole_dm_contacts()[verified["peer_id"]]["trust_level"] == "invite_pinned"
def test_imported_dm_invite_requires_root_attested_prekey_bundle(tmp_path, monkeypatch):
relay, identity_mod, _contacts_mod, _prekey_mod = _fresh_wormhole_state(tmp_path, monkeypatch)
@@ -0,0 +1,87 @@
"""Regression coverage for operator-only control surfaces."""
import pytest
@pytest.mark.parametrize(
("method", "path", "payload"),
[
("get", "/api/wormhole/identity", None),
("post", "/api/wormhole/identity/bootstrap", {}),
("post", "/api/wormhole/gate/enter", {"gate_id": "general-talk"}),
("post", "/api/wormhole/gate/leave", {"gate_id": "general-talk"}),
("post", "/api/wormhole/sign", {"event_type": "gate_event", "payload": {"ok": True}}),
("post", "/api/wormhole/gate/key/rotate", {"gate_id": "general-talk", "reason": "test"}),
(
"post",
"/api/wormhole/gate/key/grant",
{
"gate_id": "general-talk",
"recipient_node_id": "node-test",
"recipient_dh_pub": "dh-test",
},
),
("post", "/api/wormhole/gate/persona/create", {"gate_id": "general-talk", "label": "test"}),
(
"post",
"/api/wormhole/gate/persona/activate",
{"gate_id": "general-talk", "persona_id": "persona-test"},
),
("post", "/api/wormhole/gate/persona/clear", {"gate_id": "general-talk"}),
(
"post",
"/api/wormhole/gate/persona/retire",
{"gate_id": "general-talk", "persona_id": "persona-test"},
),
(
"post",
"/api/wormhole/gate/message/sign-encrypted",
{
"gate_id": "general-talk",
"epoch": 1,
"ciphertext": "ciphertext",
"nonce": "nonce",
"format": "mls1",
"envelope_hash": "hash",
},
),
("post", "/api/wormhole/gate/message/compose", {"gate_id": "general-talk", "plaintext": "hello"}),
("post", "/api/wormhole/sign-raw", {"message": "raw"}),
("post", "/api/wormhole/gate/state/export", {"gate_id": "general-talk"}),
("post", "/api/wormhole/gate/proof", {"gate_id": "general-talk"}),
("post", "/api/wormhole/connect", {}),
("post", "/api/layers", {"layers": {"viirs_nightlights": True}}),
("post", "/api/ais/feed", {"msgs": []}),
# Added in post-#227 gap audit:
# /api/wormhole/join also calls bootstrap_wormhole_identity() — same
# identity-takeover surface as /identity/bootstrap. PR #227 hardened
# the latter but missed the former.
("post", "/api/wormhole/join", {}),
# /api/sigint/transmit relays APRS-IS packets over radio using
# operator-supplied credentials. Any caller who reaches this endpoint
# could transmit on the operator's authority. Must be local-only.
(
"post",
"/api/sigint/transmit",
{
"callsign": "N0CALL",
"passcode": "12345",
"target": "NOCALL",
"message": "test",
},
),
],
)
def test_remote_control_surface_rejects_without_local_operator_or_admin(
remote_client, method, path, payload
):
request = getattr(remote_client, method)
response = request(path, json=payload) if payload is not None else request(path)
assert response.status_code == 403
def test_remote_agent_actions_poll_rejects_without_local_operator_or_admin(remote_client):
response = remote_client.get("/api/ai/agent-actions")
assert response.status_code == 403
+52
View File
@@ -0,0 +1,52 @@
"""CrowdThreat ingestion is operator opt-in only."""
class _CrowdThreatResponse:
status_code = 200
def json(self):
return {
"data": {
"threats": [
{
"id": "ct-1",
"title": "Example report",
"location": {
"lng_lat": [12.5, 41.9],
"name": "Example place",
"country": {"name": "Italy"},
},
"category": {"id": 1, "name": "Security"},
}
]
}
}
def test_crowdthreat_disabled_by_default_does_not_call_upstream(monkeypatch):
from services.fetchers import _store, crowdthreat
monkeypatch.delenv("CROWDTHREAT_ENABLED", raising=False)
monkeypatch.setitem(_store.latest_data, "crowdthreat", [{"id": "old"}])
monkeypatch.setattr(
crowdthreat,
"fetch_with_curl",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("upstream called")),
)
crowdthreat.fetch_crowdthreat()
assert _store.latest_data["crowdthreat"] == []
def test_crowdthreat_opt_in_fetches_when_layer_is_enabled(monkeypatch):
from services.fetchers import _store, crowdthreat
monkeypatch.setenv("CROWDTHREAT_ENABLED", "true")
monkeypatch.setitem(_store.active_layers, "crowdthreat", True)
monkeypatch.setattr(crowdthreat, "fetch_with_curl", lambda *args, **kwargs: _CrowdThreatResponse())
crowdthreat.fetch_crowdthreat()
assert _store.latest_data["crowdthreat"][0]["id"] == "ct-1"
assert _store.latest_data["crowdthreat"][0]["source"] == "CrowdThreat"
@@ -0,0 +1,106 @@
"""Third-party fetchers that phone home to politically/commercially
sensitive upstreams must be operator opt-in only.
Companion to ``test_crowdthreat_opt_in.py`` extends the same default-off
posture to:
* EUvsDisinfo FIMI (``FIMI_ENABLED``)
* Polymarket + Kalshi (``PREDICTION_MARKETS_ENABLED``)
* Finnhub / yfinance financial data (``FINANCIAL_ENABLED`` /
``FINNHUB_API_KEY``)
* NUFORC HuggingFace dataset (``NUFORC_ENABLED``)
Each test asserts that with the env var unset (or set to a falsy value)
the fetcher's network entry point is NOT called.
"""
def _explode(*_args, **_kwargs):
raise AssertionError("upstream called while fetcher was meant to be disabled")
def test_fimi_disabled_by_default_does_not_call_upstream(monkeypatch):
from services.fetchers import _store, fimi
monkeypatch.delenv("FIMI_ENABLED", raising=False)
monkeypatch.setitem(_store.latest_data, "fimi", [{"id": "old"}])
monkeypatch.setattr(fimi, "fetch_with_curl", _explode)
fimi.fetch_fimi()
assert _store.latest_data["fimi"] == []
def test_fimi_falsy_value_does_not_call_upstream(monkeypatch):
from services.fetchers import _store, fimi
monkeypatch.setenv("FIMI_ENABLED", "false")
monkeypatch.setitem(_store.latest_data, "fimi", [{"id": "old"}])
monkeypatch.setattr(fimi, "fetch_with_curl", _explode)
fimi.fetch_fimi()
assert _store.latest_data["fimi"] == []
def test_prediction_markets_disabled_by_default(monkeypatch):
from services.fetchers import _store, prediction_markets
monkeypatch.delenv("PREDICTION_MARKETS_ENABLED", raising=False)
monkeypatch.setitem(_store.latest_data, "prediction_markets", [{"id": "old"}])
monkeypatch.setattr(
prediction_markets, "fetch_prediction_markets_raw", _explode
)
prediction_markets.fetch_prediction_markets()
assert _store.latest_data["prediction_markets"] == []
def test_financial_disabled_when_no_optin_or_api_key(monkeypatch):
"""yfinance fallback path must not run silently — needs FINANCIAL_ENABLED."""
from services.fetchers import _store, financial
monkeypatch.delenv("FINANCIAL_ENABLED", raising=False)
monkeypatch.delenv("FINNHUB_API_KEY", raising=False)
monkeypatch.setitem(_store.latest_data, "financial", {"BTC": {"price": 1}})
monkeypatch.setattr(financial, "_fetch_finnhub_quote", _explode)
monkeypatch.setattr(financial, "_fetch_yfinance_single", _explode)
financial.fetch_financial_markets()
assert _store.latest_data["financial"] == {}
def test_financial_enabled_via_finnhub_api_key(monkeypatch):
"""Presence of FINNHUB_API_KEY counts as explicit opt-in."""
from services.fetchers import financial
monkeypatch.delenv("FINANCIAL_ENABLED", raising=False)
monkeypatch.setenv("FINNHUB_API_KEY", "test-key")
assert financial.financial_fetch_enabled() is True
def test_nuforc_disabled_by_default_skips_download(monkeypatch):
from services.fetchers import nuforc_enrichment
monkeypatch.delenv("NUFORC_ENABLED", raising=False)
monkeypatch.setattr(nuforc_enrichment, "fetch_with_curl", _explode)
result = nuforc_enrichment._download_and_build()
assert result is None
def test_news_default_on_but_killable(monkeypatch):
"""News defaults on (kill switch only), but NEWS_ENABLED=false must disable it."""
from services.fetchers import _store, news
monkeypatch.setenv("NEWS_ENABLED", "false")
monkeypatch.setitem(_store.latest_data, "news", [{"id": "old"}])
monkeypatch.setattr(news, "fetch_with_curl", _explode)
news.fetch_news()
assert _store.latest_data["news"] == []
+3 -1
View File
@@ -62,11 +62,13 @@ services:
image: ghcr.io/bigbodycobain/shadowbroker-frontend:latest
container_name: shadowbroker-frontend
ports:
- "${BIND:-127.0.0.1}:3000:3000"
- "${BIND:-127.0.0.1}:${FRONTEND_PORT:-3000}:3000"
environment:
# Points the Next.js server-side proxy at the backend container via Docker networking.
# Change this if your backend runs on a different host or port.
- BACKEND_URL=http://backend:8000
# Lets the server-side proxy authenticate protected local-node API calls.
- ADMIN_KEY=${ADMIN_KEY:-}
depends_on:
backend:
condition: service_healthy
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
buildMailboxClaims: vi.fn(async () => []),
countDmMailboxes: vi.fn(async () => ({ ok: true, count: 0 })),
ensureRegisteredDmKey: vi.fn(async () => ({ dhPubKey: 'local-dh', dhAlgo: 'X25519' })),
fetchDmPublicKey: vi.fn(async () => ({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' })),
fetchDmPublicKey: vi.fn(async () => ({ agent_id: '!sb_peer', dh_pub_key: 'peer-dh', dh_algo: 'X25519' })),
pollDmMailboxes: vi.fn(async () => ({ ok: true, messages: [] })),
sendDmMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
sendOffLedgerConsentMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
@@ -252,7 +252,7 @@ describe('MessagesView first-contact trust UX', () => {
mocks.pollDmMailboxes.mockResolvedValue({ ok: true, messages: [] });
mocks.countDmMailboxes.mockResolvedValue({ ok: true, count: 0 });
mocks.ensureRegisteredDmKey.mockResolvedValue({ dhPubKey: 'local-dh', dhAlgo: 'X25519' });
mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
mocks.fetchDmPublicKey.mockResolvedValue({ agent_id: '!sb_peer', dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' });
mocks.canUseWormholeBootstrap.mockResolvedValue(false);
mocks.exportWormholeDmInvite.mockResolvedValue({
@@ -334,8 +334,9 @@ describe('MessagesView first-contact trust UX', () => {
await openComposeForRecipient('!sb_invited', 'hello to pinned peer');
expect(screen.queryByText('Unverified First Contact')).not.toBeInTheDocument();
expect(await screen.findByText('ROOT LOCAL QUORUM')).toBeInTheDocument();
expect(await screen.findByText(/Local quorum root rootabcd\.\.123456/i)).toBeInTheDocument();
expect(screen.queryByText('ROOT LOCAL QUORUM')).not.toBeInTheDocument();
expect(screen.queryByText(/Local quorum root rootabcd\.\.123456/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Fingerprint/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled();
});
@@ -375,7 +376,34 @@ describe('MessagesView first-contact trust UX', () => {
expect(screen.queryByText(/still warming up/i)).not.toBeInTheDocument();
}, 10000);
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
it('repairs the local sending key before sending instead of surfacing backend key jargon', async () => {
contactsState = {
'!sb_pinned': {
alias: 'Pinned Peer',
blocked: false,
trust_level: 'invite_pinned',
dhPubKey: 'peer-dh',
remotePrekeyFingerprint: 'abcdef123456',
},
};
mocks.ensureRegisteredDmKey
.mockResolvedValueOnce({ ok: true, dhPubKey: '', dhAlgo: 'X25519', detail: 'Missing DH public key' })
.mockResolvedValueOnce({ ok: true, dhPubKey: 'local-dh-repaired', dhAlgo: 'X25519' });
mocks.sendDmMessage.mockResolvedValueOnce({ ok: true, transport: 'relay' });
renderMessagesView();
await openComposeForRecipient('!sb_pinned', 'hello after repair');
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
await waitFor(() => expect(mocks.ensureRegisteredDmKey).toHaveBeenCalledTimes(2));
await waitFor(() => expect(mocks.sendDmMessage).toHaveBeenCalled());
expect(await screen.findByText(/Mail delivered to Pinned Peer/i)).toBeInTheDocument();
expect(screen.queryByText(/Local DM key is unavailable/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Missing DH public key/i)).not.toBeInTheDocument();
});
it('shows saved contacts without witness-policy implementation detail', async () => {
contactsState = {
'!sb_policy': {
alias: 'Policy Peer',
@@ -404,10 +432,39 @@ describe('MessagesView first-contact trust UX', () => {
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText(/Witness-policy root rootpoli\.\.123456/i)).toBeInTheDocument();
expect(await screen.findByText('Saved Contact')).toBeInTheDocument();
expect(screen.queryByText(/Witness-policy root rootpoli\.\.123456/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Witnessed root rootpoli\.\.123456/i)).not.toBeInTheDocument();
});
it('hydrates Wormhole contacts on first load even when a local browser identity exists', async () => {
let wormholeIdentityResolved = false;
contactsState = {
'!sb_saved': {
alias: 'Saved Person',
blocked: false,
trust_level: 'invite_pinned',
invitePinnedPrekeyLookupHandle: 'handle-saved',
invitePinnedTrustFingerprint: 'savedfingerprint123456',
},
};
mocks.isWormholeSecureRequired.mockResolvedValue(true);
mocks.fetchWormholeIdentity.mockImplementation(async () => {
wormholeIdentityResolved = true;
return { node_id: '!sb_local', public_key: 'local-pub' };
});
mocks.hydrateWormholeContacts.mockImplementation(async () =>
wormholeIdentityResolved ? contactsState : {},
);
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Saved Person')).toBeInTheDocument();
expect(screen.queryByText(/No approved secure contacts yet/i)).not.toBeInTheDocument();
expect(mocks.fetchWormholeIdentity).toHaveBeenCalled();
});
it('shows an import-invite shortcut for unpinned contacts in the contact list', async () => {
contactsState = {
'!sb_unpinned': {
@@ -426,7 +483,7 @@ describe('MessagesView first-contact trust UX', () => {
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned');
});
it('surfaces pending contact requests in the contact list with approve and deny actions', async () => {
it('surfaces pending contact requests in a top-level requests tab with approve and deny actions', async () => {
localStorage.setItem(
'sb_infonet_mailbox_v1:!sb_local',
JSON.stringify({
@@ -464,7 +521,7 @@ describe('MessagesView first-contact trust UX', () => {
});
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
fireEvent.click(await screen.findByRole('button', { name: /REQUESTS/i }));
expect(await screen.findByText('Contact Requests')).toBeInTheDocument();
expect(await screen.findByText('1 pending')).toBeInTheDocument();
@@ -576,13 +633,13 @@ describe('MessagesView first-contact trust UX', () => {
expect(
await screen.findByText(
/Import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled\./i,
/This contact needs their full contact address once before messages can be sent/i,
),
).toBeInTheDocument();
expect(mocks.fetchDmPublicKey).not.toHaveBeenCalled();
});
it('announces attested invite imports as INVITE PINNED', async () => {
it('announces attested invite imports as a saved contact', async () => {
mocks.importWormholeDmInvite.mockResolvedValueOnce({
ok: true,
peer_id: '!sb_attested',
@@ -595,32 +652,34 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
});
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect(
await screen.findByText(/INVITE PINNED for !sb_attested \(invitefp\.\.tested\)\./i),
).toBeInTheDocument();
expect(await screen.findByText(/Contact saved: !sb_attested\./i)).toBeInTheDocument();
expect(await screen.findByText('Saved Contact')).toBeInTheDocument();
expect(screen.queryByText(/INVITE PINNED for/i)).not.toBeInTheDocument();
});
it('generates and copies the full signed public address instead of the lookup handle', async () => {
it('automatically creates a share address and keeps copy actions simple', async () => {
renderMessagesView();
fireEvent.click(await screen.findByRole('button', { name: 'Generate Address' }));
await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalled());
const copied = String(mocks.writeClipboard.mock.calls[0][0] || '');
expect(copied).toContain('"type": "shadowbroker.infonet.dm.invite"');
expect(copied).toContain('"prekey_lookup_handle": "handle-123"');
expect(copied).not.toBe('handle-123');
expect(await screen.findByText(/Generated and copied/i)).toBeInTheDocument();
expect(await screen.findByText(/Contact address ready/i)).toBeInTheDocument();
expect(await screen.findByText('handle-123')).toBeInTheDocument();
expect(screen.getByText(/Signed invite ready/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Copy Short Address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Copy Full Address/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Copy Short Address/i }));
await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalledWith('handle-123'));
const copied = String(mocks.writeClipboard.mock.calls.at(-1)?.[0] || '');
expect(copied).toBe('handle-123');
expect(screen.queryByText(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument();
});
it('does not advertise legacy handle-only addresses as copyable public addresses', async () => {
it('does not advertise legacy handle-only addresses as copyable contact addresses', async () => {
localStorage.setItem(
'sb_infonet_dm_addresses_v1:!sb_local',
JSON.stringify({
@@ -641,25 +700,33 @@ describe('MessagesView first-contact trust UX', () => {
renderMessagesView();
expect(await screen.findByText(/Generate an address, then send it to someone/i)).toBeInTheDocument();
expect(await screen.findByText(/Contact address ready/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Legacy handle')).toBeInTheDocument();
expect(screen.getByText('Address unavailable locally.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy' })).toBeDisabled();
expect(screen.getAllByRole('button', { name: 'Copy Short' }).some((button) => !button.hasAttribute('disabled'))).toBe(true);
expect(screen.getAllByRole('button', { name: 'Copy Full' }).some((button) => button.hasAttribute('disabled'))).toBe(true);
});
it('explains raw lookup handles instead of showing a JSON parser error', async () => {
it('sends a contact request from a short address instead of requiring JSON', async () => {
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
target: { value: 'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67' },
});
fireEvent.click(screen.getByRole('button', { name: 'Send Request' }));
expect(await screen.findByText(/only a short address ID/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Import Address' })).toBeDisabled();
await waitFor(() => expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled());
expect(await screen.findByText(/Contact request sent to/i)).toBeInTheDocument();
expect(mocks.fetchDmPublicKey).toHaveBeenCalledWith(
'http://localhost:8000',
'',
'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67',
);
expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled();
expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument();
expect(mocks.importWormholeDmInvite).not.toHaveBeenCalled();
});
@@ -675,7 +742,7 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
const addressField = screen.getByPlaceholderText(/Paste the full text copied/i);
const addressField = screen.getByPlaceholderText(/Paste a short address/i);
fireEvent.paste(addressField, {
clipboardData: {
getData: () => signedAddress,
@@ -687,7 +754,7 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'Advanced Details' }));
expect(screen.getByLabelText('Raw copied public address')).toHaveValue(signedAddress);
expect(screen.getByLabelText('Raw copied contact address')).toHaveValue(signedAddress);
});
it('imports a copied address without waiting for secure mail warm-up', async () => {
@@ -710,17 +777,113 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
});
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect(await screen.findByText(/INVITE PINNED for !sb_now \(invitefp-now\)\./i)).toBeInTheDocument();
expect(await screen.findByText(/Contact saved: !sb_now\./i)).toBeInTheDocument();
expect(mocks.importWormholeDmInvite).toHaveBeenCalled();
expect(screen.queryByText(/Secure mail is still warming up/i)).not.toBeInTheDocument();
});
it('announces compat invite imports as TOFU PINNED with backend detail', async () => {
it('saves pending-delivery contacts without showing prekey jargon', async () => {
mocks.importWormholeDmInvite.mockResolvedValueOnce({
ok: true,
peer_id: '!sb_pending',
trust_fingerprint: 'invitefp-pending',
trust_level: 'invite_pinned',
pending_prekey: true,
detail: 'Contact saved.',
contact: {
alias: 'Pending Person',
blocked: false,
trust_level: 'invite_pinned',
invitePinnedPrekeyLookupHandle: 'handle-pending',
invitePinnedTrustFingerprint: 'invitefp-pending',
dhPubKey: '',
},
});
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
});
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect(await screen.findByText(/Contact saved: Pending Person\./i)).toBeInTheDocument();
expect(await screen.findByText('Saved Contact')).toBeInTheDocument();
expect(screen.queryByText(/prekey/i)).not.toBeInTheDocument();
});
it('saves mail locally when a saved contact is not reachable yet', async () => {
contactsState = {
'!sb_pending': {
alias: 'Pending Person',
blocked: false,
trust_level: 'invite_pinned',
invitePinnedPrekeyLookupHandle: 'handle-pending',
invitePinnedTrustFingerprint: 'invitefp-pending',
dhPubKey: '',
},
};
mocks.fetchDmPublicKey.mockResolvedValueOnce({ agent_id: '!sb_pending', dh_pub_key: '', dh_algo: 'X25519' });
renderMessagesView();
await openComposeForRecipient('!sb_pending', 'hello when ready');
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
expect(await screen.findByText(/Mail is saved locally and will send automatically when the contact is reachable/i)).toBeInTheDocument();
expect(mocks.sendOffLedgerConsentMessage).not.toHaveBeenCalled();
expect(screen.queryByText(/delivery key has not reached/i)).not.toBeInTheDocument();
});
it('removes an approved contact immediately from the visible contact list', async () => {
contactsState = {
'!sb_remove': {
alias: 'Remove Me',
blocked: false,
trust_level: 'invite_pinned',
invitePinnedTrustFingerprint: 'removefingerprint123456',
dhPubKey: 'peer-dh',
},
};
mocks.removeContact.mockImplementation((peerId: string) => {
delete contactsState[peerId];
});
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Remove Me')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
expect(await screen.findByText(/Removed contact: Remove Me\./i)).toBeInTheDocument();
expect(screen.queryByText('Remove Me')).not.toBeInTheDocument();
});
it('explains unresolved address delivery without exposing backend jargon', async () => {
mocks.importWormholeDmInvite.mockRejectedValueOnce(new Error('peer prekey lookup unavailable'));
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
});
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect(await screen.findByText(/This address is valid, but contact delivery is not ready on this node yet/i)).toBeInTheDocument();
expect(screen.queryByText('peer prekey lookup unavailable')).not.toBeInTheDocument();
expect(screen.queryByText(/sender prekey/i)).not.toBeInTheDocument();
});
it('announces compat invite imports as a saved contact without backend detail', async () => {
mocks.importWormholeDmInvite.mockResolvedValueOnce({
ok: true,
peer_id: '!sb_compat',
@@ -734,17 +897,16 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
});
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect(await screen.findByText(/Contact saved: !sb_compat\./i)).toBeInTheDocument();
expect(screen.queryByText(/TOFU PINNED for/i)).not.toBeInTheDocument();
expect(
await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i),
).toBeInTheDocument();
expect(
screen.getByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i),
).toBeInTheDocument();
screen.queryByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i),
).not.toBeInTheDocument();
});
it('surfaces stable root continuity breaks on invite re-import', async () => {
@@ -783,7 +945,7 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
});
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
@@ -1357,7 +1357,9 @@ describe('wormholeIdentityClient strict profile hints', () => {
}),
);
expect(controlPlaneJson).toHaveBeenCalledWith('/api/wormhole/dm/root-health');
expect(controlPlaneJson).toHaveBeenCalledWith('/api/wormhole/dm/root-health', {
requireAdminSession: false,
});
});
it('prepares the interactive lane through the configured wormhole runtime and bootstraps identity state', async () => {
@@ -7,6 +7,7 @@
* - /api/tools/* (Sprint 1C addition)
* - /api/wormhole/* (pre-existing, regression)
* - /api/settings/* (pre-existing, regression)
* - /api/layers, /api/ais/feed, /api/ai/agent-actions
*
* Also verifies that:
* - non-sensitive mesh paths (e.g. mesh/events) do NOT receive injected key
@@ -272,6 +273,77 @@ describe('proxy admin-key injection coverage', () => {
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
});
it('POST /api/layers with valid session injects X-Admin-Key', async () => {
const cookie = await mintSession(ADMIN_KEY);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const req = new NextRequest('http://localhost/api/layers', {
method: 'POST',
body: JSON.stringify({ layers: { aircraft: true } }),
headers: { cookie, 'Content-Type': 'application/json' },
});
const res = await proxyPost(req, {
params: Promise.resolve({ path: ['layers'] }),
});
expect(res.status).toBe(200);
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
});
it('POST /api/ais/feed with valid session injects X-Admin-Key', async () => {
const cookie = await mintSession(ADMIN_KEY);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const req = new NextRequest('http://localhost/api/ais/feed', {
method: 'POST',
body: JSON.stringify({ msgs: [] }),
headers: { cookie, 'Content-Type': 'application/json' },
});
const res = await proxyPost(req, {
params: Promise.resolve({ path: ['ais', 'feed'] }),
});
expect(res.status).toBe(200);
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
});
it('GET /api/ai/agent-actions with valid session injects X-Admin-Key', async () => {
const cookie = await mintSession(ADMIN_KEY);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true, actions: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const req = new NextRequest('http://localhost/api/ai/agent-actions', {
method: 'GET',
headers: { cookie },
});
const res = await proxyGet(req, {
params: Promise.resolve({ path: ['ai', 'agent-actions'] }),
});
expect(res.status).toBe(200);
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
});
// -------------------------------------------------------------------------
// Non-sensitive mesh paths must NOT receive injected admin key
// -------------------------------------------------------------------------
+3
View File
@@ -64,6 +64,9 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean {
if (joined === 'refresh') return true;
if (joined === 'debug-latest') return true;
if (joined === 'system/update') return true;
if (joined === 'layers') return true;
if (joined === 'ais/feed') return true;
if (joined === 'ai/agent-actions') return true;
if (pathSegments[0] === 'settings') return true;
if (joined === 'mesh/infonet/ingest') return true;
if (joined === 'mesh/meshtastic/send') return true;
+2 -2
View File
@@ -203,8 +203,8 @@ export default function Dashboard() {
uap_sightings: true,
// Biosurveillance
wastewater: true,
// CrowdThreat
crowdthreat: true,
// CrowdThreat is operator opt-in only.
crowdthreat: false,
// Shodan
shodan_overlay: false,
// AI Intel
File diff suppressed because it is too large Load Diff
@@ -7,16 +7,17 @@ import { fetchInfonetNodeStatusSnapshot } from '@/mesh/controlPlaneStatusClient'
interface Stats {
meshtastic: number;
aprs: number;
infonetNodes: number;
ledgerNodes: number;
infonetEvents: number;
syncPeers: number;
seedPeers: number;
nodeEnabled: boolean;
syncOutcome: string;
}
const EMPTY: Stats = {
meshtastic: 0, aprs: 0, infonetNodes: 0, infonetEvents: 0,
syncPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
meshtastic: 0, aprs: 0, ledgerNodes: 0, infonetEvents: 0,
syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
};
export default function NetworkStats() {
@@ -32,22 +33,21 @@ export default function NetworkStats() {
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
]);
if (!alive) return;
const knownNodes = Number(infonet?.known_nodes || 0);
const authorNodes = Number(infonet?.author_nodes ?? infonet?.known_nodes ?? 0);
const registeredNodes = Number(infonet?.registered_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,
const seedPeerCount = Number(
infonet?.bootstrap?.bootstrap_seed_peer_count
?? infonet?.bootstrap?.default_sync_peer_count
?? 0,
);
setStats({
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
aprs: Number(meshRes?.signal_counts?.aprs || 0),
infonetNodes: visibleInfonetNodes,
ledgerNodes: Math.max(authorNodes, registeredNodes),
infonetEvents: Number(infonet?.total_events || 0),
syncPeers: syncPeerCount,
seedPeers: seedPeerCount,
nodeEnabled: Boolean(infonet?.node_enabled),
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
});
@@ -74,11 +74,21 @@ export default function NetworkStats() {
<span className="text-gray-700">|</span>
<span>APRS <span className={stats.aprs > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.aprs.toLocaleString()}</span></span>
<span className="text-gray-700">|</span>
<span>INFONET NODES <span className="text-white">{stats.infonetNodes}</span></span>
<span title="Distinct identities this node has seen on the accepted Infonet ledger. This is not a live user count.">
LEDGER NODES <span className="text-white">{stats.ledgerNodes}</span>
</span>
<span className="text-gray-700">|</span>
<span>EVENTS <span className="text-white">{stats.infonetEvents}</span></span>
<span className="text-gray-700">|</span>
<span>PEERS <span className="text-white">{stats.syncPeers}</span></span>
<span title="Configured peers this node pulls from. Usually this is just the seed unless another device is added as a sync peer.">
SYNC PEERS <span className="text-white">{stats.syncPeers}</span>
</span>
{stats.seedPeers > stats.syncPeers ? (
<>
<span className="text-gray-700">|</span>
<span title="Bootstrap seed peers available from config or manifest.">SEEDS <span className="text-white">{stats.seedPeers}</span></span>
</>
) : null}
</div>
);
}
@@ -305,6 +305,42 @@ function createPublicMeshAddress(): string {
return `!${fallback.toString(16).padStart(8, '0')}`;
}
function errorMessage(err: unknown, fallback: string = 'unknown error'): string {
if (err instanceof Error && err.message) return err.message;
if (typeof err === 'string' && err.trim()) return err.trim();
if (typeof err === 'object' && err !== null && 'message' in err) {
const message = String((err as { message?: unknown }).message || '').trim();
if (message) return message;
}
return fallback;
}
function describeMeshChatControlError(raw: string): string {
const message = String(raw || '').trim();
if (!message) return 'MeshChat could not update the local control plane.';
if (
message === 'control_plane_request_failed:530' ||
message === 'HTTP 530' ||
message.includes('control_plane_request_failed:530')
) {
return 'The local control plane did not complete the lane switch. Check that the backend is running and reachable, then try Mesh again.';
}
if (
message === 'control_plane_request_failed:502' ||
message === 'HTTP 502' ||
/Backend unavailable/i.test(message)
) {
return 'The frontend cannot reach the backend right now. Start or restart the backend, then try Mesh again.';
}
if (message === 'admin_session_required' || /local operator access only/i.test(message)) {
return 'This control action needs a local operator session. Open Settings or Node controls once so the app can authorize local changes, then try Mesh again.';
}
if (message.startsWith('{') || message.startsWith('<')) {
return 'MeshChat could not update the local control plane. Check the backend log for the upstream error.';
}
return message;
}
function describeGateCompatConsentRequired(): string {
return 'Local gate runtime is unavailable for this room.';
}
@@ -507,8 +543,13 @@ export function useMeshChatController({
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(detail || `HTTP ${res.status}`);
const data = await res.clone().json().catch(() => null) as
| { detail?: unknown; message?: unknown; error?: unknown }
| null;
const detail =
String(data?.detail || data?.message || data?.error || '').trim() ||
(await res.text().catch(() => '')).trim();
throw new Error(describeMeshChatControlError(detail || `HTTP ${res.status}`));
}
const data = (await res.json()) as MeshMqttSettings;
applyMeshMqttSettings(data);
@@ -528,7 +569,7 @@ export function useMeshChatController({
setMeshMqttStatusText(status);
return { ok: true as const, text: status, data };
} catch (err) {
const text = err instanceof Error ? err.message : 'MQTT settings update failed';
const text = describeMeshChatControlError(errorMessage(err, 'MQTT settings update failed'));
setMeshMqttStatusText(text);
return { ok: false as const, text };
} finally {
@@ -4222,7 +4263,14 @@ export function useMeshChatController({
);
const disablePrivateNodeForPublicMesh = useCallback(async () => {
await setInfonetNodeEnabled(false);
try {
await setInfonetNodeEnabled(false);
} catch (err) {
console.warn(
'[mesh] private node pre-disable failed before public Mesh activation; MQTT enable will retry lane isolation',
err,
);
}
}, []);
const disableWormholeForPublicMesh = useCallback(async () => {
@@ -4287,10 +4335,7 @@ export function useMeshChatController({
}
return { ok: true as const, text: successText };
} catch (err) {
const message =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message)
: 'unknown error';
const message = describeMeshChatControlError(errorMessage(err));
const errorText =
message === 'browser_identity_blocked_secure_mode'
? 'Mesh key creation is blocked while Wormhole secure mode is active. Turn Wormhole off first if you want a separate public mesh key.'
@@ -4345,10 +4390,7 @@ export function useMeshChatController({
setMeshQuickStatus(null);
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 message = describeMeshChatControlError(errorMessage(err));
const text = `Could not turn MeshChat on: ${message}`;
setIdentityWizardStatus({ type: 'err', text });
setMeshQuickStatus({ type: 'err', text });
+5 -1
View File
@@ -49,8 +49,12 @@ export async function controlPlaneJson<T>(
const fallback =
res.status === 429
? 'control_plane_rate_limited'
: res.status === 530
? 'local_control_plane_unavailable'
: res.status === 502
? 'backend_unavailable'
: `control_plane_request_failed:${res.status || 'unknown'}`;
throw new Error(data?.detail || data?.message || fallback);
throw new Error(data?.detail || data?.message || data?.error || fallback);
}
return data as T;
}
@@ -58,6 +58,8 @@ export interface InfonetNodeStatusSnapshot {
total_events?: number;
active_events?: number;
known_nodes?: number;
author_nodes?: number;
registered_nodes?: number;
chain_size_kb?: number;
head_hash?: string;
unsigned_events?: number;
+1
View File
@@ -66,6 +66,7 @@ function callWorker(payload: Omit<WorkerRequest, 'id'> & Record<string, unknown>
async function callWormhole(path: string, body: Record<string, unknown>): Promise<string> {
const data = await controlPlaneJson<{ result?: string }>(path, {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
+13 -8
View File
@@ -1554,6 +1554,7 @@ function normalizeContactMap(input: Record<string, Contact> | Record<string, unk
async function persistContactToWormhole(peerId: string, contact: Contact): Promise<void> {
await controlPlaneJson('/api/wormhole/dm/contact', {
method: 'PUT',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1565,6 +1566,7 @@ async function persistContactToWormhole(peerId: string, contact: Contact): Promi
async function deleteContactFromWormhole(peerId: string): Promise<void> {
await controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}`, {
method: 'DELETE',
requireAdminSession: false,
});
}
@@ -1601,17 +1603,20 @@ export async function hydrateWormholeContacts(force: boolean = false): Promise<R
if (!force && contactsHydration) {
return contactsHydration;
}
contactsHydration = controlPlaneJson<{ ok: boolean; contacts: Record<string, unknown> }>(
'/api/wormhole/dm/contacts',
)
.then((data) => {
contactCache = normalizeContactMap(data.contacts || {});
return contactCache;
})
.catch(() => contactCache);
contactsHydration = hydrateWormholeContactsFromNode().catch(() => contactCache);
return contactsHydration;
}
export async function hydrateWormholeContactsFromNode(): Promise<Record<string, Contact>> {
const data = await controlPlaneJson<{ ok: boolean; contacts: Record<string, unknown> }>(
'/api/wormhole/dm/contacts',
{ requireAdminSession: false },
);
contactCache = normalizeContactMap(data.contacts || {});
contactsHydration = Promise.resolve(contactCache);
return contactCache;
}
function getStoredContacts(): Record<string, Contact> {
if (!shouldUseWormholeContacts() && !contactsHydration && typeof window !== 'undefined') {
void hydrateWormholeContacts();
@@ -13,6 +13,7 @@ export async function bootstrapEncryptAccessRequest(peerId: string, plaintext: s
await ensureWormholeReadyForSecureAction('bootstrap_encrypt');
const data = await controlPlaneJson<{ result: string }>('/api/wormhole/dm/bootstrap-encrypt', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -26,6 +27,7 @@ export async function bootstrapDecryptAccessRequest(senderId: string, ciphertext
await ensureWormholeReadyForSecureAction('bootstrap_decrypt');
const data = await controlPlaneJson<{ result: string }>('/api/wormhole/dm/bootstrap-decrypt', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sender_id: senderId,
+24 -3
View File
@@ -102,6 +102,8 @@ export interface WormholeDmInviteImportResult {
trust_fingerprint: string;
trust_level: string;
detail?: string;
pending_prekey?: boolean;
prekey_detail?: string;
contact: Record<string, unknown>;
}
@@ -1091,7 +1093,9 @@ export function getWormholeDmInviteImportErrorResult(
}
export async function fetchWormholeDmRootHealth(): Promise<WormholeDmRootHealth> {
return controlPlaneJson<WormholeDmRootHealth>('/api/wormhole/dm/root-health');
return controlPlaneJson<WormholeDmRootHealth>('/api/wormhole/dm/root-health', {
requireAdminSession: false,
});
}
export async function bootstrapWormholeIdentity(): Promise<WormholeIdentity> {
@@ -1759,7 +1763,8 @@ export async function registerWormholeDmKey(): Promise<WormholeIdentity & { ok:
return controlPlaneJson<WormholeIdentity & { ok: boolean; detail?: string }>(
'/api/wormhole/dm/register-key',
{
method: 'POST',
method: 'POST',
requireAdminSession: false,
},
);
}
@@ -1771,6 +1776,7 @@ export async function issueWormholeDmSenderToken(
): Promise<WormholeDmSenderToken> {
return controlPlaneJson<WormholeDmSenderToken>('/api/wormhole/dm/sender-token', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient_id: recipientId,
@@ -1788,6 +1794,7 @@ export async function issueWormholeDmSenderTokens(
): Promise<WormholeDmSenderTokenBatch> {
return controlPlaneJson<WormholeDmSenderTokenBatch>('/api/wormhole/dm/sender-token', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient_id: recipientId,
@@ -1815,6 +1822,7 @@ export async function openWormholeSenderSeal(
): Promise<WormholeOpenedSeal> {
return controlPlaneJson<WormholeOpenedSeal>('/api/wormhole/dm/open-seal', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sender_seal: senderSeal,
@@ -1833,6 +1841,7 @@ export async function buildWormholeSenderSeal(
): Promise<WormholeBuiltSeal> {
return controlPlaneJson<WormholeBuiltSeal>('/api/wormhole/dm/build-seal', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient_id: recipientId,
@@ -1850,6 +1859,7 @@ export async function deriveWormholeDeadDropTokenPair(
): Promise<WormholeDeadDropTokenPair> {
return controlPlaneJson<WormholeDeadDropTokenPair>('/api/wormhole/dm/dead-drop-token', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1865,6 +1875,7 @@ export async function issueWormholePairwiseAlias(
): Promise<WormholePairwiseAlias> {
return controlPlaneJson<WormholePairwiseAlias>('/api/wormhole/dm/pairwise-alias', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1880,6 +1891,7 @@ export async function rotateWormholePairwiseAlias(
): Promise<WormholeRotatedPairwiseAlias> {
return controlPlaneJson<WormholeRotatedPairwiseAlias>('/api/wormhole/dm/pairwise-alias/rotate', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1895,6 +1907,7 @@ export async function deriveWormholeDeadDropTokens(
): Promise<WormholeDeadDropTokensBatch> {
return controlPlaneJson<WormholeDeadDropTokensBatch>('/api/wormhole/dm/dead-drop-tokens', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contacts,
@@ -1911,6 +1924,7 @@ export async function deriveWormholeSasPhrase(
): Promise<WormholeSasPhrase> {
return controlPlaneJson<WormholeSasPhrase>('/api/wormhole/dm/sas', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1929,6 +1943,7 @@ export async function confirmWormholeSasVerification(
): Promise<WormholeSasConfirmResult> {
return controlPlaneJson<WormholeSasConfirmResult>('/api/wormhole/dm/sas/confirm', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1944,6 +1959,7 @@ export async function acknowledgeWormholeSasFingerprint(
): Promise<WormholeSasConfirmResult> {
return controlPlaneJson<WormholeSasConfirmResult>('/api/wormhole/dm/sas/acknowledge', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1959,6 +1975,7 @@ export async function recoverWormholeSasRootContinuity(
): Promise<WormholeSasConfirmResult> {
return controlPlaneJson<WormholeSasConfirmResult>('/api/wormhole/dm/sas/recover-root', {
method: 'POST',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1970,7 +1987,9 @@ export async function recoverWormholeSasRootContinuity(
}
export async function listWormholeDmContacts(): Promise<WormholeDmContactsResponse> {
return controlPlaneJson<WormholeDmContactsResponse>('/api/wormhole/dm/contacts');
return controlPlaneJson<WormholeDmContactsResponse>('/api/wormhole/dm/contacts', {
requireAdminSession: false,
});
}
export async function putWormholeDmContact(
@@ -1979,6 +1998,7 @@ export async function putWormholeDmContact(
): Promise<{ ok: boolean; peer_id: string; contact: Record<string, unknown> }> {
return controlPlaneJson('/api/wormhole/dm/contact', {
method: 'PUT',
requireAdminSession: false,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
peer_id: peerId,
@@ -1992,6 +2012,7 @@ export async function deleteWormholeDmContact(
): Promise<{ ok: boolean; peer_id: string; deleted: boolean }> {
return controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}`, {
method: 'DELETE',
requireAdminSession: false,
});
}