1use anyhow::{Context, Result, anyhow, bail};
2use serde_json::{Value, json};
3
4use super::setup;
5use crate::{config, signing::sign_message_v31};
6
7pub(super) fn cmd_mcp() -> Result<()> {
10 crate::mcp::run()
11}
12
13pub(super) fn cmd_relay_server(
14 bind: &str,
15 local_only: bool,
16 uds: Option<&std::path::Path>,
17) -> Result<()> {
18 if let Some(socket_path) = uds {
23 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
24 std::path::PathBuf::from(home)
25 .join("state")
26 .join("wire-relay")
27 .join("uds")
28 } else {
29 dirs::state_dir()
30 .or_else(dirs::data_local_dir)
31 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
32 .join("wire-relay")
33 .join("uds")
34 };
35 let runtime = tokio::runtime::Builder::new_multi_thread()
36 .enable_all()
37 .build()?;
38 return runtime.block_on(crate::relay_server::serve_uds(
39 socket_path.to_path_buf(),
40 base,
41 ));
42 }
43 if local_only {
47 validate_loopback_bind(bind)?;
48 }
49 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
55 std::path::PathBuf::from(home)
56 .join("state")
57 .join("wire-relay")
58 } else {
59 dirs::state_dir()
60 .or_else(dirs::data_local_dir)
61 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
62 .join("wire-relay")
63 };
64 let state_dir = if local_only { base.join("local") } else { base };
65 let runtime = tokio::runtime::Builder::new_multi_thread()
66 .enable_all()
67 .build()?;
68 runtime.block_on(crate::relay_server::serve_with_mode(
69 bind,
70 state_dir,
71 crate::relay_server::ServerMode { local_only },
72 ))
73}
74
75fn validate_loopback_bind(bind: &str) -> Result<()> {
93 let host = if let Some(stripped) = bind.strip_prefix('[') {
95 let close = stripped
96 .find(']')
97 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
98 stripped[..close].to_string()
99 } else {
100 bind.rsplit_once(':')
101 .map(|(h, _)| h.to_string())
102 .unwrap_or_else(|| bind.to_string())
103 };
104 use std::net::{IpAddr, ToSocketAddrs};
105 let probe = format!("{host}:0");
106 let resolved: Vec<_> = probe
107 .to_socket_addrs()
108 .with_context(|| format!("resolving bind host {host:?}"))?
109 .collect();
110 if resolved.is_empty() {
111 bail!("--local-only: bind host {host:?} resolved to no addresses");
112 }
113 for addr in &resolved {
114 let ip = addr.ip();
115 let is_acceptable = match ip {
116 IpAddr::V4(v4) => {
117 v4.is_loopback() || v4.is_private() || {
118 let octets = v4.octets();
120 octets[0] == 100 && (64..=127).contains(&octets[1])
121 }
122 }
123 IpAddr::V6(v6) => v6.is_loopback(), };
125 if !is_acceptable {
126 bail!(
127 "--local-only refuses non-private bind: {host:?} resolves to {ip} \
128 which is not loopback (127/8, ::1), RFC 1918 private \
129 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
130 (100.64.0.0/10). Remove --local-only to bind publicly."
131 );
132 }
133 }
134 Ok(())
135}
136
137fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
140 use crate::endpoints::EndpointScope;
141 match s.to_lowercase().as_str() {
142 "federation" | "fed" => Ok(EndpointScope::Federation),
143 "local" => Ok(EndpointScope::Local),
144 "lan" => Ok(EndpointScope::Lan),
145 "uds" => Ok(EndpointScope::Uds),
146 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
147 }
148}
149
150pub(crate) fn cmd_bind_relay(
156 url: &str,
157 scope: Option<&str>,
158 replace: bool,
159 migrate_pinned: bool,
160 as_json: bool,
161) -> Result<()> {
162 use crate::endpoints::{Endpoint, self_endpoints};
163
164 if !config::is_initialized()? {
165 bail!("not initialized — run `wire up` first");
166 }
167 let card = config::read_agent_card()?;
168 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
169 let handle = crate::agent_card::display_handle_from_did(did).to_string();
170
171 let normalized_raw = url.trim_end_matches('/');
172 let normalized_owned = setup::strip_relay_url_userinfo(normalized_raw);
176 let normalized = normalized_owned.as_str();
177 setup::assert_relay_url_clean_for_publish(normalized)?;
181 let new_scope = match scope {
182 Some(s) => parse_scope(s)?,
183 None => crate::endpoints::infer_scope_from_url(normalized),
184 };
185
186 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
187 let pinned: Vec<String> = existing
188 .get("peers")
189 .and_then(|p| p.as_object())
190 .map(|o| o.keys().cloned().collect())
191 .unwrap_or_default();
192
193 let existing_eps = self_endpoints(&existing);
194 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
195
196 let destructive = replace || is_rebind_same;
203 if destructive && !pinned.is_empty() && !migrate_pinned {
204 let list = pinned.join(", ");
205 let why = if replace {
206 "`--replace` drops your other slot(s)"
207 } else {
208 "re-binding the same relay rotates its slot"
209 };
210 bail!(
211 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
212 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
213 read.\n\n\
214 SAFE PATHS:\n\
215 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
216 slots — no black-hole.\n\
217 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
218 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
219 peer out-of-band.\n\n\
220 Issue #7 (silent black-hole on relay change) caught this.",
221 n = pinned.len(),
222 );
223 }
224
225 let client = crate::relay_client::RelayClient::new(normalized);
226 client.check_healthz()?;
227 let alloc = client.allocate_slot(Some(&handle))?;
228
229 if destructive && !pinned.is_empty() {
230 eprintln!(
231 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
232 until they re-pin: {peers}",
233 mode = if replace { "replacing" } else { "rotating" },
234 n = pinned.len(),
235 peers = pinned.join(", "),
236 );
237 }
238
239 let mut state = existing;
243 if replace {
244 state["self"] = Value::Null;
245 }
246 crate::endpoints::upsert_self_endpoint(
247 &mut state,
248 Endpoint {
249 relay_url: normalized.to_string(),
250 slot_id: alloc.slot_id.clone(),
251 slot_token: alloc.slot_token.clone(),
252 scope: new_scope,
253 },
254 );
255 config::write_relay_state(&state)?;
256 let eps = self_endpoints(&state);
257
258 let scope_str = format!("{new_scope:?}").to_lowercase();
259 if as_json {
260 println!(
261 "{}",
262 serde_json::to_string(&json!({
263 "relay_url": normalized,
264 "slot_id": alloc.slot_id,
265 "scope": scope_str,
266 "endpoints": eps.len(),
267 "additive": !replace,
268 "slot_token_present": true,
269 }))?
270 );
271 } else {
272 println!(
273 "bound {scope_str} slot on {normalized} (slot {})",
274 alloc.slot_id
275 );
276 println!(
277 "self now has {n} endpoint(s): {list}",
278 n = eps.len(),
279 list = eps
280 .iter()
281 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
282 .collect::<Vec<_>>()
283 .join(", "),
284 );
285 }
286 Ok(())
287}
288
289pub(super) fn cmd_add_peer_slot(
292 handle: &str,
293 url: &str,
294 slot_id: &str,
295 slot_token: &str,
296 as_json: bool,
297) -> Result<()> {
298 use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
299 let mut state = config::read_relay_state()?;
300
301 let new_ep = Endpoint {
308 relay_url: url.to_string(),
309 slot_id: slot_id.to_string(),
310 slot_token: slot_token.to_string(),
311 scope: infer_scope_from_url(url),
312 };
313 let mut endpoints: Vec<Endpoint> = state
316 .get("peers")
317 .and_then(|p| p.get(handle))
318 .and_then(|e| e.get("endpoints"))
319 .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
320 .unwrap_or_default();
321 if let Some(existing) = endpoints
323 .iter_mut()
324 .find(|e| e.relay_url == new_ep.relay_url)
325 {
326 *existing = new_ep;
327 } else {
328 endpoints.push(new_ep);
329 }
330 let n = endpoints.len();
331 pin_peer_endpoints(&mut state, handle, &endpoints)?;
332 config::write_relay_state(&state)?;
333 if as_json {
334 println!(
335 "{}",
336 serde_json::to_string(&json!({
337 "handle": handle,
338 "relay_url": url,
339 "slot_id": slot_id,
340 "added": true,
341 "endpoint_count": n,
342 }))?
343 );
344 } else {
345 println!(
346 "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
347 );
348 }
349 Ok(())
350}
351
352pub(super) fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
355 let mut state = config::read_relay_state()?;
356 let peers = state["peers"].as_object().cloned().unwrap_or_default();
357 if peers.is_empty() {
358 bail!(
359 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
360 );
361 }
362 let outbox_dir = config::outbox_dir()?;
363 if outbox_dir.exists() {
368 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
369 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
370 let path = entry.path();
371 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
372 continue;
373 }
374 let stem = match path.file_stem().and_then(|s| s.to_str()) {
375 Some(s) => s.to_string(),
376 None => continue,
377 };
378 if pinned.contains(&stem) {
379 continue;
380 }
381 let bare = crate::agent_card::bare_handle(&stem);
384 if pinned.contains(bare) {
385 eprintln!(
386 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
387 Merge with: `cat {} >> {}` then delete the FQDN file.",
388 stem,
389 path.display(),
390 outbox_dir.join(format!("{bare}.jsonl")).display(),
391 );
392 }
393 }
394 }
395 if !outbox_dir.exists() {
396 if as_json {
397 println!(
398 "{}",
399 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
400 );
401 } else {
402 println!("phyllis: nothing to dial out — write a message first with `wire send`");
403 }
404 return Ok(());
405 }
406
407 let mut pushed = Vec::new();
408 let mut skipped = Vec::new();
409
410 let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
415 let mut state_dirty = false;
418
419 for (peer_handle, _) in peers.iter() {
425 if let Some(want) = peer_filter
426 && peer_handle != want
427 {
428 continue;
429 }
430 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
431 if !outbox.exists() {
432 continue;
433 }
434 let mut ordered_endpoints =
435 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
436 if ordered_endpoints.is_empty() {
437 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
441 let event: Value = match serde_json::from_str(line) {
442 Ok(v) => v,
443 Err(_) => continue,
444 };
445 let event_id = event
446 .get("event_id")
447 .and_then(Value::as_str)
448 .unwrap_or("")
449 .to_string();
450 skipped.push(json!({
451 "peer": peer_handle,
452 "event_id": event_id,
453 "reason": "no reachable endpoint pinned for peer",
454 }));
455 }
456 continue;
457 }
458 let body = std::fs::read_to_string(&outbox)?;
459 for line in body.lines() {
460 let event: Value = match serde_json::from_str(line) {
461 Ok(v) => v,
462 Err(_) => continue,
463 };
464 let event_id = event
465 .get("event_id")
466 .and_then(Value::as_str)
467 .unwrap_or("")
468 .to_string();
469
470 let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
479 match crate::relay_client::try_post_event_with_failover(
480 &ordered_endpoints,
481 &event,
482 |endpoint, ev| {
483 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
484 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
485 Ok(resp) => Ok(resp),
486 Err(e) => {
487 *last_err.borrow_mut() =
488 Some(crate::relay_client::format_transport_error(&e));
489 Err(e)
490 }
491 }
492 },
493 ) {
494 Ok((endpoint, resp)) => {
495 if resp.status == "duplicate" {
496 skipped.push(json!({
497 "peer": peer_handle,
498 "event_id": event_id,
499 "reason": "duplicate",
500 "endpoint": endpoint.relay_url,
501 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
502 }));
503 } else {
504 pushed.push(json!({
505 "peer": peer_handle,
506 "event_id": event_id,
507 "endpoint": endpoint.relay_url,
508 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
509 }));
510 }
511 }
512 Err(_) => {
513 let last_err_text = last_err.borrow().clone().unwrap_or_default();
523 let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
524 match try_reresolve_peer_on_slot_4xx(
525 &mut state,
526 peer_handle,
527 &last_err_text,
528 &rotated_this_push,
529 ) {
530 Ok(true) => {
531 rotated_this_push.insert(peer_handle.clone());
533 state_dirty = true;
534 ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
539 &state,
540 peer_handle,
541 );
542 *last_err.borrow_mut() = None;
543 if let Ok((endpoint, resp)) =
544 crate::relay_client::try_post_event_with_failover(
545 &ordered_endpoints,
546 &event,
547 |endpoint, ev| {
548 let client = crate::relay_client::RelayClient::new(
549 &endpoint.relay_url,
550 );
551 match client.post_event(
552 &endpoint.slot_id,
553 &endpoint.slot_token,
554 ev,
555 ) {
556 Ok(resp) => Ok(resp),
557 Err(e) => {
558 *last_err.borrow_mut() = Some(
559 crate::relay_client::format_transport_error(&e),
560 );
561 Err(e)
562 }
563 }
564 },
565 )
566 {
567 delivered_via_retry = Some((endpoint, resp));
568 }
569 }
570 Ok(false) => {
571 }
575 Err(e) => {
576 *last_err.borrow_mut() = Some(format!(
581 "{}; re-resolve also failed: {e:#}",
582 last_err.borrow().clone().unwrap_or_default()
583 ));
584 rotated_this_push.insert(peer_handle.clone());
586 }
587 }
588 if let Some((endpoint, resp)) = delivered_via_retry {
589 if resp.status == "duplicate" {
590 skipped.push(json!({
591 "peer": peer_handle,
592 "event_id": event_id,
593 "reason": "duplicate",
594 "endpoint": endpoint.relay_url,
595 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
596 "via": "slot_reresolve_retry",
597 }));
598 } else {
599 pushed.push(json!({
600 "peer": peer_handle,
601 "event_id": event_id,
602 "endpoint": endpoint.relay_url,
603 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
604 "via": "slot_reresolve_retry",
605 }));
606 }
607 } else {
608 skipped.push(json!({
613 "peer": peer_handle,
614 "event_id": event_id,
615 "reason": last_err
616 .borrow()
617 .clone()
618 .unwrap_or_else(|| "all endpoints failed".to_string()),
619 }));
620 }
621 }
622 }
623 }
624 if let Err(e) = config::drain_outbox_delivered(peer_handle) {
628 eprintln!("wire push: WARN outbox drain for {peer_handle} failed: {e:#}");
629 }
630 }
631
632 if state_dirty && let Err(e) = config::write_relay_state(&state) {
637 eprintln!(
638 "wire push: WARN failed to persist rotated peer slots: {e:#}. \
639 Slot rotation will be re-attempted on next push."
640 );
641 }
642
643 if as_json {
644 println!(
645 "{}",
646 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
647 );
648 } else {
649 println!(
650 "pushed {} event(s); skipped {} ({})",
651 pushed.len(),
652 skipped.len(),
653 if skipped.is_empty() {
654 "none"
655 } else {
656 "see --json for detail"
657 }
658 );
659 }
660 Ok(())
661}
662
663pub(super) fn cmd_pull(as_json: bool) -> Result<()> {
666 let state = config::read_relay_state()?;
667 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
668 if self_state.is_null() {
669 bail!("self slot not bound — run `wire bind-relay <url>` first");
670 }
671
672 let endpoints = crate::endpoints::self_endpoints(&state);
681 if endpoints.is_empty() {
682 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
683 }
684
685 let inbox_dir = config::inbox_dir()?;
686 config::ensure_dirs()?;
687
688 let mut total_seen = 0usize;
689 let mut all_written: Vec<Value> = Vec::new();
690 let mut all_rejected: Vec<Value> = Vec::new();
691 let mut all_blocked = false;
692 let mut all_advance_cursor_to: Option<String> = None;
693
694 for endpoint in &endpoints {
695 let cursor_key = endpoint_cursor_key(endpoint.scope);
696 let last_event_id = self_state
697 .get(&cursor_key)
698 .and_then(Value::as_str)
699 .map(str::to_string);
700 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
701 let events = match client.list_events(
702 &endpoint.slot_id,
703 &endpoint.slot_token,
704 last_event_id.as_deref(),
705 Some(1000),
706 ) {
707 Ok(ev) => ev,
708 Err(e) => {
709 eprintln!(
713 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
714 endpoint.relay_url,
715 endpoint.scope,
716 crate::relay_client::format_transport_error(&e),
717 );
718 continue;
719 }
720 };
721 total_seen += events.len();
722 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
723 crate::probe::respond_to_probes(&result.probes);
726 all_written.extend(result.written.iter().cloned());
727 all_rejected.extend(result.rejected.iter().cloned());
728 if result.blocked {
729 all_blocked = true;
730 }
731 if let Some(eid) = result.advance_cursor_to.clone() {
734 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
735 all_advance_cursor_to = Some(eid.clone());
736 }
737 let key = cursor_key.clone();
738 config::update_relay_state(|state| {
739 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
740 self_obj.insert(key, Value::String(eid));
741 }
742 Ok(())
743 })?;
744 }
745 }
746
747 let result = crate::pull::PullResult {
752 written: all_written,
753 rejected: all_rejected,
754 blocked: all_blocked,
755 advance_cursor_to: all_advance_cursor_to,
756 probes: Vec::new(),
759 };
760 let events_len = total_seen;
761
762 if as_json {
766 println!(
767 "{}",
768 serde_json::to_string(&json!({
769 "written": result.written,
770 "rejected": result.rejected,
771 "total_seen": events_len,
772 "cursor_blocked": result.blocked,
773 "cursor_advanced_to": result.advance_cursor_to,
774 }))?
775 );
776 } else {
777 let blocking = result
778 .rejected
779 .iter()
780 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
781 .count();
782 if blocking > 0 {
783 println!(
784 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
785 events_len,
786 result.written.len(),
787 result.rejected.len(),
788 blocking,
789 );
790 } else {
791 println!(
792 "pulled {} event(s); wrote {}; rejected {}",
793 events_len,
794 result.written.len(),
795 result.rejected.len(),
796 );
797 }
798 }
799 Ok(())
800}
801
802fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
807 match scope {
808 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
809 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
810 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
811 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
812 }
813}
814
815pub(super) fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
818 if !config::is_initialized()? {
819 bail!("not initialized — run `wire up` first");
820 }
821 let mut state = config::read_relay_state()?;
822 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
823 if self_state.is_null() {
824 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
825 }
826 let primary = crate::endpoints::self_primary_endpoint(&state)
830 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
831 let url = primary.relay_url.clone();
832 let old_slot_id = primary.slot_id.clone();
833 let old_slot_token = primary.slot_token.clone();
834
835 let card = config::read_agent_card()?;
837 let did = card
838 .get("did")
839 .and_then(Value::as_str)
840 .unwrap_or("")
841 .to_string();
842 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
843 let pk_b64 = card
844 .get("verify_keys")
845 .and_then(Value::as_object)
846 .and_then(|m| m.values().next())
847 .and_then(|v| v.get("key"))
848 .and_then(Value::as_str)
849 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
850 .to_string();
851 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
852 let sk_seed = config::read_private_key()?;
853
854 let normalized = url.trim_end_matches('/').to_string();
856 let client = crate::relay_client::RelayClient::new(&normalized);
857 client
858 .check_healthz()
859 .context("aborting rotation; old slot still valid")?;
860 let alloc = client.allocate_slot(Some(&handle))?;
861 let new_slot_id = alloc.slot_id.clone();
862 let new_slot_token = alloc.slot_token.clone();
863
864 let mut announced: Vec<String> = Vec::new();
871 if !no_announce {
872 let now = time::OffsetDateTime::now_utc()
873 .format(&time::format_description::well_known::Rfc3339)
874 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
875 let body = json!({
876 "reason": "operator-initiated slot rotation",
877 "new_relay_url": url,
878 "new_slot_id": new_slot_id,
879 });
883 let peers = state["peers"].as_object().cloned().unwrap_or_default();
884 for (peer_handle, _peer_info) in peers.iter() {
885 let event = json!({
886 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
887 "timestamp": now.clone(),
888 "from": did,
889 "to": format!("did:wire:{peer_handle}"),
890 "type": "wire_close",
891 "kind": 1201,
892 "body": body.clone(),
893 });
894 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
895 Ok(s) => s,
896 Err(e) => {
897 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
898 continue;
899 }
900 };
901 let ep = match crate::endpoints::peer_primary_endpoint(&state, peer_handle) {
908 Some(e) if !e.slot_id.is_empty() && !e.slot_token.is_empty() => e,
909 _ => continue, };
911 let peer_client = if ep.relay_url == url {
912 client.clone()
913 } else {
914 crate::relay_client::RelayClient::new(&ep.relay_url)
915 };
916 match peer_client.post_event(&ep.slot_id, &ep.slot_token, &signed) {
917 Ok(_) => announced.push(peer_handle.clone()),
918 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
919 }
920 }
921 }
922
923 crate::endpoints::upsert_self_endpoint(
932 &mut state,
933 crate::endpoints::Endpoint {
934 relay_url: url.clone(),
935 slot_id: new_slot_id.clone(),
936 slot_token: new_slot_token.clone(),
937 scope: primary.scope,
938 },
939 );
940 config::write_relay_state(&state)?;
941
942 if as_json {
943 println!(
944 "{}",
945 serde_json::to_string(&json!({
946 "rotated": true,
947 "old_slot_id": old_slot_id,
948 "new_slot_id": new_slot_id,
949 "relay_url": url,
950 "announced_to": announced,
951 }))?
952 );
953 } else {
954 println!("rotated slot on {url}");
955 println!(
956 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
957 );
958 println!(" new slot_id: {new_slot_id}");
959 if !announced.is_empty() {
960 println!(
961 " announced wire_close (kind=1201) to: {}",
962 announced.join(", ")
963 );
964 }
965 println!();
966 println!("next steps:");
967 println!(" - peers see the wire_close event in their next `wire pull`");
968 println!(
969 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
970 );
971 println!(" (or full re-pair via `wire dial <handle>@<relay>`)");
972 println!(" - until they do, you'll receive but they won't be able to reach you");
973 let _ = old_slot_token;
975 }
976 Ok(())
977}
978
979pub(super) fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
982 let mut trust = config::read_trust()?;
983 let mut removed_from_trust = false;
984 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
985 && agents.remove(handle).is_some()
986 {
987 removed_from_trust = true;
988 }
989 config::write_trust(&trust)?;
990
991 let mut state = config::read_relay_state()?;
992 let mut removed_from_relay = false;
993 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
994 && peers.remove(handle).is_some()
995 {
996 removed_from_relay = true;
997 }
998 config::write_relay_state(&state)?;
999
1000 let mut purged: Vec<String> = Vec::new();
1001 if purge {
1002 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
1003 let path = dir.join(format!("{handle}.jsonl"));
1004 if path.exists() {
1005 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
1006 purged.push(path.to_string_lossy().into());
1007 }
1008 }
1009 }
1010
1011 if !removed_from_trust && !removed_from_relay {
1012 if as_json {
1013 println!(
1014 "{}",
1015 serde_json::to_string(&json!({
1016 "removed": false,
1017 "reason": format!("peer {handle:?} not pinned"),
1018 }))?
1019 );
1020 } else {
1021 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
1022 }
1023 return Ok(());
1024 }
1025
1026 if as_json {
1027 println!(
1028 "{}",
1029 serde_json::to_string(&json!({
1030 "handle": handle,
1031 "removed_from_trust": removed_from_trust,
1032 "removed_from_relay_state": removed_from_relay,
1033 "purged_files": purged,
1034 }))?
1035 );
1036 } else {
1037 println!("forgot peer {handle:?}");
1038 if removed_from_trust {
1039 println!(" - removed from trust.json");
1040 }
1041 if removed_from_relay {
1042 println!(" - removed from relay.json");
1043 }
1044 if !purged.is_empty() {
1045 for p in &purged {
1046 println!(" - deleted {p}");
1047 }
1048 } else if !purge {
1049 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
1050 }
1051 }
1052 Ok(())
1053}
1054
1055pub(super) fn cmd_daemon(
1058 interval_secs: u64,
1059 once: bool,
1060 all_sessions: bool,
1061 session: Option<String>,
1062 as_json: bool,
1063) -> Result<()> {
1064 if all_sessions {
1069 if once {
1070 bail!("--all-sessions and --once are mutually exclusive (supervisor runs forever)");
1071 }
1072 if session.is_some() {
1073 bail!(
1074 "--all-sessions and --session are mutually exclusive (supervisor manages every session, not a single named one)"
1075 );
1076 }
1077 return crate::daemon_supervisor::run_supervisor(interval_secs, as_json);
1078 }
1079 if let Some(ref name) = session {
1084 let home = crate::session::find_session_home_by_name(name)
1093 .with_context(|| format!("resolving session home for --session {name}"))?
1094 .ok_or_else(|| {
1095 anyhow!(
1096 "session '{name}' not found — run `wire session list` to see initialized sessions"
1097 )
1098 })?;
1099 unsafe {
1102 std::env::set_var("WIRE_HOME", &home);
1103 }
1104 if !as_json {
1105 eprintln!(
1106 "wire daemon: pinned to session '{name}' (WIRE_HOME={})",
1107 home.display()
1108 );
1109 }
1110 }
1111 if !config::is_initialized()? {
1112 bail!("not initialized — run `wire up` first");
1113 }
1114 let _pid_guard = if !once && std::env::var("WIRE_DAEMON_NO_SINGLETON").is_err() {
1124 if let Some(holder_pid) = crate::ensure_up::daemon_singleton_holder() {
1125 if as_json {
1126 println!(
1127 "{}",
1128 serde_json::to_string(&json!({
1129 "status": "skipped",
1130 "reason": "daemon already running",
1131 "holder_pid": holder_pid,
1132 }))?
1133 );
1134 } else {
1135 eprintln!(
1136 "wire daemon: another daemon is already running (pid {holder_pid}); not starting a second polling loop. Set WIRE_DAEMON_NO_SINGLETON=1 to override."
1137 );
1138 }
1139 return Ok(());
1140 }
1141 Some(crate::ensure_up::claim_daemon_singleton()?)
1142 } else {
1143 None
1144 };
1145 if !once {
1150 crate::session::warn_if_unexpected_session_source("daemon");
1153 crate::session::warn_on_identity_collision(std::process::id(), "daemon");
1154 }
1155 let interval = std::time::Duration::from_secs(interval_secs.max(1));
1156
1157 if !as_json {
1158 if once {
1159 eprintln!("wire daemon: single sync cycle, then exit");
1160 } else {
1161 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
1162 }
1163 }
1164
1165 if let Err(e) = crate::ensure_up::write_self_daemon_pid() {
1169 eprintln!("daemon: pidfile write error: {e:#}");
1170 }
1171
1172 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
1178 if !once {
1179 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
1180 }
1181
1182 let mut notify_state: Option<(crate::inbox_watch::InboxWatcher, std::path::PathBuf)> = if once {
1188 None
1189 } else {
1190 let cursor_path = config::state_dir()?.join("notify.cursor");
1191 match crate::inbox_watch::InboxWatcher::from_cursor_file(&cursor_path) {
1192 Ok(w) => Some((w, cursor_path)),
1193 Err(e) => {
1194 eprintln!("daemon: notify watcher init failed, toasts disabled: {e:#}");
1197 None
1198 }
1199 }
1200 };
1201
1202 loop {
1203 let pushed = run_sync_push().unwrap_or_else(|e| {
1204 eprintln!("daemon: push error: {e:#}");
1205 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
1206 });
1207 let pulled = run_sync_pull().unwrap_or_else(|e| {
1208 eprintln!("daemon: pull error: {e:#}");
1209 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
1210 });
1211
1212 if let Some((ref mut watcher, ref cursor_path)) = notify_state {
1214 match super::comms::notify_sweep_new_events(watcher, cursor_path) {
1215 Ok(events) => super::comms::toast_inbox_events(&events),
1216 Err(e) => eprintln!("daemon: notify sweep error: {e:#}"),
1217 }
1218 }
1219
1220 let cycle_push_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
1227 let cycle_pull_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
1228 let cycle_rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
1229 crate::ensure_up::write_last_sync_record(cycle_push_n, cycle_pull_n, cycle_rejected_n);
1230
1231 if as_json {
1232 println!(
1233 "{}",
1234 serde_json::to_string(&json!({
1235 "ts": time::OffsetDateTime::now_utc()
1236 .format(&time::format_description::well_known::Rfc3339)
1237 .unwrap_or_default(),
1238 "push": pushed,
1239 "pull": pulled,
1240 }))?
1241 );
1242 } else if cycle_push_n > 0 || cycle_pull_n > 0 || cycle_rejected_n > 0 {
1243 eprintln!(
1244 "daemon: pushed={cycle_push_n} pulled={cycle_pull_n} rejected={cycle_rejected_n}"
1245 );
1246 }
1247
1248 if once {
1249 return Ok(());
1250 }
1251 match wake_rx.recv_timeout(interval) {
1264 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1265 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
1266 std::thread::sleep(interval);
1267 }
1268 }
1269 while wake_rx.try_recv().is_ok() {}
1270 }
1271}
1272
1273pub fn run_sync_push() -> Result<Value> {
1276 let state = config::read_relay_state()?;
1277 let peers = state["peers"].as_object().cloned().unwrap_or_default();
1278 if peers.is_empty() {
1279 return Ok(json!({"pushed": [], "skipped": []}));
1280 }
1281 let outbox_dir = config::outbox_dir()?;
1282 if !outbox_dir.exists() {
1283 return Ok(json!({"pushed": [], "skipped": []}));
1284 }
1285 let mut pushed = Vec::new();
1286 let mut skipped = Vec::new();
1287 for (peer_handle, _slot_info) in peers.iter() {
1288 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
1289 if !outbox.exists() {
1290 continue;
1291 }
1292 let ordered_endpoints =
1300 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
1301 let body = std::fs::read_to_string(&outbox)?;
1302 for line in body.lines() {
1303 let event: Value = match serde_json::from_str(line) {
1304 Ok(v) => v,
1305 Err(_) => continue,
1306 };
1307 let event_id = event
1308 .get("event_id")
1309 .and_then(Value::as_str)
1310 .unwrap_or("")
1311 .to_string();
1312 if ordered_endpoints.is_empty() {
1313 skipped.push(json!({
1316 "peer": peer_handle,
1317 "event_id": event_id,
1318 "reason": "no reachable endpoint pinned for peer",
1319 }));
1320 continue;
1321 }
1322 let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
1323 match crate::relay_client::try_post_event_with_failover(
1324 &ordered_endpoints,
1325 &event,
1326 |endpoint, ev| {
1327 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
1328 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
1329 Ok(resp) => Ok(resp),
1330 Err(e) => {
1331 *last_err.borrow_mut() =
1334 Some(crate::relay_client::format_transport_error(&e));
1335 Err(e)
1336 }
1337 }
1338 },
1339 ) {
1340 Ok((endpoint, resp)) => {
1341 let now = time::OffsetDateTime::now_utc()
1346 .format(&time::format_description::well_known::Rfc3339)
1347 .unwrap_or_default();
1348 if let Err(e) = config::append_pushed_log(peer_handle, &event_id, &now) {
1349 eprintln!(
1350 "daemon: pushed-log append for {peer_handle}/{event_id} failed (non-fatal): {e:#}"
1351 );
1352 }
1353 if resp.status == "duplicate" {
1354 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate", "endpoint": endpoint.relay_url}));
1355 } else {
1356 pushed.push(json!({"peer": peer_handle, "event_id": event_id, "endpoint": endpoint.relay_url}));
1357 }
1358 }
1359 Err(_) => {
1360 let reason = last_err
1361 .borrow()
1362 .clone()
1363 .unwrap_or_else(|| "all endpoints failed".to_string());
1364 skipped
1365 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
1366 }
1367 }
1368 }
1369 if let Err(e) = config::drain_outbox_delivered(peer_handle) {
1374 eprintln!("daemon: outbox drain for {peer_handle} failed (non-fatal): {e:#}");
1375 }
1376 }
1377 Ok(json!({"pushed": pushed, "skipped": skipped}))
1378}
1379
1380pub fn cmd_ping(peer: &str, as_json: bool) -> Result<()> {
1392 use std::time::{Duration, Instant};
1393 let bare = crate::agent_card::bare_handle(peer);
1394 let nonce = hex::encode(rand::random::<[u8; 8]>());
1395 let inbox_path = config::inbox_dir()?.join(format!("{bare}.jsonl"));
1396
1397 let start = Instant::now();
1398 crate::probe::send_probe(peer, &nonce).with_context(|| format!("sending probe to {peer}"))?;
1399
1400 let deadline = start + Duration::from_secs(5);
1401 let mut rtt_ms: Option<u128> = None;
1402 while Instant::now() < deadline {
1403 let _ = run_sync_pull();
1405 if inbox_contains_probe_ack(&inbox_path, &nonce) {
1406 rtt_ms = Some(start.elapsed().as_millis());
1407 break;
1408 }
1409 std::thread::sleep(Duration::from_millis(200));
1410 }
1411
1412 match rtt_ms {
1413 Some(ms) => {
1414 if as_json {
1415 println!(
1416 "{}",
1417 serde_json::to_string(&json!({
1418 "peer": bare, "alive": true, "rtt_ms": ms,
1419 }))?
1420 );
1421 } else {
1422 println!("{bare}: alive — probe round-trip {ms}ms");
1423 }
1424 Ok(())
1425 }
1426 None => {
1427 if as_json {
1428 println!(
1429 "{}",
1430 serde_json::to_string(&json!({
1431 "peer": bare, "alive": false, "rtt_ms": null,
1432 "reason": "no probe_ack within 5s",
1433 }))?
1434 );
1435 Ok(())
1436 } else {
1437 bail!(
1438 "{bare}: no response within 5s — their daemon may be down, unreachable, or not yet on a probe-capable build"
1439 )
1440 }
1441 }
1442 }
1443}
1444
1445fn inbox_contains_probe_ack(path: &std::path::Path, nonce: &str) -> bool {
1448 let Ok(body) = std::fs::read_to_string(path) else {
1449 return false;
1450 };
1451 body.lines()
1452 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
1453 .any(|e| crate::probe::is_probe_ack_for(&e, nonce))
1454}
1455
1456fn nostr_relays_from_peers(state: &Value) -> Vec<String> {
1467 let mut relays: Vec<String> = Vec::new();
1468 let mut push_distinct = |r: &str| {
1469 if !r.is_empty() && !relays.iter().any(|x| x == r) {
1470 relays.push(r.to_string());
1471 }
1472 };
1473 for r in crate::endpoints::self_nostr_relays(state) {
1474 push_distinct(&r);
1475 }
1476 if let Some(peers) = state.get("peers").and_then(Value::as_object) {
1477 for p in peers.values() {
1478 if let Some(r) = p
1479 .get("nostr_transport")
1480 .and_then(|n| n.get("relay"))
1481 .and_then(Value::as_str)
1482 {
1483 push_distinct(r);
1484 }
1485 }
1486 }
1487 relays
1488}
1489
1490fn pull_nostr_wire_events(relays: &[String], my_xonly: &[u8; 32]) -> Vec<Value> {
1499 let my_p_tag = hex::encode(my_xonly);
1500 let rt = match tokio::runtime::Builder::new_multi_thread()
1501 .enable_all()
1502 .build()
1503 {
1504 Ok(rt) => rt,
1505 Err(e) => {
1506 eprintln!("daemon: nostr pull runtime build failed: {e:#}");
1507 return Vec::new();
1508 }
1509 };
1510 rt.block_on(async {
1511 let mut out: Vec<Value> = Vec::new();
1512 for relay in relays {
1513 let filter = crate::nostr_relay::Filter {
1514 p_tags: vec![my_p_tag.clone()],
1515 kinds: vec![1],
1516 limit: Some(200),
1517 ..Default::default()
1518 };
1519 let events = match crate::nostr_ws::NostrWs::connect(relay).await {
1520 Ok(mut ws) => match ws.pull(filter).await {
1521 Ok(evs) => evs,
1522 Err(e) => {
1523 eprintln!("daemon: nostr pull on {relay} failed (continuing): {e:#}");
1524 continue;
1525 }
1526 },
1527 Err(e) => {
1528 eprintln!("daemon: nostr connect {relay} failed (continuing): {e:#}");
1529 continue;
1530 }
1531 };
1532 for ev in &events {
1533 if let Ok(wire) = crate::nostr_event::verify_and_decode(ev) {
1537 out.push(wire);
1538 }
1539 }
1540 }
1541 out
1542 })
1543}
1544
1545pub fn run_sync_pull() -> Result<Value> {
1546 let state = config::read_relay_state()?;
1547 if state.get("self").map(Value::is_null).unwrap_or(true) {
1548 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
1549 }
1550 let endpoints = crate::endpoints::self_endpoints(&state);
1557 if endpoints.is_empty() {
1558 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
1559 }
1560 let inbox_dir = config::inbox_dir()?;
1561 config::ensure_dirs()?;
1562
1563 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
1568 let legacy_cursor = self_obj
1569 .get("last_pulled_event_id")
1570 .and_then(Value::as_str)
1571 .map(str::to_string);
1572 let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
1573 let mut cursors: serde_json::Map<String, Value> = self_obj
1574 .get("cursors")
1575 .and_then(Value::as_object)
1576 .cloned()
1577 .unwrap_or_default();
1578
1579 let mut all_written: Vec<Value> = Vec::new();
1580 let mut all_rejected: Vec<Value> = Vec::new();
1581 let mut total_seen = 0usize;
1582 let mut blocked_any = false;
1583
1584 for ep in &endpoints {
1585 if ep.relay_url.is_empty() {
1586 continue;
1587 }
1588 let cursor = cursors
1589 .get(&ep.slot_id)
1590 .and_then(Value::as_str)
1591 .map(str::to_string)
1592 .or_else(|| {
1593 if Some(&ep.slot_id) == primary_slot.as_ref() {
1594 legacy_cursor.clone()
1595 } else {
1596 None
1597 }
1598 });
1599 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
1600 let events =
1603 match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
1604 Ok(e) => e,
1605 Err(e) => {
1606 eprintln!(
1607 "daemon: pull error on {} slot {} (continuing): {e:#}",
1608 ep.relay_url, ep.slot_id
1609 );
1610 continue;
1611 }
1612 };
1613 total_seen += events.len();
1614 let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
1617 crate::probe::respond_to_probes(&result.probes);
1619 if let Some(eid) = &result.advance_cursor_to {
1620 cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
1621 }
1622 blocked_any |= result.blocked;
1623 all_written.extend(result.written);
1624 all_rejected.extend(result.rejected);
1625 }
1626
1627 if let Ok(nsk) = config::read_nostr_key()
1636 && let Ok(my_xonly) = crate::nostr_key::xonly_from_secret(&nsk)
1637 {
1638 let relays = nostr_relays_from_peers(&state);
1639 if !relays.is_empty() {
1640 let wire_events = pull_nostr_wire_events(&relays, &my_xonly);
1641 if !wire_events.is_empty() {
1642 total_seen += wire_events.len();
1643 let result = crate::pull::process_events(&wire_events, None, &inbox_dir)?;
1644 crate::probe::respond_to_probes(&result.probes);
1645 all_written.extend(result.written);
1646 all_rejected.extend(result.rejected);
1647 }
1648 }
1649 }
1650
1651 let primary_cursor = primary_slot
1655 .as_ref()
1656 .and_then(|s| cursors.get(s))
1657 .and_then(Value::as_str)
1658 .map(str::to_string);
1659 let mut latest_inbound: std::collections::HashMap<String, String> =
1667 std::collections::HashMap::new();
1668 for w in &all_written {
1669 let from = match w.get("from").and_then(Value::as_str) {
1670 Some(s) => s.to_string(),
1671 None => continue,
1672 };
1673 let ts = match w.get("timestamp").and_then(Value::as_str) {
1674 Some(s) if !s.is_empty() => s.to_string(),
1675 _ => continue,
1676 };
1677 latest_inbound
1678 .entry(from)
1679 .and_modify(|existing| {
1680 if ts > *existing {
1681 *existing = ts.clone();
1682 }
1683 })
1684 .or_insert(ts);
1685 }
1686 config::update_relay_state(|state| {
1687 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
1688 self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
1689 if let Some(pc) = &primary_cursor {
1690 self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
1691 }
1692 }
1693 if !latest_inbound.is_empty()
1694 && let Some(peers_obj) = state.get_mut("peers").and_then(Value::as_object_mut)
1695 {
1696 for (handle, ts) in &latest_inbound {
1697 let entry = peers_obj.entry(handle.clone()).or_insert_with(|| json!({}));
1698 if let Some(obj) = entry.as_object_mut() {
1699 obj.insert("last_inbound_event_at".into(), Value::String(ts.clone()));
1700 }
1701 }
1702 }
1703 Ok(())
1704 })?;
1705
1706 Ok(json!({
1707 "written": all_written,
1708 "rejected": all_rejected,
1709 "total_seen": total_seen,
1710 "cursor_blocked": blocked_any,
1711 "endpoints_pulled": endpoints.len(),
1712 }))
1713}
1714
1715pub fn error_smells_like_slot_4xx(last_err: &str) -> bool {
1744 fn is_token_boundary(b: u8) -> bool {
1745 matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
1746 }
1747 let bytes = last_err.as_bytes();
1748 for code in ["410", "404"] {
1749 let code_bytes = code.as_bytes();
1750 let mut search_from = 0usize;
1751 while let Some(rel) = last_err[search_from..].find(code) {
1752 let abs = search_from + rel;
1753 let end = abs + code_bytes.len();
1754 let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
1755 let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
1756 if before_ok && after_ok {
1757 return true;
1758 }
1759 search_from = abs + 1;
1763 }
1764 }
1765 false
1766}
1767
1768fn try_reresolve_peer_on_slot_4xx(
1803 state: &mut Value,
1804 peer_handle: &str,
1805 last_err: &str,
1806 already_tried: &std::collections::HashSet<String>,
1807) -> Result<bool> {
1808 if !error_smells_like_slot_4xx(last_err) {
1809 return Ok(false);
1811 }
1812 if already_tried.contains(peer_handle) {
1813 return Ok(false);
1815 }
1816 let peer_entry = state
1818 .get("peers")
1819 .and_then(|p| p.get(peer_handle))
1820 .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
1821 let peer_relay = peer_entry
1822 .get("endpoints")
1823 .and_then(Value::as_array)
1824 .and_then(|arr| {
1825 arr.iter().find(|e| {
1826 e.get("scope").and_then(Value::as_str) == Some("federation")
1827 || e.get("scope").and_then(Value::as_str) == Some("Federation")
1828 })
1829 })
1830 .and_then(|e| e.get("relay_url").and_then(Value::as_str))
1831 .ok_or_else(|| {
1834 anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
1835 })?
1836 .to_string();
1837 let domain = peer_relay
1840 .trim_start_matches("https://")
1841 .trim_start_matches("http://")
1842 .split('/')
1843 .next()
1844 .unwrap_or(&peer_relay)
1845 .to_string();
1846 let handle = crate::pair_profile::Handle {
1847 nick: peer_handle.to_string(),
1848 domain,
1849 };
1850 let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
1851 let new_slot_id = resolved
1852 .get("slot_id")
1853 .and_then(Value::as_str)
1854 .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
1855 .to_string();
1856 let peers = state
1858 .get_mut("peers")
1859 .and_then(Value::as_object_mut)
1860 .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
1861 let peer_entry = peers
1862 .get_mut(peer_handle)
1863 .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
1864 let current_slot_id = peer_entry
1865 .get("endpoints")
1866 .and_then(Value::as_array)
1867 .and_then(|arr| {
1868 arr.iter().find(|e| {
1869 let scope = e.get("scope").and_then(Value::as_str);
1870 scope == Some("federation") || scope == Some("Federation")
1871 })
1872 })
1873 .and_then(|e| e.get("slot_id").and_then(Value::as_str))
1874 .unwrap_or("")
1875 .to_string();
1876 if current_slot_id == new_slot_id {
1877 return Ok(false);
1879 }
1880 if let Some(endpoints) = peer_entry
1889 .get_mut("endpoints")
1890 .and_then(Value::as_array_mut)
1891 {
1892 for ep in endpoints.iter_mut() {
1893 let scope = ep.get("scope").and_then(Value::as_str);
1894 if scope == Some("federation") || scope == Some("Federation") {
1895 ep["slot_id"] = Value::String(new_slot_id.clone());
1896 ep["slot_token"] = Value::String(String::new());
1897 }
1898 }
1899 }
1900 peer_entry["slot_id"] = Value::String(new_slot_id.clone());
1903 peer_entry["slot_token"] = Value::String(String::new());
1904 eprintln!(
1905 "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
1906 now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
1907 {peer_handle}@<relay>` to refresh the slot_token."
1908 );
1909 Ok(true)
1910}
1911
1912#[cfg(test)]
1913mod slot_reresolve_tests {
1914 use super::*;
1915
1916 #[test]
1917 fn nostr_relays_from_peers_unions_self_and_peers_distinct() {
1918 let state = serde_json::json!({
1919 "self": { "nostr_relays": ["wss://self", "wss://r1"] },
1922 "peers": {
1923 "alice": { "nostr_transport": { "npub": "aa", "relay": "wss://r1" } },
1924 "bob": { "nostr_transport": { "npub": "bb", "relay": "wss://r2" } },
1925 "carol": { "nostr_transport": { "npub": "cc", "relay": "wss://r1" } },
1927 "dave": { "endpoints": [] },
1929 "erin": { "nostr_transport": { "npub": "ee", "relay": "" } },
1931 }
1932 });
1933 let mut relays = nostr_relays_from_peers(&state);
1934 relays.sort();
1935 assert_eq!(
1937 relays,
1938 vec![
1939 "wss://r1".to_string(),
1940 "wss://r2".to_string(),
1941 "wss://self".to_string()
1942 ]
1943 );
1944 assert!(nostr_relays_from_peers(&serde_json::json!({})).is_empty());
1946 assert!(nostr_relays_from_peers(&serde_json::json!({"peers": {}})).is_empty());
1947 }
1948
1949 #[test]
1970 fn try_reresolve_skips_when_error_is_not_4xx_shape() {
1971 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
1972 let already = std::collections::HashSet::new();
1973 let res =
1976 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
1977 .unwrap();
1978 assert!(!res, "502 must NOT trigger a re-resolve");
1979
1980 let res =
1981 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
1982 .unwrap();
1983 assert!(!res, "transport errors must NOT trigger a re-resolve");
1984
1985 let res = try_reresolve_peer_on_slot_4xx(
1986 &mut state,
1987 "some-peer",
1988 "post failed: 401 Unauthorized",
1989 &already,
1990 )
1991 .unwrap();
1992 assert!(
1993 !res,
1994 "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
1995 );
1996 }
1997
1998 #[test]
1999 fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
2000 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
2005 let mut already = std::collections::HashSet::new();
2006 already.insert("some-peer".to_string());
2007 let res = try_reresolve_peer_on_slot_4xx(
2008 &mut state,
2009 "some-peer",
2010 "post failed: 410 Gone",
2011 &already,
2012 )
2013 .unwrap();
2014 assert!(
2015 !res,
2016 "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
2017 );
2018 }
2019
2020 #[test]
2021 fn try_reresolve_errors_when_peer_missing_from_state() {
2022 let mut state = json!({"peers": {}});
2026 let already = std::collections::HashSet::new();
2027 let err = try_reresolve_peer_on_slot_4xx(
2028 &mut state,
2029 "missing-peer",
2030 "post failed: 410 Gone",
2031 &already,
2032 )
2033 .unwrap_err()
2034 .to_string();
2035 assert!(
2036 err.contains("missing-peer") && err.contains("not in relay_state"),
2037 "missing-peer error must name the peer + the failure: {err}"
2038 );
2039 }
2040
2041 #[test]
2042 fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
2043 let mut state = json!({
2050 "peers": {
2051 "local-only": {
2052 "endpoints": [
2053 {
2054 "scope": "Local",
2055 "relay_url": "http://127.0.0.1:8771",
2056 "slot_id": "loc",
2057 "slot_token": "tok"
2058 }
2059 ]
2060 }
2061 }
2062 });
2063 let already = std::collections::HashSet::new();
2064 let err = try_reresolve_peer_on_slot_4xx(
2065 &mut state,
2066 "local-only",
2067 "post failed: 410 Gone",
2068 &already,
2069 )
2070 .unwrap_err()
2071 .to_string();
2072 assert!(
2073 err.contains("federation endpoint"),
2074 "no-federation error must name the problem: {err}"
2075 );
2076 }
2077
2078 #[test]
2094 fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
2095 assert!(error_smells_like_slot_4xx(
2098 "post_event failed: 410 Gone: slot rotated by peer"
2099 ));
2100 assert!(error_smells_like_slot_4xx(
2101 "post_event failed: 404 Not Found: handle no longer claimed"
2102 ));
2103 }
2104
2105 #[test]
2106 fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
2107 assert!(error_smells_like_slot_4xx(
2111 "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
2112 ));
2113 assert!(error_smells_like_slot_4xx(
2114 "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
2115 ));
2116 }
2117
2118 #[test]
2119 fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
2120 let false_positives = [
2124 "push aborted: slot 4101 expired",
2125 "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
2126 "post_event failed: 500: received 4040 bytes, expected envelope",
2127 "post_event failed: 500: event 0x4104 malformed",
2128 "post_event failed: 503: backlog=4102 entries pending",
2129 "post_event failed: 500: tx_id=4044beef",
2131 "post_event failed: 500: hash=abc410def",
2133 ];
2134 for case in false_positives {
2135 assert!(
2136 !error_smells_like_slot_4xx(case),
2137 "must NOT trigger re-resolve on substring lookalike: {case:?}"
2138 );
2139 }
2140 }
2141
2142 #[test]
2143 fn error_smells_like_slot_4xx_handles_edge_positions() {
2144 assert!(error_smells_like_slot_4xx("410 Gone"));
2146 assert!(error_smells_like_slot_4xx("404 Not Found"));
2147 assert!(error_smells_like_slot_4xx("got 410"));
2149 assert!(error_smells_like_slot_4xx("got 404"));
2150 assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
2152 assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
2153 assert!(error_smells_like_slot_4xx("410"));
2155 assert!(error_smells_like_slot_4xx("404"));
2156 assert!(!error_smells_like_slot_4xx(""));
2158 assert!(!error_smells_like_slot_4xx("no relevant status"));
2159 assert!(!error_smells_like_slot_4xx(
2162 "post_event failed: 401 Unauthorized"
2163 ));
2164 assert!(!error_smells_like_slot_4xx(
2165 "post_event failed: 403 Forbidden"
2166 ));
2167 assert!(!error_smells_like_slot_4xx(
2168 "post_event failed: 411 Length Required"
2169 ));
2170 }
2171
2172 #[test]
2173 fn run_sync_push_routes_endpoints_only_peer_not_just_flat_fields() {
2174 config::test_support::with_temp_home(|| {
2180 let state = serde_json::json!({
2184 "self": { "endpoints": [] },
2185 "peers": {
2186 "glossy-spindle": {
2187 "endpoints": [{
2188 "relay_url": "http://127.0.0.1:1",
2189 "scope": "federation",
2190 "slot_id": "8add6502f7cfec82ec2348a8c3263f08",
2191 "slot_token": "deadbeef"
2192 }]
2193 }
2194 }
2195 });
2196 config::write_relay_state(&state).unwrap();
2197
2198 let outbox_dir = config::outbox_dir().unwrap();
2199 std::fs::create_dir_all(&outbox_dir).unwrap();
2200 std::fs::write(
2201 outbox_dir.join("glossy-spindle.jsonl"),
2202 "{\"event_id\":\"evt-regression-1\",\"kind\":1,\"body\":{}}\n",
2203 )
2204 .unwrap();
2205
2206 let out = run_sync_push().unwrap();
2207 let pushed = out["pushed"].as_array().unwrap();
2208 let skipped = out["skipped"].as_array().unwrap();
2209
2210 assert!(pushed.is_empty(), "dead endpoint cannot succeed: {out}");
2215 assert_eq!(
2216 skipped.len(),
2217 1,
2218 "endpoints[]-only peer must be ATTEMPTED, not silently skipped: {out}"
2219 );
2220 assert_eq!(skipped[0]["event_id"], "evt-regression-1");
2221 let reason = skipped[0]["reason"].as_str().unwrap_or("");
2222 assert!(
2223 !reason.is_empty() && reason != "no reachable endpoint pinned for peer",
2224 "skip reason must be a transport error from the attempted POST, got: {reason:?}"
2225 );
2226 });
2227 }
2228}