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}