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