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:
parent
a658e924e1
commit
95f52572fc
@ -291,6 +291,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
"federation.approve-request" => self.handle_federation_approve_request(params).await,
|
"federation.approve-request" => self.handle_federation_approve_request(params).await,
|
||||||
"federation.reject-request" => self.handle_federation_reject_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 & Remote Access
|
||||||
"vpn.status" => self.handle_vpn_status().await,
|
"vpn.status" => self.handle_vpn_status().await,
|
||||||
|
|||||||
@ -993,4 +993,62 @@ impl RpcHandler {
|
|||||||
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
|
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
|
||||||
Ok(serde_json::json!({ "rejected": true, "id": id }))
|
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 }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,6 +218,7 @@ impl RpcHandler {
|
|||||||
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
|
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
|
||||||
let mut applied_invites: Vec<String> = Vec::new();
|
let mut applied_invites: Vec<String> = Vec::new();
|
||||||
let mut rejected_outbound: 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();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for hs in &handshakes {
|
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,
|
"new_requests": new_requests,
|
||||||
"applied_invites": applied_invites,
|
"applied_invites": applied_invites,
|
||||||
"rejected_outbound": rejected_outbound,
|
"rejected_outbound": rejected_outbound,
|
||||||
|
"cancelled_inbound": cancelled_inbound,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -225,6 +225,20 @@ pub async fn set_state(data_dir: &Path, id: &str, state: PendingState) -> Result
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -68,6 +68,16 @@ pub enum HandshakeMessage {
|
|||||||
/// Rejection reply. Optional one-line reason for the user.
|
/// Rejection reply. Optional one-line reason for the user.
|
||||||
#[serde(rename = "peer-reject")]
|
#[serde(rename = "peer-reject")]
|
||||||
PeerReject { reason: Option<String> },
|
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
|
/// Result of polling for incoming handshake messages
|
||||||
@ -395,6 +405,34 @@ pub async fn send_peer_reject(
|
|||||||
Ok(())
|
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.
|
/// Poll relays for incoming encrypted handshake DMs addressed to us.
|
||||||
/// Returns new handshake messages since `since` timestamp.
|
/// Returns new handshake messages since `since` timestamp.
|
||||||
pub async fn poll_handshakes(
|
pub async fn poll_handshakes(
|
||||||
|
|||||||
@ -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<{
|
async federationSyncState(): Promise<{
|
||||||
synced: number
|
synced: number
|
||||||
failed: number
|
failed: number
|
||||||
|
|||||||
@ -91,6 +91,7 @@
|
|||||||
@poll="pollHandshake"
|
@poll="pollHandshake"
|
||||||
@approve="approvePending"
|
@approve="approvePending"
|
||||||
@reject="rejectPending"
|
@reject="rejectPending"
|
||||||
|
@cancel="cancelPending"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NodeList
|
<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 {
|
function isOnlineCheck(node: FederatedNode): boolean {
|
||||||
if (!node.last_seen) return false
|
if (!node.last_seen) return false
|
||||||
const lastSeen = new Date(node.last_seen).getTime()
|
const lastSeen = new Date(node.last_seen).getTime()
|
||||||
|
|||||||
@ -56,6 +56,16 @@
|
|||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -76,6 +86,7 @@ defineEmits<{
|
|||||||
poll: []
|
poll: []
|
||||||
approve: [id: string]
|
approve: [id: string]
|
||||||
reject: [id: string]
|
reject: [id: string]
|
||||||
|
cancel: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Hide already-handled rows older than 24h to keep the panel from growing
|
// Hide already-handled rows older than 24h to keep the panel from growing
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user