From 2e8417e39b746106e1a552099c262cf344808e36 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 19 Apr 2026 02:28:16 -0400 Subject: [PATCH] feat(federation): cancel button for outbound pending peer requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the Pending Peer Requests panel only had Approve/Reject for inbound rows; outbound rows in the 'sent' state had no action and would sit there until the target explicitly approved or rejected. Now you can Cancel an outbound request — the local row is dropped and a PeerCancel nostr DM is sent so the target's inbound row also disappears. Backend: - HandshakeMessage::PeerCancel {reason: Option} variant. - nostr_handshake::send_peer_cancel() mirrors send_peer_reject. - handshake.poll handler dispatches inbound PeerCancel: finds the matching inbound pending row (same from_nostr_pubkey, state=Pending) and deletes it. Reply shape gains `cancelled_inbound: [id]`. - federation::pending::delete() — hard-remove (set_state only transitions; we don't want 'Cancelled' ghosts in the audit trail). - federation.cancel-request RPC: outbound+Sent only, default notify=true (cancelling silently is a footgun), best-effort DM (relay failure doesn't block local deletion). Wired in dispatcher. Frontend: - PendingRequestsPanel.vue: Cancel button appears only on outbound+sent rows. Emits 'cancel' event with request id. - Federation.vue: cancelPending(id) handler calls rpcClient.federationCancelRequest and reloads the list. - rpcClient.federationCancelRequest(id, reason?, notify=true). Co-Authored-By: Claude Opus 4.7 (1M context) --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + .../src/api/rpc/federation/handlers.rs | 58 +++++++++++++++++++ core/archipelago/src/api/rpc/handshake.rs | 21 +++++++ core/archipelago/src/federation/pending.rs | 14 +++++ core/archipelago/src/nostr_handshake.rs | 38 ++++++++++++ neode-ui/src/api/rpc-client.ts | 19 ++++++ neode-ui/src/views/Federation.vue | 17 ++++++ .../views/federation/PendingRequestsPanel.vue | 11 ++++ 8 files changed, 179 insertions(+) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index ad089f32..55206dbc 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -291,6 +291,7 @@ impl RpcHandler { } "federation.approve-request" => self.handle_federation_approve_request(params).await, "federation.reject-request" => self.handle_federation_reject_request(params).await, + "federation.cancel-request" => self.handle_federation_cancel_request(params).await, // VPN & Remote Access "vpn.status" => self.handle_vpn_status().await, diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index baef7b6a..d7a0c751 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -993,4 +993,62 @@ impl RpcHandler { info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request"); Ok(serde_json::json!({ "rejected": true, "id": id })) } + + /// federation.cancel-request — withdraw an outbound peer request we + /// sent but haven't heard back on. The local row is deleted and, + /// unless `notify=false`, a PeerCancel nostr DM is sent so the + /// target drops their inbound pending row. + pub(in crate::api::rpc) async fn handle_federation_cancel_request( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let id = params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing id"))?; + let reason = params.get("reason").and_then(|v| v.as_str()); + // Default TRUE — cancelling without notifying is a footgun (the + // recipient's UI keeps showing an unanswerable request). + let notify = params + .get("notify") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let req = pending::find_by_id(&self.config.data_dir, id) + .await? + .ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?; + if !req.outbound || !matches!(req.state, pending::PendingState::Sent) { + anyhow::bail!( + "Can only cancel outbound requests in Sent state (outbound={}, state={:?})", + req.outbound, + req.state + ); + } + + if notify { + let identity_dir = self.config.data_dir.join("identity"); + // Best-effort: log but don't fail the cancel if the nostr + // relay is unreachable — the local row is still dropped. + if let Err(e) = nostr_handshake::send_peer_cancel( + &identity_dir, + &req.from_nostr_pubkey, + reason, + &self.config.nostr_relays, + self.config.nostr_tor_proxy.as_deref(), + ) + .await + { + tracing::warn!( + id = %id, + error = %e, + "peer-cancel DM failed; local row dropped anyway" + ); + } + } + + pending::delete(&self.config.data_dir, id).await?; + info!(id = %id, to = %req.from_nostr_pubkey, notified = notify, "Cancelled outbound peer request"); + Ok(serde_json::json!({ "cancelled": true, "id": id, "notified": notify })) + } } diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index 8cd0a432..12464a1d 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -218,6 +218,7 @@ impl RpcHandler { let mut new_requests: Vec = Vec::new(); let mut applied_invites: Vec = Vec::new(); let mut rejected_outbound: Vec = Vec::new(); + let mut cancelled_inbound: Vec = Vec::new(); let mut skipped: Vec = Vec::new(); for hs in &handshakes { @@ -354,6 +355,25 @@ impl RpcHandler { ); } } + HandshakeMessage::PeerCancel { reason } => { + // Peer withdrew their PeerRequest — drop our matching + // inbound pending row so it disappears from the UI. + 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::Pending) + }) { + let row_id = row.id.clone(); + pending::delete(&self.config.data_dir, &row_id).await?; + cancelled_inbound.push(row_id); + tracing::info!( + from = %hs.from_nostr_pubkey, + reason = ?reason, + "Inbound peer request cancelled by sender" + ); + } + } } } @@ -362,6 +382,7 @@ impl RpcHandler { "new_requests": new_requests, "applied_invites": applied_invites, "rejected_outbound": rejected_outbound, + "cancelled_inbound": cancelled_inbound, "skipped": skipped, })) } diff --git a/core/archipelago/src/federation/pending.rs b/core/archipelago/src/federation/pending.rs index f58d390b..310eaded 100644 --- a/core/archipelago/src/federation/pending.rs +++ b/core/archipelago/src/federation/pending.rs @@ -225,6 +225,20 @@ pub async fn set_state(data_dir: &Path, id: &str, state: PendingState) -> Result Ok(()) } +/// Remove a pending request entirely. Used when the sender cancels an +/// outbound request they initiated and we want it gone (not just marked +/// Rejected/Cancelled — those states fill up the UI audit trail). +pub async fn delete(data_dir: &Path, id: &str) -> Result<()> { + let mut requests = load_pending(data_dir).await?; + let before = requests.len(); + requests.retain(|r| r.id != id); + if requests.len() == before { + anyhow::bail!("Pending request not found: {}", id); + } + save_pending(data_dir, &requests).await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 2b82c033..69f1a708 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -68,6 +68,16 @@ pub enum HandshakeMessage { /// Rejection reply. Optional one-line reason for the user. #[serde(rename = "peer-reject")] PeerReject { reason: Option }, + /// Cancellation of a previously-sent `PeerRequest`. Sent by the + /// original requester to withdraw the handshake before the target + /// has approved/rejected it. The recipient removes the matching + /// inbound pending row so it no longer shows in their UI. + #[serde(rename = "peer-cancel")] + PeerCancel { + /// Optional one-line reason (shown in the target's UI if they + /// still have the row). Typically empty. + reason: Option, + }, } /// Result of polling for incoming handshake messages @@ -395,6 +405,34 @@ pub async fn send_peer_reject( Ok(()) } +/// Send a `PeerCancel` notice to withdraw a previously-sent PeerRequest. +/// The recipient will drop their matching inbound pending row. +pub async fn send_peer_cancel( + identity_dir: &Path, + recipient_nostr_pubkey: &str, + reason: Option<&str>, + relays: &[String], + tor_proxy: Option<&str>, +) -> Result<()> { + let msg = HandshakeMessage::PeerCancel { + reason: reason.map(String::from), + }; + send_handshake_message( + identity_dir, + recipient_nostr_pubkey, + &msg, + relays, + tor_proxy, + ) + .await?; + tracing::info!( + "↩️ Sent peer-cancel to {}...{}", + &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], + &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] + ); + Ok(()) +} + /// Poll relays for incoming encrypted handshake DMs addressed to us. /// Returns new handshake messages since `since` timestamp. pub async fn poll_handshakes( diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 84aa2666..40b5e800 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -742,6 +742,25 @@ class RPCClient { }) } + /// Withdraw an outbound peer request we sent. Default notify=true so + /// the recipient's inbound row disappears from their UI (the main + /// UX reason to cancel — avoid leaving a stale handshake dangling). + async federationCancelRequest( + id: string, + reason?: string, + notify = true, + ): Promise<{ cancelled: boolean; id: string; notified: boolean }> { + return this.call({ + method: 'federation.cancel-request', + params: { + id, + ...(reason ? { reason } : {}), + notify, + }, + timeout: 30000, + }) + } + async federationSyncState(): Promise<{ synced: number failed: number diff --git a/neode-ui/src/views/Federation.vue b/neode-ui/src/views/Federation.vue index 1297e0b4..fc84cf5f 100644 --- a/neode-ui/src/views/Federation.vue +++ b/neode-ui/src/views/Federation.vue @@ -91,6 +91,7 @@ @poll="pollHandshake" @approve="approvePending" @reject="rejectPending" + @cancel="cancelPending" /> +
+ +
@@ -76,6 +86,7 @@ defineEmits<{ poll: [] approve: [id: string] reject: [id: string] + cancel: [id: string] }>() // Hide already-handled rows older than 24h to keep the panel from growing