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