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 // RFC-006 Part B: `endpoints[]` is the only peer-routing source. The
173 // former flat-field synthesis fallback (for pre-v0.5.16 pins with no
174 // `endpoints` array) is gone — every pin now carries `endpoints[]`
175 // (`pin_peer_endpoints` writes it; invite-accept routes through it too).
176
177 // Sort: UDS (same-host trust anchor) first, then local-loopback-
178 // with-matching-self-local, then LAN (cross-machine same-network),
179 // then federation. Drop unreachable scopes via the retain pass.
180 //
181 // v0.7.0-alpha.9: LAN endpoints sit between Local and Federation.
182 // Faster than federation; not gated by "our_local matches" because
183 // cross-machine peers won't have a matching our-local by definition.
184 //
185 // v0.7.0-alpha.16: UDS endpoints get rank 0 when peer + self share
186 // a UDS socket path (we need to be able to connect to their socket
187 // which means it must be readable by our uid). The "same-uid same-
188 // host" sister-session trust shape this enforces is the whole
189 // point of UDS — see project_wire_transport_substrate_research.
190 let our_local = our_local_relay_url.clone();
191 all.sort_by_key(|ep| match (ep.scope, &our_local) {
192 (EndpointScope::Uds, _) => 0,
193 (EndpointScope::Local, Some(our)) if &ep.relay_url == our => 1,
194 (EndpointScope::Lan, _) => 2,
195 (EndpointScope::Federation, _) => 3,
196 _ => 4,
197 });
198 // Drop unreachable: Local needs matching loopback URL; UDS needs
199 // the socket file to exist on our filesystem (the daemon-side
200 // connect will surface a clearer error than a routing-time drop
201 // would, but we still keep UDS in the routing list — failure
202 // falls through to lower-priority scopes).
203 all.retain(|ep| match (ep.scope, &our_local) {
204 (EndpointScope::Local, None) => false,
205 (EndpointScope::Local, Some(our)) => &ep.relay_url == our,
206 (EndpointScope::Lan, _) => true,
207 (EndpointScope::Uds, _) => true,
208 (EndpointScope::Federation, _) => true,
209 });
210 all
211}
212
213/// All of OUR own endpoints from `relay_state.json`. Used by `cmd_push`
214/// to find the local slot when routing local-first, and by the daemon's
215/// pull loop to iterate every slot we should be reading from.
216pub fn self_endpoints(relay_state: &Value) -> Vec<Endpoint> {
217 let self_state = match relay_state.get("self") {
218 Some(s) if !s.is_null() => s,
219 _ => return Vec::new(),
220 };
221 let mut all: Vec<Endpoint> = Vec::new();
222 if let Some(arr) = self_state.get("endpoints").and_then(Value::as_array) {
223 for ep in arr {
224 if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
225 all.push(parsed);
226 }
227 }
228 }
229 if all.is_empty() {
230 // Back-compat: synthesize a federation endpoint from legacy
231 // top-level fields. Slot_token may be absent in some old
232 // states; in that case the synthesized endpoint is partial
233 // and downstream code must guard against empty token.
234 let relay_url = self_state
235 .get("relay_url")
236 .and_then(Value::as_str)
237 .unwrap_or("");
238 let slot_id = self_state
239 .get("slot_id")
240 .and_then(Value::as_str)
241 .unwrap_or("");
242 let slot_token = self_state
243 .get("slot_token")
244 .and_then(Value::as_str)
245 .unwrap_or("");
246 if !relay_url.is_empty() && !slot_id.is_empty() {
247 all.push(Endpoint::federation(
248 relay_url.to_string(),
249 slot_id.to_string(),
250 slot_token.to_string(),
251 ));
252 }
253 }
254 all
255}
256
257/// v0.9 canonical single-reader for "my best inbound slot." Returns
258/// the first endpoint from `self_endpoints()` — which is already
259/// priority-ordered (UDS → Local-with-matching-self → LAN →
260/// Federation) AND back-compat-falls-back to legacy top-level fields.
261///
262/// Replaces ad-hoc `self_state["relay_url"].as_str()` reads scattered
263/// through the codebase. Pre-v0.9 those bare reads were the silent-
264/// fail root cause: a session with only `self.endpoints[]` (no legacy
265/// top-level fields) returned empty strings instead of the available
266/// endpoint, and pair_drop_ack / pull / rotate-slot all silently
267/// no-op'd. Always use this from new code.
268pub fn self_primary_endpoint(relay_state: &Value) -> Option<Endpoint> {
269 self_endpoints(relay_state).into_iter().next()
270}
271
272/// The single best (highest-priority) endpoint to reach `peer_handle`, or
273/// `None` if the peer has no pinned endpoints. RFC-006 Part B: the canonical
274/// replacement for reading the old flat `relay_url`/`slot_id`/`slot_token` peer
275/// fields — every peer-pin reader resolves through this (or
276/// `peer_endpoints_in_priority_order` when it needs failover).
277pub fn peer_primary_endpoint(relay_state: &Value, peer_handle: &str) -> Option<Endpoint> {
278 peer_endpoints_in_priority_order(relay_state, peer_handle)
279 .into_iter()
280 .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 let peers = relay_state
294 .as_object_mut()
295 .map(|m| {
296 m.entry("peers")
297 .or_insert_with(|| Value::Object(Default::default()))
298 })
299 .ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
300 .as_object_mut()
301 .ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
302 // v0.14.2 (#162 fix #5): preserve durable peer state across re-pin
303 // events. honey-pine observed `wire_peers` tier flapping
304 // VERIFIED → PENDING_ACK; root cause is this `peers.insert(.., entry)`
305 // wholesale-replacement losing any previously-set field. The fields
306 // we explicitly retain here represent monotonic state — once
307 // bilateral-pair is complete or the peer's published persona/profile
308 // is known, those facts must NOT be wiped just because a fresh
309 // pair_drop_ack carrying only endpoint data lands. Other fields
310 // (`relay_url`, `slot_id`, `slot_token`, `endpoints`) are always
311 // current-state and intentionally re-derived from the input below.
312 let preserved: serde_json::Map<String, Value> = peers
313 .get(peer_handle)
314 .and_then(Value::as_object)
315 .map(|m| {
316 m.iter()
317 .filter(|(k, _)| {
318 matches!(
319 k.as_str(),
320 "bilateral_completed_at" | "persona" | "profile" | "first_seen_at"
321 )
322 })
323 .map(|(k, v)| (k.clone(), v.clone()))
324 .collect()
325 })
326 .unwrap_or_default();
327 // RFC-006 Part B: `endpoints[]` is the SINGLE peer-routing source. The
328 // top-level flat `relay_url`/`slot_id`/`slot_token` fields are no longer
329 // written — they were a redundant synthesized copy (the "stale flat beats
330 // fresh array" routing hazard). All peer-pin readers now resolve through
331 // `peer_endpoints_in_priority_order`. (Self-slot flat is a separate
332 // representation, untouched here.)
333 let mut entry = preserved;
334 entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
335 peers.insert(peer_handle.to_string(), Value::Object(entry));
336 Ok(())
337}
338
339/// Infer an endpoint scope from a relay URL: `unix://` -> Uds, a loopback
340/// host -> Local, otherwise Federation. LAN is never inferred (a private-
341/// range IP is indistinguishable from a federation host by URL alone) and
342/// must be requested explicitly.
343pub fn infer_scope_from_url(url: &str) -> EndpointScope {
344 if url.starts_with("unix://") {
345 return EndpointScope::Uds;
346 }
347 let host = url
348 .trim_start_matches("http://")
349 .trim_start_matches("https://")
350 .split('/')
351 .next()
352 .unwrap_or("")
353 .split(':')
354 .next()
355 .unwrap_or("");
356 if host == "127.0.0.1" || host == "localhost" || host == "::1" {
357 EndpointScope::Local
358 } else {
359 EndpointScope::Federation
360 }
361}
362
363/// Build the `self` block for `relay_state.json` from an endpoint set:
364/// the additive `endpoints[]` array plus legacy top-level
365/// relay_url/slot_id/slot_token pointing at the federation endpoint (or,
366/// absent one, the first endpoint) for v0.5.16-and-earlier back-compat.
367fn build_self_value(eps: &[Endpoint]) -> Value {
368 let legacy = eps
369 .iter()
370 .find(|e| e.scope == EndpointScope::Federation)
371 .or_else(|| eps.first());
372 let mut self_obj = serde_json::Map::new();
373 if let Some(l) = legacy {
374 self_obj.insert("relay_url".into(), Value::String(l.relay_url.clone()));
375 self_obj.insert("slot_id".into(), Value::String(l.slot_id.clone()));
376 self_obj.insert("slot_token".into(), Value::String(l.slot_token.clone()));
377 }
378 self_obj.insert(
379 "endpoints".into(),
380 serde_json::to_value(eps).unwrap_or(Value::Null),
381 );
382 Value::Object(self_obj)
383}
384
385/// Insert-or-replace one of OUR OWN endpoints in `relay_state["self"]`,
386/// keyed by `relay_url` (re-binding the same relay updates it in place).
387/// ADDITIVE: every other existing self endpoint is preserved, so an agent
388/// can hold a local relay AND a federation relay at once. Rebuilds the
389/// legacy top-level fields. Single source of truth for the self-slot write
390/// shape — used by `cmd_bind_relay` and `init_self_idempotent`.
391pub fn upsert_self_endpoint(relay_state: &mut Value, ep: Endpoint) {
392 let mut eps = self_endpoints(relay_state);
393 eps.retain(|e| e.relay_url != ep.relay_url);
394 eps.push(ep);
395 relay_state["self"] = build_self_value(&eps);
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use serde_json::json;
402
403 #[test]
404 fn infer_scope_classifies_loopback_unix_and_federation() {
405 assert_eq!(
406 infer_scope_from_url("http://127.0.0.1:8771"),
407 EndpointScope::Local
408 );
409 assert_eq!(
410 infer_scope_from_url("http://localhost:8771"),
411 EndpointScope::Local
412 );
413 assert_eq!(
414 infer_scope_from_url("unix:///tmp/wire.sock"),
415 EndpointScope::Uds
416 );
417 assert_eq!(
418 infer_scope_from_url("https://wireup.net"),
419 EndpointScope::Federation
420 );
421 }
422
423 #[test]
424 fn upsert_self_endpoint_is_additive_then_updates_in_place() {
425 let mut state = json!({});
426 upsert_self_endpoint(
427 &mut state,
428 Endpoint::federation("https://wireup.net".into(), "fed1".into(), "ft".into()),
429 );
430 upsert_self_endpoint(
431 &mut state,
432 Endpoint::local("http://127.0.0.1:8771".into(), "loc1".into(), "lt".into()),
433 );
434 // Both kept.
435 assert_eq!(self_endpoints(&state).len(), 2);
436 // Legacy fields point at federation.
437 assert_eq!(state["self"]["relay_url"], "https://wireup.net");
438 // Re-binding the same relay replaces that one entry, not appends.
439 upsert_self_endpoint(
440 &mut state,
441 Endpoint::local("http://127.0.0.1:8771".into(), "loc2".into(), "lt2".into()),
442 );
443 let eps = self_endpoints(&state);
444 assert_eq!(eps.len(), 2, "same-relay rebind replaces, not appends");
445 let loc = eps
446 .iter()
447 .find(|e| e.scope == EndpointScope::Local)
448 .unwrap();
449 assert_eq!(loc.slot_id, "loc2", "local slot updated in place");
450 }
451
452 #[test]
453 fn peer_endpoints_ignores_flat_only_pin_post_rfc006() {
454 // RFC-006 Part B: `endpoints[]` is the single peer-routing source. A
455 // peer with ONLY the old flat fields and no `endpoints[]` array yields
456 // NO endpoints — the synthesis fallback was removed. (No users to
457 // migrate; every real pin now carries `endpoints[]`.)
458 let state = json!({
459 "peers": {
460 "alice": { "relay_url": "https://wireup.net", "slot_id": "abc", "slot_token": "tok" }
461 }
462 });
463 assert!(peer_endpoints_in_priority_order(&state, "alice").is_empty());
464 }
465
466 #[test]
467 fn peer_endpoints_lan_beats_federation() {
468 // v0.7.0-alpha.9: when a peer publishes both Lan and Federation
469 // endpoints (and we have a matching local too), priority must be
470 // Local(matched) > Lan > Federation. Lan is cross-machine same-
471 // network, faster than federation but not as fast as loopback.
472 let state = json!({
473 "self": {
474 "endpoints": [
475 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
476 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
477 ]
478 },
479 "peers": {
480 "alice": {
481 "endpoints": [
482 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
483 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
484 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
485 ]
486 }
487 }
488 });
489 let eps = peer_endpoints_in_priority_order(&state, "alice");
490 assert_eq!(
491 eps.len(),
492 3,
493 "Local(matched) + Lan + Federation all reachable"
494 );
495 assert_eq!(
496 eps[0].scope,
497 EndpointScope::Local,
498 "loopback wins (same-machine)"
499 );
500 assert_eq!(
501 eps[1].scope,
502 EndpointScope::Lan,
503 "Lan second (same-network)"
504 );
505 assert_eq!(
506 eps[2].scope,
507 EndpointScope::Federation,
508 "Federation last (anywhere)"
509 );
510 }
511
512 #[test]
513 fn peer_endpoints_lan_kept_when_self_has_no_local() {
514 // Cross-machine peer scenario: we have no Local, peer has Lan
515 // and Federation. Lan must still be kept (we connect TO their
516 // LAN address; we don't need a Local of our own to do so).
517 let state = json!({
518 "self": {
519 "endpoints": [
520 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
521 ]
522 },
523 "peers": {
524 "alice": {
525 "endpoints": [
526 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
527 {"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
528 ]
529 }
530 }
531 });
532 let eps = peer_endpoints_in_priority_order(&state, "alice");
533 assert_eq!(eps.len(), 2);
534 assert_eq!(
535 eps[0].scope,
536 EndpointScope::Lan,
537 "Lan preferred over Federation"
538 );
539 assert_eq!(eps[1].scope, EndpointScope::Federation);
540 }
541
542 #[test]
543 fn pin_peer_endpoints_writes_no_flat_fields_post_rfc006() {
544 // RFC-006 Part B: pin writes `endpoints[]` ONLY — no synthesized
545 // top-level relay_url/slot_id/slot_token. Routing reads the array.
546 let mut state = json!({});
547 let endpoints = vec![
548 Endpoint::lan(
549 "http://192.168.1.50:8771".to_string(),
550 "lan-slot".to_string(),
551 "lan-tok".to_string(),
552 ),
553 Endpoint::local(
554 "http://127.0.0.1:8771".to_string(),
555 "loop-slot".to_string(),
556 "loop-tok".to_string(),
557 ),
558 ];
559 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
560 let alice = &state["peers"]["alice"];
561 assert!(alice.get("relay_url").is_none(), "no flat relay_url");
562 assert!(alice.get("slot_id").is_none(), "no flat slot_id");
563 assert!(alice.get("slot_token").is_none(), "no flat slot_token");
564 assert_eq!(
565 alice["endpoints"].as_array().map(Vec::len),
566 Some(2),
567 "endpoints[] is the routing source"
568 );
569 }
570
571 #[test]
572 fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
573 let state = json!({
574 "self": {
575 "endpoints": [
576 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
577 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
578 ]
579 },
580 "peers": {
581 "alice": {
582 "endpoints": [
583 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
584 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
585 ]
586 }
587 }
588 });
589 let eps = peer_endpoints_in_priority_order(&state, "alice");
590 assert_eq!(eps.len(), 2);
591 assert_eq!(eps[0].scope, EndpointScope::Local);
592 assert_eq!(eps[1].scope, EndpointScope::Federation);
593 }
594
595 #[test]
596 fn peer_endpoints_drops_local_when_self_has_no_local() {
597 let state = json!({
598 "self": {
599 "endpoints": [
600 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
601 ]
602 },
603 "peers": {
604 "alice": {
605 "endpoints": [
606 {"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
607 {"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
608 ]
609 }
610 }
611 });
612 let eps = peer_endpoints_in_priority_order(&state, "alice");
613 // Only federation reachable: local was filtered.
614 assert_eq!(eps.len(), 1);
615 assert_eq!(eps[0].scope, EndpointScope::Federation);
616 }
617
618 #[test]
619 fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
620 let state = json!({
621 "self": {
622 "endpoints": [
623 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
624 ]
625 },
626 "peers": {
627 "alice": {
628 "endpoints": [
629 {"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
630 ]
631 }
632 }
633 });
634 // Our local is :8771, peer's local is :9999 — can't route there.
635 let eps = peer_endpoints_in_priority_order(&state, "alice");
636 assert_eq!(
637 eps.len(),
638 0,
639 "different local relays cannot reach each other"
640 );
641 }
642
643 #[test]
644 fn pin_then_resolve_round_trips_through_endpoints_array() {
645 // RFC-006 Part B: pin writes endpoints[]; routing resolves from it
646 // (priority order), with NO flat fields involved on either side.
647 let mut state = json!({"peers": {}});
648 let endpoints = vec![
649 Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
650 Endpoint::local(
651 "http://127.0.0.1:8771".into(),
652 "loop".into(),
653 "loop-tok".into(),
654 ),
655 ];
656 pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
657 let alice = &state["peers"]["alice"];
658 assert!(alice.get("relay_url").is_none(), "no flat fields written");
659 assert_eq!(alice["endpoints"].as_array().map(Vec::len), Some(2));
660 // Resolve from the array (no flat). Without a matching self-local relay,
661 // the peer's loopback endpoint isn't reachable for us, so federation is
662 // the primary route.
663 // Priority order drops the unreachable loopback (no matching
664 // self-local), leaving the federation route.
665 let ordered = peer_endpoints_in_priority_order(&state, "alice");
666 assert_eq!(ordered.len(), 1, "only the reachable federation route");
667 let primary = peer_primary_endpoint(&state, "alice").unwrap();
668 assert_eq!(primary.scope, EndpointScope::Federation);
669 assert_eq!(primary.slot_id, "abc");
670 }
671
672 #[test]
673 fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
674 let state = json!({
675 "self": {
676 "relay_url": "https://wireup.net",
677 "slot_id": "self-fed",
678 "slot_token": "t1"
679 }
680 });
681 let eps = self_endpoints(&state);
682 assert_eq!(eps.len(), 1);
683 assert_eq!(eps[0].scope, EndpointScope::Federation);
684 assert_eq!(eps[0].slot_id, "self-fed");
685 }
686
687 #[test]
688 fn self_endpoints_returns_both_when_dual_slot() {
689 let state = json!({
690 "self": {
691 "endpoints": [
692 {"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
693 {"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
694 ]
695 }
696 });
697 let eps = self_endpoints(&state);
698 assert_eq!(eps.len(), 2);
699 }
700}