Skip to main content

wire/
pair_invite.rs

1//! Invite-URL pair flow (v0.4.0). Single-paste, zero-config pairing.
2//!
3//! Flow:
4//!   A: `wire invite` → URL.
5//!   A pastes URL into any channel (Discord, SMS, voice-read).
6//!   B: `wire accept <URL>` → done. Both pinned.
7//!
8//! The invite URL is a self-contained bearer credential carrying A's signed
9//! agent-card, relay coords, slot_token, and a single-use pair_nonce. B parses
10//! it locally (no relay round-trip yet), pins A from the URL contents, then
11//! POSTs a signed kind=1100 `pair_drop` event to A's slot using the slot_token
12//! the URL granted. A's daemon (run_sync_pull) recognizes pair_drop events
13//! that carry a matching pending_invite nonce, verifies the embedded card,
14//! pins B, and consumes the nonce. Both sides paired.
15//!
16//! Trust model: pasting = trusting. Equivalent to Discord invite link, Zoom
17//! join URL, Signal group invite. Operator's act of moving the URL between
18//! channels IS the authentication ceremony. No SAS digits, no PAKE.
19//!
20//! The SPAKE2 + SAS code-phrase flow (`wire pair-host` / `wire pair-join` /
21//! `wire pair-confirm`) was removed in the RFC-005 follow-on. `wire dial` (with
22//! the bilateral `wire accept` gate) is the sole canonical pairing path;
23//! `wire invite` + `wire accept-invite` cover the recipient-can't-host-a-slot case.
24
25use std::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use anyhow::{Context, Result, anyhow, bail};
29use base64::Engine as _;
30use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
31use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34
35use crate::config;
36
37pub const DEFAULT_RELAY: &str = "https://wireup.net";
38pub const DEFAULT_TTL_SECS: u64 = 86_400; // 24 hours
39
40/// P0.2 (0.5.11): write a structured rejection record for `wire doctor`
41/// to surface later. Best-effort — if we can't even open the file, fall
42/// back to stderr so the operator at least sees the failure mode in their
43/// shell. Anything is better than silent.
44///
45/// Lives at `$WIRE_HOME/state/wire/pair-rejected.jsonl`. One JSON line per
46/// rejected pair event. Append-only.
47pub(crate) fn record_pair_rejection(peer_handle: &str, code: &str, detail: &str) {
48    let line = json!({
49        "ts": std::time::SystemTime::now()
50            .duration_since(std::time::UNIX_EPOCH)
51            .map(|d| d.as_secs())
52            .unwrap_or(0),
53        "peer": peer_handle,
54        "code": code,
55        "detail": detail,
56    });
57    let serialised = match serde_json::to_string(&line) {
58        Ok(s) => s,
59        Err(e) => {
60            eprintln!("wire: could not serialise pair-rejected entry: {e}");
61            return;
62        }
63    };
64    let path = match config::state_dir() {
65        Ok(d) => d.join("pair-rejected.jsonl"),
66        Err(e) => {
67            eprintln!("wire: state_dir unresolved, dropping pair-rejected log: {e}");
68            return;
69        }
70    };
71    if let Some(parent) = path.parent()
72        && let Err(e) = std::fs::create_dir_all(parent)
73    {
74        eprintln!("wire: could not create {parent:?}: {e}");
75        return;
76    }
77    use std::io::Write;
78    match std::fs::OpenOptions::new()
79        .create(true)
80        .append(true)
81        .open(&path)
82    {
83        Ok(mut f) => {
84            if let Err(e) = writeln!(f, "{serialised}") {
85                eprintln!("wire: could not append pair-rejected to {path:?}: {e}");
86            }
87        }
88        Err(e) => {
89            eprintln!("wire: could not open {path:?}: {e}");
90        }
91    }
92}
93
94/// Decoded contents of an invite URL.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct InvitePayload {
97    /// Schema version. Currently 1.
98    pub v: u32,
99    /// Issuer DID, e.g. `did:wire:paul`.
100    pub did: String,
101    /// Issuer's signed agent-card (full JSON).
102    pub card: Value,
103    /// Relay URL hosting the issuer's slot.
104    pub relay_url: String,
105    /// Issuer's slot id (32 hex chars).
106    pub slot_id: String,
107    /// Issuer's slot token (bearer auth for POSTing events to that slot).
108    pub slot_token: String,
109    /// Single-use nonce (32 random bytes hex).
110    pub nonce: String,
111    /// Unix timestamp after which this invite is invalid.
112    pub exp: u64,
113}
114
115/// On-disk record for a minted invite, awaiting acceptance.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PendingInvite {
118    pub nonce: String,
119    pub exp: u64,
120    pub uses_remaining: u32,
121    /// DIDs of peers who have already paired via this invite (for multi-use).
122    pub accepted_by: Vec<String>,
123    pub created_at: String,
124}
125
126/// Default-on policy: accept signed pair_drops from unknown peers (v0.5
127/// zero-paste discovery). Operator can opt out by writing
128/// `$WIRE_HOME/config/wire/policy.json` containing `{"accept_unknown_pair_drops": false}`.
129fn open_mode_enabled() -> bool {
130    let path = match config::config_dir() {
131        Ok(p) => p.join("policy.json"),
132        Err(_) => return true,
133    };
134    let bytes = match std::fs::read(&path) {
135        Ok(b) => b,
136        Err(_) => return true,
137    };
138    let v: Value = match serde_json::from_slice(&bytes) {
139        Ok(v) => v,
140        Err(_) => return true,
141    };
142    v.get("accept_unknown_pair_drops")
143        .and_then(Value::as_bool)
144        .unwrap_or(true)
145}
146
147pub fn pending_invites_dir() -> Result<PathBuf> {
148    Ok(config::state_dir()?.join("pending-invites"))
149}
150
151fn now_unix() -> u64 {
152    SystemTime::now()
153        .duration_since(UNIX_EPOCH)
154        .map(|d| d.as_secs())
155        .unwrap_or(0)
156}
157
158/// Hostname-derived default handle for auto-init. Falls back to "wire-user"
159/// if hostname is unavailable. Sanitized to ASCII alphanumeric / '-' / '_'.
160fn default_handle() -> String {
161    let raw = hostname::get()
162        .ok()
163        .and_then(|s| s.into_string().ok())
164        .unwrap_or_else(|| "wire-user".into());
165    let sanitized: String = raw
166        .chars()
167        .map(|c| {
168            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
169                c
170            } else {
171                '-'
172            }
173        })
174        .collect();
175    if sanitized.is_empty() {
176        "wire-user".into()
177    } else {
178        sanitized
179    }
180}
181
182/// Ensure this node has an identity + relay slot. Idempotent.
183/// Returns (did, relay_url, slot_id, slot_token).
184pub fn ensure_self_with_relay(
185    preferred_relay: Option<&str>,
186) -> Result<(String, String, String, String)> {
187    let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
188
189    if !config::is_initialized()? {
190        let handle = default_handle();
191        crate::init::init_self_idempotent(&handle, None, Some(relay))
192            .with_context(|| format!("auto-init as did:wire:{handle}"))?;
193    }
194
195    let card = config::read_agent_card()?;
196    let did = card
197        .get("did")
198        .and_then(Value::as_str)
199        .ok_or_else(|| anyhow!("agent-card missing did"))?
200        .to_string();
201
202    let mut relay_state = config::read_relay_state()?;
203
204    // v0.6.6: prefer an existing endpoint over allocating a new one.
205    // `--local-only` sessions don't have legacy `self.slot_id` but DO
206    // have `self.endpoints[]` with a local slot — those should be
207    // honored, not stomped with a fresh federation allocation. Without
208    // this guard, `wire accept` on a local-only session would
209    // auto-allocate a federation slot at DEFAULT_RELAY (wireup.net)
210    // every time, silently turning local-only sessions into dual-slot.
211    let existing = crate::endpoints::self_endpoints(&relay_state);
212    if !existing.is_empty() {
213        let ep = existing
214            .iter()
215            .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
216            .cloned()
217            .unwrap_or_else(|| existing[0].clone());
218        return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
219    }
220
221    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
222
223    if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
224        let client = crate::relay_client::RelayClient::new(relay);
225        client.check_healthz()?;
226        let handle = crate::agent_card::display_handle_from_did(&did);
227        let alloc = client.allocate_slot(Some(handle))?;
228        relay_state["self"] = json!({
229            "relay_url": relay,
230            "slot_id": alloc.slot_id,
231            "slot_token": alloc.slot_token,
232        });
233        config::write_relay_state(&relay_state)?;
234    }
235
236    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
237    let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
238    let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
239    let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
240    if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
241        bail!("self relay state incomplete after auto-allocate");
242    }
243    Ok((did, relay_url, slot_id, slot_token))
244}
245
246/// Mint a fresh invite URL. Auto-inits + auto-allocates relay slot if needed.
247pub fn mint_invite(
248    ttl_secs: Option<u64>,
249    uses: u32,
250    preferred_relay: Option<&str>,
251) -> Result<String> {
252    let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
253
254    let card = config::read_agent_card()?;
255    let sk_seed = config::read_private_key()?;
256
257    let mut nonce_bytes = [0u8; 32];
258    use rand::RngCore;
259    rand::thread_rng().fill_bytes(&mut nonce_bytes);
260    let nonce = hex::encode(nonce_bytes);
261
262    let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
263    let exp = now_unix() + ttl;
264
265    let payload = InvitePayload {
266        v: 1,
267        did: did.clone(),
268        card,
269        relay_url,
270        slot_id,
271        slot_token,
272        nonce: nonce.clone(),
273        exp,
274    };
275    let payload_bytes = serde_json::to_vec(&payload)?;
276
277    let mut sk_arr = [0u8; 32];
278    sk_arr.copy_from_slice(&sk_seed[..32]);
279    let sk = SigningKey::from_bytes(&sk_arr);
280    let sig = sk.sign(&payload_bytes);
281
282    let token = format!(
283        "{}.{}",
284        B64URL.encode(&payload_bytes),
285        B64URL.encode(sig.to_bytes())
286    );
287    let url = format!("wire://pair?v=1&inv={token}");
288
289    let now = time::OffsetDateTime::now_utc()
290        .format(&time::format_description::well_known::Rfc3339)
291        .unwrap_or_default();
292    let pending = PendingInvite {
293        nonce: nonce.clone(),
294        exp,
295        uses_remaining: uses.max(1),
296        accepted_by: vec![],
297        created_at: now,
298    };
299    let dir = pending_invites_dir()?;
300    std::fs::create_dir_all(&dir)?;
301    let path = dir.join(format!("{nonce}.json"));
302    std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
303
304    Ok(url)
305}
306
307/// Parse an invite URL and verify the embedded signature against the embedded
308/// card's first active verify key.
309pub fn parse_invite(url: &str) -> Result<InvitePayload> {
310    let rest = url
311        .strip_prefix("wire://pair?")
312        .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
313    let mut inv = None;
314    for part in rest.split('&') {
315        if let Some(v) = part.strip_prefix("inv=") {
316            inv = Some(v);
317        }
318    }
319    let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
320    let (payload_b64, sig_b64) = token
321        .split_once('.')
322        .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
323    let payload_bytes = B64URL
324        .decode(payload_b64)
325        .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
326    let sig_bytes = B64URL
327        .decode(sig_b64)
328        .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
329
330    let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
331        .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
332
333    if payload.v != 1 {
334        bail!("invite schema version {} not supported", payload.v);
335    }
336    if now_unix() > payload.exp {
337        bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
338    }
339
340    // Verify the URL signature against the issuer's card key.
341    crate::agent_card::verify_agent_card(&payload.card)
342        .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
343
344    let pk_b64 = payload
345        .card
346        .get("verify_keys")
347        .and_then(Value::as_object)
348        .and_then(|m| m.values().next())
349        .and_then(|v| v.get("key"))
350        .and_then(Value::as_str)
351        .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
352    let pk_bytes = crate::signing::b64decode(pk_b64)?;
353    let mut pk_arr = [0u8; 32];
354    if pk_bytes.len() != 32 {
355        bail!("issuer pubkey wrong length");
356    }
357    pk_arr.copy_from_slice(&pk_bytes);
358    let vk = VerifyingKey::from_bytes(&pk_arr)
359        .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
360    let mut sig_arr = [0u8; 64];
361    if sig_bytes.len() != 64 {
362        bail!("invite sig wrong length");
363    }
364    sig_arr.copy_from_slice(&sig_bytes);
365    let sig = Signature::from_bytes(&sig_arr);
366    vk.verify(&payload_bytes, &sig)
367        .map_err(|_| anyhow!("invite URL signature did not verify"))?;
368
369    Ok(payload)
370}
371
372/// Accept an invite URL. Auto-inits + auto-allocates if needed. Pins issuer
373/// from URL contents, then POSTs a signed pair_drop event to issuer's slot.
374pub fn accept_invite(url: &str) -> Result<Value> {
375    let payload = parse_invite(url)?;
376
377    // Auto-init self on the issuer's relay (or env-default if reachable).
378    let (our_did, our_relay, our_slot_id, our_slot_token) =
379        ensure_self_with_relay(Some(&payload.relay_url))?;
380
381    if our_did == payload.did {
382        bail!("refusing to accept own invite (issuer DID matches self)");
383    }
384
385    // Pin issuer in trust + relay-state.
386    let mut trust = config::read_trust()?;
387    crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
388    config::write_trust(&trust)?;
389
390    let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
391    let mut relay_state = config::read_relay_state()?;
392    // RFC-006 Part B: pin the issuer's slot as an `endpoints[]` entry (the
393    // single peer-routing source), not flat top-level fields. The invite
394    // payload's coords are a federation slot.
395    crate::endpoints::pin_peer_endpoints(
396        &mut relay_state,
397        &peer_handle,
398        &[crate::endpoints::Endpoint::federation(
399            payload.relay_url.clone(),
400            payload.slot_id.clone(),
401            payload.slot_token.clone(),
402        )],
403    )?;
404    config::write_relay_state(&relay_state)?;
405
406    // Build signed pair_drop event carrying our own card + slot coords +
407    // the issuer's pair_nonce. Issuer's daemon will look it up against
408    // pending-invites and complete the bilateral pin.
409    let our_card = config::read_agent_card()?;
410    let sk_seed = config::read_private_key()?;
411    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
412    let pk_b64 = our_card
413        .get("verify_keys")
414        .and_then(Value::as_object)
415        .and_then(|m| m.values().next())
416        .and_then(|v| v.get("key"))
417        .and_then(Value::as_str)
418        .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
419    let pk_bytes = crate::signing::b64decode(pk_b64)?;
420
421    let now = time::OffsetDateTime::now_utc()
422        .format(&time::format_description::well_known::Rfc3339)
423        .unwrap_or_default();
424    let event = json!({
425        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
426        "timestamp": now,
427        "from": our_did,
428        "to": payload.did,
429        "type": "pair_drop",
430        "kind": 1100u32,
431        "body": {
432            "card": our_card,
433            "relay_url": our_relay,
434            "slot_id": our_slot_id,
435            "slot_token": our_slot_token,
436            "pair_nonce": payload.nonce,
437        },
438    });
439    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
440    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
441
442    let client = crate::relay_client::RelayClient::new(&payload.relay_url);
443    client
444        .post_event(&payload.slot_id, &payload.slot_token, &signed)
445        .with_context(|| {
446            format!(
447                "POST pair_drop to {} slot {}",
448                payload.relay_url, payload.slot_id
449            )
450        })?;
451
452    Ok(json!({
453        "paired_with": payload.did,
454        "peer_handle": peer_handle,
455        "event_id": event_id,
456        "status": "drop_sent",
457    }))
458}
459
460/// Consume a pair_drop event during daemon pull. Returns `Ok(Some(peer_did))`
461/// if the event matched a pending invite and the peer was pinned. Returns
462/// `Ok(None)` if not a pair_drop or no matching invite. Errors only on real
463/// problems (bad sig over event, IO failure).
464pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
465    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
466    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
467    if kind != 1100 || type_str != "pair_drop" {
468        return Ok(None);
469    }
470    let body = match event.get("body") {
471        Some(b) => b,
472        None => return Ok(None),
473    };
474
475    // v0.5: accept handle-initiated pair_drops too (no pair_nonce). These
476    // come via `wire add <handle>` → POST /v1/handle/intro. Anchored only
477    // by the embedded signed card. Gated by config `accept_unknown_pair_drops`
478    // (default true). For nonce-bearing drops the existing v0.4 invite-URL
479    // path stays in force.
480    let nonce_opt = body
481        .get("pair_nonce")
482        .and_then(Value::as_str)
483        .map(str::to_string);
484    let mut pending: Option<PendingInvite> = None;
485    let mut invite_path: Option<std::path::PathBuf> = None;
486    if let Some(nonce) = nonce_opt.as_deref() {
487        let dir = pending_invites_dir()?;
488        let path = dir.join(format!("{nonce}.json"));
489        if path.exists() {
490            let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
491                .with_context(|| format!("reading pending invite {path:?}"))?;
492            if now_unix() > p.exp {
493                // P0.2: warn if cleanup fails — orphaned expired invites in
494                // `pending-invites/` will pile up and confuse `wire doctor`.
495                if let Err(e) = std::fs::remove_file(&path) {
496                    eprintln!("wire: could not delete expired invite {path:?}: {e}");
497                }
498                return Ok(None);
499            }
500            pending = Some(p);
501            invite_path = Some(path);
502        } else if !open_mode_enabled() {
503            // Nonce present but unknown locally, and open mode disabled →
504            // refuse silently (the event will fall through to the normal
505            // verify path which won't trust the sender yet).
506            return Ok(None);
507        }
508    } else if !open_mode_enabled() {
509        // No nonce + open mode disabled → ignore. Operator must opt in to
510        // be discoverable via zero-paste `wire add`.
511        return Ok(None);
512    }
513
514    let peer_card = body
515        .get("card")
516        .cloned()
517        .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
518    crate::agent_card::verify_agent_card(&peer_card)
519        .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
520
521    let peer_did = peer_card
522        .get("did")
523        .and_then(Value::as_str)
524        .ok_or_else(|| anyhow!("peer card missing did"))?
525        .to_string();
526    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
527
528    // Verify the event signature against the peer's embedded pubkey. We need
529    // a transient trust pin to drive the verifier, but for the handle path
530    // (no nonce) this is the ONLY trust-write we'd make and we throw it away
531    // immediately — see the bilateral-required branch below.
532    let mut tmp_trust = config::read_trust()?;
533    crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
534    crate::signing::verify_message_v31(event, &tmp_trust)
535        .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
536
537    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
538    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
539    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
540    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
541        bail!("pair_drop body missing relay_url/slot_id/slot_token");
542    }
543
544    // v0.5.17: peer may advertise multiple endpoints (federation +
545    // optional local). Parse `body.endpoints[]` if present. Falls back
546    // to a single federation endpoint from the legacy fields above for
547    // v0.5.16-and-earlier senders.
548    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
549        .get("endpoints")
550        .and_then(Value::as_array)
551        .map(|arr| {
552            arr.iter()
553                .filter_map(|e| {
554                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
555                })
556                .collect()
557        })
558        .unwrap_or_else(|| {
559            vec![crate::endpoints::Endpoint::federation(
560                peer_relay.to_string(),
561                peer_slot_id.to_string(),
562                peer_slot_token.to_string(),
563            )]
564        });
565
566    // ---------- v0.5.14 bilateral-required split ----------
567    //
568    // SPAKE2 invite-URL path (`pair_nonce` present): the operator already
569    // gave the sender an invite-URL out-of-band; possession of the nonce IS
570    // the consent gesture. Pin trust, write relay_state, send the ack —
571    // unchanged from v0.5.13.
572    //
573    // Handle path (no nonce, zero-paste `wire add`): the sender knows
574    // nothing more than the public phonebook entry. Receiver consent has
575    // not been gestured. **Do NOT pin trust. Do NOT write our slot_token
576    // back. Do NOT advertise relay coords.** Stash the request in pending-
577    // inbound and prompt the operator. Bilateral pin completes only when
578    // the operator runs `wire add <peer>@<their-relay>` to accept.
579    //
580    // This closes the v0.5.13 phonebook-scrape spam vector: an attacker
581    // can deposit one entry in N victims' `wire pending`, but
582    // no slot_token leaks and no message-write capability accrues.
583    if nonce_opt.is_some() {
584        // ----- SPAKE2 invite-URL path (unchanged) -----
585        config::write_trust(&tmp_trust)?;
586        let mut relay_state = config::read_relay_state()?;
587        // v0.5.17: pin all advertised endpoints (federation + optional
588        // local). Top-level legacy fields still point at the federation
589        // endpoint for back-compat readers.
590        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
591        config::write_relay_state(&relay_state)?;
592
593        // Consume invite (single-use default; decrement uses for multi-use).
594        if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
595            if pending.uses_remaining <= 1 {
596                if let Err(e) = std::fs::remove_file(&invite_path) {
597                    eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
598                }
599            } else {
600                let mut updated = pending.clone();
601                updated.uses_remaining -= 1;
602                updated.accepted_by.push(peer_did.clone());
603                std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
604            }
605        }
606        crate::os_notify::toast(
607            &format!("wire — paired with {peer_handle}"),
608            "Invite accepted. Ready to send + receive.",
609        );
610        return Ok(Some(peer_did));
611    }
612
613    // ----- Handle path: stash in pending-inbound, no capability flows -----
614    // RFC-001 §T16: a locally-blocked peer is dropped before any easing. The
615    // block check keys on both the session DID and the card's `op_did`, so
616    // blocking a (possibly rogue-admin-injected) operator mutes every session
617    // it spawns. Drop silently — no pin, no pending stash, no toast, no ack
618    // (returning `Ok(None)` leaves no fingerprintable response). Bilateral SAS
619    // is out of scope: it's an explicit operator gesture that overrides a block.
620    let blocklist = crate::blocklist::Blocklist::load();
621    if let Some(blocked_did) = blocklist.blocks_card(&peer_card) {
622        record_pair_rejection(
623            &peer_handle,
624            "blocked_peer",
625            &format!(
626                "inbound pair from locally-blocked DID {blocked_did}; dropped (wire block-peer)"
627            ),
628        );
629        return Ok(None);
630    }
631
632    // RFC-001 Phase 1b (Option A): if the peer's card proves org membership the
633    // operator opted into auto-pairing (org_policies.json `inbound=auto`), pin
634    // ORG_VERIFIED + endpoints + ack now — the per-org opt-in IS the standing
635    // consent (distinct from accepting an anonymous stranger). Safe-by-default:
636    // no policy / no v3.2 org-claims → decide=Manual → falls through to the
637    // normal pending-inbound flow below. Never reaches VERIFIED (that needs the
638    // per-peer gesture/SAS path); ORG_VERIFIED < VERIFIED.
639    if let Some(org_did) =
640        org_auto_pin_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load())
641    {
642        let mut trust = crate::config::read_trust()?;
643        crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("ORG_VERIFIED"));
644        crate::config::write_trust(&trust)?;
645
646        let endpoints_to_pin = if peer_endpoints.is_empty() {
647            vec![crate::endpoints::Endpoint::federation(
648                peer_relay.to_string(),
649                peer_slot_id.to_string(),
650                peer_slot_token.to_string(),
651            )]
652        } else {
653            peer_endpoints.clone()
654        };
655        let mut relay_state = crate::config::read_relay_state()?;
656        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
657        crate::config::write_relay_state(&relay_state)?;
658
659        send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
660            .with_context(|| format!("org-auto pair_drop_ack send to {peer_handle} failed"))?;
661
662        crate::os_notify::toast_dedup(
663            &format!("org-pair:{peer_handle}"),
664            &format!("wire — auto-paired {peer_handle}"),
665            &format!(
666                "org-verified member of {org_did}; pinned ORG_VERIFIED (your org_policies.json opt-in)"
667            ),
668        );
669        return Ok(Some(peer_did));
670    }
671
672    let now_iso = time::OffsetDateTime::now_utc()
673        .format(&time::format_description::well_known::Rfc3339)
674        .unwrap_or_default();
675    let event_id = event
676        .get("event_id")
677        .and_then(Value::as_str)
678        .unwrap_or("")
679        .to_string();
680    let event_timestamp = event
681        .get("timestamp")
682        .and_then(Value::as_str)
683        .unwrap_or("")
684        .to_string();
685    let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
686        peer_handle: peer_handle.clone(),
687        peer_did: peer_did.clone(),
688        peer_card: peer_card.clone(),
689        peer_relay_url: peer_relay.to_string(),
690        peer_slot_id: peer_slot_id.to_string(),
691        peer_slot_token: peer_slot_token.to_string(),
692        peer_endpoints: peer_endpoints.clone(),
693        event_id,
694        event_timestamp,
695        received_at: now_iso,
696    };
697    crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
698
699    // RFC-001 Phase 1b — Notify mode: default-deny pending stash above runs
700    // unchanged (no auto-pin, no auto-ack), but we ENRICH the lock-screen
701    // notification with org context when the peer's verified membership is in
702    // an org the operator marked `notify`. Same `toast_dedup` keying pattern
703    // the auto branch uses so a flurry of pair_drops doesn't spam the
704    // notification center. Falls through to the generic toast otherwise.
705    match org_notify_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load()) {
706        Some(org_did) => crate::os_notify::toast_dedup(
707            &format!("notify-pair:{peer_handle}"),
708            &format!("wire — org-verified pair request from {peer_handle}"),
709            &format!(
710                "verified member of {org_did} (your org_policies.json says `notify`). run `wire accept {peer_handle}` to pin VERIFIED, or `wire reject {peer_handle}`",
711            ),
712        ),
713        None => crate::os_notify::toast(
714            &format!("wire — pair request from {peer_handle}"),
715            &format!(
716                "run `wire accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire reject {peer_handle}` to refuse",
717            ),
718        ),
719    }
720
721    Ok(Some(peer_did))
722}
723
724/// RFC-001 Phase 1b — decide whether a received card's org membership earns an
725/// auto-pin to `ORG_VERIFIED` under the receiver's policy. Returns the matched
726/// `org_did` iff the membership verifies offline AND the policy opts that org
727/// into auto (Option A). Pure over `policy`; never yields anything above
728/// `ORG_VERIFIED`. Safe-by-default: an empty/absent policy → `None`.
729fn org_auto_pin_decision(
730    card: &Value,
731    policy: &dyn crate::pair_decision::OrgPolicy,
732) -> Option<String> {
733    match crate::pair_decision::decide(
734        &crate::org_membership::evaluate_card_membership(card),
735        policy,
736    ) {
737        crate::pair_decision::PairAction::AutoOrgVerified { org_did } => Some(org_did),
738        _ => None,
739    }
740}
741
742/// RFC-001 Phase 1b — decide whether a received card's org membership is
743/// **eligible** for a one-tap accept under the receiver's policy (Notify mode,
744/// Option B in RFC-001 §"Default ease-of-pair mechanism"). Returns the matched
745/// `org_did` iff the membership verifies offline AND the policy opts that org
746/// into `notify`. The default-deny pending stash still fires; this decision
747/// only enriches the toast with org context so the operator can recognize the
748/// vouch on the lock-screen. Safe-by-default: empty/absent policy → `None`.
749/// Auto mode wins over Notify when both apply (auto returns first; this is
750/// only consulted on the non-auto path).
751fn org_notify_decision(
752    card: &Value,
753    policy: &dyn crate::pair_decision::OrgPolicy,
754) -> Option<String> {
755    match crate::pair_decision::decide(
756        &crate::org_membership::evaluate_card_membership(card),
757        policy,
758    ) {
759        crate::pair_decision::PairAction::NotifyOrgEligible { org_did } => Some(org_did),
760        _ => None,
761    }
762}
763
764/// Send a `pair_drop_ack` event (kind=1101) carrying OUR slot_token to a peer
765/// who just intro'd to us via `/v1/handle/intro/<nick>`. Completes the
766/// zero-paste bidirectional pin. Best-effort: errors are logged but don't
767/// propagate, since the inbound pair_drop pin already succeeded and the
768/// operator can retry from either side.
769/// Send a `pair_drop_ack` (kind=1101) carrying our slot_token to a peer.
770/// Used by the SPAKE2 invite-URL path (auto-called) and by the bilateral
771/// completion path in `cmd_add` (operator-driven). Failures propagate so
772/// the caller can surface the failure loudly.
773/// Send a pair_drop_ack to a peer. Iterates the peer's pinned endpoints
774/// in priority order (UDS / Local / LAN / Federation), trying each on
775/// failure — only errors if every endpoint fails. Fixes Bug 2: previously
776/// took a single `peer_relay`/`peer_slot_id`/`peer_slot_token` triple and
777/// gave up after the first POST, so a peer whose first endpoint 4xx'd
778/// (e.g. the userinfo-malformed URL from Bug 1) was unreachable even when
779/// they advertised a second, clean endpoint.
780///
781/// Back-compat: callers that only know a single endpoint (legacy v0.5.16-
782/// era pending records without `endpoints[]`) can pass a one-element slice
783/// built from the legacy fields — the helper handles list-of-one identically
784/// to the pre-fix single-endpoint shape.
785pub fn send_pair_drop_ack(
786    peer_handle: &str,
787    peer_endpoints: &[crate::endpoints::Endpoint],
788) -> Result<()> {
789    // Load our own card + relay coords.
790    let our_card = config::read_agent_card()?;
791    let our_did = our_card
792        .get("did")
793        .and_then(Value::as_str)
794        .ok_or_else(|| anyhow!("our card missing did"))?
795        .to_string();
796    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
797    let relay_state = config::read_relay_state()?;
798    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
799    // v0.7.5 silent-fail fix: prefer top-level legacy fields (v0.5.16
800    // and earlier writers), fall back to the first endpoint in
801    // self.endpoints[] (v0.5.17+ dual-slot writers). Pre-v0.7.5 this
802    // function ONLY read the legacy fields, so any session created
803    // with `--with-local` / `--with-uds` / `--with-lan` (which only
804    // populate endpoints[]) hit `self relay state incomplete; cannot
805    // emit pair_drop_ack` and silently black-holed every pair attempt.
806    // Logged as FM3 + the slancha-api ↔ source incident 2026-05-23.
807    let mut our_relay = self_state
808        .get("relay_url")
809        .and_then(Value::as_str)
810        .unwrap_or("")
811        .to_string();
812    let mut our_slot_id = self_state
813        .get("slot_id")
814        .and_then(Value::as_str)
815        .unwrap_or("")
816        .to_string();
817    let mut our_slot_token = self_state
818        .get("slot_token")
819        .and_then(Value::as_str)
820        .unwrap_or("")
821        .to_string();
822    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
823        // Try v0.5.17+ endpoints[] form. Pick the first endpoint —
824        // priority is preserved in self_endpoints() returned order
825        // (UDS / Local / LAN / Federation, lowest-friction first), so
826        // pair_drop_ack rides the same priority routing as send.
827        let eps = crate::endpoints::self_endpoints(&relay_state);
828        if let Some(ep) = eps.first() {
829            our_relay = ep.relay_url.clone();
830            our_slot_id = ep.slot_id.clone();
831            our_slot_token = ep.slot_token.clone();
832        }
833    }
834    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
835        // STILL empty after both readers — the session genuinely has
836        // no inbound slot. This is the "agent without inbound mailbox"
837        // footgun. Refuse loudly with the exact remediation rather
838        // than the prior vague "self relay state incomplete" message.
839        bail!(
840            "this session has no inbound slot configured — peers cannot deliver to us.\n\
841             Fix: `wire bind-relay http://127.0.0.1:8771 --migrate-pinned` \
842             (allocates a slot and re-publishes our card to all pinned peers).\n\
843             Then re-run the pair flow. See WIRE_PAIRING_INCIDENT_2026-05-23 for context."
844        );
845    }
846
847    let sk_seed = config::read_private_key()?;
848    let pk_b64 = our_card
849        .get("verify_keys")
850        .and_then(Value::as_object)
851        .and_then(|m| m.values().next())
852        .and_then(|v| v.get("key"))
853        .and_then(Value::as_str)
854        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
855    let pk_bytes = crate::signing::b64decode(pk_b64)?;
856
857    let now = time::OffsetDateTime::now_utc()
858        .format(&time::format_description::well_known::Rfc3339)
859        .unwrap_or_default();
860    // v0.5.17: also advertise our endpoints[] in the ack so the peer can
861    // pin both our federation and local endpoints. Back-compat: top-level
862    // legacy fields above stay populated for v0.5.16-and-earlier readers.
863    let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
864    let mut body = json!({
865        "relay_url": our_relay,
866        "slot_id": our_slot_id,
867        "slot_token": our_slot_token,
868    });
869    if !our_endpoints.is_empty() {
870        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
871    }
872    let event = json!({
873        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
874        "timestamp": now,
875        "from": our_did,
876        "to": format!("did:wire:{peer_handle}"),
877        "type": "pair_drop_ack",
878        "kind": 1101u32,
879        "body": body,
880    });
881    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
882
883    // Bug 2 fix: try every advertised peer endpoint in priority order; only
884    // error if all fail. Pre-fix this function POSTed once to a single
885    // endpoint and gave up on the first 4xx — a peer with [bad, good]
886    // endpoints (e.g. the userinfo-malformed first endpoint surfaced by
887    // Bug 1) was unreachable even though a good endpoint sat behind it.
888    let (delivered_ep, _resp) =
889        crate::relay_client::try_post_event_with_failover(peer_endpoints, &signed, |ep, ev| {
890            crate::relay_client::post_event_to_endpoint(ep, ev)
891        })
892        .with_context(|| {
893            format!(
894                "pair_drop_ack to {peer_handle} failed across {} endpoint(s)",
895                peer_endpoints.len()
896            )
897        })?;
898    let _ = delivered_ep; // delivered_ep is available for future logging.
899    Ok(())
900}
901
902/// Consume a `pair_drop_ack` event during daemon pull. Updates
903/// relay-state.peers[<peer>] with the ack's slot_token so we can `wire send`
904/// to the peer. Returns `Ok(true)` if applied. Idempotent.
905pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
906    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
907    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
908    if kind != 1101 || type_str != "pair_drop_ack" {
909        return Ok(false);
910    }
911    let body = match event.get("body") {
912        Some(b) => b,
913        None => return Ok(false),
914    };
915    let from = event
916        .get("from")
917        .and_then(Value::as_str)
918        .ok_or_else(|| anyhow!("ack missing 'from'"))?;
919    let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
920    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
921    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
922    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
923    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
924        bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
925    }
926    // v0.5.17: parse endpoints[] if present (peer ran v0.5.17+ and has
927    // dual slots); fall back to a single federation entry synthesized
928    // from the legacy fields for v0.5.16-and-earlier acks.
929    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
930        .get("endpoints")
931        .and_then(Value::as_array)
932        .map(|arr| {
933            arr.iter()
934                .filter_map(|e| {
935                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
936                })
937                .collect()
938        })
939        .unwrap_or_else(|| {
940            vec![crate::endpoints::Endpoint::federation(
941                peer_relay.to_string(),
942                peer_slot_id.to_string(),
943                peer_slot_token.to_string(),
944            )]
945        });
946    let mut relay_state = config::read_relay_state()?;
947    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
948    // v0.14.2 (#162 fix #5): stamp the durable bilateral-completed marker
949    // on receipt of pair_drop_ack — this is the moment the bilateral
950    // handshake actually completes (we already have their slot_token
951    // pinned from their pair_drop; they sent the ack carrying ours).
952    // Monotonic: once set, NEVER cleared. `effective_peer_tier` reads
953    // this instead of slot_token presence so a transient endpoint
954    // re-pin can't flap the visible tier from VERIFIED → PENDING_ACK.
955    // `pin_peer_endpoints` preserves the field across re-pin events.
956    if let Some(peer_entry) = relay_state
957        .get_mut("peers")
958        .and_then(Value::as_object_mut)
959        .and_then(|m| m.get_mut(&peer_handle))
960        .and_then(Value::as_object_mut)
961    {
962        peer_entry
963            .entry("bilateral_completed_at".to_string())
964            .or_insert_with(|| {
965                Value::String(
966                    time::OffsetDateTime::now_utc()
967                        .format(&time::format_description::well_known::Rfc3339)
968                        .unwrap_or_default(),
969                )
970            });
971    }
972    config::write_relay_state(&relay_state)?;
973    // v0.14.2 (#162 follow-on, honey-pine cosmetic find 2026-06-01):
974    // when bilateral completes via this path (we received the peer's
975    // pair_drop_ack, meaning they already had our pair_drop_ack), any
976    // pending-inbound record from an EARLIER inbound pair_drop is now
977    // stale — the pair is bilaterally pinned, the operator no longer
978    // needs to consent. Clear it idempotently so `wire status` /
979    // `wire_pending` stop showing a "waiting on consent" entry for a
980    // peer that's already VERIFIED. honey saw sunlit-aurora linger in
981    // `pending_pairs.inbound_handles` even after the tier promoted.
982    if let Err(e) = crate::pending_inbound_pair::consume_pending_inbound(&peer_handle) {
983        // Non-fatal — pending-inbound clear is hygiene, not correctness.
984        // Log but don't fail the bilateral-completion path.
985        eprintln!("pair_drop_ack: failed to clear stale pending_inbound for {peer_handle}: {e:#}");
986    }
987    crate::os_notify::toast(
988        &format!("wire — pair complete with {peer_handle}"),
989        "Both sides bound. Ready to send + receive.",
990    );
991    Ok(true)
992}
993
994// Earlier note: "tests removed because of WIRE_HOME race." That's no longer
995// true — `config::test_support::with_temp_home` serialises env-mutating
996// tests behind a process-wide mutex, so unit tests here are safe again.
997// Keep e2e coverage in `tests/e2e_invite_pair.rs` for full-flow paranoia.
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    // ---- RFC-001 Phase 1b: org-auto-pin decision gate ----
1004
1005    struct AutoFor(String);
1006    impl crate::pair_decision::OrgPolicy for AutoFor {
1007        fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1008            (org_did == self.0).then_some(crate::pair_decision::InboundMode::Auto)
1009        }
1010    }
1011    struct EmptyPolicy;
1012    impl crate::pair_decision::OrgPolicy for EmptyPolicy {
1013        fn inbound_mode(&self, _: &str) -> Option<crate::pair_decision::InboundMode> {
1014            None
1015        }
1016    }
1017
1018    /// Build a signed v3.2 card for an operator enrolled in one org.
1019    fn org_verified_card() -> (Value, String) {
1020        let (op_sk, op_pk) = crate::signing::generate_keypair();
1021        let (org_sk, org_pk) = crate::signing::generate_keypair();
1022        let (sess_sk, sess_pk) = crate::signing::generate_keypair();
1023        let op_did = crate::agent_card::did_for_op("darby", &op_pk);
1024        let org_did = crate::agent_card::did_for_org("slanchaai", &org_pk);
1025        let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did).unwrap();
1026        let base = crate::agent_card::build_agent_card("vesper-valley", &sess_pk, None, None, None);
1027        let session_did = base
1028            .get("did")
1029            .and_then(|v| v.as_str())
1030            .unwrap()
1031            .to_string();
1032        let claims = crate::enroll::build_member_claims(
1033            "darby",
1034            &op_sk,
1035            &op_pk,
1036            &session_did,
1037            &[crate::enroll::MemberOf {
1038                org_did: org_did.clone(),
1039                org_pubkey: org_pk,
1040                member_cert,
1041            }],
1042            None,
1043        )
1044        .unwrap();
1045        let card = crate::agent_card::sign_agent_card(
1046            &crate::agent_card::with_identity_claims(&base, &claims).unwrap(),
1047            &sess_sk,
1048        );
1049        (card, org_did)
1050    }
1051
1052    #[test]
1053    fn org_auto_pin_decision_auto_only_when_policy_opts_in() {
1054        let (card, org_did) = org_verified_card();
1055        // Policy opts this org into auto → Some(org_did).
1056        assert_eq!(
1057            org_auto_pin_decision(&card, &AutoFor(org_did.clone())),
1058            Some(org_did.clone())
1059        );
1060        // Empty policy → None (safe-by-default: no opt-in, no auto-pin).
1061        assert_eq!(org_auto_pin_decision(&card, &EmptyPolicy), None);
1062    }
1063
1064    #[test]
1065    fn org_auto_pin_decision_none_for_plain_card() {
1066        // A v3.1 card with no op/org claims never auto-pins, even with an
1067        // auto-everything policy — there's no verified membership to match.
1068        let plain = serde_json::json!({
1069            "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1070        });
1071        assert_eq!(
1072            org_auto_pin_decision(&plain, &AutoFor("did:wire:org:x-1".into())),
1073            None
1074        );
1075    }
1076
1077    // ---- RFC-001 Phase 1b: org-notify decision gate ----
1078
1079    struct NotifyFor(String);
1080    impl crate::pair_decision::OrgPolicy for NotifyFor {
1081        fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1082            (org_did == self.0).then_some(crate::pair_decision::InboundMode::Notify)
1083        }
1084    }
1085
1086    #[test]
1087    fn org_notify_decision_notify_only_when_policy_opts_in() {
1088        let (card, org_did) = org_verified_card();
1089        // Policy opts this org into notify → Some(org_did).
1090        assert_eq!(
1091            org_notify_decision(&card, &NotifyFor(org_did.clone())),
1092            Some(org_did.clone())
1093        );
1094        // Empty policy → None.
1095        assert_eq!(org_notify_decision(&card, &EmptyPolicy), None);
1096    }
1097
1098    #[test]
1099    fn org_notify_decision_returns_none_when_policy_is_auto() {
1100        // Auto and Notify are mutually exclusive PairActions — a card whose
1101        // org is in the policy as `auto` must NOT also surface via the notify
1102        // helper (auto wins; notify is only consulted on the non-auto path).
1103        let (card, org_did) = org_verified_card();
1104        assert_eq!(org_notify_decision(&card, &AutoFor(org_did)), None);
1105    }
1106
1107    #[test]
1108    fn org_notify_decision_none_for_plain_card() {
1109        // A v3.1 card with no op/org claims never matches notify — no
1110        // verified membership to match against the policy.
1111        let plain = serde_json::json!({
1112            "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1113        });
1114        assert_eq!(
1115            org_notify_decision(&plain, &NotifyFor("did:wire:org:x-1".into())),
1116            None
1117        );
1118    }
1119    use crate::config;
1120
1121    #[test]
1122    fn record_pair_rejection_writes_jsonl_under_state_dir() {
1123        // P0.2: silent fails must leave a trace. This is what `wire doctor`
1124        // (P1.6) will surface. If the file isn't written, `wire doctor`
1125        // can't see the problem — same silent-fail class we're fixing.
1126        config::test_support::with_temp_home(|| {
1127            super::record_pair_rejection(
1128                "slancha-spark",
1129                "pair_drop_ack_send_failed",
1130                "POST returned 502",
1131            );
1132            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1133            assert!(path.exists(), "record_pair_rejection must create {path:?}");
1134            let body = std::fs::read_to_string(&path).unwrap();
1135            let line = body.lines().last().expect("at least one line");
1136            let parsed: Value = serde_json::from_str(line).expect("valid JSON");
1137            assert_eq!(parsed["peer"], "slancha-spark");
1138            assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
1139            assert_eq!(parsed["detail"], "POST returned 502");
1140            assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
1141        });
1142    }
1143
1144    #[test]
1145    fn maybe_consume_pair_drop_ack_clears_stale_pending_inbound() {
1146        // honey-pine cosmetic find 2026-06-01 (#162 follow-on): a peer
1147        // whose pair completed bilaterally lingered in
1148        // `pending_pairs.inbound_handles`. Repro: write a pending-inbound
1149        // record (as if peer sent us a pair_drop first), then feed a
1150        // valid kind=1101 `pair_drop_ack` for that peer through
1151        // maybe_consume_pair_drop_ack — the pending record should be
1152        // gone afterwards.
1153        config::test_support::with_temp_home(|| {
1154            let peer_handle = "test-peer";
1155            let peer_did = format!("did:wire:{peer_handle}-abcdef12");
1156            let pending = crate::pending_inbound_pair::PendingInboundPair {
1157                peer_handle: peer_handle.to_string(),
1158                peer_did: peer_did.clone(),
1159                peer_card: serde_json::json!({"did": peer_did.clone()}),
1160                peer_relay_url: "https://example.test".into(),
1161                peer_slot_id: "slot-aaaa".into(),
1162                peer_slot_token: "token-bbbb".into(),
1163                peer_endpoints: vec![],
1164                event_id: "evt-0001".into(),
1165                event_timestamp: "2026-06-01T20:00:00Z".into(),
1166                received_at: "2026-06-01T20:00:01Z".into(),
1167            };
1168            crate::pending_inbound_pair::write_pending_inbound(&pending).unwrap();
1169            assert!(
1170                crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1171                    .unwrap()
1172                    .is_some(),
1173                "precondition: pending record exists"
1174            );
1175            let ack_event = serde_json::json!({
1176                "kind": 1101,
1177                "type": "pair_drop_ack",
1178                "from": peer_did,
1179                "body": {
1180                    "relay_url": "https://example.test",
1181                    "slot_id": "slot-cccc",
1182                    "slot_token": "token-dddd",
1183                },
1184            });
1185            let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1186            assert!(consumed, "pair_drop_ack should be consumed");
1187            assert!(
1188                crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1189                    .unwrap()
1190                    .is_none(),
1191                "stale pending-inbound record must be cleared on bilateral completion"
1192            );
1193        });
1194    }
1195
1196    #[test]
1197    fn maybe_consume_pair_drop_ack_no_op_when_no_pending_inbound_exists() {
1198        // Idempotence: the consume_pending_inbound call must NOT fail or
1199        // surface an error when there's no pending record (the common
1200        // case for peers we dialed via `wire add`, where no inbound
1201        // pair_drop was ever stashed).
1202        config::test_support::with_temp_home(|| {
1203            let peer_handle = "fresh-peer";
1204            let peer_did = format!("did:wire:{peer_handle}-12345678");
1205            let ack_event = serde_json::json!({
1206                "kind": 1101,
1207                "type": "pair_drop_ack",
1208                "from": peer_did,
1209                "body": {
1210                    "relay_url": "https://example.test",
1211                    "slot_id": "slot-eeee",
1212                    "slot_token": "token-ffff",
1213                },
1214            });
1215            let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1216            assert!(consumed, "ack must still be consumed (the pinning path)");
1217        });
1218    }
1219
1220    #[test]
1221    fn record_pair_rejection_appends_multiple_lines() {
1222        // Multiple silent fails in one session must each leave a record —
1223        // it's append-only, not a single most-recent slot.
1224        config::test_support::with_temp_home(|| {
1225            super::record_pair_rejection("a", "code_a", "detail_a");
1226            super::record_pair_rejection("b", "code_b", "detail_b");
1227            super::record_pair_rejection("c", "code_c", "detail_c");
1228            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1229            let body = std::fs::read_to_string(&path).unwrap();
1230            let lines: Vec<&str> = body.lines().collect();
1231            assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
1232            for (i, peer) in ["a", "b", "c"].iter().enumerate() {
1233                let parsed: Value = serde_json::from_str(lines[i]).unwrap();
1234                assert_eq!(parsed["peer"], *peer);
1235            }
1236        });
1237    }
1238}