Skip to main content

wire/
relay_client.rs

1//! HTTP client for `wire-relay-server`.
2//!
3//! Sync wrapper around `reqwest::blocking` so CLI commands stay synchronous —
4//! the only async surface in the crate is `relay_server::serve`. Async clients
5//! land in v0.2 if a long-running daemon needs them.
6
7use anyhow::{Context, Result, anyhow};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[derive(Clone)]
12pub struct RelayClient {
13    base_url: String,
14    client: reqwest::blocking::Client,
15}
16
17#[derive(Debug, Serialize, Deserialize)]
18pub struct AllocateResponse {
19    pub slot_id: String,
20    pub slot_token: String,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct PostEventResponse {
25    pub event_id: Option<String>,
26    pub status: String,
27}
28
29impl RelayClient {
30    pub fn new(base_url: &str) -> Self {
31        let client = reqwest::blocking::Client::builder()
32            .timeout(std::time::Duration::from_secs(30))
33            .build()
34            .expect("reqwest client construction is infallible with default config");
35        Self {
36            base_url: base_url.trim_end_matches('/').to_string(),
37            client,
38        }
39    }
40
41    /// Allocate a fresh slot. Returns `(slot_id, slot_token)` — caller MUST
42    /// persist `slot_token` somewhere safe (mode 0600 file); it grants both
43    /// read and write access to the slot.
44    pub fn allocate_slot(&self, handle_hint: Option<&str>) -> Result<AllocateResponse> {
45        let body = serde_json::json!({"handle": handle_hint});
46        let resp = self
47            .client
48            .post(format!("{}/v1/slot/allocate", self.base_url))
49            .json(&body)
50            .send()
51            .with_context(|| format!("POST {}/v1/slot/allocate", self.base_url))?;
52        let status = resp.status();
53        if !status.is_success() {
54            let detail = resp.text().unwrap_or_default();
55            return Err(anyhow!("allocate failed: {status}: {detail}"));
56        }
57        Ok(resp.json()?)
58    }
59
60    /// POST a signed event to a slot. Caller passes the slot's bearer token
61    /// (the relay model in v0.1 is "shared slot token between paired peers" —
62    /// see iter 9 SPAKE2 for how this token gets exchanged).
63    pub fn post_event(
64        &self,
65        slot_id: &str,
66        slot_token: &str,
67        event: &Value,
68    ) -> Result<PostEventResponse> {
69        let body = serde_json::json!({"event": event});
70        let resp = self
71            .client
72            .post(format!("{}/v1/events/{slot_id}", self.base_url))
73            .bearer_auth(slot_token)
74            .json(&body)
75            .send()
76            .with_context(|| format!("POST {}/v1/events/{slot_id}", self.base_url))?;
77        let status = resp.status();
78        if !status.is_success() {
79            let detail = resp.text().unwrap_or_default();
80            return Err(anyhow!("post_event failed: {status}: {detail}"));
81        }
82        Ok(resp.json()?)
83    }
84
85    /// GET events from a slot. `since` is an event_id cursor (exclusive); pass
86    /// `None` for the full slot snapshot. `limit` defaults to 100, max 1000.
87    pub fn list_events(
88        &self,
89        slot_id: &str,
90        slot_token: &str,
91        since: Option<&str>,
92        limit: Option<usize>,
93    ) -> Result<Vec<Value>> {
94        let mut url = format!("{}/v1/events/{slot_id}", self.base_url);
95        let mut sep = '?';
96        if let Some(s) = since {
97            url.push(sep);
98            url.push_str(&format!("since={s}"));
99            sep = '&';
100        }
101        if let Some(n) = limit {
102            url.push(sep);
103            url.push_str(&format!("limit={n}"));
104        }
105        let resp = self
106            .client
107            .get(&url)
108            .bearer_auth(slot_token)
109            .send()
110            .with_context(|| format!("GET {url}"))?;
111        let status = resp.status();
112        if !status.is_success() {
113            let detail = resp.text().unwrap_or_default();
114            return Err(anyhow!("list_events failed: {status}: {detail}"));
115        }
116        Ok(resp.json()?)
117    }
118
119    /// R4 — probe slot attentiveness. Returns `(event_count, last_pull_at_unix)`
120    /// — the relay's view of the slot's owner's most recent poll. `None` for
121    /// `last_pull_at_unix` means the slot has not been pulled since relay
122    /// restart. Best-effort: any HTTP failure returns `Ok((0, None))` so the
123    /// caller's pre-flight check degrades to "no signal" rather than abort.
124    pub fn slot_state(&self, slot_id: &str, slot_token: &str) -> Result<(usize, Option<u64>)> {
125        let url = format!("{}/v1/slot/{slot_id}/state", self.base_url);
126        let resp = match self.client.get(&url).bearer_auth(slot_token).send() {
127            Ok(r) => r,
128            Err(_) => return Ok((0, None)),
129        };
130        if !resp.status().is_success() {
131            return Ok((0, None));
132        }
133        let v: Value = resp.json().unwrap_or(Value::Null);
134        let count = v.get("event_count").and_then(Value::as_u64).unwrap_or(0) as usize;
135        let last = v.get("last_pull_at_unix").and_then(Value::as_u64);
136        Ok((count, last))
137    }
138
139    pub fn responder_health_set(
140        &self,
141        slot_id: &str,
142        slot_token: &str,
143        record: &Value,
144    ) -> Result<Value> {
145        let resp = self
146            .client
147            .post(format!(
148                "{}/v1/slot/{slot_id}/responder-health",
149                self.base_url
150            ))
151            .bearer_auth(slot_token)
152            .json(record)
153            .send()
154            .with_context(|| {
155                format!("POST {}/v1/slot/{slot_id}/responder-health", self.base_url)
156            })?;
157        let status = resp.status();
158        if !status.is_success() {
159            let detail = resp.text().unwrap_or_default();
160            return Err(anyhow!("responder_health_set failed: {status}: {detail}"));
161        }
162        Ok(resp.json()?)
163    }
164
165    pub fn responder_health_get(&self, slot_id: &str, slot_token: &str) -> Result<Value> {
166        let resp = self
167            .client
168            .get(format!("{}/v1/slot/{slot_id}/state", self.base_url))
169            .bearer_auth(slot_token)
170            .send()
171            .with_context(|| format!("GET {}/v1/slot/{slot_id}/state", self.base_url))?;
172        let status = resp.status();
173        if !status.is_success() {
174            let detail = resp.text().unwrap_or_default();
175            return Err(anyhow!("responder_health_get failed: {status}: {detail}"));
176        }
177        let state: Value = resp.json()?;
178        Ok(state
179            .get("responder_health")
180            .cloned()
181            .unwrap_or(Value::Null))
182    }
183
184    pub fn healthz(&self) -> Result<bool> {
185        let resp = self
186            .client
187            .get(format!("{}/healthz", self.base_url))
188            .send()?;
189        Ok(resp.status().is_success())
190    }
191
192    /// Healthz pre-flight that surfaces the underlying reqwest error in its
193    /// own message. Use at every "is the relay reachable before we mutate
194    /// state" site. The three possible failure modes (network error, 5xx
195    /// from a reachable host, healthy) each get a distinct diagnostic line.
196    pub fn check_healthz(&self) -> anyhow::Result<()> {
197        match self.healthz() {
198            Ok(true) => Ok(()),
199            Ok(false) => anyhow::bail!(
200                "phyllis: silent line — {}/healthz returned non-200.\n\
201                 the host is reachable but the relay isn't returning ok. test:\n  \
202                 curl -v {}/healthz",
203                self.base_url,
204                self.base_url
205            ),
206            Err(e) => anyhow::bail!(
207                "phyllis: silent line — couldn't reach {}/healthz: {e:#}.\n\
208                 test reachability from this machine:\n  curl -v {}/healthz\n\
209                 if curl also fails, a sandbox / proxy / firewall is the usual cause.\n\
210                 (OpenShell sandbox? run `curl -fsSL https://wireup.net/openshell-policy.sh | bash -s <sandbox-name>` on the host first.)",
211                self.base_url,
212                self.base_url
213            ),
214        }
215    }
216
217    /// Open or join a pair-slot. Returns the relay-assigned `pair_id`.
218    /// `role` must be `"host"` or `"guest"`. The host calls first; the guest
219    /// uses the same `code_hash` and finds the existing slot.
220    pub fn pair_open(&self, code_hash: &str, msg_b64: &str, role: &str) -> Result<String> {
221        let body = serde_json::json!({"code_hash": code_hash, "msg": msg_b64, "role": role});
222        let resp = self
223            .client
224            .post(format!("{}/v1/pair", self.base_url))
225            .json(&body)
226            .send()?;
227        let status = resp.status();
228        if !status.is_success() {
229            let detail = resp.text().unwrap_or_default();
230            return Err(anyhow!("pair_open failed: {status}: {detail}"));
231        }
232        let v: Value = resp.json()?;
233        v.get("pair_id")
234            .and_then(Value::as_str)
235            .map(str::to_string)
236            .ok_or_else(|| anyhow!("pair_open response missing pair_id"))
237    }
238
239    /// Forget the pair-slot at this code_hash on the relay. Either side can call;
240    /// knowledge of the code is the only auth. Idempotent — succeeds even if the
241    /// slot doesn't exist. Use after a client crash mid-handshake so the host
242    /// doesn't stay locked out until TTL.
243    pub fn pair_abandon(&self, code_hash: &str) -> Result<()> {
244        let body = serde_json::json!({"code_hash": code_hash});
245        let resp = self
246            .client
247            .post(format!("{}/v1/pair/abandon", self.base_url))
248            .json(&body)
249            .send()?;
250        let status = resp.status();
251        if !status.is_success() {
252            let detail = resp.text().unwrap_or_default();
253            return Err(anyhow!("pair_abandon failed: {status}: {detail}"));
254        }
255        Ok(())
256    }
257
258    /// Read peer's SPAKE2 message + (eventually) sealed bootstrap from a pair-slot.
259    pub fn pair_get(
260        &self,
261        pair_id: &str,
262        as_role: &str,
263    ) -> Result<(Option<String>, Option<String>)> {
264        let resp = self
265            .client
266            .get(format!(
267                "{}/v1/pair/{pair_id}?as_role={as_role}",
268                self.base_url
269            ))
270            .send()?;
271        let status = resp.status();
272        if !status.is_success() {
273            let detail = resp.text().unwrap_or_default();
274            return Err(anyhow!("pair_get failed: {status}: {detail}"));
275        }
276        let v: Value = resp.json()?;
277        let peer_msg = v
278            .get("peer_msg")
279            .and_then(Value::as_str)
280            .map(str::to_string);
281        let peer_bootstrap = v
282            .get("peer_bootstrap")
283            .and_then(Value::as_str)
284            .map(str::to_string);
285        Ok((peer_msg, peer_bootstrap))
286    }
287
288    /// POST a sealed bootstrap payload to the pair-slot.
289    pub fn pair_bootstrap(&self, pair_id: &str, role: &str, sealed_b64: &str) -> Result<()> {
290        let body = serde_json::json!({"role": role, "sealed": sealed_b64});
291        let resp = self
292            .client
293            .post(format!("{}/v1/pair/{pair_id}/bootstrap", self.base_url))
294            .json(&body)
295            .send()?;
296        if !resp.status().is_success() {
297            let s = resp.status();
298            let detail = resp.text().unwrap_or_default();
299            return Err(anyhow!("pair_bootstrap failed: {s}: {detail}"));
300        }
301        Ok(())
302    }
303
304    /// Claim a `nick@<this-relay-domain>` handle (v0.5). Caller must hold
305    /// the bearer token for `slot_id`. FCFS on nick; same-DID re-claims OK.
306    pub fn handle_claim(
307        &self,
308        nick: &str,
309        slot_id: &str,
310        slot_token: &str,
311        relay_url: Option<&str>,
312        card: &Value,
313    ) -> Result<Value> {
314        let body = serde_json::json!({
315            "nick": nick,
316            "slot_id": slot_id,
317            "relay_url": relay_url,
318            "card": card,
319        });
320        let resp = self
321            .client
322            .post(format!("{}/v1/handle/claim", self.base_url))
323            .bearer_auth(slot_token)
324            .json(&body)
325            .send()
326            .with_context(|| format!("POST {}/v1/handle/claim", self.base_url))?;
327        let status = resp.status();
328        if !status.is_success() {
329            let detail = resp.text().unwrap_or_default();
330            return Err(anyhow!("handle_claim failed: {status}: {detail}"));
331        }
332        Ok(resp.json()?)
333    }
334
335    /// POST an intro (zero-paste pair-drop) event to a known nick's slot
336    /// without holding that slot's bearer token. Relay validates the event
337    /// is kind=1100 with an embedded signed agent-card; otherwise refuses.
338    pub fn handle_intro(&self, nick: &str, event: &Value) -> Result<Value> {
339        let body = serde_json::json!({"event": event});
340        let resp = self
341            .client
342            .post(format!("{}/v1/handle/intro/{nick}", self.base_url))
343            .json(&body)
344            .send()
345            .with_context(|| format!("POST {}/v1/handle/intro/{nick}", self.base_url))?;
346        let status = resp.status();
347        if !status.is_success() {
348            let detail = resp.text().unwrap_or_default();
349            return Err(anyhow!("handle_intro failed: {status}: {detail}"));
350        }
351        Ok(resp.json()?)
352    }
353
354    /// Resolve a handle on this relay via A2A v1.0 `.well-known/agent-card.json?handle=<nick>`.
355    /// Returns the parsed AgentCard JSON. Wire-served relays embed wire-native
356    /// fields (DID, slot_id, profile, raw card) under `extensions[0].params`.
357    /// Foreign A2A agents return their A2A card without wire ext — useful for
358    /// `wire whois` even when full mailbox pairing isn't possible.
359    pub fn well_known_agent_card_a2a(&self, handle: &str) -> Result<Value> {
360        let resp = self
361            .client
362            .get(format!("{}/.well-known/agent-card.json", self.base_url))
363            .query(&[("handle", handle)])
364            .send()
365            .with_context(|| {
366                format!(
367                    "GET {}/.well-known/agent-card.json?handle={handle}",
368                    self.base_url
369                )
370            })?;
371        let status = resp.status();
372        if !status.is_success() {
373            let detail = resp.text().unwrap_or_default();
374            return Err(anyhow!(
375                "well_known_agent_card_a2a failed: {status}: {detail}"
376            ));
377        }
378        Ok(resp.json()?)
379    }
380
381    /// Resolve a handle on this relay via `.well-known/wire/agent?handle=<nick>`.
382    /// Caller passes either the full `nick@domain` or just `<nick>` — the
383    /// server only uses the local part.
384    pub fn well_known_agent(&self, handle: &str) -> Result<Value> {
385        let resp = self
386            .client
387            .get(format!("{}/.well-known/wire/agent", self.base_url))
388            .query(&[("handle", handle)])
389            .send()
390            .with_context(|| {
391                format!(
392                    "GET {}/.well-known/wire/agent?handle={handle}",
393                    self.base_url
394                )
395            })?;
396        let status = resp.status();
397        if !status.is_success() {
398            let detail = resp.text().unwrap_or_default();
399            return Err(anyhow!("well_known_agent failed: {status}: {detail}"));
400        }
401        Ok(resp.json()?)
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn url_normalization_trims_trailing_slash() {
411        let c = RelayClient::new("http://example.com/");
412        assert_eq!(c.base_url, "http://example.com");
413        let c = RelayClient::new("http://example.com");
414        assert_eq!(c.base_url, "http://example.com");
415    }
416}