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    relay_state["peers"][&peer_handle] = json!({
393        "relay_url": payload.relay_url,
394        "slot_id": payload.slot_id,
395        "slot_token": payload.slot_token,
396    });
397    config::write_relay_state(&relay_state)?;
398
399    // Build signed pair_drop event carrying our own card + slot coords +
400    // the issuer's pair_nonce. Issuer's daemon will look it up against
401    // pending-invites and complete the bilateral pin.
402    let our_card = config::read_agent_card()?;
403    let sk_seed = config::read_private_key()?;
404    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
405    let pk_b64 = our_card
406        .get("verify_keys")
407        .and_then(Value::as_object)
408        .and_then(|m| m.values().next())
409        .and_then(|v| v.get("key"))
410        .and_then(Value::as_str)
411        .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
412    let pk_bytes = crate::signing::b64decode(pk_b64)?;
413
414    let now = time::OffsetDateTime::now_utc()
415        .format(&time::format_description::well_known::Rfc3339)
416        .unwrap_or_default();
417    let event = json!({
418        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
419        "timestamp": now,
420        "from": our_did,
421        "to": payload.did,
422        "type": "pair_drop",
423        "kind": 1100u32,
424        "body": {
425            "card": our_card,
426            "relay_url": our_relay,
427            "slot_id": our_slot_id,
428            "slot_token": our_slot_token,
429            "pair_nonce": payload.nonce,
430        },
431    });
432    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
433    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
434
435    let client = crate::relay_client::RelayClient::new(&payload.relay_url);
436    client
437        .post_event(&payload.slot_id, &payload.slot_token, &signed)
438        .with_context(|| {
439            format!(
440                "POST pair_drop to {} slot {}",
441                payload.relay_url, payload.slot_id
442            )
443        })?;
444
445    Ok(json!({
446        "paired_with": payload.did,
447        "peer_handle": peer_handle,
448        "event_id": event_id,
449        "status": "drop_sent",
450    }))
451}
452
453/// Consume a pair_drop event during daemon pull. Returns `Ok(Some(peer_did))`
454/// if the event matched a pending invite and the peer was pinned. Returns
455/// `Ok(None)` if not a pair_drop or no matching invite. Errors only on real
456/// problems (bad sig over event, IO failure).
457pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
458    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
459    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
460    if kind != 1100 || type_str != "pair_drop" {
461        return Ok(None);
462    }
463    let body = match event.get("body") {
464        Some(b) => b,
465        None => return Ok(None),
466    };
467
468    // v0.5: accept handle-initiated pair_drops too (no pair_nonce). These
469    // come via `wire add <handle>` → POST /v1/handle/intro. Anchored only
470    // by the embedded signed card. Gated by config `accept_unknown_pair_drops`
471    // (default true). For nonce-bearing drops the existing v0.4 invite-URL
472    // path stays in force.
473    let nonce_opt = body
474        .get("pair_nonce")
475        .and_then(Value::as_str)
476        .map(str::to_string);
477    let mut pending: Option<PendingInvite> = None;
478    let mut invite_path: Option<std::path::PathBuf> = None;
479    if let Some(nonce) = nonce_opt.as_deref() {
480        let dir = pending_invites_dir()?;
481        let path = dir.join(format!("{nonce}.json"));
482        if path.exists() {
483            let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
484                .with_context(|| format!("reading pending invite {path:?}"))?;
485            if now_unix() > p.exp {
486                // P0.2: warn if cleanup fails — orphaned expired invites in
487                // `pending-invites/` will pile up and confuse `wire doctor`.
488                if let Err(e) = std::fs::remove_file(&path) {
489                    eprintln!("wire: could not delete expired invite {path:?}: {e}");
490                }
491                return Ok(None);
492            }
493            pending = Some(p);
494            invite_path = Some(path);
495        } else if !open_mode_enabled() {
496            // Nonce present but unknown locally, and open mode disabled →
497            // refuse silently (the event will fall through to the normal
498            // verify path which won't trust the sender yet).
499            return Ok(None);
500        }
501    } else if !open_mode_enabled() {
502        // No nonce + open mode disabled → ignore. Operator must opt in to
503        // be discoverable via zero-paste `wire add`.
504        return Ok(None);
505    }
506
507    let peer_card = body
508        .get("card")
509        .cloned()
510        .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
511    crate::agent_card::verify_agent_card(&peer_card)
512        .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
513
514    let peer_did = peer_card
515        .get("did")
516        .and_then(Value::as_str)
517        .ok_or_else(|| anyhow!("peer card missing did"))?
518        .to_string();
519    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
520
521    // Verify the event signature against the peer's embedded pubkey. We need
522    // a transient trust pin to drive the verifier, but for the handle path
523    // (no nonce) this is the ONLY trust-write we'd make and we throw it away
524    // immediately — see the bilateral-required branch below.
525    let mut tmp_trust = config::read_trust()?;
526    crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
527    crate::signing::verify_message_v31(event, &tmp_trust)
528        .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
529
530    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
531    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
532    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
533    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
534        bail!("pair_drop body missing relay_url/slot_id/slot_token");
535    }
536
537    // v0.5.17: peer may advertise multiple endpoints (federation +
538    // optional local). Parse `body.endpoints[]` if present. Falls back
539    // to a single federation endpoint from the legacy fields above for
540    // v0.5.16-and-earlier senders.
541    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
542        .get("endpoints")
543        .and_then(Value::as_array)
544        .map(|arr| {
545            arr.iter()
546                .filter_map(|e| {
547                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
548                })
549                .collect()
550        })
551        .unwrap_or_else(|| {
552            vec![crate::endpoints::Endpoint::federation(
553                peer_relay.to_string(),
554                peer_slot_id.to_string(),
555                peer_slot_token.to_string(),
556            )]
557        });
558
559    // ---------- v0.5.14 bilateral-required split ----------
560    //
561    // SPAKE2 invite-URL path (`pair_nonce` present): the operator already
562    // gave the sender an invite-URL out-of-band; possession of the nonce IS
563    // the consent gesture. Pin trust, write relay_state, send the ack —
564    // unchanged from v0.5.13.
565    //
566    // Handle path (no nonce, zero-paste `wire add`): the sender knows
567    // nothing more than the public phonebook entry. Receiver consent has
568    // not been gestured. **Do NOT pin trust. Do NOT write our slot_token
569    // back. Do NOT advertise relay coords.** Stash the request in pending-
570    // inbound and prompt the operator. Bilateral pin completes only when
571    // the operator runs `wire add <peer>@<their-relay>` to accept.
572    //
573    // This closes the v0.5.13 phonebook-scrape spam vector: an attacker
574    // can deposit one entry in N victims' `wire pending`, but
575    // no slot_token leaks and no message-write capability accrues.
576    if nonce_opt.is_some() {
577        // ----- SPAKE2 invite-URL path (unchanged) -----
578        config::write_trust(&tmp_trust)?;
579        let mut relay_state = config::read_relay_state()?;
580        // v0.5.17: pin all advertised endpoints (federation + optional
581        // local). Top-level legacy fields still point at the federation
582        // endpoint for back-compat readers.
583        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
584        config::write_relay_state(&relay_state)?;
585
586        // Consume invite (single-use default; decrement uses for multi-use).
587        if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
588            if pending.uses_remaining <= 1 {
589                if let Err(e) = std::fs::remove_file(&invite_path) {
590                    eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
591                }
592            } else {
593                let mut updated = pending.clone();
594                updated.uses_remaining -= 1;
595                updated.accepted_by.push(peer_did.clone());
596                std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
597            }
598        }
599        crate::os_notify::toast(
600            &format!("wire — paired with {peer_handle}"),
601            "Invite accepted. Ready to send + receive.",
602        );
603        return Ok(Some(peer_did));
604    }
605
606    // ----- Handle path: stash in pending-inbound, no capability flows -----
607    // RFC-001 Phase 1b (Option A): if the peer's card proves org membership the
608    // operator opted into auto-pairing (org_policies.json `inbound=auto`), pin
609    // ORG_VERIFIED + endpoints + ack now — the per-org opt-in IS the standing
610    // consent (distinct from accepting an anonymous stranger). Safe-by-default:
611    // no policy / no v3.2 org-claims → decide=Manual → falls through to the
612    // normal pending-inbound flow below. Never reaches VERIFIED (that needs the
613    // per-peer gesture/SAS path); ORG_VERIFIED < VERIFIED.
614    if let Some(org_did) =
615        org_auto_pin_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load())
616    {
617        let mut trust = crate::config::read_trust()?;
618        crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("ORG_VERIFIED"));
619        crate::config::write_trust(&trust)?;
620
621        let endpoints_to_pin = if peer_endpoints.is_empty() {
622            vec![crate::endpoints::Endpoint::federation(
623                peer_relay.to_string(),
624                peer_slot_id.to_string(),
625                peer_slot_token.to_string(),
626            )]
627        } else {
628            peer_endpoints.clone()
629        };
630        let mut relay_state = crate::config::read_relay_state()?;
631        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
632        crate::config::write_relay_state(&relay_state)?;
633
634        send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
635            .with_context(|| format!("org-auto pair_drop_ack send to {peer_handle} failed"))?;
636
637        crate::os_notify::toast_dedup(
638            &format!("org-pair:{peer_handle}"),
639            &format!("wire — auto-paired {peer_handle}"),
640            &format!(
641                "org-verified member of {org_did}; pinned ORG_VERIFIED (your org_policies.json opt-in)"
642            ),
643        );
644        return Ok(Some(peer_did));
645    }
646
647    let now_iso = time::OffsetDateTime::now_utc()
648        .format(&time::format_description::well_known::Rfc3339)
649        .unwrap_or_default();
650    let event_id = event
651        .get("event_id")
652        .and_then(Value::as_str)
653        .unwrap_or("")
654        .to_string();
655    let event_timestamp = event
656        .get("timestamp")
657        .and_then(Value::as_str)
658        .unwrap_or("")
659        .to_string();
660    let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
661        peer_handle: peer_handle.clone(),
662        peer_did: peer_did.clone(),
663        peer_card: peer_card.clone(),
664        peer_relay_url: peer_relay.to_string(),
665        peer_slot_id: peer_slot_id.to_string(),
666        peer_slot_token: peer_slot_token.to_string(),
667        peer_endpoints: peer_endpoints.clone(),
668        event_id,
669        event_timestamp,
670        received_at: now_iso,
671    };
672    crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
673
674    // RFC-001 Phase 1b — Notify mode: default-deny pending stash above runs
675    // unchanged (no auto-pin, no auto-ack), but we ENRICH the lock-screen
676    // notification with org context when the peer's verified membership is in
677    // an org the operator marked `notify`. Same `toast_dedup` keying pattern
678    // the auto branch uses so a flurry of pair_drops doesn't spam the
679    // notification center. Falls through to the generic toast otherwise.
680    match org_notify_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load()) {
681        Some(org_did) => crate::os_notify::toast_dedup(
682            &format!("notify-pair:{peer_handle}"),
683            &format!("wire — org-verified pair request from {peer_handle}"),
684            &format!(
685                "verified member of {org_did} (your org_policies.json says `notify`). run `wire accept {peer_handle}` to pin VERIFIED, or `wire reject {peer_handle}`",
686            ),
687        ),
688        None => crate::os_notify::toast(
689            &format!("wire — pair request from {peer_handle}"),
690            &format!(
691                "run `wire accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire reject {peer_handle}` to refuse",
692            ),
693        ),
694    }
695
696    Ok(Some(peer_did))
697}
698
699/// RFC-001 Phase 1b — decide whether a received card's org membership earns an
700/// auto-pin to `ORG_VERIFIED` under the receiver's policy. Returns the matched
701/// `org_did` iff the membership verifies offline AND the policy opts that org
702/// into auto (Option A). Pure over `policy`; never yields anything above
703/// `ORG_VERIFIED`. Safe-by-default: an empty/absent policy → `None`.
704fn org_auto_pin_decision(
705    card: &Value,
706    policy: &dyn crate::pair_decision::OrgPolicy,
707) -> Option<String> {
708    match crate::pair_decision::decide(
709        &crate::org_membership::evaluate_card_membership(card),
710        policy,
711    ) {
712        crate::pair_decision::PairAction::AutoOrgVerified { org_did } => Some(org_did),
713        _ => None,
714    }
715}
716
717/// RFC-001 Phase 1b — decide whether a received card's org membership is
718/// **eligible** for a one-tap accept under the receiver's policy (Notify mode,
719/// Option B in RFC-001 §"Default ease-of-pair mechanism"). Returns the matched
720/// `org_did` iff the membership verifies offline AND the policy opts that org
721/// into `notify`. The default-deny pending stash still fires; this decision
722/// only enriches the toast with org context so the operator can recognize the
723/// vouch on the lock-screen. Safe-by-default: empty/absent policy → `None`.
724/// Auto mode wins over Notify when both apply (auto returns first; this is
725/// only consulted on the non-auto path).
726fn org_notify_decision(
727    card: &Value,
728    policy: &dyn crate::pair_decision::OrgPolicy,
729) -> Option<String> {
730    match crate::pair_decision::decide(
731        &crate::org_membership::evaluate_card_membership(card),
732        policy,
733    ) {
734        crate::pair_decision::PairAction::NotifyOrgEligible { org_did } => Some(org_did),
735        _ => None,
736    }
737}
738
739/// Send a `pair_drop_ack` event (kind=1101) carrying OUR slot_token to a peer
740/// who just intro'd to us via `/v1/handle/intro/<nick>`. Completes the
741/// zero-paste bidirectional pin. Best-effort: errors are logged but don't
742/// propagate, since the inbound pair_drop pin already succeeded and the
743/// operator can retry from either side.
744/// Send a `pair_drop_ack` (kind=1101) carrying our slot_token to a peer.
745/// Used by the SPAKE2 invite-URL path (auto-called) and by the bilateral
746/// completion path in `cmd_add` (operator-driven). Failures propagate so
747/// the caller can surface the failure loudly.
748/// Send a pair_drop_ack to a peer. Iterates the peer's pinned endpoints
749/// in priority order (UDS / Local / LAN / Federation), trying each on
750/// failure — only errors if every endpoint fails. Fixes Bug 2: previously
751/// took a single `peer_relay`/`peer_slot_id`/`peer_slot_token` triple and
752/// gave up after the first POST, so a peer whose first endpoint 4xx'd
753/// (e.g. the userinfo-malformed URL from Bug 1) was unreachable even when
754/// they advertised a second, clean endpoint.
755///
756/// Back-compat: callers that only know a single endpoint (legacy v0.5.16-
757/// era pending records without `endpoints[]`) can pass a one-element slice
758/// built from the legacy fields — the helper handles list-of-one identically
759/// to the pre-fix single-endpoint shape.
760pub fn send_pair_drop_ack(
761    peer_handle: &str,
762    peer_endpoints: &[crate::endpoints::Endpoint],
763) -> Result<()> {
764    // Load our own card + relay coords.
765    let our_card = config::read_agent_card()?;
766    let our_did = our_card
767        .get("did")
768        .and_then(Value::as_str)
769        .ok_or_else(|| anyhow!("our card missing did"))?
770        .to_string();
771    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
772    let relay_state = config::read_relay_state()?;
773    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
774    // v0.7.5 silent-fail fix: prefer top-level legacy fields (v0.5.16
775    // and earlier writers), fall back to the first endpoint in
776    // self.endpoints[] (v0.5.17+ dual-slot writers). Pre-v0.7.5 this
777    // function ONLY read the legacy fields, so any session created
778    // with `--with-local` / `--with-uds` / `--with-lan` (which only
779    // populate endpoints[]) hit `self relay state incomplete; cannot
780    // emit pair_drop_ack` and silently black-holed every pair attempt.
781    // Logged as FM3 + the slancha-api ↔ source incident 2026-05-23.
782    let mut our_relay = self_state
783        .get("relay_url")
784        .and_then(Value::as_str)
785        .unwrap_or("")
786        .to_string();
787    let mut our_slot_id = self_state
788        .get("slot_id")
789        .and_then(Value::as_str)
790        .unwrap_or("")
791        .to_string();
792    let mut our_slot_token = self_state
793        .get("slot_token")
794        .and_then(Value::as_str)
795        .unwrap_or("")
796        .to_string();
797    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
798        // Try v0.5.17+ endpoints[] form. Pick the first endpoint —
799        // priority is preserved in self_endpoints() returned order
800        // (UDS / Local / LAN / Federation, lowest-friction first), so
801        // pair_drop_ack rides the same priority routing as send.
802        let eps = crate::endpoints::self_endpoints(&relay_state);
803        if let Some(ep) = eps.first() {
804            our_relay = ep.relay_url.clone();
805            our_slot_id = ep.slot_id.clone();
806            our_slot_token = ep.slot_token.clone();
807        }
808    }
809    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
810        // STILL empty after both readers — the session genuinely has
811        // no inbound slot. This is the "agent without inbound mailbox"
812        // footgun. Refuse loudly with the exact remediation rather
813        // than the prior vague "self relay state incomplete" message.
814        bail!(
815            "this session has no inbound slot configured — peers cannot deliver to us.\n\
816             Fix: `wire bind-relay http://127.0.0.1:8771 --migrate-pinned` \
817             (allocates a slot and re-publishes our card to all pinned peers).\n\
818             Then re-run the pair flow. See WIRE_PAIRING_INCIDENT_2026-05-23 for context."
819        );
820    }
821
822    let sk_seed = config::read_private_key()?;
823    let pk_b64 = our_card
824        .get("verify_keys")
825        .and_then(Value::as_object)
826        .and_then(|m| m.values().next())
827        .and_then(|v| v.get("key"))
828        .and_then(Value::as_str)
829        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
830    let pk_bytes = crate::signing::b64decode(pk_b64)?;
831
832    let now = time::OffsetDateTime::now_utc()
833        .format(&time::format_description::well_known::Rfc3339)
834        .unwrap_or_default();
835    // v0.5.17: also advertise our endpoints[] in the ack so the peer can
836    // pin both our federation and local endpoints. Back-compat: top-level
837    // legacy fields above stay populated for v0.5.16-and-earlier readers.
838    let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
839    let mut body = json!({
840        "relay_url": our_relay,
841        "slot_id": our_slot_id,
842        "slot_token": our_slot_token,
843    });
844    if !our_endpoints.is_empty() {
845        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
846    }
847    let event = json!({
848        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
849        "timestamp": now,
850        "from": our_did,
851        "to": format!("did:wire:{peer_handle}"),
852        "type": "pair_drop_ack",
853        "kind": 1101u32,
854        "body": body,
855    });
856    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
857
858    // Bug 2 fix: try every advertised peer endpoint in priority order; only
859    // error if all fail. Pre-fix this function POSTed once to a single
860    // endpoint and gave up on the first 4xx — a peer with [bad, good]
861    // endpoints (e.g. the userinfo-malformed first endpoint surfaced by
862    // Bug 1) was unreachable even though a good endpoint sat behind it.
863    let (delivered_ep, _resp) =
864        crate::relay_client::try_post_event_with_failover(peer_endpoints, &signed, |ep, ev| {
865            crate::relay_client::post_event_to_endpoint(ep, ev)
866        })
867        .with_context(|| {
868            format!(
869                "pair_drop_ack to {peer_handle} failed across {} endpoint(s)",
870                peer_endpoints.len()
871            )
872        })?;
873    let _ = delivered_ep; // delivered_ep is available for future logging.
874    Ok(())
875}
876
877/// Consume a `pair_drop_ack` event during daemon pull. Updates
878/// relay-state.peers[<peer>] with the ack's slot_token so we can `wire send`
879/// to the peer. Returns `Ok(true)` if applied. Idempotent.
880pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
881    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
882    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
883    if kind != 1101 || type_str != "pair_drop_ack" {
884        return Ok(false);
885    }
886    let body = match event.get("body") {
887        Some(b) => b,
888        None => return Ok(false),
889    };
890    let from = event
891        .get("from")
892        .and_then(Value::as_str)
893        .ok_or_else(|| anyhow!("ack missing 'from'"))?;
894    let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
895    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
896    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
897    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
898    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
899        bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
900    }
901    // v0.5.17: parse endpoints[] if present (peer ran v0.5.17+ and has
902    // dual slots); fall back to a single federation entry synthesized
903    // from the legacy fields for v0.5.16-and-earlier acks.
904    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
905        .get("endpoints")
906        .and_then(Value::as_array)
907        .map(|arr| {
908            arr.iter()
909                .filter_map(|e| {
910                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
911                })
912                .collect()
913        })
914        .unwrap_or_else(|| {
915            vec![crate::endpoints::Endpoint::federation(
916                peer_relay.to_string(),
917                peer_slot_id.to_string(),
918                peer_slot_token.to_string(),
919            )]
920        });
921    let mut relay_state = config::read_relay_state()?;
922    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
923    // v0.14.2 (#162 fix #5): stamp the durable bilateral-completed marker
924    // on receipt of pair_drop_ack — this is the moment the bilateral
925    // handshake actually completes (we already have their slot_token
926    // pinned from their pair_drop; they sent the ack carrying ours).
927    // Monotonic: once set, NEVER cleared. `effective_peer_tier` reads
928    // this instead of slot_token presence so a transient endpoint
929    // re-pin can't flap the visible tier from VERIFIED → PENDING_ACK.
930    // `pin_peer_endpoints` preserves the field across re-pin events.
931    if let Some(peer_entry) = relay_state
932        .get_mut("peers")
933        .and_then(Value::as_object_mut)
934        .and_then(|m| m.get_mut(&peer_handle))
935        .and_then(Value::as_object_mut)
936    {
937        peer_entry
938            .entry("bilateral_completed_at".to_string())
939            .or_insert_with(|| {
940                Value::String(
941                    time::OffsetDateTime::now_utc()
942                        .format(&time::format_description::well_known::Rfc3339)
943                        .unwrap_or_default(),
944                )
945            });
946    }
947    config::write_relay_state(&relay_state)?;
948    // v0.14.2 (#162 follow-on, honey-pine cosmetic find 2026-06-01):
949    // when bilateral completes via this path (we received the peer's
950    // pair_drop_ack, meaning they already had our pair_drop_ack), any
951    // pending-inbound record from an EARLIER inbound pair_drop is now
952    // stale — the pair is bilaterally pinned, the operator no longer
953    // needs to consent. Clear it idempotently so `wire status` /
954    // `wire_pending` stop showing a "waiting on consent" entry for a
955    // peer that's already VERIFIED. honey saw sunlit-aurora linger in
956    // `pending_pairs.inbound_handles` even after the tier promoted.
957    if let Err(e) = crate::pending_inbound_pair::consume_pending_inbound(&peer_handle) {
958        // Non-fatal — pending-inbound clear is hygiene, not correctness.
959        // Log but don't fail the bilateral-completion path.
960        eprintln!("pair_drop_ack: failed to clear stale pending_inbound for {peer_handle}: {e:#}");
961    }
962    crate::os_notify::toast(
963        &format!("wire — pair complete with {peer_handle}"),
964        "Both sides bound. Ready to send + receive.",
965    );
966    Ok(true)
967}
968
969// Earlier note: "tests removed because of WIRE_HOME race." That's no longer
970// true — `config::test_support::with_temp_home` serialises env-mutating
971// tests behind a process-wide mutex, so unit tests here are safe again.
972// Keep e2e coverage in `tests/e2e_invite_pair.rs` for full-flow paranoia.
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    // ---- RFC-001 Phase 1b: org-auto-pin decision gate ----
979
980    struct AutoFor(String);
981    impl crate::pair_decision::OrgPolicy for AutoFor {
982        fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
983            (org_did == self.0).then_some(crate::pair_decision::InboundMode::Auto)
984        }
985    }
986    struct EmptyPolicy;
987    impl crate::pair_decision::OrgPolicy for EmptyPolicy {
988        fn inbound_mode(&self, _: &str) -> Option<crate::pair_decision::InboundMode> {
989            None
990        }
991    }
992
993    /// Build a signed v3.2 card for an operator enrolled in one org.
994    fn org_verified_card() -> (Value, String) {
995        let (op_sk, op_pk) = crate::signing::generate_keypair();
996        let (org_sk, org_pk) = crate::signing::generate_keypair();
997        let (sess_sk, sess_pk) = crate::signing::generate_keypair();
998        let op_did = crate::agent_card::did_for_op("darby", &op_pk);
999        let org_did = crate::agent_card::did_for_org("slanchaai", &org_pk);
1000        let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did).unwrap();
1001        let base = crate::agent_card::build_agent_card("vesper-valley", &sess_pk, None, None, None);
1002        let session_did = base
1003            .get("did")
1004            .and_then(|v| v.as_str())
1005            .unwrap()
1006            .to_string();
1007        let claims = crate::enroll::build_member_claims(
1008            "darby",
1009            &op_sk,
1010            &op_pk,
1011            &session_did,
1012            &[crate::enroll::MemberOf {
1013                org_did: org_did.clone(),
1014                org_pubkey: org_pk,
1015                member_cert,
1016            }],
1017            None,
1018        )
1019        .unwrap();
1020        let card = crate::agent_card::sign_agent_card(
1021            &crate::agent_card::with_identity_claims(&base, &claims).unwrap(),
1022            &sess_sk,
1023        );
1024        (card, org_did)
1025    }
1026
1027    #[test]
1028    fn org_auto_pin_decision_auto_only_when_policy_opts_in() {
1029        let (card, org_did) = org_verified_card();
1030        // Policy opts this org into auto → Some(org_did).
1031        assert_eq!(
1032            org_auto_pin_decision(&card, &AutoFor(org_did.clone())),
1033            Some(org_did.clone())
1034        );
1035        // Empty policy → None (safe-by-default: no opt-in, no auto-pin).
1036        assert_eq!(org_auto_pin_decision(&card, &EmptyPolicy), None);
1037    }
1038
1039    #[test]
1040    fn org_auto_pin_decision_none_for_plain_card() {
1041        // A v3.1 card with no op/org claims never auto-pins, even with an
1042        // auto-everything policy — there's no verified membership to match.
1043        let plain = serde_json::json!({
1044            "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1045        });
1046        assert_eq!(
1047            org_auto_pin_decision(&plain, &AutoFor("did:wire:org:x-1".into())),
1048            None
1049        );
1050    }
1051
1052    // ---- RFC-001 Phase 1b: org-notify decision gate ----
1053
1054    struct NotifyFor(String);
1055    impl crate::pair_decision::OrgPolicy for NotifyFor {
1056        fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1057            (org_did == self.0).then_some(crate::pair_decision::InboundMode::Notify)
1058        }
1059    }
1060
1061    #[test]
1062    fn org_notify_decision_notify_only_when_policy_opts_in() {
1063        let (card, org_did) = org_verified_card();
1064        // Policy opts this org into notify → Some(org_did).
1065        assert_eq!(
1066            org_notify_decision(&card, &NotifyFor(org_did.clone())),
1067            Some(org_did.clone())
1068        );
1069        // Empty policy → None.
1070        assert_eq!(org_notify_decision(&card, &EmptyPolicy), None);
1071    }
1072
1073    #[test]
1074    fn org_notify_decision_returns_none_when_policy_is_auto() {
1075        // Auto and Notify are mutually exclusive PairActions — a card whose
1076        // org is in the policy as `auto` must NOT also surface via the notify
1077        // helper (auto wins; notify is only consulted on the non-auto path).
1078        let (card, org_did) = org_verified_card();
1079        assert_eq!(org_notify_decision(&card, &AutoFor(org_did)), None);
1080    }
1081
1082    #[test]
1083    fn org_notify_decision_none_for_plain_card() {
1084        // A v3.1 card with no op/org claims never matches notify — no
1085        // verified membership to match against the policy.
1086        let plain = serde_json::json!({
1087            "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1088        });
1089        assert_eq!(
1090            org_notify_decision(&plain, &NotifyFor("did:wire:org:x-1".into())),
1091            None
1092        );
1093    }
1094    use crate::config;
1095
1096    #[test]
1097    fn record_pair_rejection_writes_jsonl_under_state_dir() {
1098        // P0.2: silent fails must leave a trace. This is what `wire doctor`
1099        // (P1.6) will surface. If the file isn't written, `wire doctor`
1100        // can't see the problem — same silent-fail class we're fixing.
1101        config::test_support::with_temp_home(|| {
1102            super::record_pair_rejection(
1103                "slancha-spark",
1104                "pair_drop_ack_send_failed",
1105                "POST returned 502",
1106            );
1107            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1108            assert!(path.exists(), "record_pair_rejection must create {path:?}");
1109            let body = std::fs::read_to_string(&path).unwrap();
1110            let line = body.lines().last().expect("at least one line");
1111            let parsed: Value = serde_json::from_str(line).expect("valid JSON");
1112            assert_eq!(parsed["peer"], "slancha-spark");
1113            assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
1114            assert_eq!(parsed["detail"], "POST returned 502");
1115            assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
1116        });
1117    }
1118
1119    #[test]
1120    fn maybe_consume_pair_drop_ack_clears_stale_pending_inbound() {
1121        // honey-pine cosmetic find 2026-06-01 (#162 follow-on): a peer
1122        // whose pair completed bilaterally lingered in
1123        // `pending_pairs.inbound_handles`. Repro: write a pending-inbound
1124        // record (as if peer sent us a pair_drop first), then feed a
1125        // valid kind=1101 `pair_drop_ack` for that peer through
1126        // maybe_consume_pair_drop_ack — the pending record should be
1127        // gone afterwards.
1128        config::test_support::with_temp_home(|| {
1129            let peer_handle = "test-peer";
1130            let peer_did = format!("did:wire:{peer_handle}-abcdef12");
1131            let pending = crate::pending_inbound_pair::PendingInboundPair {
1132                peer_handle: peer_handle.to_string(),
1133                peer_did: peer_did.clone(),
1134                peer_card: serde_json::json!({"did": peer_did.clone()}),
1135                peer_relay_url: "https://example.test".into(),
1136                peer_slot_id: "slot-aaaa".into(),
1137                peer_slot_token: "token-bbbb".into(),
1138                peer_endpoints: vec![],
1139                event_id: "evt-0001".into(),
1140                event_timestamp: "2026-06-01T20:00:00Z".into(),
1141                received_at: "2026-06-01T20:00:01Z".into(),
1142            };
1143            crate::pending_inbound_pair::write_pending_inbound(&pending).unwrap();
1144            assert!(
1145                crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1146                    .unwrap()
1147                    .is_some(),
1148                "precondition: pending record exists"
1149            );
1150            let ack_event = serde_json::json!({
1151                "kind": 1101,
1152                "type": "pair_drop_ack",
1153                "from": peer_did,
1154                "body": {
1155                    "relay_url": "https://example.test",
1156                    "slot_id": "slot-cccc",
1157                    "slot_token": "token-dddd",
1158                },
1159            });
1160            let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1161            assert!(consumed, "pair_drop_ack should be consumed");
1162            assert!(
1163                crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1164                    .unwrap()
1165                    .is_none(),
1166                "stale pending-inbound record must be cleared on bilateral completion"
1167            );
1168        });
1169    }
1170
1171    #[test]
1172    fn maybe_consume_pair_drop_ack_no_op_when_no_pending_inbound_exists() {
1173        // Idempotence: the consume_pending_inbound call must NOT fail or
1174        // surface an error when there's no pending record (the common
1175        // case for peers we dialed via `wire add`, where no inbound
1176        // pair_drop was ever stashed).
1177        config::test_support::with_temp_home(|| {
1178            let peer_handle = "fresh-peer";
1179            let peer_did = format!("did:wire:{peer_handle}-12345678");
1180            let ack_event = serde_json::json!({
1181                "kind": 1101,
1182                "type": "pair_drop_ack",
1183                "from": peer_did,
1184                "body": {
1185                    "relay_url": "https://example.test",
1186                    "slot_id": "slot-eeee",
1187                    "slot_token": "token-ffff",
1188                },
1189            });
1190            let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1191            assert!(consumed, "ack must still be consumed (the pinning path)");
1192        });
1193    }
1194
1195    #[test]
1196    fn record_pair_rejection_appends_multiple_lines() {
1197        // Multiple silent fails in one session must each leave a record —
1198        // it's append-only, not a single most-recent slot.
1199        config::test_support::with_temp_home(|| {
1200            super::record_pair_rejection("a", "code_a", "detail_a");
1201            super::record_pair_rejection("b", "code_b", "detail_b");
1202            super::record_pair_rejection("c", "code_c", "detail_c");
1203            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1204            let body = std::fs::read_to_string(&path).unwrap();
1205            let lines: Vec<&str> = body.lines().collect();
1206            assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
1207            for (i, peer) in ["a", "b", "c"].iter().enumerate() {
1208                let parsed: Value = serde_json::from_str(lines[i]).unwrap();
1209                assert_eq!(parsed["peer"], *peer);
1210            }
1211        });
1212    }
1213}