mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-13 20:12:08 +02:00
Release v0.9.79 runtime and messaging update
Ship the v0.9.79 runtime refresh with transport lane isolation, Infonet secure-message address management, MeshChat MQTT controls, selected asset trail behavior, telemetry panel refinements, onboarding updates, and desktop/package metadata alignment. Also ignore local graphify work products so analysis folders do not leak into future commits.
This commit is contained in:
@@ -173,6 +173,8 @@ backend/services/test_*.py
|
||||
# Local analysis & dev tools
|
||||
backend/analyze_xlsx.py
|
||||
backend/services/ais_cache.json
|
||||
graphify/
|
||||
graphify-out/
|
||||
|
||||
# ========================
|
||||
# Internal docs & brainstorming (never commit)
|
||||
|
||||
@@ -361,6 +361,8 @@ async def _verify_openclaw_hmac(request: Request) -> bool:
|
||||
# Bind request body: digest the raw bytes so any body tampering
|
||||
# invalidates the signature. Empty/absent bodies hash as sha256(b"").
|
||||
body_bytes = await request.body()
|
||||
# Keep the cached body available for downstream handlers that call request.json().
|
||||
request._body = body_bytes
|
||||
body_digest = _hashlib_mod.sha256(body_bytes).hexdigest()
|
||||
|
||||
# Compute expected signature: HMAC-SHA256(secret, METHOD|path|ts|nonce|body_digest)
|
||||
|
||||
@@ -14,4 +14,9 @@ if [ -d /app/image-data ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "${PRIVACY_CORE_ALLOWED_SHA256:-}" ] && [ -f /app/libprivacy_core.so ]; then
|
||||
PRIVACY_CORE_ALLOWED_SHA256="$(sha256sum /app/libprivacy_core.so | awk '{print $1}')"
|
||||
export PRIVACY_CORE_ALLOWED_SHA256
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
+110
-30
@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from json import JSONDecodeError
|
||||
|
||||
APP_VERSION = "0.9.75"
|
||||
APP_VERSION = "0.9.79"
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -3061,6 +3061,24 @@ def _resume_private_delivery_background_work(*, current_tier: str, reason: str)
|
||||
)
|
||||
|
||||
|
||||
def _is_public_meshtastic_lane_path(path: str, method: str) -> bool:
|
||||
"""Routes for the public Meshtastic MQTT lane.
|
||||
|
||||
These are intentionally outside the Wormhole/Infonet private transport
|
||||
lifecycle. Polling public MeshChat must not wake or re-enable Wormhole.
|
||||
"""
|
||||
normalized_path = str(path or "").strip()
|
||||
method_name = str(method or "").upper()
|
||||
if method_name == "POST" and normalized_path == "/api/mesh/meshtastic/send":
|
||||
return True
|
||||
if method_name == "GET" and normalized_path in {
|
||||
"/api/mesh/messages",
|
||||
"/api/mesh/channels",
|
||||
}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _upgrade_invite_scoped_contact_preferences_background() -> dict[str, Any]:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import upgrade_invite_scoped_contact_preferences
|
||||
@@ -3092,7 +3110,11 @@ def _refresh_lookup_handle_rotation_background(*, reason: str) -> dict[str, Any]
|
||||
@app.middleware("http")
|
||||
async def enforce_high_privacy_mesh(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if path.startswith("/api/mesh") or path.startswith("/api/wormhole/gate/") or path.startswith("/api/wormhole/dm/"):
|
||||
private_mesh_path = path.startswith("/api/mesh") and not _is_public_meshtastic_lane_path(
|
||||
path,
|
||||
request.method,
|
||||
)
|
||||
if private_mesh_path or path.startswith("/api/wormhole/gate/") or path.startswith("/api/wormhole/dm/"):
|
||||
request.state._private_lane_started_at = time.perf_counter()
|
||||
current_tier = "public_degraded"
|
||||
try:
|
||||
@@ -3193,7 +3215,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
|
||||
# Don't block the request on the upgrade — the transport
|
||||
# manager will converge in the background.
|
||||
if (
|
||||
path.startswith("/api/mesh")
|
||||
private_mesh_path
|
||||
and str(data.get("privacy_profile", "default")).lower() == "high"
|
||||
and not bool(data.get("enabled"))
|
||||
):
|
||||
@@ -3426,8 +3448,16 @@ async def update_layers(update: LayerUpdate, request: Request):
|
||||
from services.sigint_bridge import sigint_grid
|
||||
|
||||
if old_mesh and not new_mesh:
|
||||
sigint_grid.mesh.stop()
|
||||
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
||||
try:
|
||||
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
|
||||
keep_chat_running = mqtt_bridge_enabled()
|
||||
except Exception:
|
||||
keep_chat_running = False
|
||||
if keep_chat_running:
|
||||
logger.info("Meshtastic map layer disabled; MQTT bridge kept running for MeshChat")
|
||||
else:
|
||||
sigint_grid.mesh.stop()
|
||||
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
||||
elif not old_mesh and new_mesh:
|
||||
# Respect the global MESH_MQTT_ENABLED gate even when the UI layer is
|
||||
# toggled on. The layer toggle should not bypass the opt-in flag that
|
||||
@@ -4361,9 +4391,11 @@ async def mesh_send(request: Request):
|
||||
any_ok = any(r.ok for r in results)
|
||||
|
||||
# ─── Mirror to Meshtastic bridge feed ────────────────────────
|
||||
# The MQTT broker won't echo our own publishes back to our subscriber,
|
||||
# so inject successfully-sent messages into the bridge's deque directly.
|
||||
if any_ok and envelope.routed_via == "meshtastic":
|
||||
# The MQTT broker won't echo our own publishes back to our subscriber, so
|
||||
# inject successfully-sent channel broadcasts into the bridge directly.
|
||||
# Node-targeted packets must not appear in the public channel feed.
|
||||
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
|
||||
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
|
||||
try:
|
||||
from services.sigint_bridge import sigint_grid
|
||||
|
||||
@@ -4371,16 +4403,22 @@ async def mesh_send(request: Request):
|
||||
if bridge:
|
||||
from datetime import datetime
|
||||
|
||||
bridge.messages.appendleft(
|
||||
append_text = getattr(bridge, "append_text_message", None)
|
||||
message_record = (
|
||||
{
|
||||
"from": MeshtasticTransport.mesh_address_for_sender(node_id),
|
||||
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast",
|
||||
"to": "broadcast",
|
||||
"text": message,
|
||||
"region": credentials.get("mesh_region", "US"),
|
||||
"root": credentials.get("mesh_region", "US"),
|
||||
"channel": body.get("channel", "LongFast"),
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
)
|
||||
if callable(append_text):
|
||||
append_text(message_record)
|
||||
else:
|
||||
bridge.messages.appendleft(message_record)
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
@@ -4390,6 +4428,8 @@ async def mesh_send(request: Request):
|
||||
"event_id": "",
|
||||
"routed_via": envelope.routed_via,
|
||||
"route_reason": envelope.route_reason,
|
||||
"direct": is_direct_destination,
|
||||
"channel_echo": not is_direct_destination,
|
||||
"results": [r.to_dict() for r in results],
|
||||
}
|
||||
|
||||
@@ -4488,6 +4528,7 @@ async def mesh_messages(
|
||||
root: str = "",
|
||||
channel: str = "",
|
||||
limit: int = 30,
|
||||
include_direct: bool = False,
|
||||
):
|
||||
"""Get recent Meshtastic text messages from the MQTT bridge."""
|
||||
from services.sigint_bridge import sigint_grid
|
||||
@@ -4509,6 +4550,12 @@ async def mesh_messages(
|
||||
msgs = [m for m in msgs if m.get("root", "").upper() == root_filter]
|
||||
if channel:
|
||||
msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()]
|
||||
if not include_direct:
|
||||
msgs = [
|
||||
m
|
||||
for m in msgs
|
||||
if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"}
|
||||
]
|
||||
return msgs[: min(limit, 100)]
|
||||
|
||||
|
||||
@@ -8789,6 +8836,16 @@ export_wormhole_dm_invite = getattr(
|
||||
"export_wormhole_dm_invite",
|
||||
_wormhole_identity_unavailable,
|
||||
)
|
||||
list_prekey_lookup_handle_records_for_ui = getattr(
|
||||
_mesh_wormhole_identity,
|
||||
"list_prekey_lookup_handle_records_for_ui",
|
||||
_wormhole_identity_unavailable,
|
||||
)
|
||||
revoke_prekey_lookup_handle = getattr(
|
||||
_mesh_wormhole_identity,
|
||||
"revoke_prekey_lookup_handle",
|
||||
_wormhole_identity_unavailable,
|
||||
)
|
||||
import_wormhole_dm_invite = getattr(
|
||||
_mesh_wormhole_identity,
|
||||
"import_wormhole_dm_invite",
|
||||
@@ -8935,6 +8992,13 @@ async def api_get_node_settings(request: Request):
|
||||
@limiter.limit("10/minute")
|
||||
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
|
||||
_refresh_node_peer_store()
|
||||
if bool(body.enabled):
|
||||
try:
|
||||
from services.transport_lane_isolation import disable_public_mesh_lane
|
||||
|
||||
disable_public_mesh_lane(reason="private_node_enabled")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to disable public Mesh while enabling private node: %s", exc)
|
||||
result = _set_participant_node_enabled(bool(body.enabled))
|
||||
if bool(body.enabled):
|
||||
_kick_public_sync_background("operator_enable")
|
||||
@@ -9659,7 +9723,7 @@ async def api_get_wormhole_status(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/join")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_join(request: Request):
|
||||
existing = read_wormhole_settings()
|
||||
@@ -9713,7 +9777,7 @@ async def api_wormhole_join(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/wormhole/leave", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/leave")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_leave(request: Request):
|
||||
updated = write_wormhole_settings(enabled=False)
|
||||
@@ -9730,7 +9794,7 @@ async def api_wormhole_leave(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)])
|
||||
@app.get("/api/wormhole/identity")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_identity(request: Request):
|
||||
try:
|
||||
@@ -9743,7 +9807,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", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/identity/bootstrap")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_identity_bootstrap(request: Request):
|
||||
bootstrap_wormhole_identity()
|
||||
@@ -9776,8 +9840,24 @@ async def api_wormhole_dm_identity(request: Request):
|
||||
|
||||
@app.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_invite(request: Request):
|
||||
return export_wormhole_dm_invite()
|
||||
async def api_wormhole_dm_invite(
|
||||
request: Request,
|
||||
label: str = Query("", max_length=96),
|
||||
expires_in_s: int = Query(0, ge=0, le=2_592_000),
|
||||
):
|
||||
return export_wormhole_dm_invite(label=label, expires_in_s=expires_in_s)
|
||||
|
||||
|
||||
@app.get("/api/wormhole/dm/invite/handles", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_invite_handles(request: Request):
|
||||
return list_prekey_lookup_handle_records_for_ui()
|
||||
|
||||
|
||||
@app.delete("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str):
|
||||
return revoke_prekey_lookup_handle(handle)
|
||||
|
||||
|
||||
@app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)])
|
||||
@@ -10507,7 +10587,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest):
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/enter")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
|
||||
gate_id = str(body.gate_id or "")
|
||||
@@ -10521,25 +10601,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/leave")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
|
||||
return leave_gate(str(body.gate_id or ""))
|
||||
|
||||
|
||||
@app.get("/api/wormhole/gate/{gate_id}/identity", dependencies=[Depends(require_local_operator)])
|
||||
@app.get("/api/wormhole/gate/{gate_id}/identity")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_identity(request: Request, gate_id: str):
|
||||
return get_active_gate_identity(gate_id)
|
||||
|
||||
|
||||
@app.get("/api/wormhole/gate/{gate_id}/personas", dependencies=[Depends(require_local_operator)])
|
||||
@app.get("/api/wormhole/gate/{gate_id}/personas")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_personas(request: Request, gate_id: str):
|
||||
return list_gate_personas(gate_id)
|
||||
|
||||
|
||||
@app.get("/api/wormhole/gate/{gate_id}/key", dependencies=[Depends(require_local_operator)])
|
||||
@app.get("/api/wormhole/gate/{gate_id}/key")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_key_status(request: Request, gate_id: str):
|
||||
exposure = metadata_exposure_for_request(request, authenticated=True)
|
||||
@@ -10563,7 +10643,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/persona/create")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_create(
|
||||
request: Request, body: WormholeGatePersonaCreateRequest
|
||||
@@ -10579,7 +10659,7 @@ async def api_wormhole_gate_persona_create(
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/persona/activate")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_activate(
|
||||
request: Request, body: WormholeGatePersonaActivateRequest
|
||||
@@ -10595,7 +10675,7 @@ async def api_wormhole_gate_persona_activate(
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/persona/clear")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
|
||||
gate_id = str(body.gate_id or "")
|
||||
@@ -10609,7 +10689,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/persona/retire")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_retire(
|
||||
request: Request, body: WormholeGatePersonaActivateRequest
|
||||
@@ -10690,7 +10770,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate
|
||||
return composed
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/message/sign-encrypted")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_message_sign_encrypted(
|
||||
request: Request,
|
||||
@@ -10722,7 +10802,7 @@ async def api_wormhole_gate_message_sign_encrypted(
|
||||
return signed
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/message/post-encrypted", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/message/post-encrypted")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_message_post_encrypted(
|
||||
request: Request,
|
||||
@@ -10902,13 +10982,13 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat
|
||||
return {"ok": True, "results": results}
|
||||
|
||||
|
||||
@app.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/state/export")
|
||||
@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", dependencies=[Depends(require_local_operator)])
|
||||
@app.post("/api/wormhole/gate/proof")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
|
||||
proof = _sign_gate_access_proof(str(body.gate_id or ""))
|
||||
@@ -11455,7 +11535,7 @@ async def api_wormhole_health(request: Request):
|
||||
return _redact_wormhole_status(full_state, authenticated=ok)
|
||||
|
||||
|
||||
@app.post("/api/wormhole/connect", dependencies=[Depends(require_admin)])
|
||||
@app.post("/api/wormhole/connect")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_connect(request: Request):
|
||||
settings = read_wormhole_settings()
|
||||
|
||||
@@ -7,7 +7,7 @@ py-modules = []
|
||||
|
||||
[project]
|
||||
name = "backend"
|
||||
version = "0.9.75"
|
||||
version = "0.9.79"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"apscheduler==3.10.3",
|
||||
@@ -43,7 +43,7 @@ dev = ["pytest>=8.3.4", "pytest-asyncio==0.25.0", "ruff>=0.9.0", "black>=24.0.0"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# The current backend carries historical style debt in large legacy modules.
|
||||
# Keep CI focused on actionable correctness checks for the v0.9.75 release.
|
||||
# Keep CI focused on actionable correctness checks for the v0.9.79 release.
|
||||
ignore = ["E401", "E402", "E701", "E731", "E741", "F401", "F402", "F541", "F811", "F841"]
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -132,6 +132,13 @@ async def api_get_node_settings(request: Request):
|
||||
@limiter.limit("10/minute")
|
||||
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
|
||||
_refresh_node_peer_store()
|
||||
if bool(body.enabled):
|
||||
try:
|
||||
from services.transport_lane_isolation import disable_public_mesh_lane
|
||||
|
||||
disable_public_mesh_lane(reason="private_node_enabled")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to disable public Mesh while enabling private node: %s", exc)
|
||||
result = _set_participant_node_enabled(bool(body.enabled))
|
||||
if bool(body.enabled):
|
||||
try:
|
||||
@@ -174,17 +181,22 @@ async def api_set_meshtastic_mqtt_settings(request: Request, body: MeshtasticMqt
|
||||
|
||||
enabled_requested = updates.get("enabled")
|
||||
settings = write_meshtastic_mqtt_settings(**updates)
|
||||
if isinstance(enabled_requested, bool):
|
||||
logger.info("Meshtastic MQTT settings update: enabled=%s", enabled_requested)
|
||||
|
||||
if enabled_requested is True:
|
||||
# Public MQTT and Wormhole are intentionally mutually exclusive lanes.
|
||||
try:
|
||||
from services.node_settings import write_node_settings
|
||||
from services.wormhole_settings import write_wormhole_settings
|
||||
from services.wormhole_supervisor import disconnect_wormhole
|
||||
|
||||
write_wormhole_settings(enabled=False)
|
||||
disconnect_wormhole(reason="public_mesh_enabled")
|
||||
write_node_settings(enabled=False)
|
||||
_set_participant_node_enabled(False)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to disable Wormhole while enabling public mesh: %s", exc)
|
||||
logger.warning("Failed to disable private mesh lane while enabling public mesh: %s", exc)
|
||||
|
||||
if bool(settings.get("enabled")):
|
||||
if sigint_grid.mesh.is_running():
|
||||
@@ -357,8 +369,8 @@ async def api_reset_all_agent_credentials(request: Request):
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"new_hmac_secret": new_secret,
|
||||
"detail": "All agent credentials have been reset. Reconfigure your agent with the new credentials.",
|
||||
"hmac_regenerated": True,
|
||||
"detail": "All agent credentials have been reset. Use the agent connection screen to generate or reveal replacement credentials.",
|
||||
**results,
|
||||
}
|
||||
|
||||
|
||||
@@ -1585,7 +1585,7 @@ async def agent_tool_manifest(request: Request):
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"access_tier": access_tier,
|
||||
"available_commands": available_commands,
|
||||
"transport": {
|
||||
@@ -2221,7 +2221,7 @@ async def api_capabilities(request: Request):
|
||||
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
||||
return {
|
||||
"ok": True,
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"auth": {
|
||||
"method": "HMAC-SHA256",
|
||||
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
|
||||
|
||||
+10
-2
@@ -335,8 +335,16 @@ async def update_layers(update: LayerUpdate, request: Request):
|
||||
logger.info("AIS stream started (ship layer enabled)")
|
||||
from services.sigint_bridge import sigint_grid
|
||||
if old_mesh and not new_mesh:
|
||||
sigint_grid.mesh.stop()
|
||||
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
||||
try:
|
||||
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
|
||||
keep_chat_running = mqtt_bridge_enabled()
|
||||
except Exception:
|
||||
keep_chat_running = False
|
||||
if keep_chat_running:
|
||||
logger.info("Meshtastic map layer disabled; MQTT bridge kept running for MeshChat")
|
||||
else:
|
||||
sigint_grid.mesh.stop()
|
||||
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
||||
elif not old_mesh and new_mesh:
|
||||
try:
|
||||
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
|
||||
|
||||
@@ -8,7 +8,7 @@ from services.data_fetcher import get_latest_data
|
||||
from services.schemas import HealthResponse
|
||||
import os
|
||||
|
||||
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.75")
|
||||
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.79")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -721,9 +721,11 @@ async def mesh_send(request: Request):
|
||||
any_ok = any(r.ok for r in results)
|
||||
|
||||
# ─── Mirror to Meshtastic bridge feed ────────────────────────
|
||||
# The MQTT broker won't echo our own publishes back to our subscriber,
|
||||
# so inject successfully-sent messages into the bridge's deque directly.
|
||||
if any_ok and envelope.routed_via == "meshtastic":
|
||||
# The MQTT broker won't echo our own publishes back to our subscriber, so
|
||||
# inject successfully-sent channel broadcasts into the bridge directly.
|
||||
# Node-targeted packets must not appear in the public channel feed.
|
||||
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
|
||||
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
|
||||
try:
|
||||
from services.sigint_bridge import sigint_grid
|
||||
|
||||
@@ -734,7 +736,7 @@ async def mesh_send(request: Request):
|
||||
bridge.messages.appendleft(
|
||||
{
|
||||
"from": MeshtasticTransport.mesh_address_for_sender(node_id),
|
||||
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast",
|
||||
"to": "broadcast",
|
||||
"text": message,
|
||||
"region": credentials.get("mesh_region", "US"),
|
||||
"channel": body.get("channel", "LongFast"),
|
||||
@@ -750,6 +752,8 @@ async def mesh_send(request: Request):
|
||||
"event_id": "",
|
||||
"routed_via": envelope.routed_via,
|
||||
"route_reason": envelope.route_reason,
|
||||
"direct": is_direct_destination,
|
||||
"channel_echo": not is_direct_destination,
|
||||
"results": [r.to_dict() for r in results],
|
||||
}
|
||||
|
||||
@@ -818,9 +822,10 @@ async def meshtastic_public_send(request: Request):
|
||||
if not cb_ok:
|
||||
results = [TransportResult(False, "meshtastic", cb_reason)]
|
||||
else:
|
||||
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
|
||||
envelope.route_reason = (
|
||||
"Local public Meshtastic MQTT path"
|
||||
if MeshtasticTransport._parse_node_id(destination) is None
|
||||
if not is_direct_destination
|
||||
else "Local public Meshtastic direct node path"
|
||||
)
|
||||
credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")}
|
||||
@@ -830,23 +835,28 @@ async def meshtastic_public_send(request: Request):
|
||||
results = [result]
|
||||
|
||||
any_ok = any(r.ok for r in results)
|
||||
if any_ok and envelope.routed_via == "meshtastic":
|
||||
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
|
||||
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
|
||||
try:
|
||||
from datetime import datetime
|
||||
from services.sigint_bridge import sigint_grid
|
||||
|
||||
bridge = sigint_grid.mesh
|
||||
if bridge:
|
||||
bridge.messages.appendleft(
|
||||
{
|
||||
"from": MeshtasticTransport.mesh_address_for_sender(sender_id),
|
||||
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast",
|
||||
"text": message,
|
||||
"region": str(body.get("mesh_region", "US") or "US"),
|
||||
"channel": str(body.get("channel", "LongFast") or "LongFast"),
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
)
|
||||
record = {
|
||||
"from": MeshtasticTransport.mesh_address_for_sender(sender_id),
|
||||
"to": "broadcast",
|
||||
"text": message,
|
||||
"region": str(body.get("mesh_region", "US") or "US"),
|
||||
"root": str(body.get("mesh_region", "US") or "US"),
|
||||
"channel": str(body.get("channel", "LongFast") or "LongFast"),
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
append_text = getattr(bridge, "append_text_message", None)
|
||||
if callable(append_text):
|
||||
append_text(record)
|
||||
else:
|
||||
bridge.messages.appendleft(record)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -856,6 +866,8 @@ async def meshtastic_public_send(request: Request):
|
||||
"event_id": "",
|
||||
"routed_via": envelope.routed_via,
|
||||
"route_reason": envelope.route_reason,
|
||||
"direct": is_direct_destination,
|
||||
"channel_echo": not is_direct_destination,
|
||||
"results": [r.to_dict() for r in results],
|
||||
}
|
||||
|
||||
@@ -954,6 +966,7 @@ async def mesh_messages(
|
||||
root: str = "",
|
||||
channel: str = "",
|
||||
limit: int = 30,
|
||||
include_direct: bool = False,
|
||||
):
|
||||
"""Get recent Meshtastic text messages from the MQTT bridge."""
|
||||
from services.sigint_bridge import sigint_grid
|
||||
@@ -975,6 +988,12 @@ async def mesh_messages(
|
||||
msgs = [m for m in msgs if m.get("root", "").upper() == root_filter]
|
||||
if channel:
|
||||
msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()]
|
||||
if not include_direct:
|
||||
msgs = [
|
||||
m
|
||||
for m in msgs
|
||||
if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"}
|
||||
]
|
||||
return msgs[: min(limit, 100)]
|
||||
|
||||
|
||||
|
||||
+74
-325
@@ -78,6 +78,21 @@ export_wormhole_dm_invite = getattr(
|
||||
"export_wormhole_dm_invite",
|
||||
_wormhole_identity_unavailable,
|
||||
)
|
||||
list_prekey_lookup_handle_records_for_ui = getattr(
|
||||
_mesh_wormhole_identity,
|
||||
"list_prekey_lookup_handle_records_for_ui",
|
||||
_wormhole_identity_unavailable,
|
||||
)
|
||||
rename_prekey_lookup_handle = getattr(
|
||||
_mesh_wormhole_identity,
|
||||
"rename_prekey_lookup_handle",
|
||||
_wormhole_identity_unavailable,
|
||||
)
|
||||
revoke_prekey_lookup_handle = getattr(
|
||||
_mesh_wormhole_identity,
|
||||
"revoke_prekey_lookup_handle",
|
||||
_wormhole_identity_unavailable,
|
||||
)
|
||||
import_wormhole_dm_invite = getattr(
|
||||
_mesh_wormhole_identity,
|
||||
"import_wormhole_dm_invite",
|
||||
@@ -311,6 +326,10 @@ class WormholeDmInviteImportRequest(BaseModel):
|
||||
alias: str = ""
|
||||
|
||||
|
||||
class WormholeDmInviteHandleUpdateRequest(BaseModel):
|
||||
label: str = ""
|
||||
|
||||
|
||||
class WormholeDmSenderTokenRequest(BaseModel):
|
||||
recipient_id: str
|
||||
delivery_class: str
|
||||
@@ -477,6 +496,7 @@ def decrypt_wormhole_dm_envelope(
|
||||
remote_alias: str | None = None,
|
||||
session_welcome: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate to main.py, which owns current MLS/alias/legacy gating behavior."""
|
||||
import main as _m
|
||||
|
||||
return _m.decrypt_wormhole_dm_envelope(
|
||||
@@ -489,71 +509,13 @@ def decrypt_wormhole_dm_envelope(
|
||||
session_welcome=session_welcome,
|
||||
)
|
||||
|
||||
resolved_local, resolved_remote = _resolve_dm_aliases(
|
||||
peer_id=peer_id,
|
||||
local_alias=local_alias,
|
||||
remote_alias=remote_alias,
|
||||
)
|
||||
normalized_format = str(payload_format or "dm1").strip().lower() or "dm1"
|
||||
if normalized_format != "mls1" and is_dm_locked_to_mls(resolved_local, resolved_remote):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": "DM session is locked to MLS format",
|
||||
"required_format": "mls1",
|
||||
"current_format": normalized_format,
|
||||
}
|
||||
if normalized_format == "mls1":
|
||||
has_session = has_mls_dm_session(resolved_local, resolved_remote)
|
||||
if not has_session.get("ok"):
|
||||
return has_session
|
||||
if not has_session.get("exists"):
|
||||
ensured = ensure_mls_dm_session(resolved_local, resolved_remote, str(session_welcome or ""))
|
||||
if not ensured.get("ok"):
|
||||
return ensured
|
||||
decrypted = decrypt_mls_dm(
|
||||
resolved_local,
|
||||
resolved_remote,
|
||||
str(ciphertext or ""),
|
||||
str(nonce or ""),
|
||||
)
|
||||
if not decrypted.get("ok"):
|
||||
return decrypted
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": str(peer_id or "").strip(),
|
||||
"local_alias": resolved_local,
|
||||
"remote_alias": resolved_remote,
|
||||
"plaintext": str(decrypted.get("plaintext", "") or ""),
|
||||
"format": "mls1",
|
||||
}
|
||||
|
||||
from services.wormhole_supervisor import get_transport_tier
|
||||
|
||||
current_tier = get_transport_tier()
|
||||
if str(current_tier or "").startswith("private_"):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": "MLS format required in private transport mode — legacy DM decrypt blocked",
|
||||
}
|
||||
logger.warning("legacy dm decrypt path used")
|
||||
legacy = decrypt_wormhole_dm(peer_id=str(peer_id or ""), ciphertext=str(ciphertext or ""))
|
||||
if not legacy.get("ok"):
|
||||
return legacy
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": str(peer_id or "").strip(),
|
||||
"local_alias": resolved_local,
|
||||
"remote_alias": resolved_remote,
|
||||
"plaintext": str(legacy.get("result", "") or ""),
|
||||
"format": "dm1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.get("/api/settings/wormhole")
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit("240/minute")
|
||||
async def api_get_wormhole_settings(request: Request):
|
||||
settings = await asyncio.to_thread(read_wormhole_settings)
|
||||
return _redact_wormhole_settings(settings, authenticated=_scoped_view_authenticated(request, "wormhole"))
|
||||
@@ -582,248 +544,9 @@ async def api_set_wormhole_settings(request: Request, body: WormholeUpdate):
|
||||
return {**updated, "requires_restart": False, "runtime": state}
|
||||
|
||||
|
||||
class PrivacyProfileUpdate(BaseModel):
|
||||
profile: str
|
||||
|
||||
|
||||
class WormholeSignRequest(BaseModel):
|
||||
event_type: str
|
||||
payload: dict
|
||||
sequence: int | None = None
|
||||
gate_id: str | None = None
|
||||
|
||||
|
||||
class WormholeSignRawRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class WormholeDmEncryptRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_dh_pub: str = ""
|
||||
plaintext: str
|
||||
local_alias: str | None = None
|
||||
remote_alias: str | None = None
|
||||
remote_prekey_bundle: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WormholeDmComposeRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_dh_pub: str = ""
|
||||
plaintext: str
|
||||
local_alias: str | None = None
|
||||
remote_alias: str | None = None
|
||||
remote_prekey_bundle: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WormholeDmDecryptRequest(BaseModel):
|
||||
peer_id: str
|
||||
ciphertext: str
|
||||
format: str = "dm1"
|
||||
nonce: str = ""
|
||||
local_alias: str | None = None
|
||||
remote_alias: str | None = None
|
||||
session_welcome: str | None = None
|
||||
|
||||
|
||||
class WormholeDmResetRequest(BaseModel):
|
||||
peer_id: str | None = None
|
||||
|
||||
|
||||
class WormholeDmBootstrapEncryptRequest(BaseModel):
|
||||
peer_id: str
|
||||
plaintext: str
|
||||
|
||||
|
||||
class WormholeDmBootstrapDecryptRequest(BaseModel):
|
||||
sender_id: str = ""
|
||||
ciphertext: str
|
||||
|
||||
|
||||
class WormholeDmSenderTokenRequest(BaseModel):
|
||||
recipient_id: str
|
||||
delivery_class: str
|
||||
recipient_token: str = ""
|
||||
count: int = 1
|
||||
|
||||
|
||||
class WormholeOpenSealRequest(BaseModel):
|
||||
sender_seal: str
|
||||
candidate_dh_pub: str = ""
|
||||
recipient_id: str
|
||||
expected_msg_id: str
|
||||
|
||||
|
||||
class WormholeBuildSealRequest(BaseModel):
|
||||
recipient_id: str
|
||||
recipient_dh_pub: str = ""
|
||||
msg_id: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class WormholeDeadDropTokenRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_dh_pub: str = ""
|
||||
peer_ref: str = ""
|
||||
|
||||
|
||||
class WormholePairwiseAliasRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_dh_pub: str = ""
|
||||
|
||||
|
||||
class WormholePairwiseAliasRotateRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_dh_pub: str = ""
|
||||
grace_ms: int = 45_000
|
||||
|
||||
|
||||
class WormholeDeadDropContactsRequest(BaseModel):
|
||||
contacts: list[dict[str, Any]]
|
||||
limit: int = 24
|
||||
|
||||
|
||||
class WormholeSasRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_dh_pub: str = ""
|
||||
words: int = 8
|
||||
peer_ref: str = ""
|
||||
|
||||
|
||||
class WormholeGateRequest(BaseModel):
|
||||
gate_id: str
|
||||
rotate: bool = False
|
||||
|
||||
|
||||
class WormholeGatePersonaCreateRequest(BaseModel):
|
||||
gate_id: str
|
||||
label: str = ""
|
||||
|
||||
|
||||
class WormholeGatePersonaActivateRequest(BaseModel):
|
||||
gate_id: str
|
||||
persona_id: str
|
||||
|
||||
|
||||
class WormholeGateKeyGrantRequest(BaseModel):
|
||||
gate_id: str
|
||||
recipient_node_id: str
|
||||
recipient_dh_pub: str
|
||||
recipient_scope: str = "member"
|
||||
|
||||
|
||||
class WormholeGateComposeRequest(BaseModel):
|
||||
gate_id: str
|
||||
plaintext: str
|
||||
reply_to: str = ""
|
||||
compat_plaintext: bool = False
|
||||
|
||||
|
||||
class WormholeGateDecryptRequest(BaseModel):
|
||||
gate_id: str
|
||||
epoch: int = 0
|
||||
ciphertext: str
|
||||
nonce: str = ""
|
||||
sender_ref: str = ""
|
||||
format: str = "mls1"
|
||||
gate_envelope: str = ""
|
||||
envelope_hash: str = ""
|
||||
recovery_envelope: bool = False
|
||||
compat_decrypt: bool = False
|
||||
event_id: str = ""
|
||||
|
||||
|
||||
class WormholeGateDecryptBatchRequest(BaseModel):
|
||||
messages: list[WormholeGateDecryptRequest]
|
||||
|
||||
|
||||
class WormholeGateRotateRequest(BaseModel):
|
||||
gate_id: str
|
||||
reason: str = "manual_rotate"
|
||||
|
||||
def decrypt_wormhole_dm_envelope(
|
||||
*,
|
||||
peer_id: str,
|
||||
ciphertext: str,
|
||||
payload_format: str = "dm1",
|
||||
nonce: str = "",
|
||||
local_alias: str | None = None,
|
||||
remote_alias: str | None = None,
|
||||
session_welcome: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
import main as _m
|
||||
|
||||
return _m.decrypt_wormhole_dm_envelope(
|
||||
peer_id=peer_id,
|
||||
ciphertext=ciphertext,
|
||||
payload_format=payload_format,
|
||||
nonce=nonce,
|
||||
local_alias=local_alias,
|
||||
remote_alias=remote_alias,
|
||||
session_welcome=session_welcome,
|
||||
)
|
||||
|
||||
resolved_local, resolved_remote = _resolve_dm_aliases(
|
||||
peer_id=peer_id,
|
||||
local_alias=local_alias,
|
||||
remote_alias=remote_alias,
|
||||
)
|
||||
normalized_format = str(payload_format or "dm1").strip().lower() or "dm1"
|
||||
if normalized_format != "mls1" and is_dm_locked_to_mls(resolved_local, resolved_remote):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": "DM session is locked to MLS format",
|
||||
"required_format": "mls1",
|
||||
"current_format": normalized_format,
|
||||
}
|
||||
if normalized_format == "mls1":
|
||||
has_session = has_mls_dm_session(resolved_local, resolved_remote)
|
||||
if not has_session.get("ok"):
|
||||
return has_session
|
||||
if not has_session.get("exists"):
|
||||
ensured = ensure_mls_dm_session(resolved_local, resolved_remote, str(session_welcome or ""))
|
||||
if not ensured.get("ok"):
|
||||
return ensured
|
||||
decrypted = decrypt_mls_dm(
|
||||
resolved_local,
|
||||
resolved_remote,
|
||||
str(ciphertext or ""),
|
||||
str(nonce or ""),
|
||||
)
|
||||
if not decrypted.get("ok"):
|
||||
return decrypted
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": str(peer_id or "").strip(),
|
||||
"local_alias": resolved_local,
|
||||
"remote_alias": resolved_remote,
|
||||
"plaintext": str(decrypted.get("plaintext", "") or ""),
|
||||
"format": "mls1",
|
||||
}
|
||||
|
||||
from services.wormhole_supervisor import get_transport_tier
|
||||
|
||||
current_tier = get_transport_tier()
|
||||
if str(current_tier or "").startswith("private_"):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": "MLS format required in private transport mode — legacy DM decrypt blocked",
|
||||
}
|
||||
logger.warning("legacy dm decrypt path used")
|
||||
legacy = decrypt_wormhole_dm(peer_id=str(peer_id or ""), ciphertext=str(ciphertext or ""))
|
||||
if not legacy.get("ok"):
|
||||
return legacy
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": str(peer_id or "").strip(),
|
||||
"local_alias": resolved_local,
|
||||
"remote_alias": resolved_remote,
|
||||
"plaintext": str(legacy.get("result", "") or ""),
|
||||
"format": "dm1",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/settings/privacy-profile")
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit("240/minute")
|
||||
async def api_get_privacy_profile(request: Request):
|
||||
data = await asyncio.to_thread(read_wormhole_settings)
|
||||
return _redact_privacy_profile_settings(
|
||||
@@ -833,7 +556,7 @@ async def api_get_privacy_profile(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/settings/wormhole-status")
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit("240/minute")
|
||||
async def api_get_wormhole_status(request: Request):
|
||||
state = await asyncio.to_thread(get_wormhole_state)
|
||||
transport_tier = _current_private_lane_tier(state)
|
||||
@@ -866,7 +589,7 @@ async def api_get_wormhole_status(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/join")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_join(request: Request):
|
||||
from services.config import get_settings
|
||||
@@ -907,7 +630,7 @@ async def api_wormhole_join(request: Request):
|
||||
)
|
||||
|
||||
# Enable node participation so the sync/push workers connect to peers.
|
||||
# This is the voluntary opt-in — the node only joins the network when
|
||||
# This is the voluntary opt-in — the node only joins the network when
|
||||
# the user explicitly opens the Wormhole.
|
||||
from services.node_settings import write_node_settings
|
||||
|
||||
@@ -923,7 +646,7 @@ async def api_wormhole_join(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/wormhole/leave", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/leave")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_leave(request: Request):
|
||||
updated = write_wormhole_settings(enabled=False)
|
||||
@@ -940,8 +663,8 @@ async def api_wormhole_leave(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
@router.get("/api/wormhole/identity")
|
||||
@limiter.limit("240/minute")
|
||||
async def api_wormhole_identity(request: Request):
|
||||
try:
|
||||
bootstrap_wormhole_persona_state()
|
||||
@@ -951,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", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/identity/bootstrap")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_identity_bootstrap(request: Request):
|
||||
bootstrap_wormhole_identity()
|
||||
@@ -970,7 +693,7 @@ async def api_wormhole_identity_bootstrap(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/wormhole/dm/identity", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit("240/minute")
|
||||
async def api_wormhole_dm_identity(request: Request):
|
||||
try:
|
||||
bootstrap_wormhole_persona_state()
|
||||
@@ -982,8 +705,34 @@ async def api_wormhole_dm_identity(request: Request):
|
||||
|
||||
@router.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_invite(request: Request):
|
||||
return export_wormhole_dm_invite()
|
||||
async def api_wormhole_dm_invite(
|
||||
request: Request,
|
||||
label: str = Query("", max_length=96),
|
||||
expires_in_s: int = Query(0, ge=0, le=2_592_000),
|
||||
):
|
||||
return export_wormhole_dm_invite(label=label, expires_in_s=expires_in_s)
|
||||
|
||||
|
||||
@router.get("/api/wormhole/dm/invite/handles", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("240/minute")
|
||||
async def api_wormhole_dm_invite_handles(request: Request):
|
||||
return list_prekey_lookup_handle_records_for_ui()
|
||||
|
||||
|
||||
@router.patch("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_invite_handle_update(
|
||||
request: Request,
|
||||
handle: str,
|
||||
body: WormholeDmInviteHandleUpdateRequest,
|
||||
):
|
||||
return rename_prekey_lookup_handle(handle, str(body.label or "").strip())
|
||||
|
||||
|
||||
@router.delete("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str):
|
||||
return revoke_prekey_lookup_handle(handle)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)])
|
||||
@@ -1024,7 +773,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/enter")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
|
||||
gate_id = str(body.gate_id or "")
|
||||
@@ -1038,25 +787,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/leave")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
|
||||
return leave_gate(str(body.gate_id or ""))
|
||||
|
||||
|
||||
@router.get("/api/wormhole/gate/{gate_id}/identity", dependencies=[Depends(require_local_operator)])
|
||||
@router.get("/api/wormhole/gate/{gate_id}/identity")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_identity(request: Request, gate_id: str):
|
||||
return get_active_gate_identity(gate_id)
|
||||
|
||||
|
||||
@router.get("/api/wormhole/gate/{gate_id}/personas", dependencies=[Depends(require_local_operator)])
|
||||
@router.get("/api/wormhole/gate/{gate_id}/personas")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_personas(request: Request, gate_id: str):
|
||||
return list_gate_personas(gate_id)
|
||||
|
||||
|
||||
@router.get("/api/wormhole/gate/{gate_id}/key", dependencies=[Depends(require_local_operator)])
|
||||
@router.get("/api/wormhole/gate/{gate_id}/key")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_key_status(request: Request, gate_id: str):
|
||||
import main as _m
|
||||
@@ -1080,7 +829,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/persona/create")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_create(
|
||||
request: Request, body: WormholeGatePersonaCreateRequest
|
||||
@@ -1096,7 +845,7 @@ async def api_wormhole_gate_persona_create(
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/persona/activate")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_activate(
|
||||
request: Request, body: WormholeGatePersonaActivateRequest
|
||||
@@ -1112,7 +861,7 @@ async def api_wormhole_gate_persona_activate(
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/persona/clear")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
|
||||
gate_id = str(body.gate_id or "")
|
||||
@@ -1126,7 +875,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/persona/retire")
|
||||
@limiter.limit("20/minute")
|
||||
async def api_wormhole_gate_persona_retire(
|
||||
request: Request, body: WormholeGatePersonaActivateRequest
|
||||
@@ -1195,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", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/message/sign-encrypted")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_message_sign_encrypted(
|
||||
request: Request,
|
||||
@@ -1205,7 +954,7 @@ async def api_wormhole_gate_message_sign_encrypted(
|
||||
return await _m.api_wormhole_gate_message_sign_encrypted(request, body)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/gate/message/post-encrypted", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/message/post-encrypted")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_message_post_encrypted(
|
||||
request: Request,
|
||||
@@ -1255,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", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/state/export")
|
||||
@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", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/wormhole/gate/proof")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
|
||||
proof = _sign_gate_access_proof(str(body.gate_id or ""))
|
||||
@@ -1547,7 +1296,7 @@ class PrivateDeliveryActionRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("/api/wormhole/status")
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit("240/minute")
|
||||
async def api_wormhole_status(request: Request):
|
||||
import main as _m
|
||||
|
||||
@@ -1590,7 +1339,7 @@ async def api_wormhole_private_delivery_action(
|
||||
|
||||
|
||||
@router.get("/api/wormhole/health")
|
||||
@limiter.limit("30/minute")
|
||||
@limiter.limit("240/minute")
|
||||
async def api_wormhole_health(request: Request):
|
||||
state = get_wormhole_state()
|
||||
transport_tier = _current_private_lane_tier(state)
|
||||
@@ -1611,7 +1360,7 @@ async def api_wormhole_health(request: Request):
|
||||
return _redact_wormhole_status(full_state, authenticated=ok)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/connect", dependencies=[Depends(require_admin)])
|
||||
@router.post("/api/wormhole/connect")
|
||||
@limiter.limit("10/minute")
|
||||
async def api_wormhole_connect(request: Request):
|
||||
settings = read_wormhole_settings()
|
||||
|
||||
@@ -32,7 +32,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
|
||||
_LIST_TIMEOUT_S = 30
|
||||
_DOWNLOAD_TIMEOUT_S = 600
|
||||
_USER_AGENT = (
|
||||
"ShadowBroker-OSINT/0.9.75 "
|
||||
"ShadowBroker-OSINT/0.9.79 "
|
||||
"(+https://github.com/BigBodyCobain/Shadowbroker; "
|
||||
"contact: bigbodycobain@gmail.com)"
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ def fetch_meshtastic_nodes():
|
||||
callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip()
|
||||
except Exception:
|
||||
callsign = ""
|
||||
ua_base = "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)"
|
||||
ua_base = "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)"
|
||||
user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base
|
||||
|
||||
try:
|
||||
|
||||
@@ -25,7 +25,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
|
||||
_HTTP_TIMEOUT_S = 60
|
||||
|
||||
_USER_AGENT = (
|
||||
"ShadowBroker-OSINT/0.9.75 "
|
||||
"ShadowBroker-OSINT/0.9.79 "
|
||||
"(+https://github.com/BigBodyCobain/Shadowbroker; "
|
||||
"contact: bigbodycobain@gmail.com)"
|
||||
)
|
||||
|
||||
@@ -1264,6 +1264,21 @@ class DMRelay:
|
||||
)
|
||||
self._save()
|
||||
|
||||
def unregister_prekey_lookup_alias(self, alias: str) -> bool:
|
||||
"""Remove an invite-scoped lookup alias from the local relay."""
|
||||
handle = str(alias or "").strip()
|
||||
if not handle:
|
||||
return False
|
||||
removed = False
|
||||
with self._lock:
|
||||
self._refresh_from_shared_relay()
|
||||
if handle in self._prekey_lookup_aliases:
|
||||
del self._prekey_lookup_aliases[handle]
|
||||
removed = True
|
||||
if removed:
|
||||
self._save()
|
||||
return removed
|
||||
|
||||
def consume_one_time_prekey(self, agent_id: str) -> dict[str, Any] | None:
|
||||
"""Atomically claim the next published one-time prekey for a peer bundle."""
|
||||
claimed: dict[str, Any] | None = None
|
||||
|
||||
@@ -520,7 +520,7 @@ class MeshtasticTransport:
|
||||
|
||||
def _on_connect(client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
info = client.publish(topic, payload, qos=0)
|
||||
info = client.publish(topic, payload, qos=1)
|
||||
info.wait_for_publish(timeout=5)
|
||||
published[0] = True
|
||||
client.disconnect()
|
||||
@@ -550,9 +550,9 @@ class MeshtasticTransport:
|
||||
True,
|
||||
self.NAME,
|
||||
(
|
||||
f"Published direct to !{to_node:08x} via {region}/{channel}"
|
||||
f"Broker accepted direct publish to !{to_node:08x} via {region}/{channel}"
|
||||
if direct_node is not None
|
||||
else f"Published to {region}/{channel} ({len(payload)}B protobuf)"
|
||||
else f"Broker accepted channel publish to {region}/{channel} ({len(payload)}B protobuf)"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -11,6 +11,7 @@ import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -51,6 +52,8 @@ PREKEY_LOOKUP_ROTATE_BEFORE_REMAINING_USES = 8
|
||||
PREKEY_LOOKUP_ROTATION_OVERLAP_S = 12 * 60 * 60
|
||||
PREKEY_LOOKUP_ROTATION_ACTIVE_CAP = 4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_int(val, default=0) -> int:
|
||||
try:
|
||||
@@ -107,6 +110,7 @@ def _default_identity() -> dict[str, Any]:
|
||||
def _prekey_lookup_handle_record(
|
||||
handle: str,
|
||||
*,
|
||||
label: str = "",
|
||||
issued_at: int = 0,
|
||||
expires_at: int = 0,
|
||||
max_uses: int = 0,
|
||||
@@ -125,6 +129,7 @@ def _prekey_lookup_handle_record(
|
||||
bounded_max_uses = max(1, _safe_int(max_uses or PREKEY_LOOKUP_HANDLE_MAX_USES, PREKEY_LOOKUP_HANDLE_MAX_USES))
|
||||
return {
|
||||
"handle": str(handle or "").strip(),
|
||||
"label": str(label or "").strip()[:96],
|
||||
"issued_at": issued,
|
||||
"expires_at": bounded_expires_at,
|
||||
"max_uses": bounded_max_uses,
|
||||
@@ -152,8 +157,10 @@ def _coerce_prekey_lookup_handle_record(
|
||||
max_uses = _safe_int(value.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES)
|
||||
use_count = _safe_int(value.get("use_count", value.get("uses", 0)) or 0, 0)
|
||||
last_used_at = _safe_int(value.get("last_used_at", value.get("last_used", 0)) or 0, 0)
|
||||
label = str(value.get("label", "") or "").strip()
|
||||
return _prekey_lookup_handle_record(
|
||||
handle,
|
||||
label=label,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
max_uses=max_uses,
|
||||
@@ -228,6 +235,23 @@ def _fresh_prekey_lookup_handle_record(*, now: int | None = None) -> dict[str, A
|
||||
)
|
||||
|
||||
|
||||
def _prekey_registration_failure_blocks_dm_invite(detail: str) -> bool:
|
||||
"""Only trust-root failures block address export; transport warm-up can finish later."""
|
||||
lowered = str(detail or "").lower()
|
||||
critical_markers = (
|
||||
"root transparency",
|
||||
"external root witness",
|
||||
"stable root",
|
||||
"witness threshold",
|
||||
"witness finality",
|
||||
"root manifest",
|
||||
"root witness",
|
||||
"manifest_fingerprint",
|
||||
"policy fingerprint",
|
||||
)
|
||||
return any(marker in lowered for marker in critical_markers)
|
||||
|
||||
|
||||
def _bounded_lookup_handle_records(
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
@@ -884,6 +908,7 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
|
||||
existing_handles.append(
|
||||
_prekey_lookup_handle_record(
|
||||
lookup_handle,
|
||||
label=str(label or "").strip(),
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
@@ -920,14 +945,25 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prekey_registration: dict[str, Any] = {"ok": False, "detail": "prekey bundle publish not attempted"}
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle
|
||||
|
||||
registered = register_wormhole_prekey_bundle()
|
||||
if not registered.get("ok"):
|
||||
return {"ok": False, "detail": str(registered.get("detail", "") or "prekey bundle registration failed")}
|
||||
prekey_registration = register_wormhole_prekey_bundle()
|
||||
if not prekey_registration.get("ok"):
|
||||
detail = str(prekey_registration.get("detail", "") or "prekey bundle registration failed")
|
||||
if _prekey_registration_failure_blocks_dm_invite(detail):
|
||||
return {"ok": False, "detail": detail}
|
||||
logger.warning(
|
||||
"DM invite prekey publish pending: %s",
|
||||
detail,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or "prekey bundle registration failed"}
|
||||
prekey_registration = {"ok": False, "detail": str(exc) or "prekey bundle registration failed"}
|
||||
detail = str(prekey_registration.get("detail", "") or "")
|
||||
if _prekey_registration_failure_blocks_dm_invite(detail):
|
||||
return {"ok": False, "detail": detail}
|
||||
logger.warning("DM invite prekey publish pending: %s", prekey_registration["detail"])
|
||||
|
||||
invite_node_id, invite_public_key, invite_private_key = _generate_invite_signing_identity()
|
||||
payload = _attach_dm_invite_root_distribution(payload)
|
||||
@@ -958,6 +994,8 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
|
||||
"peer_id": str(invite_node_id or ""),
|
||||
"trust_fingerprint": str(payload.get("identity_commitment", "") or ""),
|
||||
"invite": invite,
|
||||
"prekey_publish_pending": not bool(prekey_registration.get("ok")),
|
||||
"prekey_registration": prekey_registration,
|
||||
}
|
||||
|
||||
|
||||
@@ -980,6 +1018,140 @@ def get_prekey_lookup_handle_records() -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def list_prekey_lookup_handle_records_for_ui(*, now: int | None = None) -> dict[str, Any]:
|
||||
"""Return shareable DM address records without exposing local identity secrets."""
|
||||
current_time = _safe_int(now or time.time(), int(time.time()))
|
||||
addresses: list[dict[str, Any]] = []
|
||||
for record in get_prekey_lookup_handle_records():
|
||||
handle = str(record.get("handle", "") or "").strip()
|
||||
if not handle:
|
||||
continue
|
||||
expires_at = _effective_prekey_lookup_handle_expires_at(record)
|
||||
max_uses = max(
|
||||
1,
|
||||
_safe_int(
|
||||
record.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES,
|
||||
PREKEY_LOOKUP_HANDLE_MAX_USES,
|
||||
),
|
||||
)
|
||||
use_count = max(0, _safe_int(record.get("use_count", 0) or 0, 0))
|
||||
addresses.append(
|
||||
{
|
||||
"handle": handle,
|
||||
"label": str(record.get("label", "") or "").strip(),
|
||||
"issued_at": _safe_int(record.get("issued_at", 0) or 0, 0),
|
||||
"expires_at": expires_at,
|
||||
"max_uses": max_uses,
|
||||
"use_count": use_count,
|
||||
"remaining_uses": max(0, max_uses - use_count),
|
||||
"last_used_at": _safe_int(record.get("last_used_at", 0) or 0, 0),
|
||||
"expired": bool(expires_at > 0 and current_time >= expires_at),
|
||||
"exhausted": bool(use_count >= max_uses),
|
||||
}
|
||||
)
|
||||
addresses.sort(key=lambda item: _safe_int(item.get("issued_at", 0) or 0, 0), reverse=True)
|
||||
return {"ok": True, "addresses": addresses}
|
||||
|
||||
|
||||
def rename_prekey_lookup_handle(handle: str, label: str) -> dict[str, Any]:
|
||||
"""Rename an active invite-scoped DM lookup handle without changing the handle."""
|
||||
lookup_handle = str(handle or "").strip()
|
||||
next_label = str(label or "").strip()[:96]
|
||||
if not lookup_handle:
|
||||
return {"ok": False, "detail": "missing_lookup_handle"}
|
||||
|
||||
current_time = int(time.time())
|
||||
data = read_wormhole_identity()
|
||||
existing, _ = _normalize_prekey_lookup_handles(
|
||||
data.get("prekey_lookup_handles", []),
|
||||
fallback_issued_at=current_time,
|
||||
now=current_time,
|
||||
)
|
||||
updated = False
|
||||
next_records: list[dict[str, Any]] = []
|
||||
for record in existing:
|
||||
current = dict(record)
|
||||
if str(current.get("handle", "") or "").strip() == lookup_handle:
|
||||
current["label"] = next_label
|
||||
updated = True
|
||||
next_records.append(current)
|
||||
|
||||
if not updated:
|
||||
return {
|
||||
"ok": False,
|
||||
"handle": lookup_handle,
|
||||
"label": next_label,
|
||||
"updated": False,
|
||||
"detail": "lookup_handle_not_found",
|
||||
}
|
||||
|
||||
normalized_records, _ = _normalize_prekey_lookup_handles(
|
||||
next_records,
|
||||
fallback_issued_at=current_time,
|
||||
now=current_time,
|
||||
)
|
||||
_write_identity({"prekey_lookup_handles": normalized_records})
|
||||
return {
|
||||
"ok": True,
|
||||
"handle": lookup_handle,
|
||||
"label": next_label,
|
||||
"updated": True,
|
||||
}
|
||||
|
||||
|
||||
def revoke_prekey_lookup_handle(handle: str) -> dict[str, Any]:
|
||||
"""Revoke an invite-scoped DM lookup handle for future first-contact attempts."""
|
||||
lookup_handle = str(handle or "").strip()
|
||||
if not lookup_handle:
|
||||
return {"ok": False, "detail": "missing_lookup_handle"}
|
||||
current_time = int(time.time())
|
||||
data = read_wormhole_identity()
|
||||
existing, _ = _normalize_prekey_lookup_handles(
|
||||
data.get("prekey_lookup_handles", []),
|
||||
fallback_issued_at=current_time,
|
||||
now=current_time,
|
||||
)
|
||||
next_records = [
|
||||
dict(record)
|
||||
for record in existing
|
||||
if str(record.get("handle", "") or "").strip() != lookup_handle
|
||||
]
|
||||
identity_removed = len(next_records) != len(existing)
|
||||
if identity_removed:
|
||||
_write_identity({"prekey_lookup_handles": next_records})
|
||||
|
||||
relay_removed = False
|
||||
try:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
relay_removed = bool(dm_relay.unregister_prekey_lookup_alias(lookup_handle))
|
||||
except Exception:
|
||||
relay_removed = False
|
||||
|
||||
republished = False
|
||||
detail = ""
|
||||
if identity_removed:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle
|
||||
|
||||
registered = register_wormhole_prekey_bundle()
|
||||
republished = bool(registered.get("ok"))
|
||||
if not republished:
|
||||
detail = str(registered.get("detail", "") or "prekey bundle republish failed")
|
||||
except Exception as exc:
|
||||
detail = str(exc) or "prekey bundle republish failed"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"handle": lookup_handle,
|
||||
"revoked": bool(identity_removed or relay_removed),
|
||||
"identity_removed": identity_removed,
|
||||
"relay_removed": relay_removed,
|
||||
"republished": republished,
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
def record_prekey_lookup_handle_use(handle: str, *, now: int | None = None) -> dict[str, Any] | None:
|
||||
lookup_handle = str(handle or "").strip()
|
||||
if not lookup_handle:
|
||||
@@ -999,6 +1171,7 @@ def record_prekey_lookup_handle_use(handle: str, *, now: int | None = None) -> d
|
||||
if str(current.get("handle", "") or "").strip() == lookup_handle:
|
||||
current = _prekey_lookup_handle_record(
|
||||
lookup_handle,
|
||||
label=str(current.get("label", "") or "").strip(),
|
||||
issued_at=_safe_int(current.get("issued_at", 0) or 0, current_time),
|
||||
expires_at=_safe_int(current.get("expires_at", 0) or 0, 0),
|
||||
max_uses=_safe_int(current.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES),
|
||||
@@ -1129,6 +1302,7 @@ def maybe_rotate_prekey_lookup_handles(*, now: int | None = None) -> dict[str, A
|
||||
candidate_records.append(
|
||||
_prekey_lookup_handle_record(
|
||||
old_handle,
|
||||
label=str(record.get("label", "") or "").strip(),
|
||||
issued_at=_safe_int(record.get("issued_at", 0) or 0, current_time),
|
||||
expires_at=overlap_expires_at,
|
||||
max_uses=_safe_int(record.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES),
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -23,7 +24,7 @@ from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from services.mesh.mesh_crypto import build_signature_payload, derive_node_id, verify_node_binding, verify_signature
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json
|
||||
from services.mesh.mesh_secure_storage import SecureStorageError, read_domain_json, write_domain_json
|
||||
from services.mesh.mesh_wormhole_identity import root_identity_fingerprint_for_material
|
||||
from services.mesh.mesh_wormhole_persona import (
|
||||
bootstrap_wormhole_persona_state,
|
||||
@@ -51,6 +52,7 @@ DEFAULT_ROOT_WITNESS_THRESHOLD = 2
|
||||
DEFAULT_ROOT_WITNESS_MANAGEMENT_SCOPE = "local"
|
||||
DEFAULT_ROOT_WITNESS_INDEPENDENCE_GROUP = "local_system"
|
||||
DEFAULT_ROOT_EXTERNAL_WITNESS_MAX_AGE_S = 3600
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_int(val: Any, default: int = 0) -> int:
|
||||
@@ -461,12 +463,22 @@ def witness_policy_fingerprint(policy: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def read_root_distribution_state() -> dict[str, Any]:
|
||||
raw = read_domain_json(
|
||||
ROOT_DISTRIBUTION_DOMAIN,
|
||||
ROOT_DISTRIBUTION_FILE,
|
||||
_default_state,
|
||||
base_dir=DATA_DIR,
|
||||
)
|
||||
try:
|
||||
raw = read_domain_json(
|
||||
ROOT_DISTRIBUTION_DOMAIN,
|
||||
ROOT_DISTRIBUTION_FILE,
|
||||
_default_state,
|
||||
base_dir=DATA_DIR,
|
||||
)
|
||||
except SecureStorageError as exc:
|
||||
detail = str(exc)
|
||||
if "Failed to decrypt domain JSON" not in detail:
|
||||
raise
|
||||
logger.warning(
|
||||
"Root distribution state could not decrypt; regenerating local witness distribution: %s",
|
||||
detail,
|
||||
)
|
||||
raw = _default_state()
|
||||
state = {**_default_state(), **dict(raw or {})}
|
||||
state["witness_identity"] = {**_empty_witness_identity(), **dict(state.get("witness_identity") or {})}
|
||||
witness_identities, witness_changed = _normalize_witness_identities(
|
||||
|
||||
@@ -108,8 +108,18 @@ def normalize_topic_filter(value: str) -> str | None:
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _default_topic_for_root(root: str) -> str:
|
||||
return f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#"
|
||||
def _default_topics_for_root(root: str) -> list[str]:
|
||||
"""Return the default LongFast subscriptions for a region root.
|
||||
|
||||
The public broker carries protobuf/encrypted traffic under ``/e/`` and
|
||||
companion decoded JSON traffic under ``/json/``. Positions often arrive on
|
||||
the protobuf path, while public text is commonly easiest to observe on the
|
||||
JSON path.
|
||||
"""
|
||||
return [
|
||||
f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#",
|
||||
f"msh/{root}/2/json/{DEFAULT_CHANNEL}/#",
|
||||
]
|
||||
|
||||
|
||||
def build_subscription_topics(
|
||||
@@ -124,7 +134,11 @@ def build_subscription_topics(
|
||||
# via MESH_MQTT_EXTRA_ROOTS to avoid flooding the public broker.
|
||||
roots.extend(root for root in (normalize_root(item) for item in _split_config_values(extra_roots)) if root)
|
||||
|
||||
topics = [_default_topic_for_root(root) for root in _dedupe(roots)]
|
||||
topics = [
|
||||
topic
|
||||
for root in _dedupe(roots)
|
||||
for topic in _default_topics_for_root(root)
|
||||
]
|
||||
topics.extend(
|
||||
topic
|
||||
for topic in (
|
||||
|
||||
@@ -73,7 +73,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
|
||||
both Python requests and the barebones Windows system curl.
|
||||
"""
|
||||
default_headers = {
|
||||
"User-Agent": "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)",
|
||||
"User-Agent": "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)",
|
||||
}
|
||||
if headers:
|
||||
default_headers.update(headers)
|
||||
|
||||
@@ -20,7 +20,7 @@ from cachetools import TTLCache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SHODAN_BASE = "https://api.shodan.io"
|
||||
_USER_AGENT = "ShadowBroker/0.9.75 local Shodan connector"
|
||||
_USER_AGENT = "ShadowBroker/0.9.79 local Shodan connector"
|
||||
_REQUEST_TIMEOUT = 15
|
||||
_MIN_INTERVAL_SECONDS = 1.05 # Shodan docs say API plans are rate limited to ~1 req/sec.
|
||||
_DEFAULT_SEARCH_PAGES = 1
|
||||
|
||||
@@ -545,6 +545,198 @@ class MeshtasticBridge:
|
||||
self._message_dedupe[key] = now
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _message_dedupe_key(message: dict) -> str:
|
||||
sender = str(message.get("from") or "???").strip().lower()
|
||||
recipient = str(message.get("to") or "broadcast").strip().lower()
|
||||
text = str(message.get("text") or "").strip()
|
||||
channel = str(message.get("channel") or "LongFast").strip().lower()
|
||||
root = str(message.get("root") or message.get("region") or "").strip().lower()
|
||||
if root == "us":
|
||||
root = "us"
|
||||
return f"{sender}:{recipient}:{root}:{channel}:{text}"
|
||||
|
||||
def append_text_message(self, message: dict, *, dedupe_window_s: float = 5.0) -> bool:
|
||||
"""Append a Meshtastic text message unless it is a near-immediate echo."""
|
||||
if not str(message.get("text") or "").strip():
|
||||
return False
|
||||
now = time.time()
|
||||
cutoff = now - max(1.0, dedupe_window_s)
|
||||
next_message = dict(message)
|
||||
next_message.setdefault("to", "broadcast")
|
||||
next_message.setdefault("channel", "LongFast")
|
||||
next_message.setdefault("timestamp", datetime.utcnow().isoformat() + "Z")
|
||||
key = self._message_dedupe_key(next_message)
|
||||
for existing in list(self.messages)[:40]:
|
||||
if self._message_dedupe_key(existing) != key:
|
||||
continue
|
||||
try:
|
||||
existing_ts_raw = existing.get("timestamp")
|
||||
existing_ts = (
|
||||
datetime.fromisoformat(str(existing_ts_raw).replace("Z", "+00:00")).timestamp()
|
||||
if existing_ts_raw
|
||||
else now
|
||||
)
|
||||
except Exception:
|
||||
existing_ts = now
|
||||
if existing_ts >= cutoff:
|
||||
if not existing.get("root") and next_message.get("root"):
|
||||
existing["root"] = next_message.get("root")
|
||||
if not existing.get("region") and next_message.get("region"):
|
||||
existing["region"] = next_message.get("region")
|
||||
return False
|
||||
self.messages.appendleft(next_message)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _coerce_node_ref(value) -> str:
|
||||
"""Normalize Meshtastic node identifiers into the public !xxxxxxxx form."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, int):
|
||||
return f"!{value & 0xFFFFFFFF:08x}"
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw.startswith("!"):
|
||||
return raw
|
||||
lowered = raw.lower()
|
||||
if lowered.startswith("0x"):
|
||||
try:
|
||||
return f"!{int(lowered, 16) & 0xFFFFFFFF:08x}"
|
||||
except ValueError:
|
||||
return raw
|
||||
if raw.isdigit():
|
||||
try:
|
||||
return f"!{int(raw) & 0xFFFFFFFF:08x}"
|
||||
except ValueError:
|
||||
return raw
|
||||
if len(raw) == 8 and all(ch in "0123456789abcdefABCDEF" for ch in raw):
|
||||
return f"!{raw.lower()}"
|
||||
return raw
|
||||
|
||||
@staticmethod
|
||||
def _first_text_value(*values) -> str:
|
||||
for value in values:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", errors="replace")
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if text:
|
||||
return MeshtasticBridge._repair_text_mojibake(text)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _repair_text_mojibake(text: str) -> str:
|
||||
"""Repair common UTF-8-as-Latin-1 mojibake from MQTT JSON bridges."""
|
||||
if not text or not any(marker in text for marker in ("Ã", "Ð", "Ñ")):
|
||||
return text
|
||||
try:
|
||||
repaired = text.encode("latin-1").decode("utf-8").strip()
|
||||
except UnicodeError:
|
||||
return text
|
||||
if repaired and repaired != text:
|
||||
return repaired
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _first_present(*values):
|
||||
for value in values:
|
||||
if value is not None and value != "":
|
||||
return value
|
||||
return None
|
||||
|
||||
def _extract_json_text_message(self, data: dict, topic: str) -> dict | None:
|
||||
"""Extract a public Meshtastic text event from decoded MQTT JSON.
|
||||
|
||||
Meshtastic JSON brokers are not perfectly uniform. Some packets expose
|
||||
text at the top level, some under ``decoded`` or ``payload``. Keep this
|
||||
permissive for receive, but only return messages with non-empty text.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
topic_meta = parse_topic_metadata(topic)
|
||||
packet = data.get("packet") if isinstance(data.get("packet"), dict) else {}
|
||||
decoded = data.get("decoded") if isinstance(data.get("decoded"), dict) else {}
|
||||
payload_obj = data.get("payload")
|
||||
payload = payload_obj if isinstance(payload_obj, dict) else {}
|
||||
decoded_payload_obj = decoded.get("payload") if decoded else None
|
||||
decoded_payload = decoded_payload_obj if isinstance(decoded_payload_obj, dict) else {}
|
||||
|
||||
text = self._first_text_value(
|
||||
data.get("text"),
|
||||
data.get("message"),
|
||||
data.get("msg"),
|
||||
payload_obj if isinstance(payload_obj, str) else "",
|
||||
payload.get("text"),
|
||||
payload.get("message"),
|
||||
payload.get("msg"),
|
||||
payload.get("payload") if isinstance(payload.get("payload"), str) else "",
|
||||
decoded.get("text"),
|
||||
decoded.get("message"),
|
||||
decoded.get("payload") if isinstance(decoded.get("payload"), str) else "",
|
||||
decoded_payload.get("text"),
|
||||
decoded_payload.get("message"),
|
||||
decoded_payload.get("msg"),
|
||||
)
|
||||
if not text:
|
||||
return None
|
||||
|
||||
sender = self._coerce_node_ref(
|
||||
self._first_present(
|
||||
data.get("from"),
|
||||
data.get("fromId"),
|
||||
data.get("from_id"),
|
||||
data.get("sender"),
|
||||
data.get("senderId"),
|
||||
data.get("sender_id"),
|
||||
packet.get("from"),
|
||||
packet.get("fromId"),
|
||||
packet.get("from_id"),
|
||||
decoded.get("from"),
|
||||
)
|
||||
)
|
||||
recipient = self._coerce_node_ref(
|
||||
self._first_present(
|
||||
data.get("to"),
|
||||
data.get("toId"),
|
||||
data.get("to_id"),
|
||||
data.get("recipient"),
|
||||
data.get("recipientId"),
|
||||
data.get("recipient_id"),
|
||||
packet.get("to"),
|
||||
packet.get("toId"),
|
||||
packet.get("to_id"),
|
||||
decoded.get("to"),
|
||||
)
|
||||
)
|
||||
if not recipient or recipient in {"!ffffffff", "broadcast"}:
|
||||
recipient = "broadcast"
|
||||
|
||||
timestamp = datetime.utcnow().isoformat() + "Z"
|
||||
rx_time = self._first_present(
|
||||
data.get("rxTime"),
|
||||
data.get("rx_time"),
|
||||
data.get("timestamp"),
|
||||
packet.get("rxTime"),
|
||||
packet.get("timestamp"),
|
||||
)
|
||||
if isinstance(rx_time, (int, float)) and rx_time > 0:
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(float(rx_time), tz=timezone.utc).isoformat()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"from": sender or topic.split("/")[-1],
|
||||
"to": recipient,
|
||||
"text": text[:500],
|
||||
"region": topic_meta["region"],
|
||||
"root": topic_meta["root"],
|
||||
"channel": topic_meta["channel"],
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
def start(self):
|
||||
if self._thread and self._thread.is_alive():
|
||||
if not self._stop.is_set():
|
||||
@@ -693,6 +885,9 @@ class MeshtasticBridge:
|
||||
if "/json/" in topic:
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
text_message = self._extract_json_text_message(data, topic)
|
||||
if text_message:
|
||||
self.append_text_message(text_message, dedupe_window_s=30.0)
|
||||
if self._rate_limited():
|
||||
return
|
||||
self._ingest_data(data, topic)
|
||||
@@ -715,7 +910,7 @@ class MeshtasticBridge:
|
||||
topic_meta["root"],
|
||||
):
|
||||
return
|
||||
self.messages.appendleft(
|
||||
self.append_text_message(
|
||||
{
|
||||
"from": data.get("from", "???"),
|
||||
"to": recipient,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def disable_public_mesh_lane(*, reason: str = "private_lane_enabled") -> dict[str, Any]:
|
||||
"""Disable public Meshtastic MQTT before private Wormhole/Infonet starts."""
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"reason": reason,
|
||||
"settings_disabled": False,
|
||||
"runtime_stopped": False,
|
||||
}
|
||||
|
||||
# Scheduled Wormhole prewarm must not mutate the user's explicit public
|
||||
# MeshChat session. Only a deliberate private-lane activation should sever
|
||||
# the public MQTT lane.
|
||||
normalized_reason = str(reason or "").strip().lower()
|
||||
if normalized_reason == "wormhole_scheduled_prewarm" or normalized_reason.endswith(":scheduled_prewarm"):
|
||||
try:
|
||||
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
|
||||
|
||||
if mqtt_bridge_enabled():
|
||||
logger.info("Keeping public Mesh lane active during Wormhole prewarm: %s", reason)
|
||||
result["skipped"] = True
|
||||
result["skip_reason"] = "public_mesh_user_enabled"
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.debug("Could not inspect public Mesh state during %s: %s", reason, exc)
|
||||
|
||||
logger.info("Disabling public Mesh lane: %s", reason)
|
||||
|
||||
try:
|
||||
from services.meshtastic_mqtt_settings import write_meshtastic_mqtt_settings
|
||||
|
||||
settings = write_meshtastic_mqtt_settings(enabled=False)
|
||||
result["settings_disabled"] = not bool(settings.get("enabled"))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to disable public Mesh settings during %s: %s", reason, exc)
|
||||
result["ok"] = False
|
||||
result["settings_error"] = str(exc)
|
||||
|
||||
try:
|
||||
from services.sigint_bridge import sigint_grid
|
||||
|
||||
if sigint_grid.mesh.is_running():
|
||||
sigint_grid.mesh.stop()
|
||||
result["runtime_stopped"] = not sigint_grid.mesh.is_running()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop public Mesh runtime during %s: %s", reason, exc)
|
||||
result["ok"] = False
|
||||
result["runtime_error"] = str(exc)
|
||||
|
||||
return result
|
||||
@@ -24,7 +24,7 @@ from cachetools import TTLCache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FINNHUB_BASE = "https://finnhub.io/api/v1"
|
||||
_USER_AGENT = "ShadowBroker/0.9.75 Finnhub connector"
|
||||
_USER_AGENT = "ShadowBroker/0.9.79 Finnhub connector"
|
||||
_REQUEST_TIMEOUT = 12
|
||||
_MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min
|
||||
|
||||
|
||||
@@ -243,6 +243,48 @@ def _pid_alive(pid: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _find_wormhole_server_pid() -> int:
|
||||
if os.name == "nt":
|
||||
return 0
|
||||
proc_dir = Path("/proc")
|
||||
if not proc_dir.exists():
|
||||
return 0
|
||||
current_pid = os.getpid()
|
||||
script_name = WORMHOLE_SCRIPT.name
|
||||
script_path = str(WORMHOLE_SCRIPT)
|
||||
for entry in proc_dir.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
pid = int(entry.name)
|
||||
if pid == current_pid:
|
||||
continue
|
||||
try:
|
||||
raw = (entry / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace")
|
||||
if script_path in cmdline or script_name in cmdline:
|
||||
return pid
|
||||
return 0
|
||||
|
||||
|
||||
def _terminate_pid(pid: int, *, timeout_s: float = 5.0) -> None:
|
||||
if os.name == "nt" or pid <= 0:
|
||||
return
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
return
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline and _pid_alive(pid):
|
||||
time.sleep(0.1)
|
||||
if _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _probe_ready(timeout_s: float = 1.5) -> bool:
|
||||
try:
|
||||
with urlopen(f"http://{WORMHOLE_HOST}:{WORMHOLE_PORT}/api/health", timeout=timeout_s) as resp:
|
||||
@@ -266,17 +308,32 @@ def _probe_json(path: str, timeout_s: float = 1.5) -> dict[str, Any] | None:
|
||||
def _current_runtime_state() -> dict[str, Any]:
|
||||
settings = read_wormhole_settings()
|
||||
status = read_wormhole_status()
|
||||
configured = bool(settings.get("enabled"))
|
||||
running = False
|
||||
ready = False
|
||||
pid = int(status.get("pid", 0) or 0)
|
||||
if _PROCESS and _PROCESS.poll() is None:
|
||||
if not configured:
|
||||
# Disabled private transport must stay disabled even if a stale local
|
||||
# wormhole process is still answering on the health port. Public
|
||||
# MeshChat relies on this state to keep the MQTT and Wormhole lanes
|
||||
# mutually exclusive.
|
||||
pid = 0
|
||||
ready = False
|
||||
elif _PROCESS and _PROCESS.poll() is None:
|
||||
running = True
|
||||
pid = int(_PROCESS.pid or 0)
|
||||
elif _pid_alive(pid):
|
||||
running = True
|
||||
elif _probe_ready(timeout_s=0.35):
|
||||
running = True
|
||||
pid = 0
|
||||
ready = running and _probe_ready()
|
||||
else:
|
||||
if _pid_alive(pid):
|
||||
running = True
|
||||
else:
|
||||
discovered_pid = _find_wormhole_server_pid()
|
||||
if discovered_pid > 0:
|
||||
running = True
|
||||
pid = discovered_pid
|
||||
if not running and _probe_ready(timeout_s=0.35):
|
||||
running = True
|
||||
pid = 0
|
||||
ready = running and _probe_ready()
|
||||
if not running:
|
||||
pid = 0
|
||||
transport_active = status.get("transport_active", "") if ready else ""
|
||||
@@ -319,13 +376,13 @@ def _current_runtime_state() -> dict[str, Any]:
|
||||
anonymous_mode = bool(settings.get("anonymous_mode"))
|
||||
anonymous_mode_ready = bool(
|
||||
anonymous_mode
|
||||
and settings.get("enabled")
|
||||
and configured
|
||||
and ready
|
||||
and effective_transport in {"tor", "tor_arti", "i2p", "mixnet"}
|
||||
)
|
||||
snapshot = {
|
||||
"installed": _installed(),
|
||||
"configured": bool(settings.get("enabled")),
|
||||
"configured": configured,
|
||||
"running": running,
|
||||
"ready": ready,
|
||||
"transport_configured": str(settings.get("transport", "direct") or "direct"),
|
||||
@@ -395,6 +452,12 @@ def get_wormhole_state() -> dict[str, Any]:
|
||||
def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
_invalidate_state_cache()
|
||||
try:
|
||||
from services.transport_lane_isolation import disable_public_mesh_lane
|
||||
|
||||
disable_public_mesh_lane(reason=f"wormhole_{reason}")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to enforce public/private lane isolation during %s: %s", reason, exc)
|
||||
settings = read_wormhole_settings()
|
||||
if not settings.get("enabled"):
|
||||
settings = settings.copy()
|
||||
@@ -487,8 +550,8 @@ def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]:
|
||||
def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
_invalidate_state_cache()
|
||||
current = _current_runtime_state()
|
||||
pid = int(current.get("pid", 0) or 0)
|
||||
status = read_wormhole_status()
|
||||
pid = int(status.get("pid", 0) or 0)
|
||||
global _PROCESS
|
||||
if _PROCESS and _PROCESS.poll() is None:
|
||||
try:
|
||||
@@ -499,14 +562,15 @@ def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]:
|
||||
_PROCESS.kill()
|
||||
except Exception:
|
||||
pass
|
||||
elif os.name != "nt" and _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
if os.name != "nt":
|
||||
_terminate_pid(pid)
|
||||
discovered_pid = _find_wormhole_server_pid()
|
||||
if discovered_pid > 0 and discovered_pid != pid:
|
||||
_terminate_pid(discovered_pid)
|
||||
_PROCESS = None
|
||||
write_wormhole_status(
|
||||
reason=reason,
|
||||
configured=False,
|
||||
running=False,
|
||||
ready=False,
|
||||
pid=0,
|
||||
|
||||
@@ -2,15 +2,21 @@ from services.mesh.meshtastic_topics import build_subscription_topics, known_roo
|
||||
|
||||
|
||||
def test_default_subscription_is_longfast_only():
|
||||
assert build_subscription_topics() == ["msh/US/2/e/LongFast/#"]
|
||||
assert build_subscription_topics() == [
|
||||
"msh/US/2/e/LongFast/#",
|
||||
"msh/US/2/json/LongFast/#",
|
||||
]
|
||||
assert known_roots() == ["US"]
|
||||
|
||||
|
||||
def test_extra_roots_are_longfast_only():
|
||||
assert build_subscription_topics(extra_roots="EU_868,ANZ") == [
|
||||
"msh/US/2/e/LongFast/#",
|
||||
"msh/US/2/json/LongFast/#",
|
||||
"msh/EU_868/2/e/LongFast/#",
|
||||
"msh/EU_868/2/json/LongFast/#",
|
||||
"msh/ANZ/2/e/LongFast/#",
|
||||
"msh/ANZ/2/json/LongFast/#",
|
||||
]
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shadowbroker/desktop-shell",
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"private": true,
|
||||
"description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -4201,7 +4201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shadowbroker-tauri-shell"
|
||||
version = "0.9.75"
|
||||
version = "0.9.79"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shadowbroker-tauri-shell"
|
||||
version = "0.9.75"
|
||||
version = "0.9.79"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShadowBroker",
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"identifier": "com.shadowbroker.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/out",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.75",
|
||||
"version": "0.9.79",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-all.cjs",
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
} from '@/lib/updateRuntime';
|
||||
|
||||
const RELEASE: GitHubLatestRelease = {
|
||||
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.75',
|
||||
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.79',
|
||||
assets: [
|
||||
{ name: 'ShadowBroker_0.9.75_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
|
||||
{ name: 'ShadowBroker_0.9.75_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
|
||||
{ name: 'ShadowBroker_0.9.75_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
|
||||
{ name: 'ShadowBroker_0.9.75_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
|
||||
{ name: 'ShadowBroker_0.9.79_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
|
||||
{ name: 'ShadowBroker_0.9.79_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
|
||||
{ name: 'ShadowBroker_0.9.79_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
|
||||
{ name: 'ShadowBroker_0.9.79_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Heart,
|
||||
} from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.75';
|
||||
const CURRENT_VERSION = '0.9.79';
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
const RELEASE_TITLE = 'Onboarding, Live Feeds, Mesh, and Agent Hardening';
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
Ban,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Copy,
|
||||
Inbox,
|
||||
KeyRound,
|
||||
Mail,
|
||||
PencilLine,
|
||||
RefreshCcw,
|
||||
@@ -89,13 +91,18 @@ import {
|
||||
import {
|
||||
fetchWormholeStatus,
|
||||
fetchWormholeIdentity,
|
||||
exportWormholeDmInvite,
|
||||
getWormholeDmInviteImportErrorResult,
|
||||
importWormholeDmInvite,
|
||||
isWormholeReady,
|
||||
isWormholeSecureRequired,
|
||||
listWormholeDmInviteHandles,
|
||||
prepareWormholeInteractiveLane,
|
||||
issueWormholePairwiseAlias,
|
||||
openWormholeSenderSeal,
|
||||
renameWormholeDmInviteHandle,
|
||||
revokeWormholeDmInviteHandle,
|
||||
type WormholeDmAddressRecord,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
import {
|
||||
updatePrivateDeliveryAction,
|
||||
@@ -149,6 +156,23 @@ interface MailboxSnapshot {
|
||||
items: MailItem[];
|
||||
}
|
||||
|
||||
interface LocalDmAddress {
|
||||
id: string;
|
||||
label: string;
|
||||
handle: string;
|
||||
peerId: string;
|
||||
trustFingerprint: string;
|
||||
inviteBlob: string;
|
||||
createdAt: number;
|
||||
revokedAt?: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
interface LocalDmAddressSnapshot {
|
||||
version: 1;
|
||||
addresses: LocalDmAddress[];
|
||||
}
|
||||
|
||||
interface ComposeDraft {
|
||||
recipient: string;
|
||||
subject: string;
|
||||
@@ -179,6 +203,10 @@ function mailboxStorageKey(scopeId: string): string {
|
||||
return `sb_infonet_mailbox_v1:${scopeId}`;
|
||||
}
|
||||
|
||||
function dmAddressStorageKey(scopeId: string): string {
|
||||
return `sb_infonet_dm_addresses_v1:${scopeId}`;
|
||||
}
|
||||
|
||||
function sortMessages(items: MailItem[]): MailItem[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (b.timestamp !== a.timestamp) {
|
||||
@@ -253,6 +281,63 @@ function saveMailbox(scopeId: string, items: MailItem[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function loadDmAddresses(scopeId: string): LocalDmAddress[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(dmAddressStorageKey(scopeId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as LocalDmAddressSnapshot;
|
||||
if (parsed?.version !== STORAGE_VERSION || !Array.isArray(parsed.addresses)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.addresses
|
||||
.filter((item) => item && typeof item.handle === 'string' && item.handle.trim())
|
||||
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDmAddresses(scopeId: string, addresses: LocalDmAddress[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const payload: LocalDmAddressSnapshot = {
|
||||
version: STORAGE_VERSION,
|
||||
addresses: addresses.slice(0, 32),
|
||||
};
|
||||
localStorage.setItem(dmAddressStorageKey(scopeId), JSON.stringify(payload));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function inviteLookupHandle(invite: Record<string, unknown> | undefined): string {
|
||||
const payload = invite?.payload;
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return '';
|
||||
}
|
||||
return String((payload as Record<string, unknown>).prekey_lookup_handle || '').trim();
|
||||
}
|
||||
|
||||
function shortHandle(value: string): string {
|
||||
const clean = String(value || '').trim();
|
||||
if (clean.length <= 16) return clean;
|
||||
return `${clean.slice(0, 8)}...${clean.slice(-6)}`;
|
||||
}
|
||||
|
||||
function formatDmAddressDate(value?: number): string {
|
||||
if (!value) return 'never';
|
||||
try {
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function dmAddressShareText(address: LocalDmAddress): string {
|
||||
return String(address.handle || '').trim();
|
||||
}
|
||||
|
||||
function encodeMailPayload(subject: string, body: string): string {
|
||||
const cleanSubject = subject.trim() || 'Secure Message';
|
||||
return `${MAIL_SUBJECT_PREFIX}${cleanSubject}\n\n${body.trim()}`;
|
||||
@@ -545,13 +630,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
subject: '',
|
||||
body: '',
|
||||
});
|
||||
const [contactRequestTarget, setContactRequestTarget] = useState('');
|
||||
const [inviteImportAlias, setInviteImportAlias] = useState('');
|
||||
const [inviteImportBlob, setInviteImportBlob] = useState('');
|
||||
const [inviteBusy, setInviteBusy] = useState(false);
|
||||
const [inviteScanOpen, setInviteScanOpen] = useState(false);
|
||||
const [inviteScanStatus, setInviteScanStatus] = useState('');
|
||||
const [dmLaneWarmStatus, setDmLaneWarmStatus] = useState('');
|
||||
const [dmAddressLabel, setDmAddressLabel] = useState('');
|
||||
const [dmAddresses, setDmAddresses] = useState<LocalDmAddress[]>([]);
|
||||
const [remoteDmHandles, setRemoteDmHandles] = useState<Record<string, WormholeDmAddressRecord>>({});
|
||||
const [dmAddressEditLabels, setDmAddressEditLabels] = useState<Record<string, string>>({});
|
||||
const [dmAddressBusy, setDmAddressBusy] = useState('');
|
||||
const [dmAddressCopyStatus, setDmAddressCopyStatus] = useState('');
|
||||
const [, setDmLaneWarmStatus] = useState('');
|
||||
const [privateDelivery, setPrivateDelivery] = useState<PrivateDeliverySummary | null>(null);
|
||||
const [privateDeliveryBusyId, setPrivateDeliveryBusyId] = useState('');
|
||||
const inviteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -566,12 +656,17 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
|
||||
useEffect(() => {
|
||||
setMessages(loadMailbox(scopeId));
|
||||
setDmAddresses(loadDmAddresses(scopeId));
|
||||
}, [scopeId]);
|
||||
|
||||
useEffect(() => {
|
||||
saveMailbox(scopeId, sortMessages(messages));
|
||||
}, [messages, scopeId]);
|
||||
|
||||
useEffect(() => {
|
||||
saveDmAddresses(scopeId, dmAddresses);
|
||||
}, [dmAddresses, scopeId]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -651,6 +746,31 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
),
|
||||
[privateDelivery],
|
||||
);
|
||||
const activeDmAddresses = useMemo(
|
||||
() =>
|
||||
dmAddresses.filter((address) => {
|
||||
const server = remoteDmHandles[address.handle];
|
||||
return !address.revokedAt && !server?.expired && !server?.exhausted && !server?.revoked;
|
||||
}),
|
||||
[dmAddresses, remoteDmHandles],
|
||||
);
|
||||
const managedDmAddresses = useMemo(() => {
|
||||
const seen = new Set(dmAddresses.map((address) => address.handle));
|
||||
const serverOnly = Object.values(remoteDmHandles)
|
||||
.filter((address) => address.handle && !seen.has(address.handle))
|
||||
.map<LocalDmAddress>((address) => ({
|
||||
id: `remote-dm-address-${address.handle}`,
|
||||
label: address.label || 'DM address',
|
||||
handle: address.handle,
|
||||
peerId: '',
|
||||
trustFingerprint: '',
|
||||
inviteBlob: '',
|
||||
createdAt: address.issued_at || 0,
|
||||
expiresAt: address.expires_at || undefined,
|
||||
}));
|
||||
return [...dmAddresses, ...serverOnly].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
}, [dmAddresses, remoteDmHandles]);
|
||||
const primaryDmAddress = activeDmAddresses[0] || null;
|
||||
|
||||
const resolveMessagingIdentity = useCallback(async () => {
|
||||
const localIdentity = getNodeIdentity();
|
||||
@@ -1562,25 +1682,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
}, [contacts, dmLaneReady, draft, ensureSecureMailLane, identity, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState]);
|
||||
|
||||
const handleSendContactRequest = useCallback(async () => {
|
||||
const recipient = contactRequestTarget.trim();
|
||||
if (!recipient) {
|
||||
setComposeError('Enter an agent ID to send a contact request.');
|
||||
return;
|
||||
}
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
recipient,
|
||||
}));
|
||||
setActiveTab('compose');
|
||||
setComposeError('');
|
||||
setComposeStatus(
|
||||
requiresVerifiedFirstContact(contacts[recipient])
|
||||
? 'Signed invite import is required before first contact. Unverified TOFU contact requests are disabled.'
|
||||
: '',
|
||||
);
|
||||
}, [contactRequestTarget, contacts]);
|
||||
|
||||
const handleImportInvite = useCallback(async () => {
|
||||
const raw = inviteImportBlob.trim();
|
||||
if (!raw) {
|
||||
@@ -1643,6 +1744,174 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
}, [ensureSecureMailLane, inviteImportAlias, inviteImportBlob, wormholeReadyState]);
|
||||
|
||||
const refreshDmAddressHandles = useCallback(async () => {
|
||||
try {
|
||||
const result = await listWormholeDmInviteHandles();
|
||||
const next: Record<string, WormholeDmAddressRecord> = {};
|
||||
for (const address of result.addresses || []) {
|
||||
if (address.handle) {
|
||||
next[address.handle] = address;
|
||||
}
|
||||
}
|
||||
setRemoteDmHandles(next);
|
||||
} catch {
|
||||
/* best effort only */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity) return;
|
||||
void refreshDmAddressHandles();
|
||||
}, [identity, refreshDmAddressHandles]);
|
||||
|
||||
const handleGenerateDmAddress = useCallback(async () => {
|
||||
setDmAddressBusy('generate');
|
||||
setDmAddressCopyStatus('');
|
||||
setComposeError('');
|
||||
setComposeStatus('');
|
||||
try {
|
||||
const label = dmAddressLabel.trim() || `DM address ${new Date().toLocaleString()}`;
|
||||
const exported = await exportWormholeDmInvite({ label });
|
||||
if (!exported.ok || !exported.invite) {
|
||||
throw new Error(exported.detail || 'DM address generation failed');
|
||||
}
|
||||
const handle = inviteLookupHandle(exported.invite as unknown as Record<string, unknown>);
|
||||
if (!handle) {
|
||||
throw new Error('DM address did not include a lookup handle.');
|
||||
}
|
||||
const inviteBlob = JSON.stringify(
|
||||
{
|
||||
type: 'shadowbroker.infonet.dm.invite',
|
||||
version: 1,
|
||||
label,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
invite: exported.invite,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const record: LocalDmAddress = {
|
||||
id: randomId('dm-address'),
|
||||
label,
|
||||
handle,
|
||||
peerId: exported.peer_id,
|
||||
trustFingerprint: exported.trust_fingerprint,
|
||||
inviteBlob,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
expiresAt: Number((exported.invite.payload as Record<string, unknown>)?.expires_at || 0) || undefined,
|
||||
};
|
||||
setDmAddresses((prev) => [record, ...prev.filter((item) => item.handle !== handle)].slice(0, 32));
|
||||
setDmAddressLabel('');
|
||||
await navigator.clipboard?.writeText(handle).catch(() => undefined);
|
||||
setDmAddressCopyStatus(
|
||||
exported.prekey_publish_pending
|
||||
? `Generated and copied ${label} address. Private delivery will activate as soon as the lane finishes connecting.`
|
||||
: `Generated and copied ${label} address.`,
|
||||
);
|
||||
await refreshDmAddressHandles();
|
||||
} catch (error) {
|
||||
setComposeError(error instanceof Error ? error.message : 'DM address generation failed');
|
||||
} finally {
|
||||
setDmAddressBusy('');
|
||||
}
|
||||
}, [dmAddressLabel, refreshDmAddressHandles]);
|
||||
|
||||
const handleCopyDmAddress = useCallback(async (address: LocalDmAddress) => {
|
||||
setDmAddressBusy(`copy:${address.handle}`);
|
||||
setDmAddressCopyStatus('');
|
||||
try {
|
||||
const shareText = dmAddressShareText(address);
|
||||
if (!shareText) {
|
||||
throw new Error('This address has no local handle to copy.');
|
||||
}
|
||||
await navigator.clipboard?.writeText(shareText);
|
||||
setDmAddressCopyStatus(`Copied ${address.label || shortHandle(address.handle)}.`);
|
||||
} catch (error) {
|
||||
setDmAddressCopyStatus(
|
||||
error instanceof Error ? error.message : 'Copy failed. Select the address and copy it manually.',
|
||||
);
|
||||
} finally {
|
||||
setDmAddressBusy('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRevokeDmAddress = useCallback(
|
||||
async (address: LocalDmAddress) => {
|
||||
setDmAddressBusy(`revoke:${address.handle}`);
|
||||
setDmAddressCopyStatus('');
|
||||
try {
|
||||
const result = await revokeWormholeDmInviteHandle(address.handle);
|
||||
setDmAddresses((prev) =>
|
||||
prev.map((item) =>
|
||||
item.handle === address.handle
|
||||
? { ...item, inviteBlob: '', revokedAt: Math.floor(Date.now() / 1000) }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setDmAddressCopyStatus(
|
||||
result.revoked
|
||||
? `Revoked ${address.label || shortHandle(address.handle)} for new first-contact and removed the local share text.`
|
||||
: `${address.label || shortHandle(address.handle)} was already inactive.`,
|
||||
);
|
||||
await refreshDmAddressHandles();
|
||||
} catch (error) {
|
||||
setComposeError(error instanceof Error ? error.message : 'DM address revoke failed');
|
||||
} finally {
|
||||
setDmAddressBusy('');
|
||||
}
|
||||
},
|
||||
[refreshDmAddressHandles],
|
||||
);
|
||||
|
||||
const handleRenameDmAddress = useCallback(
|
||||
async (address: LocalDmAddress, nextLabel: string) => {
|
||||
const label = nextLabel.trim() || 'DM address';
|
||||
setDmAddressBusy(`rename:${address.handle}`);
|
||||
setDmAddressCopyStatus('');
|
||||
setComposeError('');
|
||||
try {
|
||||
const result = await renameWormholeDmInviteHandle(address.handle, label);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.detail || 'DM address label update failed');
|
||||
}
|
||||
setDmAddresses((prev) => {
|
||||
const found = prev.some((item) => item.handle === address.handle);
|
||||
const updated = prev.map((item) =>
|
||||
item.handle === address.handle ? { ...item, label } : item,
|
||||
);
|
||||
return found ? updated : [{ ...address, label }, ...prev].slice(0, 32);
|
||||
});
|
||||
setRemoteDmHandles((prev) =>
|
||||
prev[address.handle]
|
||||
? { ...prev, [address.handle]: { ...prev[address.handle], label } }
|
||||
: prev,
|
||||
);
|
||||
setDmAddressEditLabels((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[address.handle];
|
||||
return next;
|
||||
});
|
||||
setDmAddressCopyStatus(`Renamed ${shortHandle(address.handle)} to ${label}.`);
|
||||
await refreshDmAddressHandles();
|
||||
} catch (error) {
|
||||
setComposeError(error instanceof Error ? error.message : 'DM address label update failed');
|
||||
} finally {
|
||||
setDmAddressBusy('');
|
||||
}
|
||||
},
|
||||
[refreshDmAddressHandles],
|
||||
);
|
||||
|
||||
const handleForgetDmAddress = useCallback((address: LocalDmAddress) => {
|
||||
setDmAddresses((prev) => prev.filter((item) => item.handle !== address.handle));
|
||||
setDmAddressEditLabels((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[address.handle];
|
||||
return next;
|
||||
});
|
||||
setDmAddressCopyStatus(`Forgot local record for ${address.label || shortHandle(address.handle)}.`);
|
||||
}, []);
|
||||
|
||||
const handleStartInviteScan = useCallback(() => {
|
||||
setComposeError('');
|
||||
setComposeStatus('');
|
||||
@@ -1850,23 +2119,20 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}, []);
|
||||
|
||||
const statusLine = useMemo(() => {
|
||||
if (dmLaneWarmStatus) {
|
||||
return dmLaneWarmStatus;
|
||||
}
|
||||
if (!wormholeReadyState) {
|
||||
return 'Secure mail is preparing the local obfuscated identity in the background.';
|
||||
}
|
||||
if (!dmLaneReady) {
|
||||
return 'Secure mail is starting the direct obfuscated DM transport in the background.';
|
||||
}
|
||||
if (!identity) {
|
||||
return 'Secure mail is preparing the local private identity in the background.';
|
||||
return 'Secure identity is loading.';
|
||||
}
|
||||
if (!wormholeReadyState || !dmLaneReady) {
|
||||
return 'Private message delivery is connecting. You can generate and copy your public address now.';
|
||||
}
|
||||
if (syncing) {
|
||||
return 'SYNCING SECURE MAILBOX...';
|
||||
}
|
||||
return `SECURE MAIL READY - ${serverPendingCount} remote items still pending on the server.`;
|
||||
}, [dmLaneReady, dmLaneWarmStatus, identity, serverPendingCount, syncing, wormholeReadyState]);
|
||||
if (serverPendingCount > 0) {
|
||||
return `SECURE MAIL READY - ${serverPendingCount} queued server item${serverPendingCount === 1 ? '' : 's'} still syncing.`;
|
||||
}
|
||||
return 'SECURE MAIL READY';
|
||||
}, [dmLaneReady, identity, serverPendingCount, syncing, wormholeReadyState]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
@@ -1899,6 +2165,62 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
{statusLine}
|
||||
</div>
|
||||
|
||||
<div className="border border-emerald-500/25 bg-emerald-950/5 px-4 py-4 mb-4 shrink-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] tracking-[0.2em] uppercase text-emerald-300 flex items-center">
|
||||
<KeyRound size={14} className="mr-2" />
|
||||
My Public Address
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-300">
|
||||
{primaryDmAddress ? (
|
||||
<>
|
||||
<div className="text-white">{primaryDmAddress.label || 'Default address'}</div>
|
||||
<div className="mt-2 overflow-auto break-all border border-emerald-500/20 bg-black/40 p-3 font-mono text-[12px] leading-relaxed text-emerald-200">
|
||||
{dmAddressShareText(primaryDmAddress)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
'Generate an address, then send it to someone so they can contact you.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{primaryDmAddress && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyDmAddress(primaryDmAddress)}
|
||||
className="px-4 py-2 border border-cyan-500/30 text-cyan-300 text-xs tracking-[0.18em] uppercase"
|
||||
>
|
||||
<Copy size={13} className="inline mr-1" />
|
||||
Copy Address
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleGenerateDmAddress()}
|
||||
disabled={dmAddressBusy === 'generate'}
|
||||
className="px-4 py-2 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
{dmAddressBusy === 'generate' ? 'Generating...' : primaryDmAddress ? 'New Address' : 'Generate Address'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab('contacts');
|
||||
setDmAddressCopyStatus('Manage addresses below. Revoke an address to stop new first-contact through it.');
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-700 bg-gray-950/20 text-gray-300 text-xs tracking-[0.18em] uppercase"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{dmAddressCopyStatus && (
|
||||
<div className="mt-3 text-sm text-emerald-300">{dmAddressCopyStatus}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(pollError || composeError || composeStatus) && (
|
||||
<div className="space-y-2 mb-4 shrink-0">
|
||||
{pollError && (
|
||||
@@ -2135,13 +2457,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border border-amber-500/20 bg-amber-950/10 px-4 py-3 text-xs text-amber-300">
|
||||
{dmLaneWarmStatus
|
||||
? dmLaneWarmStatus
|
||||
: !wormholeReadyState
|
||||
? 'Secure mail is waking up in the background. You can finish the draft now and send when the lane is ready.'
|
||||
: !dmLaneReady
|
||||
? 'Secure mail is bringing the private lane online in the background.'
|
||||
: 'If the recipient is not already in your contacts, sending from here opens with a secure contact request first. Full mail begins after they accept.'}
|
||||
{!wormholeReadyState || !dmLaneReady
|
||||
? 'Private message lane is still connecting. You can write now and send when it is ready.'
|
||||
: 'To message someone new, paste their public address in Contacts first. Existing contacts can be messaged from here.'}
|
||||
</div>
|
||||
|
||||
{privateDeliveryRows.length > 0 && (
|
||||
@@ -2209,7 +2527,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
<button
|
||||
onClick={() => {
|
||||
setInviteImportAlias((prev) => prev || composeRecipient);
|
||||
setContactRequestTarget(composeRecipient);
|
||||
setActiveTab('contacts');
|
||||
setComposeStatus(
|
||||
`Import a signed invite for ${composeRecipient} before returning to Compose.`,
|
||||
@@ -2352,7 +2669,6 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
<button
|
||||
onClick={() => {
|
||||
setInviteImportAlias((prev) => prev || peerId);
|
||||
setContactRequestTarget(peerId);
|
||||
setComposeStatus(
|
||||
`Import a signed invite for ${displayNameForPeer(peerId, contacts)} in the panel below.`,
|
||||
);
|
||||
@@ -2400,70 +2716,163 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-800/80 p-6">
|
||||
<div className="text-xs tracking-[0.2em] uppercase text-cyan-300 mb-4 flex items-center">
|
||||
<UserPlus size={14} className="mr-2" />
|
||||
Add Contact
|
||||
</div>
|
||||
<label className="text-xs tracking-[0.18em] uppercase text-gray-500">
|
||||
Agent ID
|
||||
<input
|
||||
value={contactRequestTarget}
|
||||
onChange={(event) => setContactRequestTarget(event.target.value)}
|
||||
className="mt-2 w-full bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-cyan-500/40"
|
||||
placeholder="!sb_..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Signed invite import is now the required first-contact path for new secure contact
|
||||
requests. Verify the invite over QR or another trusted side channel first.
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="space-y-6">
|
||||
<div className="border border-emerald-500/25 p-6 bg-emerald-950/5">
|
||||
<div className="text-xs tracking-[0.2em] uppercase text-emerald-300 mb-4 flex items-center">
|
||||
<KeyRound size={14} className="mr-2" />
|
||||
Your Share Addresses
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 leading-[1.65] mb-4">
|
||||
This is what you give someone so they can message you. Generate more than one
|
||||
if you want separate labels, then revoke any address you no longer want to accept
|
||||
new first-contact requests through.
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={dmAddressLabel}
|
||||
onChange={(event) => setDmAddressLabel(event.target.value)}
|
||||
className="flex-1 bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40"
|
||||
placeholder="Label, e.g. Signal test, conference, one-off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleSendContactRequest()}
|
||||
className="px-4 py-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
Prepare First Contact
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStartInviteScan()}
|
||||
onClick={() => void handleGenerateDmAddress()}
|
||||
disabled={dmAddressBusy === 'generate'}
|
||||
className="px-4 py-3 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
Scan Invite QR
|
||||
{dmAddressBusy === 'generate' ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
{dmAddressCopyStatus && (
|
||||
<div className="mt-3 text-sm text-emerald-300">{dmAddressCopyStatus}</div>
|
||||
)}
|
||||
<div className="mt-4 space-y-3">
|
||||
{managedDmAddresses.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No public address yet. Generate one above.</div>
|
||||
) : (
|
||||
managedDmAddresses.map((address) => {
|
||||
const server = remoteDmHandles[address.handle];
|
||||
const inactive = Boolean(address.revokedAt || server?.expired || server?.exhausted || server?.revoked);
|
||||
const shareText = dmAddressShareText(address);
|
||||
const editLabel = dmAddressEditLabels[address.handle] ?? address.label ?? '';
|
||||
return (
|
||||
<div key={address.id} className="border border-gray-800/70 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-white break-words">
|
||||
{address.label || 'DM address'}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-gray-500">
|
||||
Created: {formatDmAddressDate(address.createdAt)}
|
||||
{address.expiresAt ? ` / Expires: ${formatDmAddressDate(address.expiresAt)}` : ''}
|
||||
</div>
|
||||
<div className={`mt-2 text-[11px] tracking-[0.16em] uppercase ${inactive ? 'text-red-300' : 'text-emerald-300'}`}>
|
||||
{inactive
|
||||
? 'Inactive'
|
||||
: `${server?.remaining_uses ?? 'active'} first-contact uses left`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
onClick={() => void handleCopyDmAddress(address)}
|
||||
disabled={!shareText}
|
||||
className="px-3 py-2 border border-cyan-500/30 text-cyan-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
|
||||
title="Copy public address"
|
||||
>
|
||||
<Copy size={13} className="inline mr-1" />
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleRevokeDmAddress(address)}
|
||||
disabled={inactive || dmAddressBusy === `revoke:${address.handle}`}
|
||||
className="px-3 py-2 border border-red-500/30 text-red-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
|
||||
>
|
||||
{dmAddressBusy === `revoke:${address.handle}` ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
{inactive && (
|
||||
<button
|
||||
onClick={() => handleForgetDmAddress(address)}
|
||||
className="px-3 py-2 border border-gray-700 text-gray-300 text-xs tracking-[0.18em] uppercase"
|
||||
>
|
||||
Forget
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<input
|
||||
value={editLabel}
|
||||
onChange={(event) =>
|
||||
setDmAddressEditLabels((prev) => ({
|
||||
...prev,
|
||||
[address.handle]: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="bg-transparent border border-gray-800 px-3 py-2 text-xs text-white outline-none focus:border-emerald-500/40"
|
||||
placeholder="Address label"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleRenameDmAddress(address, editLabel)}
|
||||
disabled={
|
||||
dmAddressBusy === `rename:${address.handle}` ||
|
||||
editLabel.trim() === (address.label || 'DM address')
|
||||
}
|
||||
className="px-3 py-2 border border-emerald-500/30 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
|
||||
>
|
||||
{dmAddressBusy === `rename:${address.handle}` ? 'Saving...' : 'Save Label'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/40 p-3 text-[11px] text-emerald-200 font-mono break-all">
|
||||
{shareText || 'Address unavailable locally.'}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 leading-relaxed">
|
||||
Revoking disables this public address for new first-contact requests. Existing approved
|
||||
contacts remain contacts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-800/80">
|
||||
<div className="text-xs tracking-[0.2em] uppercase text-emerald-300 mb-4">
|
||||
Import Verified Invite
|
||||
<div className="border border-gray-800/80 p-6">
|
||||
<div className="text-xs tracking-[0.2em] uppercase text-cyan-300 mb-4 flex items-center">
|
||||
<UserPlus size={14} className="mr-2" />
|
||||
Paste Someone's Address
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 leading-[1.65]">
|
||||
Paste the public address someone gave you. Once imported, they show up as a contact
|
||||
and you can send secure mail from Compose.
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="text-xs tracking-[0.18em] uppercase text-gray-500">
|
||||
Local Alias
|
||||
<input
|
||||
value={inviteImportAlias}
|
||||
onChange={(event) => setInviteImportAlias(event.target.value)}
|
||||
className="mt-2 w-full bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40"
|
||||
placeholder="Optional display label"
|
||||
placeholder="Optional label, e.g. Jordan, field laptop"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs tracking-[0.18em] uppercase text-gray-500 block mt-4">
|
||||
Signed Invite JSON
|
||||
Public Address
|
||||
<textarea
|
||||
value={inviteImportBlob}
|
||||
onChange={(event) => setInviteImportBlob(event.target.value)}
|
||||
className="mt-2 w-full min-h-[200px] bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40 font-mono"
|
||||
placeholder='Paste the full export blob or the nested "invite" object here...'
|
||||
placeholder="Paste the address blob someone copied from their Secure Messages screen..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4 text-sm text-gray-500 leading-[1.65]">
|
||||
Importing a signed invite pins first contact to a trusted out-of-band identity
|
||||
instead of plain first-sight TOFU. Use this when the invite came from QR,
|
||||
in-person exchange, or another authenticated side channel.
|
||||
The pasted address is signed. Importing it creates the first-contact anchor for
|
||||
that person without exposing your private keys.
|
||||
</div>
|
||||
{(inviteScanOpen || inviteScanStatus) && (
|
||||
<div className="mt-4 border border-emerald-500/20 bg-black/30 p-4">
|
||||
@@ -2490,17 +2899,24 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => void handleImportInvite()}
|
||||
disabled={inviteBusy || !inviteImportBlob.trim()}
|
||||
className="px-4 py-3 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
{inviteBusy ? 'Importing...' : 'Import Signed Invite'}
|
||||
{inviteBusy ? 'Importing...' : 'Import Address'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStartInviteScan()}
|
||||
className="px-4 py-3 border border-gray-700 bg-gray-950/20 text-gray-400 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
Camera Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -275,6 +275,11 @@ function hasKnownRouteName(value?: string | null): boolean {
|
||||
function flightHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>, dynamicRoute: DynamicRoute | null): boolean {
|
||||
if (!entity) return false;
|
||||
if (dynamicRoute?.orig_loc && dynamicRoute?.dest_loc) return true;
|
||||
return flightPayloadHasKnownRoute(entity);
|
||||
}
|
||||
|
||||
function flightPayloadHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>): boolean {
|
||||
if (!entity) return false;
|
||||
if (!('origin_loc' in entity) && !('origin_name' in entity)) return false;
|
||||
const flight = entity as Flight;
|
||||
return Boolean(
|
||||
@@ -653,7 +658,7 @@ const MaplibreViewer = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (isFlight && flightHasKnownRoute(entity, dynamicRoute)) {
|
||||
if (isFlight && flightPayloadHasKnownRoute(entity)) {
|
||||
setSelectedTrailPoints([]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -1483,7 +1488,7 @@ const MaplibreViewer = ({
|
||||
void interpTick;
|
||||
const entity = findSelectedEntity(selectedEntity, data);
|
||||
if (!entity || selectedTrailPoints.length < 2) return null;
|
||||
if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightHasKnownRoute(entity, dynamicRoute)) {
|
||||
if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightPayloadHasKnownRoute(entity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,10 +113,13 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
meshQuickStatus,
|
||||
meshSessionActive,
|
||||
publicMeshAddress,
|
||||
activePublicMeshAddress,
|
||||
meshView,
|
||||
setMeshView,
|
||||
meshDirectTarget,
|
||||
setMeshDirectTarget,
|
||||
meshAddressDraft,
|
||||
setMeshAddressDraft,
|
||||
meshMqttSettings,
|
||||
meshMqttForm,
|
||||
setMeshMqttForm,
|
||||
@@ -133,6 +136,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
publicIdentity,
|
||||
hasStoredPublicLaneIdentity,
|
||||
hasPublicLaneIdentity,
|
||||
canUsePublicMeshInput,
|
||||
hasId,
|
||||
shouldShowIdentityWarning,
|
||||
wormholeEnabled,
|
||||
@@ -339,14 +343,13 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
void handleRequestAccess(targetId);
|
||||
};
|
||||
const meshActivationText =
|
||||
meshQuickStatus?.text ||
|
||||
(publicMeshBlockedByWormhole
|
||||
publicMeshBlockedByWormhole
|
||||
? hasStoredPublicLaneIdentity
|
||||
? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.'
|
||||
: 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? 'MeshChat is off. Turn it on to use your saved public mesh key.'
|
||||
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.');
|
||||
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.';
|
||||
const handleMeshActivationAction = () => {
|
||||
if (hasStoredPublicLaneIdentity) {
|
||||
void handleActivatePublicMeshSession();
|
||||
@@ -358,6 +361,21 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
}
|
||||
void handleQuickCreatePublicIdentity();
|
||||
};
|
||||
const normalizeMeshDirectAddress = (value: string) => {
|
||||
const compact = value.trim().replace(/^!/, '').toLowerCase();
|
||||
return /^[0-9a-f]{8}$/.test(compact) ? `!${compact}` : '';
|
||||
};
|
||||
const handleMeshDirectTargetSubmit = () => {
|
||||
const target = normalizeMeshDirectAddress(meshAddressDraft);
|
||||
if (!target) {
|
||||
setSendError('enter node address like !1ee21986');
|
||||
window.setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
setMeshDirectTarget(target);
|
||||
setMeshView('channel');
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
const meshActivationLabel = identityWizardBusy
|
||||
? 'GETTING MESH KEY'
|
||||
: hasStoredPublicLaneIdentity
|
||||
@@ -482,7 +500,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anonymousModeEnabled && !anonymousModeReady && (
|
||||
{activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
|
||||
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
|
||||
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked
|
||||
until Wormhole is running over Tor, I2P, or Mixnet.
|
||||
@@ -1144,8 +1162,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1 border-b border-[var(--border-primary)]/20 shrink-0 bg-green-950/10">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 px-3 py-1 border-b border-[var(--border-primary)]/20 shrink-0 bg-green-950/10">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-wrap">
|
||||
<button
|
||||
onClick={() => setMeshView('channel')}
|
||||
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
|
||||
@@ -1176,24 +1194,71 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
>
|
||||
SETTINGS
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`text-[10px] font-mono truncate ${
|
||||
meshMqttConnected
|
||||
? 'text-green-300/80'
|
||||
: meshMqttEnabled
|
||||
? 'text-amber-300/80'
|
||||
: 'text-[var(--text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{meshSessionActive && publicMeshAddress
|
||||
? `${meshMqttConnectionLabel} / ADDR ${publicMeshAddress.toUpperCase()}`
|
||||
: publicMeshAddress
|
||||
? `${meshMqttConnectionLabel} / KEY SAVED`
|
||||
: `${meshMqttConnectionLabel} / NO ADDRESS`}
|
||||
<button
|
||||
onClick={() => {
|
||||
setMeshAddressDraft(meshDirectTarget || '');
|
||||
setMeshView('message');
|
||||
}}
|
||||
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
|
||||
meshView === 'message'
|
||||
? 'border-green-500/40 text-green-200 bg-green-950/25'
|
||||
: 'border-[var(--border-primary)]/40 text-[var(--text-muted)] hover:text-green-300'
|
||||
}`}
|
||||
>
|
||||
MESSAGE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
|
||||
{meshView === 'message' && (
|
||||
<div className="space-y-2 py-1 text-[11px] font-mono">
|
||||
<div className="border border-green-700/35 bg-green-950/10 p-2">
|
||||
<div className="text-green-300 tracking-[0.18em]">DIRECT MESHTASTIC MESSAGE</div>
|
||||
<div className="mt-1 text-[10px] text-[var(--text-muted)] leading-[1.5]">
|
||||
Enter a public Meshtastic node address. Direct MQTT publishes are public/degraded and depend on the target mesh hearing the broker bridge.
|
||||
</div>
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-[var(--text-muted)]">NODE ADDRESS</span>
|
||||
<input
|
||||
value={meshAddressDraft}
|
||||
onChange={(e) => setMeshAddressDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleMeshDirectTargetSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="!1ee21986"
|
||||
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-green-200 outline-none placeholder:text-[var(--text-muted)] focus:border-green-500/50"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleMeshDirectTargetSubmit}
|
||||
className="border border-green-600/45 bg-green-950/20 px-2 py-1.5 text-green-300 hover:bg-green-950/35"
|
||||
>
|
||||
USE ADDRESS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMeshDirectTarget('');
|
||||
setMeshAddressDraft('');
|
||||
setMeshView('channel');
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="border border-cyan-700/40 bg-cyan-950/15 px-2 py-1.5 text-cyan-300 hover:bg-cyan-950/25"
|
||||
>
|
||||
BROADCAST
|
||||
</button>
|
||||
</div>
|
||||
{meshDirectTarget && (
|
||||
<div className="border border-amber-600/30 bg-amber-950/10 p-2 text-amber-200/85 leading-[1.5]">
|
||||
Active direct target: {meshDirectTarget.toUpperCase()}. Type in the input below and press send, or clear it to return to channel broadcast.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{meshView === 'settings' && (
|
||||
<div className="space-y-2 py-1 text-[11px] font-mono">
|
||||
<div className="border border-cyan-800/35 bg-cyan-950/10 p-2">
|
||||
@@ -1338,26 +1403,26 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!meshSessionActive && meshView !== 'settings' && (
|
||||
{!canUsePublicMeshInput && meshView !== 'settings' && (
|
||||
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
|
||||
MeshChat is off. Turn it on to connect the public mesh lane.
|
||||
</div>
|
||||
)}
|
||||
{meshSessionActive && meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
||||
{canUsePublicMeshInput && meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||
No messages from {meshRegion} / {meshChannel}
|
||||
</div>
|
||||
)}
|
||||
{meshSessionActive && meshView === 'inbox' && (
|
||||
{canUsePublicMeshInput && meshView === 'inbox' && (
|
||||
<>
|
||||
{!publicMeshAddress && (
|
||||
{!activePublicMeshAddress && (
|
||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||
Create or load a public mesh identity to see direct Meshtastic traffic.
|
||||
</div>
|
||||
)}
|
||||
{publicMeshAddress && meshInboxMessages.length === 0 && (
|
||||
{activePublicMeshAddress && meshInboxMessages.length === 0 && (
|
||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||
No public direct messages addressed to {publicMeshAddress.toUpperCase()} yet.
|
||||
No public direct messages addressed to {activePublicMeshAddress.toUpperCase()} yet.
|
||||
</div>
|
||||
)}
|
||||
{meshInboxMessages.map((m, i) => (
|
||||
@@ -1371,7 +1436,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] text-amber-200/70 mb-0.5">
|
||||
TO {publicMeshAddress.toUpperCase()}
|
||||
TO {activePublicMeshAddress.toUpperCase()}
|
||||
</div>
|
||||
<div className="break-words whitespace-pre-wrap text-amber-100/90">
|
||||
{m.text}
|
||||
@@ -2264,10 +2329,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
? `→ INFONET${selectedGate ? ` / ${selectedGate}` : ''}${privateInfonetTransportReady ? '' : ' / EXPERIMENTAL ENCRYPTION'}`
|
||||
: '→ PRIVATE LANE LOCKED'
|
||||
: activeTab === 'meshtastic'
|
||||
? hasPublicLaneIdentity
|
||||
? canUsePublicMeshInput
|
||||
? meshDirectTarget
|
||||
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
|
||||
: `→ MESH / ${meshRegion} / ${meshChannel}`
|
||||
? `→ MESH / TO ${meshDirectTarget.toUpperCase()} / FROM ${activePublicMeshAddress.toUpperCase()}`
|
||||
: `→ MESH / ${meshRegion} / ${meshChannel} / ${activePublicMeshAddress.toUpperCase()}`
|
||||
: publicMeshBlockedByWormhole
|
||||
? '→ MESH BLOCKED / WORMHOLE ACTIVE'
|
||||
: hasStoredPublicLaneIdentity
|
||||
? '→ MESH OFF'
|
||||
: '→ MESH LOCKED'
|
||||
@@ -2279,7 +2346,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'meshtastic' && !hasPublicLaneIdentity && !sendError && (
|
||||
{activeTab === 'meshtastic' && !sendError && (!canUsePublicMeshInput || meshQuickStatus) && (
|
||||
<div
|
||||
className={`px-3 pt-1 text-[12px] font-mono leading-[1.5] ${
|
||||
meshQuickStatus?.type === 'err'
|
||||
@@ -2289,7 +2356,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
: 'text-green-300/70'
|
||||
}`}
|
||||
>
|
||||
{meshActivationText}
|
||||
{meshQuickStatus?.text || meshActivationText}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||
@@ -2319,7 +2386,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
NEED WORMHOLE
|
||||
</span>
|
||||
</button>
|
||||
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
|
||||
) : activeTab === 'meshtastic' && !canUsePublicMeshInput ? (
|
||||
<button
|
||||
onClick={handleMeshActivationAction}
|
||||
disabled={identityWizardBusy}
|
||||
@@ -2335,7 +2402,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
||||
</button>
|
||||
) : activeTab === 'meshtastic' && meshDirectTarget ? (
|
||||
<button
|
||||
onClick={() => setMeshDirectTarget('')}
|
||||
onClick={() => {
|
||||
setMeshDirectTarget('');
|
||||
setMeshAddressDraft('');
|
||||
}}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-amber-700/40 bg-amber-950/10 text-amber-200 hover:bg-amber-950/20 hover:border-amber-500/50 transition-colors"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
extractNativeGateResyncTarget,
|
||||
} from '@/lib/desktopControlContract';
|
||||
import type { DesktopControlAuditReport } from '@/lib/desktopControlContract';
|
||||
import { fetchPrivacyProfileSnapshot } from '@/mesh/controlPlaneStatusClient';
|
||||
import { fetchPrivacyProfileSnapshot, setInfonetNodeEnabled } from '@/mesh/controlPlaneStatusClient';
|
||||
import {
|
||||
getNodeIdentity,
|
||||
getStoredNodeDescriptor,
|
||||
@@ -397,8 +397,9 @@ export function useMeshChatController({
|
||||
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [meshSessionActive, setMeshSessionActive] = useState(false);
|
||||
const [publicMeshAddress, setPublicMeshAddress] = useState('');
|
||||
const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings'>('channel');
|
||||
const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings' | 'message'>('channel');
|
||||
const [meshDirectTarget, setMeshDirectTarget] = useState('');
|
||||
const [meshAddressDraft, setMeshAddressDraft] = useState('');
|
||||
const [meshMqttSettings, setMeshMqttSettings] = useState<MeshMqttSettings | null>(null);
|
||||
const [meshMqttForm, setMeshMqttForm] = useState<MeshMqttForm>({
|
||||
broker: 'mqtt.meshtastic.org',
|
||||
@@ -427,14 +428,17 @@ export function useMeshChatController({
|
||||
const storedPublicMeshAddress = clientHydrated ? readStoredPublicMeshAddress() : '';
|
||||
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicMeshAddress);
|
||||
const publicIdentity = null;
|
||||
const hasPublicLaneIdentity = meshSessionActive && Boolean(publicMeshAddress);
|
||||
const activePublicMeshAddress = publicMeshAddress || storedPublicMeshAddress;
|
||||
const hasPublicLaneIdentity = meshSessionActive && Boolean(activePublicMeshAddress);
|
||||
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
|
||||
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
|
||||
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
||||
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
|
||||
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
||||
const infonetAutoBootstrapRef = useRef(false);
|
||||
const meshMqttRuntime = meshMqttSettings?.runtime;
|
||||
const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled);
|
||||
const canUsePublicMeshInput = Boolean(activePublicMeshAddress) && meshMqttEnabled && !publicMeshBlockedByWormhole;
|
||||
const meshMqttRunning = Boolean(meshMqttRuntime?.running);
|
||||
const meshMqttConnected = Boolean(meshMqttRuntime?.connected);
|
||||
const meshMqttConnectionLabel = !meshMqttEnabled
|
||||
@@ -546,16 +550,12 @@ export function useMeshChatController({
|
||||
const displayPublicMeshSender = useCallback(
|
||||
(sender: string) => {
|
||||
if (!sender) return '???';
|
||||
if (
|
||||
hasPublicLaneIdentity &&
|
||||
publicMeshAddress &&
|
||||
sender.toLowerCase() === publicMeshAddress.toLowerCase()
|
||||
) {
|
||||
return publicMeshAddress.toUpperCase();
|
||||
if (activePublicMeshAddress && sender.toLowerCase() === activePublicMeshAddress.toLowerCase()) {
|
||||
return activePublicMeshAddress.toUpperCase();
|
||||
}
|
||||
return sender;
|
||||
},
|
||||
[hasPublicLaneIdentity, publicMeshAddress],
|
||||
[activePublicMeshAddress],
|
||||
);
|
||||
|
||||
const openIdentityWizard = useCallback(
|
||||
@@ -1221,6 +1221,7 @@ export function useMeshChatController({
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const cursorMirrorRef = useRef<HTMLDivElement>(null);
|
||||
const cursorMarkerRef = useRef<HTMLSpanElement>(null);
|
||||
const publicMeshPrivacyEnforcedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = messagesEndRef.current;
|
||||
@@ -1329,15 +1330,21 @@ export function useMeshChatController({
|
||||
() => infoMessages.filter((m) => !m.node_id || !mutedUsers.has(m.node_id)),
|
||||
[infoMessages, mutedUsers],
|
||||
);
|
||||
const isBroadcastMeshMessage = useCallback((m: MeshtasticMessage) => {
|
||||
const target = String(m.to || 'broadcast').trim().toLowerCase();
|
||||
return target === '' || target === 'broadcast' || target === '^all';
|
||||
}, []);
|
||||
const filteredMeshMessages = useMemo(
|
||||
() => meshMessages.filter((m) => !mutedUsers.has(m.from)),
|
||||
[meshMessages, mutedUsers],
|
||||
() => meshMessages.filter((m) => isBroadcastMeshMessage(m) && !mutedUsers.has(m.from)),
|
||||
[isBroadcastMeshMessage, meshMessages, mutedUsers],
|
||||
);
|
||||
const meshInboxMessages = useMemo(() => {
|
||||
if (!meshSessionActive || !publicMeshAddress) return [];
|
||||
const target = publicMeshAddress.toLowerCase();
|
||||
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
|
||||
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
|
||||
if (!activePublicMeshAddress) return [];
|
||||
const target = activePublicMeshAddress.toLowerCase();
|
||||
return meshMessages.filter(
|
||||
(m) => !mutedUsers.has(m.from) && String(m.to || '').toLowerCase() === target,
|
||||
);
|
||||
}, [activePublicMeshAddress, meshMessages, mutedUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'meshtastic') return;
|
||||
@@ -1961,7 +1968,7 @@ export function useMeshChatController({
|
||||
|
||||
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||
if (!expanded || activeTab !== 'meshtastic' || !canUsePublicMeshInput) return;
|
||||
let cancelled = false;
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
@@ -2020,12 +2027,12 @@ export function useMeshChatController({
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [expanded, activeTab, meshRegion, meshSessionActive]);
|
||||
}, [expanded, activeTab, meshRegion, canUsePublicMeshInput]);
|
||||
|
||||
// ─── Meshtastic Polling ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||
if (!expanded || activeTab !== 'meshtastic' || !canUsePublicMeshInput) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
@@ -2034,6 +2041,7 @@ export function useMeshChatController({
|
||||
region: meshRegion,
|
||||
channel: meshChannel,
|
||||
});
|
||||
if (meshView === 'inbox') params.set('include_direct', '1');
|
||||
const res = await fetch(`${API_BASE}/api/mesh/messages?${params}`);
|
||||
if (res.ok && !cancelled) {
|
||||
const data = await res.json();
|
||||
@@ -2049,13 +2057,13 @@ export function useMeshChatController({
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]);
|
||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView, canUsePublicMeshInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshSessionActive) return;
|
||||
if (canUsePublicMeshInput) return;
|
||||
setMeshMessages([]);
|
||||
setMeshQuickStatus(null);
|
||||
}, [meshSessionActive]);
|
||||
}, [canUsePublicMeshInput]);
|
||||
|
||||
// ─── DM Polling ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2540,7 +2548,7 @@ export function useMeshChatController({
|
||||
if (!msg || busy) return;
|
||||
if (activeTab !== 'meshtastic' && !hasId) return;
|
||||
|
||||
const cooldownMs = activeTab === 'dms' ? 0 : 30_000;
|
||||
const cooldownMs = activeTab === 'dms' ? 0 : activeTab === 'meshtastic' ? 6_000 : 30_000;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastSendTime;
|
||||
if (cooldownMs > 0 && elapsed < cooldownMs) {
|
||||
@@ -2550,8 +2558,8 @@ export function useMeshChatController({
|
||||
return;
|
||||
}
|
||||
|
||||
if (anonymousPublicBlocked && (activeTab === 'infonet' || activeTab === 'meshtastic')) {
|
||||
setSendError('hidden transport required for public posting');
|
||||
if (anonymousPublicBlocked && activeTab === 'infonet') {
|
||||
setSendError('hidden transport required for infonet posting');
|
||||
setTimeout(() => setSendError(''), 4000);
|
||||
return;
|
||||
}
|
||||
@@ -2625,10 +2633,11 @@ export function useMeshChatController({
|
||||
]);
|
||||
setGateReplyContext(null);
|
||||
} else if (activeTab === 'meshtastic') {
|
||||
if (!meshSessionActive || !publicMeshAddress) {
|
||||
const meshSenderAddress = activePublicMeshAddress;
|
||||
if (!meshSenderAddress) {
|
||||
setInputValue(msg);
|
||||
setLastSendTime(0);
|
||||
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
|
||||
setSendError('public mesh identity needed');
|
||||
openIdentityWizard({
|
||||
type: 'err',
|
||||
text: hasStoredPublicLaneIdentity
|
||||
@@ -2639,6 +2648,10 @@ export function useMeshChatController({
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
if (!meshSessionActive) {
|
||||
setPublicMeshAddress(meshSenderAddress);
|
||||
setMeshSessionActive(true);
|
||||
}
|
||||
if (!meshMqttEnabled) {
|
||||
setInputValue(msg);
|
||||
setLastSendTime(0);
|
||||
@@ -2680,7 +2693,7 @@ export function useMeshChatController({
|
||||
priority: 'normal',
|
||||
ephemeral: false,
|
||||
transport_lock: 'meshtastic',
|
||||
sender_id: publicMeshAddress,
|
||||
sender_id: meshSenderAddress,
|
||||
mesh_region: meshRegion,
|
||||
}),
|
||||
});
|
||||
@@ -2700,12 +2713,28 @@ export function useMeshChatController({
|
||||
return;
|
||||
}
|
||||
// Re-fetch — backend injects our msg into the bridge feed after publish
|
||||
const directTarget = meshDestination !== 'broadcast'
|
||||
? meshDestination.startsWith('!')
|
||||
? meshDestination.toUpperCase()
|
||||
: `!${meshDestination}`.toUpperCase()
|
||||
: '';
|
||||
const routeDetail = Array.isArray(sendData.results) && sendData.results[0]?.reason
|
||||
? String(sendData.results[0].reason)
|
||||
: String(sendData.route_reason || 'MQTT broker accepted publish');
|
||||
setMeshQuickStatus({
|
||||
type: 'ok',
|
||||
text: directTarget
|
||||
? `Direct message queued for ${directTarget}. ${routeDetail}`
|
||||
: `Channel message published to ${meshRegion}/${meshChannel}. ${routeDetail}`,
|
||||
});
|
||||
window.setTimeout(() => setMeshQuickStatus(null), 6000);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const params = new URLSearchParams({
|
||||
limit: '30',
|
||||
region: meshRegion,
|
||||
channel: meshChannel,
|
||||
});
|
||||
if (directTarget) params.set('include_direct', '1');
|
||||
const mRes = await fetch(`${API_BASE}/api/mesh/messages?${params}`);
|
||||
if (mRes.ok) {
|
||||
const data = await mRes.json();
|
||||
@@ -4138,7 +4167,7 @@ export function useMeshChatController({
|
||||
privateInfonetTransportReady,
|
||||
});
|
||||
const inputDisabled =
|
||||
!hasId ||
|
||||
(activeTab !== 'meshtastic' && !hasId) ||
|
||||
busy ||
|
||||
(activeTab === 'infonet' && !privateInfonetReady) ||
|
||||
(activeTab === 'infonet' && !selectedGate) ||
|
||||
@@ -4148,7 +4177,7 @@ export function useMeshChatController({
|
||||
wormholeReadyState &&
|
||||
!selectedGateAccessReady) ||
|
||||
(activeTab === 'infonet' && anonymousPublicBlocked) ||
|
||||
(activeTab === 'meshtastic' && (!hasPublicLaneIdentity || !meshMqttEnabled)) ||
|
||||
(activeTab === 'meshtastic' && !canUsePublicMeshInput) ||
|
||||
(activeTab === 'dms' &&
|
||||
(dmView !== 'chat' ||
|
||||
!selectedContact ||
|
||||
@@ -4192,6 +4221,10 @@ export function useMeshChatController({
|
||||
[inputDisabled],
|
||||
);
|
||||
|
||||
const disablePrivateNodeForPublicMesh = useCallback(async () => {
|
||||
await setInfonetNodeEnabled(false);
|
||||
}, []);
|
||||
|
||||
const disableWormholeForPublicMesh = useCallback(async () => {
|
||||
const requireBackendLeave = wormholeEnabled || wormholeReadyState;
|
||||
try {
|
||||
@@ -4207,7 +4240,28 @@ export function useMeshChatController({
|
||||
setWormholeRnsDirectReady(false);
|
||||
setWormholeRnsPeers({ active: 0, configured: 0 });
|
||||
setSecureModeCached(false);
|
||||
}, [wormholeEnabled, wormholeReadyState]);
|
||||
await disablePrivateNodeForPublicMesh();
|
||||
}, [disablePrivateNodeForPublicMesh, wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!meshSessionActive || !activePublicMeshAddress || !meshMqttEnabled) {
|
||||
publicMeshPrivacyEnforcedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (publicMeshPrivacyEnforcedRef.current) return;
|
||||
publicMeshPrivacyEnforcedRef.current = true;
|
||||
void disableWormholeForPublicMesh().catch((err) => {
|
||||
publicMeshPrivacyEnforcedRef.current = false;
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown error';
|
||||
setMeshQuickStatus({
|
||||
type: 'err',
|
||||
text: `Could not isolate public Mesh lane: ${message}`,
|
||||
});
|
||||
});
|
||||
}, [activePublicMeshAddress, disableWormholeForPublicMesh, meshMqttEnabled, meshSessionActive]);
|
||||
|
||||
const createPublicMeshIdentity = useCallback(
|
||||
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
|
||||
@@ -4286,9 +4340,9 @@ export function useMeshChatController({
|
||||
setMeshSessionActive(true);
|
||||
setMeshMessages([]);
|
||||
setSendError('');
|
||||
const text = `MeshChat is on with saved address ${readyAddress}.`;
|
||||
const text = `MeshChat is on. Address ${readyAddress}.`;
|
||||
setIdentityWizardStatus({ type: 'ok', text });
|
||||
setMeshQuickStatus({ type: 'ok', text });
|
||||
setMeshQuickStatus(null);
|
||||
return { ok: true as const, text };
|
||||
} catch (err) {
|
||||
const message =
|
||||
@@ -4308,7 +4362,8 @@ export function useMeshChatController({
|
||||
const target = String(address || '').trim();
|
||||
if (!target) return;
|
||||
setMeshDirectTarget(target);
|
||||
setMeshView('inbox');
|
||||
setMeshAddressDraft(target);
|
||||
setMeshView('channel');
|
||||
setSenderPopup(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
@@ -4319,7 +4374,7 @@ export function useMeshChatController({
|
||||
: await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
||||
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
||||
setIdentityWizardStatus(status);
|
||||
setMeshQuickStatus(status);
|
||||
setMeshQuickStatus(result.ok ? null : status);
|
||||
if (result.ok) {
|
||||
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
||||
}
|
||||
@@ -4424,6 +4479,23 @@ export function useMeshChatController({
|
||||
setIdentityWizardBusy(false);
|
||||
}
|
||||
}, [wormholeDescriptor?.nodeId, wormholeEnabled, wormholeReadyState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || activeTab !== 'infonet') {
|
||||
infonetAutoBootstrapRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (privateInfonetReady) {
|
||||
infonetAutoBootstrapRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (identityWizardBusy || infonetAutoBootstrapRef.current) return;
|
||||
infonetAutoBootstrapRef.current = true;
|
||||
void handleBootstrapPrivateIdentity().catch(() => {
|
||||
infonetAutoBootstrapRef.current = false;
|
||||
});
|
||||
}, [activeTab, expanded, handleBootstrapPrivateIdentity, identityWizardBusy, privateInfonetReady]);
|
||||
|
||||
return {
|
||||
// UI state
|
||||
expanded,
|
||||
@@ -4447,10 +4519,13 @@ export function useMeshChatController({
|
||||
meshQuickStatus,
|
||||
meshSessionActive,
|
||||
publicMeshAddress,
|
||||
activePublicMeshAddress,
|
||||
meshView,
|
||||
setMeshView,
|
||||
meshDirectTarget,
|
||||
setMeshDirectTarget,
|
||||
meshAddressDraft,
|
||||
setMeshAddressDraft,
|
||||
meshMqttSettings,
|
||||
meshMqttForm,
|
||||
setMeshMqttForm,
|
||||
@@ -4467,6 +4542,7 @@ export function useMeshChatController({
|
||||
publicIdentity,
|
||||
hasStoredPublicLaneIdentity,
|
||||
hasPublicLaneIdentity,
|
||||
canUsePublicMeshInput,
|
||||
hasId,
|
||||
shouldShowIdentityWarning,
|
||||
wormholeEnabled,
|
||||
|
||||
@@ -245,147 +245,26 @@ const VESSEL_TYPE_WIKI: Record<string, string> = {
|
||||
|
||||
type FlightTrailPoint = { lat?: number; lng?: number; alt?: number; ts?: number } | number[];
|
||||
|
||||
function readTrailTimestamp(point: FlightTrailPoint): number | null {
|
||||
if (Array.isArray(point)) {
|
||||
const ts = Number(point[3]);
|
||||
return Number.isFinite(ts) && ts > 0 ? ts : null;
|
||||
}
|
||||
const ts = Number(point?.ts);
|
||||
return Number.isFinite(ts) && ts > 0 ? ts : null;
|
||||
}
|
||||
|
||||
function readTrailLatLng(point: FlightTrailPoint): { lat: number; lng: number } | null {
|
||||
const lat = Number(Array.isArray(point) ? point[0] : point?.lat);
|
||||
const lng = Number(Array.isArray(point) ? point[1] : point?.lng);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
function distanceNm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const earthRadiusNm = 3440.065;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * earthRadiusNm * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||||
}
|
||||
|
||||
function formatObservedDuration(hours: number): string {
|
||||
const minutes = Math.max(1, Math.round(hours * 60));
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const wholeHours = Math.floor(minutes / 60);
|
||||
const remainder = minutes % 60;
|
||||
return remainder ? `${wholeHours}h ${remainder}m` : `${wholeHours}h`;
|
||||
}
|
||||
|
||||
function estimateObservedEmissions(flight: any): {
|
||||
fuelGallons: number;
|
||||
co2Kg: number;
|
||||
durationLabel: string;
|
||||
distanceLabel: string | null;
|
||||
basisLabel: string;
|
||||
} | null {
|
||||
const fuelGph = Number(flight?.emissions?.fuel_gph);
|
||||
const co2KgPerHour = Number(flight?.emissions?.co2_kg_per_hour);
|
||||
const trail = Array.isArray(flight?.trail) ? (flight.trail as FlightTrailPoint[]) : [];
|
||||
const isTrackedAircraft = flight?.type === 'tracked_flight' || Boolean(flight?.alert_category);
|
||||
const minimumObservedHours = isTrackedAircraft ? 1 / 60 : 5 / 60;
|
||||
if (!Number.isFinite(fuelGph) || !Number.isFinite(co2KgPerHour)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = trail
|
||||
.map(readTrailTimestamp)
|
||||
.filter((ts): ts is number => ts !== null)
|
||||
.sort((a, b) => a - b);
|
||||
if (timestamps.length >= 2) {
|
||||
const elapsedHours = (timestamps[timestamps.length - 1] - timestamps[0]) / 3600;
|
||||
if (Number.isFinite(elapsedHours) && elapsedHours >= minimumObservedHours) {
|
||||
let distance = 0;
|
||||
let previous: { lat: number; lng: number } | null = null;
|
||||
for (const point of trail) {
|
||||
const current = readTrailLatLng(point);
|
||||
if (previous && current) distance += distanceNm(previous, current);
|
||||
if (current) previous = current;
|
||||
}
|
||||
|
||||
return {
|
||||
fuelGallons: Math.round(fuelGph * elapsedHours),
|
||||
co2Kg: Math.round(co2KgPerHour * elapsedHours),
|
||||
durationLabel: formatObservedDuration(elapsedHours),
|
||||
distanceLabel: distance > 1 ? `${Math.round(distance).toLocaleString()} nm` : null,
|
||||
basisLabel: 'trail history',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const origin = Array.isArray(flight?.origin_loc)
|
||||
? { lng: Number(flight.origin_loc[0]), lat: Number(flight.origin_loc[1]) }
|
||||
: null;
|
||||
const current = { lat: Number(flight?.lat), lng: Number(flight?.lng) };
|
||||
const speedKnots = Number(flight?.speed_knots);
|
||||
if (
|
||||
origin &&
|
||||
Number.isFinite(origin.lat) &&
|
||||
Number.isFinite(origin.lng) &&
|
||||
Number.isFinite(current.lat) &&
|
||||
Number.isFinite(current.lng) &&
|
||||
Number.isFinite(speedKnots) &&
|
||||
speedKnots > 50
|
||||
) {
|
||||
const flownNm = distanceNm(origin, current);
|
||||
const elapsedHours = flownNm / speedKnots;
|
||||
if (Number.isFinite(elapsedHours) && elapsedHours >= minimumObservedHours && elapsedHours <= 18) {
|
||||
return {
|
||||
fuelGallons: Math.round(fuelGph * elapsedHours),
|
||||
co2Kg: Math.round(co2KgPerHour * elapsedHours),
|
||||
durationLabel: formatObservedDuration(elapsedHours),
|
||||
distanceLabel: `${Math.round(flownNm).toLocaleString()} nm`,
|
||||
basisLabel: 'route progress',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function EmissionsEstimateBlock({ flight }: { flight: any }) {
|
||||
const observed = estimateObservedEmissions(flight);
|
||||
const emissions = flight?.emissions;
|
||||
const context = observed
|
||||
? `${observed.durationLabel} ${observed.basisLabel}${observed.distanceLabel ? ` / ${observed.distanceLabel}` : ''}`
|
||||
: emissions
|
||||
? 'Rate only until enough trail history accumulates'
|
||||
: null;
|
||||
const context = emissions ? 'Model-based cruise estimate' : null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
|
||||
{observed ? 'FUEL BURNED' : 'FUEL RATE'}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL RATE</div>
|
||||
<div className="text-xs font-bold text-orange-400">
|
||||
{observed ? (
|
||||
<>{observed.fuelGallons.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">GAL</span></>
|
||||
) : emissions ? (
|
||||
{emissions ? (
|
||||
<>{emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></>
|
||||
) : 'UNKNOWN'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
|
||||
{observed ? 'CO2 PRODUCED' : 'CO2 RATE'}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 RATE</div>
|
||||
<div className="text-xs font-bold text-red-400">
|
||||
{observed ? (
|
||||
<>{observed.co2Kg.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG</span></>
|
||||
) : emissions ? (
|
||||
{emissions ? (
|
||||
<>{emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></>
|
||||
) : 'UNKNOWN'}
|
||||
</div>
|
||||
@@ -394,7 +273,6 @@ function EmissionsEstimateBlock({ flight }: { flight: any }) {
|
||||
{context && (
|
||||
<div className="mt-1.5 text-[10px] text-[var(--text-muted)] leading-relaxed">
|
||||
{context}
|
||||
{observed && emissions ? ` - estimated from ${emissions.fuel_gph} GPH model rate.` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio, Bot, Copy, Check, Network } from 'lucide-react';
|
||||
|
||||
const CURRENT_ONBOARDING_VERSION = '0.9.75-agentic-onboarding-1';
|
||||
const CURRENT_ONBOARDING_VERSION = '0.9.79-agentic-onboarding-1';
|
||||
const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`;
|
||||
const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Database, Clock, X } from 'lucide-react';
|
||||
|
||||
const CURRENT_VERSION = '0.9.75';
|
||||
const CURRENT_VERSION = '0.9.79';
|
||||
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
|
||||
|
||||
interface StartupWarmupModalProps {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import {
|
||||
derivePublicMeshAddress,
|
||||
getNodeIdentity,
|
||||
hasSovereignty,
|
||||
signEvent,
|
||||
@@ -13,11 +12,28 @@ import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
|
||||
const MESH_NODE_ID_RE = /^![0-9a-f]{8}$/i;
|
||||
const PUBLIC_MESH_ADDRESS_KEY = 'sb_public_meshtastic_address';
|
||||
|
||||
function isMeshtasticNodeId(value: string | undefined | null): boolean {
|
||||
return !!value && MESH_NODE_ID_RE.test(value.trim());
|
||||
}
|
||||
|
||||
function normalizePublicMeshAddress(value: string | undefined | null): string {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
const body = raw.startsWith('!') ? raw.slice(1) : raw;
|
||||
if (!/^[0-9a-f]{8}$/.test(body)) return '';
|
||||
return `!${body}`;
|
||||
}
|
||||
|
||||
function readStoredPublicMeshAddress(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
return normalizePublicMeshAddress(window.localStorage.getItem(PUBLIC_MESH_ADDRESS_KEY));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline send-message form for SIGINT popups — routes via MeshRouter */
|
||||
export function SigintSendForm({
|
||||
destination,
|
||||
@@ -40,26 +56,11 @@ export function SigintSendForm({
|
||||
const isDirectMesh = isMesh && isMeshtasticNodeId(destination);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isMesh) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
}
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
}
|
||||
derivePublicMeshAddress(identity.nodeId)
|
||||
.then((addr) => {
|
||||
if (!cancelled) setPublicMeshAddress(addr);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicMeshAddress('');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
setPublicMeshAddress(readStoredPublicMeshAddress());
|
||||
}, [isMesh]);
|
||||
|
||||
const handleSend = async () => {
|
||||
@@ -71,6 +72,56 @@ export function SigintSendForm({
|
||||
}
|
||||
setStatus('sending');
|
||||
try {
|
||||
if (isMesh) {
|
||||
const meshSender = normalizePublicMeshAddress(publicMeshAddress || readStoredPublicMeshAddress());
|
||||
if (!meshSender) {
|
||||
setStatus('error');
|
||||
setDetail('public mesh key required');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
message: msg.trim(),
|
||||
destination: destination || 'broadcast',
|
||||
channel: channel || 'LongFast',
|
||||
priority: 'normal',
|
||||
ephemeral: false,
|
||||
transport_lock: 'meshtastic',
|
||||
};
|
||||
const v = validateEventPayload('message', payload);
|
||||
if (!v.ok) {
|
||||
setStatus('error');
|
||||
setDetail(`invalid payload: ${v.reason}`);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/mesh/meshtastic/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
destination: destination || 'broadcast',
|
||||
message: msg.trim(),
|
||||
sender_id: meshSender,
|
||||
channel: channel || 'LongFast',
|
||||
priority: 'normal',
|
||||
ephemeral: false,
|
||||
transport_lock: 'meshtastic',
|
||||
mesh_region: region || 'US',
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.ok) {
|
||||
setStatus('sent');
|
||||
const routeDetail = Array.isArray(data.results) && data.results[0]?.reason
|
||||
? String(data.results[0].reason)
|
||||
: String(data.route_reason || 'MQTT broker accepted publish');
|
||||
setDetail(routeDetail);
|
||||
setMsg('');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setDetail(String(data.detail || data.route_reason || 'send failed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity || !hasSovereignty()) {
|
||||
setStatus('error');
|
||||
@@ -234,22 +285,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const identity = getNodeIdentity();
|
||||
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
|
||||
setPublicMeshAddress('');
|
||||
return;
|
||||
}
|
||||
derivePublicMeshAddress(identity.nodeId)
|
||||
.then((addr) => {
|
||||
if (!cancelled) setPublicMeshAddress(addr);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicMeshAddress('');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
setPublicMeshAddress(readStoredPublicMeshAddress());
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -281,6 +317,10 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
const regionData = channelStats?.roots?.[region] || channelStats?.regions?.[region];
|
||||
const regionChannels = regionData?.channels || {};
|
||||
const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]);
|
||||
const channelMessages = messages.filter((m) => {
|
||||
const target = String(m.to || 'broadcast').trim().toLowerCase();
|
||||
return target === '' || target === 'broadcast' || target === '^all';
|
||||
});
|
||||
|
||||
if (loading)
|
||||
return <div className="text-[11px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
|
||||
@@ -317,13 +357,13 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
)}
|
||||
|
||||
{/* Message feed */}
|
||||
{messages.length > 0 ? (
|
||||
{channelMessages.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[11px] text-green-400/60 tracking-widest mb-1">
|
||||
MESSAGES — {channel} ({region})
|
||||
</div>
|
||||
<div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin">
|
||||
{messages.map((m: MeshtasticMessage, i: number) => {
|
||||
{channelMessages.map((m: MeshtasticMessage, i: number) => {
|
||||
const directedToYou =
|
||||
!!publicMeshAddress &&
|
||||
typeof m.to === 'string' &&
|
||||
|
||||
@@ -46,7 +46,11 @@ export async function controlPlaneJson<T>(
|
||||
const res = await controlPlaneFetch(path, options);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.ok === false) {
|
||||
throw new Error(data?.detail || data?.message || 'control_plane_request_failed');
|
||||
const fallback =
|
||||
res.status === 429
|
||||
? 'control_plane_rate_limited'
|
||||
: `control_plane_request_failed:${res.status || 'unknown'}`;
|
||||
throw new Error(data?.detail || data?.message || fallback);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ export interface WormholeDmInviteExport {
|
||||
peer_id: string;
|
||||
trust_fingerprint: string;
|
||||
invite: WormholeDmInviteEnvelope;
|
||||
prekey_publish_pending?: boolean;
|
||||
prekey_registration?: Record<string, unknown>;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface WormholeDmInviteImportResult {
|
||||
@@ -102,6 +105,44 @@ export interface WormholeDmInviteImportResult {
|
||||
contact: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WormholeDmAddressRecord {
|
||||
handle: string;
|
||||
label: string;
|
||||
issued_at: number;
|
||||
expires_at: number;
|
||||
max_uses: number;
|
||||
use_count: number;
|
||||
remaining_uses: number;
|
||||
last_used_at: number;
|
||||
expired: boolean;
|
||||
exhausted: boolean;
|
||||
revoked?: boolean;
|
||||
}
|
||||
|
||||
export interface WormholeDmInviteHandlesResponse {
|
||||
ok: boolean;
|
||||
addresses: WormholeDmAddressRecord[];
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface WormholeDmInviteHandleRevokeResult {
|
||||
ok: boolean;
|
||||
handle: string;
|
||||
revoked: boolean;
|
||||
identity_removed?: boolean;
|
||||
relay_removed?: boolean;
|
||||
republished?: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface WormholeDmInviteHandleUpdateResult {
|
||||
ok: boolean;
|
||||
handle: string;
|
||||
label: string;
|
||||
updated: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export type WormholeDmInviteImportFailure = Partial<WormholeDmInviteImportResult> & {
|
||||
ok?: false;
|
||||
};
|
||||
@@ -939,12 +980,70 @@ export async function fetchWormholeIdentity(): Promise<WormholeIdentity> {
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function exportWormholeDmInvite(): Promise<WormholeDmInviteExport> {
|
||||
return controlPlaneJson<WormholeDmInviteExport>('/api/wormhole/dm/invite', {
|
||||
export async function exportWormholeDmInvite(options: {
|
||||
label?: string;
|
||||
expiresInSeconds?: number;
|
||||
} = {}): Promise<WormholeDmInviteExport> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.label?.trim()) {
|
||||
params.set('label', options.label.trim());
|
||||
}
|
||||
if (options.expiresInSeconds && options.expiresInSeconds > 0) {
|
||||
params.set('expires_in_s', String(Math.floor(options.expiresInSeconds)));
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : '';
|
||||
return controlPlaneJson<WormholeDmInviteExport>(`/api/wormhole/dm/invite${suffix}`, {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWormholeDmInviteHandles(): Promise<WormholeDmInviteHandlesResponse> {
|
||||
return controlPlaneJson<WormholeDmInviteHandlesResponse>('/api/wormhole/dm/invite/handles', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeWormholeDmInviteHandle(
|
||||
handle: string,
|
||||
): Promise<WormholeDmInviteHandleRevokeResult> {
|
||||
const response = await controlPlaneFetch(
|
||||
`/api/wormhole/dm/invite/handles/${encodeURIComponent(handle)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
requireAdminSession: false,
|
||||
},
|
||||
);
|
||||
const data = (await response.json().catch(() => ({}))) as WormholeDmInviteHandleRevokeResult & {
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok || data?.ok === false) {
|
||||
throw new Error(String(data?.detail || data?.message || 'DM address revoke failed'));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function renameWormholeDmInviteHandle(
|
||||
handle: string,
|
||||
label: string,
|
||||
): Promise<WormholeDmInviteHandleUpdateResult> {
|
||||
const response = await controlPlaneFetch(
|
||||
`/api/wormhole/dm/invite/handles/${encodeURIComponent(handle)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label }),
|
||||
requireAdminSession: false,
|
||||
},
|
||||
);
|
||||
const data = (await response.json().catch(() => ({}))) as WormholeDmInviteHandleUpdateResult & {
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok || data?.ok === false) {
|
||||
throw new Error(String(data?.detail || data?.message || 'DM address label update failed'));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importWormholeDmInvite(
|
||||
invite: Record<string, unknown>,
|
||||
alias: string = '',
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
function buildCsp(_nonce: string): string {
|
||||
function buildCsp(nonce: string, strictScripts = false): string {
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const scriptSrc = isDev
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: strictScripts
|
||||
? `script-src 'self' 'nonce-${nonce}' blob:`
|
||||
: "script-src 'self' 'unsafe-inline' blob:";
|
||||
const directives = [
|
||||
"default-src 'self'",
|
||||
isDev
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
|
||||
: "script-src 'self' 'unsafe-inline' blob:",
|
||||
scriptSrc,
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"img-src 'self' data: blob: https:",
|
||||
isDev
|
||||
@@ -35,10 +38,8 @@ function buildCsp(_nonce: string): string {
|
||||
export function middleware(request: NextRequest) {
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
|
||||
|
||||
// Forward a nonce for future fully-wired CSP support. Do not include it in
|
||||
// script-src until every Next inline bootstrap script receives the nonce;
|
||||
// otherwise production hydration can fail and leave the app on the static
|
||||
// "prioritizing map feeds" shell.
|
||||
// Forward a nonce for staged CSP support. Strict script-src is opt-in until
|
||||
// every Next inline bootstrap script is verified with the nonce in production.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('x-nonce', nonce);
|
||||
|
||||
@@ -46,7 +47,11 @@ export function middleware(request: NextRequest) {
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
|
||||
response.headers.set('Content-Security-Policy', buildCsp(nonce));
|
||||
const strictCsp = process.env.SHADOWBROKER_STRICT_CSP === '1';
|
||||
response.headers.set('Content-Security-Policy', buildCsp(nonce, strictCsp));
|
||||
if (!strictCsp && process.env.NODE_ENV === 'production') {
|
||||
response.headers.set('Content-Security-Policy-Report-Only', buildCsp(nonce, true));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
apiVersion: v2
|
||||
name: shadowbroker
|
||||
version: 0.9.75
|
||||
appVersion: "0.9.75"
|
||||
version: 0.9.79
|
||||
appVersion: "0.9.79"
|
||||
description: simple shadowbroker installation
|
||||
type: application
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "shadowbroker"
|
||||
version = "0.9.75"
|
||||
version = "0.9.79"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
@@ -74,7 +74,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "backend"
|
||||
version = "0.9.75"
|
||||
version = "0.9.79"
|
||||
source = { editable = "backend" }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -2257,7 +2257,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "shadowbroker"
|
||||
version = "0.9.75"
|
||||
version = "0.9.79"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.metadata]
|
||||
|
||||
Reference in New Issue
Block a user