Skip to main content

vector_core/community/v2/
inbound.rs

1//! v2 inbound bridge — turns opened v2 events into the protocol-agnostic
2//! [`InboundEventHandler`] callbacks the rest of Vector (and the SDK's
3//! `on_message`) already consumes. The handler is the seam: v1 and v2 both feed
4//! it, so a bot receives v2 messages with no SDK change.
5//!
6//! Dispatch is by which plane a kind-1059 wrap opens under. A received wrap is
7//! tried against each held channel's Chat-Plane key (author match, no trial
8//! decrypt), then the Guestbook plane; a control-plane fold is a heavier
9//! separate path (metadata/roster), handled by the service refresh, not here.
10
11use nostr_sdk::prelude::PublicKey;
12use nostr_sdk::ToBech32;
13
14use super::super::attachments::attachments_from_tags;
15use super::chat::{self, ChatEvent};
16use super::community::CommunityV2;
17use super::guestbook::{self, GuestbookEntry};
18use super::stream;
19use crate::event_handler::InboundEventHandler;
20use crate::state::ChatState;
21use crate::types::{EmojiTag, Message, Reaction};
22
23/// Build a protocol-agnostic [`Message`] from an opened v2 chat Message event.
24/// Mirrors v1's `build_message` field-for-field (id = the rumor id, ms time,
25/// `mine`, npub, imeta attachments, NIP-30 emoji, the reply reference), so the
26/// frontend/SDK renderers treat a v2 message identically to a v1 or DM one.
27pub fn chat_message_to_message(
28    opened: &stream::OpenedStream,
29    reply_to: &Option<chat::ReplyRef>,
30    emoji: &[(String, String)],
31    my_pubkey: &PublicKey,
32) -> Message {
33    let (replied_to, replied_to_npub) = match reply_to {
34        Some(r) => (
35            crate::simd::hex::bytes_to_hex_32(&r.id),
36            r.author.and_then(|a| a.to_bech32().ok()),
37        ),
38        None => (String::new(), None),
39    };
40    Message {
41        id: opened.rumor_id.to_hex(),
42        content: opened.rumor.content.clone(),
43        replied_to,
44        replied_to_npub,
45        at: opened.at_ms,
46        mine: opened.author == *my_pubkey,
47        npub: opened.author.to_bech32().ok(),
48        attachments: attachments_from_tags(opened.rumor.tags.iter(), &crate::db::get_download_dir()),
49        emoji_tags: emoji
50            .iter()
51            .map(|(shortcode, url)| EmojiTag { shortcode: shortcode.clone(), url: url.clone() })
52            .collect(),
53        wrapper_event_id: Some(opened.wrapper_id.to_hex()),
54        ..Default::default()
55    }
56}
57
58/// Whether `author` sits on the banlist of the community owning `channel_id`
59/// (fail-open on a lookup error: availability must never hide honest traffic —
60/// the ban re-applies on the next fold).
61fn author_is_banned_here(channel_id: &str, author: &PublicKey) -> bool {
62    let Ok(Some(cid_hex)) = crate::db::community::community_id_for_channel(channel_id) else {
63        return false;
64    };
65    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
66    !banned.is_empty() && banned.contains(&author.to_hex())
67}
68
69/// What applying a v2 chat event to STATE yielded — the caller persists it (async)
70/// once the STATE lock is dropped. Mirrors v1's `IncomingEvent` for the chat sub-kinds:
71/// a message row is saved fresh or re-saved (a landed reaction rides the row), a delete
72/// drops it. Persistence is the caller's so the apply step stays sync + lock-scoped.
73pub enum ChatPersist {
74    /// A brand-new message — save its row.
75    New(Message),
76    /// A message changed: a reaction landed (`edit_event` None → re-save the row, which
77    /// carries reactions) or an edit applied (`edit_event` Some → save the folded
78    /// MESSAGE_EDIT event, event-sourced like v1 + DMs, never a row overwrite).
79    Updated { message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
80    /// A message removed by its author — drop its row.
81    Removed(String),
82}
83
84/// Apply an opened v2 [`ChatEvent`] to STATE (dedup + aggregate onto the SHARED
85/// [`ChatState`]), mirroring v1's `ingest_message`/`apply_reaction`/`apply_delete`. Sync:
86/// the DB dedup read + STATE mutation run under the caller's lock; the caller then does the
87/// async DB persist on the returned [`ChatPersist`] (see [`persist_chat`]). Returns `None`
88/// for a duplicate, a non-persisted kind (typing/webxdc), an edit (increment 2), or an
89/// aggregate whose target isn't resident in this channel.
90pub fn apply_chat_to_state(state: &mut ChatState, event: &ChatEvent, channel_id: &str, my_pubkey: &PublicKey) -> Option<ChatPersist> {
91    // CORD-04 §4: a banned npub VANISHES — every chat event they author (message,
92    // reaction, edit, delete) is dropped at fold time. Severance (the rekey) only
93    // cuts their READ of new epochs; they still hold old epoch keys and can post
94    // to old planes forever — refusing to fold them is what makes the ban hold.
95    // The persisted banlist can never name the owner (the authority fold refuses
96    // a ban whose target is position 0), so no owner exemption is needed here.
97    if author_is_banned_here(channel_id, &event.opened().author) {
98        return None;
99    }
100    match event {
101        ChatEvent::Message { opened, reply_to, emoji } => {
102            let msg = chat_message_to_message(opened, reply_to, emoji, my_pubkey);
103            // DB dedup: a known inner id is already stored — don't re-ingest/re-emit (a
104            // catch-up sweep re-fetches the whole page; in-memory STATE holds only a window).
105            if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
106                return None;
107            }
108            state.ensure_community_chat(channel_id);
109            // Persist regardless of the STATE-add result: `event_exists` already proved
110            // it's not in the DB, so a `false` here means only that another writer put it
111            // in STATE first — the row must still be saved, or it's lost until re-fetch.
112            state.add_message_to_chat(channel_id, msg.clone());
113            Some(ChatPersist::New(msg))
114        }
115        ChatEvent::Reaction { opened, target, emoji, emoji_url, .. } => {
116            let target_id = crate::simd::hex::bytes_to_hex_32(target);
117            // Cross-channel guard: a reaction lands only on a target resident in the SAME
118            // channel it was sealed under (its binding authenticates its own channel, never
119            // the target's) — else a member could inject reactions across channels.
120            if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == channel_id) {
121                return None;
122            }
123            let reaction = Reaction {
124                id: opened.rumor_id.to_hex(),
125                reference_id: target_id.clone(),
126                author_id: opened.author.to_hex(),
127                emoji: emoji.clone(),
128                emoji_url: emoji_url.clone(),
129            };
130            let (_c, added) = state.add_reaction_to_message(&target_id, reaction)?;
131            added.then(|| state.find_message(&target_id).map(|(_c, m)| ChatPersist::Updated { message: m, edit_event: None }))?
132        }
133        ChatEvent::Edit { opened, target, new_content } => {
134            // Dedup by the edit's own rumor id (its MESSAGE_EDIT row below): the
135            // in-message `apply_edit` dedups silently, so without this a re-wrapped
136            // duplicate would still return Updated and re-fire the handler — the
137            // replay hole the persist-gated callbacks exist to close.
138            if crate::db::events::event_exists(&opened.rumor_id.to_hex()).unwrap_or(false) {
139                return None;
140            }
141            let target_id = crate::simd::hex::bytes_to_hex_32(target);
142            // Author-scoped + same-channel: only the original author edits their own message.
143            let editor_npub = opened.author.to_bech32().ok()?;
144            if !matches!(state.find_message(&target_id), Some((chat, m)) if chat.id == channel_id && m.npub.as_deref() == Some(editor_npub.as_str())) {
145                return None;
146            }
147            // Apply to STATE via the shared canonical applier (seeds history with the
148            // original once, dedups by `edited_at`, swaps content) — reused from v1/DMs.
149            let edited_at = opened.at_ms;
150            let (_c, message) = state.update_message(&target_id, |m| m.apply_edit(new_content.clone(), edited_at, Vec::new()))?;
151            // Persist as a folded MESSAGE_EDIT event (chat_id set at save time), matching v1.
152            let edit_event = crate::stored_event::StoredEventBuilder::new()
153                .id(opened.rumor_id.to_hex())
154                .kind(crate::stored_event::event_kind::MESSAGE_EDIT)
155                .content(new_content.clone())
156                .reference_id(Some(target_id.clone()))
157                .created_at(edited_at / 1000)
158                .mine(opened.author == *my_pubkey)
159                .npub(opened.author.to_bech32().ok())
160                .build();
161            Some(ChatPersist::Updated { message, edit_event: Some(Box::new(edit_event)) })
162        }
163        ChatEvent::Delete { opened, target, .. } => {
164            let target_id = crate::simd::hex::bytes_to_hex_32(target);
165            // Author-scoped: a delete removes its author's OWN message. A moderation-hide
166            // (deleting another's message under MANAGE_MESSAGES) is a gated follow-up.
167            let own = matches!(state.find_message(&target_id), Some((_, m)) if m.npub.as_deref() == opened.author.to_bech32().ok().as_deref());
168            own.then(|| state.remove_message(&target_id).map(|_| ChatPersist::Removed(target_id)))?
169        }
170        ChatEvent::Typing { .. } | ChatEvent::Webxdc { .. } => None,
171    }
172}
173
174/// Apply an already-opened chat event to STATE + the shared store — the LIVE
175/// counterpart of [`crate::VectorCore::v2_backfill_channel`]'s catch-up persistence.
176/// The dispatcher opened the wrap (so nothing decrypts twice); the returned outcome
177/// is what the caller's callbacks fire from — a duplicate, a non-resident target,
178/// or a forged edit/delete yields `None` and nothing re-fires.
179pub async fn persist_chat_event(
180    event: &ChatEvent,
181    channel_id: &str,
182    my_pubkey: &PublicKey,
183    session: &crate::state::SessionGuard,
184) -> Option<ChatPersist> {
185    let outcome = {
186        let mut st = crate::state::STATE.lock().await;
187        // A swap can land on the lock await: only mutate THIS account's STATE.
188        if !session.is_valid() {
189            return None;
190        }
191        apply_chat_to_state(&mut st, event, channel_id, my_pubkey)
192    }?;
193    // Resolve a reply's preview (content/npub) from the DB before persist + emit
194    // (v1 parity): the parent is often persisted but outside the in-memory window,
195    // and without this the live render shows a reply with no context.
196    let outcome = match outcome {
197        ChatPersist::New(mut m) => {
198            if !m.replied_to.is_empty() {
199                let _ = crate::db::events::populate_reply_context(&mut m).await;
200            }
201            ChatPersist::New(m)
202        }
203        o => o,
204    };
205    // …and only persist to THIS account's DB (the save straddles an await).
206    if !session.is_valid() {
207        return None;
208    }
209    persist_chat(channel_id, &outcome).await;
210    Some(outcome)
211}
212
213/// Persist an [`apply_chat_to_state`] outcome to the shared events DB — async, run by the
214/// caller AFTER the STATE lock drops (a message row carries its reactions, so a reaction
215/// re-saves the row; a delete drops it).
216pub async fn persist_chat(channel_id: &str, outcome: &ChatPersist) {
217    match outcome {
218        ChatPersist::New(m) => {
219            let _ = crate::db::events::save_message(channel_id, m).await;
220        }
221        // An edit is event-sourced: save the MESSAGE_EDIT row (folded on reload), never a
222        // row overwrite. A reaction rides the message row, so re-save it.
223        ChatPersist::Updated { message, edit_event } => match edit_event {
224            Some(ev) => {
225                let mut ev = (**ev).clone();
226                // get-or-CREATE: a lookup-only id would leave a fresh channel's edit at
227                // chat_id 0 (orphaned, dropped on the reload fold).
228                if let Ok(cid) = crate::db::id_cache::get_or_create_chat_id(channel_id) {
229                    ev.chat_id = cid;
230                }
231                let _ = crate::db::events::save_event(&ev).await;
232            }
233            None => {
234                let _ = crate::db::events::save_message(channel_id, message).await;
235            }
236        },
237        ChatPersist::Removed(id) => {
238            let _ = crate::db::events::delete_event(id).await;
239        }
240    }
241}
242
243/// The typed outcome of dispatching one v2 wrap.
244#[derive(Debug, Clone)]
245pub enum DispatchedV2 {
246    /// An OPENED chat event (message/reaction/edit/delete) on `channel_id` (hex),
247    /// NOT yet applied. The realtime layer runs it through [`persist_chat_event`]
248    /// and fires the matching callback from the outcome — so a re-wrapped
249    /// duplicate (any keyholder can re-wrap a signed seal into a fresh 1059) or a
250    /// forged edit/delete never re-fires a handler, exactly v1's model.
251    Chat { channel_id: String, event: Box<ChatEvent> },
252    /// A typing indicator from `npub` on `channel_id`.
253    Typing { channel_id: String, npub: String },
254    /// A Guestbook join/leave for `npub`.
255    Presence { npub: String, joined: bool },
256    /// A wrap on this community's Control Plane — its metadata/channel set may
257    /// have changed. Recognized here (address match) but NOT folded: the fold
258    /// needs the whole edition chain, so the realtime layer re-fetches + re-folds
259    /// + re-subscribes. `community_id` is hex.
260    Control { community_id: String },
261    /// A wrap on one of this community's next-epoch rekey planes — a rotation is in
262    /// flight. Recognized by address; the realtime layer runs the stateful catch-up
263    /// ([`super::service::follow_rekeys`]) across every scope. `community_id` is hex.
264    Rekey { community_id: String },
265    /// A verified owner-signed tombstone at the dissolved plane (CORD-02 §9): the
266    /// community is dead. The realtime layer seals it read-only. `community_id` is hex.
267    Dissolved { community_id: String },
268    /// The wrap opened on a v2 plane but carries nothing the handler renders
269    /// (e.g. a WebXDC signal, or a kick we don't surface in the first cut).
270    Ignored,
271    /// Not a v2 plane of this community — try elsewhere / drop.
272    NotOurs,
273}
274
275/// Dispatch a received kind-1059 wrap for `community`: route it to the plane it
276/// opens under. Chat events are returned OPENED (the realtime layer persists,
277/// then fires callbacks from the outcome); only the non-persisted kinds (typing,
278/// guestbook presence) fire their callback inline here. Purely in-memory — so
279/// this stays offline-testable.
280pub fn dispatch_wrap(
281    wrap: &nostr_sdk::Event,
282    community: &CommunityV2,
283    _my_pubkey: &PublicKey,
284    handler: &dyn InboundEventHandler,
285) -> DispatchedV2 {
286    // 1. Chat planes: try each held channel by its group key (author match).
287    for ch in &community.channels {
288        // A keyless private channel is UNREADABLE — never address it at the root plane
289        // (channel_secret falls back to the root, which would be a private→public leak).
290        if ch.private && ch.key.is_none() {
291            continue;
292        }
293        let (secret, epoch) = community.channel_secret(ch);
294        let group = super::derive::channel_group_key(&secret, &ch.id, epoch);
295        if wrap.pubkey != group.pk() {
296            continue;
297        }
298        let Ok(event) = chat::open_chat_event(wrap, &group, &ch.id, epoch) else {
299            return DispatchedV2::NotOurs;
300        };
301        let channel_id = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
302        return dispatch_chat_event(event, &channel_id, handler);
303    }
304
305    // 2. Guestbook plane: join/leave presence.
306    let gb = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
307    if wrap.pubkey == gb.pk() {
308        if let Ok(opened) = stream::open_wrap(wrap, &gb) {
309            if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
310                return dispatch_guestbook(&ev.entry, community, handler);
311            }
312        }
313        return DispatchedV2::Ignored;
314    }
315
316    // 3. Control plane: a metadata/channel edition. Recognized by address only —
317    // the fold needs the whole chain, which the realtime layer re-fetches. Shares
318    // one address helper with the subscription so the two can't drift.
319    if wrap.pubkey == super::realtime::control_author(community) {
320        return DispatchedV2::Control { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
321    }
322
323    // 4. Rekey planes: a rotation in flight (base or a private channel), addressed
324    // at the next epoch. Same author-set the subscription rides — one source of
325    // truth ([`super::realtime::rekey_authors`]) so recognition and subscription
326    // can't drift.
327    if super::realtime::rekey_authors(community).iter().any(|p| *p == wrap.pubkey) {
328        return DispatchedV2::Rekey { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
329    }
330
331    // 5. Dissolved plane: the terminal tombstone (CORD-02 §9). Honor ONLY a valid
332    // owner seal — a foreign event at this public address is noise.
333    if wrap.pubkey == super::derive::dissolved_group_key(community.id()).pk() {
334        if super::dissolution::verify_dissolved(wrap, &community.identity) {
335            return DispatchedV2::Dissolved { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
336        }
337        return DispatchedV2::Ignored;
338    }
339
340    DispatchedV2::NotOurs
341}
342
343fn dispatch_chat_event(event: ChatEvent, channel_id: &str, handler: &dyn InboundEventHandler) -> DispatchedV2 {
344    match event {
345        // Typing is ephemeral (never persisted) — the one chat kind fired inline,
346        // so it carries its own CORD-04 banned-author drop (the persisted kinds
347        // get theirs in `apply_chat_to_state`).
348        ChatEvent::Typing { opened } => {
349            if author_is_banned_here(channel_id, &opened.author) {
350                return DispatchedV2::Ignored;
351            }
352            let npub = opened.author.to_bech32().unwrap_or_default();
353            let until = opened.at_ms / 1000 + 30;
354            handler.on_community_typing(channel_id, &npub, until);
355            DispatchedV2::Typing { channel_id: channel_id.to_string(), npub }
356        }
357        ChatEvent::Webxdc { .. } => DispatchedV2::Ignored,
358        // Message/Reaction/Edit/Delete all persist first; their callbacks fire from
359        // the outcome (dedup + author checks), never optimistically.
360        event => DispatchedV2::Chat { channel_id: channel_id.to_string(), event: Box::new(event) },
361    }
362}
363
364fn dispatch_guestbook(ev: &GuestbookEntry, community: &CommunityV2, handler: &dyn InboundEventHandler) -> DispatchedV2 {
365    // CORD-04 §4: a banned npub's presence announcements vanish with the rest of
366    // their events (the memberlist fold subtracts them separately).
367    if let GuestbookEntry::Join { member, .. } | GuestbookEntry::Leave { member, .. } = ev {
368        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
369        let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
370        if banned.contains(&member.to_hex()) {
371            return DispatchedV2::Ignored;
372        }
373    }
374    // Presence is announced against the community, keyed to `#general` for the
375    // handler's channel-scoped signature (v1's convention).
376    let chat_id = community
377        .channels
378        .first()
379        .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
380        .unwrap_or_default();
381    match ev {
382        GuestbookEntry::Join { member, at_ms, invited_by } => {
383            let npub = member.to_bech32().unwrap_or_default();
384            let (by, label) = match invited_by {
385                Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
386                None => (None, None),
387            };
388            handler.on_community_presence(&chat_id, &npub, true, "", at_ms / 1000, by, label);
389            DispatchedV2::Presence { npub, joined: true }
390        }
391        GuestbookEntry::Leave { member, at_ms } => {
392            let npub = member.to_bech32().unwrap_or_default();
393            handler.on_community_presence(&chat_id, &npub, false, "", at_ms / 1000, None, None);
394            DispatchedV2::Presence { npub, joined: false }
395        }
396        // Kicks/snapshots aren't surfaced to the handler in the first cut.
397        GuestbookEntry::Kick { .. } | GuestbookEntry::Snapshot { .. } => DispatchedV2::Ignored,
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use super::super::service;
405    use crate::community::transport::memory::MemoryRelay;
406    use crate::community::transport::Transport;
407    use nostr_sdk::prelude::Keys;
408    use std::sync::Mutex;
409
410    /// A handler that records every callback it receives.
411    #[derive(Default)]
412    struct Recorder {
413        messages: Mutex<Vec<(String, Message)>>,
414        updates: Mutex<Vec<(String, String)>>,
415        removed: Mutex<Vec<(String, String)>>,
416        presence: Mutex<Vec<(String, bool)>>,
417    }
418    impl InboundEventHandler for Recorder {
419        fn on_community_message(&self, chat_id: &str, msg: &Message, _is_new: bool) {
420            self.messages.lock().unwrap().push((chat_id.to_string(), msg.clone()));
421        }
422        fn on_community_update(&self, chat_id: &str, target: &str, _msg: &Message) {
423            self.updates.lock().unwrap().push((chat_id.to_string(), target.to_string()));
424        }
425        fn on_community_removed(&self, chat_id: &str, target: &str) {
426            self.removed.lock().unwrap().push((chat_id.to_string(), target.to_string()));
427        }
428        fn on_community_presence(&self, _c: &str, npub: &str, joined: bool, _e: &str, _a: u64, _b: Option<&str>, _l: Option<&str>) {
429            self.presence.lock().unwrap().push((npub.to_string(), joined));
430        }
431    }
432
433    fn init() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
434        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
435        crate::db::close_database();
436        crate::db::clear_id_caches();
437        let tmp = tempfile::tempdir().unwrap();
438        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(90_000);
439        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
440        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
441        let mut acct = String::from("npub1");
442        let mut v = n as usize;
443        for _ in 0..58 {
444            acct.push(B[v % 32] as char);
445            v = v / 32 + 7;
446        }
447        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
448        crate::db::set_app_data_dir(tmp.path().to_path_buf());
449        crate::db::set_current_account(acct.clone()).unwrap();
450        crate::db::init_database(&acct).unwrap();
451        let _ = crate::state::take_nostr_client();
452        let me = Keys::generate();
453        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
454        crate::state::set_my_public_key(me.public_key());
455        (tmp, guard, me)
456    }
457
458    #[tokio::test]
459    async fn a_received_message_wrap_opens_then_fires_from_the_persist_outcome() {
460        use nostr_sdk::prelude::Timestamp;
461        let (_tmp, _guard, me) = init();
462        let relay = MemoryRelay::new();
463        let community = service::create_community(&relay, "In", vec!["wss://r".into()], None).await.unwrap();
464        let general = community.channels[0].id;
465        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
466
467        // ANOTHER member (holds the root) posts — the incoming case, so no local
468        // send echo pre-persisted it.
469        let member = Keys::generate();
470        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
471        let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "ping", None, &[], vec![], 5_000);
472        let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
473
474        // Dispatch OPENS the event but fires no message callback — that belongs to
475        // the persist outcome (dedup + author checks), v1's model.
476        let rec = Recorder::default();
477        let dispatched = dispatch_wrap(&wrap, &community, &me.public_key(), &rec);
478        assert!(rec.messages.lock().unwrap().is_empty(), "no optimistic message callback");
479        let DispatchedV2::Chat { channel_id, event } = dispatched else {
480            panic!("a chat wrap dispatches as Chat");
481        };
482        assert_eq!(channel_id, cid);
483
484        let session = crate::state::SessionGuard::capture();
485        let outcome = persist_chat_event(&event, &channel_id, &me.public_key(), &session).await;
486        let Some(ChatPersist::New(msg)) = outcome else {
487            panic!("the first delivery persists as New");
488        };
489        assert_eq!(msg.content, "ping");
490        assert!(!msg.mine, "authored by the other member");
491
492        // A RE-WRAP of the same signed rumor (any keyholder can mint one) is a
493        // fresh outer event, but the persist dedups on the inner id — no re-fire.
494        let (rewrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(6), false).unwrap();
495        assert_ne!(rewrap.id, wrap.id, "a re-wrap is a distinct outer event");
496        let DispatchedV2::Chat { event: dup, .. } = dispatch_wrap(&rewrap, &community, &me.public_key(), &rec) else {
497            panic!("the re-wrap still opens");
498        };
499        assert!(
500            persist_chat_event(&dup, &channel_id, &me.public_key(), &session).await.is_none(),
501            "a re-wrapped duplicate yields no outcome (nothing re-fires)"
502        );
503    }
504
505    #[tokio::test]
506    async fn a_guestbook_join_wrap_fires_presence() {
507        let (_tmp, _guard, me) = init();
508        let relay = MemoryRelay::new();
509        // create_community publishes the owner's genesis Join to the guestbook.
510        let community = service::create_community(&relay, "GB", vec!["wss://r".into()], None).await.unwrap();
511        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
512        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb.pk_hex()], ..Default::default() };
513        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
514
515        let rec = Recorder::default();
516        for w in &wraps {
517            dispatch_wrap(w, &community, &me.public_key(), &rec);
518        }
519        let pres = rec.presence.lock().unwrap();
520        assert_eq!(pres.len(), 1, "the owner's genesis Join fires one presence");
521        assert!(pres[0].1, "it's a join");
522        assert_eq!(pres[0].0, me.public_key().to_bech32().unwrap());
523    }
524
525    #[tokio::test]
526    async fn v2_chat_events_persist_into_the_shared_store() {
527        let (_tmp, _guard, me) = init();
528        let relay = MemoryRelay::new();
529        let community = service::create_community(&relay, "Persist", vec!["wss://r".into()], None).await.unwrap();
530        let general = community.channels[0].id;
531        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
532        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
533        let me_hex = me.public_key().to_hex();
534
535        let msg_id = service::send_message(&relay, &community, &general, "persist me").await.unwrap();
536        service::send_reaction(&relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
537
538        // The SEND ECHO persisted both immediately — send-then-read works with no
539        // listen loop (the INT-W3 contract).
540        assert!(crate::db::events::event_exists(&msg_id).unwrap(), "the send echo persisted the message row");
541        let reacted = {
542            let st = crate::state::STATE.lock().await;
543            st.find_message(&msg_id).map(|(_, m)| m.reactions.iter().any(|r| r.emoji == "🔥")).unwrap_or(false)
544        };
545        assert!(reacted, "the send echo aggregated the reaction onto the stored message");
546
547        // The relay's copies of our own sends then arrive — every one dedups
548        // against the echoed rows (no double rows, no re-fires).
549        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
550        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
551        let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
552        events.sort_by_key(|e| e.opened().at_ms);
553        assert!(!events.is_empty());
554        for ev in &events {
555            let outcome = {
556                let mut st = crate::state::STATE.lock().await;
557                apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
558            };
559            assert!(outcome.is_none(), "the relay echo of an already-echoed send dedups");
560        }
561    }
562
563    #[tokio::test]
564    async fn a_v2_edit_persists_as_a_folded_edit_event() {
565        let (_tmp, _guard, me) = init();
566        let relay = MemoryRelay::new();
567        let community = service::create_community(&relay, "Edit", vec!["wss://r".into()], None).await.unwrap();
568        let general = community.channels[0].id;
569        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
570        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
571
572        let msg_id = service::send_message(&relay, &community, &general, "original").await.unwrap();
573        service::send_edit(&relay, &community, &general, &msg_id, "edited!").await.unwrap();
574
575        // Apply messages BEFORE their edits (a target must be resident to edit).
576        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
577        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
578        let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
579        events.sort_by_key(|e| (!matches!(e, ChatEvent::Message { .. }), e.opened().at_ms));
580        for ev in &events {
581            let outcome = {
582                let mut st = crate::state::STATE.lock().await;
583                apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
584            };
585            if let Some(o) = outcome {
586                persist_chat(&cid, &o).await;
587            }
588        }
589
590        let content = {
591            let st = crate::state::STATE.lock().await;
592            st.find_message(&msg_id).map(|(_, m)| m.content)
593        };
594        assert_eq!(content.as_deref(), Some("edited!"), "the edit applied to the stored message");
595        let edit_id = events.iter().find_map(|e| matches!(e, ChatEvent::Edit { .. }).then(|| e.opened().rumor_id.to_hex())).unwrap();
596        assert!(crate::db::events::event_exists(&edit_id).unwrap(), "the MESSAGE_EDIT event is persisted (folds on reload)");
597    }
598
599    #[tokio::test]
600    async fn a_reaction_after_a_rekey_aggregates_onto_a_prior_epoch_message() {
601        // Sync stability across a rekey: a message written at epoch 0 and a reaction
602        // to it written at epoch 1 (its wrap sealed + bound under the NEW epoch key)
603        // must still aggregate — STATE keys by rumor id, not epoch, so a reaction to
604        // pre-refound history lands. The reaction's OWN binding is epoch 1; the
605        // target's is epoch 0.
606        use nostr_sdk::prelude::Timestamp;
607        let (_tmp, _guard, me) = init();
608        let relay = MemoryRelay::new();
609        let community = service::create_community(&relay, "Rekeyed", vec!["wss://r".into()], None).await.unwrap();
610        let general = community.channels[0].id;
611        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
612        let session = crate::state::SessionGuard::capture();
613
614        // Epoch-0 message, opened under the epoch-0 public key, persisted.
615        let member = Keys::generate();
616        let g0 = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
617        let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "before the rekey", None, &[], vec![], 5_000);
618        let msg_id = msg.id.unwrap().to_hex();
619        let (mw, _) = chat::seal_chat_rumor(&msg, &g0, &member, Timestamp::from_secs(5), false).unwrap();
620        let ev = chat::open_chat_event(&mw, &g0, &general, community.root_epoch).unwrap();
621        assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
622
623        // Epoch-1 reaction (same root, next epoch → a distinct channel key) to that
624        // epoch-0 message, opened under the epoch-1 key.
625        let next = crate::community::Epoch(community.root_epoch.0 + 1);
626        let g1 = super::super::derive::channel_group_key(&community.community_root, &general, next);
627        let reaction = chat::build_reaction_rumor(member.public_key(), &general, next, &msg_id, &member.public_key().to_hex(), super::super::kind::MESSAGE, "🎉", None, 6_000);
628        let (rw, _) = chat::seal_chat_rumor(&reaction, &g1, &member, Timestamp::from_secs(6), false).unwrap();
629        let rev = chat::open_chat_event(&rw, &g1, &general, next).unwrap();
630        let outcome = persist_chat_event(&rev, &cid, &me.public_key(), &session).await;
631        assert!(matches!(outcome, Some(ChatPersist::Updated { .. })), "the cross-epoch reaction updates the target");
632
633        let reacted = {
634            let st = crate::state::STATE.lock().await;
635            st.find_message(&msg_id).map(|(_, m)| m.reactions.iter().any(|r| r.emoji == "🎉")).unwrap_or(false)
636        };
637        assert!(reacted, "the epoch-1 reaction aggregated onto the epoch-0 message");
638    }
639
640    #[tokio::test]
641    async fn a_v2_message_with_an_imeta_attachment_surfaces_as_an_attachment() {
642        use nostr_sdk::prelude::Timestamp;
643        let (_tmp, _guard, me) = init();
644        let relay = MemoryRelay::new();
645        let community = service::create_community(&relay, "Files", vec!["wss://r".into()], None).await.unwrap();
646        let general = community.channels[0].id;
647        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
648        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
649        let session = crate::state::SessionGuard::capture();
650
651        // Build a valid NIP-92 imeta tag via the same encoder the v2 file pipeline
652        // uses, so the round-trip mirrors production exactly.
653        let attachment = crate::types::Attachment {
654            id: "a".repeat(64),
655            key: "0".repeat(64),
656            nonce: "1".repeat(32),
657            extension: "png".into(),
658            name: "photo.png".into(),
659            url: "https://blossom.example/abc".into(),
660            path: String::new(),
661            size: 4096,
662            img_meta: None,
663            downloading: false,
664            downloaded: false,
665            webxdc_topic: None,
666            group_id: None,
667            original_hash: Some("b".repeat(64)),
668            scheme_version: None,
669            mls_filename: None,
670        };
671        let imeta = crate::community::attachments::attachment_to_imeta(&attachment);
672        let member = Keys::generate();
673        let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "here's a file", None, &[], vec![imeta], 5_000);
674        let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
675        let ev = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
676
677        let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
678        let Some(ChatPersist::New(msg)) = outcome else {
679            panic!("the file message persists as new");
680        };
681        assert_eq!(msg.attachments.len(), 1, "the imeta tag parsed into one attachment");
682        let att = &msg.attachments[0];
683        assert!(att.url.contains("blossom.example"), "attachment url carried through: {}", att.url);
684        assert_eq!(att.extension, "png", "extension carried through");
685    }
686
687    #[tokio::test]
688    async fn a_banned_members_every_chat_event_is_dropped_on_sight() {
689        // CORD-04 §4: a banned npub vanishes — message, reaction, edit, delete,
690        // typing, and presence alike. Severance only cuts their READ; this fold
691        // gate is what makes the ban hold against old-epoch keys they still have.
692        use nostr_sdk::prelude::Timestamp;
693        let (_tmp, _guard, me) = init();
694        let relay = MemoryRelay::new();
695        let community = service::create_community(&relay, "BanGate", vec!["wss://r".into()], None).await.unwrap();
696        let general = community.channels[0].id;
697        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
698        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
699        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
700        let session = crate::state::SessionGuard::capture();
701        let rogue = Keys::generate();
702
703        // Pre-ban: the rogue's message folds like anyone's.
704        let m1 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "pre-ban", None, &[], vec![], 5_000);
705        let m1_id = m1.id.unwrap().to_hex();
706        let (w1, _) = chat::seal_chat_rumor(&m1, &group, &rogue, Timestamp::from_secs(5), false).unwrap();
707        let ev = chat::open_chat_event(&w1, &group, &general, community.root_epoch).unwrap();
708        assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
709
710        // The ban lands (the fold's persisted banlist).
711        crate::db::community::set_community_banlist(&cid_hex, &[rogue.public_key().to_hex()], 1_000).unwrap();
712
713        // Post-ban: every kind they author drops.
714        let m2 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "post-ban", None, &[], vec![], 6_000);
715        let (w2, _) = chat::seal_chat_rumor(&m2, &group, &rogue, Timestamp::from_secs(6), false).unwrap();
716        let ev = chat::open_chat_event(&w2, &group, &general, community.root_epoch).unwrap();
717        assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned message is dropped");
718
719        let edit = chat::build_edit_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, "rewritten", 7_000);
720        let (we, _) = chat::seal_chat_rumor(&edit, &group, &rogue, Timestamp::from_secs(7), false).unwrap();
721        let ev = chat::open_chat_event(&we, &group, &general, community.root_epoch).unwrap();
722        assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned edit is dropped");
723
724        let del = chat::build_delete_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, super::super::kind::MESSAGE, 8_000);
725        let (wd, _) = chat::seal_chat_rumor(&del, &group, &rogue, Timestamp::from_secs(8), false).unwrap();
726        let ev = chat::open_chat_event(&wd, &group, &general, community.root_epoch).unwrap();
727        assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned delete is dropped");
728        assert!(
729            crate::state::STATE.lock().await.find_message(&m1_id).is_some(),
730            "their pre-ban message survives their own post-ban delete"
731        );
732
733        // Typing + presence fire inline — the dispatcher's own gate covers them.
734        let rec = Recorder::default();
735        let typ = chat::build_typing_rumor(rogue.public_key(), &general, community.root_epoch, 9_000);
736        let (wt, _) = chat::seal_chat_rumor(&typ, &group, &rogue, Timestamp::from_secs(9), true).unwrap();
737        assert!(matches!(dispatch_wrap(&wt, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
738        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
739        let join = guestbook::build_join_rumor(rogue.public_key(), None, 10_000);
740        let (wj, _) = guestbook::seal_guestbook_rumor(&join, &gb, &rogue, Timestamp::from_secs(10)).unwrap();
741        assert!(matches!(dispatch_wrap(&wj, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
742        assert!(rec.presence.lock().unwrap().is_empty(), "no presence callback for a banned join");
743
744        // An innocent author still folds normally.
745        let innocent = Keys::generate();
746        let m3 = chat::build_message_rumor(innocent.public_key(), &general, community.root_epoch, "innocent", None, &[], vec![], 11_000);
747        let (w3, _) = chat::seal_chat_rumor(&m3, &group, &innocent, Timestamp::from_secs(11), false).unwrap();
748        let ev = chat::open_chat_event(&w3, &group, &general, community.root_epoch).unwrap();
749        assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
750    }
751
752    #[tokio::test]
753    async fn an_armada_threaded_reply_persists_and_fires_as_an_inline_reply() {
754        use nostr_sdk::prelude::Timestamp;
755        let (_tmp, _guard, me) = init();
756        let relay = MemoryRelay::new();
757        let community = service::create_community(&relay, "Thread", vec!["wss://r".into()], None).await.unwrap();
758        let general = community.channels[0].id;
759        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
760        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
761        let session = crate::state::SessionGuard::capture();
762
763        // A member posts a root message, then a kind-1111 threaded reply to it
764        // (the shape Armada sends).
765        let member = Keys::generate();
766        let root = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "thread root", None, &[], vec![], 5_000);
767        let root_id = root.id.unwrap().to_hex();
768        let (rw, _) = chat::seal_chat_rumor(&root, &group, &member, Timestamp::from_secs(5), false).unwrap();
769        let reply = chat::build_comment_rumor(
770            member.public_key(),
771            &general,
772            community.root_epoch,
773            "thread reply",
774            &root_id,
775            super::super::kind::MESSAGE,
776            &member.public_key().to_hex(),
777            None,
778            &[],
779            6_000,
780        );
781        let reply_id = reply.id.unwrap().to_hex();
782        let (tw, _) = chat::seal_chat_rumor(&reply, &group, &member, Timestamp::from_secs(6), false).unwrap();
783
784        for w in [&rw, &tw] {
785            let ev = chat::open_chat_event(w, &group, &general, community.root_epoch).unwrap();
786            let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
787            assert!(matches!(outcome, Some(ChatPersist::New(_))), "both persist as new messages");
788        }
789        // The reply row carries its parent as inline reply context, resolved
790        // from the persisted root (v1's reply-preview parity).
791        let held = {
792            let st = crate::state::STATE.lock().await;
793            st.find_message(&reply_id).map(|(_, m)| m)
794        }
795        .expect("the threaded reply is resident");
796        assert_eq!(held.replied_to, root_id, "the immediate parent is the reply context");
797        assert_eq!(held.content, "thread reply");
798
799        // Its author deletes it — target kind 1111 (the delete e/k grammar).
800        let del = chat::build_delete_rumor(member.public_key(), &general, community.root_epoch, &reply_id, super::super::kind::COMMENT, 7_000);
801        let (dw, _) = chat::seal_chat_rumor(&del, &group, &member, Timestamp::from_secs(7), false).unwrap();
802        let ev = chat::open_chat_event(&dw, &group, &general, community.root_epoch).unwrap();
803        let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
804        assert!(matches!(outcome, Some(ChatPersist::Removed(id)) if id == reply_id), "the author's delete removes their thread reply");
805    }
806
807    #[tokio::test]
808    async fn an_edit_replay_never_refires() {
809        use nostr_sdk::prelude::Timestamp;
810        let (_tmp, _guard, me) = init();
811        let relay = MemoryRelay::new();
812        let community = service::create_community(&relay, "EditReplay", vec!["wss://r".into()], None).await.unwrap();
813        let general = community.channels[0].id;
814        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
815        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
816        let session = crate::state::SessionGuard::capture();
817
818        // Another member posts, then edits their own message.
819        let member = Keys::generate();
820        let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "v1 text", None, &[], vec![], 5_000);
821        let msg_id = msg.id.unwrap().to_hex();
822        let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
823        let edit = chat::build_edit_rumor(member.public_key(), &general, community.root_epoch, &msg_id, "v2 text", 6_000);
824        let (ew, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(6), false).unwrap();
825        for w in [&mw, &ew] {
826            if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
827                let _ = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
828            }
829        }
830
831        // A RE-WRAP of the same signed EDIT (fresh outer id) must not re-fire: the
832        // MESSAGE_EDIT row dedups it, exactly like the other three chat kinds.
833        let (replay, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(7), false).unwrap();
834        assert_ne!(replay.id, ew.id, "a re-wrap is a distinct outer event");
835        let ev = chat::open_chat_event(&replay, &group, &general, community.root_epoch).unwrap();
836        assert!(
837            persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(),
838            "a replayed edit yields no outcome (no handler re-fire)"
839        );
840    }
841
842    #[tokio::test]
843    async fn a_forged_edit_from_a_non_author_is_ignored() {
844        // Author-scoping on edits (the counterpart to the forged-delete guard): a
845        // member (holds the channel key) forges an EDIT of someone else's message.
846        // It must not rewrite the content.
847        use nostr_sdk::prelude::Timestamp;
848        let (_tmp, _guard, me) = init();
849        let relay = MemoryRelay::new();
850        let community = service::create_community(&relay, "EditGuard", vec!["wss://r".into()], None).await.unwrap();
851        let general = community.channels[0].id;
852        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
853        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
854        let session = crate::state::SessionGuard::capture();
855
856        // The real author posts a message.
857        let author = Keys::generate();
858        let msg = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, "original", None, &[], vec![], 5_000);
859        let msg_id = msg.id.unwrap().to_hex();
860        let (mw, _) = chat::seal_chat_rumor(&msg, &group, &author, Timestamp::from_secs(5), false).unwrap();
861        let ev = chat::open_chat_event(&mw, &group, &general, community.root_epoch).unwrap();
862        persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
863
864        // A stranger (member, holds the key) forges an edit of the author's message.
865        let stranger = Keys::generate();
866        let edit = chat::build_edit_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, "TAMPERED", 6_000);
867        let (ew, _) = chat::seal_chat_rumor(&edit, &group, &stranger, Timestamp::from_secs(6), false).unwrap();
868        let ev = chat::open_chat_event(&ew, &group, &general, community.root_epoch).unwrap();
869        assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a forged edit yields no outcome");
870
871        let content = {
872            let st = crate::state::STATE.lock().await;
873            st.find_message(&msg_id).map(|(_, m)| m.content)
874        };
875        assert_eq!(content.as_deref(), Some("original"), "the message content is unchanged by the forged edit");
876    }
877
878    #[tokio::test]
879    async fn a_reaction_cannot_be_injected_across_channels() {
880        // A member holds BOTH channels' keys, so they can seal a valid reaction in
881        // channel A whose target is a message resident in channel B. The
882        // cross-channel guard (a reaction lands only on a same-channel target) must
883        // drop it — else reactions could be injected onto messages in channels the
884        // reaction was never sealed under.
885        use nostr_sdk::prelude::Timestamp;
886        let (_tmp, _guard, me) = init();
887        let relay = MemoryRelay::new();
888        let mut community = service::create_community(&relay, "TwoChan", vec!["wss://r".into()], None).await.unwrap();
889        let chan_a = community.channels[0].id;
890        let chan_b = service::create_public_channel(&relay, &community, "b").await.unwrap();
891        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
892        let a_hex = crate::simd::hex::bytes_to_hex_32(&chan_a.0);
893        let session = crate::state::SessionGuard::capture();
894
895        // A message lives in channel B.
896        let author = Keys::generate();
897        let gb = super::super::derive::channel_group_key(&community.community_root, &chan_b, community.root_epoch);
898        let msg = chat::build_message_rumor(author.public_key(), &chan_b, community.root_epoch, "in B", None, &[], vec![], 5_000);
899        let msg_id = msg.id.unwrap().to_hex();
900        let (mw, _) = chat::seal_chat_rumor(&msg, &gb, &author, Timestamp::from_secs(5), false).unwrap();
901        let bev = chat::open_chat_event(&mw, &gb, &chan_b, community.root_epoch).unwrap();
902        persist_chat_event(&bev, &crate::simd::hex::bytes_to_hex_32(&chan_b.0), &me.public_key(), &session).await;
903
904        // A reaction sealed in channel A, targeting the channel-B message.
905        let ga = super::super::derive::channel_group_key(&community.community_root, &chan_a, community.root_epoch);
906        let reaction = chat::build_reaction_rumor(author.public_key(), &chan_a, community.root_epoch, &msg_id, &author.public_key().to_hex(), super::super::kind::MESSAGE, "💥", None, 6_000);
907        let (rw, _) = chat::seal_chat_rumor(&reaction, &ga, &author, Timestamp::from_secs(6), false).unwrap();
908        let aev = chat::open_chat_event(&rw, &ga, &chan_a, community.root_epoch).unwrap();
909        // Applied under channel A (where it was sealed) — the target is in B.
910        let outcome = persist_chat_event(&aev, &a_hex, &me.public_key(), &session).await;
911        assert!(outcome.is_none(), "a cross-channel reaction is dropped");
912        let reacted = {
913            let st = crate::state::STATE.lock().await;
914            st.find_message(&msg_id).map(|(_, m)| !m.reactions.is_empty()).unwrap_or(false)
915        };
916        assert!(!reacted, "the channel-B message gained no reaction from the channel-A injection");
917    }
918
919    #[tokio::test]
920    async fn a_forged_delete_from_a_non_author_is_ignored() {
921        use nostr_sdk::prelude::Timestamp;
922        let (_tmp, _guard, me) = init();
923        let relay = MemoryRelay::new();
924        let community = service::create_community(&relay, "Forge", vec!["wss://r".into()], None).await.unwrap();
925        let general = community.channels[0].id;
926        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
927        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
928
929        // `me` posts a message and it's persisted into STATE.
930        let msg_id = service::send_message(&relay, &community, &general, "mine").await.unwrap();
931        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
932        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
933        for w in &wraps {
934            if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
935                let mut st = crate::state::STATE.lock().await;
936                apply_chat_to_state(&mut st, &ev, &cid, &me.public_key());
937            }
938        }
939
940        // A STRANGER (a member, so holds the channel key) forges a delete of `me`'s message.
941        let stranger = nostr_sdk::prelude::Keys::generate();
942        let del = chat::build_delete_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, super::super::kind::MESSAGE, 9_000);
943        let (wrap, _) = chat::seal_chat_rumor(&del, &group, &stranger, Timestamp::from_secs(9), false).unwrap();
944        let event = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
945
946        let outcome = {
947            let mut st = crate::state::STATE.lock().await;
948            apply_chat_to_state(&mut st, &event, &cid, &me.public_key())
949        };
950        assert!(outcome.is_none(), "a forged delete from a non-author yields no removal");
951        let survives = {
952            let st = crate::state::STATE.lock().await;
953            st.find_message(&msg_id).is_some()
954        };
955        assert!(survives, "the message survives the forged delete (live view + DB stay consistent)");
956    }
957
958    #[tokio::test]
959    async fn a_foreign_wrap_is_not_ours() {
960        let (_tmp, _guard, me) = init();
961        let relay = MemoryRelay::new();
962        let community = service::create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
963
964        // A wrap from an unrelated stream key (e.g. a DM giftwrap, or another
965        // community) must not match any plane.
966        let stranger = super::super::derive::channel_group_key(&[0x99u8; 32], &community.channels[0].id, community.root_epoch);
967        let rumor = chat::build_message_rumor(me.public_key(), &community.channels[0].id, community.root_epoch, "not yours", None, &[], vec![], 1_000);
968        let (wrap, _) = chat::seal_chat_rumor(&rumor, &stranger, &me, nostr_sdk::prelude::Timestamp::from_secs(1), false).unwrap();
969
970        let rec = Recorder::default();
971        assert!(matches!(dispatch_wrap(&wrap, &community, &me.public_key(), &rec), DispatchedV2::NotOurs));
972        assert!(rec.messages.lock().unwrap().is_empty());
973    }
974}