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}