1use std::path::PathBuf;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use anyhow::{Context, Result, anyhow, bail};
27use base64::Engine as _;
28use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
29use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32
33use crate::config;
34
35pub const DEFAULT_RELAY: &str = "https://wireup.net";
36pub const DEFAULT_TTL_SECS: u64 = 86_400; pub(crate) fn record_pair_rejection(peer_handle: &str, code: &str, detail: &str) {
46 let line = json!({
47 "ts": std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map(|d| d.as_secs())
50 .unwrap_or(0),
51 "peer": peer_handle,
52 "code": code,
53 "detail": detail,
54 });
55 let serialised = match serde_json::to_string(&line) {
56 Ok(s) => s,
57 Err(e) => {
58 eprintln!("wire: could not serialise pair-rejected entry: {e}");
59 return;
60 }
61 };
62 let path = match config::state_dir() {
63 Ok(d) => d.join("pair-rejected.jsonl"),
64 Err(e) => {
65 eprintln!("wire: state_dir unresolved, dropping pair-rejected log: {e}");
66 return;
67 }
68 };
69 if let Some(parent) = path.parent()
70 && let Err(e) = std::fs::create_dir_all(parent)
71 {
72 eprintln!("wire: could not create {parent:?}: {e}");
73 return;
74 }
75 use std::io::Write;
76 match std::fs::OpenOptions::new()
77 .create(true)
78 .append(true)
79 .open(&path)
80 {
81 Ok(mut f) => {
82 if let Err(e) = writeln!(f, "{serialised}") {
83 eprintln!("wire: could not append pair-rejected to {path:?}: {e}");
84 }
85 }
86 Err(e) => {
87 eprintln!("wire: could not open {path:?}: {e}");
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct InvitePayload {
95 pub v: u32,
97 pub did: String,
99 pub card: Value,
101 pub relay_url: String,
103 pub slot_id: String,
105 pub slot_token: String,
107 pub nonce: String,
109 pub exp: u64,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PendingInvite {
116 pub nonce: String,
117 pub exp: u64,
118 pub uses_remaining: u32,
119 pub accepted_by: Vec<String>,
121 pub created_at: String,
122}
123
124fn open_mode_enabled() -> bool {
128 let path = match config::config_dir() {
129 Ok(p) => p.join("policy.json"),
130 Err(_) => return true,
131 };
132 let bytes = match std::fs::read(&path) {
133 Ok(b) => b,
134 Err(_) => return true,
135 };
136 let v: Value = match serde_json::from_slice(&bytes) {
137 Ok(v) => v,
138 Err(_) => return true,
139 };
140 v.get("accept_unknown_pair_drops")
141 .and_then(Value::as_bool)
142 .unwrap_or(true)
143}
144
145pub fn pending_invites_dir() -> Result<PathBuf> {
146 Ok(config::state_dir()?.join("pending-invites"))
147}
148
149fn now_unix() -> u64 {
150 SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .map(|d| d.as_secs())
153 .unwrap_or(0)
154}
155
156fn default_handle() -> String {
159 let raw = hostname::get()
160 .ok()
161 .and_then(|s| s.into_string().ok())
162 .unwrap_or_else(|| "wire-user".into());
163 let sanitized: String = raw
164 .chars()
165 .map(|c| {
166 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
167 c
168 } else {
169 '-'
170 }
171 })
172 .collect();
173 if sanitized.is_empty() {
174 "wire-user".into()
175 } else {
176 sanitized
177 }
178}
179
180pub fn ensure_self_with_relay(
183 preferred_relay: Option<&str>,
184) -> Result<(String, String, String, String)> {
185 let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
186
187 if !config::is_initialized()? {
188 let handle = default_handle();
189 crate::pair_session::init_self_idempotent(&handle, None, Some(relay))
190 .with_context(|| format!("auto-init as did:wire:{handle}"))?;
191 }
192
193 let card = config::read_agent_card()?;
194 let did = card
195 .get("did")
196 .and_then(Value::as_str)
197 .ok_or_else(|| anyhow!("agent-card missing did"))?
198 .to_string();
199
200 let mut relay_state = config::read_relay_state()?;
201
202 let existing = crate::endpoints::self_endpoints(&relay_state);
210 if !existing.is_empty() {
211 let ep = existing
212 .iter()
213 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
214 .cloned()
215 .unwrap_or_else(|| existing[0].clone());
216 return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
217 }
218
219 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
220
221 if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
222 let client = crate::relay_client::RelayClient::new(relay);
223 client.check_healthz()?;
224 let handle = crate::agent_card::display_handle_from_did(&did);
225 let alloc = client.allocate_slot(Some(handle))?;
226 relay_state["self"] = json!({
227 "relay_url": relay,
228 "slot_id": alloc.slot_id,
229 "slot_token": alloc.slot_token,
230 });
231 config::write_relay_state(&relay_state)?;
232 }
233
234 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
235 let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
236 let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
237 let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
238 if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
239 bail!("self relay state incomplete after auto-allocate");
240 }
241 Ok((did, relay_url, slot_id, slot_token))
242}
243
244pub fn mint_invite(
246 ttl_secs: Option<u64>,
247 uses: u32,
248 preferred_relay: Option<&str>,
249) -> Result<String> {
250 let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
251
252 let card = config::read_agent_card()?;
253 let sk_seed = config::read_private_key()?;
254
255 let mut nonce_bytes = [0u8; 32];
256 use rand::RngCore;
257 rand::thread_rng().fill_bytes(&mut nonce_bytes);
258 let nonce = hex::encode(nonce_bytes);
259
260 let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
261 let exp = now_unix() + ttl;
262
263 let payload = InvitePayload {
264 v: 1,
265 did: did.clone(),
266 card,
267 relay_url,
268 slot_id,
269 slot_token,
270 nonce: nonce.clone(),
271 exp,
272 };
273 let payload_bytes = serde_json::to_vec(&payload)?;
274
275 let mut sk_arr = [0u8; 32];
276 sk_arr.copy_from_slice(&sk_seed[..32]);
277 let sk = SigningKey::from_bytes(&sk_arr);
278 let sig = sk.sign(&payload_bytes);
279
280 let token = format!(
281 "{}.{}",
282 B64URL.encode(&payload_bytes),
283 B64URL.encode(sig.to_bytes())
284 );
285 let url = format!("wire://pair?v=1&inv={token}");
286
287 let now = time::OffsetDateTime::now_utc()
288 .format(&time::format_description::well_known::Rfc3339)
289 .unwrap_or_default();
290 let pending = PendingInvite {
291 nonce: nonce.clone(),
292 exp,
293 uses_remaining: uses.max(1),
294 accepted_by: vec![],
295 created_at: now,
296 };
297 let dir = pending_invites_dir()?;
298 std::fs::create_dir_all(&dir)?;
299 let path = dir.join(format!("{nonce}.json"));
300 std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
301
302 Ok(url)
303}
304
305pub fn parse_invite(url: &str) -> Result<InvitePayload> {
308 let rest = url
309 .strip_prefix("wire://pair?")
310 .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
311 let mut inv = None;
312 for part in rest.split('&') {
313 if let Some(v) = part.strip_prefix("inv=") {
314 inv = Some(v);
315 }
316 }
317 let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
318 let (payload_b64, sig_b64) = token
319 .split_once('.')
320 .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
321 let payload_bytes = B64URL
322 .decode(payload_b64)
323 .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
324 let sig_bytes = B64URL
325 .decode(sig_b64)
326 .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
327
328 let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
329 .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
330
331 if payload.v != 1 {
332 bail!("invite schema version {} not supported", payload.v);
333 }
334 if now_unix() > payload.exp {
335 bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
336 }
337
338 crate::agent_card::verify_agent_card(&payload.card)
340 .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
341
342 let pk_b64 = payload
343 .card
344 .get("verify_keys")
345 .and_then(Value::as_object)
346 .and_then(|m| m.values().next())
347 .and_then(|v| v.get("key"))
348 .and_then(Value::as_str)
349 .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
350 let pk_bytes = crate::signing::b64decode(pk_b64)?;
351 let mut pk_arr = [0u8; 32];
352 if pk_bytes.len() != 32 {
353 bail!("issuer pubkey wrong length");
354 }
355 pk_arr.copy_from_slice(&pk_bytes);
356 let vk = VerifyingKey::from_bytes(&pk_arr)
357 .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
358 let mut sig_arr = [0u8; 64];
359 if sig_bytes.len() != 64 {
360 bail!("invite sig wrong length");
361 }
362 sig_arr.copy_from_slice(&sig_bytes);
363 let sig = Signature::from_bytes(&sig_arr);
364 vk.verify(&payload_bytes, &sig)
365 .map_err(|_| anyhow!("invite URL signature did not verify"))?;
366
367 Ok(payload)
368}
369
370pub fn accept_invite(url: &str) -> Result<Value> {
373 let payload = parse_invite(url)?;
374
375 let (our_did, our_relay, our_slot_id, our_slot_token) =
377 ensure_self_with_relay(Some(&payload.relay_url))?;
378
379 if our_did == payload.did {
380 bail!("refusing to accept own invite (issuer DID matches self)");
381 }
382
383 let mut trust = config::read_trust()?;
385 crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
386 config::write_trust(&trust)?;
387
388 let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
389 let mut relay_state = config::read_relay_state()?;
390 relay_state["peers"][&peer_handle] = json!({
391 "relay_url": payload.relay_url,
392 "slot_id": payload.slot_id,
393 "slot_token": payload.slot_token,
394 });
395 config::write_relay_state(&relay_state)?;
396
397 let our_card = config::read_agent_card()?;
401 let sk_seed = config::read_private_key()?;
402 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
403 let pk_b64 = our_card
404 .get("verify_keys")
405 .and_then(Value::as_object)
406 .and_then(|m| m.values().next())
407 .and_then(|v| v.get("key"))
408 .and_then(Value::as_str)
409 .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
410 let pk_bytes = crate::signing::b64decode(pk_b64)?;
411
412 let now = time::OffsetDateTime::now_utc()
413 .format(&time::format_description::well_known::Rfc3339)
414 .unwrap_or_default();
415 let event = json!({
416 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
417 "timestamp": now,
418 "from": our_did,
419 "to": payload.did,
420 "type": "pair_drop",
421 "kind": 1100u32,
422 "body": {
423 "card": our_card,
424 "relay_url": our_relay,
425 "slot_id": our_slot_id,
426 "slot_token": our_slot_token,
427 "pair_nonce": payload.nonce,
428 },
429 });
430 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
431 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
432
433 let client = crate::relay_client::RelayClient::new(&payload.relay_url);
434 client
435 .post_event(&payload.slot_id, &payload.slot_token, &signed)
436 .with_context(|| {
437 format!(
438 "POST pair_drop to {} slot {}",
439 payload.relay_url, payload.slot_id
440 )
441 })?;
442
443 Ok(json!({
444 "paired_with": payload.did,
445 "peer_handle": peer_handle,
446 "event_id": event_id,
447 "status": "drop_sent",
448 }))
449}
450
451pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
456 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
457 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
458 if kind != 1100 || type_str != "pair_drop" {
459 return Ok(None);
460 }
461 let body = match event.get("body") {
462 Some(b) => b,
463 None => return Ok(None),
464 };
465
466 let nonce_opt = body
472 .get("pair_nonce")
473 .and_then(Value::as_str)
474 .map(str::to_string);
475 let mut pending: Option<PendingInvite> = None;
476 let mut invite_path: Option<std::path::PathBuf> = None;
477 if let Some(nonce) = nonce_opt.as_deref() {
478 let dir = pending_invites_dir()?;
479 let path = dir.join(format!("{nonce}.json"));
480 if path.exists() {
481 let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
482 .with_context(|| format!("reading pending invite {path:?}"))?;
483 if now_unix() > p.exp {
484 if let Err(e) = std::fs::remove_file(&path) {
487 eprintln!("wire: could not delete expired invite {path:?}: {e}");
488 }
489 return Ok(None);
490 }
491 pending = Some(p);
492 invite_path = Some(path);
493 } else if !open_mode_enabled() {
494 return Ok(None);
498 }
499 } else if !open_mode_enabled() {
500 return Ok(None);
503 }
504
505 let peer_card = body
506 .get("card")
507 .cloned()
508 .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
509 crate::agent_card::verify_agent_card(&peer_card)
510 .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
511
512 let peer_did = peer_card
513 .get("did")
514 .and_then(Value::as_str)
515 .ok_or_else(|| anyhow!("peer card missing did"))?
516 .to_string();
517 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
518
519 let mut tmp_trust = config::read_trust()?;
524 crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
525 crate::signing::verify_message_v31(event, &tmp_trust)
526 .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
527
528 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
529 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
530 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
531 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
532 bail!("pair_drop body missing relay_url/slot_id/slot_token");
533 }
534
535 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
540 .get("endpoints")
541 .and_then(Value::as_array)
542 .map(|arr| {
543 arr.iter()
544 .filter_map(|e| {
545 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
546 })
547 .collect()
548 })
549 .unwrap_or_else(|| {
550 vec![crate::endpoints::Endpoint::federation(
551 peer_relay.to_string(),
552 peer_slot_id.to_string(),
553 peer_slot_token.to_string(),
554 )]
555 });
556
557 if nonce_opt.is_some() {
575 config::write_trust(&tmp_trust)?;
577 let mut relay_state = config::read_relay_state()?;
578 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
582 config::write_relay_state(&relay_state)?;
583
584 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
586 if pending.uses_remaining <= 1 {
587 if let Err(e) = std::fs::remove_file(&invite_path) {
588 eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
589 }
590 } else {
591 let mut updated = pending.clone();
592 updated.uses_remaining -= 1;
593 updated.accepted_by.push(peer_did.clone());
594 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
595 }
596 }
597 crate::os_notify::toast(
598 &format!("wire — paired with {peer_handle}"),
599 "Invite accepted. Ready to send + receive.",
600 );
601 return Ok(Some(peer_did));
602 }
603
604 if let Some(org_did) =
613 org_auto_pin_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load())
614 {
615 let mut trust = crate::config::read_trust()?;
616 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("ORG_VERIFIED"));
617 crate::config::write_trust(&trust)?;
618
619 let endpoints_to_pin = if peer_endpoints.is_empty() {
620 vec![crate::endpoints::Endpoint::federation(
621 peer_relay.to_string(),
622 peer_slot_id.to_string(),
623 peer_slot_token.to_string(),
624 )]
625 } else {
626 peer_endpoints.clone()
627 };
628 let mut relay_state = crate::config::read_relay_state()?;
629 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
630 crate::config::write_relay_state(&relay_state)?;
631
632 send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
633 .with_context(|| format!("org-auto pair_drop_ack send to {peer_handle} failed"))?;
634
635 crate::os_notify::toast_dedup(
636 &format!("org-pair:{peer_handle}"),
637 &format!("wire — auto-paired {peer_handle}"),
638 &format!(
639 "org-verified member of {org_did}; pinned ORG_VERIFIED (your org_policies.json opt-in)"
640 ),
641 );
642 return Ok(Some(peer_did));
643 }
644
645 let now_iso = time::OffsetDateTime::now_utc()
646 .format(&time::format_description::well_known::Rfc3339)
647 .unwrap_or_default();
648 let event_id = event
649 .get("event_id")
650 .and_then(Value::as_str)
651 .unwrap_or("")
652 .to_string();
653 let event_timestamp = event
654 .get("timestamp")
655 .and_then(Value::as_str)
656 .unwrap_or("")
657 .to_string();
658 let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
659 peer_handle: peer_handle.clone(),
660 peer_did: peer_did.clone(),
661 peer_card: peer_card.clone(),
662 peer_relay_url: peer_relay.to_string(),
663 peer_slot_id: peer_slot_id.to_string(),
664 peer_slot_token: peer_slot_token.to_string(),
665 peer_endpoints: peer_endpoints.clone(),
666 event_id,
667 event_timestamp,
668 received_at: now_iso,
669 };
670 crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
671
672 match org_notify_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load()) {
679 Some(org_did) => crate::os_notify::toast_dedup(
680 &format!("notify-pair:{peer_handle}"),
681 &format!("wire — org-verified pair request from {peer_handle}"),
682 &format!(
683 "verified member of {org_did} (your org_policies.json says `notify`). run `wire pair-accept {peer_handle}` to pin VERIFIED, or `wire pair-reject {peer_handle}`",
684 ),
685 ),
686 None => crate::os_notify::toast(
687 &format!("wire — pair request from {peer_handle}"),
688 &format!(
689 "run `wire pair-accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire pair-reject {peer_handle}` to refuse",
690 ),
691 ),
692 }
693
694 Ok(Some(peer_did))
695}
696
697fn org_auto_pin_decision(
703 card: &Value,
704 policy: &dyn crate::pair_decision::OrgPolicy,
705) -> Option<String> {
706 match crate::pair_decision::decide(
707 &crate::org_membership::evaluate_card_membership(card),
708 policy,
709 ) {
710 crate::pair_decision::PairAction::AutoOrgVerified { org_did } => Some(org_did),
711 _ => None,
712 }
713}
714
715fn org_notify_decision(
725 card: &Value,
726 policy: &dyn crate::pair_decision::OrgPolicy,
727) -> Option<String> {
728 match crate::pair_decision::decide(
729 &crate::org_membership::evaluate_card_membership(card),
730 policy,
731 ) {
732 crate::pair_decision::PairAction::NotifyOrgEligible { org_did } => Some(org_did),
733 _ => None,
734 }
735}
736
737pub fn send_pair_drop_ack(
759 peer_handle: &str,
760 peer_endpoints: &[crate::endpoints::Endpoint],
761) -> Result<()> {
762 let our_card = config::read_agent_card()?;
764 let our_did = our_card
765 .get("did")
766 .and_then(Value::as_str)
767 .ok_or_else(|| anyhow!("our card missing did"))?
768 .to_string();
769 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
770 let relay_state = config::read_relay_state()?;
771 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
772 let mut our_relay = self_state
781 .get("relay_url")
782 .and_then(Value::as_str)
783 .unwrap_or("")
784 .to_string();
785 let mut our_slot_id = self_state
786 .get("slot_id")
787 .and_then(Value::as_str)
788 .unwrap_or("")
789 .to_string();
790 let mut our_slot_token = self_state
791 .get("slot_token")
792 .and_then(Value::as_str)
793 .unwrap_or("")
794 .to_string();
795 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
796 let eps = crate::endpoints::self_endpoints(&relay_state);
801 if let Some(ep) = eps.first() {
802 our_relay = ep.relay_url.clone();
803 our_slot_id = ep.slot_id.clone();
804 our_slot_token = ep.slot_token.clone();
805 }
806 }
807 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
808 bail!(
813 "this session has no inbound slot configured — peers cannot deliver to us.\n\
814 Fix: `wire bind-relay http://127.0.0.1:8771 --migrate-pinned` \
815 (allocates a slot and re-publishes our card to all pinned peers).\n\
816 Then re-run the pair flow. See WIRE_PAIRING_INCIDENT_2026-05-23 for context."
817 );
818 }
819
820 let sk_seed = config::read_private_key()?;
821 let pk_b64 = our_card
822 .get("verify_keys")
823 .and_then(Value::as_object)
824 .and_then(|m| m.values().next())
825 .and_then(|v| v.get("key"))
826 .and_then(Value::as_str)
827 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
828 let pk_bytes = crate::signing::b64decode(pk_b64)?;
829
830 let now = time::OffsetDateTime::now_utc()
831 .format(&time::format_description::well_known::Rfc3339)
832 .unwrap_or_default();
833 let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
837 let mut body = json!({
838 "relay_url": our_relay,
839 "slot_id": our_slot_id,
840 "slot_token": our_slot_token,
841 });
842 if !our_endpoints.is_empty() {
843 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
844 }
845 let event = json!({
846 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
847 "timestamp": now,
848 "from": our_did,
849 "to": format!("did:wire:{peer_handle}"),
850 "type": "pair_drop_ack",
851 "kind": 1101u32,
852 "body": body,
853 });
854 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
855
856 let (delivered_ep, _resp) =
862 crate::relay_client::try_post_event_with_failover(peer_endpoints, &signed, |ep, ev| {
863 crate::relay_client::post_event_to_endpoint(ep, ev)
864 })
865 .with_context(|| {
866 format!(
867 "pair_drop_ack to {peer_handle} failed across {} endpoint(s)",
868 peer_endpoints.len()
869 )
870 })?;
871 let _ = delivered_ep; Ok(())
873}
874
875pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
879 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
880 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
881 if kind != 1101 || type_str != "pair_drop_ack" {
882 return Ok(false);
883 }
884 let body = match event.get("body") {
885 Some(b) => b,
886 None => return Ok(false),
887 };
888 let from = event
889 .get("from")
890 .and_then(Value::as_str)
891 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
892 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
893 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
894 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
895 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
896 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
897 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
898 }
899 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
903 .get("endpoints")
904 .and_then(Value::as_array)
905 .map(|arr| {
906 arr.iter()
907 .filter_map(|e| {
908 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
909 })
910 .collect()
911 })
912 .unwrap_or_else(|| {
913 vec![crate::endpoints::Endpoint::federation(
914 peer_relay.to_string(),
915 peer_slot_id.to_string(),
916 peer_slot_token.to_string(),
917 )]
918 });
919 let mut relay_state = config::read_relay_state()?;
920 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
921 if let Some(peer_entry) = relay_state
930 .get_mut("peers")
931 .and_then(Value::as_object_mut)
932 .and_then(|m| m.get_mut(&peer_handle))
933 .and_then(Value::as_object_mut)
934 {
935 peer_entry
936 .entry("bilateral_completed_at".to_string())
937 .or_insert_with(|| {
938 Value::String(
939 time::OffsetDateTime::now_utc()
940 .format(&time::format_description::well_known::Rfc3339)
941 .unwrap_or_default(),
942 )
943 });
944 }
945 config::write_relay_state(&relay_state)?;
946 if let Err(e) = crate::pending_inbound_pair::consume_pending_inbound(&peer_handle) {
956 eprintln!("pair_drop_ack: failed to clear stale pending_inbound for {peer_handle}: {e:#}");
959 }
960 crate::os_notify::toast(
961 &format!("wire — pair complete with {peer_handle}"),
962 "Both sides bound. Ready to send + receive.",
963 );
964 Ok(true)
965}
966
967#[cfg(test)]
973mod tests {
974 use super::*;
975
976 struct AutoFor(String);
979 impl crate::pair_decision::OrgPolicy for AutoFor {
980 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
981 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Auto)
982 }
983 }
984 struct EmptyPolicy;
985 impl crate::pair_decision::OrgPolicy for EmptyPolicy {
986 fn inbound_mode(&self, _: &str) -> Option<crate::pair_decision::InboundMode> {
987 None
988 }
989 }
990
991 fn org_verified_card() -> (Value, String) {
993 let (op_sk, op_pk) = crate::signing::generate_keypair();
994 let (org_sk, org_pk) = crate::signing::generate_keypair();
995 let (sess_sk, sess_pk) = crate::signing::generate_keypair();
996 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
997 let org_did = crate::agent_card::did_for_org("slanchaai", &org_pk);
998 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did).unwrap();
999 let base = crate::agent_card::build_agent_card("vesper-valley", &sess_pk, None, None, None);
1000 let session_did = base
1001 .get("did")
1002 .and_then(|v| v.as_str())
1003 .unwrap()
1004 .to_string();
1005 let claims = crate::enroll::build_member_claims(
1006 "darby",
1007 &op_sk,
1008 &op_pk,
1009 &session_did,
1010 &[crate::enroll::MemberOf {
1011 org_did: org_did.clone(),
1012 org_pubkey: org_pk,
1013 member_cert,
1014 }],
1015 None,
1016 )
1017 .unwrap();
1018 let card = crate::agent_card::sign_agent_card(
1019 &crate::agent_card::with_identity_claims(&base, &claims).unwrap(),
1020 &sess_sk,
1021 );
1022 (card, org_did)
1023 }
1024
1025 #[test]
1026 fn org_auto_pin_decision_auto_only_when_policy_opts_in() {
1027 let (card, org_did) = org_verified_card();
1028 assert_eq!(
1030 org_auto_pin_decision(&card, &AutoFor(org_did.clone())),
1031 Some(org_did.clone())
1032 );
1033 assert_eq!(org_auto_pin_decision(&card, &EmptyPolicy), None);
1035 }
1036
1037 #[test]
1038 fn org_auto_pin_decision_none_for_plain_card() {
1039 let plain = serde_json::json!({
1042 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1043 });
1044 assert_eq!(
1045 org_auto_pin_decision(&plain, &AutoFor("did:wire:org:x-1".into())),
1046 None
1047 );
1048 }
1049
1050 struct NotifyFor(String);
1053 impl crate::pair_decision::OrgPolicy for NotifyFor {
1054 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1055 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Notify)
1056 }
1057 }
1058
1059 #[test]
1060 fn org_notify_decision_notify_only_when_policy_opts_in() {
1061 let (card, org_did) = org_verified_card();
1062 assert_eq!(
1064 org_notify_decision(&card, &NotifyFor(org_did.clone())),
1065 Some(org_did.clone())
1066 );
1067 assert_eq!(org_notify_decision(&card, &EmptyPolicy), None);
1069 }
1070
1071 #[test]
1072 fn org_notify_decision_returns_none_when_policy_is_auto() {
1073 let (card, org_did) = org_verified_card();
1077 assert_eq!(org_notify_decision(&card, &AutoFor(org_did)), None);
1078 }
1079
1080 #[test]
1081 fn org_notify_decision_none_for_plain_card() {
1082 let plain = serde_json::json!({
1085 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1086 });
1087 assert_eq!(
1088 org_notify_decision(&plain, &NotifyFor("did:wire:org:x-1".into())),
1089 None
1090 );
1091 }
1092 use crate::config;
1093
1094 #[test]
1095 fn record_pair_rejection_writes_jsonl_under_state_dir() {
1096 config::test_support::with_temp_home(|| {
1100 super::record_pair_rejection(
1101 "slancha-spark",
1102 "pair_drop_ack_send_failed",
1103 "POST returned 502",
1104 );
1105 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1106 assert!(path.exists(), "record_pair_rejection must create {path:?}");
1107 let body = std::fs::read_to_string(&path).unwrap();
1108 let line = body.lines().last().expect("at least one line");
1109 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
1110 assert_eq!(parsed["peer"], "slancha-spark");
1111 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
1112 assert_eq!(parsed["detail"], "POST returned 502");
1113 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
1114 });
1115 }
1116
1117 #[test]
1118 fn maybe_consume_pair_drop_ack_clears_stale_pending_inbound() {
1119 config::test_support::with_temp_home(|| {
1127 let peer_handle = "test-peer";
1128 let peer_did = format!("did:wire:{peer_handle}-abcdef12");
1129 let pending = crate::pending_inbound_pair::PendingInboundPair {
1130 peer_handle: peer_handle.to_string(),
1131 peer_did: peer_did.clone(),
1132 peer_card: serde_json::json!({"did": peer_did.clone()}),
1133 peer_relay_url: "https://example.test".into(),
1134 peer_slot_id: "slot-aaaa".into(),
1135 peer_slot_token: "token-bbbb".into(),
1136 peer_endpoints: vec![],
1137 event_id: "evt-0001".into(),
1138 event_timestamp: "2026-06-01T20:00:00Z".into(),
1139 received_at: "2026-06-01T20:00:01Z".into(),
1140 };
1141 crate::pending_inbound_pair::write_pending_inbound(&pending).unwrap();
1142 assert!(
1143 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1144 .unwrap()
1145 .is_some(),
1146 "precondition: pending record exists"
1147 );
1148 let ack_event = serde_json::json!({
1149 "kind": 1101,
1150 "type": "pair_drop_ack",
1151 "from": peer_did,
1152 "body": {
1153 "relay_url": "https://example.test",
1154 "slot_id": "slot-cccc",
1155 "slot_token": "token-dddd",
1156 },
1157 });
1158 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1159 assert!(consumed, "pair_drop_ack should be consumed");
1160 assert!(
1161 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1162 .unwrap()
1163 .is_none(),
1164 "stale pending-inbound record must be cleared on bilateral completion"
1165 );
1166 });
1167 }
1168
1169 #[test]
1170 fn maybe_consume_pair_drop_ack_no_op_when_no_pending_inbound_exists() {
1171 config::test_support::with_temp_home(|| {
1176 let peer_handle = "fresh-peer";
1177 let peer_did = format!("did:wire:{peer_handle}-12345678");
1178 let ack_event = serde_json::json!({
1179 "kind": 1101,
1180 "type": "pair_drop_ack",
1181 "from": peer_did,
1182 "body": {
1183 "relay_url": "https://example.test",
1184 "slot_id": "slot-eeee",
1185 "slot_token": "token-ffff",
1186 },
1187 });
1188 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1189 assert!(consumed, "ack must still be consumed (the pinning path)");
1190 });
1191 }
1192
1193 #[test]
1194 fn record_pair_rejection_appends_multiple_lines() {
1195 config::test_support::with_temp_home(|| {
1198 super::record_pair_rejection("a", "code_a", "detail_a");
1199 super::record_pair_rejection("b", "code_b", "detail_b");
1200 super::record_pair_rejection("c", "code_c", "detail_c");
1201 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1202 let body = std::fs::read_to_string(&path).unwrap();
1203 let lines: Vec<&str> = body.lines().collect();
1204 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
1205 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
1206 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
1207 assert_eq!(parsed["peer"], *peer);
1208 }
1209 });
1210 }
1211}