Skip to main content

vector_core/community/
send.rs

1//! Sending and fetching Community channel messages over a [`Transport`].
2//!
3//! `publish_message` seals a message (envelope) and publishes the outer event to
4//! the Community's relays. `fetch_channel_messages` queries the channel's current
5//! epoch pseudonym, then decrypts + verifies each event, silently dropping any that
6//! fail (wrong key, splice, bad signature) — a non-member, or a spliced event, never
7//! surfaces. Both are transport-agnostic so they run identically against the live
8//! client and the in-memory test relay.
9
10use nostr_sdk::prelude::*;
11
12use super::derive::channel_pseudonym;
13use super::envelope::{open_message_multi, seal_message_with_ephemeral, seal_with_signed_inner, OpenedMessage};
14#[cfg(test)]
15use super::envelope::open_message;
16use super::transport::{Evidence, Query, Transport};
17use super::{Channel, Community};
18use crate::stored_event::event_kind;
19
20/// Seal `content` and publish it to the Community's relays.
21///
22/// Returns the published outer event AND its **retained ephemeral signing key**.
23/// The key is one-time on the wire (no persistent author↔channel linkage), but
24/// the sender keeps it so they can later [`delete_own_message`] their own message
25/// Persist it (see `db::community::store_message_key`) — exactly like Vector's
26/// `nip17_wrap_keys` for DMs. Discarding it just means that message can't be deleted.
27pub async fn publish_message<T: Transport + ?Sized>(
28    transport: &T,
29    community: &Community,
30    channel: &Channel,
31    author: &Keys,
32    content: &str,
33    ms: u64,
34) -> Result<(Event, Keys), String> {
35    let ephemeral = Keys::generate();
36    let outer = seal_message_with_ephemeral(
37        &ephemeral, author, &channel.key, &channel.id, channel.epoch, content, ms,
38    )
39    .map_err(|e| e.to_string())?;
40    transport.publish(&outer, &community.relays).await?;
41    Ok((outer, ephemeral))
42}
43
44/// Publish a message whose inner authorship event was already signed externally (via the
45/// active `NostrSigner` — local keys OR a NIP-46 bunker). Mirrors [`publish_message`] but
46/// is signer-agnostic, so bunker accounts can post (parity with DMs). Returns the
47/// published outer event + its retained ephemeral key.
48/// `durable`: control/moderation events (a hide, a presence-join that must reliably land so the sender
49/// stays in the observed recipient set) broadcast durably (per-relay retry); ordinary chat messages
50/// pass `false` for the latency-sensitive single-attempt path.
51pub async fn publish_signed_message<T: Transport + ?Sized>(
52    transport: &T,
53    community: &Community,
54    channel: &Channel,
55    inner: &Event,
56    durable: bool,
57) -> Result<(Event, Keys), String> {
58    let ephemeral = Keys::generate();
59    let outer = seal_with_signed_inner(&ephemeral, inner, &channel.key, &channel.id, channel.epoch)
60        .map_err(|e| e.to_string())?;
61    if durable {
62        transport.publish_durable(&outer, &community.relays).await?;
63    } else {
64        transport.publish(&outer, &community.relays).await?;
65    }
66    Ok((outer, ephemeral))
67}
68
69/// Delete a message the sender previously published, via its retained ephemeral key
70/// (NIP-09 — the deletion must be signed by the same key that signed the event, so
71/// only the original sender can delete their own message).
72pub async fn delete_own_message<T: Transport + ?Sized>(
73    transport: &T,
74    relays: &[String],
75    ephemeral: &Keys,
76    outer_event_id: EventId,
77) -> Result<(), String> {
78    let deletion = EventBuilder::delete(EventDeletionRequest::new().ids([outer_event_id]))
79        .sign_with_keys(ephemeral)
80        .map_err(|e| e.to_string())?;
81    transport.publish_durable(&deletion, relays).await
82}
83
84/// Every epoch pseudonym the member can derive for a channel (one per retained `(epoch, key)`), so a
85/// fetch spans ALL held epochs — messages posted under an older epoch aren't stranded after a rekey
86/// catch-up. Falls back to the head epoch for send-built/test channels (`read_epoch_keys`).
87fn channel_read_pseudonyms(channel: &Channel) -> Vec<String> {
88    channel
89        .read_epoch_keys()
90        .iter()
91        .map(|(epoch, key)| channel_pseudonym(key, &channel.id, *epoch).to_hex())
92        .collect()
93}
94
95/// Fetch + open all messages across the channel's held epochs. Events that fail to
96/// open (wrong key, splice, forged signature) are dropped, not surfaced.
97pub async fn fetch_channel_messages<T: Transport + ?Sized>(
98    transport: &T,
99    community: &Community,
100    channel: &Channel,
101) -> Result<Vec<OpenedMessage>, String> {
102    let query = Query {
103        kinds: vec![event_kind::COMMUNITY_MESSAGE],
104        z_tags: channel_read_pseudonyms(channel),
105        since: None,
106        // Positive-data read: signed messages from any relay are safe, gaps
107        // heal via the straggler sink + live sub. No verdict drawn from absence.
108        evidence: Evidence::Fast,
109        ..Default::default()
110    };
111    let events = transport.fetch(&query, &community.relays).await?;
112    // Drop events that fail to open (wrong key, splice, forged sig, bad version) —
113    // a non-member or spliced event must never surface. Log drops (id + error only,
114    // never content or keys) so a flood of garbage under a known pseudonym is visible
115    // rather than indistinguishable from an empty channel.
116    let epoch_keys = channel.read_epoch_keys();
117    let mut opened: Vec<OpenedMessage> = Vec::new();
118    let mut dropped = 0usize;
119    for ev in &events {
120        match open_message_multi(ev, &channel.id, &epoch_keys) {
121            Ok(msg) => opened.push(msg),
122            Err(e) => {
123                dropped += 1;
124                crate::log_debug!("[community] dropped event {}: {}", ev.id.to_hex(), e);
125            }
126        }
127    }
128    if dropped > 0 {
129        crate::log_debug!(
130            "[community] channel {} fetch: {} opened, {} dropped",
131            channel.id.to_hex(),
132            opened.len(),
133            dropped
134        );
135    }
136    // Dedup on the INNER (message) id, never the outer wrapper id. One inner
137    // message can ride multiple outer wrappers — a member re-broadcasting, redundant
138    // multi-relay copies, or an exact replay — and they must collapse to one row. Keep
139    // the first occurrence.
140    {
141        let mut seen = std::collections::HashSet::new();
142        opened.retain(|m| seen.insert(m.message_id));
143    }
144    // Deterministic chat order: inner authenticated ms timestamp, ties by inner id.
145    opened.sort_by(|a, b| {
146        a.ms.unwrap_or(0)
147            .cmp(&b.ms.unwrap_or(0))
148            .then_with(|| a.message_id.to_hex().cmp(&b.message_id.to_hex()))
149    });
150    Ok(opened)
151}
152
153/// Raw fetch of every append-plane event — messages (3300), reactions (3301), edits (3302)
154/// — for a channel's CURRENT-epoch pseudonym. Backfill/cold-start primitive ("recent on
155/// open"): unlike [`fetch_channel_messages`] this returns the un-opened outer events of all
156/// sub-kinds so the caller can run them through `inbound::process_channel_batch`, which opens,
157/// verifies, dedups (inner id), and applies reactions/edits to their target messages.
158pub async fn fetch_channel_events<T: Transport + ?Sized>(
159    transport: &T,
160    community: &Community,
161    channel: &Channel,
162) -> Result<Vec<Event>, String> {
163    let query = Query {
164        kinds: vec![
165            event_kind::COMMUNITY_MESSAGE,
166            event_kind::COMMUNITY_REACTION,
167            event_kind::COMMUNITY_EDIT,
168            event_kind::COMMUNITY_DELETE,
169            event_kind::COMMUNITY_PRESENCE,
170            event_kind::COMMUNITY_KICK,
171            event_kind::COMMUNITY_WEBXDC,
172        ],
173        z_tags: channel_read_pseudonyms(channel),
174        since: None,
175        // Positive-data read (see fetch_channel_messages).
176        evidence: Evidence::Fast,
177        ..Default::default()
178    };
179    transport.fetch(&query, &community.relays).await
180}
181
182/// Fetch one PAGE of a channel's append-plane events (3300/3301/3302) for its current-epoch
183/// pseudonym, newest-first, capped at `limit`. `until` (seconds, inclusive) pages OLDER
184/// history — pass the oldest-known message's `created_at` to step back a page; pass `None`
185/// for the latest page. The Discord-style sync primitive: latest-page on open/join/boot,
186/// older-page when local DB history is exhausted on scroll-up. Returns raw outer events for
187/// `inbound::process_channel_batch`.
188pub async fn fetch_channel_page<T: Transport + ?Sized>(
189    transport: &T,
190    community: &Community,
191    channel: &Channel,
192    until: Option<u64>,
193    since: Option<u64>,
194    limit: usize,
195) -> Result<Vec<Event>, String> {
196    let query = Query {
197        kinds: vec![
198            event_kind::COMMUNITY_MESSAGE,
199            event_kind::COMMUNITY_REACTION,
200            event_kind::COMMUNITY_EDIT,
201            event_kind::COMMUNITY_DELETE,
202            event_kind::COMMUNITY_PRESENCE,
203            event_kind::COMMUNITY_KICK,
204            event_kind::COMMUNITY_WEBXDC,
205        ],
206        // OR-set over every held epoch pseudonym: the relay returns the newest `limit` events ACROSS
207        // epochs for this `until`, so a "latest 20" page naturally spans rekeys (newest epoch fills
208        // first, older epochs backfill the deficit) and scroll-back keeps walking older epochs.
209        z_tags: channel_read_pseudonyms(channel),
210        until,
211        // `since` (latest-page only) skips re-pulling events already held — set to the newest wire
212        // time seen. Inclusive on the relay, so the boundary second is re-admitted (dedup drops it),
213        // catching any sibling event sharing that second. Epoch spanning is unaffected (it's in
214        // z_tags, above), and back-pagination passes `None` here.
215        since,
216        limit: Some(limit),
217        // Latest pages are positive-data reads. Older pages (`until` set) are
218        // force-promoted to Full by the transport — the history-start latch
219        // needs the completest union the reachable relays allow.
220        evidence: Evidence::Fast,
221        ..Default::default()
222    };
223    transport.fetch(&query, &community.relays).await
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::community::transport::memory::MemoryRelay;
230    use crate::community::{Channel, ChannelKey, Epoch};
231
232    /// A Community with a fixed relay set for tests.
233    fn community() -> Community {
234        Community::create("HQ", "general", vec!["r1".into(), "r2".into(), "r3".into()])
235    }
236
237    /// Simulate a second member: same Community keys (they were handed the keys on
238    /// join), but a distinct identity for authorship.
239    fn member_view(of: &Community) -> Community {
240        Community {
241            id: of.id,
242            server_root_key: of.server_root_key.clone(),
243            server_root_epoch: of.server_root_epoch,
244            name: of.name.clone(),
245            description: of.description.clone(),
246            icon: of.icon.clone(),
247            banner: of.banner.clone(),
248            relays: of.relays.clone(),
249            channels: of.channels.clone(),
250            owner_attestation: of.owner_attestation.clone(),
251            dissolved: of.dissolved,
252        }
253    }
254
255    #[tokio::test]
256    async fn two_clients_exchange_via_relay() {
257        let relay = MemoryRelay::new();
258        let community = community();
259        let channel = community.channels[0].clone();
260
261        let alice = Keys::generate();
262        publish_message(&relay, &community, &channel, &alice, "gm from alice", 100)
263            .await
264            .unwrap();
265
266        // Bob holds the same channel key (joined member) and reads it back.
267        let bob_view = member_view(&community);
268        let msgs = fetch_channel_messages(&relay, &bob_view, &bob_view.channels[0])
269            .await
270            .unwrap();
271        assert_eq!(msgs.len(), 1);
272        assert_eq!(msgs[0].content, "gm from alice");
273        assert_eq!(msgs[0].author, alice.public_key());
274    }
275
276    /// Build a single-epoch channel VIEW (for publishing under a specific epoch key).
277    fn epoch_view(base: &Channel, key: ChannelKey, epoch: u64) -> Channel {
278        Channel {
279            id: base.id, key, epoch: Epoch(epoch), name: base.name.clone(),
280            banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(),
281            dissolved: false,
282        }
283    }
284
285    #[tokio::test]
286    async fn fetch_spans_held_epochs_after_rekeys() {
287        // multi-epoch read: a member who caught up across rekeys (holds keys for epochs 0,1,2) fetches
288        // the messages posted under EACH — none stranded by a rekey.
289        let relay = MemoryRelay::new();
290        let community = community();
291        let base = community.channels[0].clone();
292        let alice = Keys::generate();
293        let k0 = base.key.clone();
294        let k1 = ChannelKey([0x11u8; 32]);
295        let k2 = ChannelKey([0x22u8; 32]);
296
297        publish_message(&relay, &community, &epoch_view(&base, k0.clone(), 0), &alice, "epoch0", 100).await.unwrap();
298        publish_message(&relay, &community, &epoch_view(&base, k1.clone(), 1), &alice, "epoch1", 200).await.unwrap();
299        publish_message(&relay, &community, &epoch_view(&base, k2.clone(), 2), &alice, "epoch2", 300).await.unwrap();
300
301        // Reader holds ALL three epoch keys (head = epoch 2). One fetch returns all three, time-ordered.
302        let mut reader = member_view(&community);
303        reader.channels[0] = Channel {
304            id: base.id, key: k2.clone(), epoch: Epoch(2), name: base.name.clone(),
305            banned: Vec::new(), protected: Vec::new(), roster: Default::default(),
306            epoch_keys: vec![(Epoch(0), k0), (Epoch(1), k1), (Epoch(2), k2.clone())],
307            dissolved: false,
308        };
309        let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0]).await.unwrap();
310        let contents: Vec<&str> = msgs.iter().map(|m| m.content.as_str()).collect();
311        assert_eq!(contents, vec!["epoch0", "epoch1", "epoch2"], "every held epoch's messages returned, none stranded");
312
313        // Regression: a reader holding ONLY the head epoch (no archive → single-epoch fallback) sees just
314        // the current epoch — the old behavior, confirming the archive is what unlocks history.
315        let mut head_only = member_view(&community);
316        head_only.channels[0] = epoch_view(&base, k2, 2);
317        let only = fetch_channel_messages(&relay, &head_only, &head_only.channels[0]).await.unwrap();
318        assert_eq!(only.iter().map(|m| m.content.as_str()).collect::<Vec<_>>(), vec!["epoch2"],
319            "head-only reader sees only the current epoch (single-epoch fallback)");
320    }
321
322    #[tokio::test]
323    async fn page_spans_the_epoch_boundary() {
324        // A single page query covers the held-epoch OR-set, so one page can carry messages from BOTH the
325        // head epoch AND an older one (the "fill the page across epochs" mechanic) — and each opens under
326        // its own epoch key via the per-event #z selection.
327        let relay = MemoryRelay::new();
328        let community = community();
329        let base = community.channels[0].clone();
330        let alice = Keys::generate();
331        let k0 = base.key.clone();
332        let k1 = ChannelKey([0x33u8; 32]);
333        publish_message(&relay, &community, &epoch_view(&base, k0.clone(), 0), &alice, "old-a", 100).await.unwrap();
334        publish_message(&relay, &community, &epoch_view(&base, k0.clone(), 0), &alice, "old-b", 200).await.unwrap();
335        publish_message(&relay, &community, &epoch_view(&base, k1.clone(), 1), &alice, "new-c", 300).await.unwrap();
336
337        let mut reader = member_view(&community);
338        reader.channels[0] = Channel {
339            id: base.id, key: k1.clone(), epoch: Epoch(1), name: base.name.clone(),
340            banned: Vec::new(), protected: Vec::new(), roster: Default::default(),
341            epoch_keys: vec![(Epoch(0), k0), (Epoch(1), k1)],
342            dissolved: false,
343        };
344        let page = fetch_channel_page(&relay, &reader, &reader.channels[0], None, None, 20).await.unwrap();
345        let opened: Vec<String> = page.iter()
346            .filter_map(|e| open_message_multi(e, &reader.channels[0].id, &reader.channels[0].read_epoch_keys()).ok())
347            .map(|m| m.content)
348            .collect();
349        assert!(opened.contains(&"new-c".to_string()), "head-epoch message in the page");
350        assert!(opened.contains(&"old-a".to_string()) && opened.contains(&"old-b".to_string()),
351            "older-epoch messages in the SAME page (across the epoch boundary)");
352    }
353
354    #[tokio::test]
355    async fn three_clients_one_broadcast() {
356        // O(1) broadcast: Alice publishes once; Bob AND Carol both decrypt it.
357        let relay = MemoryRelay::new();
358        let community = community();
359        let channel = community.channels[0].clone();
360        let alice = Keys::generate();
361        publish_message(&relay, &community, &channel, &alice, "hello all", 1)
362            .await
363            .unwrap();
364
365        for reader in [member_view(&community), member_view(&community)] {
366            let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0])
367                .await
368                .unwrap();
369            assert_eq!(msgs.len(), 1);
370            assert_eq!(msgs[0].content, "hello all");
371        }
372    }
373
374    #[tokio::test]
375    async fn non_member_with_wrong_key_cannot_read() {
376        let relay = MemoryRelay::new();
377        let community = community();
378        let channel = community.channels[0].clone();
379        let alice = Keys::generate();
380        publish_message(&relay, &community, &channel, &alice, "secret", 1)
381            .await
382            .unwrap();
383
384        // Outsider holds a DIFFERENT channel key (same id/relays). They can't even
385        // derive the right pseudonym, so the query returns nothing...
386        let mut outsider = member_view(&community);
387        outsider.channels[0].key = ChannelKey([0xeeu8; 32]);
388        let msgs = fetch_channel_messages(&relay, &outsider, &outsider.channels[0])
389            .await
390            .unwrap();
391        assert!(msgs.is_empty(), "wrong key derives a different pseudonym → no hits");
392
393        // ...and even handed the raw event, opening it fails (MAC).
394        let raw = relay
395            .fetch(
396                &Query { kinds: vec![event_kind::COMMUNITY_MESSAGE], ..Default::default() },
397                &community.relays,
398            )
399            .await
400            .unwrap();
401        assert_eq!(raw.len(), 1);
402        assert!(open_message(&raw[0], &outsider.channels[0].key, &channel.id, channel.epoch).is_err());
403    }
404
405    #[tokio::test]
406    async fn messages_return_in_ms_order() {
407        let relay = MemoryRelay::new();
408        let community = community();
409        let channel = community.channels[0].clone();
410        let alice = Keys::generate();
411        // Publish out of order.
412        publish_message(&relay, &community, &channel, &alice, "third", 300).await.unwrap();
413        publish_message(&relay, &community, &channel, &alice, "first", 100).await.unwrap();
414        publish_message(&relay, &community, &channel, &alice, "second", 200).await.unwrap();
415
416        let reader = member_view(&community);
417        let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0]).await.unwrap();
418        let contents: Vec<&str> = msgs.iter().map(|m| m.content.as_str()).collect();
419        assert_eq!(contents, vec!["first", "second", "third"]);
420    }
421
422    #[tokio::test]
423    async fn other_channel_traffic_is_not_returned() {
424        // A second channel's messages (different key+id → different pseudonym) must
425        // not appear when fetching the first channel.
426        let relay = MemoryRelay::new();
427        let community = community();
428        let chan_a = community.channels[0].clone();
429        let chan_b = Channel {
430            id: super::super::ChannelId([0x77u8; 32]),
431            key: ChannelKey([0x88u8; 32]),
432            epoch: Epoch(0),
433            name: "other".into(),
434            banned: Vec::new(),
435            protected: Vec::new(), roster: Default::default(),
436            epoch_keys: Vec::new(),
437            dissolved: false,
438        };
439        let alice = Keys::generate();
440        publish_message(&relay, &community, &chan_a, &alice, "in A", 1).await.unwrap();
441        publish_message(&relay, &community, &chan_b, &alice, "in B", 1).await.unwrap();
442
443        let reader = member_view(&community);
444        let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0]).await.unwrap();
445        assert_eq!(msgs.len(), 1);
446        assert_eq!(msgs[0].content, "in A");
447    }
448
449    #[tokio::test]
450    async fn two_distinct_messages_are_not_collapsed() {
451        // Two DISTINCT inner messages (different message_id) must return as two rows —
452        // dedup keys on the inner id, so it must not over-collapse genuinely different
453        // messages.
454        let relay = MemoryRelay::new();
455        let community = community();
456        let channel = community.channels[0].clone();
457        let alice = Keys::generate();
458        publish_message(&relay, &community, &channel, &alice, "one", 1).await.unwrap();
459        publish_message(&relay, &community, &channel, &alice, "two", 2).await.unwrap();
460
461        let reader = member_view(&community);
462        let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0]).await.unwrap();
463        assert_eq!(msgs.len(), 2);
464        let contents: Vec<&str> = msgs.iter().map(|m| m.content.as_str()).collect();
465        assert!(contents.contains(&"one") && contents.contains(&"two"));
466    }
467
468    #[tokio::test]
469    async fn backfill_fetches_and_applies_messages_then_reactions() {
470        // Cold-start backfill core: fetch the raw channel events and process them as a batch
471        // into a fresh STATE. Messages must ingest AND a reaction must land on its target —
472        // which only works if the batch processes messages before control events (relay
473        // return order is arbitrary).
474        use super::super::inbound::{process_channel_batch, IncomingEvent};
475        let relay = MemoryRelay::new();
476        let community = community();
477        let channel = community.channels[0].clone();
478        let alice = Keys::generate();
479        let bob = Keys::generate();
480
481        let (m1_outer, _) = publish_message(&relay, &community, &channel, &alice, "hello", 1).await.unwrap();
482        publish_message(&relay, &community, &channel, &alice, "world", 2).await.unwrap();
483        // Bob reacts to m1 (a 3301 referencing m1's INNER id).
484        let m1_inner = open_message(&m1_outer, &channel.key, &channel.id, channel.epoch).unwrap().message_id.to_hex();
485        let react_inner = super::super::envelope::build_inner_typed(
486            bob.public_key(), &channel.id, channel.epoch,
487            event_kind::COMMUNITY_REACTION, "🔥", 3, Some(&m1_inner), &[],
488        ).sign_with_keys(&bob).unwrap();
489        let react_outer = seal_with_signed_inner(&Keys::generate(), &react_inner, &channel.key, &channel.id, channel.epoch).unwrap();
490        relay.publish(&react_outer, &community.relays).await.unwrap();
491
492        let events = fetch_channel_events(&relay, &community, &channel).await.unwrap();
493        assert_eq!(events.len(), 3, "two messages + one reaction fetched");
494        let mut state = crate::state::ChatState::new();
495        let applied = process_channel_batch(&mut state, &events, &channel, &bob.public_key());
496
497        let new_msgs = applied.iter().filter(|e| matches!(e, IncomingEvent::NewMessage(_))).count();
498        let updates: Vec<&String> = applied.iter().filter_map(|e| match e {
499            IncomingEvent::Updated { target_id, .. } => Some(target_id),
500            _ => None,
501        }).collect();
502        assert_eq!(new_msgs, 2, "both messages backfilled");
503        assert_eq!(updates.len(), 1, "reaction applied during backfill");
504        assert_eq!(updates[0], &m1_inner, "reaction landed on its target message");
505    }
506
507    #[tokio::test]
508    async fn same_inner_message_in_two_wrappers_collapses() {
509        // dedup on the INNER id. The SAME signed inner message, sealed into two
510        // DIFFERENT outer wrappers (distinct ephemeral keys → distinct outer ids, e.g. a
511        // member re-broadcast or a replay), must collapse to a single row on fetch.
512        let relay = MemoryRelay::new();
513        let community = community();
514        let channel = community.channels[0].clone();
515        let alice = Keys::generate();
516
517        // One signed inner authorship event → one message_id.
518        let inner = super::super::envelope::build_inner_event(alice.public_key(), &channel.id, channel.epoch, "dup me", 1, None)
519            .sign_with_keys(&alice)
520            .unwrap();
521        // Two independent outer wrappers carrying that exact inner.
522        let outer_a = seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap();
523        let outer_b = seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap();
524        assert_ne!(outer_a.id, outer_b.id, "distinct outer wrappers");
525        relay.publish(&outer_a, &community.relays).await.unwrap();
526        relay.publish(&outer_b, &community.relays).await.unwrap();
527
528        let reader = member_view(&community);
529        let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0]).await.unwrap();
530        assert_eq!(msgs.len(), 1, "same inner id collapses across wrappers");
531        assert_eq!(msgs[0].content, "dup me");
532    }
533
534    #[tokio::test]
535    async fn bad_event_dropped_good_event_kept_in_same_batch() {
536        // A garbage event under the same pseudonym must be dropped while a valid one
537        // in the same fetch is returned (open-failure isolation).
538        let relay = MemoryRelay::new();
539        let community = community();
540        let channel = community.channels[0].clone();
541        let alice = Keys::generate();
542        // Valid message.
543        publish_message(&relay, &community, &channel, &alice, "valid", 1).await.unwrap();
544        // Garbage event carrying the right pseudonym but undecryptable content.
545        let pseudonym =
546            super::super::derive::channel_pseudonym(&channel.key, &channel.id, channel.epoch);
547        let garbage = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "not-base64-or-cipher!!")
548            .tags([
549                Tag::custom(
550                    TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
551                    [pseudonym.to_hex()],
552                ),
553                Tag::custom(TagKind::Custom("v".into()), ["1".to_string()]),
554            ])
555            .sign_with_keys(&Keys::generate())
556            .unwrap();
557        relay.publish(&garbage, &community.relays).await.unwrap();
558
559        let reader = member_view(&community);
560        let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0]).await.unwrap();
561        assert_eq!(msgs.len(), 1);
562        assert_eq!(msgs[0].content, "valid");
563    }
564
565    #[tokio::test]
566    async fn publish_retains_key_and_owner_can_delete() {
567        let relay = MemoryRelay::new();
568        let community = community();
569        let channel = community.channels[0].clone();
570        let alice = Keys::generate();
571
572        let (outer, ephemeral) =
573            publish_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
574        // The retained key is exactly the one that signed the outer event.
575        assert_eq!(ephemeral.public_key(), outer.pubkey);
576
577        let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
578        assert_eq!(before.len(), 1);
579
580        // Delete via the retained ephemeral key → gone (MemoryRelay honors NIP-09).
581        delete_own_message(&relay, &community.relays, &ephemeral, outer.id).await.unwrap();
582        let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
583        assert!(after.is_empty(), "owner's deletion should remove the message");
584    }
585
586    #[tokio::test]
587    async fn deletion_by_a_different_key_is_ignored() {
588        // NIP-09 same-pubkey rule: only the original (ephemeral) signer can delete.
589        let relay = MemoryRelay::new();
590        let community = community();
591        let channel = community.channels[0].clone();
592        let alice = Keys::generate();
593        let (outer, ephemeral) =
594            publish_message(&relay, &community, &channel, &alice, "mine", 1).await.unwrap();
595
596        let attacker = Keys::generate();
597        delete_own_message(&relay, &community.relays, &attacker, outer.id).await.unwrap();
598        let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
599        assert_eq!(after.len(), 1, "a foreign key must not delete someone else's message");
600
601        // Prove the deletion machinery actually works (so the assert above isn't
602        // passing merely because deletion is a no-op): the real key DOES delete it.
603        delete_own_message(&relay, &community.relays, &ephemeral, outer.id).await.unwrap();
604        let gone = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
605        assert!(gone.is_empty(), "the original signer's key must delete it");
606    }
607
608    #[tokio::test]
609    async fn deletion_must_reach_all_relays_to_take_effect() {
610        // NIP-09 deletion is not magically global: if the delete lands on only one of
611        // a redundant relay set, the event survives on the others (redundancy cuts
612        // both ways). Documents that a real delete must be sent to every server relay.
613        let relay = MemoryRelay::new();
614        let community = community(); // relays r1, r2, r3
615        let channel = community.channels[0].clone();
616        let alice = Keys::generate();
617        let (outer, ephemeral) =
618            publish_message(&relay, &community, &channel, &alice, "sticky", 1).await.unwrap();
619
620        // Delete on ONLY r1.
621        delete_own_message(&relay, &["r1".to_string()], &ephemeral, outer.id).await.unwrap();
622        // Still fetchable across the full set (lives on r2/r3).
623        let still = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
624        assert_eq!(still.len(), 1, "deletion on a subset must not remove it everywhere");
625
626        // Delete on all relays → finally gone.
627        delete_own_message(&relay, &community.relays, &ephemeral, outer.id).await.unwrap();
628        let gone = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
629        assert!(gone.is_empty());
630    }
631
632    /// LIVE on-relay end-to-end test against jskitty.com (the user's strfry).
633    /// `#[ignore]`d — run explicitly with `cargo test -p vector-core -- --ignored
634    /// live_relay`. Publishes under a RANDOM channel pseudonym (so it pollutes no
635    /// real namespace), verifies fetch+open over the wire, then ALWAYS NIP-09-deletes
636    /// its events (cleanup runs before any assertion so a failure can't leak garbage).
637    #[tokio::test]
638    #[ignore]
639    async fn live_relay_roundtrip_and_cleanup() {
640        use super::super::transport::LiveTransport;
641        use crate::community::derive::channel_pseudonym;
642
643        let _ = rustls::crypto::ring::default_provider().install_default();
644
645        let relays = vec!["wss://jskitty.com/nostr".to_string()];
646        let community = Community::create("LiveTest", "general", relays.clone());
647        let channel = community.channels[0].clone();
648        let alice = Keys::generate();
649        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
650
651        let pseudonym = channel_pseudonym(&channel.key, &channel.id, channel.epoch).to_hex();
652        eprintln!("[live] channel pseudonym (z tag) = {pseudonym}");
653
654        // 1. Publish two messages, retaining each ephemeral key for later deletion.
655        // Use a real epoch-ms so the split-out created_at is "now" (relays reject
656        // events dated absurdly far in the past/future).
657        let now_ms = std::time::SystemTime::now()
658            .duration_since(std::time::UNIX_EPOCH)
659            .unwrap()
660            .as_millis() as u64;
661        let mut published: Vec<(Keys, Event)> = Vec::new();
662        let mut publish_errs: Vec<String> = Vec::new();
663        for (i, body) in ["live hello one", "live hello two"].iter().enumerate() {
664            let ephemeral = Keys::generate();
665            let outer = seal_message_with_ephemeral(
666                &ephemeral, &alice, &channel.key, &channel.id, channel.epoch, body, now_ms + i as u64,
667            )
668            .expect("seal");
669            match transport.publish(&outer, &relays).await {
670                Ok(()) => {
671                    eprintln!("[live] published event {}", outer.id.to_hex());
672                    published.push((ephemeral, outer));
673                }
674                Err(e) => publish_errs.push(e),
675            }
676        }
677
678        // 2. Fetch back over the wire.
679        let fetched = fetch_channel_messages(&transport, &community, &channel).await;
680
681        // 3. CLEANUP FIRST — delete every published event via its retained ephemeral
682        //    key, before any assertion can panic and strand garbage on the relay.
683        let mut cleanup_ok = true;
684        for (ephemeral, outer) in &published {
685            let del = EventBuilder::delete(EventDeletionRequest::new().ids([outer.id]))
686                .sign_with_keys(ephemeral)
687                .expect("build deletion");
688            if let Err(e) = transport.publish(&del, &relays).await {
689                cleanup_ok = false;
690                eprintln!("[live] CLEANUP FAILED for {}: {e} — manual delete may be needed", outer.id.to_hex());
691            } else {
692                eprintln!("[live] deleted event {}", outer.id.to_hex());
693            }
694        }
695
696        // 4. Now it's safe to assert.
697        assert!(publish_errs.is_empty(), "publish errors: {publish_errs:?}");
698        let msgs = fetched.expect("fetch failed");
699        let contents: Vec<&str> = msgs.iter().map(|m| m.content.as_str()).collect();
700        assert!(contents.contains(&"live hello one"), "got {contents:?}");
701        assert!(contents.contains(&"live hello two"), "got {contents:?}");
702        for m in &msgs {
703            assert_eq!(m.author, alice.public_key(), "author recovered over the wire");
704        }
705        assert!(cleanup_ok, "cleanup deletion failed — check relay for leftover events");
706        eprintln!("[live] OK: {} messages round-tripped and cleaned up", msgs.len());
707    }
708
709    #[tokio::test]
710    async fn redundancy_a_dropped_relay_still_delivers() {
711        // The message lands on only one of the three relays (the others "missed" it);
712        // a member fetching across the set still receives it.
713        let relay = MemoryRelay::new();
714        let community = community();
715        let channel = community.channels[0].clone();
716        let alice = Keys::generate();
717
718        let outer = seal_message_with_ephemeral(
719            &Keys::generate(), &alice, &channel.key, &channel.id, channel.epoch, "survives", 1,
720        )
721        .unwrap();
722        relay.inject(&outer, &["r2".to_string()]); // only r2 has it
723
724        let reader = member_view(&community);
725        let msgs = fetch_channel_messages(&relay, &reader, &reader.channels[0]).await.unwrap();
726        assert_eq!(msgs.len(), 1);
727        assert_eq!(msgs[0].content, "survives");
728    }
729}