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..de567ff 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -168,6 +168,7 @@ def _gate_privileged_access_status_snapshot_local() -> dict[str, Any]:
# ---------------------------------------------------------------------------
_SECRET_VARS = [
"AIS_API_KEY",
+ "AISHUB_USERNAME",
"OPENSKY_CLIENT_ID",
"OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY",
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/ai_intel_store.py b/backend/services/ai_intel_store.py
index 48c19f5..c7fda01 100644
--- a/backend/services/ai_intel_store.py
+++ b/backend/services/ai_intel_store.py
@@ -137,15 +137,16 @@ def inject_layer_data(
tagged.append(entry)
with _data_lock:
- existing = latest_data.get(layer)
- if not isinstance(existing, list):
- existing = []
+ current = latest_data.get(layer)
+ existing = list(current) if isinstance(current, list) else []
if mode == "replace":
existing = [e for e in existing if not e.get("_injected")]
- existing.extend(tagged)
- latest_data[layer] = existing
+ # Readers can hold references to published layer lists after releasing
+ # _data_lock. Build a fresh list and swap it atomically rather than
+ # mutating the published object in place with list.extend().
+ latest_data[layer] = [*existing, *tagged]
bump_data_version()
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..d22a910 100644
--- a/backend/services/config.py
+++ b/backend/services/config.py
@@ -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 = ""
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_ai_intel_store_copy_on_write.py b/backend/tests/test_ai_intel_store_copy_on_write.py
new file mode 100644
index 0000000..8c828e6
--- /dev/null
+++ b/backend/tests/test_ai_intel_store_copy_on_write.py
@@ -0,0 +1,59 @@
+"""Regression coverage for copy-on-write OpenClaw layer injection."""
+
+from services import ai_intel_store
+from services.fetchers import _store
+
+
+def _publish_test_layer(monkeypatch, items):
+ published = list(items)
+ monkeypatch.setitem(_store.latest_data, "air_quality", published)
+ monkeypatch.setattr(_store, "bump_data_version", lambda: None)
+ return published
+
+
+def test_append_does_not_mutate_previously_published_list(monkeypatch):
+ before = _publish_test_layer(monkeypatch, [{"id": "existing"}])
+
+ result = ai_intel_store.inject_layer_data(
+ "air_quality",
+ [{"id": "injected"}],
+ mode="append",
+ )
+
+ after = _store.latest_data["air_quality"]
+ assert result == {
+ "ok": True,
+ "layer": "air_quality",
+ "injected": 1,
+ "mode": "append",
+ }
+ assert after is not before
+ assert before == [{"id": "existing"}]
+ assert [item["id"] for item in after] == ["existing", "injected"]
+ assert after[-1]["_injected"] is True
+ assert after[-1]["_source"] == "user:openclaw"
+
+
+def test_replace_does_not_mutate_previously_published_list(monkeypatch):
+ before = _publish_test_layer(
+ monkeypatch,
+ [
+ {"id": "native"},
+ {"id": "old-injected", "_injected": True},
+ ],
+ )
+
+ result = ai_intel_store.inject_layer_data(
+ "air_quality",
+ [{"id": "new-injected"}],
+ mode="replace",
+ )
+
+ after = _store.latest_data["air_quality"]
+ assert result["ok"] is True
+ assert after is not before
+ assert before == [
+ {"id": "native"},
+ {"id": "old-injected", "_injected": True},
+ ]
+ assert [item["id"] for item in after] == ["native", "new-injected"]
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/backend/tests/test_regen_duplicate_routes_baseline.py b/backend/tests/test_regen_duplicate_routes_baseline.py
new file mode 100644
index 0000000..3494140
--- /dev/null
+++ b/backend/tests/test_regen_duplicate_routes_baseline.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+
+from scripts.regen_duplicate_routes_baseline import (
+ build_baseline_payload,
+ collect_duplicate_routes,
+ write_baseline,
+)
+
+
+def _route(path: str, methods: set[str], module: str):
+ def endpoint():
+ return None
+
+ endpoint.__module__ = module
+ return SimpleNamespace(path=path, methods=methods, endpoint=endpoint)
+
+
+def test_collect_duplicate_routes_matches_ci_guard_shape():
+ routes = [
+ _route("/api/layers", {"POST"}, "routers.data"),
+ _route("/api/layers", {"POST"}, "main"),
+ _route("/api/health", {"GET", "HEAD"}, "routers.health"),
+ _route("/api/health", {"GET", "HEAD"}, "main"),
+ _route("/api/only-once", {"GET"}, "routers.example"),
+ SimpleNamespace(path=None, methods={"GET"}, endpoint=lambda: None),
+ ]
+
+ assert collect_duplicate_routes(routes) == {
+ "GET /api/health": ["main", "routers.health"],
+ "POST /api/layers": ["main", "routers.data"],
+ }
+
+
+def test_build_baseline_payload_is_deterministic():
+ payload = build_baseline_payload(
+ {
+ "POST /z": ["routers.z", "main"],
+ "GET /a": ["routers.a", "main"],
+ }
+ )
+
+ assert list(payload["duplicates"]) == ["GET /a", "POST /z"]
+ assert payload["duplicates"]["GET /a"] == ["main", "routers.a"]
+ assert payload["_meta"]["issue"] == "#239"
+ assert payload["_meta"]["generated_with"] == (
+ "python -m scripts.regen_duplicate_routes_baseline"
+ )
+
+
+def test_write_baseline_emits_stable_json(tmp_path):
+ output = tmp_path / "duplicate_routes_baseline.json"
+ duplicates = {"POST /api/layers": ["routers.data", "main"]}
+
+ payload = write_baseline(output, duplicates=duplicates)
+
+ assert json.loads(output.read_text(encoding="utf-8")) == payload
+ assert output.read_text(encoding="utf-8").endswith("\n")
+ assert payload["duplicates"] == {
+ "POST /api/layers": ["main", "routers.data"]
+ }
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 3d0506d..dca49ae 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -994,7 +994,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.
@@ -599,6 +616,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
['AIS_API_KEY', 'AIS Stream API Key'],
+ ['AISHUB_USERNAME', 'AISHub Username (optional backup)'],
['GFW_API_TOKEN', 'Global Fishing Watch API Token (optional)'],
].map(([key, label]) => (
0,
+ aishubConfigured: Boolean(proxy.aishub_configured),
});
} catch {
// Backend unreachable — separate problem. Banner not relevant.
diff --git a/scripts/regen_duplicate_routes_baseline.py b/scripts/regen_duplicate_routes_baseline.py
new file mode 100644
index 0000000..c90963b
--- /dev/null
+++ b/scripts/regen_duplicate_routes_baseline.py
@@ -0,0 +1,106 @@
+"""Regenerate the tolerated duplicate-route baseline used by issue #239 tests.
+
+Run from the repository root with::
+
+ python -m scripts.regen_duplicate_routes_baseline
+
+The command imports the backend application, inspects FastAPI's registered
+routes, and rewrites ``backend/tests/data/duplicate_routes_baseline.json`` in a
+deterministic order.
+"""
+from __future__ import annotations
+
+import json
+import sys
+from collections import defaultdict
+from collections.abc import Iterable
+from pathlib import Path
+from typing import Any
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+BACKEND_DIR = REPO_ROOT / "backend"
+DEFAULT_BASELINE_PATH = BACKEND_DIR / "tests" / "data" / "duplicate_routes_baseline.json"
+
+_BASELINE_NOTE = (
+ "Snapshot of currently-tolerated duplicate route registrations. The test in "
+ "test_no_new_duplicate_routes.py fails if any NEW (method, path) duplicate "
+ "appears outside this list. Removing entries (by actually deduping) is fine "
+ "and the test stays green. New entries here require explicit, reviewed updates."
+)
+
+
+def collect_duplicate_routes(routes: Iterable[Any]) -> dict[str, list[str]]:
+ """Return duplicate ``METHOD /path`` registrations and their modules."""
+ by_key: dict[str, list[str]] = defaultdict(list)
+
+ for route in routes:
+ path = getattr(route, "path", None)
+ methods = getattr(route, "methods", None)
+ endpoint = getattr(route, "endpoint", None)
+ if not path or not methods or endpoint is None:
+ continue
+
+ module = str(getattr(endpoint, "__module__", "") or "")
+ for method in sorted(methods):
+ if method in {"HEAD", "OPTIONS"}:
+ continue
+ by_key[f"{method} {path}"].append(module)
+
+ return {
+ key: sorted(modules)
+ for key, modules in sorted(by_key.items())
+ if len(modules) > 1
+ }
+
+
+def current_duplicates() -> dict[str, list[str]]:
+ """Import the backend application and inspect its live route table."""
+ backend = str(BACKEND_DIR)
+ if backend not in sys.path:
+ sys.path.insert(0, backend)
+
+ import main
+
+ return collect_duplicate_routes(main.app.routes)
+
+
+def build_baseline_payload(duplicates: dict[str, list[str]]) -> dict[str, Any]:
+ """Build the canonical JSON payload written by the regeneration command."""
+ return {
+ "_meta": {
+ "issue": "#239",
+ "note": _BASELINE_NOTE,
+ "generated_with": "python -m scripts.regen_duplicate_routes_baseline",
+ },
+ "duplicates": {
+ key: sorted(modules)
+ for key, modules in sorted(duplicates.items())
+ },
+ }
+
+
+def write_baseline(
+ path: Path = DEFAULT_BASELINE_PATH,
+ *,
+ duplicates: dict[str, list[str]] | None = None,
+) -> dict[str, Any]:
+ """Write the baseline and return the payload for callers and tests."""
+ payload = build_baseline_payload(
+ current_duplicates() if duplicates is None else duplicates
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ return payload
+
+
+def main() -> int:
+ payload = write_baseline()
+ print(
+ f"Wrote {len(payload['duplicates'])} duplicate route entries to "
+ f"{DEFAULT_BASELINE_PATH.relative_to(REPO_ROOT)}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())