//! Nostr peer-discovery RPCs. //! //! `handshake.discover` — browse other nodes' presence events on configured //! relays. Returns DID + nostr pubkey only; no onion is ever exposed. //! //! `handshake.connect` — send a `PeerRequest` to a discovered node's nostr //! pubkey. Records the outbound request locally so the user can see what //! they've sent. Does NOT include our onion address on the wire. //! //! `handshake.poll` — fetch new NIP-44 DMs addressed to our nostr pubkey //! and dispatch them: inbound `PeerRequest` is queued in //! `federation::pending` for manual approval; inbound `PeerInvite` is //! applied via the existing federation invite-acceptance flow (which //! adds the new peer as `Observer` — see federation.rs); inbound //! `PeerReject` is recorded against the matching outbound row. use super::RpcHandler; use crate::federation::pending::{self, PendingPeerRequest, PendingState}; use crate::nostr_handshake::{self, HandshakeMessage}; use anyhow::{Context, Result}; use nostr_sdk::FromBech32; use serde::{Deserialize, Serialize}; const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json"; /// Runtime override for `Config::nostr_discovery_enabled`. The OS-level /// config file is read once at boot and is OFF by default; this state file /// lets the user flip discoverability on/off at runtime via the Federation /// UI without restarting the service. Both the boot-time presence publish /// and the `handshake.poll` handler check this file before doing anything. #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct NostrDiscoveryState { #[serde(default)] enabled: bool, } async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState { let path = data_dir.join(NOSTR_STATE_FILE); match tokio::fs::read_to_string(&path).await { Ok(s) => serde_json::from_str(&s).unwrap_or_default(), Err(_) => NostrDiscoveryState::default(), } } async fn save_discovery_state( data_dir: &std::path::Path, state: &NostrDiscoveryState, ) -> Result<()> { let path = data_dir.join(NOSTR_STATE_FILE); let content = serde_json::to_string_pretty(state).context("serialize discovery state")?; tokio::fs::write(&path, content) .await .context("write discovery state")?; Ok(()) } impl RpcHandler { /// Read the current runtime discoverability flag. pub(super) async fn handle_nostr_discovery_status(&self) -> Result { let state = load_discovery_state(&self.config.data_dir).await; Ok(serde_json::json!({ "enabled": state.enabled })) } /// Set the runtime discoverability flag. If turning ON, publish presence /// once immediately so the user gets visible feedback that the relays /// have been notified. If turning OFF, do NOT actively scrub the relays /// here — `nostr_handshake::publish_presence` is replaceable, so the /// next reboot's startup pass plus the existing legacy revocation in /// `nostr_discovery::revoke_legacy_advertisements` are sufficient. A /// future Layer 3 task adds an explicit "tombstone" publish if needed. pub(super) async fn handle_nostr_set_discovery( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let enabled = params .get("enabled") .and_then(|v| v.as_bool()) .ok_or_else(|| anyhow::anyhow!("Missing enabled"))?; save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?; if enabled && !self.config.nostr_relays.is_empty() { let (data, _) = self.state_manager.get_snapshot().await; let identity_dir = self.config.data_dir.join("identity"); let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey) .unwrap_or_default(); let version = data.server_info.version.clone(); let relays = self.config.nostr_relays.clone(); let tor_proxy = self.config.nostr_tor_proxy.clone(); tokio::spawn(async move { if let Err(e) = nostr_handshake::publish_presence( &identity_dir, &did, &version, &relays, tor_proxy.as_deref(), ) .await { tracing::warn!("Initial presence publish failed: {}", e); } }); } Ok(serde_json::json!({ "enabled": enabled })) } /// Discover discoverable nodes via Nostr presence events. /// Returns (nostr_pubkey, npub, DID, version) only — never an onion. pub(super) async fn handle_handshake_discover(&self) -> Result { // Discoverability gate: respect the runtime toggle. We allow `discover` // to query relays as long as the user is actively browsing — they're // an anonymous observer of presence events, not publishing anything. let identity_dir = self.config.data_dir.join("identity"); let nodes = nostr_handshake::discover_nodes( &identity_dir, &self.config.nostr_relays, self.config.nostr_tor_proxy.as_deref(), ) .await?; Ok(serde_json::json!({ "nodes": nodes })) } /// Send a `PeerRequest` to a discovered node. Onion is never sent. /// Params: `{ recipient_nostr_pubkey, message?, name? }`. pub(super) async fn handle_handshake_connect( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let recipient_raw = params .get("recipient_nostr_pubkey") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?; let recipient_hex = if recipient_raw.starts_with("npub1") { nostr_sdk::PublicKey::from_bech32(recipient_raw) .map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))? .to_hex() } else { recipient_raw.to_string() }; let recipient_npub = nostr_sdk::PublicKey::from_hex(&recipient_hex) .ok() .and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok()) .unwrap_or_default(); let message = params.get("message").and_then(|v| v.as_str()); let optional_name = params.get("name").and_then(|v| v.as_str()); let (data, _) = self.state_manager.get_snapshot().await; let our_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); let our_version = &data.server_info.version; let our_name = optional_name.or(data.server_info.name.as_deref()); let identity_dir = self.config.data_dir.join("identity"); nostr_handshake::send_peer_request( &identity_dir, &recipient_hex, &our_did, our_version, our_name, message, &self.config.nostr_relays, self.config.nostr_tor_proxy.as_deref(), ) .await?; // Record the outbound request so the user can see "Sent" status // and so the eventual NIP-44 PeerInvite reply can be matched. let row = pending::insert_outbound( &self.config.data_dir, recipient_hex.clone(), recipient_npub, String::new(), // remote DID unknown until they reply None, message.map(String::from), ) .await?; Ok(serde_json::json!({ "ok": true, "sent_to": recipient_hex, "id": row.id, })) } /// Poll relays for inbound NIP-44 handshake messages, then dispatch: /// - `PeerRequest` → queue in `federation::pending` for approval /// - `PeerInvite` → apply via federation invite flow (adds as Observer) /// - `PeerReject` → mark matching outbound row as `Rejected` /// /// Never auto-adds peers, never auto-responds, never sends our onion. pub(super) async fn handle_handshake_poll(&self) -> Result { // Runtime gate: if the user hasn't enabled discoverability, don't // touch the relays. The poll endpoint is a hard no-op until they // explicitly opt in via the Federation UI toggle. let state = load_discovery_state(&self.config.data_dir).await; if !state.enabled { return Ok(serde_json::json!({ "polled": 0, "new_requests": Vec::::new(), "applied_invites": Vec::::new(), "rejected_outbound": Vec::::new(), "skipped": Vec::::new(), "discovery_disabled": true, })); } let identity_dir = self.config.data_dir.join("identity"); let handshakes = nostr_handshake::poll_handshakes( &identity_dir, &self.config.nostr_relays, self.config.nostr_tor_proxy.as_deref(), None, ) .await?; let mut new_requests: Vec = Vec::new(); let mut applied_invites: Vec = Vec::new(); let mut rejected_outbound: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for hs in &handshakes { match &hs.message { HandshakeMessage::PeerRequest { from_did, version: _, name, message, } => { match pending::insert_inbound( &self.config.data_dir, hs.from_nostr_pubkey.clone(), hs.from_nostr_npub.clone(), from_did.clone(), name.clone(), message.clone(), ) .await { Ok(Some(row)) => new_requests.push(row), Ok(None) => skipped.push(hs.from_nostr_pubkey.clone()), Err(e) => { tracing::warn!( from = %hs.from_nostr_pubkey, error = %e, "Dropped peer request (rate limit or storage error)" ); skipped.push(hs.from_nostr_pubkey.clone()); } } } HandshakeMessage::PeerInvite { invite_code } => { // Match against an outbound Sent request from this nostr // pubkey. If we never sent them anything, ignore — we // don't accept unsolicited invites over Nostr. let pendings = pending::load_pending(&self.config.data_dir).await?; let matching = pendings.iter().find(|r| { r.outbound && r.from_nostr_pubkey == hs.from_nostr_pubkey && matches!(r.state, PendingState::Sent) }); let Some(row) = matching else { tracing::warn!( from = %hs.from_nostr_pubkey, "Ignoring unsolicited PeerInvite — no matching Sent request" ); continue; }; let row_id = row.id.clone(); let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey) .unwrap_or_default(); let local_onion = data.server_info.tor_address.clone().unwrap_or_default(); let local_pubkey = data.server_info.pubkey.clone(); let identity_dir2 = self.config.data_dir.join("identity"); let node_identity = crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?; match crate::federation::accept_invite( &self.config.data_dir, invite_code, &local_did, &local_onion, &local_pubkey, |bytes| node_identity.sign(bytes), ) .await { Ok(node) => { // Approved-by-them: their box already has us as Observer // (their approval handler added us under that trust level // before sending the invite). Demote our local entry to // Observer too — accept_invite hardcodes Trusted, but the // discovery flow should never auto-trust. let _ = crate::federation::set_trust_level( &self.config.data_dir, &node.did, crate::federation::TrustLevel::Observer, ) .await; // Mirror into the mesh peer table immediately so the // chat UI can address the new peer without waiting // for the next mesh restart. let svc = self.mesh_service.read().await; if let Some(svc) = svc.as_ref() { crate::mesh::upsert_federation_peer( &svc.shared_state(), &node.pubkey, &node.did, node.name.as_deref(), ) .await; } pending::set_state( &self.config.data_dir, &row_id, PendingState::Approved, ) .await?; applied_invites.push(node.did); } Err(e) => { tracing::warn!( from = %hs.from_nostr_pubkey, error = %e, "Failed to apply PeerInvite" ); } } } HandshakeMessage::PeerReject { reason } => { let pendings = pending::load_pending(&self.config.data_dir).await?; if let Some(row) = pendings.iter().find(|r| { r.outbound && r.from_nostr_pubkey == hs.from_nostr_pubkey && matches!(r.state, PendingState::Sent) }) { let row_id = row.id.clone(); pending::set_state(&self.config.data_dir, &row_id, PendingState::Rejected) .await?; rejected_outbound.push(row_id); tracing::info!( from = %hs.from_nostr_pubkey, reason = ?reason, "Outbound peer request rejected" ); } } } } Ok(serde_json::json!({ "polled": handshakes.len(), "new_requests": new_requests, "applied_invites": applied_invites, "rejected_outbound": rejected_outbound, "skipped": skipped, })) } }