feat(federation): cancel button for outbound pending peer requests

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<String>} 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) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-19 02:28:16 -04:00
parent a658e924e1
commit 95f52572fc
8 changed files with 179 additions and 0 deletions

View File

@ -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,

View File

@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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 }))
}
}

View File

@ -218,6 +218,7 @@ impl RpcHandler {
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
let mut applied_invites: Vec<String> = Vec::new();
let mut rejected_outbound: Vec<String> = Vec::new();
let mut cancelled_inbound: Vec<String> = Vec::new();
let mut skipped: Vec<String> = 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,
}))
}

View File

@ -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::*;

View File

@ -68,6 +68,16 @@ pub enum HandshakeMessage {
/// Rejection reply. Optional one-line reason for the user.
#[serde(rename = "peer-reject")]
PeerReject { reason: Option<String> },
/// 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<String>,
},
}
/// 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(

View File

@ -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

View File

@ -91,6 +91,7 @@
@poll="pollHandshake"
@approve="approvePending"
@reject="rejectPending"
@cancel="cancelPending"
/>
<NodeList
@ -346,6 +347,22 @@ async function rejectPending(id: string) {
}
}
async function cancelPending(id: string) {
pendingBusyId.value = id
discoveryError.value = ''
try {
// Default notify=true from the rpc-client the peer's inbound row
// disappears from their UI so they don't have to wonder about a
// stale handshake.
await rpcClient.federationCancelRequest(id)
await loadPendingRequests()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Cancel failed'
} finally {
pendingBusyId.value = null
}
}
function isOnlineCheck(node: FederatedNode): boolean {
if (!node.last_seen) return false
const lastSeen = new Date(node.last_seen).getTime()

View File

@ -56,6 +56,16 @@
Reject
</button>
</div>
<div v-else-if="req.outbound && req.state === 'sent'" class="flex flex-col gap-2 shrink-0">
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-white/70 hover:text-red-300 disabled:opacity-50"
:disabled="busyId === req.id"
:title="'Withdraw the request and notify the peer to drop their pending row'"
@click="$emit('cancel', req.id)"
>
{{ busyId === req.id ? '…' : 'Cancel' }}
</button>
</div>
</div>
</div>
</div>
@ -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