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