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