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.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,
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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::*;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user