diff --git a/.env.example b/.env.example
index ad72720..e925239 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,10 @@ OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
AIS_API_KEY=
+# Optional AISHub REST backup when AISStream is silent/offline (same ships layer, ~20 min cadence).
+# Free registration at https://www.aishub.net/api — paste the account username (not a password).
+# AISHUB_USERNAME=
+
# Global Fishing Watch — fishing vessel activity events (Fishing Activity map layer).
# Free API token from https://globalfishingwatch.org/our-apis/tokens
# Without this the fishing_activity layer stays empty.
diff --git a/backend/main.py b/backend/main.py
index 320e934..aedf2a6 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,4 +1,4 @@
-import os
+import os
from dotenv import load_dotenv
load_dotenv()
@@ -139,8 +139,8 @@ def _check_explicit_scoped_auth_local(
if not admin_key and not scoped_tokens:
if _allow_insecure_admin() or (_debug_mode_enabled() and host == "test"):
return True, "ok", "debug_override"
- return False, "Forbidden — admin key not configured", ""
- return False, "Forbidden — invalid or missing admin key", ""
+ return False, "Forbidden ΓÇö admin key not configured", ""
+ return False, "Forbidden ΓÇö invalid or missing admin key", ""
def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]:
@@ -164,10 +164,11 @@ def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]:
# Docker Swarm Secrets support
# For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY),
# the file is read and its trimmed content is placed into VAR.
-# This MUST run before service imports — modules read os.environ at import time.
+# This MUST run before service imports — modules read os.environ at import time.
# ---------------------------------------------------------------------------
_SECRET_VARS = [
"AIS_API_KEY",
+ "AISHUB_USERNAME",
"OPENSKY_CLIENT_ID",
"OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY",
@@ -212,7 +213,7 @@ if not _MESH_ONLY:
from services.ais_stream import start_ais_stream, stop_ais_stream
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
else:
- # Lean mesh/wormhole process — avoid importing the OSINT fetcher graph.
+ # Lean mesh/wormhole process ΓÇö avoid importing the OSINT fetcher graph.
def start_scheduler(*_a, **_k): # type: ignore[misc]
return None
@@ -1642,7 +1643,7 @@ def _peer_sync_response(peer_url: str, body: dict[str, Any]) -> dict[str, Any]:
# HTTP 429 must be surfaced as a typed exception carrying the
# Retry-After value, so finish_sync can honor it and stop hammering
# the upstream. Pre-fix this path just stringified the status into
- # a ValueError, which finish_sync then ignored — keeping the
+ # a ValueError, which finish_sync then ignored ΓÇö keeping the
# upstream's rate-limit bucket full indefinitely.
if response.status_code == 429:
from services.mesh.mesh_infonet_sync_support import (
@@ -1679,8 +1680,8 @@ def _hydrate_gate_store_from_chain(events: list[dict]) -> int:
"""Copy any gate_message chain events into the local gate_store for read/decrypt.
Only events that are resident in the local infonet (accepted or already
- present) are hydrated. The canonical infonet-resident event is used —
- never the raw batch event — so a forged batch entry carrying a valid
+ present) are hydrated. The canonical infonet-resident event is used —
+ never the raw batch event — so a forged batch entry carrying a valid
event_id but attacker-chosen payload cannot pollute gate_store.
"""
import copy
@@ -1765,7 +1766,7 @@ def _sync_from_peer(
Returns ``(ok, error, forked, retry_after_s)``. The fourth tuple
element is non-zero only when the peer responded with HTTP 429
- and supplied a parseable ``Retry-After`` header — see the typed
+ and supplied a parseable ``Retry-After`` header ΓÇö see the typed
``PeerSyncRateLimited`` exception in mesh_infonet_sync_support.py.
Callers should pass that value to ``finish_sync(retry_after_s=...)``
so the next attempt actually waits.
@@ -1908,7 +1909,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
except PeerSyncHTTPError as exc:
# _sync_from_peer catches PeerSyncRateLimited internally (4-tuple
# path for 429 with Retry-After). Other non-200 statuses surface
- # here as PeerSyncHTTPError — pull retry_after_s + status off it
+ # here as PeerSyncHTTPError ΓÇö pull retry_after_s + status off it
# so the cooldown calculation below can honor server hints even
# for non-429 throttling responses.
ok = False
@@ -1973,7 +1974,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
failure_backoff_s=failure_backoff_s,
# 429 retry-storm fix: when the peer returned HTTP 429 with
# a Retry-After header, finish_sync uses max(exponential,
- # retry_after) for next_sync_due_at — so we actually wait
+ # retry_after) for next_sync_due_at ΓÇö so we actually wait
# the time the upstream asked for instead of hammering
# every 60s and keeping its rate-limit bucket full forever.
retry_after_s=retry_after_s,
@@ -2147,13 +2148,13 @@ def _start_infonet_node_runtime(reason: str = "startup") -> None:
logger.warning(f"Node bootstrap runtime failed to initialize: {e}")
-# ─── Background HTTP Peer Push Worker ────────────────────────────────────
+# ─── Background HTTP Peer Push Worker ────────────────────────────────────
# Runs alongside the sync loop. Every PUSH_INTERVAL seconds, batches new
# Infonet events and sends them via HMAC-authenticated POST to push peers.
_PEER_PUSH_INTERVAL_S = 10
_PEER_PUSH_BATCH_SIZE = 50
-_peer_push_last_index: dict[str, int] = {} # peer_url → last pushed event index
+_peer_push_last_index: dict[str, int] = {} # peer_url → last pushed event index
_INFONET_SYNC_RATE_LIMIT = "600/minute"
@@ -2173,7 +2174,7 @@ def _http_peer_push_loop() -> None:
# legacy global MESH_PEER_PUSH_SECRET path and the per-peer
# MESH_PEER_SECRETS map. The per-peer skip happens below
# ("if not peer_key: continue"), so we don't gate the whole
- # loop on the global secret being set — an install that only
+ # loop on the global secret being set ΓÇö an install that only
# configures per-peer secrets is now valid.
peers = _filter_infonet_peer_urls(authenticated_push_peer_urls())
@@ -2233,7 +2234,7 @@ def _http_peer_push_loop() -> None:
_peer_push_last_index[normalized] = last_idx + len(batch)
logger.info(
f"Pushed {len(batch)} event(s) to {normalized[:40]} "
- f"(idx {last_idx}→{last_idx + len(batch)})"
+ f"(idx {last_idx}→{last_idx + len(batch)})"
)
else:
logger.warning(f"Peer push to {normalized[:40]} returned {resp.status_code}")
@@ -2261,13 +2262,13 @@ def _swarm_manifest_pull_loop() -> None:
_NODE_SYNC_STOP.wait(max(30, interval_s))
-# ─── Background Gate Message Pull Worker ─────────────────────────────────
+# ─── Background Gate Message Pull Worker ─────────────────────────────────
# Periodically pulls gate events from relay peers that this node is missing.
# Complements the push loop: push sends OUR events to peers, pull fetches
# THEIR events from peers (needed when this node is behind NAT).
_GATE_PULL_INTERVAL_S = 10
-_gate_pull_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → known count}
+_gate_pull_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → known count}
def _http_gate_pull_loop() -> None:
@@ -2404,9 +2405,9 @@ def _http_gate_pull_loop() -> None:
-# ─── Background Gate Message Push Worker ─────────────────────────────────
+# ─── Background Gate Message Push Worker ─────────────────────────────────
-_gate_push_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → count}
+_gate_push_last_count: dict[str, dict[str, int]] = {} # peer → {gate_id → count}
def _http_gate_push_loop() -> None:
@@ -2749,9 +2750,9 @@ async def lifespan(app: FastAPI):
validate_env(strict=not _MESH_ONLY)
if _MESH_ONLY:
- logger.info("MESH_ONLY enabled — skipping global data fetchers/schedulers.")
+ logger.info("MESH_ONLY enabled — skipping global data fetchers/schedulers.")
else:
- # Start AIS stream first — it loads the disk cache (instant ships) then
+ # Start AIS stream first — it loads the disk cache (instant ships) then
# begins accumulating live vessel data via WebSocket in the background.
start_ais_stream()
@@ -2759,7 +2760,7 @@ async def lifespan(app: FastAPI):
# in _scheduler_loop, so we do NOT call it again in the preload thread.
start_carrier_tracker()
- # Start SIGINT grid eagerly — APRS-IS TCP + Meshtastic MQTT connections
+ # Start SIGINT grid eagerly — APRS-IS TCP + Meshtastic MQTT connections
# take a few seconds to handshake and start receiving packets. By starting
# now, the bridges are already accumulating signals by the time the first
# fetch_sigint() reads them during the preload cycle.
@@ -2868,7 +2869,7 @@ async def lifespan(app: FastAPI):
delay_s = float(os.environ.get("SHADOWBROKER_STARTUP_PRELOAD_DELAY_S", "2.0") or 0)
if delay_s > 0:
time.sleep(delay_s)
- logger.info("=== PRELOADING DATA (background — server already accepting requests) ===")
+ logger.info("=== PRELOADING DATA (background — server already accepting requests) ===")
try:
update_all_data(startup_mode=True)
logger.info("=== PRELOAD COMPLETE ===")
@@ -2882,7 +2883,7 @@ async def lifespan(app: FastAPI):
try:
from services.tor_hidden_service import tor_service, HOSTNAME_PATH
if HOSTNAME_PATH.exists():
- logger.info("Previous Tor hidden service detected — auto-restarting...")
+ logger.info("Previous Tor hidden service detected — auto-restarting...")
threading.Thread(
target=tor_service.start, daemon=True
).start()
@@ -3195,9 +3196,9 @@ def _trusted_gate_reply_to(event: dict) -> str:
def _derive_anon_handle(node_id: str, gate_id: str) -> str:
"""Derive a stable per-session, per-gate anonymous display handle.
- Same node_id + same gate → same handle for every message that session
+ Same node_id + same gate → same handle for every message that session
posts (lets other members follow a conversation thread). Different
- session (anon re-enters → new node_id) → new handle. Different gate →
+ session (anon re-enters → new node_id) → new handle. Different gate →
different handle for the same session (prevents cross-gate linking).
Not reversible: the handle is HMAC-SHA256(node_id, gate_id) truncated
to 4 hex chars (~16 bits), which is enough to tell sessions apart in
@@ -3238,7 +3239,7 @@ def _strip_gate_identity_member(event: dict, *, envelope_policy: str = "envelope
"transport_lock": str(payload.get("transport_lock", "") or ""),
# gate_envelope is AES-256-GCM ciphertext encrypted under the gate's
# domain key (gate_secret). Only members who hold the gate_secret
- # can decrypt it — so exposing the ciphertext itself to members is
+ # can decrypt it ΓÇö so exposing the ciphertext itself to members is
# safe, and it's REQUIRED for the envelope_always decrypt path that
# gives members durable re-readable history. envelope_hash is the
# cryptographic binding (SHA-256 of gate_envelope) the decrypt path
@@ -3301,7 +3302,7 @@ def _strip_gate_identity_privileged(event: dict) -> dict:
def _strip_gate_identity(event: dict) -> dict:
- """Legacy alias — defaults to member (narrowed) view."""
+ """Legacy alias — defaults to member (narrowed) view."""
return _strip_gate_identity_member(event)
@@ -3483,7 +3484,7 @@ def _verify_gate_access(request: Request, gate_id: str) -> str:
return ""
-# ── Non-hostile transport auto-upgrade ────────────────────────────────
+# ── Non-hostile transport auto-upgrade ────────────────────────────────
#
# The mesh/wormhole middleware can try to bring the wormhole supervisor
# up in the background when a user hits a tier-gated route on a weak
@@ -3750,8 +3751,8 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
# background and return an ok:True "preparing" response
# (202 Accepted) so the client shows a spinner rather
# than an approval dialog. The request itself is NOT
- # forwarded to the handler — the tier is too low for the
- # route's required privacy — but the client can poll and
+ # forwarded to the handler ΓÇö the tier is too low for the
+ # route's required privacy ΓÇö but the client can poll and
# retry transparently once the lane warms up.
try:
upgraded = await _try_transparent_transport_upgrade()
@@ -3784,7 +3785,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
data = read_wormhole_settings()
# Tor-style: if the user selected high privacy but Wormhole
# isn't enabled yet, just turn it on and kick off warmup.
- # Don't block the request on the upgrade — the transport
+ # Don't block the request on the upgrade ΓÇö the transport
# manager will converge in the background.
if (
private_mesh_path
@@ -3807,7 +3808,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
or _is_anonymous_dm_action_path(path, request.method)
or _is_anonymous_wormhole_gate_admin_path(path, request.method)
):
- # Tor-style: anonymous mode is on → do whatever is required for
+ # Tor-style: anonymous mode is on → do whatever is required for
# it to function. Auto-enable Wormhole if off, and schedule
# hidden-transport warmup WITHOUT blocking this request. The
# transport manager converges in the background; the user sees
@@ -3971,7 +3972,7 @@ def _queue_viirs_change_refresh() -> None:
@limiter.limit("60/minute")
async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001
"""Receive frontend map bounds. AIS stream stays global so open-ocean
- vessels are never dropped — the frontend worker handles viewport culling."""
+ vessels are never dropped — the frontend worker handles viewport culling."""
return {"status": "ok"}
@@ -4139,7 +4140,7 @@ async def nearest_sdr(
return find_nearest_kiwisdr(lat, lng, kiwisdr_data)
-# ─── Per-Identity Throttle State ──────────────────────────────────────────
+# ─── Per-Identity Throttle State ──────────────────────────────────────────
# In-memory: {node_id: {"last_send": timestamp, "daily_count": int, "daily_reset": timestamp}}
# Bounded to 10000 entries with 24hr TTL to prevent unbounded memory growth
_node_throttle: TTLCache = TTLCache(maxsize=10000, ttl=86400)
@@ -4223,7 +4224,7 @@ def _check_throttle(
def _check_gate_post_cooldown(sender_id: str, gate_id: str) -> tuple[bool, str]:
- """Check cooldown — does NOT record it. Call _record_gate_post_cooldown() after success."""
+ """Check cooldown — does NOT record it. Call _record_gate_post_cooldown() after success."""
gate_key = str(gate_id or "").strip().lower()
sender_key = str(sender_id or "").strip()
if not gate_key or not sender_key:
@@ -4425,7 +4426,7 @@ def _prepared_signed_write(request: Request):
@limiter.limit("10/minute")
@requires_signed_write(kind=SignedWriteKind.MESH_SEND)
async def mesh_send(request: Request):
- """Unified mesh message endpoint — auto-routes via optimal transport.
+ """Unified mesh message endpoint — auto-routes via optimal transport.
Body: { destination, message, priority?, channel?, node_id?, credentials? }
The router picks APRS, Meshtastic, or Internet based on gate logic.
@@ -4437,7 +4438,7 @@ async def mesh_send(request: Request):
if not destination or not message:
return {"ok": False, "detail": "Missing required fields: destination, message"}
- # ─── Byte limit enforcement ───────────────────────────────────
+ # ─── Byte limit enforcement ───────────────────────────────────
payload_bytes = len(message.encode("utf-8"))
payload_type = body.get("payload_type", "text")
max_bytes = _BYTE_LIMITS.get(payload_type, 200)
@@ -4447,7 +4448,7 @@ async def mesh_send(request: Request):
"detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.",
}
- # ─── Signature verification & node registration ──────────────
+ # ─── Signature verification & node registration ──────────────
node_id = body.get("node_id", body.get("sender_id", "anonymous"))
public_key = body.get("public_key", "")
public_key_algo = body.get("public_key_algo", "")
@@ -4470,9 +4471,9 @@ async def mesh_send(request: Request):
reputation_ledger.register_node(node_id, public_key, public_key_algo)
except Exception:
- pass # Non-critical — don't block sends if reputation module fails
+ pass # Non-critical — don't block sends if reputation module fails
- # ─── Per-identity throttle ────────────────────────────────────
+ # ─── Per-identity throttle ────────────────────────────────────
priority_str = signed_payload["priority"]
transport_lock = str(body.get("transport_lock", "") or "").lower()
throttle_ok, throttle_reason = _check_throttle(node_id, priority_str, transport_lock)
@@ -4495,7 +4496,7 @@ async def mesh_send(request: Request):
}
priority = priority_map.get(priority_str, Priority.NORMAL)
- # ─── C-1 fix: compute trust_tier from Wormhole state ───────
+ # ─── C-1 fix: compute trust_tier from Wormhole state ───────
from services.wormhole_supervisor import get_transport_tier
computed_tier = get_transport_tier()
@@ -4511,7 +4512,7 @@ async def mesh_send(request: Request):
)
credentials = body.get("credentials", {})
- # ─── C-2 fix: enforce tier before transport_lock dispatch ──
+ # ─── C-2 fix: enforce tier before transport_lock dispatch ──
private_tier = str(envelope.trust_tier or "").startswith("private_")
if transport_lock == "meshtastic":
if private_tier:
@@ -4547,7 +4548,7 @@ async def mesh_send(request: Request):
results = mesh_router.route(envelope, credentials)
any_ok = any(r.ok for r in results)
- # ─── Mirror to Meshtastic bridge feed ────────────────────────
+ # ─── Mirror to Meshtastic bridge feed ────────────────────────
# The MQTT broker won't echo our own publishes back to our subscriber, so
# inject successfully-sent channel broadcasts into the bridge directly.
# Node-targeted packets must not appear in the public channel feed.
@@ -4719,14 +4720,14 @@ async def mesh_messages(
@app.get("/api/mesh/channels")
@limiter.limit("30/minute")
async def mesh_channels(request: Request):
- """Get Meshtastic channel population stats — nodes per region/channel."""
+ """Get Meshtastic channel population stats — nodes per region/channel."""
stats = get_latest_data().get("mesh_channel_stats", {})
return stats
-# ─── Reputation Endpoints ─────────────────────────────────────────────────
+# ─── Reputation Endpoints ─────────────────────────────────────────────────
-# Cached root node_id — avoids 5 encrypted disk reads per vote.
+# Cached root node_id — avoids 5 encrypted disk reads per vote.
_root_node_id_cache: dict[str, object] = {"value": None, "ts": 0.0}
_ROOT_NODE_ID_TTL = 30.0 # seconds
@@ -4783,7 +4784,7 @@ async def mesh_vote(request: Request):
vote_payload = {"target_id": target_id, "vote": vote, "gate": gate}
# Resolve stable local operator ID for duplicate-vote prevention.
- # Personas generate unique keypairs, so voter_id alone is insufficient —
+ # Personas generate unique keypairs, so voter_id alone is insufficient —
# use the root identity's node_id as a stable anchor so switching personas
# doesn't let the same operator vote multiple times on the same post.
stable_voter_id = voter_id
@@ -5072,7 +5073,7 @@ async def mesh_identity_revoke(request: Request):
return {"ok": True, "detail": "Identity revoked"}
-# ─── Gate Endpoints ───────────────────────────────────────────────────────
+# ─── Gate Endpoints ───────────────────────────────────────────────────────
@app.post("/api/mesh/gate/create")
@@ -5143,7 +5144,7 @@ async def gate_create(request: Request):
@app.get("/api/mesh/gate/list")
@limiter.limit("30/minute")
async def gate_list(request: Request):
- """List all known gates (public catalog — secrets are never included)."""
+ """List all known gates (public catalog — secrets are never included)."""
from services.mesh.mesh_reputation import gate_manager
return {"gates": gate_manager.list_gates()}
@@ -5246,7 +5247,7 @@ def _submit_gate_message_envelope(request: Request, gate_id: str, body: dict[str
payload_ok, payload_reason = validate_event_payload("gate_message", gate_payload)
if not payload_ok:
return {"ok": False, "detail": payload_reason}
- # gate_envelope is not part of the signed payload — envelope_hash binds it.
+ # gate_envelope is not part of the signed payload — envelope_hash binds it.
# reply_to is signed for new compose flows; if only the legacy no-reply_to
# signature verifies, strip it rather than accepting unauthenticated
# threading metadata.
@@ -5396,13 +5397,13 @@ def _submit_gate_message_envelope(request: Request, gate_id: str, body: dict[str
)
-# ─── Infonet Endpoints ───────────────────────────────────────────────────
+# ─── Infonet Endpoints ───────────────────────────────────────────────────
@app.get("/api/mesh/infonet/status")
@limiter.limit("30/minute")
async def infonet_status(request: Request, verify_signatures: bool = False):
- """Get Infonet metadata — event counts, head hash, chain size."""
+ """Get Infonet metadata — event counts, head hash, chain size."""
from services.mesh.mesh_hashchain import infonet
from services.wormhole_supervisor import get_wormhole_state
@@ -5581,7 +5582,7 @@ async def mesh_metrics(request: Request):
ok, detail = _check_scoped_auth(request, "mesh.audit")
if not ok:
if detail == "insufficient scope":
- raise HTTPException(status_code=403, detail="Forbidden — insufficient scope")
+ raise HTTPException(status_code=403, detail="Forbidden — insufficient scope")
raise HTTPException(status_code=403, detail=detail)
return snapshot()
@@ -5981,7 +5982,7 @@ async def gate_peer_pull(request: Request):
# ---------------------------------------------------------------------------
-# Peer Management API — operator endpoints for adding / removing / listing
+# Peer Management API — operator endpoints for adding / removing / listing
# peers without editing peer_store.json by hand.
# ---------------------------------------------------------------------------
@@ -6038,7 +6039,7 @@ async def add_peer(request: Request):
if not transport:
transport = peer_transport_kind(peer_url)
if not transport:
- return {"ok": False, "detail": "Cannot determine transport for peer_url — provide transport explicitly"}
+ return {"ok": False, "detail": "Cannot determine transport for peer_url — provide transport explicitly"}
label = str(body.get("label", "") or "").strip()
role = str(body.get("role", "") or "").strip().lower() or "relay"
@@ -6415,7 +6416,7 @@ async def infonet_events_by_type(
}
-# ─── Oracle Endpoints ─────────────────────────────────────────────────────
+# ─── Oracle Endpoints ─────────────────────────────────────────────────────
@app.post("/api/mesh/oracle/predict")
@@ -6425,8 +6426,8 @@ async def oracle_predict(request: Request):
"""Place a prediction on a market outcome. FINAL decision.
Body: {node_id, market_title, side, stake_amount?: number}
- - stake_amount = 0 or omitted → FREE PICK (earn rep if correct)
- - stake_amount > 0 → STAKE REP (risk rep, split loser pool if correct)
+ - stake_amount = 0 or omitted → FREE PICK (earn rep if correct)
+ - stake_amount > 0 → STAKE REP (risk rep, split loser pool if correct)
- side can be "yes"/"no" or an outcome name for multi-outcome markets
"""
from services.mesh.mesh_oracle import oracle_ledger
@@ -6465,7 +6466,7 @@ async def oracle_predict(request: Request):
if m.get("title", "").lower() == market_title.lower():
matched = m
break
- # Fuzzy fallback — partial match
+ # Fuzzy fallback — partial match
if not matched:
for m in markets:
if market_title.lower() in m.get("title", "").lower():
@@ -6496,13 +6497,13 @@ async def oracle_predict(request: Request):
probability = 100.0 - probability
if stake_amount > 0:
- # STAKED prediction — risk rep for bigger reward
+ # STAKED prediction — risk rep for bigger reward
ok, detail = oracle_ledger.place_market_stake(
node_id, matched["title"], side, stake_amount, probability
)
mode = "staked"
else:
- # FREE prediction — no rep risked
+ # FREE prediction — no rep risked
ok, detail = oracle_ledger.place_prediction(node_id, matched["title"], side, probability)
mode = "free"
@@ -6725,7 +6726,7 @@ async def oracle_resolve(request: Request):
@app.get("/api/mesh/oracle/consensus")
@limiter.limit("30/minute")
async def oracle_consensus(request: Request, market_title: str = ""):
- """Get network consensus for a market — picks + staked rep per side."""
+ """Get network consensus for a market — picks + staked rep per side."""
from services.mesh.mesh_oracle import oracle_ledger
if not market_title:
@@ -6814,7 +6815,7 @@ async def oracle_stakes_for_message(request: Request, message_id: str):
@app.get("/api/mesh/oracle/profile")
@limiter.limit("30/minute")
async def oracle_profile(request: Request, node_id: str = ""):
- """Get full oracle profile — rep, prediction history, win rate, farming score."""
+ """Get full oracle profile — rep, prediction history, win rate, farming score."""
from services.mesh.mesh_oracle import oracle_ledger
if not node_id:
@@ -6852,7 +6853,7 @@ async def oracle_resolve_stakes(request: Request):
return {"ok": True, "resolutions": resolutions, "count": len(resolutions)}
-# ─── Encrypted DM Relay (Dead Drop) ───────────────────────────────────────
+# ─── Encrypted DM Relay (Dead Drop) ───────────────────────────────────────
def _secure_dm_enabled() -> bool:
@@ -6886,7 +6887,7 @@ def _anonymous_dm_hidden_transport_requested() -> bool:
is *ready* yet.
Use this (not the ``_enforced`` variant) for *protective* logic that must
- keep stated privacy intent honored during warmup — e.g., skipping direct
+ keep stated privacy intent honored during warmup ΓÇö e.g., skipping direct
RNS metadata lookups. ``_enforced`` is for claim/telemetry paths that
report what is currently being honored.
"""
@@ -7164,7 +7165,7 @@ async def _dm_send_from_signed_request(request: Request):
return {"ok": False, "detail": "DM timestamp is too far from current time"}
if delivery_class not in ("request", "shared"):
return {"ok": False, "detail": "delivery_class must be request or shared"}
- # Contact requests are the first-contact handshake — do not require prior verification.
+ # Contact requests are the first-contact handshake ΓÇö do not require prior verification.
if delivery_class == "shared":
try:
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
@@ -7492,7 +7493,7 @@ async def _dm_count_secure_from_signed_request(request: Request):
mailbox_keys = dm_relay.claim_mailbox_keys(agent_id, claims)
relay_ids = dm_relay.claim_message_ids(agent_id, claims)
direct_ids = set()
- # Rec #9: requested (not merely enforced) — skip direct-lane count probe
+ # Rec #9: requested (not merely enforced) ΓÇö skip direct-lane count probe
# as soon as anonymous mode is requested, even before ready converges.
if not _anonymous_dm_hidden_transport_requested():
try:
@@ -7593,7 +7594,7 @@ async def dm_get_pubkey(
if key_bundle is None:
# Invite handles are minted on the owner's node. When a remote peer
# pastes a short address, resolve it across the private fleet before
- # failing — same path as prekey-bundle import.
+ # failing ΓÇö same path as prekey-bundle import.
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
@@ -8160,7 +8161,7 @@ async def debug_latest_data(request: Request):
return list(get_latest_data().keys())
-# ── CCTV media proxy (bypass CORS for cross-origin video/image streams) ───
+# ── CCTV media proxy (bypass CORS for cross-origin video/image streams) ───
_CCTV_PROXY_ALLOWED_HOSTS = {
"s3-eu-west-1.amazonaws.com", # TfL JamCams
"jamcams.tfl.gov.uk",
@@ -8809,12 +8810,12 @@ def api_region_dossier(
lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180),
):
- """Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
+ """Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop."""
return get_region_dossier(lat, lng)
# ---------------------------------------------------------------------------
-# Geocoding — proxy to Nominatim with caching and proper headers
+# Geocoding — proxy to Nominatim with caching and proper headers
# ---------------------------------------------------------------------------
from services.geocode import search_geocode, reverse_geocode
@@ -8987,7 +8988,7 @@ async def api_sentinel_tile(request: Request):
evalscript = evalscripts.get(preset, evalscripts["TRUE-COLOR"])
# Adaptive time range: wider window at lower zoom for better coverage.
- # Sentinel-2 has 5-day revisit — a single day often has gaps.
+ # Sentinel-2 has 5-day revisit — a single day often has gaps.
# At low zoom we mosaic over more days to fill gaps.
from datetime import datetime as _dt, timedelta as _td
@@ -9057,7 +9058,7 @@ async def api_sentinel_tile(request: Request):
# ---------------------------------------------------------------------------
-# API Settings — key registry & management
+# API Settings — key registry & management
# ---------------------------------------------------------------------------
from services.api_settings import get_api_keys, get_env_path_info
from services.shodan_connector import (
@@ -9132,7 +9133,7 @@ async def api_shodan_host(request: Request, body: ShodanHostRequest):
# ---------------------------------------------------------------------------
-# Finnhub — free market intelligence (quotes, congress trades, insider txns)
+# Finnhub — free market intelligence (quotes, congress trades, insider txns)
# ---------------------------------------------------------------------------
from services.unusual_whales_connector import (
FinnhubConnectorError,
@@ -9222,7 +9223,7 @@ async def api_reset_news_feeds(request: Request):
# ---------------------------------------------------------------------------
-# Wormhole Settings — local agent toggle
+# Wormhole Settings — local agent toggle
# ---------------------------------------------------------------------------
from services.wormhole_settings import read_wormhole_settings, write_wormhole_settings
from services.wormhole_status import read_wormhole_status
@@ -9393,7 +9394,7 @@ class NodeSettingsUpdate(BaseModel):
@limiter.limit("30/minute")
async def api_get_node_settings(request: Request):
"""Issue #243 (tg12): node mode and participant state are
- operational posture. Anonymous callers receive an empty stub —
+ operational posture. Anonymous callers receive an empty stub ΓÇö
enough for the UI to know the endpoint exists but nothing
fingerprintable. Authenticated callers see the full state.
@@ -10097,7 +10098,7 @@ def decrypt_wormhole_dm_envelope(
if str(current_tier or "").startswith("private_"):
return {
"ok": False,
- "detail": "MLS format required in private transport mode — legacy DM decrypt blocked",
+ "detail": "MLS format required in private transport mode — legacy DM decrypt blocked",
}
if not _legacy_dm1_allowed():
return {
@@ -10251,7 +10252,7 @@ async def api_wormhole_join(request: Request):
)
# Enable node participation so the sync/push workers connect to peers.
- # This is the voluntary opt-in — the node only joins the network when
+ # This is the voluntary opt-in — the node only joins the network when
# the user explicitly opens the Wormhole.
from services.node_settings import write_node_settings
@@ -12100,7 +12101,7 @@ async def api_set_privacy_profile(request: Request, body: PrivacyProfileUpdate):
# ---------------------------------------------------------------------------
-# System — self-update
+# System — self-update
# ---------------------------------------------------------------------------
from pathlib import Path
from services.updater import perform_update, schedule_restart
@@ -12125,7 +12126,7 @@ async def system_update(request: Request):
status_code=500,
media_type="application/json",
)
- # Docker: skip restart — user must pull new images manually
+ # Docker: skip restart — user must pull new images manually
if result.get("status") == "docker":
return result
# Schedule restart AFTER response flushes (2s delay)
diff --git a/backend/routers/health.py b/backend/routers/health.py
index f610270..ac5076a 100644
--- a/backend/routers/health.py
+++ b/backend/routers/health.py
@@ -111,6 +111,12 @@ async def health_check(request: Request):
ais_status = ais_proxy_status() or {}
except Exception:
ais_status = {}
+ try:
+ from services.fetchers.aishub_fallback import aishub_fallback_enabled
+
+ ais_status["aishub_configured"] = bool(aishub_fallback_enabled())
+ except Exception:
+ ais_status["aishub_configured"] = bool(str(os.environ.get("AISHUB_USERNAME", "") or "").strip())
if ais_status.get("degraded_tls") and top_status == "ok":
# Don't override a worse top-level status if SLOs already failed,
# but escalate ok -> degraded so the field surfaces in dashboards.
diff --git a/backend/services/api_settings.py b/backend/services/api_settings.py
index 30cfe8f..2a29e87 100644
--- a/backend/services/api_settings.py
+++ b/backend/services/api_settings.py
@@ -56,6 +56,15 @@ API_REGISTRY = [
"url": "https://aisstream.io/",
"required": True,
},
+ {
+ "id": "aishub_username",
+ "env_key": "AISHUB_USERNAME",
+ "name": "AISHub Username (backup)",
+ "description": "Free AISHub account username used as a slow REST backup when AISStream is silent or offline. Does not replace live AIS — polls about every 20 minutes into the same ships layer. Register at aishub.net/api.",
+ "category": "Maritime",
+ "url": "https://www.aishub.net/api",
+ "required": False,
+ },
{
"id": "gfw_api_token",
"env_key": "GFW_API_TOKEN",
diff --git a/backend/services/config.py b/backend/services/config.py
index 3b09ff7..cc83137 100644
--- a/backend/services/config.py
+++ b/backend/services/config.py
@@ -1,4 +1,4 @@
-"""Typed configuration via pydantic-settings."""
+"""Typed configuration via pydantic-settings."""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
# Data sources
AIS_API_KEY: str = ""
+ AISHUB_USERNAME: str = "" # Optional AISHub REST backup when AISStream is silent
OPENSKY_CLIENT_ID: str = ""
OPENSKY_CLIENT_SECRET: str = ""
LTA_ACCOUNT_KEY: str = ""
@@ -31,7 +32,7 @@ class Settings(BaseSettings):
MESH_RNS_ENABLED: bool = False
MESH_ARTI_ENABLED: bool = False
# When true, trust wormhole_status.json ready bit if the child process is
- # alive — avoids transport-tier flapping when /api/health probes time out
+ # alive ΓÇö avoids transport-tier flapping when /api/health probes time out
# under Tor load (common during live DM E2E).
MESH_WORMHOLE_TRUST_FILE_READY: bool = False
MESH_ARTI_SOCKS_PORT: int = 9050
@@ -80,7 +81,7 @@ class Settings(BaseSettings):
MESH_PEER_PUSH_SECRET: str = ""
# Issue #256 (tg12): optional per-peer HMAC secret map. Comma-separated
# `url=secret` pairs. When a peer URL appears here, only that per-peer
- # secret is accepted for it — the global MESH_PEER_PUSH_SECRET above is
+ # secret is accepted for it ΓÇö the global MESH_PEER_PUSH_SECRET above is
# ignored for that specific URL. Single-peer installs and unmigrated
# multi-peer installs leave this empty and behavior is unchanged.
MESH_PEER_SECRETS: str = ""
@@ -122,7 +123,7 @@ class Settings(BaseSettings):
MESH_RNS_IBF_FAIL_THRESHOLD: int = 3
MESH_RNS_IBF_COOLDOWN_S: int = 120
MESH_VERIFY_INTERVAL_S: int = 600
- # MESH_VERIFY_SIGNATURES is intentionally removed — the audit loop in main.py
+ # MESH_VERIFY_SIGNATURES is intentionally removed ΓÇö the audit loop in main.py
# always calls validate_chain_incremental(verify_signatures=True). Any value
# set in the environment is ignored.
MESH_DM_SECURE_MODE: bool = True
@@ -144,14 +145,14 @@ class Settings(BaseSettings):
# Anti-spam: cap on distinct UNACKED messages a single sender can have
# parked in a single recipient's mailbox at any one time. Once the
# recipient pulls (acks) a message, the sender's quota for that pair
- # frees up. Default 2 — a sender who wants to deliver more must wait
+ # frees up. Default 2 ΓÇö a sender who wants to deliver more must wait
# for the recipient to actually read the prior messages.
#
# This cap is enforced TWICE: once on the local deposit path (the
# sender's own node refuses to spool the 3rd message) AND once on
# the replication-acceptance path (honest peer relays refuse to
# accept inbound replicas that would put them over the cap). The
- # double enforcement makes the rule a NETWORK rule — patching out
+ # double enforcement makes the rule a NETWORK rule ΓÇö patching out
# the local check on a hostile sender's relay doesn't let extras
# propagate, because every honest peer enforces the same cap on
# inbound replication.
@@ -168,7 +169,7 @@ class Settings(BaseSettings):
MESH_VOTER_BLIND_SALT_GRACE_DAYS: int = 30
MESH_DM_MAX_MSG_BYTES: int = 8192
MESH_DM_ALLOW_SENDER_SEAL: bool = False
- # TTL for DH key and prekey bundle registrations — stale entries are pruned.
+ # TTL for DH key and prekey bundle registrations ΓÇö stale entries are pruned.
MESH_DM_KEY_TTL_DAYS: int = 30
# TTL for invite-scoped prekey lookup aliases; shorter windows reduce
# long-lived relay linkage between opaque lookup handles and agent IDs.
@@ -176,7 +177,7 @@ class Settings(BaseSettings):
# TTL for relay witness history; keep continuity metadata bounded instead
# of relying on a hidden hardcoded retention window.
MESH_DM_WITNESS_TTL_DAYS: int = 14
- # TTL for mailbox binding metadata — shorter = smaller metadata footprint on disk.
+ # TTL for mailbox binding metadata ΓÇö shorter = smaller metadata footprint on disk.
MESH_DM_BINDING_TTL_DAYS: int = 3
# When False, mailbox bindings are memory-only (agents re-register on restart).
# Enable explicitly only if restart continuity is worth persisting DM graph metadata.
@@ -319,7 +320,7 @@ class Settings(BaseSettings):
# Second explicit opt-in for private-tier clearnet fallback. Without this
# acknowledgement, "allow" remains requested but not effective.
MESH_PRIVATE_CLEARNET_FALLBACK_ACKNOWLEDGE: bool = False
- # Meshtastic MQTT bridge — disabled by default to avoid hammering the
+ # Meshtastic MQTT bridge ΓÇö disabled by default to avoid hammering the
# public broker. Users opt in explicitly.
MESH_MQTT_ENABLED: bool = False
# Meshtastic MQTT broker credentials (defaults match public firmware).
@@ -327,7 +328,7 @@ class Settings(BaseSettings):
MESH_MQTT_PORT: int = 1883
MESH_MQTT_USER: str = "meshdev"
MESH_MQTT_PASS: str = "large4cats"
- # Hex-encoded PSK — empty string means use the default LongFast key.
+ # Hex-encoded PSK ΓÇö empty string means use the default LongFast key.
# Must decode to exactly 16 or 32 bytes when set.
MESH_MQTT_PSK: str = ""
# Optional operator-provided Meshtastic node ID (e.g. "!abcd1234") included
@@ -350,16 +351,16 @@ class Settings(BaseSettings):
OPERATOR_HANDLE: str = ""
# SAR (Synthetic Aperture Radar) data layer
- # Mode A — free catalog metadata, no account, default-on
+ # Mode A ΓÇö free catalog metadata, no account, default-on
MESH_SAR_CATALOG_ENABLED: bool = True
- # Mode B — free pre-processed anomalies (OPERA / EGMS / GFM / EMS / UNOSAT)
+ # Mode B ΓÇö free pre-processed anomalies (OPERA / EGMS / GFM / EMS / UNOSAT)
# Two-step opt-in: must be "allow" AND _ACKNOWLEDGE must be true
MESH_SAR_PRODUCTS_FETCH: str = "block"
MESH_SAR_PRODUCTS_FETCH_ACKNOWLEDGE: bool = False
- # NASA Earthdata Login (free) — required for OPERA products
+ # NASA Earthdata Login (free) ΓÇö required for OPERA products
MESH_SAR_EARTHDATA_USER: str = ""
MESH_SAR_EARTHDATA_TOKEN: str = ""
- # Copernicus Data Space (free) — required for EGMS / EMS products
+ # Copernicus Data Space (free) ΓÇö required for EGMS / EMS products
MESH_SAR_COPERNICUS_USER: str = ""
MESH_SAR_COPERNICUS_TOKEN: str = ""
# Whether OpenClaw agents may read/act on the SAR layer
diff --git a/backend/services/env_check.py b/backend/services/env_check.py
index d7e4fbb..5eaad70 100644
--- a/backend/services/env_check.py
+++ b/backend/services/env_check.py
@@ -46,6 +46,7 @@ _CRITICAL_WARN = {
_OPTIONAL = {
"AIS_API_KEY": "AIS vessel streaming (ships layer will be empty without it)",
+ "AISHUB_USERNAME": "AISHub REST backup when AISStream is silent (optional; free at aishub.net/api)",
"GFW_API_TOKEN": "Global Fishing Watch fishing-vessel activity (fishing_activity layer)",
"LTA_ACCOUNT_KEY": "Singapore LTA traffic cameras (CCTV layer)",
"PUBLIC_API_KEY": "Optional client auth for public endpoints (recommended for exposed deployments)",
diff --git a/backend/tests/test_ais_upstream_health.py b/backend/tests/test_ais_upstream_health.py
index de7ee65..9001726 100644
--- a/backend/tests/test_ais_upstream_health.py
+++ b/backend/tests/test_ais_upstream_health.py
@@ -130,6 +130,7 @@ class TestHealthEndpointEscalation:
body = res.json()
assert body["ais_proxy"]["connected"] is False
assert body["ais_proxy"]["proxy_spawn_count"] == 5
+ assert "aishub_configured" in body["ais_proxy"]
# Without API_KEY this would stay "ok"; with it set + connected=false,
# we expect at least "degraded" (could be "error" if an SLO is also
# red, but never "ok").
@@ -138,6 +139,18 @@ class TestHealthEndpointEscalation:
f"got {body['status']!r}"
)
+ def test_health_reports_aishub_configured_flag(self, client, monkeypatch):
+ _reset_ais_module()
+ monkeypatch.setenv("AISHUB_USERNAME", "shadowbroker-test")
+ res = client.get("/api/health")
+ assert res.status_code == 200
+ assert res.json()["ais_proxy"]["aishub_configured"] is True
+
+ monkeypatch.delenv("AISHUB_USERNAME", raising=False)
+ res = client.get("/api/health")
+ assert res.status_code == 200
+ assert res.json()["ais_proxy"]["aishub_configured"] is False
+
def test_no_api_key_does_not_escalate(self, client, monkeypatch):
"""When AIS_API_KEY isn't set, the operator hasn't opted in. Don't
flag the system as degraded just because AIS isn't running — that's
diff --git a/backend/tests/test_aishub_api_settings.py b/backend/tests/test_aishub_api_settings.py
new file mode 100644
index 0000000..cc651c0
--- /dev/null
+++ b/backend/tests/test_aishub_api_settings.py
@@ -0,0 +1,9 @@
+from services.api_settings import ALLOWED_ENV_KEYS, API_REGISTRY
+
+
+def test_aishub_username_is_in_api_registry():
+ entry = next((item for item in API_REGISTRY if item.get("env_key") == "AISHUB_USERNAME"), None)
+ assert entry is not None
+ assert entry["category"] == "Maritime"
+ assert entry["required"] is False
+ assert "AISHUB_USERNAME" in ALLOWED_ENV_KEYS
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index be4f968..3c43b14 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -993,7 +993,7 @@ export default function Dashboard() {
{/* AIS UPSTREAM OUTAGE BANNER — renders only when AIS is configured
but the WebSocket upstream is unreachable. Tells users the empty
ocean isn't their fault. */}
-
+ setSettingsOpen(true)} />
{/* ONBOARDING MODAL */}
{showOnboarding && (
diff --git a/frontend/src/components/AisUpstreamBanner.tsx b/frontend/src/components/AisUpstreamBanner.tsx
index 883c045..30415ad 100644
--- a/frontend/src/components/AisUpstreamBanner.tsx
+++ b/frontend/src/components/AisUpstreamBanner.tsx
@@ -1,15 +1,18 @@
/**
- * AisUpstreamBanner — visible notice that AIS ship data is unavailable
- * because the upstream provider (AISStream) is offline.
+ * AisUpstreamBanner — visible notice that AISStream ship data is unavailable.
*
* Renders nothing when AIS is healthy or when AIS isn't configured at all.
- * Mounted at the app shell level so users see it before they wonder why
- * the ocean looks empty.
+ * When AISStream is silent, nudge operators toward the existing AISHub REST
+ * backup (Settings → API Keys) or confirm that backup is already active.
*/
import { useState } from 'react';
import { useAisUpstreamHealth } from '@/hooks/useAisUpstreamHealth';
-export function AisUpstreamBanner() {
+type AisUpstreamBannerProps = {
+ onOpenApiKeys?: () => void;
+};
+
+export function AisUpstreamBanner({ onOpenApiKeys }: AisUpstreamBannerProps = {}) {
const health = useAisUpstreamHealth();
const [dismissed, setDismissed] = useState(false);
@@ -29,6 +32,10 @@ export function AisUpstreamBanner() {
}
}
+ const detail = health.aishubConfigured
+ ? `AISStream is silent (${stalenessLabel}). AISHub backup polling stays active on a slower cadence (~20 min) — live WebSocket traffic will resume when AISStream recovers.`
+ : `AISStream is silent (${stalenessLabel}). Add a free AISHub username under Settings → API Keys → Maritime for slow backup ship coverage while AISStream is down.`;
+
return (
⚠
-
Ship data temporarily unavailable
-
- AISStream upstream is offline ({stalenessLabel}). The map will
- refill once their service comes back online — nothing is wrong
- with your install.
+
,
+ required: false,
+ description:
+ 'Slow REST backup for the ships layer when AISStream is silent or offline. Uses the same map layer on a ~20 minute cadence.',
+ steps: [
+ 'Create a free account at aishub.net',
+ 'Open the API page and note your username',
+ 'Paste the username into Quick Local Setup above or Settings → API Keys → Maritime',
+ ],
+ url: 'https://www.aishub.net/api',
+ color: 'blue',
+ },
{
name: 'Global Fishing Watch',
icon: ,
@@ -79,6 +93,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '',
+ AISHUB_USERNAME: '',
GFW_API_TOKEN: '',
});
const [setupSaving, setSetupSaving] = useState(false);
@@ -129,6 +144,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
OPENSKY_CLIENT_ID: '',
OPENSKY_CLIENT_SECRET: '',
AIS_API_KEY: '',
+ AISHUB_USERNAME: '',
GFW_API_TOKEN: '',
});
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
@@ -577,9 +593,10 @@ const OnboardingModal = React.memo(function OnboardingModal({
OpenSky Network and AIS Stream are the free keys that make ShadowBroker
- useful immediately: live aircraft and vessel tracking. Global Fishing Watch
- unlocks the fishing-activity layer. Paste them below or use Settings later;
- secrets stay on the local backend.
+ useful immediately: live aircraft and vessel tracking. Optionally add an
+ AISHub username as a slow ships-layer backup when AISStream is silent.
+ Global Fishing Watch unlocks the fishing-activity layer. Paste them below
+ or use Settings later; secrets stay on the local backend.