feat(peers): bidirectional /network peer requests

Before: Alice sent /network.send-request to Bob, Bob accepted via
/network.accept-request and gained Alice in his peers list, but Alice
was never notified — her pending row sat there and she had to
manually add Bob separately. User complaint: "it's strange you have
to do it both ways."

Fix — the accept now fires a best-effort connection_accepted message
back to the requester:

- handle_network_accept_request: after writing the local peer record,
  assembles a `{type: "connection_accepted", request_id, from_did,
  from_onion, from_pubkey}` JSON, signs + encrypts + POSTs it to the
  requester via node_message::send_to_peer. Uses PeerRequest internally
  so it prefers FIPS and falls back to Tor.
- handle_node_message: parses incoming plaintext as JSON; on a match
  for type=connection_accepted, auto-adds the sender to peers.json
  (the existing self-pubkey guard in add_peer still applies) and
  short-circuits the normal store_received path so the acceptance
  doesn't also land as a chat message in Alice's inbox.

Offline handling: if Alice is offline when Bob accepts, the notify
warns and the local accept still succeeds. Alice will receive any
subsequent message from Bob normally; future iteration could
retry on reconnect.

Federation-invite flow (federation.accept-invite → notify_join) was
already bidirectional; this closes the gap for the peer flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-19 04:34:37 -04:00
co-authored by Claude Opus 4.7
parent 60758263f3
commit f756365935
2 changed files with 87 additions and 2 deletions
@@ -89,6 +89,47 @@ impl ApiHandler {
msg.clone()
};
// Detect a `connection_accepted` reply: the remote peer just
// approved an outbound request we sent, so mirror their add on
// our side (bidirectional peering without a manual second
// click). JSON-shape only — any non-matching payload stays in
// the normal received-messages store below.
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&plaintext) {
if val.get("type").and_then(|v| v.as_str()) == Some("connection_accepted") {
if let (Some(their_onion), Some(their_pubkey)) = (
val.get("from_onion").and_then(|v| v.as_str()),
val.get("from_pubkey").and_then(|v| v.as_str()),
) {
let data_dir = std::path::Path::new("/var/lib/archipelago");
let peer = crate::peers::KnownPeer {
onion: their_onion.to_string(),
pubkey: their_pubkey.to_string(),
name: val
.get("from_name")
.and_then(|v| v.as_str())
.map(String::from),
added_at: Some(chrono::Utc::now().to_rfc3339()),
};
match crate::peers::add_peer(data_dir, peer).await {
Ok(_) => tracing::info!(
from = %sanitize_log_string(from),
"Auto-added peer after connection_accepted"
),
Err(e) => tracing::warn!(
from = %sanitize_log_string(from),
error = %e,
"Failed to auto-add peer on connection_accepted"
),
}
}
return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
));
}
}
let safe_from = sanitize_log_string(from);
let safe_msg = sanitize_log_string(&plaintext);
tracing::info!("Received message from {}: {}", safe_from, safe_msg);