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/// Choose an existing self endpoint to reuse, honoring an explicit relay.
183///
184/// - `Some(relay)` → ONLY a slot already on that relay (compared with a
185///   trailing slash trimmed) qualifies. A named relay is never silently
186///   swapped for an unrelated existing slot — that was #279, where
187///   `wire claim --relay wireup.net` reused the loopback primary and POSTed
188///   the claim to `127.0.0.1`. `None` means "allocate one on the requested
189///   relay" to the caller.
190/// - `None` → any existing slot, federation-first then first (the v0.6.6
191///   local-only-preserving behavior — don't churn / don't auto-federate).
192///
193/// Pure (no I/O) so the relay-honoring choice is locked by unit tests.
194fn pick_reusable_self_endpoint<'a>(
195    existing: &'a [crate::endpoints::Endpoint],
196    preferred_relay: Option<&str>,
197) -> Option<&'a crate::endpoints::Endpoint> {
198    let norm = |u: &str| u.trim_end_matches('/').to_string();
199    match preferred_relay {
200        Some(p) => existing.iter().find(|e| norm(&e.relay_url) == norm(p)),
201        None => existing
202            .iter()
203            .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
204            .or_else(|| existing.first()),
205    }
206}
207
208/// Ensure this node has an identity + relay slot. Idempotent.
209/// Returns (did, relay_url, slot_id, slot_token).
210pub fn ensure_self_with_relay(
211    preferred_relay: Option<&str>,
212) -> Result<(String, String, String, String)> {
213    let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
214
215    if !config::is_initialized()? {
216        let handle = default_handle();
217        crate::init::init_self_idempotent(&handle, None, Some(relay))
218            .with_context(|| format!("auto-init as did:wire:{handle}"))?;
219    }
220
221    let card = config::read_agent_card()?;
222    let did = card
223        .get("did")
224        .and_then(Value::as_str)
225        .ok_or_else(|| anyhow!("agent-card missing did"))?
226        .to_string();
227
228    let mut relay_state = config::read_relay_state()?;
229
230    // Pick a reusable existing self slot, honoring an explicit relay choice.
231    //
232    // v0.6.6: prefer an existing endpoint over allocating a new one — a
233    // `--local-only` session has no legacy `self.slot_id` but DOES have a
234    // local slot in `self.endpoints[]`, which must not be stomped with a
235    // fresh federation allocation (that silently turned local-only sessions
236    // dual-slot).
237    //
238    // #279: BUT when the caller names a relay (`wire claim --relay X`,
239    // `wire add --relay X`, accept-invite with the inviter's relay), only a
240    // slot ALREADY on X qualifies for reuse. The old code returned any
241    // existing slot regardless, so `claim --relay wireup.net` reused the
242    // loopback primary and POSTed the claim to `127.0.0.1` — the relay flag
243    // was silently ignored. If no slot is on X, fall through and allocate one
244    // there (additively, so other slots survive).
245    let existing = crate::endpoints::self_endpoints(&relay_state);
246    if let Some(ep) = pick_reusable_self_endpoint(&existing, preferred_relay).cloned() {
247        return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
248    }
249
250    // No reusable slot on the target relay → allocate one there and ADD it to
251    // `self.endpoints[]` (additive: existing slots, e.g. a local relay, are
252    // preserved). Goes through `upsert_self_endpoint` so the write shape +
253    // legacy top-level fields match `bind-relay` / `init`.
254    let client = crate::relay_client::RelayClient::new(relay);
255    client.check_healthz()?;
256    let handle = crate::agent_card::display_handle_from_did(&did);
257    let alloc = client.allocate_slot(Some(handle))?;
258    let scope = crate::endpoints::infer_scope_from_url(relay);
259    let ep = match scope {
260        crate::endpoints::EndpointScope::Local => crate::endpoints::Endpoint::local(
261            relay.to_string(),
262            alloc.slot_id.clone(),
263            alloc.slot_token.clone(),
264        ),
265        crate::endpoints::EndpointScope::Lan => crate::endpoints::Endpoint::lan(
266            relay.to_string(),
267            alloc.slot_id.clone(),
268            alloc.slot_token.clone(),
269        ),
270        crate::endpoints::EndpointScope::Uds => crate::endpoints::Endpoint::uds(
271            relay.to_string(),
272            alloc.slot_id.clone(),
273            alloc.slot_token.clone(),
274        ),
275        crate::endpoints::EndpointScope::Federation => crate::endpoints::Endpoint::federation(
276            relay.to_string(),
277            alloc.slot_id.clone(),
278            alloc.slot_token.clone(),
279        ),
280    };
281    crate::endpoints::upsert_self_endpoint(&mut relay_state, ep);
282    config::write_relay_state(&relay_state)?;
283    Ok((did, relay.to_string(), alloc.slot_id, alloc.slot_token))
284}
285
286/// Mint a fresh invite URL. Auto-inits + auto-allocates relay slot if needed.
287pub fn mint_invite(
288    ttl_secs: Option<u64>,
289    uses: u32,
290    preferred_relay: Option<&str>,
291) -> Result<String> {
292    let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
293
294    let card = config::read_agent_card()?;
295    let sk_seed = config::read_private_key()?;
296
297    let mut nonce_bytes = [0u8; 32];
298    use rand::RngCore;
299    rand::thread_rng().fill_bytes(&mut nonce_bytes);
300    let nonce = hex::encode(nonce_bytes);
301
302    let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
303    let exp = now_unix() + ttl;
304
305    let payload = InvitePayload {
306        v: 1,
307        did: did.clone(),
308        card,
309        relay_url,
310        slot_id,
311        slot_token,
312        nonce: nonce.clone(),
313        exp,
314    };
315    let payload_bytes = serde_json::to_vec(&payload)?;
316
317    let mut sk_arr = [0u8; 32];
318    sk_arr.copy_from_slice(&sk_seed[..32]);
319    let sk = SigningKey::from_bytes(&sk_arr);
320    let sig = sk.sign(&payload_bytes);
321
322    let token = format!(
323        "{}.{}",
324        B64URL.encode(&payload_bytes),
325        B64URL.encode(sig.to_bytes())
326    );
327    let url = format!("wire://pair?v=1&inv={token}");
328
329    let now = time::OffsetDateTime::now_utc()
330        .format(&time::format_description::well_known::Rfc3339)
331        .unwrap_or_default();
332    let pending = PendingInvite {
333        nonce: nonce.clone(),
334        exp,
335        uses_remaining: uses.max(1),
336        accepted_by: vec![],
337        created_at: now,
338    };
339    let dir = pending_invites_dir()?;
340    std::fs::create_dir_all(&dir)?;
341    let path = dir.join(format!("{nonce}.json"));
342    std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
343
344    Ok(url)
345}
346
347/// Parse an invite URL and verify the embedded signature against the embedded
348/// card's first active verify key.
349pub fn parse_invite(url: &str) -> Result<InvitePayload> {
350    let rest = url
351        .strip_prefix("wire://pair?")
352        .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
353    let mut inv = None;
354    for part in rest.split('&') {
355        if let Some(v) = part.strip_prefix("inv=") {
356            inv = Some(v);
357        }
358    }
359    let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
360    let (payload_b64, sig_b64) = token
361        .split_once('.')
362        .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
363    let payload_bytes = B64URL
364        .decode(payload_b64)
365        .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
366    let sig_bytes = B64URL
367        .decode(sig_b64)
368        .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
369
370    let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
371        .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
372
373    if payload.v != 1 {
374        bail!("invite schema version {} not supported", payload.v);
375    }
376    if now_unix() > payload.exp {
377        bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
378    }
379
380    // Verify the URL signature against the issuer's card key.
381    crate::agent_card::verify_agent_card(&payload.card)
382        .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
383
384    let pk_b64 = payload
385        .card
386        .get("verify_keys")
387        .and_then(Value::as_object)
388        .and_then(|m| m.values().next())
389        .and_then(|v| v.get("key"))
390        .and_then(Value::as_str)
391        .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
392    let pk_bytes = crate::signing::b64decode(pk_b64)?;
393    let mut pk_arr = [0u8; 32];
394    if pk_bytes.len() != 32 {
395        bail!("issuer pubkey wrong length");
396    }
397    pk_arr.copy_from_slice(&pk_bytes);
398    let vk = VerifyingKey::from_bytes(&pk_arr)
399        .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
400    let mut sig_arr = [0u8; 64];
401    if sig_bytes.len() != 64 {
402        bail!("invite sig wrong length");
403    }
404    sig_arr.copy_from_slice(&sig_bytes);
405    let sig = Signature::from_bytes(&sig_arr);
406    vk.verify(&payload_bytes, &sig)
407        .map_err(|_| anyhow!("invite URL signature did not verify"))?;
408
409    Ok(payload)
410}
411
412/// Accept an invite URL. Auto-inits + auto-allocates if needed. Pins issuer
413/// from URL contents, then POSTs a signed pair_drop event to issuer's slot.
414pub fn accept_invite(url: &str) -> Result<Value> {
415    let payload = parse_invite(url)?;
416
417    // Auto-init self on the issuer's relay (or env-default if reachable).
418    let (our_did, our_relay, our_slot_id, our_slot_token) =
419        ensure_self_with_relay(Some(&payload.relay_url))?;
420
421    if our_did == payload.did {
422        bail!("refusing to accept own invite (issuer DID matches self)");
423    }
424
425    // Pin issuer in trust + relay-state.
426    // #246: atomic read-modify-write so a concurrent daemon pull-pin can't
427    // lost-update this foreground pin.
428    config::update_trust(|trust| {
429        crate::trust::add_agent_card_pin(trust, &payload.card, Some("VERIFIED"))
430            .map_err(anyhow::Error::msg)
431    })?;
432
433    let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
434    let mut relay_state = config::read_relay_state()?;
435    // RFC-006 Part B: pin the issuer's slot as an `endpoints[]` entry (the
436    // single peer-routing source), not flat top-level fields. The invite
437    // payload's coords are a federation slot.
438    crate::endpoints::pin_peer_endpoints(
439        &mut relay_state,
440        &peer_handle,
441        &[crate::endpoints::Endpoint::federation(
442            payload.relay_url.clone(),
443            payload.slot_id.clone(),
444            payload.slot_token.clone(),
445        )],
446    )?;
447    config::write_relay_state(&relay_state)?;
448
449    // Build signed pair_drop event carrying our own card + slot coords +
450    // the issuer's pair_nonce. Issuer's daemon will look it up against
451    // pending-invites and complete the bilateral pin.
452    let our_card = config::read_agent_card()?;
453    let sk_seed = config::read_private_key()?;
454    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
455    let pk_b64 = our_card
456        .get("verify_keys")
457        .and_then(Value::as_object)
458        .and_then(|m| m.values().next())
459        .and_then(|v| v.get("key"))
460        .and_then(Value::as_str)
461        .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
462    let pk_bytes = crate::signing::b64decode(pk_b64)?;
463
464    let now = time::OffsetDateTime::now_utc()
465        .format(&time::format_description::well_known::Rfc3339)
466        .unwrap_or_default();
467    let event = json!({
468        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
469        "timestamp": now,
470        "from": our_did,
471        "to": payload.did,
472        "type": "pair_drop",
473        "kind": 1100u32,
474        "body": {
475            "card": our_card,
476            "relay_url": our_relay,
477            "slot_id": our_slot_id,
478            "slot_token": our_slot_token,
479            "pair_nonce": payload.nonce,
480        },
481    });
482    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
483    let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
484
485    let client = crate::relay_client::RelayClient::new(&payload.relay_url);
486    client
487        .post_event(&payload.slot_id, &payload.slot_token, &signed)
488        .with_context(|| {
489            format!(
490                "POST pair_drop to {} slot {}",
491                payload.relay_url, payload.slot_id
492            )
493        })?;
494
495    Ok(json!({
496        "paired_with": payload.did,
497        "peer_handle": peer_handle,
498        "event_id": event_id,
499        "status": "drop_sent",
500    }))
501}
502
503/// Consume a pair_drop event during daemon pull. Returns `Ok(Some(peer_did))`
504/// if the event matched a pending invite and the peer was pinned. Returns
505/// `Ok(None)` if not a pair_drop or no matching invite. Errors only on real
506/// problems (bad sig over event, IO failure).
507pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
508    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
509    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
510    if kind != 1100 || type_str != "pair_drop" {
511        return Ok(None);
512    }
513    let body = match event.get("body") {
514        Some(b) => b,
515        None => return Ok(None),
516    };
517
518    // v0.5: accept handle-initiated pair_drops too (no pair_nonce). These
519    // come via `wire add <handle>` → POST /v1/handle/intro. Anchored only
520    // by the embedded signed card. Gated by config `accept_unknown_pair_drops`
521    // (default true). For nonce-bearing drops the existing v0.4 invite-URL
522    // path stays in force.
523    let nonce_opt = body
524        .get("pair_nonce")
525        .and_then(Value::as_str)
526        .map(str::to_string);
527    let mut pending: Option<PendingInvite> = None;
528    let mut invite_path: Option<std::path::PathBuf> = None;
529    if let Some(nonce) = nonce_opt.as_deref() {
530        let dir = pending_invites_dir()?;
531        let path = dir.join(format!("{nonce}.json"));
532        if path.exists() {
533            let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
534                .with_context(|| format!("reading pending invite {path:?}"))?;
535            if now_unix() > p.exp {
536                // P0.2: warn if cleanup fails — orphaned expired invites in
537                // `pending-invites/` will pile up and confuse `wire doctor`.
538                if let Err(e) = std::fs::remove_file(&path) {
539                    eprintln!("wire: could not delete expired invite {path:?}: {e}");
540                }
541                return Ok(None);
542            }
543            pending = Some(p);
544            invite_path = Some(path);
545        } else if !open_mode_enabled() {
546            // Nonce present but unknown locally, and open mode disabled →
547            // refuse silently (the event will fall through to the normal
548            // verify path which won't trust the sender yet).
549            return Ok(None);
550        }
551    } else if !open_mode_enabled() {
552        // No nonce + open mode disabled → ignore. Operator must opt in to
553        // be discoverable via zero-paste `wire add`.
554        return Ok(None);
555    }
556
557    let peer_card = body
558        .get("card")
559        .cloned()
560        .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
561    crate::agent_card::verify_agent_card(&peer_card)
562        .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
563
564    let peer_did = peer_card
565        .get("did")
566        .and_then(Value::as_str)
567        .ok_or_else(|| anyhow!("peer card missing did"))?
568        .to_string();
569    let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
570
571    // Verify the event signature against the peer's embedded pubkey. We need
572    // a transient trust pin to drive the verifier, but for the handle path
573    // (no nonce) this is the ONLY trust-write we'd make and we throw it away
574    // immediately — see the bilateral-required branch below.
575    let mut tmp_trust = config::read_trust()?;
576    // Transient pin to drive the verifier. If this nick is already pinned to a
577    // DIFFERENT identity (#245 collision), the pin is refused and the incumbent
578    // entry stays — which is correct: an impostor card then fails verify against
579    // the incumbent's key, exactly the rejection we want.
580    let _ = crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
581    crate::signing::verify_message_v31(event, &tmp_trust)
582        .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
583
584    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
585    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
586    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
587    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
588        bail!("pair_drop body missing relay_url/slot_id/slot_token");
589    }
590
591    // v0.5.17: peer may advertise multiple endpoints (federation +
592    // optional local). Parse `body.endpoints[]` if present. Falls back
593    // to a single federation endpoint from the legacy fields above for
594    // v0.5.16-and-earlier senders.
595    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
596        .get("endpoints")
597        .and_then(Value::as_array)
598        .map(|arr| {
599            arr.iter()
600                .filter_map(|e| {
601                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
602                })
603                .collect()
604        })
605        .unwrap_or_else(|| {
606            vec![crate::endpoints::Endpoint::federation(
607                peer_relay.to_string(),
608                peer_slot_id.to_string(),
609                peer_slot_token.to_string(),
610            )]
611        });
612
613    // ---------- v0.5.14 bilateral-required split ----------
614    //
615    // SPAKE2 invite-URL path (`pair_nonce` present): the operator already
616    // gave the sender an invite-URL out-of-band; possession of the nonce IS
617    // the consent gesture. Pin trust, write relay_state, send the ack —
618    // unchanged from v0.5.13.
619    //
620    // Handle path (no nonce, zero-paste `wire add`): the sender knows
621    // nothing more than the public phonebook entry. Receiver consent has
622    // not been gestured. **Do NOT pin trust. Do NOT write our slot_token
623    // back. Do NOT advertise relay coords.** Stash the request in pending-
624    // inbound and prompt the operator. Bilateral pin completes only when
625    // the operator runs `wire add <peer>@<their-relay>` to accept.
626    //
627    // This closes the v0.5.13 phonebook-scrape spam vector: an attacker
628    // can deposit one entry in N victims' `wire pending`, but
629    // no slot_token leaks and no message-write capability accrues.
630    if nonce_opt.is_some() {
631        // ----- SPAKE2 invite-URL path (unchanged) -----
632        config::write_trust(&tmp_trust)?;
633        let mut relay_state = config::read_relay_state()?;
634        // v0.5.17: pin all advertised endpoints (federation + optional
635        // local). Top-level legacy fields still point at the federation
636        // endpoint for back-compat readers.
637        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
638        config::write_relay_state(&relay_state)?;
639
640        // Consume invite (single-use default; decrement uses for multi-use).
641        if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
642            if pending.uses_remaining <= 1 {
643                if let Err(e) = std::fs::remove_file(&invite_path) {
644                    eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
645                }
646            } else {
647                let mut updated = pending.clone();
648                updated.uses_remaining -= 1;
649                updated.accepted_by.push(peer_did.clone());
650                std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
651            }
652        }
653        crate::os_notify::toast(
654            &format!("wire — paired with {peer_handle}"),
655            "Invite accepted. Ready to send + receive.",
656        );
657        return Ok(Some(peer_did));
658    }
659
660    // ----- Handle path: stash in pending-inbound, no capability flows -----
661    // RFC-001 §T16: a locally-blocked peer is dropped before any easing. The
662    // block check keys on both the session DID and the card's `op_did`, so
663    // blocking a (possibly rogue-admin-injected) operator mutes every session
664    // it spawns. Drop silently — no pin, no pending stash, no toast, no ack
665    // (returning `Ok(None)` leaves no fingerprintable response). Bilateral SAS
666    // is out of scope: it's an explicit operator gesture that overrides a block.
667    let blocklist = crate::blocklist::Blocklist::load();
668    if let Some(blocked_did) = blocklist.blocks_card(&peer_card) {
669        record_pair_rejection(
670            &peer_handle,
671            "blocked_peer",
672            &format!(
673                "inbound pair from locally-blocked DID {blocked_did}; dropped (wire block-peer)"
674            ),
675        );
676        return Ok(None);
677    }
678
679    // RFC-001 Phase 1b (Option A): if the peer's card proves org membership the
680    // operator opted into auto-pairing (org_policies.json `inbound=auto`), pin
681    // ORG_VERIFIED + endpoints + ack now — the per-org opt-in IS the standing
682    // consent (distinct from accepting an anonymous stranger). Safe-by-default:
683    // no policy / no v3.2 org-claims → decide=Manual → falls through to the
684    // normal pending-inbound flow below. Never reaches VERIFIED (that needs the
685    // per-peer gesture/SAS path); ORG_VERIFIED < VERIFIED.
686    if let Some(org_did) =
687        org_auto_pin_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load())
688    {
689        crate::config::update_trust(|trust| {
690            crate::trust::add_agent_card_pin(trust, &peer_card, Some("ORG_VERIFIED"))
691                .map_err(anyhow::Error::msg)
692        })?;
693
694        let endpoints_to_pin = if peer_endpoints.is_empty() {
695            vec![crate::endpoints::Endpoint::federation(
696                peer_relay.to_string(),
697                peer_slot_id.to_string(),
698                peer_slot_token.to_string(),
699            )]
700        } else {
701            peer_endpoints.clone()
702        };
703        let mut relay_state = crate::config::read_relay_state()?;
704        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
705        crate::config::write_relay_state(&relay_state)?;
706
707        send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
708            .with_context(|| format!("org-auto pair_drop_ack send to {peer_handle} failed"))?;
709
710        crate::os_notify::toast_dedup(
711            &format!("org-pair:{peer_handle}"),
712            &format!("wire — auto-paired {peer_handle}"),
713            &format!(
714                "org-verified member of {org_did}; pinned ORG_VERIFIED (your org_policies.json opt-in)"
715            ),
716        );
717        return Ok(Some(peer_did));
718    }
719
720    // RFC-001 amendment #182: same-machine signed attestation. If the peer's
721    // card proves — cryptographically, not by filesystem witness — that it is
722    // owned by the SAME operator (op_did == mine) AND lives on THIS exact
723    // (machine, OS user) (fingerprint match + op_sk signature), pin it
724    // ORG_VERIFIED + ack now. The op_sk signature is what makes this stronger
725    // than `pull::maybe_autopin_local_sister`'s disk read (coral's #182
726    // constraint 1). Never crosses into VERIFIED (that needs the per-peer SAS
727    // gesture); ORG_VERIFIED < VERIFIED, same as the org-auto lane above.
728    if crate::same_machine::auto_pin_decision(&peer_card).is_some() {
729        crate::config::update_trust(|trust| {
730            crate::trust::add_agent_card_pin(trust, &peer_card, Some("ORG_VERIFIED"))
731                .map_err(anyhow::Error::msg)
732        })?;
733
734        let endpoints_to_pin = if peer_endpoints.is_empty() {
735            vec![crate::endpoints::Endpoint::federation(
736                peer_relay.to_string(),
737                peer_slot_id.to_string(),
738                peer_slot_token.to_string(),
739            )]
740        } else {
741            peer_endpoints.clone()
742        };
743        let mut relay_state = crate::config::read_relay_state()?;
744        crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
745        crate::config::write_relay_state(&relay_state)?;
746
747        send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
748            .with_context(|| format!("same-machine pair_drop_ack send to {peer_handle} failed"))?;
749
750        crate::os_notify::toast_dedup(
751            &format!("same-machine:{peer_handle}"),
752            &format!("wire — auto-paired {peer_handle}"),
753            "Same operator, same machine; pinned ORG_VERIFIED (same-machine attestation).",
754        );
755        return Ok(Some(peer_did));
756    }
757
758    // #15 rotation-refresh: a re-intro (no nonce) from a peer whose DID is
759    // ALREADY pinned at a consented tier (we accepted THIS exact identity) is a
760    // TRANSPORT refresh — typically after a RUDE slot rotation that left our
761    // peers holding a now-410 slot. Re-pin their advertised endpoints and re-ack
762    // (restoring our write-token) WITHOUT a fresh manual accept: no NEW consent
763    // is needed for an identity we already trust, and the verified card sig +
764    // #245's DID-keyed pin mean only the real key-holder can trigger it. The
765    // tier is unchanged; this never crosses UNTRUSTED → trusted (a not-yet-
766    // accepted or different-DID peer still falls through to pending-inbound).
767    {
768        let trust = config::read_trust()?;
769        let existing = trust.get("agents").and_then(|a| a.get(&peer_handle));
770        let existing_did = existing.and_then(|e| e.get("did")).and_then(Value::as_str);
771        let existing_tier = existing
772            .and_then(|e| e.get("tier"))
773            .and_then(Value::as_str)
774            .unwrap_or("UNTRUSTED");
775        if existing_did == Some(peer_did.as_str()) && existing_tier != "UNTRUSTED" {
776            let endpoints_to_pin = if peer_endpoints.is_empty() {
777                vec![crate::endpoints::Endpoint::federation(
778                    peer_relay.to_string(),
779                    peer_slot_id.to_string(),
780                    peer_slot_token.to_string(),
781                )]
782            } else {
783                peer_endpoints.clone()
784            };
785            let mut relay_state = config::read_relay_state()?;
786            crate::endpoints::pin_peer_endpoints(
787                &mut relay_state,
788                &peer_handle,
789                &endpoints_to_pin,
790            )?;
791            config::write_relay_state(&relay_state)?;
792            // Refresh the card pin at the SAME tier (covers key-succession:
793            // same DID, added key). #245 guard allows it — same DID.
794            let tier_owned = existing_tier.to_string();
795            config::update_trust(|t| {
796                crate::trust::add_agent_card_pin(t, &peer_card, Some(&tier_owned))
797                    .map_err(anyhow::Error::msg)
798            })?;
799            send_pair_drop_ack(&peer_handle, &endpoints_to_pin).with_context(|| {
800                format!("rotation-refresh pair_drop_ack to {peer_handle} failed")
801            })?;
802            crate::os_notify::toast_dedup(
803                &format!("rotate-refresh:{peer_handle}"),
804                &format!("wire — {peer_handle} rotated; refreshed"),
805                "Re-acked their new relay slot (already-trusted identity).",
806            );
807            return Ok(Some(peer_did));
808        }
809    }
810
811    let now_iso = time::OffsetDateTime::now_utc()
812        .format(&time::format_description::well_known::Rfc3339)
813        .unwrap_or_default();
814    let event_id = event
815        .get("event_id")
816        .and_then(Value::as_str)
817        .unwrap_or("")
818        .to_string();
819    let event_timestamp = event
820        .get("timestamp")
821        .and_then(Value::as_str)
822        .unwrap_or("")
823        .to_string();
824    let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
825        peer_handle: peer_handle.clone(),
826        peer_did: peer_did.clone(),
827        peer_card: peer_card.clone(),
828        peer_relay_url: peer_relay.to_string(),
829        peer_slot_id: peer_slot_id.to_string(),
830        peer_slot_token: peer_slot_token.to_string(),
831        peer_endpoints: peer_endpoints.clone(),
832        event_id,
833        event_timestamp,
834        received_at: now_iso,
835    };
836    crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
837
838    // RFC-001 Phase 1b — Notify mode: default-deny pending stash above runs
839    // unchanged (no auto-pin, no auto-ack), but we ENRICH the lock-screen
840    // notification with org context when the peer's verified membership is in
841    // an org the operator marked `notify`. Same `toast_dedup` keying pattern
842    // the auto branch uses so a flurry of pair_drops doesn't spam the
843    // notification center. Falls through to the generic toast otherwise.
844    match org_notify_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load()) {
845        Some(org_did) => crate::os_notify::toast_dedup(
846            &format!("notify-pair:{peer_handle}"),
847            &format!("wire — org-verified pair request from {peer_handle}"),
848            &format!(
849                "verified member of {org_did} (your org_policies.json says `notify`). run `wire accept {peer_handle}` to pin VERIFIED, or `wire reject {peer_handle}`",
850            ),
851        ),
852        None => crate::os_notify::toast(
853            &format!("wire — pair request from {peer_handle}"),
854            &format!(
855                "run `wire accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire reject {peer_handle}` to refuse",
856            ),
857        ),
858    }
859
860    Ok(Some(peer_did))
861}
862
863/// RFC-001 Phase 1b — decide whether a received card's org membership earns an
864/// auto-pin to `ORG_VERIFIED` under the receiver's policy. Returns the matched
865/// `org_did` iff the membership verifies offline AND the policy opts that org
866/// into auto (Option A). Pure over `policy`; never yields anything above
867/// `ORG_VERIFIED`. Safe-by-default: an empty/absent policy → `None`.
868fn org_auto_pin_decision(
869    card: &Value,
870    policy: &dyn crate::pair_decision::OrgPolicy,
871) -> Option<String> {
872    match crate::pair_decision::decide(
873        &crate::org_membership::evaluate_card_membership(card),
874        policy,
875    ) {
876        crate::pair_decision::PairAction::AutoOrgVerified { org_did } => Some(org_did),
877        _ => None,
878    }
879}
880
881/// RFC-001 Phase 1b — decide whether a received card's org membership is
882/// **eligible** for a one-tap accept under the receiver's policy (Notify mode,
883/// Option B in RFC-001 §"Default ease-of-pair mechanism"). Returns the matched
884/// `org_did` iff the membership verifies offline AND the policy opts that org
885/// into `notify`. The default-deny pending stash still fires; this decision
886/// only enriches the toast with org context so the operator can recognize the
887/// vouch on the lock-screen. Safe-by-default: empty/absent policy → `None`.
888/// Auto mode wins over Notify when both apply (auto returns first; this is
889/// only consulted on the non-auto path).
890fn org_notify_decision(
891    card: &Value,
892    policy: &dyn crate::pair_decision::OrgPolicy,
893) -> Option<String> {
894    match crate::pair_decision::decide(
895        &crate::org_membership::evaluate_card_membership(card),
896        policy,
897    ) {
898        crate::pair_decision::PairAction::NotifyOrgEligible { org_did } => Some(org_did),
899        _ => None,
900    }
901}
902
903/// Send a `pair_drop_ack` event (kind=1101) carrying OUR slot_token to a peer
904/// who just intro'd to us via `/v1/handle/intro/<nick>`. Completes the
905/// zero-paste bidirectional pin. Best-effort: errors are logged but don't
906/// propagate, since the inbound pair_drop pin already succeeded and the
907/// operator can retry from either side.
908/// Send a `pair_drop_ack` (kind=1101) carrying our slot_token to a peer.
909/// Used by the SPAKE2 invite-URL path (auto-called) and by the bilateral
910/// completion path in `cmd_add` (operator-driven). Failures propagate so
911/// the caller can surface the failure loudly.
912/// Send a pair_drop_ack to a peer. Iterates the peer's pinned endpoints
913/// in priority order (UDS / Local / LAN / Federation), trying each on
914/// failure — only errors if every endpoint fails. Fixes Bug 2: previously
915/// took a single `peer_relay`/`peer_slot_id`/`peer_slot_token` triple and
916/// gave up after the first POST, so a peer whose first endpoint 4xx'd
917/// (e.g. the userinfo-malformed URL from Bug 1) was unreachable even when
918/// they advertised a second, clean endpoint.
919///
920/// Back-compat: callers that only know a single endpoint (legacy v0.5.16-
921/// era pending records without `endpoints[]`) can pass a one-element slice
922/// built from the legacy fields — the helper handles list-of-one identically
923/// to the pre-fix single-endpoint shape.
924pub fn send_pair_drop_ack(
925    peer_handle: &str,
926    peer_endpoints: &[crate::endpoints::Endpoint],
927) -> Result<()> {
928    // Load our own card + relay coords.
929    let our_card = config::read_agent_card()?;
930    let our_did = our_card
931        .get("did")
932        .and_then(Value::as_str)
933        .ok_or_else(|| anyhow!("our card missing did"))?
934        .to_string();
935    let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
936    let relay_state = config::read_relay_state()?;
937    let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
938    // v0.7.5 silent-fail fix: prefer top-level legacy fields (v0.5.16
939    // and earlier writers), fall back to the first endpoint in
940    // self.endpoints[] (v0.5.17+ dual-slot writers). Pre-v0.7.5 this
941    // function ONLY read the legacy fields, so any session created
942    // with `--with-local` / `--with-uds` / `--with-lan` (which only
943    // populate endpoints[]) hit `self relay state incomplete; cannot
944    // emit pair_drop_ack` and silently black-holed every pair attempt.
945    // Logged as FM3 + the slancha-api ↔ source incident 2026-05-23.
946    let mut our_relay = self_state
947        .get("relay_url")
948        .and_then(Value::as_str)
949        .unwrap_or("")
950        .to_string();
951    let mut our_slot_id = self_state
952        .get("slot_id")
953        .and_then(Value::as_str)
954        .unwrap_or("")
955        .to_string();
956    let mut our_slot_token = self_state
957        .get("slot_token")
958        .and_then(Value::as_str)
959        .unwrap_or("")
960        .to_string();
961    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
962        // Try v0.5.17+ endpoints[] form. Pick the first endpoint —
963        // priority is preserved in self_endpoints() returned order
964        // (UDS / Local / LAN / Federation, lowest-friction first), so
965        // pair_drop_ack rides the same priority routing as send.
966        let eps = crate::endpoints::self_endpoints(&relay_state);
967        if let Some(ep) = eps.first() {
968            our_relay = ep.relay_url.clone();
969            our_slot_id = ep.slot_id.clone();
970            our_slot_token = ep.slot_token.clone();
971        }
972    }
973    if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
974        // STILL empty after both readers — the session genuinely has
975        // no inbound slot. This is the "agent without inbound mailbox"
976        // footgun. Refuse loudly with the exact remediation rather
977        // than the prior vague "self relay state incomplete" message.
978        bail!(
979            "this session has no inbound slot configured — peers cannot deliver to us.\n\
980             Fix: `wire bind-relay http://127.0.0.1:8771 --migrate-pinned` \
981             (allocates a slot and re-publishes our card to all pinned peers).\n\
982             Then re-run the pair flow. See WIRE_PAIRING_INCIDENT_2026-05-23 for context."
983        );
984    }
985
986    let sk_seed = config::read_private_key()?;
987    let pk_b64 = our_card
988        .get("verify_keys")
989        .and_then(Value::as_object)
990        .and_then(|m| m.values().next())
991        .and_then(|v| v.get("key"))
992        .and_then(Value::as_str)
993        .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
994    let pk_bytes = crate::signing::b64decode(pk_b64)?;
995
996    let now = time::OffsetDateTime::now_utc()
997        .format(&time::format_description::well_known::Rfc3339)
998        .unwrap_or_default();
999    // v0.5.17: also advertise our endpoints[] in the ack so the peer can
1000    // pin both our federation and local endpoints. Back-compat: top-level
1001    // legacy fields above stay populated for v0.5.16-and-earlier readers.
1002    let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
1003    let mut body = json!({
1004        "relay_url": our_relay,
1005        "slot_id": our_slot_id,
1006        "slot_token": our_slot_token,
1007    });
1008    if !our_endpoints.is_empty() {
1009        body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
1010    }
1011    let event = json!({
1012        "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1013        "timestamp": now,
1014        "from": our_did,
1015        "to": format!("did:wire:{peer_handle}"),
1016        "type": "pair_drop_ack",
1017        "kind": 1101u32,
1018        "body": body,
1019    });
1020    let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
1021
1022    // Bug 2 fix: try every advertised peer endpoint in priority order; only
1023    // error if all fail. Pre-fix this function POSTed once to a single
1024    // endpoint and gave up on the first 4xx — a peer with [bad, good]
1025    // endpoints (e.g. the userinfo-malformed first endpoint surfaced by
1026    // Bug 1) was unreachable even though a good endpoint sat behind it.
1027    let (delivered_ep, _resp) =
1028        crate::relay_client::try_post_event_with_failover(peer_endpoints, &signed, |ep, ev| {
1029            crate::relay_client::post_event_to_endpoint(ep, ev)
1030        })
1031        .with_context(|| {
1032            format!(
1033                "pair_drop_ack to {peer_handle} failed across {} endpoint(s)",
1034                peer_endpoints.len()
1035            )
1036        })?;
1037    let _ = delivered_ep; // delivered_ep is available for future logging.
1038    Ok(())
1039}
1040
1041/// Consume a `pair_drop_ack` event during daemon pull. Updates
1042/// relay-state.peers[<peer>] with the ack's slot_token so we can `wire send`
1043/// to the peer. Returns `Ok(true)` if applied. Idempotent.
1044pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
1045    let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
1046    let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
1047    if kind != 1101 || type_str != "pair_drop_ack" {
1048        return Ok(false);
1049    }
1050    let body = match event.get("body") {
1051        Some(b) => b,
1052        None => return Ok(false),
1053    };
1054    let from = event
1055        .get("from")
1056        .and_then(Value::as_str)
1057        .ok_or_else(|| anyhow!("ack missing 'from'"))?;
1058    let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
1059    let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
1060    let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
1061    let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
1062    if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
1063        bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
1064    }
1065    // v0.5.17: parse endpoints[] if present (peer ran v0.5.17+ and has
1066    // dual slots); fall back to a single federation entry synthesized
1067    // from the legacy fields for v0.5.16-and-earlier acks.
1068    let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
1069        .get("endpoints")
1070        .and_then(Value::as_array)
1071        .map(|arr| {
1072            arr.iter()
1073                .filter_map(|e| {
1074                    serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
1075                })
1076                .collect()
1077        })
1078        .unwrap_or_else(|| {
1079            vec![crate::endpoints::Endpoint::federation(
1080                peer_relay.to_string(),
1081                peer_slot_id.to_string(),
1082                peer_slot_token.to_string(),
1083            )]
1084        });
1085    let mut relay_state = config::read_relay_state()?;
1086    crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
1087    // v0.14.2 (#162 fix #5): stamp the durable bilateral-completed marker
1088    // on receipt of pair_drop_ack — this is the moment the bilateral
1089    // handshake actually completes (we already have their slot_token
1090    // pinned from their pair_drop; they sent the ack carrying ours).
1091    // Monotonic: once set, NEVER cleared. `effective_peer_tier` reads
1092    // this instead of slot_token presence so a transient endpoint
1093    // re-pin can't flap the visible tier from VERIFIED → PENDING_ACK.
1094    // `pin_peer_endpoints` preserves the field across re-pin events.
1095    if let Some(peer_entry) = relay_state
1096        .get_mut("peers")
1097        .and_then(Value::as_object_mut)
1098        .and_then(|m| m.get_mut(&peer_handle))
1099        .and_then(Value::as_object_mut)
1100    {
1101        peer_entry
1102            .entry("bilateral_completed_at".to_string())
1103            .or_insert_with(|| {
1104                Value::String(
1105                    time::OffsetDateTime::now_utc()
1106                        .format(&time::format_description::well_known::Rfc3339)
1107                        .unwrap_or_default(),
1108                )
1109            });
1110    }
1111    config::write_relay_state(&relay_state)?;
1112    // v0.14.2 (#162 follow-on, honey-pine cosmetic find 2026-06-01):
1113    // when bilateral completes via this path (we received the peer's
1114    // pair_drop_ack, meaning they already had our pair_drop_ack), any
1115    // pending-inbound record from an EARLIER inbound pair_drop is now
1116    // stale — the pair is bilaterally pinned, the operator no longer
1117    // needs to consent. Clear it idempotently so `wire status` /
1118    // `wire_pending` stop showing a "waiting on consent" entry for a
1119    // peer that's already VERIFIED. honey saw sunlit-aurora linger in
1120    // `pending_pairs.inbound_handles` even after the tier promoted.
1121    if let Err(e) = crate::pending_inbound_pair::consume_pending_inbound(&peer_handle) {
1122        // Non-fatal — pending-inbound clear is hygiene, not correctness.
1123        // Log but don't fail the bilateral-completion path.
1124        eprintln!("pair_drop_ack: failed to clear stale pending_inbound for {peer_handle}: {e:#}");
1125    }
1126    crate::os_notify::toast(
1127        &format!("wire — pair complete with {peer_handle}"),
1128        "Both sides bound. Ready to send + receive.",
1129    );
1130    Ok(true)
1131}
1132
1133// Earlier note: "tests removed because of WIRE_HOME race." That's no longer
1134// true — `config::test_support::with_temp_home` serialises env-mutating
1135// tests behind a process-wide mutex, so unit tests here are safe again.
1136// Keep e2e coverage in `tests/e2e_invite_pair.rs` for full-flow paranoia.
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use crate::endpoints::Endpoint;
1142
1143    // ---- #279: relay-honoring self-slot reuse ----
1144
1145    #[test]
1146    fn pick_reusable_some_relay_only_matches_that_relay() {
1147        // The #279 layout: a loopback primary + a federation slot. Asking for
1148        // wireup.net must NOT return the loopback slot — it returns the
1149        // wireup.net slot, and a relay with NO existing slot returns None
1150        // (caller then allocates there) instead of an unrelated slot.
1151        let existing = vec![
1152            Endpoint::local("http://127.0.0.1:18791".into(), "loc".into(), "lt".into()),
1153            Endpoint::federation("https://wireup.net".into(), "fed".into(), "ft".into()),
1154        ];
1155        let pick = pick_reusable_self_endpoint(&existing, Some("https://wireup.net")).unwrap();
1156        assert_eq!(pick.slot_id, "fed");
1157        // Trailing slash is normalized away.
1158        let pick2 = pick_reusable_self_endpoint(&existing, Some("https://wireup.net/")).unwrap();
1159        assert_eq!(pick2.slot_id, "fed");
1160        // A relay we hold no slot on → None (allocate, don't reuse loopback).
1161        assert!(pick_reusable_self_endpoint(&existing, Some("https://other.example")).is_none());
1162    }
1163
1164    #[test]
1165    fn pick_reusable_loopback_only_does_not_satisfy_federation_request() {
1166        // The exact #279 trap: only a loopback slot exists, caller asks for a
1167        // federation relay → must NOT reuse the loopback (which the peer can't
1168        // reach) — returns None so the caller allocates on the real relay.
1169        let existing = vec![Endpoint::local(
1170            "http://127.0.0.1:18791".into(),
1171            "loc".into(),
1172            "lt".into(),
1173        )];
1174        assert!(pick_reusable_self_endpoint(&existing, Some("https://wireup.net")).is_none());
1175    }
1176
1177    #[test]
1178    fn pick_reusable_none_prefers_federation_then_first() {
1179        // No explicit relay → keep the v0.6.6 behavior: federation-first, else
1180        // the first existing slot (don't churn, don't auto-federate).
1181        let local_only = vec![Endpoint::local(
1182            "http://127.0.0.1:8771".into(),
1183            "loc".into(),
1184            "lt".into(),
1185        )];
1186        assert_eq!(
1187            pick_reusable_self_endpoint(&local_only, None)
1188                .unwrap()
1189                .slot_id,
1190            "loc"
1191        );
1192        let dual = vec![
1193            Endpoint::local("http://127.0.0.1:8771".into(), "loc".into(), "lt".into()),
1194            Endpoint::federation("https://wireup.net".into(), "fed".into(), "ft".into()),
1195        ];
1196        assert_eq!(
1197            pick_reusable_self_endpoint(&dual, None).unwrap().slot_id,
1198            "fed"
1199        );
1200        assert!(pick_reusable_self_endpoint(&[], None).is_none());
1201    }
1202
1203    // ---- RFC-001 Phase 1b: org-auto-pin decision gate ----
1204
1205    struct AutoFor(String);
1206    impl crate::pair_decision::OrgPolicy for AutoFor {
1207        fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1208            (org_did == self.0).then_some(crate::pair_decision::InboundMode::Auto)
1209        }
1210    }
1211    struct EmptyPolicy;
1212    impl crate::pair_decision::OrgPolicy for EmptyPolicy {
1213        fn inbound_mode(&self, _: &str) -> Option<crate::pair_decision::InboundMode> {
1214            None
1215        }
1216    }
1217
1218    /// Build a signed v3.2 card for an operator enrolled in one org.
1219    fn org_verified_card() -> (Value, String) {
1220        let (op_sk, op_pk) = crate::signing::generate_keypair();
1221        let (org_sk, org_pk) = crate::signing::generate_keypair();
1222        let (sess_sk, sess_pk) = crate::signing::generate_keypair();
1223        let op_did = crate::agent_card::did_for_op("darby", &op_pk);
1224        let org_did = crate::agent_card::did_for_org("slanchaai", &org_pk);
1225        let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did).unwrap();
1226        let base = crate::agent_card::build_agent_card("vesper-valley", &sess_pk, None, None, None);
1227        let session_did = base
1228            .get("did")
1229            .and_then(|v| v.as_str())
1230            .unwrap()
1231            .to_string();
1232        let claims = crate::enroll::build_member_claims(
1233            "darby",
1234            &op_sk,
1235            &op_pk,
1236            &session_did,
1237            &[crate::enroll::MemberOf {
1238                org_did: org_did.clone(),
1239                org_pubkey: org_pk,
1240                member_cert,
1241            }],
1242            None,
1243        )
1244        .unwrap();
1245        let card = crate::agent_card::sign_agent_card(
1246            &crate::agent_card::with_identity_claims(&base, &claims).unwrap(),
1247            &sess_sk,
1248        );
1249        (card, org_did)
1250    }
1251
1252    #[test]
1253    fn org_auto_pin_decision_auto_only_when_policy_opts_in() {
1254        let (card, org_did) = org_verified_card();
1255        // Policy opts this org into auto → Some(org_did).
1256        assert_eq!(
1257            org_auto_pin_decision(&card, &AutoFor(org_did.clone())),
1258            Some(org_did.clone())
1259        );
1260        // Empty policy → None (safe-by-default: no opt-in, no auto-pin).
1261        assert_eq!(org_auto_pin_decision(&card, &EmptyPolicy), None);
1262    }
1263
1264    #[test]
1265    fn org_auto_pin_decision_none_for_plain_card() {
1266        // A v3.1 card with no op/org claims never auto-pins, even with an
1267        // auto-everything policy — there's no verified membership to match.
1268        let plain = serde_json::json!({
1269            "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1270        });
1271        assert_eq!(
1272            org_auto_pin_decision(&plain, &AutoFor("did:wire:org:x-1".into())),
1273            None
1274        );
1275    }
1276
1277    // ---- RFC-001 Phase 1b: org-notify decision gate ----
1278
1279    struct NotifyFor(String);
1280    impl crate::pair_decision::OrgPolicy for NotifyFor {
1281        fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1282            (org_did == self.0).then_some(crate::pair_decision::InboundMode::Notify)
1283        }
1284    }
1285
1286    #[test]
1287    fn org_notify_decision_notify_only_when_policy_opts_in() {
1288        let (card, org_did) = org_verified_card();
1289        // Policy opts this org into notify → Some(org_did).
1290        assert_eq!(
1291            org_notify_decision(&card, &NotifyFor(org_did.clone())),
1292            Some(org_did.clone())
1293        );
1294        // Empty policy → None.
1295        assert_eq!(org_notify_decision(&card, &EmptyPolicy), None);
1296    }
1297
1298    #[test]
1299    fn org_notify_decision_returns_none_when_policy_is_auto() {
1300        // Auto and Notify are mutually exclusive PairActions — a card whose
1301        // org is in the policy as `auto` must NOT also surface via the notify
1302        // helper (auto wins; notify is only consulted on the non-auto path).
1303        let (card, org_did) = org_verified_card();
1304        assert_eq!(org_notify_decision(&card, &AutoFor(org_did)), None);
1305    }
1306
1307    #[test]
1308    fn org_notify_decision_none_for_plain_card() {
1309        // A v3.1 card with no op/org claims never matches notify — no
1310        // verified membership to match against the policy.
1311        let plain = serde_json::json!({
1312            "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1313        });
1314        assert_eq!(
1315            org_notify_decision(&plain, &NotifyFor("did:wire:org:x-1".into())),
1316            None
1317        );
1318    }
1319    use crate::config;
1320
1321    #[test]
1322    fn record_pair_rejection_writes_jsonl_under_state_dir() {
1323        // P0.2: silent fails must leave a trace. This is what `wire doctor`
1324        // (P1.6) will surface. If the file isn't written, `wire doctor`
1325        // can't see the problem — same silent-fail class we're fixing.
1326        config::test_support::with_temp_home(|| {
1327            super::record_pair_rejection(
1328                "slancha-spark",
1329                "pair_drop_ack_send_failed",
1330                "POST returned 502",
1331            );
1332            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1333            assert!(path.exists(), "record_pair_rejection must create {path:?}");
1334            let body = std::fs::read_to_string(&path).unwrap();
1335            let line = body.lines().last().expect("at least one line");
1336            let parsed: Value = serde_json::from_str(line).expect("valid JSON");
1337            assert_eq!(parsed["peer"], "slancha-spark");
1338            assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
1339            assert_eq!(parsed["detail"], "POST returned 502");
1340            assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
1341        });
1342    }
1343
1344    #[test]
1345    fn maybe_consume_pair_drop_ack_clears_stale_pending_inbound() {
1346        // honey-pine cosmetic find 2026-06-01 (#162 follow-on): a peer
1347        // whose pair completed bilaterally lingered in
1348        // `pending_pairs.inbound_handles`. Repro: write a pending-inbound
1349        // record (as if peer sent us a pair_drop first), then feed a
1350        // valid kind=1101 `pair_drop_ack` for that peer through
1351        // maybe_consume_pair_drop_ack — the pending record should be
1352        // gone afterwards.
1353        config::test_support::with_temp_home(|| {
1354            let peer_handle = "test-peer";
1355            let peer_did = format!("did:wire:{peer_handle}-abcdef12");
1356            let pending = crate::pending_inbound_pair::PendingInboundPair {
1357                peer_handle: peer_handle.to_string(),
1358                peer_did: peer_did.clone(),
1359                peer_card: serde_json::json!({"did": peer_did.clone()}),
1360                peer_relay_url: "https://example.test".into(),
1361                peer_slot_id: "slot-aaaa".into(),
1362                peer_slot_token: "token-bbbb".into(),
1363                peer_endpoints: vec![],
1364                event_id: "evt-0001".into(),
1365                event_timestamp: "2026-06-01T20:00:00Z".into(),
1366                received_at: "2026-06-01T20:00:01Z".into(),
1367            };
1368            crate::pending_inbound_pair::write_pending_inbound(&pending).unwrap();
1369            assert!(
1370                crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1371                    .unwrap()
1372                    .is_some(),
1373                "precondition: pending record exists"
1374            );
1375            let ack_event = serde_json::json!({
1376                "kind": 1101,
1377                "type": "pair_drop_ack",
1378                "from": peer_did,
1379                "body": {
1380                    "relay_url": "https://example.test",
1381                    "slot_id": "slot-cccc",
1382                    "slot_token": "token-dddd",
1383                },
1384            });
1385            let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1386            assert!(consumed, "pair_drop_ack should be consumed");
1387            assert!(
1388                crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1389                    .unwrap()
1390                    .is_none(),
1391                "stale pending-inbound record must be cleared on bilateral completion"
1392            );
1393        });
1394    }
1395
1396    #[test]
1397    fn maybe_consume_pair_drop_ack_no_op_when_no_pending_inbound_exists() {
1398        // Idempotence: the consume_pending_inbound call must NOT fail or
1399        // surface an error when there's no pending record (the common
1400        // case for peers we dialed via `wire add`, where no inbound
1401        // pair_drop was ever stashed).
1402        config::test_support::with_temp_home(|| {
1403            let peer_handle = "fresh-peer";
1404            let peer_did = format!("did:wire:{peer_handle}-12345678");
1405            let ack_event = serde_json::json!({
1406                "kind": 1101,
1407                "type": "pair_drop_ack",
1408                "from": peer_did,
1409                "body": {
1410                    "relay_url": "https://example.test",
1411                    "slot_id": "slot-eeee",
1412                    "slot_token": "token-ffff",
1413                },
1414            });
1415            let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1416            assert!(consumed, "ack must still be consumed (the pinning path)");
1417        });
1418    }
1419
1420    #[test]
1421    fn record_pair_rejection_appends_multiple_lines() {
1422        // Multiple silent fails in one session must each leave a record —
1423        // it's append-only, not a single most-recent slot.
1424        config::test_support::with_temp_home(|| {
1425            super::record_pair_rejection("a", "code_a", "detail_a");
1426            super::record_pair_rejection("b", "code_b", "detail_b");
1427            super::record_pair_rejection("c", "code_c", "detail_c");
1428            let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1429            let body = std::fs::read_to_string(&path).unwrap();
1430            let lines: Vec<&str> = body.lines().collect();
1431            assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
1432            for (i, peer) in ["a", "b", "c"].iter().enumerate() {
1433                let parsed: Value = serde_json::from_str(lines[i]).unwrap();
1434                assert_eq!(parsed["peer"], *peer);
1435            }
1436        });
1437    }
1438}