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` is purely additive:
21//!
22//! ```jsonc
23//! {
24//!   "self": {
25//!     "relay_url": "https://wireup.net",     // legacy federation pointer
26//!     "slot_id":   "abc...",
27//!     "slot_token":"...",
28//!     "endpoints": [                          // v0.5.17 additive
29//!       {"relay_url": "https://wireup.net",     "slot_id": "abc...",  "slot_token": "...", "scope": "federation"},
30//!       {"relay_url": "http://127.0.0.1:8771",  "slot_id": "loop...", "slot_token": "...", "scope": "local"}
31//!     ]
32//!   },
33//!   "peers": {
34//!     "wire-mesh": {
35//!       "relay_url": "https://wireup.net",   // legacy back-compat
36//!       "slot_id":   "...",
37//!       "slot_token":"...",
38//!       "endpoints": [...]                    // v0.5.17 additive
39//!     }
40//!   }
41//! }
42//! ```
43
44use anyhow::Result;
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47
48/// Where this endpoint sits in the reachability graph.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum EndpointScope {
52    /// Public-facing relay (e.g. `https://wireup.net`). Crosses machines.
53    Federation,
54    /// Loopback-only relay (e.g. `http://127.0.0.1:8771`). Same-machine only.
55    Local,
56    /// LAN-bound relay (e.g. `http://192.168.1.50:8771`). Reachable from
57    /// other machines on the same network without going through federation.
58    /// v0.7.0-alpha.9: third scope for noble-creek-on-paul-mac ↔
59    /// running-light-on-spark style across-the-room pairing without
60    /// wireup.net hop. Visible to anyone who fetches the agent-card —
61    /// opt-in per session (operator passes `--with-lan-relay <url>` at
62    /// `wire session new` time).
63    Lan,
64    /// Unix Domain Socket (e.g. `unix:///path/to/local.sock`). Same-host,
65    /// same-uid only. v0.7.0-alpha.16: framed primarily as a SECURITY
66    /// boundary — no bound TCP port (no firewall surface), SO_PEERCRED
67    /// kernel-attested peer uid (sister-session trust anchor), 0600
68    /// socket permissions. Performance win over loopback HTTP is real
69    /// but tiny (~1.3µs) and not the headline reason. Opt-in via
70    /// `wire session new --with-uds`; Unix-only (Windows falls back to
71    /// Local loopback).
72    Uds,
73}
74
75/// One reachable address for a wire identity. Includes the bearer
76/// `slot_token` because endpoints flow through the pair_drop body,
77/// which is encrypted at protocol level (signed envelope + bilateral
78/// pin gate from v0.5.14). Token is the slot's bearer credential; it
79/// MUST stay private to the pair and is never published in the agent
80/// card or phonebook.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Endpoint {
83    pub relay_url: String,
84    pub slot_id: String,
85    pub slot_token: String,
86    pub scope: EndpointScope,
87}
88
89impl Endpoint {
90    pub fn federation(relay_url: String, slot_id: String, slot_token: String) -> Self {
91        Self {
92            relay_url,
93            slot_id,
94            slot_token,
95            scope: EndpointScope::Federation,
96        }
97    }
98
99    pub fn local(relay_url: String, slot_id: String, slot_token: String) -> Self {
100        Self {
101            relay_url,
102            slot_id,
103            slot_token,
104            scope: EndpointScope::Local,
105        }
106    }
107
108    /// v0.7.0-alpha.9: construct a LAN-scope endpoint.
109    pub fn lan(relay_url: String, slot_id: String, slot_token: String) -> Self {
110        Self {
111            relay_url,
112            slot_id,
113            slot_token,
114            scope: EndpointScope::Lan,
115        }
116    }
117
118    /// v0.7.0-alpha.16: construct a UDS-scope endpoint.
119    /// `relay_url` is a `unix:///abs/path/to/local.sock` URL (the
120    /// `unix://` scheme is wire-internal; readers route to a UDS HTTP
121    /// client rather than reqwest).
122    pub fn uds(relay_url: String, slot_id: String, slot_token: String) -> Self {
123        Self {
124            relay_url,
125            slot_id,
126            slot_token,
127            scope: EndpointScope::Uds,
128        }
129    }
130}
131
132/// Read all of a peer's pinned endpoints from `relay_state.json`,
133/// sorted in routing priority order:
134///
135/// 1. Local endpoints first — only when we ALSO have a local slot
136///    (i.e. our `self.endpoints` includes a local one with the same
137///    relay_url). Otherwise local endpoints are skipped because we
138///    can't reach them.
139/// 2. Federation endpoints second.
140///
141/// Back-compat: peers stored by v0.5.16 or earlier have only the
142/// top-level `relay_url`/`slot_id`/`slot_token`; this falls back to
143/// synthesizing a single federation `Endpoint` from those fields.
144pub fn peer_endpoints_in_priority_order(relay_state: &Value, peer_handle: &str) -> Vec<Endpoint> {
145    let our_local_relay_url = relay_state
146        .get("self")
147        .and_then(|s| s.get("endpoints"))
148        .and_then(Value::as_array)
149        .and_then(|arr| {
150            arr.iter()
151                .find(|e| e.get("scope").and_then(Value::as_str) == Some("local"))
152                .and_then(|e| e.get("relay_url"))
153                .and_then(Value::as_str)
154                .map(str::to_string)
155        });
156
157    let peer = match relay_state.get("peers").and_then(|p| p.get(peer_handle)) {
158        Some(p) => p,
159        None => return Vec::new(),
160    };
161
162    let mut all: Vec<Endpoint> = Vec::new();
163
164    if let Some(arr) = peer.get("endpoints").and_then(Value::as_array) {
165        for ep in arr {
166            if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
167                all.push(parsed);
168            }
169        }
170    }
171
172    // Back-compat: peer was pinned by v0.5.16 or earlier and has no
173    // `endpoints` array, just the top-level legacy fields. Synthesize
174    // one federation Endpoint from them so routing still finds a path.
175    if all.is_empty() {
176        let relay_url = peer.get("relay_url").and_then(Value::as_str).unwrap_or("");
177        let slot_id = peer.get("slot_id").and_then(Value::as_str).unwrap_or("");
178        let slot_token = peer.get("slot_token").and_then(Value::as_str).unwrap_or("");
179        if !relay_url.is_empty() && !slot_id.is_empty() && !slot_token.is_empty() {
180            all.push(Endpoint::federation(
181                relay_url.to_string(),
182                slot_id.to_string(),
183                slot_token.to_string(),
184            ));
185        }
186    }
187
188    // Sort: UDS (same-host trust anchor) first, then local-loopback-
189    // with-matching-self-local, then LAN (cross-machine same-network),
190    // then federation. Drop unreachable scopes via the retain pass.
191    //
192    // v0.7.0-alpha.9: LAN endpoints sit between Local and Federation.
193    // Faster than federation; not gated by "our_local matches" because
194    // cross-machine peers won't have a matching our-local by definition.
195    //
196    // v0.7.0-alpha.16: UDS endpoints get rank 0 when peer + self share
197    // a UDS socket path (we need to be able to connect to their socket
198    // which means it must be readable by our uid). The "same-uid same-
199    // host" sister-session trust shape this enforces is the whole
200    // point of UDS — see project_wire_transport_substrate_research.
201    let our_local = our_local_relay_url.clone();
202    all.sort_by_key(|ep| match (ep.scope, &our_local) {
203        (EndpointScope::Uds, _) => 0,
204        (EndpointScope::Local, Some(our)) if &ep.relay_url == our => 1,
205        (EndpointScope::Lan, _) => 2,
206        (EndpointScope::Federation, _) => 3,
207        _ => 4,
208    });
209    // Drop unreachable: Local needs matching loopback URL; UDS needs
210    // the socket file to exist on our filesystem (the daemon-side
211    // connect will surface a clearer error than a routing-time drop
212    // would, but we still keep UDS in the routing list — failure
213    // falls through to lower-priority scopes).
214    all.retain(|ep| match (ep.scope, &our_local) {
215        (EndpointScope::Local, None) => false,
216        (EndpointScope::Local, Some(our)) => &ep.relay_url == our,
217        (EndpointScope::Lan, _) => true,
218        (EndpointScope::Uds, _) => true,
219        (EndpointScope::Federation, _) => true,
220    });
221    all
222}
223
224/// All of OUR own endpoints from `relay_state.json`. Used by `cmd_push`
225/// to find the local slot when routing local-first, and by the daemon's
226/// pull loop to iterate every slot we should be reading from.
227pub fn self_endpoints(relay_state: &Value) -> Vec<Endpoint> {
228    let self_state = match relay_state.get("self") {
229        Some(s) if !s.is_null() => s,
230        _ => return Vec::new(),
231    };
232    let mut all: Vec<Endpoint> = Vec::new();
233    if let Some(arr) = self_state.get("endpoints").and_then(Value::as_array) {
234        for ep in arr {
235            if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
236                all.push(parsed);
237            }
238        }
239    }
240    if all.is_empty() {
241        // Back-compat: synthesize a federation endpoint from legacy
242        // top-level fields. Slot_token may be absent in some old
243        // states; in that case the synthesized endpoint is partial
244        // and downstream code must guard against empty token.
245        let relay_url = self_state
246            .get("relay_url")
247            .and_then(Value::as_str)
248            .unwrap_or("");
249        let slot_id = self_state
250            .get("slot_id")
251            .and_then(Value::as_str)
252            .unwrap_or("");
253        let slot_token = self_state
254            .get("slot_token")
255            .and_then(Value::as_str)
256            .unwrap_or("");
257        if !relay_url.is_empty() && !slot_id.is_empty() {
258            all.push(Endpoint::federation(
259                relay_url.to_string(),
260                slot_id.to_string(),
261                slot_token.to_string(),
262            ));
263        }
264    }
265    all
266}
267
268/// v0.9 canonical single-reader for "my best inbound slot." Returns
269/// the first endpoint from `self_endpoints()` — which is already
270/// priority-ordered (UDS → Local-with-matching-self → LAN →
271/// Federation) AND back-compat-falls-back to legacy top-level fields.
272///
273/// Replaces ad-hoc `self_state["relay_url"].as_str()` reads scattered
274/// through the codebase. Pre-v0.9 those bare reads were the silent-
275/// fail root cause: a session with only `self.endpoints[]` (no legacy
276/// top-level fields) returned empty strings instead of the available
277/// endpoint, and pair_drop_ack / pull / rotate-slot all silently
278/// no-op'd. Always use this from new code.
279pub fn self_primary_endpoint(relay_state: &Value) -> Option<Endpoint> {
280    self_endpoints(relay_state).into_iter().next()
281}
282
283/// Pin a peer's full set of endpoints into `relay_state.json` under
284/// `peers[handle]`. Preserves the v0.5.16-and-earlier `relay_url` /
285/// `slot_id` / `slot_token` top-level fields (pointing at the
286/// federation endpoint) so older code paths and back-compat readers
287/// don't break. The new `endpoints` array is additive.
288pub fn pin_peer_endpoints(
289    relay_state: &mut Value,
290    peer_handle: &str,
291    endpoints: &[Endpoint],
292) -> Result<()> {
293    // Pick the federation endpoint (if any) to fill the legacy fields.
294    // v0.7.0-alpha.9: when no federation present, prefer LAN over Local
295    // for the legacy fields — LAN is cross-machine-reachable.
296    let fed = endpoints
297        .iter()
298        .find(|e| e.scope == EndpointScope::Federation);
299    let peers = relay_state
300        .as_object_mut()
301        .map(|m| {
302            m.entry("peers")
303                .or_insert_with(|| Value::Object(Default::default()))
304        })
305        .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
306        .as_object_mut()
307        .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
308    // v0.14.2 (#162 fix #5): preserve durable peer state across re-pin
309    // events. honey-pine observed `wire_peers` tier flapping
310    // VERIFIED → PENDING_ACK; root cause is this `peers.insert(.., entry)`
311    // wholesale-replacement losing any previously-set field. The fields
312    // we explicitly retain here represent monotonic state — once
313    // bilateral-pair is complete or the peer's published persona/profile
314    // is known, those facts must NOT be wiped just because a fresh
315    // pair_drop_ack carrying only endpoint data lands. Other fields
316    // (`relay_url`, `slot_id`, `slot_token`, `endpoints`) are always
317    // current-state and intentionally re-derived from the input below.
318    let preserved: serde_json::Map<String, Value> = peers
319        .get(peer_handle)
320        .and_then(Value::as_object)
321        .map(|m| {
322            m.iter()
323                .filter(|(k, _)| {
324                    matches!(
325                        k.as_str(),
326                        "bilateral_completed_at" | "persona" | "profile" | "first_seen_at"
327                    )
328                })
329                .map(|(k, v)| (k.clone(), v.clone()))
330                .collect()
331        })
332        .unwrap_or_default();
333    let mut entry = preserved;
334    if let Some(f) = fed {
335        entry.insert("relay_url".into(), Value::String(f.relay_url.clone()));
336        entry.insert("slot_id".into(), Value::String(f.slot_id.clone()));
337        entry.insert("slot_token".into(), Value::String(f.slot_token.clone()));
338    } else if let Some(lan_ep) = endpoints.iter().find(|e| e.scope == EndpointScope::Lan) {
339        entry.insert("relay_url".into(), Value::String(lan_ep.relay_url.clone()));
340        entry.insert("slot_id".into(), Value::String(lan_ep.slot_id.clone()));
341        entry.insert(
342            "slot_token".into(),
343            Value::String(lan_ep.slot_token.clone()),
344        );
345    } else if let Some(loc) = endpoints.iter().find(|e| e.scope == EndpointScope::Local) {
346        // No federation, no LAN? Local is the only option. Unusual
347        // (peer would only be reachable from same loopback), but keeps
348        // schema invariant intact.
349        entry.insert("relay_url".into(), Value::String(loc.relay_url.clone()));
350        entry.insert("slot_id".into(), Value::String(loc.slot_id.clone()));
351        entry.insert("slot_token".into(), Value::String(loc.slot_token.clone()));
352    }
353    entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
354    peers.insert(peer_handle.to_string(), Value::Object(entry));
355    Ok(())
356}
357
358/// Infer an endpoint scope from a relay URL: `unix://` -> Uds, a loopback
359/// host -> Local, otherwise Federation. LAN is never inferred (a private-
360/// range IP is indistinguishable from a federation host by URL alone) and
361/// must be requested explicitly.
362pub fn infer_scope_from_url(url: &str) -> EndpointScope {
363    if url.starts_with("unix://") {
364        return EndpointScope::Uds;
365    }
366    let host = url
367        .trim_start_matches("http://")
368        .trim_start_matches("https://")
369        .split('/')
370        .next()
371        .unwrap_or("")
372        .split(':')
373        .next()
374        .unwrap_or("");
375    if host == "127.0.0.1" || host == "localhost" || host == "::1" {
376        EndpointScope::Local
377    } else {
378        EndpointScope::Federation
379    }
380}
381
382/// Build the `self` block for `relay_state.json` from an endpoint set:
383/// the additive `endpoints[]` array plus legacy top-level
384/// relay_url/slot_id/slot_token pointing at the federation endpoint (or,
385/// absent one, the first endpoint) for v0.5.16-and-earlier back-compat.
386fn build_self_value(eps: &[Endpoint]) -> Value {
387    let legacy = eps
388        .iter()
389        .find(|e| e.scope == EndpointScope::Federation)
390        .or_else(|| eps.first());
391    let mut self_obj = serde_json::Map::new();
392    if let Some(l) = legacy {
393        self_obj.insert("relay_url".into(), Value::String(l.relay_url.clone()));
394        self_obj.insert("slot_id".into(), Value::String(l.slot_id.clone()));
395        self_obj.insert("slot_token".into(), Value::String(l.slot_token.clone()));
396    }
397    self_obj.insert(
398        "endpoints".into(),
399        serde_json::to_value(eps).unwrap_or(Value::Null),
400    );
401    Value::Object(self_obj)
402}
403
404/// Insert-or-replace one of OUR OWN endpoints in `relay_state["self"]`,
405/// keyed by `relay_url` (re-binding the same relay updates it in place).
406/// ADDITIVE: every other existing self endpoint is preserved, so an agent
407/// can hold a local relay AND a federation relay at once. Rebuilds the
408/// legacy top-level fields. Single source of truth for the self-slot write
409/// shape — used by `cmd_bind_relay` and `init_self_idempotent`.
410pub fn upsert_self_endpoint(relay_state: &mut Value, ep: Endpoint) {
411    let mut eps = self_endpoints(relay_state);
412    eps.retain(|e| e.relay_url != ep.relay_url);
413    eps.push(ep);
414    relay_state["self"] = build_self_value(&eps);
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use serde_json::json;
421
422    #[test]
423    fn infer_scope_classifies_loopback_unix_and_federation() {
424        assert_eq!(
425            infer_scope_from_url("http://127.0.0.1:8771"),
426            EndpointScope::Local
427        );
428        assert_eq!(
429            infer_scope_from_url("http://localhost:8771"),
430            EndpointScope::Local
431        );
432        assert_eq!(
433            infer_scope_from_url("unix:///tmp/wire.sock"),
434            EndpointScope::Uds
435        );
436        assert_eq!(
437            infer_scope_from_url("https://wireup.net"),
438            EndpointScope::Federation
439        );
440    }
441
442    #[test]
443    fn upsert_self_endpoint_is_additive_then_updates_in_place() {
444        let mut state = json!({});
445        upsert_self_endpoint(
446            &mut state,
447            Endpoint::federation("https://wireup.net".into(), "fed1".into(), "ft".into()),
448        );
449        upsert_self_endpoint(
450            &mut state,
451            Endpoint::local("http://127.0.0.1:8771".into(), "loc1".into(), "lt".into()),
452        );
453        // Both kept.
454        assert_eq!(self_endpoints(&state).len(), 2);
455        // Legacy fields point at federation.
456        assert_eq!(state["self"]["relay_url"], "https://wireup.net");
457        // Re-binding the same relay replaces that one entry, not appends.
458        upsert_self_endpoint(
459            &mut state,
460            Endpoint::local("http://127.0.0.1:8771".into(), "loc2".into(), "lt2".into()),
461        );
462        let eps = self_endpoints(&state);
463        assert_eq!(eps.len(), 2, "same-relay rebind replaces, not appends");
464        let loc = eps
465            .iter()
466            .find(|e| e.scope == EndpointScope::Local)
467            .unwrap();
468        assert_eq!(loc.slot_id, "loc2", "local slot updated in place");
469    }
470
471    #[test]
472    fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
473        let state = json!({
474            "peers": {
475                "alice": {
476                    "relay_url": "https://wireup.net",
477                    "slot_id": "abc",
478                    "slot_token": "tok"
479                }
480            }
481        });
482        let eps = peer_endpoints_in_priority_order(&state, "alice");
483        assert_eq!(eps.len(), 1);
484        assert_eq!(eps[0].relay_url, "https://wireup.net");
485        assert_eq!(eps[0].scope, EndpointScope::Federation);
486    }
487
488    #[test]
489    fn peer_endpoints_lan_beats_federation() {
490        // v0.7.0-alpha.9: when a peer publishes both Lan and Federation
491        // endpoints (and we have a matching local too), priority must be
492        // Local(matched) > Lan > Federation. Lan is cross-machine same-
493        // network, faster than federation but not as fast as loopback.
494        let state = json!({
495            "self": {
496                "endpoints": [
497                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
498                    {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
499                ]
500            },
501            "peers": {
502                "alice": {
503                    "endpoints": [
504                        {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
505                        {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
506                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
507                    ]
508                }
509            }
510        });
511        let eps = peer_endpoints_in_priority_order(&state, "alice");
512        assert_eq!(
513            eps.len(),
514            3,
515            "Local(matched) + Lan + Federation all reachable"
516        );
517        assert_eq!(
518            eps[0].scope,
519            EndpointScope::Local,
520            "loopback wins (same-machine)"
521        );
522        assert_eq!(
523            eps[1].scope,
524            EndpointScope::Lan,
525            "Lan second (same-network)"
526        );
527        assert_eq!(
528            eps[2].scope,
529            EndpointScope::Federation,
530            "Federation last (anywhere)"
531        );
532    }
533
534    #[test]
535    fn peer_endpoints_lan_kept_when_self_has_no_local() {
536        // Cross-machine peer scenario: we have no Local, peer has Lan
537        // and Federation. Lan must still be kept (we connect TO their
538        // LAN address; we don't need a Local of our own to do so).
539        let state = json!({
540            "self": {
541                "endpoints": [
542                    {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
543                ]
544            },
545            "peers": {
546                "alice": {
547                    "endpoints": [
548                        {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
549                        {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
550                    ]
551                }
552            }
553        });
554        let eps = peer_endpoints_in_priority_order(&state, "alice");
555        assert_eq!(eps.len(), 2);
556        assert_eq!(
557            eps[0].scope,
558            EndpointScope::Lan,
559            "Lan preferred over Federation"
560        );
561        assert_eq!(eps[1].scope, EndpointScope::Federation);
562    }
563
564    #[test]
565    fn pin_peer_endpoints_uses_lan_as_legacy_when_no_federation() {
566        // Backward compat: when peer has no federation endpoint but has
567        // a LAN one, the legacy top-level relay_url/slot_id/slot_token
568        // should point at the LAN address (since LAN is cross-machine
569        // reachable; Local loopback wouldn't be).
570        let mut state = json!({});
571        let endpoints = vec![
572            Endpoint::lan(
573                "http://192.168.1.50:8771".to_string(),
574                "lan-slot".to_string(),
575                "lan-tok".to_string(),
576            ),
577            Endpoint::local(
578                "http://127.0.0.1:8771".to_string(),
579                "loop-slot".to_string(),
580                "loop-tok".to_string(),
581            ),
582        ];
583        pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
584        let alice = &state["peers"]["alice"];
585        assert_eq!(
586            alice["relay_url"], "http://192.168.1.50:8771",
587            "LAN wins legacy fields"
588        );
589        assert_eq!(alice["slot_id"], "lan-slot");
590    }
591
592    #[test]
593    fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
594        let state = json!({
595            "self": {
596                "endpoints": [
597                    {"relay_url": "https://wireup.net",    "slot_id": "self-fed",  "slot_token": "t1", "scope": "federation"},
598                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
599                ]
600            },
601            "peers": {
602                "alice": {
603                    "endpoints": [
604                        {"relay_url": "https://wireup.net",    "slot_id": "a-fed",  "slot_token": "ta1", "scope": "federation"},
605                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
606                    ]
607                }
608            }
609        });
610        let eps = peer_endpoints_in_priority_order(&state, "alice");
611        assert_eq!(eps.len(), 2);
612        assert_eq!(eps[0].scope, EndpointScope::Local);
613        assert_eq!(eps[1].scope, EndpointScope::Federation);
614    }
615
616    #[test]
617    fn peer_endpoints_drops_local_when_self_has_no_local() {
618        let state = json!({
619            "self": {
620                "endpoints": [
621                    {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
622                ]
623            },
624            "peers": {
625                "alice": {
626                    "endpoints": [
627                        {"relay_url": "https://wireup.net",    "slot_id": "a-fed",  "slot_token": "ta1", "scope": "federation"},
628                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
629                    ]
630                }
631            }
632        });
633        let eps = peer_endpoints_in_priority_order(&state, "alice");
634        // Only federation reachable: local was filtered.
635        assert_eq!(eps.len(), 1);
636        assert_eq!(eps[0].scope, EndpointScope::Federation);
637    }
638
639    #[test]
640    fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
641        let state = json!({
642            "self": {
643                "endpoints": [
644                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
645                ]
646            },
647            "peers": {
648                "alice": {
649                    "endpoints": [
650                        {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
651                    ]
652                }
653            }
654        });
655        // Our local is :8771, peer's local is :9999 — can't route there.
656        let eps = peer_endpoints_in_priority_order(&state, "alice");
657        assert_eq!(
658            eps.len(),
659            0,
660            "different local relays cannot reach each other"
661        );
662    }
663
664    #[test]
665    fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
666        let mut state = json!({"peers": {}});
667        let endpoints = vec![
668            Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
669            Endpoint::local(
670                "http://127.0.0.1:8771".into(),
671                "loop".into(),
672                "loop-tok".into(),
673            ),
674        ];
675        pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
676        let alice = &state["peers"]["alice"];
677        // Legacy fields point at the federation endpoint.
678        assert_eq!(alice["relay_url"], "https://wireup.net");
679        assert_eq!(alice["slot_id"], "abc");
680        assert_eq!(alice["slot_token"], "tok");
681        // Endpoints array carries the full set.
682        let eps = alice["endpoints"].as_array().unwrap();
683        assert_eq!(eps.len(), 2);
684    }
685
686    #[test]
687    fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
688        let state = json!({
689            "self": {
690                "relay_url": "https://wireup.net",
691                "slot_id": "self-fed",
692                "slot_token": "t1"
693            }
694        });
695        let eps = self_endpoints(&state);
696        assert_eq!(eps.len(), 1);
697        assert_eq!(eps[0].scope, EndpointScope::Federation);
698        assert_eq!(eps[0].slot_id, "self-fed");
699    }
700
701    #[test]
702    fn self_endpoints_returns_both_when_dual_slot() {
703        let state = json!({
704            "self": {
705                "endpoints": [
706                    {"relay_url": "https://wireup.net",    "slot_id": "self-fed",  "slot_token": "t1", "scope": "federation"},
707                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
708                ]
709            }
710        });
711        let eps = self_endpoints(&state);
712        assert_eq!(eps.len(), 2);
713    }
714}