mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-27 09:32:28 +02:00
Harden infonet control surfaces
This commit is contained in:
+36
-14
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+12
-12
@@ -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()
|
||||
|
||||
@@ -318,7 +318,7 @@ active_layers: dict[str, bool] = {
|
||||
"uap_sightings": True,
|
||||
"wastewater": True,
|
||||
"ai_intel": True,
|
||||
"crowdthreat": True,
|
||||
"crowdthreat": False,
|
||||
"sar": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,118 @@ 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
|
||||
request = urllib.request.Request(
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "ShadowBroker-Infonet/0.9 (+https://github.com/BigBodyCobain/Shadowbroker)",
|
||||
},
|
||||
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 +1039,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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,69 @@
|
||||
"""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": []}),
|
||||
],
|
||||
)
|
||||
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
|
||||
@@ -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"
|
||||
+3
-1
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user