Skip to main content

wire/
send.rs

1//! Synchronous event delivery — collapses the legacy
2//! `wire send → outbox → daemon push → relay` 3-step into a single
3//! direct relay POST.
4//!
5//! ## Why this exists
6//!
7//! Paul (2026-06-01): *"Why are we dealing with this whole outbox
8//! queued delivered thing it's a headache and always breaks can we
9//! streamline and collapse steps."*
10//!
11//! Pre-fix, every `wire send` (CLI and MCP) wrote to
12//! `<outbox_dir>/<peer>.jsonl` and returned `status: "queued"`. The
13//! daemon's 5s push loop later POSTed the event to the relay. Three
14//! distinct silent-drop classes hide in those steps:
15//!
16//! 1. **outbox write succeeds, daemon never pushes** — daemon dead,
17//!    daemon on wrong WIRE_HOME, TLS broken (the #176 → #183 saga),
18//!    operator never ran `wire push`. `queued` looked like success
19//!    but no byte ever left the box.
20//! 2. **daemon pushed, peer's relay slot stale** — earlier
21//!    half-paired state, peer rotated slot, slot_token expired (the
22//!    brisk-iris case). Push got 4xx, marked as skipped in the daemon
23//!    log, operator never sees it from the `wire send` side.
24//! 3. **content-hash dedup blocks retries** — `event_id` is
25//!    `sha256(canonical(body))`. Sending the same body twice produces
26//!    the same event_id; relay drops the second as `duplicate`. Retry
27//!    feels like success but never reaches the peer.
28//!
29//! ## The new contract
30//!
31//! - **Default** (`wire send`, `tool_send`): synchronous POST to the
32//!   peer's pinned relay slot. Returns `Delivered` / `Duplicate` /
33//!   `Failed` inline. No outbox write on the happy path. Operator
34//!   sees the actual verdict, not a fake `queued`.
35//!
36//! - **`--queue` opt-in** (CLI flag; MCP `queue: true` arg):
37//!   preserves the legacy outbox-write path for explicit batching /
38//!   offline-buffer / pre-pair queue use cases. The daemon's
39//!   `run_sync_push` loop continues to drain the outbox so anything
40//!   written via this path still delivers.
41//!
42//! - **Peer not pinned**: the relay coords are unknown — sync POST
43//!   is impossible. We error explicitly with a hint to run
44//!   `wire dial <peer>` (or pass `--queue` if the operator wants
45//!   pre-pair queueing). Pre-fix this case silently wrote to outbox
46//!   and the daemon would never push it; now it's loud.
47//!
48//! - **Stale slot (4xx from relay)**: return `Failed` with the slot
49//!   error string. The existing `cli::error_smells_like_slot_4xx`
50//!   classifier already detects this shape; the caller surfaces the
51//!   re-resolve hint. We do NOT auto-re-pair without the operator's
52//!   consent (that's `wire dial`'s job).
53
54use anyhow::{Context, Result};
55use serde::Serialize;
56use serde_json::{Value, json};
57
58/// Result of attempting a synchronous delivery to a peer.
59#[derive(Debug, Clone, Serialize)]
60#[serde(tag = "status", rename_all = "snake_case")]
61pub enum SyncDelivery {
62    /// Relay accepted the event. First-time landing on the peer's slot.
63    Delivered {
64        event_id: String,
65        relay_url: String,
66        slot_id: String,
67    },
68    /// Relay said `duplicate` — same `event_id` already on the slot.
69    /// Not a failure: the relay HAS the event, the peer can pull it.
70    /// Surfaced distinctly so the caller can decide whether to nudge
71    /// content uniqueness on the next attempt.
72    Duplicate {
73        event_id: String,
74        relay_url: String,
75        slot_id: String,
76    },
77    /// Delivered over the peer's **Nostr** transport (RFC-007 D3): no HTTP slot
78    /// was reachable, but the peer has a recorded `nostr_transport` and the
79    /// relay accepted the published NIP-01 event. `npub` is the peer's x-only
80    /// transport key the event was `p`-tagged to. Counts as relay-reached: the
81    /// peer can pull it with `wire nostr fetch`.
82    DeliveredNostr {
83        event_id: String,
84        relay_url: String,
85        npub: String,
86    },
87    /// Peer isn't in `relay_state.peers` — no slot coords to POST to.
88    /// This is the explicit "you haven't paired yet" case. The
89    /// caller should either suggest `wire dial <peer>` or write
90    /// to outbox via the `--queue` opt-in.
91    PeerUnknown { event_id: String },
92    /// Relay returned a 4xx/410 — slot has rotated, token expired,
93    /// peer half-paired and never completed bilateral. The caller
94    /// surfaces a hint to `wire dial <peer>`.
95    SlotStale {
96        event_id: String,
97        relay_url: String,
98        slot_id: String,
99        detail: String,
100    },
101    /// Transport failure (TLS, DNS, connect timeout, 5xx). The
102    /// caller decides whether to fall back to `--queue` or surface
103    /// the error.
104    TransportError {
105        event_id: String,
106        relay_url: String,
107        slot_id: String,
108        detail: String,
109    },
110}
111
112impl SyncDelivery {
113    /// Compact status string for callers that just want the verdict.
114    /// Same shape as the JSON `status` field.
115    pub fn status_str(&self) -> &'static str {
116        match self {
117            SyncDelivery::Delivered { .. } => "delivered",
118            SyncDelivery::Duplicate { .. } => "duplicate",
119            SyncDelivery::DeliveredNostr { .. } => "delivered_nostr",
120            SyncDelivery::PeerUnknown { .. } => "peer_unknown",
121            SyncDelivery::SlotStale { .. } => "slot_stale",
122            SyncDelivery::TransportError { .. } => "transport_error",
123        }
124    }
125
126    /// True when the event reached the relay (Delivered or
127    /// Duplicate). Both states mean the peer CAN pull it.
128    pub fn reached_relay(&self) -> bool {
129        matches!(
130            self,
131            SyncDelivery::Delivered { .. }
132                | SyncDelivery::Duplicate { .. }
133                | SyncDelivery::DeliveredNostr { .. }
134        )
135    }
136
137    pub fn event_id(&self) -> &str {
138        match self {
139            SyncDelivery::Delivered { event_id, .. }
140            | SyncDelivery::Duplicate { event_id, .. }
141            | SyncDelivery::DeliveredNostr { event_id, .. }
142            | SyncDelivery::PeerUnknown { event_id }
143            | SyncDelivery::SlotStale { event_id, .. }
144            | SyncDelivery::TransportError { event_id, .. } => event_id,
145        }
146    }
147}
148
149/// Attempt synchronous delivery of `signed_event` to `peer_handle`.
150///
151/// Reads the peer's slot coords from `relay_state.peers`, builds a
152/// `RelayClient`, POSTs the event. Maps every observable outcome onto
153/// a [`SyncDelivery`] variant.
154///
155/// On success (`Delivered` or `Duplicate`), appends a row to the
156/// per-peer pushed log (`<outbox_dir>/<peer>.pushed.jsonl`) so the
157/// `pending_push_count` counter in `wire status` stays accurate
158/// across both code paths (sync send + legacy daemon push).
159pub fn attempt_deliver(peer_handle: &str, signed_event: &Value) -> Result<SyncDelivery> {
160    let event_id = signed_event
161        .get("event_id")
162        .and_then(Value::as_str)
163        .unwrap_or("")
164        .to_string();
165
166    // RFC-006 Part B: resolve the peer's reachable endpoints from `endpoints[]`
167    // — the single peer-routing source — highest-priority first (UDS → local →
168    // LAN → federation). No pinned endpoints → PeerUnknown so the caller can
169    // act. We try each in order and return on the first that reaches the relay
170    // (priority failover — e.g. a sister's local relay first, federation as
171    // backup); if all fail, the last failure verdict is returned.
172    let state = crate::config::read_relay_state().context("reading relay state")?;
173    let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
174
175    let mut last_failure: Option<SyncDelivery> = None;
176    for ep in endpoints {
177        if ep.relay_url.is_empty() || ep.slot_id.is_empty() || ep.slot_token.is_empty() {
178            continue;
179        }
180        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
181        match client.post_event(&ep.slot_id, &ep.slot_token, signed_event) {
182            Ok(resp) => {
183                // Append a row to the per-peer pushed log so
184                // `pending_push_count` decrements regardless of whether the
185                // event reached the relay via sync send (this path) or via
186                // daemon push. Non-fatal on append failure.
187                let now = time::OffsetDateTime::now_utc()
188                    .format(&time::format_description::well_known::Rfc3339)
189                    .unwrap_or_default();
190                if let Err(e) = crate::config::append_pushed_log(peer_handle, &event_id, &now) {
191                    eprintln!(
192                        "wire send: pushed-log append for {peer_handle}/{event_id} failed (non-fatal): {e:#}"
193                    );
194                }
195                return Ok(if resp.status == "duplicate" {
196                    SyncDelivery::Duplicate {
197                        event_id,
198                        relay_url: ep.relay_url,
199                        slot_id: ep.slot_id,
200                    }
201                } else {
202                    SyncDelivery::Delivered {
203                        event_id,
204                        relay_url: ep.relay_url,
205                        slot_id: ep.slot_id,
206                    }
207                });
208            }
209            Err(e) => {
210                let detail = crate::relay_client::format_transport_error(&e);
211                // Classify 4xx/410 (stale slot) distinctly from transport
212                // errors; reuse the relay's error-text classifier so both
213                // paths agree. Keep as last_failure and try the next endpoint.
214                last_failure = Some(if crate::cli::error_smells_like_slot_4xx(&detail) {
215                    SyncDelivery::SlotStale {
216                        event_id: event_id.clone(),
217                        relay_url: ep.relay_url,
218                        slot_id: ep.slot_id,
219                        detail,
220                    }
221                } else {
222                    SyncDelivery::TransportError {
223                        event_id: event_id.clone(),
224                        relay_url: ep.relay_url,
225                        slot_id: ep.slot_id,
226                        detail,
227                    }
228                });
229            }
230        }
231    }
232
233    // No HTTP slot reached the peer (none recorded, or all failed). RFC-007 D3:
234    // if the peer has a recorded Nostr transport and this session is enrolled
235    // with a secp transport key, route the same signed wire event over Nostr.
236    // This is strictly a fallback — when the peer has no `nostr_transport` the
237    // HTTP verdict above is returned byte-identical.
238    if let Some((peer_npub, nostr_relay)) =
239        crate::endpoints::peer_nostr_transport(&state, peer_handle)
240        && let Ok(nsk) = crate::config::read_nostr_key()
241    {
242        match deliver_over_nostr(&peer_npub, &nostr_relay, signed_event, &nsk) {
243            Ok(true) => {
244                return Ok(SyncDelivery::DeliveredNostr {
245                    event_id,
246                    relay_url: nostr_relay,
247                    npub: peer_npub,
248                });
249            }
250            Ok(false) => {
251                last_failure = Some(SyncDelivery::TransportError {
252                    event_id: event_id.clone(),
253                    relay_url: nostr_relay,
254                    slot_id: String::new(),
255                    detail: "nostr relay rejected the event (OK=false)".to_string(),
256                });
257            }
258            Err(e) => {
259                last_failure = Some(SyncDelivery::TransportError {
260                    event_id: event_id.clone(),
261                    relay_url: nostr_relay,
262                    slot_id: String::new(),
263                    detail: format!("nostr publish failed: {e:#}"),
264                });
265            }
266        }
267    }
268
269    // Every endpoint failed (or all carried empty coords).
270    Ok(last_failure.unwrap_or(SyncDelivery::PeerUnknown { event_id }))
271}
272
273/// Encode `signed_event` as a NIP-01 event addressed (`p`-tagged) to the peer's
274/// x-only transport key `peer_npub_hex`, sign it with our secp transport key
275/// `nsk`, and publish it to `relay_url`. Returns the relay's OK verdict.
276///
277/// HTTP-slot transport is sync (`reqwest` blocking) but `NostrWs` is async, so
278/// we drive the one-shot publish on a fresh runtime (the bridge pattern shared
279/// with `cli/relay.rs` + `cli/nostr.rs`). Pure event-building is factored into
280/// [`build_addressed_nostr`] so it's unit-testable without a relay.
281fn deliver_over_nostr(
282    peer_npub_hex: &str,
283    relay_url: &str,
284    signed_event: &Value,
285    nsk: &[u8; 32],
286) -> Result<bool> {
287    let ev = build_addressed_nostr(signed_event, nsk, peer_npub_hex)?;
288    let rt = tokio::runtime::Builder::new_multi_thread()
289        .enable_all()
290        .build()
291        .context("build nostr runtime")?;
292    rt.block_on(async {
293        let mut ws = crate::nostr_ws::NostrWs::connect(relay_url)
294            .await
295            .with_context(|| format!("connect {relay_url}"))?;
296        ws.publish(&ev).await.context("publish over nostr")
297    })
298}
299
300/// Build the NIP-01 event for a Nostr-routed send: the full signed wire event
301/// rides in `content` (inner Ed25519 sig intact), schnorr-signed by our secp
302/// key and `p`-tagged to the peer. Surfaced for unit tests.
303fn build_addressed_nostr(
304    signed_event: &Value,
305    nsk: &[u8; 32],
306    peer_npub_hex: &str,
307) -> Result<crate::nostr_event::NostrEvent> {
308    crate::nostr_event::wire_to_nostr_addressed(signed_event, nsk, peer_npub_hex)
309        .map_err(|e| anyhow::anyhow!("encode wire event as nostr: {e}"))
310}
311
312/// Build the actionable `peer_unknown` reason string from the three states the
313/// old single message conflated (#284.6). `trusted` = a trust pin exists;
314/// `has_endpoint` = relay_state has any endpoint for the peer; `has_usable_slot`
315/// = at least one endpoint carries a non-empty `slot_token`. Pure → unit-tested.
316///
317/// The key operator guidance: when a peer is pinned but unsendable, the BARE
318/// nickname dial short-circuits to `already_pinned` WITHOUT re-registering the
319/// slot, so the fix is the FULL `<peer>@<relay>` dial.
320fn peer_unknown_reason(
321    peer: &str,
322    trusted: bool,
323    has_endpoint: bool,
324    has_usable_slot: bool,
325) -> String {
326    if !trusted {
327        format!(
328            "peer '{peer}' is not pinned — run `wire dial {peer}@<relay>` to pair, or pass --queue (CLI) / queue:true (MCP) to buffer for the daemon to attempt later"
329        )
330    } else if !has_endpoint {
331        format!(
332            "peer '{peer}' IS pinned but has no relay endpoint recorded — re-register with a FULL `wire dial {peer}@<relay>` (the bare nickname reports `already_pinned` WITHOUT re-registering the slot)"
333        )
334    } else if !has_usable_slot {
335        format!(
336            "peer '{peer}' IS pinned but its relay slot has no token yet — their pair_drop_ack hasn't landed (common right after a daemon/MCP restart). Re-run the FULL `wire dial {peer}@<relay>` (NOT the bare nickname) to re-register, then resend"
337        )
338    } else {
339        format!(
340            "peer '{peer}' could not be reached on any recorded endpoint — check `wire status`, then re-dial `{peer}@<relay>`"
341        )
342    }
343}
344
345/// Classify why `peer` is (un)sendable from live trust + relay-state.
346/// Returns `None` when the peer has at least one endpoint carrying a
347/// non-empty `slot_token` — i.e. a send has a route. Returns
348/// `Some(reason)` — the SAME actionable string the send path surfaces on
349/// `peer_unknown` — when the peer is not pinned, has no endpoint, or has an
350/// endpoint whose `slot_token` is still empty (the `pair_drop_ack` hasn't
351/// landed yet). Shared by the send path (`delivery_json`) and the dial path
352/// (`cmd_dial`) so the two surfaces can't drift: a bare-nick `wire dial`
353/// that resolves an already-pinned-but-unsendable peer can show the operator
354/// the exact same cause + next-command that a bouncing `wire send` would.
355pub(crate) fn unsendable_reason(peer: &str) -> Option<String> {
356    let trust = crate::config::read_trust().unwrap_or_default();
357    let state = crate::config::read_relay_state().unwrap_or_default();
358    let trusted = trust.get("agents").and_then(|a| a.get(peer)).is_some();
359    let eps = crate::endpoints::peer_endpoints_in_priority_order(&state, peer);
360    let has_endpoint = !eps.is_empty();
361    // Mirror the send loop's usability test EXACTLY (the `continue` skip in
362    // `sync_send`): an endpoint routes only when relay_url + slot_id +
363    // slot_token are ALL non-empty. A token sitting on an otherwise-malformed
364    // endpoint is skipped there, so it must not read as "usable" here either.
365    let has_usable_slot = eps
366        .iter()
367        .any(|e| !e.relay_url.is_empty() && !e.slot_id.is_empty() && !e.slot_token.is_empty());
368    // RFC-007 D3: the send path also delivers over Nostr when no HTTP endpoint
369    // routes, provided the peer has a recorded `nostr_transport` AND this
370    // session holds a secp transport key. A peer reachable only that way IS
371    // sendable — don't warn on dial that its HTTP slot has no token.
372    let nostr_reachable = crate::endpoints::peer_nostr_transport(&state, peer).is_some()
373        && crate::config::read_nostr_key().is_ok();
374    if has_usable_slot || nostr_reachable {
375        None
376    } else {
377        Some(peer_unknown_reason(
378            peer,
379            trusted,
380            has_endpoint,
381            has_usable_slot,
382        ))
383    }
384}
385
386/// Render a `SyncDelivery` as the JSON value `wire send --json` /
387/// `tool_send` return. Fields are flat (no nested struct) so JSON
388/// consumers can read `.status` + `.event_id` directly without
389/// pattern-matching the variant tag.
390pub fn delivery_json(d: &SyncDelivery, peer: &str) -> Value {
391    let base = json!({
392        "status": d.status_str(),
393        "peer": peer,
394        "event_id": d.event_id(),
395    });
396    let mut obj = base.as_object().cloned().unwrap_or_default();
397    match d {
398        SyncDelivery::Delivered {
399            relay_url, slot_id, ..
400        }
401        | SyncDelivery::Duplicate {
402            relay_url, slot_id, ..
403        } => {
404            obj.insert("relay_url".into(), json!(relay_url));
405            obj.insert("slot_id".into(), json!(slot_id));
406        }
407        SyncDelivery::DeliveredNostr {
408            relay_url, npub, ..
409        } => {
410            obj.insert("relay_url".into(), json!(relay_url));
411            obj.insert("transport".into(), json!("nostr"));
412            obj.insert("npub".into(), json!(npub));
413        }
414        SyncDelivery::SlotStale {
415            relay_url,
416            slot_id,
417            detail,
418            ..
419        }
420        | SyncDelivery::TransportError {
421            relay_url,
422            slot_id,
423            detail,
424            ..
425        } => {
426            obj.insert("relay_url".into(), json!(relay_url));
427            obj.insert("slot_id".into(), json!(slot_id));
428            obj.insert("reason".into(), json!(detail));
429        }
430        SyncDelivery::PeerUnknown { .. } => {
431            // #284.6: "peer_unknown" conflated three distinct states — no trust
432            // pin, pinned-but-no-endpoint, and pinned-with-an-endpoint-whose-
433            // slot_token-is-empty (the ack hasn't landed, common after a
434            // daemon/MCP restart). A peer can be VERIFIED in trust yet hit this.
435            // Classify against live state so the message names the real cause
436            // and the real fix (a FULL `@relay` dial, not the nickname short-
437            // circuit which reports already_pinned without re-registering).
438            // Via the shared `unsendable_reason` classifier the dial path
439            // reuses. `None` here would mean a usable slot exists despite the
440            // send reporting PeerUnknown (a route raced away mid-send) — fall
441            // back to the generic "could not be reached" guidance.
442            let reason = unsendable_reason(peer)
443                .unwrap_or_else(|| peer_unknown_reason(peer, true, true, true));
444            obj.insert("reason".into(), json!(reason));
445        }
446    }
447    Value::Object(obj)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn peer_unknown_reason_classifies_the_three_states() {
456        // Not pinned at all.
457        let r = peer_unknown_reason("p", false, false, false);
458        assert!(r.contains("is not pinned"), "{r}");
459        // Pinned but no endpoint → full dial, warn about nickname short-circuit.
460        let r = peer_unknown_reason("p", true, false, false);
461        assert!(r.contains("IS pinned"), "{r}");
462        assert!(r.contains("no relay endpoint"), "{r}");
463        assert!(r.contains("@<relay>"), "{r}");
464        assert!(r.contains("bare nickname"), "{r}");
465        // Pinned, endpoint exists, but slot_token empty (the #284.6 desync).
466        let r = peer_unknown_reason("p", true, true, false);
467        assert!(r.contains("no token yet"), "{r}");
468        assert!(r.contains("daemon/MCP restart"), "{r}");
469        assert!(r.contains("NOT the bare nickname"), "{r}");
470        // Pinned + usable slot but unreachable (fallback wording).
471        let r = peer_unknown_reason("p", true, true, true);
472        assert!(r.contains("could not be reached"), "{r}");
473    }
474
475    #[test]
476    fn unsendable_reason_reads_live_state() {
477        use crate::endpoints::{Endpoint, EndpointScope, pin_peer_endpoints};
478        crate::config::test_support::with_temp_home(|| {
479            // Unknown peer, empty home → the send path's "not pinned" verdict.
480            let r = unsendable_reason("ghost").expect("unknown peer is unsendable");
481            assert!(r.contains("is not pinned"), "{r}");
482
483            // Peer with a usable federation slot → sendable → None. Guards the
484            // common bare-nick dial: no spurious warning when a route exists.
485            let mut st = crate::config::read_relay_state().unwrap();
486            pin_peer_endpoints(
487                &mut st,
488                "live",
489                &[Endpoint {
490                    relay_url: "https://wireup.net".into(),
491                    slot_id: "slot-live".into(),
492                    slot_token: "tok-abc".into(),
493                    scope: EndpointScope::Federation,
494                }],
495            )
496            .unwrap();
497            crate::config::write_relay_state(&st).unwrap();
498            assert!(
499                unsendable_reason("live").is_none(),
500                "peer with a non-empty slot_token must read as sendable"
501            );
502
503            // Pinned in trust but its endpoint token is still empty (the
504            // pair_drop_ack hasn't landed — the #284.6 desync the dial path
505            // must now surface instead of a bland `already_pinned`).
506            let mut st2 = crate::config::read_relay_state().unwrap();
507            pin_peer_endpoints(
508                &mut st2,
509                "pending",
510                &[Endpoint {
511                    relay_url: "https://wireup.net".into(),
512                    slot_id: "slot-pending".into(),
513                    slot_token: String::new(),
514                    scope: EndpointScope::Federation,
515                }],
516            )
517            .unwrap();
518            crate::config::write_relay_state(&st2).unwrap();
519            crate::config::update_trust(|t| {
520                t.get_mut("agents")
521                    .and_then(Value::as_object_mut)
522                    .unwrap()
523                    .insert(
524                        "pending".into(),
525                        json!({"did": "did:wire:pending-0000", "tier": "VERIFIED"}),
526                    );
527                Ok(())
528            })
529            .unwrap();
530            let r = unsendable_reason("pending").expect("empty-token peer is unsendable");
531            assert!(r.contains("no token yet"), "{r}");
532
533            // Reachable only over Nostr: empty HTTP token, but a recorded
534            // nostr_transport + a local nostr key → sendable (the RFC-007 D3
535            // fallback the send path takes), so NO dial warning.
536            let mut st3 = crate::config::read_relay_state().unwrap();
537            pin_peer_endpoints(
538                &mut st3,
539                "nostronly",
540                &[Endpoint {
541                    relay_url: "https://wireup.net".into(),
542                    slot_id: "slot-n".into(),
543                    slot_token: String::new(),
544                    scope: EndpointScope::Federation,
545                }],
546            )
547            .unwrap();
548            st3["peers"]["nostronly"]["nostr_transport"] =
549                json!({"npub": "npub1xxx", "relay": "wss://relay.example"});
550            crate::config::write_relay_state(&st3).unwrap();
551            crate::config::write_nostr_key(&[3u8; 32]).unwrap();
552            assert!(
553                unsendable_reason("nostronly").is_none(),
554                "a Nostr-reachable peer must read as sendable despite an empty HTTP token"
555            );
556
557            // Malformed endpoint: a token present but relay_url/slot_id empty is
558            // NOT a usable route (the send loop skips it), so still unsendable —
559            // matches the send path's `continue` skip exactly.
560            let mut st4 = crate::config::read_relay_state().unwrap();
561            pin_peer_endpoints(
562                &mut st4,
563                "malformed",
564                &[Endpoint {
565                    relay_url: String::new(),
566                    slot_id: String::new(),
567                    slot_token: "tok-orphan".into(),
568                    scope: EndpointScope::Federation,
569                }],
570            )
571            .unwrap();
572            crate::config::write_relay_state(&st4).unwrap();
573            assert!(
574                unsendable_reason("malformed").is_some(),
575                "a token on an endpoint with empty relay_url/slot_id is not usable"
576            );
577        });
578    }
579
580    #[test]
581    fn status_str_matches_variant() {
582        let d = SyncDelivery::Delivered {
583            event_id: "x".into(),
584            relay_url: "https://r".into(),
585            slot_id: "s".into(),
586        };
587        assert_eq!(d.status_str(), "delivered");
588        assert!(d.reached_relay());
589
590        let d = SyncDelivery::Duplicate {
591            event_id: "x".into(),
592            relay_url: "https://r".into(),
593            slot_id: "s".into(),
594        };
595        assert_eq!(d.status_str(), "duplicate");
596        assert!(
597            d.reached_relay(),
598            "duplicate counts as relay-reached: peer can pull it"
599        );
600
601        let d = SyncDelivery::PeerUnknown {
602            event_id: "x".into(),
603        };
604        assert_eq!(d.status_str(), "peer_unknown");
605        assert!(!d.reached_relay());
606
607        let d = SyncDelivery::SlotStale {
608            event_id: "x".into(),
609            relay_url: "https://r".into(),
610            slot_id: "s".into(),
611            detail: "410".into(),
612        };
613        assert_eq!(d.status_str(), "slot_stale");
614        assert!(!d.reached_relay());
615
616        let d = SyncDelivery::TransportError {
617            event_id: "x".into(),
618            relay_url: "https://r".into(),
619            slot_id: "s".into(),
620            detail: "tls".into(),
621        };
622        assert_eq!(d.status_str(), "transport_error");
623        assert!(!d.reached_relay());
624    }
625
626    #[test]
627    fn delivered_nostr_counts_as_reached_and_renders_transport() {
628        let d = SyncDelivery::DeliveredNostr {
629            event_id: "ev1".into(),
630            relay_url: "wss://relay.example".into(),
631            npub: "ab".repeat(32),
632        };
633        assert_eq!(d.status_str(), "delivered_nostr");
634        assert!(
635            d.reached_relay(),
636            "nostr delivery means the peer can pull it"
637        );
638        assert_eq!(d.event_id(), "ev1");
639
640        let v = delivery_json(&d, "alice");
641        assert_eq!(v["status"], "delivered_nostr");
642        assert_eq!(v["peer"], "alice");
643        assert_eq!(v["event_id"], "ev1");
644        assert_eq!(v["relay_url"], "wss://relay.example");
645        assert_eq!(v["transport"], "nostr");
646        assert_eq!(v["npub"], "ab".repeat(32));
647        // No HTTP-slot field on the nostr path.
648        assert!(v.get("slot_id").is_none(), "nostr send has no slot_id");
649        assert!(v.get("reason").is_none(), "success has no reason");
650    }
651
652    #[test]
653    fn build_addressed_nostr_is_verifiable_and_addressed() {
654        use crate::nostr_key::generate_transport_key;
655        use crate::signing::{generate_keypair, sign_message_v31};
656
657        let (sk, pk) = generate_keypair();
658        let wire = sign_message_v31(
659            &json!({
660                "v": "3.1",
661                "timestamp": "2026-06-14T12:00:00Z",
662                "from": "did:wire:slate-lotus-88232017",
663                "to": "did:wire:raven-kettle-1234",
664                "kind": 1,
665                "body": {"content": "routed over nostr"},
666            }),
667            &sk,
668            &pk,
669            "slate-lotus",
670        )
671        .unwrap();
672
673        let (nsk, _x) = generate_transport_key();
674        let (_psk, peer_x) = generate_transport_key();
675        let peer_hex = hex::encode(peer_x);
676
677        let ev = build_addressed_nostr(&wire, &nsk, &peer_hex).unwrap();
678        // Addressed to the peer (their #p filter selects on this).
679        assert!(
680            ev.tags
681                .iter()
682                .any(|t| t.first().map(String::as_str) == Some("p") && t.get(1) == Some(&peer_hex))
683        );
684        // Transport authenticates and the full signed wire event survives intact.
685        assert_eq!(crate::nostr_event::verify_and_decode(&ev).unwrap(), wire);
686    }
687
688    #[test]
689    fn delivery_json_includes_reason_only_for_failures() {
690        let ok = SyncDelivery::Delivered {
691            event_id: "abc".into(),
692            relay_url: "https://r".into(),
693            slot_id: "s".into(),
694        };
695        let v = delivery_json(&ok, "alice");
696        assert_eq!(v["status"], "delivered");
697        assert_eq!(v["event_id"], "abc");
698        assert_eq!(v["peer"], "alice");
699        assert_eq!(v["relay_url"], "https://r");
700        assert!(v.get("reason").is_none(), "happy path has no reason field");
701
702        let bad = SyncDelivery::TransportError {
703            event_id: "abc".into(),
704            relay_url: "https://r".into(),
705            slot_id: "s".into(),
706            detail: "TLS error: UnknownIssuer".into(),
707        };
708        let v = delivery_json(&bad, "alice");
709        assert_eq!(v["status"], "transport_error");
710        assert_eq!(v["reason"], "TLS error: UnknownIssuer");
711
712        let unknown = SyncDelivery::PeerUnknown {
713            event_id: "abc".into(),
714        };
715        let v = delivery_json(&unknown, "alice");
716        assert_eq!(v["status"], "peer_unknown");
717        assert!(
718            v["reason"]
719                .as_str()
720                .unwrap_or("")
721                .contains("wire dial alice")
722        );
723    }
724}