1use 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; pub(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#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct InvitePayload {
97 pub v: u32,
99 pub did: String,
101 pub card: Value,
103 pub relay_url: String,
105 pub slot_id: String,
107 pub slot_token: String,
109 pub nonce: String,
111 pub exp: u64,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PendingInvite {
118 pub nonce: String,
119 pub exp: u64,
120 pub uses_remaining: u32,
121 pub accepted_by: Vec<String>,
123 pub created_at: String,
124}
125
126fn 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
158fn 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
182pub fn ensure_self_with_relay(
185 preferred_relay: Option<&str>,
186) -> Result<(String, String, String, String)> {
187 let relay = preferred_relay.unwrap_or(DEFAULT_RELAY);
188
189 if !config::is_initialized()? {
190 let handle = default_handle();
191 crate::init::init_self_idempotent(&handle, None, Some(relay))
192 .with_context(|| format!("auto-init as did:wire:{handle}"))?;
193 }
194
195 let card = config::read_agent_card()?;
196 let did = card
197 .get("did")
198 .and_then(Value::as_str)
199 .ok_or_else(|| anyhow!("agent-card missing did"))?
200 .to_string();
201
202 let mut relay_state = config::read_relay_state()?;
203
204 let existing = crate::endpoints::self_endpoints(&relay_state);
212 if !existing.is_empty() {
213 let ep = existing
214 .iter()
215 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
216 .cloned()
217 .unwrap_or_else(|| existing[0].clone());
218 return Ok((did, ep.relay_url, ep.slot_id, ep.slot_token));
219 }
220
221 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
222
223 if self_state.is_null() || self_state.get("slot_id").and_then(Value::as_str).is_none() {
224 let client = crate::relay_client::RelayClient::new(relay);
225 client.check_healthz()?;
226 let handle = crate::agent_card::display_handle_from_did(&did);
227 let alloc = client.allocate_slot(Some(handle))?;
228 relay_state["self"] = json!({
229 "relay_url": relay,
230 "slot_id": alloc.slot_id,
231 "slot_token": alloc.slot_token,
232 });
233 config::write_relay_state(&relay_state)?;
234 }
235
236 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
237 let relay_url = self_state["relay_url"].as_str().unwrap_or("").to_string();
238 let slot_id = self_state["slot_id"].as_str().unwrap_or("").to_string();
239 let slot_token = self_state["slot_token"].as_str().unwrap_or("").to_string();
240 if relay_url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
241 bail!("self relay state incomplete after auto-allocate");
242 }
243 Ok((did, relay_url, slot_id, slot_token))
244}
245
246pub fn mint_invite(
248 ttl_secs: Option<u64>,
249 uses: u32,
250 preferred_relay: Option<&str>,
251) -> Result<String> {
252 let (did, relay_url, slot_id, slot_token) = ensure_self_with_relay(preferred_relay)?;
253
254 let card = config::read_agent_card()?;
255 let sk_seed = config::read_private_key()?;
256
257 let mut nonce_bytes = [0u8; 32];
258 use rand::RngCore;
259 rand::thread_rng().fill_bytes(&mut nonce_bytes);
260 let nonce = hex::encode(nonce_bytes);
261
262 let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
263 let exp = now_unix() + ttl;
264
265 let payload = InvitePayload {
266 v: 1,
267 did: did.clone(),
268 card,
269 relay_url,
270 slot_id,
271 slot_token,
272 nonce: nonce.clone(),
273 exp,
274 };
275 let payload_bytes = serde_json::to_vec(&payload)?;
276
277 let mut sk_arr = [0u8; 32];
278 sk_arr.copy_from_slice(&sk_seed[..32]);
279 let sk = SigningKey::from_bytes(&sk_arr);
280 let sig = sk.sign(&payload_bytes);
281
282 let token = format!(
283 "{}.{}",
284 B64URL.encode(&payload_bytes),
285 B64URL.encode(sig.to_bytes())
286 );
287 let url = format!("wire://pair?v=1&inv={token}");
288
289 let now = time::OffsetDateTime::now_utc()
290 .format(&time::format_description::well_known::Rfc3339)
291 .unwrap_or_default();
292 let pending = PendingInvite {
293 nonce: nonce.clone(),
294 exp,
295 uses_remaining: uses.max(1),
296 accepted_by: vec![],
297 created_at: now,
298 };
299 let dir = pending_invites_dir()?;
300 std::fs::create_dir_all(&dir)?;
301 let path = dir.join(format!("{nonce}.json"));
302 std::fs::write(&path, serde_json::to_vec_pretty(&pending)?)?;
303
304 Ok(url)
305}
306
307pub fn parse_invite(url: &str) -> Result<InvitePayload> {
310 let rest = url
311 .strip_prefix("wire://pair?")
312 .ok_or_else(|| anyhow!("not a wire pair invite URL (must start with wire://pair?)"))?;
313 let mut inv = None;
314 for part in rest.split('&') {
315 if let Some(v) = part.strip_prefix("inv=") {
316 inv = Some(v);
317 }
318 }
319 let token = inv.ok_or_else(|| anyhow!("invite URL missing `inv=` parameter"))?;
320 let (payload_b64, sig_b64) = token
321 .split_once('.')
322 .ok_or_else(|| anyhow!("invite token missing `.` separator (payload.sig)"))?;
323 let payload_bytes = B64URL
324 .decode(payload_b64)
325 .map_err(|e| anyhow!("invite payload b64 decode failed: {e}"))?;
326 let sig_bytes = B64URL
327 .decode(sig_b64)
328 .map_err(|e| anyhow!("invite sig b64 decode failed: {e}"))?;
329
330 let payload: InvitePayload = serde_json::from_slice(&payload_bytes)
331 .map_err(|e| anyhow!("invite payload JSON decode failed: {e}"))?;
332
333 if payload.v != 1 {
334 bail!("invite schema version {} not supported", payload.v);
335 }
336 if now_unix() > payload.exp {
337 bail!("invite expired (exp={}, now={})", payload.exp, now_unix());
338 }
339
340 crate::agent_card::verify_agent_card(&payload.card)
342 .map_err(|e| anyhow!("invite issuer's card signature invalid: {e}"))?;
343
344 let pk_b64 = payload
345 .card
346 .get("verify_keys")
347 .and_then(Value::as_object)
348 .and_then(|m| m.values().next())
349 .and_then(|v| v.get("key"))
350 .and_then(Value::as_str)
351 .ok_or_else(|| anyhow!("issuer card missing verify_keys[*].key"))?;
352 let pk_bytes = crate::signing::b64decode(pk_b64)?;
353 let mut pk_arr = [0u8; 32];
354 if pk_bytes.len() != 32 {
355 bail!("issuer pubkey wrong length");
356 }
357 pk_arr.copy_from_slice(&pk_bytes);
358 let vk = VerifyingKey::from_bytes(&pk_arr)
359 .map_err(|e| anyhow!("issuer pubkey decode failed: {e}"))?;
360 let mut sig_arr = [0u8; 64];
361 if sig_bytes.len() != 64 {
362 bail!("invite sig wrong length");
363 }
364 sig_arr.copy_from_slice(&sig_bytes);
365 let sig = Signature::from_bytes(&sig_arr);
366 vk.verify(&payload_bytes, &sig)
367 .map_err(|_| anyhow!("invite URL signature did not verify"))?;
368
369 Ok(payload)
370}
371
372pub fn accept_invite(url: &str) -> Result<Value> {
375 let payload = parse_invite(url)?;
376
377 let (our_did, our_relay, our_slot_id, our_slot_token) =
379 ensure_self_with_relay(Some(&payload.relay_url))?;
380
381 if our_did == payload.did {
382 bail!("refusing to accept own invite (issuer DID matches self)");
383 }
384
385 let mut trust = config::read_trust()?;
387 crate::trust::add_agent_card_pin(&mut trust, &payload.card, Some("VERIFIED"));
388 config::write_trust(&trust)?;
389
390 let peer_handle = crate::agent_card::display_handle_from_did(&payload.did).to_string();
391 let mut relay_state = config::read_relay_state()?;
392 relay_state["peers"][&peer_handle] = json!({
393 "relay_url": payload.relay_url,
394 "slot_id": payload.slot_id,
395 "slot_token": payload.slot_token,
396 });
397 config::write_relay_state(&relay_state)?;
398
399 let our_card = config::read_agent_card()?;
403 let sk_seed = config::read_private_key()?;
404 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
405 let pk_b64 = our_card
406 .get("verify_keys")
407 .and_then(Value::as_object)
408 .and_then(|m| m.values().next())
409 .and_then(|v| v.get("key"))
410 .and_then(Value::as_str)
411 .ok_or_else(|| anyhow!("our agent-card missing verify_keys[*].key"))?;
412 let pk_bytes = crate::signing::b64decode(pk_b64)?;
413
414 let now = time::OffsetDateTime::now_utc()
415 .format(&time::format_description::well_known::Rfc3339)
416 .unwrap_or_default();
417 let event = json!({
418 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
419 "timestamp": now,
420 "from": our_did,
421 "to": payload.did,
422 "type": "pair_drop",
423 "kind": 1100u32,
424 "body": {
425 "card": our_card,
426 "relay_url": our_relay,
427 "slot_id": our_slot_id,
428 "slot_token": our_slot_token,
429 "pair_nonce": payload.nonce,
430 },
431 });
432 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
433 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
434
435 let client = crate::relay_client::RelayClient::new(&payload.relay_url);
436 client
437 .post_event(&payload.slot_id, &payload.slot_token, &signed)
438 .with_context(|| {
439 format!(
440 "POST pair_drop to {} slot {}",
441 payload.relay_url, payload.slot_id
442 )
443 })?;
444
445 Ok(json!({
446 "paired_with": payload.did,
447 "peer_handle": peer_handle,
448 "event_id": event_id,
449 "status": "drop_sent",
450 }))
451}
452
453pub fn maybe_consume_pair_drop(event: &Value) -> Result<Option<String>> {
458 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
459 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
460 if kind != 1100 || type_str != "pair_drop" {
461 return Ok(None);
462 }
463 let body = match event.get("body") {
464 Some(b) => b,
465 None => return Ok(None),
466 };
467
468 let nonce_opt = body
474 .get("pair_nonce")
475 .and_then(Value::as_str)
476 .map(str::to_string);
477 let mut pending: Option<PendingInvite> = None;
478 let mut invite_path: Option<std::path::PathBuf> = None;
479 if let Some(nonce) = nonce_opt.as_deref() {
480 let dir = pending_invites_dir()?;
481 let path = dir.join(format!("{nonce}.json"));
482 if path.exists() {
483 let p: PendingInvite = serde_json::from_slice(&std::fs::read(&path)?)
484 .with_context(|| format!("reading pending invite {path:?}"))?;
485 if now_unix() > p.exp {
486 if let Err(e) = std::fs::remove_file(&path) {
489 eprintln!("wire: could not delete expired invite {path:?}: {e}");
490 }
491 return Ok(None);
492 }
493 pending = Some(p);
494 invite_path = Some(path);
495 } else if !open_mode_enabled() {
496 return Ok(None);
500 }
501 } else if !open_mode_enabled() {
502 return Ok(None);
505 }
506
507 let peer_card = body
508 .get("card")
509 .cloned()
510 .ok_or_else(|| anyhow!("pair_drop body missing card"))?;
511 crate::agent_card::verify_agent_card(&peer_card)
512 .map_err(|e| anyhow!("pair_drop peer card sig invalid: {e}"))?;
513
514 let peer_did = peer_card
515 .get("did")
516 .and_then(Value::as_str)
517 .ok_or_else(|| anyhow!("peer card missing did"))?
518 .to_string();
519 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
520
521 let mut tmp_trust = config::read_trust()?;
526 crate::trust::add_agent_card_pin(&mut tmp_trust, &peer_card, Some("VERIFIED"));
527 crate::signing::verify_message_v31(event, &tmp_trust)
528 .map_err(|e| anyhow!("pair_drop event sig verify failed: {e}"))?;
529
530 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
531 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
532 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
533 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
534 bail!("pair_drop body missing relay_url/slot_id/slot_token");
535 }
536
537 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
542 .get("endpoints")
543 .and_then(Value::as_array)
544 .map(|arr| {
545 arr.iter()
546 .filter_map(|e| {
547 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
548 })
549 .collect()
550 })
551 .unwrap_or_else(|| {
552 vec![crate::endpoints::Endpoint::federation(
553 peer_relay.to_string(),
554 peer_slot_id.to_string(),
555 peer_slot_token.to_string(),
556 )]
557 });
558
559 if nonce_opt.is_some() {
577 config::write_trust(&tmp_trust)?;
579 let mut relay_state = config::read_relay_state()?;
580 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
584 config::write_relay_state(&relay_state)?;
585
586 if let (Some(pending), Some(invite_path)) = (pending, invite_path) {
588 if pending.uses_remaining <= 1 {
589 if let Err(e) = std::fs::remove_file(&invite_path) {
590 eprintln!("wire: could not delete consumed invite {invite_path:?}: {e}");
591 }
592 } else {
593 let mut updated = pending.clone();
594 updated.uses_remaining -= 1;
595 updated.accepted_by.push(peer_did.clone());
596 std::fs::write(&invite_path, serde_json::to_vec_pretty(&updated)?)?;
597 }
598 }
599 crate::os_notify::toast(
600 &format!("wire — paired with {peer_handle}"),
601 "Invite accepted. Ready to send + receive.",
602 );
603 return Ok(Some(peer_did));
604 }
605
606 if let Some(org_did) =
615 org_auto_pin_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load())
616 {
617 let mut trust = crate::config::read_trust()?;
618 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("ORG_VERIFIED"));
619 crate::config::write_trust(&trust)?;
620
621 let endpoints_to_pin = if peer_endpoints.is_empty() {
622 vec![crate::endpoints::Endpoint::federation(
623 peer_relay.to_string(),
624 peer_slot_id.to_string(),
625 peer_slot_token.to_string(),
626 )]
627 } else {
628 peer_endpoints.clone()
629 };
630 let mut relay_state = crate::config::read_relay_state()?;
631 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints_to_pin)?;
632 crate::config::write_relay_state(&relay_state)?;
633
634 send_pair_drop_ack(&peer_handle, &endpoints_to_pin)
635 .with_context(|| format!("org-auto pair_drop_ack send to {peer_handle} failed"))?;
636
637 crate::os_notify::toast_dedup(
638 &format!("org-pair:{peer_handle}"),
639 &format!("wire — auto-paired {peer_handle}"),
640 &format!(
641 "org-verified member of {org_did}; pinned ORG_VERIFIED (your org_policies.json opt-in)"
642 ),
643 );
644 return Ok(Some(peer_did));
645 }
646
647 let now_iso = time::OffsetDateTime::now_utc()
648 .format(&time::format_description::well_known::Rfc3339)
649 .unwrap_or_default();
650 let event_id = event
651 .get("event_id")
652 .and_then(Value::as_str)
653 .unwrap_or("")
654 .to_string();
655 let event_timestamp = event
656 .get("timestamp")
657 .and_then(Value::as_str)
658 .unwrap_or("")
659 .to_string();
660 let pending_inbound = crate::pending_inbound_pair::PendingInboundPair {
661 peer_handle: peer_handle.clone(),
662 peer_did: peer_did.clone(),
663 peer_card: peer_card.clone(),
664 peer_relay_url: peer_relay.to_string(),
665 peer_slot_id: peer_slot_id.to_string(),
666 peer_slot_token: peer_slot_token.to_string(),
667 peer_endpoints: peer_endpoints.clone(),
668 event_id,
669 event_timestamp,
670 received_at: now_iso,
671 };
672 crate::pending_inbound_pair::write_pending_inbound(&pending_inbound)?;
673
674 match org_notify_decision(&peer_card, &crate::org_policy::FileOrgPolicy::load()) {
681 Some(org_did) => crate::os_notify::toast_dedup(
682 &format!("notify-pair:{peer_handle}"),
683 &format!("wire — org-verified pair request from {peer_handle}"),
684 &format!(
685 "verified member of {org_did} (your org_policies.json says `notify`). run `wire accept {peer_handle}` to pin VERIFIED, or `wire reject {peer_handle}`",
686 ),
687 ),
688 None => crate::os_notify::toast(
689 &format!("wire — pair request from {peer_handle}"),
690 &format!(
691 "run `wire accept {peer_handle}` (or `wire add {peer_handle}@{peer_relay}`) to accept, or `wire reject {peer_handle}` to refuse",
692 ),
693 ),
694 }
695
696 Ok(Some(peer_did))
697}
698
699fn org_auto_pin_decision(
705 card: &Value,
706 policy: &dyn crate::pair_decision::OrgPolicy,
707) -> Option<String> {
708 match crate::pair_decision::decide(
709 &crate::org_membership::evaluate_card_membership(card),
710 policy,
711 ) {
712 crate::pair_decision::PairAction::AutoOrgVerified { org_did } => Some(org_did),
713 _ => None,
714 }
715}
716
717fn org_notify_decision(
727 card: &Value,
728 policy: &dyn crate::pair_decision::OrgPolicy,
729) -> Option<String> {
730 match crate::pair_decision::decide(
731 &crate::org_membership::evaluate_card_membership(card),
732 policy,
733 ) {
734 crate::pair_decision::PairAction::NotifyOrgEligible { org_did } => Some(org_did),
735 _ => None,
736 }
737}
738
739pub fn send_pair_drop_ack(
761 peer_handle: &str,
762 peer_endpoints: &[crate::endpoints::Endpoint],
763) -> Result<()> {
764 let our_card = config::read_agent_card()?;
766 let our_did = our_card
767 .get("did")
768 .and_then(Value::as_str)
769 .ok_or_else(|| anyhow!("our card missing did"))?
770 .to_string();
771 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
772 let relay_state = config::read_relay_state()?;
773 let self_state = relay_state.get("self").cloned().unwrap_or(Value::Null);
774 let mut our_relay = self_state
783 .get("relay_url")
784 .and_then(Value::as_str)
785 .unwrap_or("")
786 .to_string();
787 let mut our_slot_id = self_state
788 .get("slot_id")
789 .and_then(Value::as_str)
790 .unwrap_or("")
791 .to_string();
792 let mut our_slot_token = self_state
793 .get("slot_token")
794 .and_then(Value::as_str)
795 .unwrap_or("")
796 .to_string();
797 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
798 let eps = crate::endpoints::self_endpoints(&relay_state);
803 if let Some(ep) = eps.first() {
804 our_relay = ep.relay_url.clone();
805 our_slot_id = ep.slot_id.clone();
806 our_slot_token = ep.slot_token.clone();
807 }
808 }
809 if our_relay.is_empty() || our_slot_id.is_empty() || our_slot_token.is_empty() {
810 bail!(
815 "this session has no inbound slot configured — peers cannot deliver to us.\n\
816 Fix: `wire bind-relay http://127.0.0.1:8771 --migrate-pinned` \
817 (allocates a slot and re-publishes our card to all pinned peers).\n\
818 Then re-run the pair flow. See WIRE_PAIRING_INCIDENT_2026-05-23 for context."
819 );
820 }
821
822 let sk_seed = config::read_private_key()?;
823 let pk_b64 = our_card
824 .get("verify_keys")
825 .and_then(Value::as_object)
826 .and_then(|m| m.values().next())
827 .and_then(|v| v.get("key"))
828 .and_then(Value::as_str)
829 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
830 let pk_bytes = crate::signing::b64decode(pk_b64)?;
831
832 let now = time::OffsetDateTime::now_utc()
833 .format(&time::format_description::well_known::Rfc3339)
834 .unwrap_or_default();
835 let our_endpoints = crate::endpoints::self_endpoints(&relay_state);
839 let mut body = json!({
840 "relay_url": our_relay,
841 "slot_id": our_slot_id,
842 "slot_token": our_slot_token,
843 });
844 if !our_endpoints.is_empty() {
845 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
846 }
847 let event = json!({
848 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
849 "timestamp": now,
850 "from": our_did,
851 "to": format!("did:wire:{peer_handle}"),
852 "type": "pair_drop_ack",
853 "kind": 1101u32,
854 "body": body,
855 });
856 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
857
858 let (delivered_ep, _resp) =
864 crate::relay_client::try_post_event_with_failover(peer_endpoints, &signed, |ep, ev| {
865 crate::relay_client::post_event_to_endpoint(ep, ev)
866 })
867 .with_context(|| {
868 format!(
869 "pair_drop_ack to {peer_handle} failed across {} endpoint(s)",
870 peer_endpoints.len()
871 )
872 })?;
873 let _ = delivered_ep; Ok(())
875}
876
877pub fn maybe_consume_pair_drop_ack(event: &Value) -> Result<bool> {
881 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
882 let type_str = event.get("type").and_then(Value::as_str).unwrap_or("");
883 if kind != 1101 || type_str != "pair_drop_ack" {
884 return Ok(false);
885 }
886 let body = match event.get("body") {
887 Some(b) => b,
888 None => return Ok(false),
889 };
890 let from = event
891 .get("from")
892 .and_then(Value::as_str)
893 .ok_or_else(|| anyhow!("ack missing 'from'"))?;
894 let peer_handle = crate::agent_card::display_handle_from_did(from).to_string();
895 let peer_relay = body.get("relay_url").and_then(Value::as_str).unwrap_or("");
896 let peer_slot_id = body.get("slot_id").and_then(Value::as_str).unwrap_or("");
897 let peer_slot_token = body.get("slot_token").and_then(Value::as_str).unwrap_or("");
898 if peer_relay.is_empty() || peer_slot_id.is_empty() || peer_slot_token.is_empty() {
899 bail!("pair_drop_ack body missing relay_url/slot_id/slot_token");
900 }
901 let peer_endpoints: Vec<crate::endpoints::Endpoint> = body
905 .get("endpoints")
906 .and_then(Value::as_array)
907 .map(|arr| {
908 arr.iter()
909 .filter_map(|e| {
910 serde_json::from_value::<crate::endpoints::Endpoint>(e.clone()).ok()
911 })
912 .collect()
913 })
914 .unwrap_or_else(|| {
915 vec![crate::endpoints::Endpoint::federation(
916 peer_relay.to_string(),
917 peer_slot_id.to_string(),
918 peer_slot_token.to_string(),
919 )]
920 });
921 let mut relay_state = config::read_relay_state()?;
922 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &peer_endpoints)?;
923 if let Some(peer_entry) = relay_state
932 .get_mut("peers")
933 .and_then(Value::as_object_mut)
934 .and_then(|m| m.get_mut(&peer_handle))
935 .and_then(Value::as_object_mut)
936 {
937 peer_entry
938 .entry("bilateral_completed_at".to_string())
939 .or_insert_with(|| {
940 Value::String(
941 time::OffsetDateTime::now_utc()
942 .format(&time::format_description::well_known::Rfc3339)
943 .unwrap_or_default(),
944 )
945 });
946 }
947 config::write_relay_state(&relay_state)?;
948 if let Err(e) = crate::pending_inbound_pair::consume_pending_inbound(&peer_handle) {
958 eprintln!("pair_drop_ack: failed to clear stale pending_inbound for {peer_handle}: {e:#}");
961 }
962 crate::os_notify::toast(
963 &format!("wire — pair complete with {peer_handle}"),
964 "Both sides bound. Ready to send + receive.",
965 );
966 Ok(true)
967}
968
969#[cfg(test)]
975mod tests {
976 use super::*;
977
978 struct AutoFor(String);
981 impl crate::pair_decision::OrgPolicy for AutoFor {
982 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
983 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Auto)
984 }
985 }
986 struct EmptyPolicy;
987 impl crate::pair_decision::OrgPolicy for EmptyPolicy {
988 fn inbound_mode(&self, _: &str) -> Option<crate::pair_decision::InboundMode> {
989 None
990 }
991 }
992
993 fn org_verified_card() -> (Value, String) {
995 let (op_sk, op_pk) = crate::signing::generate_keypair();
996 let (org_sk, org_pk) = crate::signing::generate_keypair();
997 let (sess_sk, sess_pk) = crate::signing::generate_keypair();
998 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
999 let org_did = crate::agent_card::did_for_org("slanchaai", &org_pk);
1000 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did).unwrap();
1001 let base = crate::agent_card::build_agent_card("vesper-valley", &sess_pk, None, None, None);
1002 let session_did = base
1003 .get("did")
1004 .and_then(|v| v.as_str())
1005 .unwrap()
1006 .to_string();
1007 let claims = crate::enroll::build_member_claims(
1008 "darby",
1009 &op_sk,
1010 &op_pk,
1011 &session_did,
1012 &[crate::enroll::MemberOf {
1013 org_did: org_did.clone(),
1014 org_pubkey: org_pk,
1015 member_cert,
1016 }],
1017 None,
1018 )
1019 .unwrap();
1020 let card = crate::agent_card::sign_agent_card(
1021 &crate::agent_card::with_identity_claims(&base, &claims).unwrap(),
1022 &sess_sk,
1023 );
1024 (card, org_did)
1025 }
1026
1027 #[test]
1028 fn org_auto_pin_decision_auto_only_when_policy_opts_in() {
1029 let (card, org_did) = org_verified_card();
1030 assert_eq!(
1032 org_auto_pin_decision(&card, &AutoFor(org_did.clone())),
1033 Some(org_did.clone())
1034 );
1035 assert_eq!(org_auto_pin_decision(&card, &EmptyPolicy), None);
1037 }
1038
1039 #[test]
1040 fn org_auto_pin_decision_none_for_plain_card() {
1041 let plain = serde_json::json!({
1044 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1045 });
1046 assert_eq!(
1047 org_auto_pin_decision(&plain, &AutoFor("did:wire:org:x-1".into())),
1048 None
1049 );
1050 }
1051
1052 struct NotifyFor(String);
1055 impl crate::pair_decision::OrgPolicy for NotifyFor {
1056 fn inbound_mode(&self, org_did: &str) -> Option<crate::pair_decision::InboundMode> {
1057 (org_did == self.0).then_some(crate::pair_decision::InboundMode::Notify)
1058 }
1059 }
1060
1061 #[test]
1062 fn org_notify_decision_notify_only_when_policy_opts_in() {
1063 let (card, org_did) = org_verified_card();
1064 assert_eq!(
1066 org_notify_decision(&card, &NotifyFor(org_did.clone())),
1067 Some(org_did.clone())
1068 );
1069 assert_eq!(org_notify_decision(&card, &EmptyPolicy), None);
1071 }
1072
1073 #[test]
1074 fn org_notify_decision_returns_none_when_policy_is_auto() {
1075 let (card, org_did) = org_verified_card();
1079 assert_eq!(org_notify_decision(&card, &AutoFor(org_did)), None);
1080 }
1081
1082 #[test]
1083 fn org_notify_decision_none_for_plain_card() {
1084 let plain = serde_json::json!({
1087 "schema_version": "v3.1", "did": "did:wire:plain-deadbeef", "handle": "plain"
1088 });
1089 assert_eq!(
1090 org_notify_decision(&plain, &NotifyFor("did:wire:org:x-1".into())),
1091 None
1092 );
1093 }
1094 use crate::config;
1095
1096 #[test]
1097 fn record_pair_rejection_writes_jsonl_under_state_dir() {
1098 config::test_support::with_temp_home(|| {
1102 super::record_pair_rejection(
1103 "slancha-spark",
1104 "pair_drop_ack_send_failed",
1105 "POST returned 502",
1106 );
1107 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1108 assert!(path.exists(), "record_pair_rejection must create {path:?}");
1109 let body = std::fs::read_to_string(&path).unwrap();
1110 let line = body.lines().last().expect("at least one line");
1111 let parsed: Value = serde_json::from_str(line).expect("valid JSON");
1112 assert_eq!(parsed["peer"], "slancha-spark");
1113 assert_eq!(parsed["code"], "pair_drop_ack_send_failed");
1114 assert_eq!(parsed["detail"], "POST returned 502");
1115 assert!(parsed["ts"].as_u64().unwrap_or(0) > 0);
1116 });
1117 }
1118
1119 #[test]
1120 fn maybe_consume_pair_drop_ack_clears_stale_pending_inbound() {
1121 config::test_support::with_temp_home(|| {
1129 let peer_handle = "test-peer";
1130 let peer_did = format!("did:wire:{peer_handle}-abcdef12");
1131 let pending = crate::pending_inbound_pair::PendingInboundPair {
1132 peer_handle: peer_handle.to_string(),
1133 peer_did: peer_did.clone(),
1134 peer_card: serde_json::json!({"did": peer_did.clone()}),
1135 peer_relay_url: "https://example.test".into(),
1136 peer_slot_id: "slot-aaaa".into(),
1137 peer_slot_token: "token-bbbb".into(),
1138 peer_endpoints: vec![],
1139 event_id: "evt-0001".into(),
1140 event_timestamp: "2026-06-01T20:00:00Z".into(),
1141 received_at: "2026-06-01T20:00:01Z".into(),
1142 };
1143 crate::pending_inbound_pair::write_pending_inbound(&pending).unwrap();
1144 assert!(
1145 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1146 .unwrap()
1147 .is_some(),
1148 "precondition: pending record exists"
1149 );
1150 let ack_event = serde_json::json!({
1151 "kind": 1101,
1152 "type": "pair_drop_ack",
1153 "from": peer_did,
1154 "body": {
1155 "relay_url": "https://example.test",
1156 "slot_id": "slot-cccc",
1157 "slot_token": "token-dddd",
1158 },
1159 });
1160 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1161 assert!(consumed, "pair_drop_ack should be consumed");
1162 assert!(
1163 crate::pending_inbound_pair::read_pending_inbound(peer_handle)
1164 .unwrap()
1165 .is_none(),
1166 "stale pending-inbound record must be cleared on bilateral completion"
1167 );
1168 });
1169 }
1170
1171 #[test]
1172 fn maybe_consume_pair_drop_ack_no_op_when_no_pending_inbound_exists() {
1173 config::test_support::with_temp_home(|| {
1178 let peer_handle = "fresh-peer";
1179 let peer_did = format!("did:wire:{peer_handle}-12345678");
1180 let ack_event = serde_json::json!({
1181 "kind": 1101,
1182 "type": "pair_drop_ack",
1183 "from": peer_did,
1184 "body": {
1185 "relay_url": "https://example.test",
1186 "slot_id": "slot-eeee",
1187 "slot_token": "token-ffff",
1188 },
1189 });
1190 let consumed = super::maybe_consume_pair_drop_ack(&ack_event).unwrap();
1191 assert!(consumed, "ack must still be consumed (the pinning path)");
1192 });
1193 }
1194
1195 #[test]
1196 fn record_pair_rejection_appends_multiple_lines() {
1197 config::test_support::with_temp_home(|| {
1200 super::record_pair_rejection("a", "code_a", "detail_a");
1201 super::record_pair_rejection("b", "code_b", "detail_b");
1202 super::record_pair_rejection("c", "code_c", "detail_c");
1203 let path = config::state_dir().unwrap().join("pair-rejected.jsonl");
1204 let body = std::fs::read_to_string(&path).unwrap();
1205 let lines: Vec<&str> = body.lines().collect();
1206 assert_eq!(lines.len(), 3, "expected 3 entries, got {}", lines.len());
1207 for (i, peer) in ["a", "b", "c"].iter().enumerate() {
1208 let parsed: Value = serde_json::from_str(lines[i]).unwrap();
1209 assert_eq!(parsed["peer"], *peer);
1210 }
1211 });
1212 }
1213}