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}
57
58/// One reachable address for a wire identity. Includes the bearer
59/// `slot_token` because endpoints flow through the pair_drop body,
60/// which is encrypted at protocol level (signed envelope + bilateral
61/// pin gate from v0.5.14). Token is the slot's bearer credential; it
62/// MUST stay private to the pair and is never published in the agent
63/// card or phonebook.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Endpoint {
66    pub relay_url: String,
67    pub slot_id: String,
68    pub slot_token: String,
69    pub scope: EndpointScope,
70}
71
72impl Endpoint {
73    pub fn federation(relay_url: String, slot_id: String, slot_token: String) -> Self {
74        Self {
75            relay_url,
76            slot_id,
77            slot_token,
78            scope: EndpointScope::Federation,
79        }
80    }
81
82    pub fn local(relay_url: String, slot_id: String, slot_token: String) -> Self {
83        Self {
84            relay_url,
85            slot_id,
86            slot_token,
87            scope: EndpointScope::Local,
88        }
89    }
90}
91
92/// Read all of a peer's pinned endpoints from `relay_state.json`,
93/// sorted in routing priority order:
94///
95/// 1. Local endpoints first — only when we ALSO have a local slot
96///    (i.e. our `self.endpoints` includes a local one with the same
97///    relay_url). Otherwise local endpoints are skipped because we
98///    can't reach them.
99/// 2. Federation endpoints second.
100///
101/// Back-compat: peers stored by v0.5.16 or earlier have only the
102/// top-level `relay_url`/`slot_id`/`slot_token`; this falls back to
103/// synthesizing a single federation `Endpoint` from those fields.
104pub fn peer_endpoints_in_priority_order(
105    relay_state: &Value,
106    peer_handle: &str,
107) -> Vec<Endpoint> {
108    let our_local_relay_url = relay_state
109        .get("self")
110        .and_then(|s| s.get("endpoints"))
111        .and_then(Value::as_array)
112        .and_then(|arr| {
113            arr.iter()
114                .find(|e| e.get("scope").and_then(Value::as_str) == Some("local"))
115                .and_then(|e| e.get("relay_url"))
116                .and_then(Value::as_str)
117                .map(str::to_string)
118        });
119
120    let peer = match relay_state
121        .get("peers")
122        .and_then(|p| p.get(peer_handle))
123    {
124        Some(p) => p,
125        None => return Vec::new(),
126    };
127
128    let mut all: Vec<Endpoint> = Vec::new();
129
130    if let Some(arr) = peer.get("endpoints").and_then(Value::as_array) {
131        for ep in arr {
132            if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
133                all.push(parsed);
134            }
135        }
136    }
137
138    // Back-compat: peer was pinned by v0.5.16 or earlier and has no
139    // `endpoints` array, just the top-level legacy fields. Synthesize
140    // one federation Endpoint from them so routing still finds a path.
141    if all.is_empty() {
142        let relay_url = peer.get("relay_url").and_then(Value::as_str).unwrap_or("");
143        let slot_id = peer.get("slot_id").and_then(Value::as_str).unwrap_or("");
144        let slot_token = peer
145            .get("slot_token")
146            .and_then(Value::as_str)
147            .unwrap_or("");
148        if !relay_url.is_empty() && !slot_id.is_empty() && !slot_token.is_empty() {
149            all.push(Endpoint::federation(
150                relay_url.to_string(),
151                slot_id.to_string(),
152                slot_token.to_string(),
153            ));
154        }
155    }
156
157    // Sort: local-with-matching-self-local first, then federation,
158    // then any local we can't reach (filtered out by predicate).
159    let our_local = our_local_relay_url.clone();
160    all.sort_by_key(|ep| match (ep.scope, &our_local) {
161        (EndpointScope::Local, Some(our)) if &ep.relay_url == our => 0,
162        (EndpointScope::Federation, _) => 1,
163        _ => 2,
164    });
165    // Drop unreachable locals (we have no local slot or our local relay
166    // doesn't match the peer's local relay_url).
167    all.retain(|ep| match (ep.scope, &our_local) {
168        (EndpointScope::Local, None) => false,
169        (EndpointScope::Local, Some(our)) => &ep.relay_url == our,
170        (EndpointScope::Federation, _) => true,
171    });
172    all
173}
174
175/// All of OUR own endpoints from `relay_state.json`. Used by `cmd_push`
176/// to find the local slot when routing local-first, and by the daemon's
177/// pull loop to iterate every slot we should be reading from.
178pub fn self_endpoints(relay_state: &Value) -> Vec<Endpoint> {
179    let self_state = match relay_state.get("self") {
180        Some(s) if !s.is_null() => s,
181        _ => return Vec::new(),
182    };
183    let mut all: Vec<Endpoint> = Vec::new();
184    if let Some(arr) = self_state.get("endpoints").and_then(Value::as_array) {
185        for ep in arr {
186            if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
187                all.push(parsed);
188            }
189        }
190    }
191    if all.is_empty() {
192        // Back-compat: synthesize a federation endpoint from legacy
193        // top-level fields. Slot_token may be absent in some old
194        // states; in that case the synthesized endpoint is partial
195        // and downstream code must guard against empty token.
196        let relay_url = self_state
197            .get("relay_url")
198            .and_then(Value::as_str)
199            .unwrap_or("");
200        let slot_id = self_state.get("slot_id").and_then(Value::as_str).unwrap_or("");
201        let slot_token = self_state
202            .get("slot_token")
203            .and_then(Value::as_str)
204            .unwrap_or("");
205        if !relay_url.is_empty() && !slot_id.is_empty() {
206            all.push(Endpoint::federation(
207                relay_url.to_string(),
208                slot_id.to_string(),
209                slot_token.to_string(),
210            ));
211        }
212    }
213    all
214}
215
216/// Pin a peer's full set of endpoints into `relay_state.json` under
217/// `peers[handle]`. Preserves the v0.5.16-and-earlier `relay_url` /
218/// `slot_id` / `slot_token` top-level fields (pointing at the
219/// federation endpoint) so older code paths and back-compat readers
220/// don't break. The new `endpoints` array is additive.
221pub fn pin_peer_endpoints(
222    relay_state: &mut Value,
223    peer_handle: &str,
224    endpoints: &[Endpoint],
225) -> Result<()> {
226    // Pick the federation endpoint (if any) to fill the legacy fields.
227    let fed = endpoints
228        .iter()
229        .find(|e| e.scope == EndpointScope::Federation);
230    let peers = relay_state
231        .as_object_mut()
232        .map(|m| {
233            m.entry("peers")
234                .or_insert_with(|| Value::Object(Default::default()))
235        })
236        .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
237        .as_object_mut()
238        .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
239    let mut entry = serde_json::Map::new();
240    if let Some(f) = fed {
241        entry.insert("relay_url".into(), Value::String(f.relay_url.clone()));
242        entry.insert("slot_id".into(), Value::String(f.slot_id.clone()));
243        entry.insert("slot_token".into(), Value::String(f.slot_token.clone()));
244    } else if let Some(loc) = endpoints
245        .iter()
246        .find(|e| e.scope == EndpointScope::Local)
247    {
248        // No federation endpoint? Use the local one as the legacy field
249        // values. This case is unusual (peer would be unreachable from
250        // other machines), but keeps the schema invariant intact.
251        entry.insert("relay_url".into(), Value::String(loc.relay_url.clone()));
252        entry.insert("slot_id".into(), Value::String(loc.slot_id.clone()));
253        entry.insert("slot_token".into(), Value::String(loc.slot_token.clone()));
254    }
255    entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
256    peers.insert(peer_handle.to_string(), Value::Object(entry));
257    Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264
265    #[test]
266    fn peer_endpoints_back_compat_falls_back_to_legacy_fields() {
267        let state = json!({
268            "peers": {
269                "alice": {
270                    "relay_url": "https://wireup.net",
271                    "slot_id": "abc",
272                    "slot_token": "tok"
273                }
274            }
275        });
276        let eps = peer_endpoints_in_priority_order(&state, "alice");
277        assert_eq!(eps.len(), 1);
278        assert_eq!(eps[0].relay_url, "https://wireup.net");
279        assert_eq!(eps[0].scope, EndpointScope::Federation);
280    }
281
282    #[test]
283    fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
284        let state = json!({
285            "self": {
286                "endpoints": [
287                    {"relay_url": "https://wireup.net",    "slot_id": "self-fed",  "slot_token": "t1", "scope": "federation"},
288                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
289                ]
290            },
291            "peers": {
292                "alice": {
293                    "endpoints": [
294                        {"relay_url": "https://wireup.net",    "slot_id": "a-fed",  "slot_token": "ta1", "scope": "federation"},
295                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
296                    ]
297                }
298            }
299        });
300        let eps = peer_endpoints_in_priority_order(&state, "alice");
301        assert_eq!(eps.len(), 2);
302        assert_eq!(eps[0].scope, EndpointScope::Local);
303        assert_eq!(eps[1].scope, EndpointScope::Federation);
304    }
305
306    #[test]
307    fn peer_endpoints_drops_local_when_self_has_no_local() {
308        let state = json!({
309            "self": {
310                "endpoints": [
311                    {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
312                ]
313            },
314            "peers": {
315                "alice": {
316                    "endpoints": [
317                        {"relay_url": "https://wireup.net",    "slot_id": "a-fed",  "slot_token": "ta1", "scope": "federation"},
318                        {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
319                    ]
320                }
321            }
322        });
323        let eps = peer_endpoints_in_priority_order(&state, "alice");
324        // Only federation reachable: local was filtered.
325        assert_eq!(eps.len(), 1);
326        assert_eq!(eps[0].scope, EndpointScope::Federation);
327    }
328
329    #[test]
330    fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
331        let state = json!({
332            "self": {
333                "endpoints": [
334                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
335                ]
336            },
337            "peers": {
338                "alice": {
339                    "endpoints": [
340                        {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
341                    ]
342                }
343            }
344        });
345        // Our local is :8771, peer's local is :9999 — can't route there.
346        let eps = peer_endpoints_in_priority_order(&state, "alice");
347        assert_eq!(eps.len(), 0, "different local relays cannot reach each other");
348    }
349
350    #[test]
351    fn pin_peer_endpoints_preserves_legacy_top_level_fields() {
352        let mut state = json!({"peers": {}});
353        let endpoints = vec![
354            Endpoint::federation(
355                "https://wireup.net".into(),
356                "abc".into(),
357                "tok".into(),
358            ),
359            Endpoint::local(
360                "http://127.0.0.1:8771".into(),
361                "loop".into(),
362                "loop-tok".into(),
363            ),
364        ];
365        pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
366        let alice = &state["peers"]["alice"];
367        // Legacy fields point at the federation endpoint.
368        assert_eq!(alice["relay_url"], "https://wireup.net");
369        assert_eq!(alice["slot_id"], "abc");
370        assert_eq!(alice["slot_token"], "tok");
371        // Endpoints array carries the full set.
372        let eps = alice["endpoints"].as_array().unwrap();
373        assert_eq!(eps.len(), 2);
374    }
375
376    #[test]
377    fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
378        let state = json!({
379            "self": {
380                "relay_url": "https://wireup.net",
381                "slot_id": "self-fed",
382                "slot_token": "t1"
383            }
384        });
385        let eps = self_endpoints(&state);
386        assert_eq!(eps.len(), 1);
387        assert_eq!(eps[0].scope, EndpointScope::Federation);
388        assert_eq!(eps[0].slot_id, "self-fed");
389    }
390
391    #[test]
392    fn self_endpoints_returns_both_when_dual_slot() {
393        let state = json!({
394            "self": {
395                "endpoints": [
396                    {"relay_url": "https://wireup.net",    "slot_id": "self-fed",  "slot_token": "t1", "scope": "federation"},
397                    {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
398                ]
399            }
400        });
401        let eps = self_endpoints(&state);
402        assert_eq!(eps.len(), 2);
403    }
404}