Skip to main content

aex_core/
endpoint.rs

1//! `Endpoint` — a single way a recipient can reach a sender's data plane.
2//!
3//! Introduced in Sprint 2 for transport plurality (`v1.3.0-beta.1`).
4//! A transfer carries a list of endpoints (`reachable_at[]`); the recipient
5//! SDK tries them in the sender's declared priority order per ADR-0012
6//! (sender-ranked, serial, sticky) and stops at the first that works.
7//!
8//! ```text
9//!     reachable_at[] (JSONB on transfers, JSON on the wire)
10//!         │
11//!         ├── { kind: "cloudflare_quick", url: "https://x.trycloudflare.com", priority: 0 }
12//!         ├── { kind: "iroh",              url: "iroh:NodeID@relay:443",        priority: 1 }
13//!         └── { kind: "frp",               url: "https://frp.example.com/x",    priority: 2 }
14//!              │
15//!              └── recipient tries in priority order, sticks with first success
16//! ```
17//!
18//! ## Forward compatibility
19//!
20//! `kind` is a `String`, not an enum, so unknown kinds from a newer peer
21//! are preserved losslessly. Recipients MUST skip endpoints whose `kind`
22//! is not in [`Endpoint::KNOWN_KINDS`] rather than erroring. This mirrors
23//! the capability-bit philosophy in ADR-0018 — new transports land
24//! additively without requiring a wire bump.
25
26use serde::{Deserialize, Serialize};
27
28/// A single way to reach a sender's data plane.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Endpoint {
31    /// Transport kind. See [`Endpoint::KIND_*`] constants for known values.
32    /// Unknown values are preserved but MUST be skipped by recipients.
33    pub kind: String,
34    /// Reachable address. Schema is transport-specific:
35    /// - `cloudflare_quick`, `cloudflare_named`, `tailscale_funnel`, `frp`: `https://host/...`
36    /// - `iroh`: `iroh:<NodeID>@<relay_host>:<port>`
37    pub url: String,
38    /// Sender's preference (lower = try first). Ties broken by array order.
39    #[serde(default)]
40    pub priority: i32,
41    /// Optional last-known-good timestamp (Unix seconds) used by the control
42    /// plane's health cache. Absent on fresh endpoints.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub health_hint_unix: Option<i64>,
45}
46
47impl Endpoint {
48    /// Cloudflare Quick Tunnel (`*.trycloudflare.com`, ephemeral).
49    pub const KIND_CLOUDFLARE_QUICK: &'static str = "cloudflare_quick";
50    /// Cloudflare Named Tunnel (`*.workers.dev` or custom hostname, persistent).
51    pub const KIND_CLOUDFLARE_NAMED: &'static str = "cloudflare_named";
52    /// Iroh peer-to-peer with DERP relay fallback.
53    pub const KIND_IROH: &'static str = "iroh";
54    /// Tailscale Funnel (public hostname on a tailnet).
55    pub const KIND_TAILSCALE_FUNNEL: &'static str = "tailscale_funnel";
56    /// FRP self-hosted reverse proxy.
57    pub const KIND_FRP: &'static str = "frp";
58
59    /// All kinds this crate knows how to reach. Adding a new transport in a
60    /// later sprint adds a constant here + extends this array.
61    pub const KNOWN_KINDS: &'static [&'static str] = &[
62        Self::KIND_CLOUDFLARE_QUICK,
63        Self::KIND_CLOUDFLARE_NAMED,
64        Self::KIND_IROH,
65        Self::KIND_TAILSCALE_FUNNEL,
66        Self::KIND_FRP,
67    ];
68
69    /// True if `self.kind` is in [`Self::KNOWN_KINDS`]. Recipients use this
70    /// to skip forward-incompatible endpoints without failing the transfer.
71    pub fn is_known_kind(&self) -> bool {
72        Self::KNOWN_KINDS.contains(&self.kind.as_str())
73    }
74
75    /// Convenience: Cloudflare Quick Tunnel endpoint at priority 0.
76    pub fn cloudflare_quick(url: impl Into<String>) -> Self {
77        Self {
78            kind: Self::KIND_CLOUDFLARE_QUICK.into(),
79            url: url.into(),
80            priority: 0,
81            health_hint_unix: None,
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn cloudflare_quick_builder() {
92        let e = Endpoint::cloudflare_quick("https://foo.trycloudflare.com");
93        assert_eq!(e.kind, "cloudflare_quick");
94        assert_eq!(e.url, "https://foo.trycloudflare.com");
95        assert_eq!(e.priority, 0);
96        assert!(e.is_known_kind());
97    }
98
99    #[test]
100    fn unknown_kind_preserved_and_flagged() {
101        let e = Endpoint {
102            kind: "future_transport_v9".into(),
103            url: "future:alien@mars:443".into(),
104            priority: 5,
105            health_hint_unix: None,
106        };
107        assert!(!e.is_known_kind());
108    }
109
110    #[test]
111    fn serde_roundtrip_minimal() {
112        let original = Endpoint::cloudflare_quick("https://x.trycloudflare.com");
113        let json = serde_json::to_string(&original).unwrap();
114        // Priority 0 is the default but explicit in serialization; health_hint absent.
115        assert!(json.contains(r#""kind":"cloudflare_quick""#));
116        assert!(json.contains(r#""url":"https://x.trycloudflare.com""#));
117        assert!(!json.contains("health_hint_unix"));
118        let back: Endpoint = serde_json::from_str(&json).unwrap();
119        assert_eq!(back, original);
120    }
121
122    #[test]
123    fn serde_roundtrip_with_health_hint() {
124        let original = Endpoint {
125            kind: Endpoint::KIND_IROH.into(),
126            url: "iroh:abc123@relay.aex.dev:443".into(),
127            priority: 1,
128            health_hint_unix: Some(1_700_000_000),
129        };
130        let json = serde_json::to_string(&original).unwrap();
131        assert!(json.contains(r#""health_hint_unix":1700000000"#));
132        let back: Endpoint = serde_json::from_str(&json).unwrap();
133        assert_eq!(back, original);
134    }
135
136    #[test]
137    fn deserialize_preserves_unknown_kind() {
138        let json = r#"{"kind":"unknown_transport","url":"x://y","priority":9}"#;
139        let e: Endpoint = serde_json::from_str(json).unwrap();
140        assert_eq!(e.kind, "unknown_transport");
141        assert!(!e.is_known_kind());
142    }
143
144    #[test]
145    fn priority_defaults_to_zero_when_missing() {
146        let json = r#"{"kind":"cloudflare_quick","url":"https://x.trycloudflare.com"}"#;
147        let e: Endpoint = serde_json::from_str(json).unwrap();
148        assert_eq!(e.priority, 0);
149        assert_eq!(e.health_hint_unix, None);
150    }
151
152    #[test]
153    fn known_kinds_covers_sprint_2_transports() {
154        for k in [
155            Endpoint::KIND_CLOUDFLARE_QUICK,
156            Endpoint::KIND_CLOUDFLARE_NAMED,
157            Endpoint::KIND_IROH,
158            Endpoint::KIND_TAILSCALE_FUNNEL,
159            Endpoint::KIND_FRP,
160        ] {
161            assert!(Endpoint::KNOWN_KINDS.contains(&k), "kind {k} missing");
162        }
163    }
164}