Skip to main content

wire/
endpoints.rs

1//! Multi-endpoint routing for v0.5.17 (dual-slot sessions).
2//!
3//! Each wire session can hold up to TWO slots:
4//!   - **Federation** — on a public relay (default `https://wireup.net`),
5//!     listed in the phonebook, reachable across machines.
6//!   - **Local** — on a loopback relay (default `http://127.0.0.1:8771`,
7//!     started with `wire relay-server --local-only`), invisible from
8//!     off-box, sub-millisecond round-trip for same-machine sister-Claude
9//!     traffic.
10//!
11//! Both slots are advertised to paired peers via the `pair_drop` body's
12//! `endpoints[]` array (additive — v0.5.16-and-earlier peers see only
13//! the federation endpoint at the top-level legacy fields, unchanged).
14//!
15//! Routing decision lives in `cmd_push`: walk a peer's pinned endpoints
16//! in priority order (local first if we also have a local slot), POST
17//! the event, fall back to the next endpoint on failure. Pulling: the
18//! daemon reads from BOTH slots, dedupes by `event_id`.
19//!
20//! Storage shape in `relay_state.json`:
21//!
22//! ```jsonc
23//! {
24//!   "self": {
25//!     // Self-slot still carries the flat triple (the #263 daemon-survival
26//!     // fix synthesizes a sister's flat fields; self-collapse is Part A /
27//!     // a separate slice — see RFC-006).
28//!     "relay_url": "https://wireup.net",
29//!     "slot_id":   "abc...",
30//!     "slot_token":"...",
31//!     "endpoints": [
32//!       {"relay_url": "https://wireup.net",     "slot_id": "abc...",  "slot_token": "...", "scope": "federation"},
33//!       {"relay_url": "http://127.0.0.1:8771",  "slot_id": "loop...", "slot_token": "...", "scope": "local"}
34//!     ]
35//!   },
36//!   "peers": {
37//!     "wire-mesh": {
38//!       // RFC-006 Part B (#268): peers carry `endpoints[]` ONLY — the single
39//!       // peer-routing source. The flat relay_url/slot_id/slot_token triple
40//!       // is no longer written (it was the "stale flat beats fresh array"
41//!       // routing hazard). All peer-pin readers resolve through
42//!       // `peer_endpoints_in_priority_order` / `peer_primary_endpoint`.
43//!       "endpoints": [...]
44//!     }
45//!   }
46//! }
47//! ```
48
49use anyhow::Result;
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52
53/// Where this endpoint sits in the reachability graph.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum EndpointScope {
57    /// Public-facing relay (e.g. `https://wireup.net`). Crosses machines.
58    Federation,
59    /// Loopback-only relay (e.g. `http://127.0.0.1:8771`). Same-machine only.
60    Local,
61    /// LAN-bound relay (e.g. `http://192.168.1.50:8771`). Reachable from
62    /// other machines on the same network without going through federation.
63    /// v0.7.0-alpha.9: third scope for noble-creek-on-paul-mac ↔
64    /// running-light-on-spark style across-the-room pairing without
65    /// wireup.net hop. Visible to anyone who fetches the agent-card —
66    /// opt-in per session (operator passes `--with-lan-relay <url>` at
67    /// `wire session new` time).
68    Lan,
69    /// Unix Domain Socket (e.g. `unix:///path/to/local.sock`). Same-host,
70    /// same-uid only. v0.7.0-alpha.16: framed primarily as a SECURITY
71    /// boundary — no bound TCP port (no firewall surface), SO_PEERCRED
72    /// kernel-attested peer uid (sister-session trust anchor), 0600
73    /// socket permissions. Performance win over loopback HTTP is real
74    /// but tiny (~1.3µs) and not the headline reason. Opt-in via
75    /// `wire session new --with-uds`; Unix-only (Windows falls back to
76    /// Local loopback).
77    Uds,
78}
79
80/// One reachable address for a wire identity. Includes the bearer
81/// `slot_token` because endpoints flow through the pair_drop body,
82/// which is encrypted at protocol level (signed envelope + bilateral
83/// pin gate from v0.5.14). Token is the slot's bearer credential; it
84/// MUST stay private to the pair and is never published in the agent
85/// card or phonebook.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Endpoint {
88    pub relay_url: String,
89    pub slot_id: String,
90    pub slot_token: String,
91    pub scope: EndpointScope,
92}
93
94impl Endpoint {
95    pub fn federation(relay_url: String, slot_id: String, slot_token: String) -> Self {
96        Self {
97            relay_url,
98            slot_id,
99            slot_token,
100            scope: EndpointScope::Federation,
101        }
102    }
103
104    pub fn local(relay_url: String, slot_id: String, slot_token: String) -> Self {
105        Self {
106            relay_url,
107            slot_id,
108            slot_token,
109            scope: EndpointScope::Local,
110        }
111    }
112
113    /// v0.7.0-alpha.9: construct a LAN-scope endpoint.
114    pub fn lan(relay_url: String, slot_id: String, slot_token: String) -> Self {
115        Self {
116            relay_url,
117            slot_id,
118            slot_token,
119            scope: EndpointScope::Lan,
120        }
121    }
122
123    /// v0.7.0-alpha.16: construct a UDS-scope endpoint.
124    /// `relay_url` is a `unix:///abs/path/to/local.sock` URL (the
125    /// `unix://` scheme is wire-internal; readers route to a UDS HTTP
126    /// client rather than reqwest).
127    pub fn uds(relay_url: String, slot_id: String, slot_token: String) -> Self {
128        Self {
129            relay_url,
130            slot_id,
131            slot_token,
132            scope: EndpointScope::Uds,
133        }
134    }
135}
136
137/// Read all of a peer's pinned endpoints from `relay_state.json`,
138/// sorted in routing priority order:
139///
140/// 1. Local endpoints first — only when we ALSO have a local slot
141///    (i.e. our `self.endpoints` includes a local one with the same
142///    relay_url). Otherwise local endpoints are skipped because we
143///    can't reach them.
144/// 2. Federation endpoints second.
145///
146/// RFC-006 Part B (#268): reads `endpoints[]` only — the single peer-routing
147/// source. There is no flat-field synthesis fallback; a peer pin with no
148/// `endpoints[]` array yields no endpoints (every real pin carries it).
149pub fn peer_endpoints_in_priority_order(relay_state: &Value, peer_handle: &str) -> Vec<Endpoint> {
150    let our_local_relay_url = relay_state
151        .get("self")
152        .and_then(|s| s.get("endpoints"))
153        .and_then(Value::as_array)
154        .and_then(|arr| {
155            arr.iter()
156                .find(|e| e.get("scope").and_then(Value::as_str) == Some("local"))
157                .and_then(|e| e.get("relay_url"))
158                .and_then(Value::as_str)
159                .map(str::to_string)
160        });
161
162    let peer = match relay_state.get("peers").and_then(|p| p.get(peer_handle)) {
163        Some(p) => p,
164        None => return Vec::new(),
165    };
166
167    let mut all: Vec<Endpoint> = Vec::new();
168
169    if let Some(arr) = peer.get("endpoints").and_then(Value::as_array) {
170        for ep in arr {
171            if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
172                all.push(parsed);
173            }
174        }
175    }
176
177    // RFC-006 Part B: `endpoints[]` is the only peer-routing source. The
178    // former flat-field synthesis fallback (for pre-v0.5.16 pins with no
179    // `endpoints` array) is gone — every pin now carries `endpoints[]`
180    // (`pin_peer_endpoints` writes it; invite-accept routes through it too).
181
182    // Sort: UDS (same-host trust anchor) first, then local-loopback-
183    // with-matching-self-local, then LAN (cross-machine same-network),
184    // then federation. Drop unreachable scopes via the retain pass.
185    //
186    // v0.7.0-alpha.9: LAN endpoints sit between Local and Federation.
187    // Faster than federation; not gated by "our_local matches" because
188    // cross-machine peers won't have a matching our-local by definition.
189    //
190    // v0.7.0-alpha.16: UDS endpoints get rank 0 when peer + self share
191    // a UDS socket path (we need to be able to connect to their socket
192    // which means it must be readable by our uid). The "same-uid same-
193    // host" sister-session trust shape this enforces is the whole
194    // point of UDS — see project_wire_transport_substrate_research.
195    let our_local = our_local_relay_url.clone();
196    all.sort_by_key(|ep| match (ep.scope, &our_local) {
197        (EndpointScope::Uds, _) => 0,
198        (EndpointScope::Local, Some(our)) if &ep.relay_url == our => 1,
199        (EndpointScope::Lan, _) => 2,
200        (EndpointScope::Federation, _) => 3,
201        _ => 4,
202    });
203    // Drop unreachable: Local needs matching loopback URL; UDS needs
204    // the socket file to exist on our filesystem (the daemon-side
205    // connect will surface a clearer error than a routing-time drop
206    // would, but we still keep UDS in the routing list — failure
207    // falls through to lower-priority scopes).
208    all.retain(|ep| match (ep.scope, &our_local) {
209        (EndpointScope::Local, None) => false,
210        (EndpointScope::Local, Some(our)) => &ep.relay_url == our,
211        (EndpointScope::Lan, _) => true,
212        (EndpointScope::Uds, _) => true,
213        (EndpointScope::Federation, _) => true,
214    });
215    all
216}
217
218/// All of OUR own endpoints from `relay_state.json`. Used by `cmd_push`
219/// to find the local slot when routing local-first, and by the daemon's
220/// pull loop to iterate every slot we should be reading from.
221pub fn self_endpoints(relay_state: &Value) -> Vec<Endpoint> {
222    let self_state = match relay_state.get("self") {
223        Some(s) if !s.is_null() => s,
224        _ => return Vec::new(),
225    };
226    let mut all: Vec<Endpoint> = Vec::new();
227    if let Some(arr) = self_state.get("endpoints").and_then(Value::as_array) {
228        for ep in arr {
229            if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
230                all.push(parsed);
231            }
232        }
233    }
234    if all.is_empty() {
235        // Back-compat: synthesize a federation endpoint from legacy
236        // top-level fields. Slot_token may be absent in some old
237        // states; in that case the synthesized endpoint is partial
238        // and downstream code must guard against empty token.
239        let relay_url = self_state
240            .get("relay_url")
241            .and_then(Value::as_str)
242            .unwrap_or("");
243        let slot_id = self_state
244            .get("slot_id")
245            .and_then(Value::as_str)
246            .unwrap_or("");
247        let slot_token = self_state
248            .get("slot_token")
249            .and_then(Value::as_str)
250            .unwrap_or("");
251        if !relay_url.is_empty() && !slot_id.is_empty() {
252            all.push(Endpoint::federation(
253                relay_url.to_string(),
254                slot_id.to_string(),
255                slot_token.to_string(),
256            ));
257        }
258    }
259    all
260}
261
262/// v0.9 canonical single-reader for "my best inbound slot." Returns
263/// the first endpoint from `self_endpoints()` — which is already
264/// priority-ordered (UDS → Local-with-matching-self → LAN →
265/// Federation) AND back-compat-falls-back to legacy top-level fields.
266///
267/// Replaces ad-hoc `self_state["relay_url"].as_str()` reads scattered
268/// through the codebase. Pre-v0.9 those bare reads were the silent-
269/// fail root cause: a session with only `self.endpoints[]` (no legacy
270/// top-level fields) returned empty strings instead of the available
271/// endpoint, and pair_drop_ack / pull / rotate-slot all silently
272/// no-op'd. Always use this from new code.
273pub fn self_primary_endpoint(relay_state: &Value) -> Option<Endpoint> {
274    self_endpoints(relay_state).into_iter().next()
275}
276
277/// The single best (highest-priority) endpoint to reach `peer_handle`, or
278/// `None` if the peer has no pinned endpoints. RFC-006 Part B: the canonical
279/// replacement for reading the old flat `relay_url`/`slot_id`/`slot_token` peer
280/// fields — every peer-pin reader resolves through this (or
281/// `peer_endpoints_in_priority_order` when it needs failover).
282pub fn peer_primary_endpoint(relay_state: &Value, peer_handle: &str) -> Option<Endpoint> {
283    peer_endpoints_in_priority_order(relay_state, peer_handle)
284        .into_iter()
285        .next()
286}
287
288/// The `slot_token` of a peer's pinned **federation** endpoint on `relay_url`,
289/// or `""` if there's no such endpoint (or it hasn't acked yet).
290///
291/// RFC-006 Part B: the canonical way to carry a peer's already-arrived reply
292/// token forward across a re-pin. It replaces the old flat `peers[h].slot_token`
293/// read — which Part B (#268) stopped *writing*, so any dial path still reading
294/// it now reads `""` and silently wipes the peer's reply token on re-dial. Both
295/// dial paths (`cli::pairing` and the MCP `tool_dial`) route through here so
296/// they can't drift apart again.
297pub fn peer_federation_token(relay_state: &Value, peer_handle: &str, relay_url: &str) -> String {
298    relay_state
299        .get("peers")
300        .and_then(|p| p.get(peer_handle))
301        .and_then(|e| e.get("endpoints"))
302        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
303        .unwrap_or_default()
304        .into_iter()
305        .find(|e| e.scope == EndpointScope::Federation && e.relay_url == relay_url)
306        .map(|e| e.slot_token)
307        .unwrap_or_default()
308}
309
310/// Pin a peer's full set of endpoints into `relay_state.json` under
311/// `peers[handle]`. RFC-006 Part B (#268): writes `endpoints[]` ONLY — the
312/// single peer-routing source. The flat `relay_url`/`slot_id`/`slot_token`
313/// triple is no longer written. Durable non-routing fields
314/// (`bilateral_completed_at`, `persona`, `profile`, `first_seen_at`,
315/// `nostr_transport`) are preserved across re-pins (see below).
316pub fn pin_peer_endpoints(
317    relay_state: &mut Value,
318    peer_handle: &str,
319    endpoints: &[Endpoint],
320) -> Result<()> {
321    let peers = relay_state
322        .as_object_mut()
323        .map(|m| {
324            m.entry("peers")
325                .or_insert_with(|| Value::Object(Default::default()))
326        })
327        .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
328        .as_object_mut()
329        .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
330    // v0.14.2 (#162 fix #5): preserve durable peer state across re-pin
331    // events. honey-pine observed `wire_peers` tier flapping
332    // VERIFIED → PENDING_ACK; root cause is this `peers.insert(.., entry)`
333    // wholesale-replacement losing any previously-set field. The fields
334    // we explicitly retain here represent monotonic state — once
335    // bilateral-pair is complete or the peer's published persona/profile
336    // is known, those facts must NOT be wiped just because a fresh
337    // pair_drop_ack carrying only endpoint data lands. Other fields
338    // (`relay_url`, `slot_id`, `slot_token`, `endpoints`) are always
339    // current-state and intentionally re-derived from the input below.
340    let preserved: serde_json::Map<String, Value> = peers
341        .get(peer_handle)
342        .and_then(Value::as_object)
343        .map(|m| {
344            m.iter()
345                .filter(|(k, _)| {
346                    matches!(
347                        k.as_str(),
348                        "bilateral_completed_at"
349                            | "persona"
350                            | "profile"
351                            | "first_seen_at"
352                            // RFC-007 D3.4: a peer's Nostr transport coords are
353                            // durable reachability state — must survive an
354                            // HTTP-endpoint re-pin (same monotonic-state rule).
355                            | "nostr_transport"
356                    )
357                })
358                .map(|(k, v)| (k.clone(), v.clone()))
359                .collect()
360        })
361        .unwrap_or_default();
362    // RFC-006 Part B: `endpoints[]` is the SINGLE peer-routing source. The
363    // top-level flat `relay_url`/`slot_id`/`slot_token` fields are no longer
364    // written — they were a redundant synthesized copy (the "stale flat beats
365    // fresh array" routing hazard). All peer-pin readers now resolve through
366    // `peer_endpoints_in_priority_order`. (Self-slot flat is a separate
367    // representation, untouched here.)
368    let mut entry = preserved;
369    entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
370    peers.insert(peer_handle.to_string(), Value::Object(entry));
371    Ok(())
372}
373
374/// RFC-007 D3.4: record a peer's **Nostr transport** reachability — their
375/// x-only npub (hex) + a `wss://` relay to reach them on — under
376/// `peers[handle].nostr_transport`. Read-modify-write so it composes with
377/// `pin_peer_endpoints` (which preserves this field). Reachability only; trust
378/// is a separate pin. Idempotent.
379pub fn pin_peer_nostr_transport(
380    relay_state: &mut Value,
381    peer_handle: &str,
382    npub_hex: &str,
383    relay_url: &str,
384) -> Result<()> {
385    let peers = relay_state
386        .as_object_mut()
387        .map(|m| {
388            m.entry("peers")
389                .or_insert_with(|| Value::Object(Default::default()))
390        })
391        .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
392        .as_object_mut()
393        .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
394    let entry = peers
395        .entry(peer_handle.to_string())
396        .or_insert_with(|| Value::Object(Default::default()))
397        .as_object_mut()
398        .ok_or_else(|| anyhow::anyhow!("relay_state.peers[{peer_handle}] is not an object"))?;
399    entry.insert(
400        "nostr_transport".into(),
401        serde_json::json!({ "npub": npub_hex, "relay": relay_url }),
402    );
403    Ok(())
404}
405
406/// Read a peer's Nostr transport coords `(npub_hex, relay_url)`, or `None` if
407/// the peer has no Nostr transport recorded.
408pub fn peer_nostr_transport(relay_state: &Value, peer_handle: &str) -> Option<(String, String)> {
409    let nt = relay_state
410        .get("peers")?
411        .get(peer_handle)?
412        .get("nostr_transport")?;
413    let npub = nt.get("npub")?.as_str()?.to_string();
414    let relay = nt.get("relay")?.as_str()?.to_string();
415    if npub.is_empty() || relay.is_empty() {
416        return None;
417    }
418    Some((npub, relay))
419}
420
421/// RFC-007 D3: record a Nostr relay this session is *reachable on* — one we've
422/// paired/fetched over (`wire nostr pair/accept/fetch --relay X`). Persisted as
423/// the distinct set `self.nostr_relays[]`. The daemon pull-loop reads this as
424/// the authoritative "where do peers publish my inbound" set: a peer sends to me
425/// by publishing to a relay *I'm* reachable on, which isn't necessarily a relay
426/// *I* reach *them* on (the asymmetric case the peer-transport set misses).
427/// Read-modify-write, idempotent (dedups).
428pub fn pin_self_nostr_relay(relay_state: &mut Value, relay_url: &str) -> Result<()> {
429    if relay_url.is_empty() {
430        return Ok(());
431    }
432    let self_obj = relay_state
433        .as_object_mut()
434        .map(|m| {
435            m.entry("self")
436                .or_insert_with(|| Value::Object(Default::default()))
437        })
438        .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
439        .as_object_mut()
440        .ok_or_else(|| anyhow::anyhow!("relay_state.self is not an object"))?;
441    let arr = self_obj
442        .entry("nostr_relays")
443        .or_insert_with(|| Value::Array(Vec::new()))
444        .as_array_mut()
445        .ok_or_else(|| anyhow::anyhow!("relay_state.self.nostr_relays is not an array"))?;
446    if !arr.iter().any(|v| v.as_str() == Some(relay_url)) {
447        arr.push(Value::String(relay_url.to_string()));
448    }
449    Ok(())
450}
451
452/// The distinct Nostr relays this session is reachable on (`self.nostr_relays[]`).
453/// Empty when never paired over Nostr.
454pub fn self_nostr_relays(relay_state: &Value) -> Vec<String> {
455    relay_state
456        .get("self")
457        .and_then(|s| s.get("nostr_relays"))
458        .and_then(Value::as_array)
459        .map(|a| {
460            a.iter()
461                .filter_map(|v| v.as_str())
462                .filter(|s| !s.is_empty())
463                .map(str::to_string)
464                .collect()
465        })
466        .unwrap_or_default()
467}
468
469/// Infer an endpoint scope from a relay URL: `unix://` -> Uds, a loopback
470/// host -> Local, otherwise Federation. LAN is never inferred (a private-
471/// range IP is indistinguishable from a federation host by URL alone) and
472/// must be requested explicitly.
473pub fn infer_scope_from_url(url: &str) -> EndpointScope {
474    if url.starts_with("unix://") {
475        return EndpointScope::Uds;
476    }
477    let host = url
478        .trim_start_matches("http://")
479        .trim_start_matches("https://")
480        .split('/')
481        .next()
482        .unwrap_or("")
483        .split(':')
484        .next()
485        .unwrap_or("");
486    if is_loopback_host(host) {
487        EndpointScope::Local
488    } else {
489        EndpointScope::Federation
490    }
491}
492
493/// True iff `host` (no scheme, no port) is a loopback address the E4 trust-path
494/// gates treat as `Local` scope: `infer_scope_from_url`, the handle validator +
495/// URL builder (`pair_profile::is_valid_domain` / `relay_url_for_domain`), and
496/// the `is_known_relay_domain` phishing-warning suppression all key off THIS one
497/// predicate so scheme + scope can never disagree. Keeping one predicate is
498/// load-bearing: if these gates disagreed on "loopback", a handle could parse +
499/// get an `http://` URL while being classified `Federation` (advertised off-box).
500///
501/// IPv4 `127.0.0.1` + `localhost` only. IPv6 `::1` is intentionally excluded — an
502/// IPv6 authority needs bracketing (`[::1]:port`) the handle/URL path doesn't
503/// carry, so `nick@::1:port` is rejected rather than half-accepted into a
504/// malformed `http://::1:port`; use `127.0.0.1` for a loopback handle. Do not
505/// broaden the IPv4 set to the full /8 here without updating every caller. (NB:
506/// `session.rs::url_is_loopback` is a SEPARATE, deliberately-broader /8 predicate
507/// for same-box session discovery — not a trust gate.)
508pub fn is_loopback_host(host: &str) -> bool {
509    host == "127.0.0.1" || host == "localhost"
510}
511
512/// True iff this endpoint set is reachable ONLY from the same box — every
513/// endpoint resolves (by URL) to a loopback/`Local` or `Uds` address, with no
514/// off-box `Federation`/LAN host. A peer pinned this way can't complete a
515/// bilateral reply path with a *remote* peer: the reply has nowhere off-box to
516/// land. This is the #277 honesty signal — `wire accept` reported
517/// `bilateral_accepted` even when the resulting pin advertised only loopback
518/// endpoints. Inferred from the URL (not the advertised `scope`) so a loopback
519/// address mislabeled `federation` (exactly the #277 case) is still caught.
520/// Empty set → false (no pin to judge).
521pub fn endpoints_are_local_only(endpoints: &[Endpoint]) -> bool {
522    !endpoints.is_empty()
523        && endpoints.iter().all(|e| {
524            matches!(
525                infer_scope_from_url(&e.relay_url),
526                EndpointScope::Local | EndpointScope::Uds
527            )
528        })
529}
530
531/// Build the `self` block for `relay_state.json` from an endpoint set:
532/// the additive `endpoints[]` array plus legacy top-level
533/// relay_url/slot_id/slot_token pointing at the federation endpoint (or,
534/// absent one, the first endpoint) for v0.5.16-and-earlier back-compat.
535fn build_self_value(eps: &[Endpoint]) -> Value {
536    let legacy = eps
537        .iter()
538        .find(|e| e.scope == EndpointScope::Federation)
539        .or_else(|| eps.first());
540    let mut self_obj = serde_json::Map::new();
541    if let Some(l) = legacy {
542        self_obj.insert("relay_url".into(), Value::String(l.relay_url.clone()));
543        self_obj.insert("slot_id".into(), Value::String(l.slot_id.clone()));
544        self_obj.insert("slot_token".into(), Value::String(l.slot_token.clone()));
545    }
546    self_obj.insert(
547        "endpoints".into(),
548        serde_json::to_value(eps).unwrap_or(Value::Null),
549    );
550    Value::Object(self_obj)
551}
552
553/// Insert-or-replace one of OUR OWN endpoints in `relay_state["self"]`,
554/// keyed by `relay_url` (re-binding the same relay updates it in place).
555/// ADDITIVE: every other existing self endpoint is preserved, so an agent
556/// can hold a local relay AND a federation relay at once. Rebuilds the
557/// legacy top-level fields. Single source of truth for the self-slot write
558/// shape — used by `cmd_bind_relay` and `init_self_idempotent`.
559pub fn upsert_self_endpoint(relay_state: &mut Value, ep: Endpoint) {
560    let mut eps = self_endpoints(relay_state);
561    eps.retain(|e| e.relay_url != ep.relay_url);
562    eps.push(ep);
563    relay_state["self"] = build_self_value(&eps);
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use serde_json::json;
570
571    #[test]
572    fn infer_scope_classifies_loopback_unix_and_federation() {
573        assert_eq!(
574            infer_scope_from_url("http://127.0.0.1:8771"),
575            EndpointScope::Local
576        );
577        assert_eq!(
578            infer_scope_from_url("http://localhost:8771"),
579            EndpointScope::Local
580        );
581        assert_eq!(
582            infer_scope_from_url("unix:///tmp/wire.sock"),
583            EndpointScope::Uds
584        );
585        assert_eq!(
586            infer_scope_from_url("https://wireup.net"),
587            EndpointScope::Federation
588        );
589    }
590
591    #[test]
592    fn upsert_self_endpoint_is_additive_then_updates_in_place() {
593        let mut state = json!({});
594        upsert_self_endpoint(
595            &mut state,
596            Endpoint::federation("https://wireup.net".into(), "fed1".into(), "ft".into()),
597        );
598        upsert_self_endpoint(
599            &mut state,
600            Endpoint::local("http://127.0.0.1:8771".into(), "loc1".into(), "lt".into()),
601        );
602        // Both kept.
603        assert_eq!(self_endpoints(&state).len(), 2);
604        // Legacy fields point at federation.
605        assert_eq!(state["self"]["relay_url"], "https://wireup.net");
606        // Re-binding the same relay replaces that one entry, not appends.
607        upsert_self_endpoint(
608            &mut state,
609            Endpoint::local("http://127.0.0.1:8771".into(), "loc2".into(), "lt2".into()),
610        );
611        let eps = self_endpoints(&state);
612        assert_eq!(eps.len(), 2, "same-relay rebind replaces, not appends");
613        let loc = eps
614            .iter()
615            .find(|e| e.scope == EndpointScope::Local)
616            .unwrap();
617        assert_eq!(loc.slot_id, "loc2", "local slot updated in place");
618    }
619
620    #[test]
621    fn peer_endpoints_ignores_flat_only_pin_post_rfc006() {
622        // RFC-006 Part B: `endpoints[]` is the single peer-routing source. A
623        // peer with ONLY the old flat fields and no `endpoints[]` array yields
624        // NO endpoints — the synthesis fallback was removed. (No users to
625        // migrate; every real pin now carries `endpoints[]`.)
626        let state = json!({
627            "peers": {
628                "alice": { "relay_url": "https://wireup.net", "slot_id": "abc", "slot_token": "tok" }
629            }
630        });
631        assert!(peer_endpoints_in_priority_order(&state, "alice").is_empty());
632    }
633
634    #[test]
635    fn self_nostr_relay_roundtrips_and_dedups() {
636        let mut state = json!({});
637        pin_self_nostr_relay(&mut state, "wss://r1").unwrap();
638        pin_self_nostr_relay(&mut state, "wss://r2").unwrap();
639        pin_self_nostr_relay(&mut state, "wss://r1").unwrap(); // dup → no-op
640        pin_self_nostr_relay(&mut state, "").unwrap(); // empty → no-op
641        assert_eq!(
642            self_nostr_relays(&state),
643            vec!["wss://r1".to_string(), "wss://r2".to_string()]
644        );
645        // Composes with an existing self block (doesn't clobber other self keys).
646        let mut state2 = json!({"self": {"relay_url": "https://wireup.net", "slot_id": "s"}});
647        pin_self_nostr_relay(&mut state2, "wss://x").unwrap();
648        assert_eq!(state2["self"]["relay_url"], "https://wireup.net");
649        assert_eq!(self_nostr_relays(&state2), vec!["wss://x".to_string()]);
650        // Absent → empty.
651        assert!(self_nostr_relays(&json!({})).is_empty());
652    }
653
654    #[test]
655    fn endpoints_are_local_only_catches_loopback_pin_incl_mislabeled_scope() {
656        // Empty → false (nothing to judge).
657        assert!(!endpoints_are_local_only(&[]));
658        // A loopback URL mislabeled `federation` (the #277 case) is still caught
659        // — we infer from the URL, not the advertised scope.
660        let mislabeled =
661            Endpoint::federation("http://127.0.0.1:18791".into(), "s".into(), "t".into());
662        assert!(endpoints_are_local_only(std::slice::from_ref(&mislabeled)));
663        // A real federation host → reachable, not local-only.
664        let fed = Endpoint::federation("https://wireup.net".into(), "s".into(), "t".into());
665        assert!(!endpoints_are_local_only(std::slice::from_ref(&fed)));
666        // Mixed (loopback + federation) → reachable off-box via the federation one.
667        assert!(!endpoints_are_local_only(&[mislabeled.clone(), fed]));
668        // UDS is same-host only → local-only.
669        let uds = Endpoint::uds("unix:///tmp/wire.sock".into(), "s".into(), "t".into());
670        assert!(endpoints_are_local_only(&[uds]));
671    }
672
673    #[test]
674    fn peer_federation_token_carries_forward_from_endpoints_not_flat() {
675        // RFC-006 Part B regression: re-dial must carry the peer's already-acked
676        // reply token forward from `endpoints[]`. Reading the old flat
677        // `slot_token` (which Part B stopped writing) returned "" and wiped it.
678        let state = json!({
679            "peers": {
680                "alice": {
681                    // A stale flat field must NOT be the source of truth...
682                    "slot_token": "STALE_FLAT",
683                    "endpoints": [
684                        {"relay_url": "https://wireup.net", "slot_id": "s1", "slot_token": "REAL_TOK", "scope": "federation"},
685                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "l1", "slot_token": "LOCAL_TOK", "scope": "local"}
686                    ]
687                }
688            }
689        });
690        // ...the federation endpoint's token on the matching relay is.
691        assert_eq!(
692            peer_federation_token(&state, "alice", "https://wireup.net"),
693            "REAL_TOK"
694        );
695        // A different relay (no matching federation endpoint) → empty, not the
696        // local token and not the stale flat field.
697        assert_eq!(
698            peer_federation_token(&state, "alice", "https://other.example"),
699            ""
700        );
701        // Unknown peer → empty.
702        assert_eq!(
703            peer_federation_token(&state, "nobody", "https://wireup.net"),
704            ""
705        );
706    }
707
708    #[test]
709    fn peer_endpoints_lan_beats_federation() {
710        // v0.7.0-alpha.9: when a peer publishes both Lan and Federation
711        // endpoints (and we have a matching local too), priority must be
712        // Local(matched) > Lan > Federation. Lan is cross-machine same-
713        // network, faster than federation but not as fast as loopback.
714        let state = json!({
715            "self": {
716                "endpoints": [
717                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
718                    {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
719                ]
720            },
721            "peers": {
722                "alice": {
723                    "endpoints": [
724                        {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
725                        {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
726                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
727                    ]
728                }
729            }
730        });
731        let eps = peer_endpoints_in_priority_order(&state, "alice");
732        assert_eq!(
733            eps.len(),
734            3,
735            "Local(matched) + Lan + Federation all reachable"
736        );
737        assert_eq!(
738            eps[0].scope,
739            EndpointScope::Local,
740            "loopback wins (same-machine)"
741        );
742        assert_eq!(
743            eps[1].scope,
744            EndpointScope::Lan,
745            "Lan second (same-network)"
746        );
747        assert_eq!(
748            eps[2].scope,
749            EndpointScope::Federation,
750            "Federation last (anywhere)"
751        );
752    }
753
754    #[test]
755    fn peer_endpoints_lan_kept_when_self_has_no_local() {
756        // Cross-machine peer scenario: we have no Local, peer has Lan
757        // and Federation. Lan must still be kept (we connect TO their
758        // LAN address; we don't need a Local of our own to do so).
759        let state = json!({
760            "self": {
761                "endpoints": [
762                    {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
763                ]
764            },
765            "peers": {
766                "alice": {
767                    "endpoints": [
768                        {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
769                        {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
770                    ]
771                }
772            }
773        });
774        let eps = peer_endpoints_in_priority_order(&state, "alice");
775        assert_eq!(eps.len(), 2);
776        assert_eq!(
777            eps[0].scope,
778            EndpointScope::Lan,
779            "Lan preferred over Federation"
780        );
781        assert_eq!(eps[1].scope, EndpointScope::Federation);
782    }
783
784    #[test]
785    fn pin_peer_endpoints_writes_no_flat_fields_post_rfc006() {
786        // RFC-006 Part B: pin writes `endpoints[]` ONLY — no synthesized
787        // top-level relay_url/slot_id/slot_token. Routing reads the array.
788        let mut state = json!({});
789        let endpoints = vec![
790            Endpoint::lan(
791                "http://192.168.1.50:8771".to_string(),
792                "lan-slot".to_string(),
793                "lan-tok".to_string(),
794            ),
795            Endpoint::local(
796                "http://127.0.0.1:8771".to_string(),
797                "loop-slot".to_string(),
798                "loop-tok".to_string(),
799            ),
800        ];
801        pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
802        let alice = &state["peers"]["alice"];
803        assert!(alice.get("relay_url").is_none(), "no flat relay_url");
804        assert!(alice.get("slot_id").is_none(), "no flat slot_id");
805        assert!(alice.get("slot_token").is_none(), "no flat slot_token");
806        assert_eq!(
807            alice["endpoints"].as_array().map(Vec::len),
808            Some(2),
809            "endpoints[] is the routing source"
810        );
811    }
812
813    #[test]
814    fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
815        let state = json!({
816            "self": {
817                "endpoints": [
818                    {"relay_url": "https://wireup.net",    "slot_id": "self-fed",  "slot_token": "t1", "scope": "federation"},
819                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
820                ]
821            },
822            "peers": {
823                "alice": {
824                    "endpoints": [
825                        {"relay_url": "https://wireup.net",    "slot_id": "a-fed",  "slot_token": "ta1", "scope": "federation"},
826                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
827                    ]
828                }
829            }
830        });
831        let eps = peer_endpoints_in_priority_order(&state, "alice");
832        assert_eq!(eps.len(), 2);
833        assert_eq!(eps[0].scope, EndpointScope::Local);
834        assert_eq!(eps[1].scope, EndpointScope::Federation);
835    }
836
837    #[test]
838    fn peer_endpoints_drops_local_when_self_has_no_local() {
839        let state = json!({
840            "self": {
841                "endpoints": [
842                    {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
843                ]
844            },
845            "peers": {
846                "alice": {
847                    "endpoints": [
848                        {"relay_url": "https://wireup.net",    "slot_id": "a-fed",  "slot_token": "ta1", "scope": "federation"},
849                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
850                    ]
851                }
852            }
853        });
854        let eps = peer_endpoints_in_priority_order(&state, "alice");
855        // Only federation reachable: local was filtered.
856        assert_eq!(eps.len(), 1);
857        assert_eq!(eps[0].scope, EndpointScope::Federation);
858    }
859
860    #[test]
861    fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
862        let state = json!({
863            "self": {
864                "endpoints": [
865                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
866                ]
867            },
868            "peers": {
869                "alice": {
870                    "endpoints": [
871                        {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
872                    ]
873                }
874            }
875        });
876        // Our local is :8771, peer's local is :9999 — can't route there.
877        let eps = peer_endpoints_in_priority_order(&state, "alice");
878        assert_eq!(
879            eps.len(),
880            0,
881            "different local relays cannot reach each other"
882        );
883    }
884
885    #[test]
886    fn pin_then_resolve_round_trips_through_endpoints_array() {
887        // RFC-006 Part B: pin writes endpoints[]; routing resolves from it
888        // (priority order), with NO flat fields involved on either side.
889        let mut state = json!({"peers": {}});
890        let endpoints = vec![
891            Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
892            Endpoint::local(
893                "http://127.0.0.1:8771".into(),
894                "loop".into(),
895                "loop-tok".into(),
896            ),
897        ];
898        pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
899        let alice = &state["peers"]["alice"];
900        assert!(alice.get("relay_url").is_none(), "no flat fields written");
901        assert_eq!(alice["endpoints"].as_array().map(Vec::len), Some(2));
902        // Resolve from the array (no flat). Without a matching self-local relay,
903        // the peer's loopback endpoint isn't reachable for us, so federation is
904        // the primary route.
905        // Priority order drops the unreachable loopback (no matching
906        // self-local), leaving the federation route.
907        let ordered = peer_endpoints_in_priority_order(&state, "alice");
908        assert_eq!(ordered.len(), 1, "only the reachable federation route");
909        let primary = peer_primary_endpoint(&state, "alice").unwrap();
910        assert_eq!(primary.scope, EndpointScope::Federation);
911        assert_eq!(primary.slot_id, "abc");
912    }
913
914    #[test]
915    fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
916        let state = json!({
917            "self": {
918                "relay_url": "https://wireup.net",
919                "slot_id": "self-fed",
920                "slot_token": "t1"
921            }
922        });
923        let eps = self_endpoints(&state);
924        assert_eq!(eps.len(), 1);
925        assert_eq!(eps[0].scope, EndpointScope::Federation);
926        assert_eq!(eps[0].slot_id, "self-fed");
927    }
928
929    #[test]
930    fn self_endpoints_returns_both_when_dual_slot() {
931        let state = json!({
932            "self": {
933                "endpoints": [
934                    {"relay_url": "https://wireup.net",    "slot_id": "self-fed",  "slot_token": "t1", "scope": "federation"},
935                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
936                ]
937            }
938        });
939        let eps = self_endpoints(&state);
940        assert_eq!(eps.len(), 2);
941    }
942
943    // ── RFC-007 D3.4: per-peer Nostr transport reachability ──
944    #[test]
945    fn nostr_transport_roundtrips() {
946        let mut state = serde_json::json!({});
947        let npub = "a".repeat(64);
948        pin_peer_nostr_transport(&mut state, "raven-kettle", &npub, "wss://relay.damus.io")
949            .unwrap();
950        assert_eq!(
951            peer_nostr_transport(&state, "raven-kettle"),
952            Some((npub.clone(), "wss://relay.damus.io".to_string()))
953        );
954        // Unknown peer → None.
955        assert_eq!(peer_nostr_transport(&state, "nobody"), None);
956    }
957
958    #[test]
959    fn nostr_transport_survives_endpoint_repin() {
960        // The #162 hazard: a later HTTP-endpoint pin must NOT wipe the recorded
961        // Nostr transport (it's monotonic reachability state).
962        let mut state = serde_json::json!({});
963        let npub = "b".repeat(64);
964        pin_peer_nostr_transport(&mut state, "p", &npub, "wss://nos.lol").unwrap();
965        pin_peer_endpoints(
966            &mut state,
967            "p",
968            &[Endpoint::federation(
969                "https://wireup.net".into(),
970                "slot1".into(),
971                "tok1".into(),
972            )],
973        )
974        .unwrap();
975        // Both survive.
976        assert_eq!(
977            peer_nostr_transport(&state, "p"),
978            Some((npub, "wss://nos.lol".to_string()))
979        );
980        assert_eq!(peer_endpoints_in_priority_order(&state, "p").len(), 1);
981    }
982
983    #[test]
984    fn nostr_transport_is_idempotent_and_updatable() {
985        let mut state = serde_json::json!({});
986        pin_peer_nostr_transport(&mut state, "p", &"c".repeat(64), "wss://a").unwrap();
987        // Re-record with a new relay → updates.
988        pin_peer_nostr_transport(&mut state, "p", &"c".repeat(64), "wss://b").unwrap();
989        assert_eq!(
990            peer_nostr_transport(&state, "p"),
991            Some(("c".repeat(64), "wss://b".to_string()))
992        );
993    }
994}