use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EndpointScope {
Federation,
Local,
Lan,
Uds,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Endpoint {
pub relay_url: String,
pub slot_id: String,
pub slot_token: String,
pub scope: EndpointScope,
}
impl Endpoint {
pub fn federation(relay_url: String, slot_id: String, slot_token: String) -> Self {
Self {
relay_url,
slot_id,
slot_token,
scope: EndpointScope::Federation,
}
}
pub fn local(relay_url: String, slot_id: String, slot_token: String) -> Self {
Self {
relay_url,
slot_id,
slot_token,
scope: EndpointScope::Local,
}
}
pub fn lan(relay_url: String, slot_id: String, slot_token: String) -> Self {
Self {
relay_url,
slot_id,
slot_token,
scope: EndpointScope::Lan,
}
}
pub fn uds(relay_url: String, slot_id: String, slot_token: String) -> Self {
Self {
relay_url,
slot_id,
slot_token,
scope: EndpointScope::Uds,
}
}
}
pub fn peer_endpoints_in_priority_order(relay_state: &Value, peer_handle: &str) -> Vec<Endpoint> {
let our_local_relay_url = relay_state
.get("self")
.and_then(|s| s.get("endpoints"))
.and_then(Value::as_array)
.and_then(|arr| {
arr.iter()
.find(|e| e.get("scope").and_then(Value::as_str) == Some("local"))
.and_then(|e| e.get("relay_url"))
.and_then(Value::as_str)
.map(str::to_string)
});
let peer = match relay_state.get("peers").and_then(|p| p.get(peer_handle)) {
Some(p) => p,
None => return Vec::new(),
};
let mut all: Vec<Endpoint> = Vec::new();
if let Some(arr) = peer.get("endpoints").and_then(Value::as_array) {
for ep in arr {
if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
all.push(parsed);
}
}
}
let our_local = our_local_relay_url.clone();
all.sort_by_key(|ep| match (ep.scope, &our_local) {
(EndpointScope::Uds, _) => 0,
(EndpointScope::Local, Some(our)) if &ep.relay_url == our => 1,
(EndpointScope::Lan, _) => 2,
(EndpointScope::Federation, _) => 3,
_ => 4,
});
all.retain(|ep| match (ep.scope, &our_local) {
(EndpointScope::Local, None) => false,
(EndpointScope::Local, Some(our)) => &ep.relay_url == our,
(EndpointScope::Lan, _) => true,
(EndpointScope::Uds, _) => true,
(EndpointScope::Federation, _) => true,
});
all
}
pub fn self_endpoints(relay_state: &Value) -> Vec<Endpoint> {
let self_state = match relay_state.get("self") {
Some(s) if !s.is_null() => s,
_ => return Vec::new(),
};
let mut all: Vec<Endpoint> = Vec::new();
if let Some(arr) = self_state.get("endpoints").and_then(Value::as_array) {
for ep in arr {
if let Ok(parsed) = serde_json::from_value::<Endpoint>(ep.clone()) {
all.push(parsed);
}
}
}
if all.is_empty() {
let relay_url = self_state
.get("relay_url")
.and_then(Value::as_str)
.unwrap_or("");
let slot_id = self_state
.get("slot_id")
.and_then(Value::as_str)
.unwrap_or("");
let slot_token = self_state
.get("slot_token")
.and_then(Value::as_str)
.unwrap_or("");
if !relay_url.is_empty() && !slot_id.is_empty() {
all.push(Endpoint::federation(
relay_url.to_string(),
slot_id.to_string(),
slot_token.to_string(),
));
}
}
all
}
pub fn self_primary_endpoint(relay_state: &Value) -> Option<Endpoint> {
self_endpoints(relay_state).into_iter().next()
}
pub fn peer_primary_endpoint(relay_state: &Value, peer_handle: &str) -> Option<Endpoint> {
peer_endpoints_in_priority_order(relay_state, peer_handle)
.into_iter()
.next()
}
pub fn peer_federation_token(relay_state: &Value, peer_handle: &str, relay_url: &str) -> String {
relay_state
.get("peers")
.and_then(|p| p.get(peer_handle))
.and_then(|e| e.get("endpoints"))
.and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
.unwrap_or_default()
.into_iter()
.find(|e| e.scope == EndpointScope::Federation && e.relay_url == relay_url)
.map(|e| e.slot_token)
.unwrap_or_default()
}
pub fn pin_peer_endpoints(
relay_state: &mut Value,
peer_handle: &str,
endpoints: &[Endpoint],
) -> Result<()> {
let peers = relay_state
.as_object_mut()
.map(|m| {
m.entry("peers")
.or_insert_with(|| Value::Object(Default::default()))
})
.ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
let preserved: serde_json::Map<String, Value> = peers
.get(peer_handle)
.and_then(Value::as_object)
.map(|m| {
m.iter()
.filter(|(k, _)| {
matches!(
k.as_str(),
"bilateral_completed_at"
| "persona"
| "profile"
| "first_seen_at"
| "nostr_transport"
)
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.unwrap_or_default();
let mut entry = preserved;
entry.insert("endpoints".into(), serde_json::to_value(endpoints)?);
peers.insert(peer_handle.to_string(), Value::Object(entry));
Ok(())
}
pub fn pin_peer_nostr_transport(
relay_state: &mut Value,
peer_handle: &str,
npub_hex: &str,
relay_url: &str,
) -> Result<()> {
let peers = relay_state
.as_object_mut()
.map(|m| {
m.entry("peers")
.or_insert_with(|| Value::Object(Default::default()))
})
.ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("relay_state.peers is not an object"))?;
let entry = peers
.entry(peer_handle.to_string())
.or_insert_with(|| Value::Object(Default::default()))
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("relay_state.peers[{peer_handle}] is not an object"))?;
entry.insert(
"nostr_transport".into(),
serde_json::json!({ "npub": npub_hex, "relay": relay_url }),
);
Ok(())
}
pub fn peer_nostr_transport(relay_state: &Value, peer_handle: &str) -> Option<(String, String)> {
let nt = relay_state
.get("peers")?
.get(peer_handle)?
.get("nostr_transport")?;
let npub = nt.get("npub")?.as_str()?.to_string();
let relay = nt.get("relay")?.as_str()?.to_string();
if npub.is_empty() || relay.is_empty() {
return None;
}
Some((npub, relay))
}
pub fn pin_self_nostr_relay(relay_state: &mut Value, relay_url: &str) -> Result<()> {
if relay_url.is_empty() {
return Ok(());
}
let self_obj = relay_state
.as_object_mut()
.map(|m| {
m.entry("self")
.or_insert_with(|| Value::Object(Default::default()))
})
.ok_or_else(|| anyhow::anyhow!("relay_state.json root is not an object"))?
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("relay_state.self is not an object"))?;
let arr = self_obj
.entry("nostr_relays")
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("relay_state.self.nostr_relays is not an array"))?;
if !arr.iter().any(|v| v.as_str() == Some(relay_url)) {
arr.push(Value::String(relay_url.to_string()));
}
Ok(())
}
pub fn self_nostr_relays(relay_state: &Value) -> Vec<String> {
relay_state
.get("self")
.and_then(|s| s.get("nostr_relays"))
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
pub fn infer_scope_from_url(url: &str) -> EndpointScope {
if url.starts_with("unix://") {
return EndpointScope::Uds;
}
let host = url
.trim_start_matches("http://")
.trim_start_matches("https://")
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
if is_loopback_host(host) {
EndpointScope::Local
} else {
EndpointScope::Federation
}
}
pub fn is_loopback_host(host: &str) -> bool {
host == "127.0.0.1" || host == "localhost"
}
pub fn endpoints_are_local_only(endpoints: &[Endpoint]) -> bool {
!endpoints.is_empty()
&& endpoints.iter().all(|e| {
matches!(
infer_scope_from_url(&e.relay_url),
EndpointScope::Local | EndpointScope::Uds
)
})
}
fn build_self_value(eps: &[Endpoint]) -> Value {
let legacy = eps
.iter()
.find(|e| e.scope == EndpointScope::Federation)
.or_else(|| eps.first());
let mut self_obj = serde_json::Map::new();
if let Some(l) = legacy {
self_obj.insert("relay_url".into(), Value::String(l.relay_url.clone()));
self_obj.insert("slot_id".into(), Value::String(l.slot_id.clone()));
self_obj.insert("slot_token".into(), Value::String(l.slot_token.clone()));
}
self_obj.insert(
"endpoints".into(),
serde_json::to_value(eps).unwrap_or(Value::Null),
);
Value::Object(self_obj)
}
pub fn upsert_self_endpoint(relay_state: &mut Value, ep: Endpoint) {
let mut eps = self_endpoints(relay_state);
eps.retain(|e| e.relay_url != ep.relay_url);
eps.push(ep);
relay_state["self"] = build_self_value(&eps);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn infer_scope_classifies_loopback_unix_and_federation() {
assert_eq!(
infer_scope_from_url("http://127.0.0.1:8771"),
EndpointScope::Local
);
assert_eq!(
infer_scope_from_url("http://localhost:8771"),
EndpointScope::Local
);
assert_eq!(
infer_scope_from_url("unix:///tmp/wire.sock"),
EndpointScope::Uds
);
assert_eq!(
infer_scope_from_url("https://wireup.net"),
EndpointScope::Federation
);
}
#[test]
fn upsert_self_endpoint_is_additive_then_updates_in_place() {
let mut state = json!({});
upsert_self_endpoint(
&mut state,
Endpoint::federation("https://wireup.net".into(), "fed1".into(), "ft".into()),
);
upsert_self_endpoint(
&mut state,
Endpoint::local("http://127.0.0.1:8771".into(), "loc1".into(), "lt".into()),
);
assert_eq!(self_endpoints(&state).len(), 2);
assert_eq!(state["self"]["relay_url"], "https://wireup.net");
upsert_self_endpoint(
&mut state,
Endpoint::local("http://127.0.0.1:8771".into(), "loc2".into(), "lt2".into()),
);
let eps = self_endpoints(&state);
assert_eq!(eps.len(), 2, "same-relay rebind replaces, not appends");
let loc = eps
.iter()
.find(|e| e.scope == EndpointScope::Local)
.unwrap();
assert_eq!(loc.slot_id, "loc2", "local slot updated in place");
}
#[test]
fn peer_endpoints_ignores_flat_only_pin_post_rfc006() {
let state = json!({
"peers": {
"alice": { "relay_url": "https://wireup.net", "slot_id": "abc", "slot_token": "tok" }
}
});
assert!(peer_endpoints_in_priority_order(&state, "alice").is_empty());
}
#[test]
fn self_nostr_relay_roundtrips_and_dedups() {
let mut state = json!({});
pin_self_nostr_relay(&mut state, "wss://r1").unwrap();
pin_self_nostr_relay(&mut state, "wss://r2").unwrap();
pin_self_nostr_relay(&mut state, "wss://r1").unwrap(); pin_self_nostr_relay(&mut state, "").unwrap(); assert_eq!(
self_nostr_relays(&state),
vec!["wss://r1".to_string(), "wss://r2".to_string()]
);
let mut state2 = json!({"self": {"relay_url": "https://wireup.net", "slot_id": "s"}});
pin_self_nostr_relay(&mut state2, "wss://x").unwrap();
assert_eq!(state2["self"]["relay_url"], "https://wireup.net");
assert_eq!(self_nostr_relays(&state2), vec!["wss://x".to_string()]);
assert!(self_nostr_relays(&json!({})).is_empty());
}
#[test]
fn endpoints_are_local_only_catches_loopback_pin_incl_mislabeled_scope() {
assert!(!endpoints_are_local_only(&[]));
let mislabeled =
Endpoint::federation("http://127.0.0.1:18791".into(), "s".into(), "t".into());
assert!(endpoints_are_local_only(std::slice::from_ref(&mislabeled)));
let fed = Endpoint::federation("https://wireup.net".into(), "s".into(), "t".into());
assert!(!endpoints_are_local_only(std::slice::from_ref(&fed)));
assert!(!endpoints_are_local_only(&[mislabeled.clone(), fed]));
let uds = Endpoint::uds("unix:///tmp/wire.sock".into(), "s".into(), "t".into());
assert!(endpoints_are_local_only(&[uds]));
}
#[test]
fn peer_federation_token_carries_forward_from_endpoints_not_flat() {
let state = json!({
"peers": {
"alice": {
"slot_token": "STALE_FLAT",
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "s1", "slot_token": "REAL_TOK", "scope": "federation"},
{"relay_url": "http://127.0.0.1:8771", "slot_id": "l1", "slot_token": "LOCAL_TOK", "scope": "local"}
]
}
}
});
assert_eq!(
peer_federation_token(&state, "alice", "https://wireup.net"),
"REAL_TOK"
);
assert_eq!(
peer_federation_token(&state, "alice", "https://other.example"),
""
);
assert_eq!(
peer_federation_token(&state, "nobody", "https://wireup.net"),
""
);
}
#[test]
fn peer_endpoints_lan_beats_federation() {
let state = json!({
"self": {
"endpoints": [
{"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t1", "scope": "local"},
{"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t2", "scope": "federation"}
]
},
"peers": {
"alice": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
{"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"},
{"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta-loop", "scope": "local"}
]
}
}
});
let eps = peer_endpoints_in_priority_order(&state, "alice");
assert_eq!(
eps.len(),
3,
"Local(matched) + Lan + Federation all reachable"
);
assert_eq!(
eps[0].scope,
EndpointScope::Local,
"loopback wins (same-machine)"
);
assert_eq!(
eps[1].scope,
EndpointScope::Lan,
"Lan second (same-network)"
);
assert_eq!(
eps[2].scope,
EndpointScope::Federation,
"Federation last (anywhere)"
);
}
#[test]
fn peer_endpoints_lan_kept_when_self_has_no_local() {
let state = json!({
"self": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
]
},
"peers": {
"alice": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta-f", "scope": "federation"},
{"relay_url": "http://192.168.1.50:8771", "slot_id": "a-lan", "slot_token": "ta-l", "scope": "lan"}
]
}
}
});
let eps = peer_endpoints_in_priority_order(&state, "alice");
assert_eq!(eps.len(), 2);
assert_eq!(
eps[0].scope,
EndpointScope::Lan,
"Lan preferred over Federation"
);
assert_eq!(eps[1].scope, EndpointScope::Federation);
}
#[test]
fn pin_peer_endpoints_writes_no_flat_fields_post_rfc006() {
let mut state = json!({});
let endpoints = vec![
Endpoint::lan(
"http://192.168.1.50:8771".to_string(),
"lan-slot".to_string(),
"lan-tok".to_string(),
),
Endpoint::local(
"http://127.0.0.1:8771".to_string(),
"loop-slot".to_string(),
"loop-tok".to_string(),
),
];
pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
let alice = &state["peers"]["alice"];
assert!(alice.get("relay_url").is_none(), "no flat relay_url");
assert!(alice.get("slot_id").is_none(), "no flat slot_id");
assert!(alice.get("slot_token").is_none(), "no flat slot_token");
assert_eq!(
alice["endpoints"].as_array().map(Vec::len),
Some(2),
"endpoints[] is the routing source"
);
}
#[test]
fn peer_endpoints_orders_local_first_when_self_has_matching_local() {
let state = json!({
"self": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
{"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
]
},
"peers": {
"alice": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
{"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
]
}
}
});
let eps = peer_endpoints_in_priority_order(&state, "alice");
assert_eq!(eps.len(), 2);
assert_eq!(eps[0].scope, EndpointScope::Local);
assert_eq!(eps[1].scope, EndpointScope::Federation);
}
#[test]
fn peer_endpoints_drops_local_when_self_has_no_local() {
let state = json!({
"self": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"}
]
},
"peers": {
"alice": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "a-fed", "slot_token": "ta1", "scope": "federation"},
{"relay_url": "http://127.0.0.1:8771", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
]
}
}
});
let eps = peer_endpoints_in_priority_order(&state, "alice");
assert_eq!(eps.len(), 1);
assert_eq!(eps[0].scope, EndpointScope::Federation);
}
#[test]
fn peer_endpoints_drops_local_when_relay_urls_dont_match() {
let state = json!({
"self": {
"endpoints": [
{"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
]
},
"peers": {
"alice": {
"endpoints": [
{"relay_url": "http://127.0.0.1:9999", "slot_id": "a-loop", "slot_token": "ta2", "scope": "local"}
]
}
}
});
let eps = peer_endpoints_in_priority_order(&state, "alice");
assert_eq!(
eps.len(),
0,
"different local relays cannot reach each other"
);
}
#[test]
fn pin_then_resolve_round_trips_through_endpoints_array() {
let mut state = json!({"peers": {}});
let endpoints = vec![
Endpoint::federation("https://wireup.net".into(), "abc".into(), "tok".into()),
Endpoint::local(
"http://127.0.0.1:8771".into(),
"loop".into(),
"loop-tok".into(),
),
];
pin_peer_endpoints(&mut state, "alice", &endpoints).unwrap();
let alice = &state["peers"]["alice"];
assert!(alice.get("relay_url").is_none(), "no flat fields written");
assert_eq!(alice["endpoints"].as_array().map(Vec::len), Some(2));
let ordered = peer_endpoints_in_priority_order(&state, "alice");
assert_eq!(ordered.len(), 1, "only the reachable federation route");
let primary = peer_primary_endpoint(&state, "alice").unwrap();
assert_eq!(primary.scope, EndpointScope::Federation);
assert_eq!(primary.slot_id, "abc");
}
#[test]
fn self_endpoints_back_compat_falls_back_to_legacy_fields() {
let state = json!({
"self": {
"relay_url": "https://wireup.net",
"slot_id": "self-fed",
"slot_token": "t1"
}
});
let eps = self_endpoints(&state);
assert_eq!(eps.len(), 1);
assert_eq!(eps[0].scope, EndpointScope::Federation);
assert_eq!(eps[0].slot_id, "self-fed");
}
#[test]
fn self_endpoints_returns_both_when_dual_slot() {
let state = json!({
"self": {
"endpoints": [
{"relay_url": "https://wireup.net", "slot_id": "self-fed", "slot_token": "t1", "scope": "federation"},
{"relay_url": "http://127.0.0.1:8771", "slot_id": "self-loop", "slot_token": "t2", "scope": "local"}
]
}
});
let eps = self_endpoints(&state);
assert_eq!(eps.len(), 2);
}
#[test]
fn nostr_transport_roundtrips() {
let mut state = serde_json::json!({});
let npub = "a".repeat(64);
pin_peer_nostr_transport(&mut state, "raven-kettle", &npub, "wss://relay.damus.io")
.unwrap();
assert_eq!(
peer_nostr_transport(&state, "raven-kettle"),
Some((npub.clone(), "wss://relay.damus.io".to_string()))
);
assert_eq!(peer_nostr_transport(&state, "nobody"), None);
}
#[test]
fn nostr_transport_survives_endpoint_repin() {
let mut state = serde_json::json!({});
let npub = "b".repeat(64);
pin_peer_nostr_transport(&mut state, "p", &npub, "wss://nos.lol").unwrap();
pin_peer_endpoints(
&mut state,
"p",
&[Endpoint::federation(
"https://wireup.net".into(),
"slot1".into(),
"tok1".into(),
)],
)
.unwrap();
assert_eq!(
peer_nostr_transport(&state, "p"),
Some((npub, "wss://nos.lol".to_string()))
);
assert_eq!(peer_endpoints_in_priority_order(&state, "p").len(), 1);
}
#[test]
fn nostr_transport_is_idempotent_and_updatable() {
let mut state = serde_json::json!({});
pin_peer_nostr_transport(&mut state, "p", &"c".repeat(64), "wss://a").unwrap();
pin_peer_nostr_transport(&mut state, "p", &"c".repeat(64), "wss://b").unwrap();
assert_eq!(
peer_nostr_transport(&state, "p"),
Some(("c".repeat(64), "wss://b".to_string()))
);
}
}