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::prelude::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    let attachments = attachments_from_tags(opened.rumor.tags.iter(), &crate::db::get_download_dir());
41    Message {
42        id: opened.rumor_id.to_hex(),
43        // Drop any blob URL a foreign client (e.g. Armada) also inlined into the caption.
44        content: super::super::attachments::strip_attachment_urls(&opened.rumor.content, &attachments),
45        replied_to,
46        replied_to_npub,
47        at: opened.at_ms,
48        mine: opened.author == *my_pubkey,
49        npub: opened.author.to_bech32().ok(),
50        attachments,
51        emoji_tags: emoji
52            .iter()
53            .map(|(shortcode, url)| EmojiTag { shortcode: shortcode.clone(), url: url.clone() })
54            .collect(),
55        addressed_bots: crate::bot_interface::addressed_bots(opened.rumor.tags.iter()),
56        wrapper_event_id: Some(opened.wrapper_id.to_hex()),
57        // Self-Destruct Timer: the sender's NIP-40 expiry drives our local purge.
58        expiration: chat::message_expiration(&opened.rumor),
59        ..Default::default()
60    }
61}
62
63/// Whether `author` sits on the banlist of the community owning `channel_id`
64/// (fail-open on a lookup error: availability must never hide honest traffic —
65/// the ban re-applies on the next fold).
66fn author_is_banned_here(channel_id: &str, author: &PublicKey) -> bool {
67    let Ok(Some(cid_hex)) = crate::db::community::community_id_for_channel(channel_id) else {
68        return false;
69    };
70    crate::db::community::is_author_banned(&cid_hex, author)
71}
72
73/// May `deleter` remove a message authored by `author` in this channel? The
74/// deleter needs `MANAGE_MESSAGES`, a strict outrank, and a synced citation.
75/// Blocked once dissolved: a dead community honors no new authority action, only
76/// the self-deletes its seal deliberately leaves open (CORD-02 §9).
77fn moderation_delete_authorized(
78    channel_id: &str,
79    deleter: &PublicKey,
80    author: &PublicKey,
81    tags: &nostr_sdk::prelude::Tags,
82) -> bool {
83    let Ok(Some(cid_hex)) = crate::db::community::community_id_for_channel(channel_id) else {
84        return false;
85    };
86    if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
87        return false;
88    }
89    let Some(owner_hex) = crate::community::moderation::owner_hex(&cid_hex) else {
90        return false; // no provable owner → no provable authority chain
91    };
92    let deleter_hex = deleter.to_hex();
93    let citation = crate::community::edition::AuthorityCitation::from_tags(tags);
94    if !super::service::citation_is_synced(&cid_hex, &owner_hex, &deleter_hex, citation.as_ref()) {
95        return false;
96    }
97    let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
98    crate::community::moderation::can_hide(Some(&owner_hex), &roster, &deleter_hex, &author.to_hex())
99}
100
101/// What applying a v2 chat event to STATE yielded — the caller persists it (async)
102/// once the STATE lock is dropped. Mirrors v1's `IncomingEvent` for the chat sub-kinds:
103/// a message row is saved fresh or re-saved (a landed reaction rides the row), a delete
104/// drops it. Persistence is the caller's so the apply step stays sync + lock-scoped.
105pub enum ChatPersist {
106    /// A brand-new message — save its row.
107    New(Message),
108    /// A message changed: a reaction landed (`edit_event` None → re-save the row, which
109    /// carries reactions) or an edit applied (`edit_event` Some → save the folded
110    /// MESSAGE_EDIT event, event-sourced like v1 + DMs, never a row overwrite).
111    Updated { message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
112    /// A message removed by its author — drop its row.
113    Removed(String),
114    /// A reaction revoked by its reactor — drop the kind-7 row (save is
115    /// additive; a lingering row would resurrect the chip on the next load)
116    /// and re-save the parent so its embedded reactions refresh.
117    ReactionRemoved { reaction_id: String, message: Message },
118}
119
120/// Apply an opened v2 [`ChatEvent`] to STATE (dedup + aggregate onto the SHARED
121/// [`ChatState`]), mirroring v1's `ingest_message`/`apply_reaction`/`apply_delete`. Sync:
122/// the DB dedup read + STATE mutation run under the caller's lock; the caller then does the
123/// async DB persist on the returned [`ChatPersist`] (see [`persist_chat`]). Returns `None`
124/// for a duplicate, a non-persisted kind (typing/webxdc), an edit (increment 2), or an
125/// aggregate whose target isn't resident in this channel.
126pub fn apply_chat_to_state(state: &mut ChatState, event: &ChatEvent, channel_id: &str, my_pubkey: &PublicKey) -> Option<ChatPersist> {
127    // CORD-04 §4: a banned npub VANISHES — every chat event they author (message,
128    // reaction, edit, delete) is dropped at fold time. Severance (the rekey) only
129    // cuts their READ of new epochs; they still hold old epoch keys and can post
130    // to old planes forever — refusing to fold them is what makes the ban hold.
131    // The persisted banlist can never name the owner (the authority fold refuses
132    // a ban whose target is position 0), so no owner exemption is needed here.
133    if author_is_banned_here(channel_id, &event.opened().author) {
134        return None;
135    }
136    match event {
137        ChatEvent::Message { opened, reply_to, emoji } => {
138            let msg = chat_message_to_message(opened, reply_to, emoji, my_pubkey);
139            // DB dedup: a known inner id is already stored — don't re-ingest/re-emit (a
140            // catch-up sweep re-fetches the whole page; in-memory STATE holds only a window).
141            if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
142                return None;
143            }
144            state.ensure_community_chat(channel_id);
145            // Persist regardless of the STATE-add result: `event_exists` already proved
146            // it's not in the DB, so a `false` here means only that another writer put it
147            // in STATE first — the row must still be saved, or it's lost until re-fetch.
148            state.add_message_to_chat(channel_id, &msg);
149            Some(ChatPersist::New(msg))
150        }
151        ChatEvent::Reaction { opened, target, emoji, emoji_url, .. } => {
152            let target_id = crate::simd::hex::bytes_to_hex_32(target);
153            // Cross-channel guard: a reaction lands only on a target resident in the SAME
154            // channel it was sealed under (its binding authenticates its own channel, never
155            // the target's) — else a member could inject reactions across channels.
156            if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == channel_id) {
157                return None;
158            }
159            let reaction = Reaction {
160                id: opened.rumor_id.to_hex(),
161                reference_id: target_id.clone(),
162                // npub, not hex: the frontend's whole reaction contract (profile
163                // name resolve + the own-reaction highlight vs strPubkey) keys on
164                // bech32 — exactly what v1 stores.
165                author_id: opened.author.to_bech32().unwrap_or_else(|_| opened.author.to_hex()),
166                emoji: emoji.clone(),
167                emoji_url: emoji_url.clone(),
168            };
169            let (_c, added) = state.add_reaction_to_message(&target_id, reaction)?;
170            added.then(|| state.find_message(&target_id).map(|(_c, m)| ChatPersist::Updated { message: m, edit_event: None }))?
171        }
172        ChatEvent::Edit { opened, target, new_content, emoji } => {
173            // Dedup by the edit's own rumor id (its MESSAGE_EDIT row below): the
174            // in-message `apply_edit` dedups silently, so without this a re-wrapped
175            // duplicate would still return Updated and re-fire the handler — the
176            // replay hole the persist-gated callbacks exist to close.
177            if crate::db::events::event_exists(&opened.rumor_id.to_hex()).unwrap_or(false) {
178                return None;
179            }
180            let target_id = crate::simd::hex::bytes_to_hex_32(target);
181            // Author-scoped + same-channel: only the original author edits their own message.
182            let editor_npub = opened.author.to_bech32().ok()?;
183            if !matches!(state.find_message(&target_id), Some((chat, m)) if chat.id == channel_id && m.npub.as_deref() == Some(editor_npub.as_str())) {
184                return None;
185            }
186            // Apply to STATE via the shared canonical applier (seeds history with the
187            // original once, dedups by `edited_at`, swaps content) — reused from v1/DMs.
188            // The edit's emoji pairs ride along: apply_edit adopts the LATEST edit's
189            // set, so peers render the replacement text from the replacement's tags.
190            let edited_at = opened.at_ms;
191            let emoji_tags: Vec<crate::types::EmojiTag> = emoji
192                .iter()
193                .map(|(shortcode, url)| crate::types::EmojiTag { shortcode: shortcode.clone(), url: url.clone() })
194                .collect();
195            let (_c, message) = state.update_message(&target_id, |m| m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone()))?;
196            // Persist as a folded MESSAGE_EDIT event (chat_id set at save time), matching
197            // v1 — emoji tags included, or the reload fold re-strips what the live fold kept.
198            let edit_event = crate::stored_event::StoredEventBuilder::new()
199                .id(opened.rumor_id.to_hex())
200                .kind(crate::stored_event::event_kind::MESSAGE_EDIT)
201                .content(new_content.clone())
202                .tags(emoji.iter().map(|(s, u)| vec!["emoji".to_string(), s.clone(), u.clone()]).collect())
203                .reference_id(Some(target_id.clone()))
204                .created_at(edited_at / 1000)
205                .mine(opened.author == *my_pubkey)
206                .npub(opened.author.to_bech32().ok())
207                .build();
208            Some(ChatPersist::Updated { message, edit_event: Some(Box::new(edit_event)) })
209        }
210        ChatEvent::Delete { opened, target, .. } => {
211            let target_id = crate::simd::hex::bytes_to_hex_32(target);
212            // A delete may target a REACTION (an un-react) rather than a message.
213            // Reactions are author-revocable only: the deleter must be the
214            // reactor. Checked before the message path so a reaction id never
215            // falls through to message-removal logic (v1's exact rule).
216            if let Some((_chat, message_id, author_npub, _)) = state.find_reaction(&target_id) {
217                let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == opened.author).unwrap_or(false);
218                if !reactor_ok {
219                    return None;
220                }
221                return state
222                    .remove_reaction_from_message(&message_id, &target_id)
223                    .map(|(_cid, message)| ChatPersist::ReactionRemoved { reaction_id: target_id, message });
224            }
225            // A delete removes its author's OWN message, or someone else's under
226            // MANAGE_MESSAGES. Resolve the target's author from the resident copy, then
227            // the DB for a paged-out row — residency is a cache detail, and authorizing
228            // only what's in the window would let a moderator's hide of an older message
229            // land on some peers and not others.
230            let resident = state.find_message(&target_id).and_then(|(_, m)| m.npub.clone());
231            let author_npub = match resident {
232                Some(n) => Some(n),
233                None => crate::db::events::event_author(&target_id).ok().flatten(),
234            };
235            let author = author_npub.as_deref().and_then(|n| PublicKey::parse(n).ok())?;
236            let authorized = author == opened.author
237                || moderation_delete_authorized(channel_id, &opened.author, &author, &opened.rumor.tags);
238            if !authorized {
239                return None;
240            }
241            // A paged-out target isn't in STATE to remove; the caller's persist step
242            // drops the stored row either way.
243            let _ = state.remove_message(&target_id);
244            Some(ChatPersist::Removed(target_id))
245        }
246        ChatEvent::Typing { .. } | ChatEvent::Webxdc { .. } => None,
247    }
248}
249
250/// Apply an already-opened chat event to STATE + the shared store — the LIVE
251/// counterpart of [`crate::VectorCore::v2_backfill_channel`]'s catch-up persistence.
252/// The dispatcher opened the wrap (so nothing decrypts twice); the returned outcome
253/// is what the caller's callbacks fire from — a duplicate, a non-resident target,
254/// or a forged edit/delete yields `None` and nothing re-fires.
255pub async fn persist_chat_event(
256    event: &ChatEvent,
257    channel_id: &str,
258    my_pubkey: &PublicKey,
259) -> Option<ChatPersist> {
260    let outcome = {
261        let mut st = crate::state::STATE.lock().await;
262        apply_chat_to_state(&mut st, event, channel_id, my_pubkey)
263    }?;
264    // Resolve a reply's preview (content/npub) from the DB before persist + emit
265    // (v1 parity): the parent is often persisted but outside the in-memory window,
266    // and without this the live render shows a reply with no context.
267    let outcome = match outcome {
268        ChatPersist::New(mut m) => {
269            if !m.replied_to.is_empty() {
270                let _ = crate::db::events::populate_reply_context(&mut m).await;
271            }
272            // Bytes we already hold verify at ARRIVAL, mirroring the DM ingest:
273            // before the persist below, so the row lands downloaded; the hint
274            // patches the STATE copy apply_chat_to_state already inserted.
275            crate::db::attachments::verify_message_attachments(&mut m, Some(channel_id)).await;
276            ChatPersist::New(m)
277        }
278        o => o,
279    };
280    // …and only persist to THIS account's DB (the save straddles an await).
281    persist_chat(channel_id, &outcome).await;
282    // §7 pin duties: a deleted pinned message wants the omitting edition, an
283    // edited one a refreshed proof bundle. Fired only from a PERSISTED outcome
284    // (the dedup/authorization gates above already passed); non-curators no-op.
285    match (&outcome, event) {
286        (ChatPersist::Removed(target), _) => {
287            super::service::spawn_pin_duty(channel_id, target, None);
288        }
289        (ChatPersist::Updated { .. }, ChatEvent::Edit { opened, target, .. }) => {
290            let target_hex = crate::simd::hex::bytes_to_hex_32(target);
291            super::service::spawn_pin_duty(channel_id, &target_hex, Some(opened.clone()));
292        }
293        _ => {}
294    }
295    Some(outcome)
296}
297
298/// Persist an [`apply_chat_to_state`] outcome to the shared events DB — async, run by the
299/// caller AFTER the STATE lock drops (a message row carries its reactions, so a reaction
300/// re-saves the row; a delete drops it).
301pub async fn persist_chat(channel_id: &str, outcome: &ChatPersist) {
302    match outcome {
303        ChatPersist::New(m) => {
304            let _ = crate::db::events::save_message(channel_id, m).await;
305        }
306        // An edit is event-sourced: save the MESSAGE_EDIT row (folded on reload), never a
307        // row overwrite. A reaction rides the message row, so re-save it.
308        ChatPersist::Updated { message, edit_event } => match edit_event {
309            Some(ev) => {
310                let mut ev = (**ev).clone();
311                // get-or-CREATE: a lookup-only id would leave a fresh channel's edit at
312                // chat_id 0 (orphaned, dropped on the reload fold).
313                if let Ok(cid) = crate::db::id_cache::get_or_create_chat_id(channel_id) {
314                    ev.chat_id = cid;
315                }
316                let _ = crate::db::events::save_event(&ev).await;
317            }
318            None => {
319                let _ = crate::db::events::save_message(channel_id, message).await;
320            }
321        },
322        ChatPersist::Removed(id) => {
323            let _ = crate::db::events::delete_event(id).await;
324        }
325        ChatPersist::ReactionRemoved { reaction_id, message } => {
326            let _ = crate::db::events::delete_event(reaction_id).await;
327            let _ = crate::db::events::save_message(channel_id, message).await;
328        }
329    }
330}
331
332/// The typed outcome of dispatching one v2 wrap.
333#[derive(Debug, Clone)]
334pub enum DispatchedV2 {
335    /// An OPENED chat event (message/reaction/edit/delete) on `channel_id` (hex),
336    /// NOT yet applied. The realtime layer runs it through [`persist_chat_event`]
337    /// and fires the matching callback from the outcome — so a re-wrapped
338    /// duplicate (any keyholder can re-wrap a signed seal into a fresh 1059) or a
339    /// forged edit/delete never re-fires a handler, exactly v1's model.
340    Chat { channel_id: String, event: Box<ChatEvent> },
341    /// A typing indicator from `npub` on `channel_id`.
342    Typing { channel_id: String, npub: String },
343    /// A Guestbook join/leave for `npub`.
344    Presence { npub: String, joined: bool },
345    /// A Guestbook Kick naming `target`. Returned unjudged: the store keeps the
346    /// raw rumor and the memberlist fold is what applies KICK authority, so the
347    /// realtime layer ingests it and re-folds rather than trusting the wrap.
348    Kick { target: PublicKey },
349    /// A wrap on this community's Control Plane — its metadata/channel set may
350    /// have changed. Recognized here (address match) but NOT folded: the fold
351    /// needs the whole edition chain, so the realtime layer re-fetches + re-folds
352    /// + re-subscribes. `community_id` is hex.
353    Control { community_id: String },
354    /// A wrap on one of this community's next-epoch rekey planes — a rotation is in
355    /// flight. Recognized by address; the realtime layer runs the stateful catch-up
356    /// ([`super::service::follow_rekeys`]) across every scope. `community_id` is hex.
357    Rekey { community_id: String },
358    /// A verified owner-signed tombstone at the dissolved plane (CORD-02 §9): the
359    /// community is dead. The realtime layer seals it read-only. `community_id` is hex.
360    Dissolved { community_id: String },
361    /// The wrap opened on a v2 plane but carries nothing the handler renders
362    /// (e.g. a WebXDC signal, or a kick we don't surface in the first cut).
363    Ignored,
364    /// Not a v2 plane of this community — try elsewhere / drop.
365    NotOurs,
366}
367
368/// Dispatch a received kind-1059 wrap for `community`: route it to the plane it
369/// opens under. Chat events are returned OPENED (the realtime layer persists,
370/// then fires callbacks from the outcome); only the non-persisted kinds (typing,
371/// guestbook presence) fire their callback inline here. Purely in-memory — so
372/// this stays offline-testable.
373pub fn dispatch_wrap(
374    wrap: &nostr_sdk::prelude::Event,
375    community: &CommunityV2,
376    my_pubkey: &PublicKey,
377    handler: &dyn InboundEventHandler,
378) -> DispatchedV2 {
379    // 1. Chat planes: try each held channel by its group key (author match).
380    for ch in &community.channels {
381        // A keyless private channel is UNREADABLE — never address it at the root plane
382        // (channel_secret falls back to the root, which would be a private→public leak).
383        if ch.private && ch.key.is_none() {
384            continue;
385        }
386        let (secret, epoch) = community.channel_secret(ch);
387        let group = super::derive::channel_group_key(&secret, &ch.id, epoch);
388        if wrap.pubkey != group.pk() {
389            continue;
390        }
391        let Ok(event) = chat::open_chat_event(wrap, &group, &ch.id, epoch) else {
392            return DispatchedV2::NotOurs;
393        };
394        let channel_id = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
395        return dispatch_chat_event(event, &channel_id, my_pubkey, handler);
396    }
397
398    // 2. Guestbook plane: join/leave presence.
399    let gb = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
400    if wrap.pubkey == gb.pk() {
401        if let Ok(opened) = stream::open_wrap(wrap, &gb) {
402            if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
403                return dispatch_guestbook(&ev, community, handler);
404            }
405        }
406        return DispatchedV2::Ignored;
407    }
408
409    // 3. Control plane: a metadata/channel edition. Recognized by address only —
410    // the fold needs the whole chain, which the realtime layer re-fetches. Shares
411    // one address helper with the subscription so the two can't drift.
412    if wrap.pubkey == super::realtime::control_author(community) {
413        return DispatchedV2::Control { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
414    }
415
416    // 4. Rekey planes: a rotation in flight (base or a private channel), addressed
417    // at the next epoch. Same author-set the subscription rides — one source of
418    // truth ([`super::realtime::rekey_authors`]) so recognition and subscription
419    // can't drift.
420    if super::realtime::rekey_authors(community).contains(&wrap.pubkey) {
421        return DispatchedV2::Rekey { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
422    }
423
424    // 5. Dissolved plane: the terminal tombstone (CORD-02 §9). Honor ONLY a valid
425    // owner seal — a foreign event at this public address is noise.
426    if wrap.pubkey == super::derive::dissolved_group_key(community.id()).pk() {
427        if super::dissolution::verify_dissolved(wrap, &community.identity) {
428            return DispatchedV2::Dissolved { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
429        }
430        return DispatchedV2::Ignored;
431    }
432
433    DispatchedV2::NotOurs
434}
435
436fn dispatch_chat_event(event: ChatEvent, channel_id: &str, my_pubkey: &PublicKey, handler: &dyn InboundEventHandler) -> DispatchedV2 {
437    match event {
438        // Typing is ephemeral (never persisted) — fired inline, so it carries its
439        // own CORD-04 banned-author drop (the persisted kinds get theirs in
440        // `apply_chat_to_state`).
441        ChatEvent::Typing { opened } => {
442            // Drop by AUTHOR, not by wrap id: another client signing with the same
443            // key is still us, and the send-side dedup only knows our own wraps.
444            // Drop by AUTHOR, not by wrap id: another client signing with the same
445            // key is still us, and the send-side dedup only knows our own wraps.
446            if opened.author == *my_pubkey || author_is_banned_here(channel_id, &opened.author) {
447                return DispatchedV2::Ignored;
448            }
449            let npub = opened.author.to_bech32().unwrap_or_default();
450            let until = opened.at_ms / 1000 + 30;
451            handler.on_community_typing(channel_id, &npub, until);
452            DispatchedV2::Typing { channel_id: channel_id.to_string(), npub }
453        }
454        // A WebXDC peer signal fires the same handler surface v1 uses — the shared
455        // tail (30078 persist + recency gate + Iroh wiring + the lobby emit) does
456        // the rest. Own-device echoes drop: the local realtime layer tracks itself.
457        ChatEvent::Webxdc { opened } => {
458            if opened.author == *my_pubkey || author_is_banned_here(channel_id, &opened.author) {
459                return DispatchedV2::Ignored;
460            }
461            let Some((topic_id, node_addr)) = crate::webxdc::parse_peer_signal(&opened.rumor.content) else {
462                return DispatchedV2::Ignored;
463            };
464            let npub = opened.author.to_bech32().unwrap_or_default();
465            handler.on_community_webxdc(
466                channel_id,
467                &npub,
468                &topic_id,
469                node_addr.as_deref(),
470                &opened.rumor_id.to_hex(),
471                opened.at_ms / 1000,
472            );
473            DispatchedV2::Ignored
474        }
475        // Message/Reaction/Edit/Delete all persist first; their callbacks fire from
476        // the outcome (dedup + author checks), never optimistically.
477        event => DispatchedV2::Chat { channel_id: channel_id.to_string(), event: Box::new(event) },
478    }
479}
480
481fn dispatch_guestbook(ev: &guestbook::GuestbookEvent, community: &CommunityV2, handler: &dyn InboundEventHandler) -> DispatchedV2 {
482    // CORD-04 §4 is a RENDER/FOLD rule, not a storage rule: a banned npub's
483    // presence draws no line, but the event still reaches the store, because the
484    // fold subtracts the banlist REVERSIBLY. Dropping it here instead is
485    // destructive: an invite legally races an unban ("any keyholder can whisper
486    // keys"), so a Join can arrive seconds before the unban edition — eaten at
487    // the store, the member stays invisible after the unban with nothing left to
488    // re-fold.
489    let suppressed = match &ev.entry {
490        GuestbookEntry::Join { member, .. } | GuestbookEntry::Leave { member, .. } => {
491            let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
492            crate::db::community::is_author_banned(&cid_hex, member)
493        }
494        _ => false,
495    };
496    // The REAL rumor id keys the presence line: `save_system_event_at` dedups by
497    // it, so every distinct join/leave inserts exactly once no matter how many
498    // paths (live replay, reconnect, catch-up) deliver it.
499    let event_id = crate::simd::hex::bytes_to_hex_32(&ev.rumor_id);
500    // Presence is announced against the community's SURFACED row (the primary
501    // channel — the one chat the list shows). A community with NO channels has no
502    // row to announce against, and `unwrap_or_default()` invented one: a blank
503    // conversation id, which a UNIQUE identifier then collapsed into a single
504    // unopenable chat shared by every such community. None = don't announce; the
505    // dispatch still reports the presence so the memberlist bookkeeping runs, and
506    // the guestbook re-folds the line once a channel exists.
507    let chat_id = community
508        .primary_channel()
509        .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0));
510    match &ev.entry {
511        GuestbookEntry::Join { member, at_ms, invited_by } => {
512            let npub = member.to_bech32().unwrap_or_default();
513            let (by, label) = match invited_by {
514                Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
515                None => (None, None),
516            };
517            if let (false, Some(chat)) = (suppressed, chat_id.as_deref()) {
518                handler.on_community_presence(chat, &npub, true, &event_id, at_ms / 1000, by, label);
519            }
520            DispatchedV2::Presence { npub: npub.clone(), joined: true }
521        }
522        GuestbookEntry::Leave { member, at_ms } => {
523            let npub = member.to_bech32().unwrap_or_default();
524            if let (false, Some(chat)) = (suppressed, chat_id.as_deref()) {
525                handler.on_community_presence(chat, &npub, false, &event_id, at_ms / 1000, None, None);
526            }
527            DispatchedV2::Presence { npub: npub.clone(), joined: false }
528        }
529        // A Kick shapes the memberlist, not the feed — so no presence line, but it
530        // MUST reach the store or the target never leaves anyone's roster.
531        GuestbookEntry::Kick { target, .. } => DispatchedV2::Kick { target: *target },
532        GuestbookEntry::Snapshot { .. } => DispatchedV2::Ignored,
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use super::super::service;
540    use crate::community::transport::memory::MemoryRelay;
541    use crate::community::transport::Transport;
542    use nostr_sdk::prelude::Keys;
543    use std::sync::Mutex;
544
545    /// A handler that records every callback it receives.
546    #[derive(Default)]
547    struct Recorder {
548        messages: Mutex<Vec<(String, Message)>>,
549        updates: Mutex<Vec<(String, String)>>,
550        removed: Mutex<Vec<(String, String)>>,
551        presence: Mutex<Vec<(String, bool)>>,
552        typing: Mutex<Vec<(String, String)>>,
553    }
554    impl InboundEventHandler for Recorder {
555        fn on_community_message(&self, chat_id: &str, msg: &Message, _is_new: bool) {
556            self.messages.lock().unwrap().push((chat_id.to_string(), msg.clone()));
557        }
558        fn on_community_update(&self, chat_id: &str, target: &str, _msg: &Message) {
559            self.updates.lock().unwrap().push((chat_id.to_string(), target.to_string()));
560        }
561        fn on_community_removed(&self, chat_id: &str, target: &str) {
562            self.removed.lock().unwrap().push((chat_id.to_string(), target.to_string()));
563        }
564        fn on_community_presence(&self, _c: &str, npub: &str, joined: bool, _e: &str, _a: u64, _b: Option<&str>, _l: Option<&str>) {
565            self.presence.lock().unwrap().push((npub.to_string(), joined));
566        }
567        fn on_community_typing(&self, chat_id: &str, npub: &str, _until: u64) {
568            self.typing.lock().unwrap().push((chat_id.to_string(), npub.to_string()));
569        }
570    }
571
572    fn init() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
573        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
574        crate::db::close_database();
575        crate::db::clear_id_caches();
576        let tmp = tempfile::tempdir().unwrap();
577        static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(90_000);
578        let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
579        const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
580        let mut acct = String::from("npub1");
581        let mut v = n as usize;
582        for _ in 0..58 {
583            acct.push(B[v % 32] as char);
584            v = v / 32 + 7;
585        }
586        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
587        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
588        crate::db::set_current_account(acct.clone()).unwrap();
589        crate::db::init_database(&acct).unwrap();
590        let _ = crate::state::take_nostr_client();
591        let me = Keys::generate();
592        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
593        crate::state::set_my_public_key(me.public_key());
594        (tmp, guard, me)
595    }
596
597    #[tokio::test]
598    async fn a_received_message_wrap_opens_then_fires_from_the_persist_outcome() {
599        use nostr_sdk::prelude::Timestamp;
600        let (_tmp, _guard, me) = init();
601        let relay = MemoryRelay::new();
602        let community = service::create_community(&relay, "In", vec!["wss://r".into()], None).await.unwrap();
603        let general = community.channels[0].id;
604        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
605
606        // ANOTHER member (holds the root) posts — the incoming case, so no local
607        // send echo pre-persisted it.
608        let member = Keys::generate();
609        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
610        let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "ping", None, &[], vec![], 5_000);
611        let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
612
613        // Dispatch OPENS the event but fires no message callback — that belongs to
614        // the persist outcome (dedup + author checks), v1's model.
615        let rec = Recorder::default();
616        let dispatched = dispatch_wrap(&wrap, &community, &me.public_key(), &rec);
617        assert!(rec.messages.lock().unwrap().is_empty(), "no optimistic message callback");
618        let DispatchedV2::Chat { channel_id, event } = dispatched else {
619            panic!("a chat wrap dispatches as Chat");
620        };
621        assert_eq!(channel_id, cid);
622
623        let outcome = persist_chat_event(&event, &channel_id, &me.public_key()).await;
624        let Some(ChatPersist::New(msg)) = outcome else {
625            panic!("the first delivery persists as New");
626        };
627        assert_eq!(msg.content, "ping");
628        assert!(!msg.mine, "authored by the other member");
629
630        // A RE-WRAP of the same signed rumor (any keyholder can mint one) is a
631        // fresh outer event, but the persist dedups on the inner id — no re-fire.
632        let (rewrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(6), false).unwrap();
633        assert_ne!(rewrap.id, wrap.id, "a re-wrap is a distinct outer event");
634        let DispatchedV2::Chat { event: dup, .. } = dispatch_wrap(&rewrap, &community, &me.public_key(), &rec) else {
635            panic!("the re-wrap still opens");
636        };
637        assert!(
638            persist_chat_event(&dup, &channel_id, &me.public_key()).await.is_none(),
639            "a re-wrapped duplicate yields no outcome (nothing re-fires)"
640        );
641    }
642
643    #[tokio::test]
644    async fn a_guestbook_join_wrap_fires_presence() {
645        let (_tmp, _guard, me) = init();
646        let relay = MemoryRelay::new();
647        // create_community publishes the owner's genesis Join to the guestbook.
648        let community = service::create_community(&relay, "GB", vec!["wss://r".into()], None).await.unwrap();
649        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
650        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb.pk_hex()], ..Default::default() };
651        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
652
653        let rec = Recorder::default();
654        for w in &wraps {
655            dispatch_wrap(w, &community, &me.public_key(), &rec);
656        }
657        let pres = rec.presence.lock().unwrap();
658        assert_eq!(pres.len(), 1, "the owner's genesis Join fires one presence");
659        assert!(pres[0].1, "it's a join");
660        assert_eq!(pres[0].0, me.public_key().to_bech32().unwrap());
661    }
662
663    #[tokio::test]
664    async fn v2_chat_events_persist_into_the_shared_store() {
665        let (_tmp, _guard, me) = init();
666        let relay = MemoryRelay::new();
667        let community = service::create_community(&relay, "Persist", vec!["wss://r".into()], None).await.unwrap();
668        let general = community.channels[0].id;
669        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
670        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
671        let me_hex = me.public_key().to_hex();
672
673        let msg_id = service::send_message(&relay, &community, &general, "persist me").await.unwrap();
674        service::send_reaction(&relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
675
676        // The SEND ECHO persisted both immediately — send-then-read works with no
677        // listen loop (the INT-W3 contract).
678        assert!(crate::db::events::event_exists(&msg_id).unwrap(), "the send echo persisted the message row");
679        let reacted = {
680            let st = crate::state::STATE.lock().await;
681            st.find_message(&msg_id).map(|(_, m)| m.reactions.iter().any(|r| r.emoji == "🔥")).unwrap_or(false)
682        };
683        assert!(reacted, "the send echo aggregated the reaction onto the stored message");
684
685        // The relay's copies of our own sends then arrive — every one dedups
686        // against the echoed rows (no double rows, no re-fires).
687        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
688        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
689        let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
690        events.sort_by_key(|e| e.opened().at_ms);
691        assert!(!events.is_empty());
692        for ev in &events {
693            let outcome = {
694                let mut st = crate::state::STATE.lock().await;
695                apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
696            };
697            assert!(outcome.is_none(), "the relay echo of an already-echoed send dedups");
698        }
699    }
700
701    #[tokio::test]
702    async fn a_v2_edit_persists_as_a_folded_edit_event() {
703        let (_tmp, _guard, me) = init();
704        let relay = MemoryRelay::new();
705        let community = service::create_community(&relay, "Edit", vec!["wss://r".into()], None).await.unwrap();
706        let general = community.channels[0].id;
707        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
708        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
709
710        let msg_id = service::send_message(&relay, &community, &general, "original").await.unwrap();
711        service::send_edit(&relay, &community, &general, &msg_id, "edited!", &[]).await.unwrap();
712
713        // Apply messages BEFORE their edits (a target must be resident to edit).
714        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
715        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
716        let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
717        events.sort_by_key(|e| (!matches!(e, ChatEvent::Message { .. }), e.opened().at_ms));
718        for ev in &events {
719            let outcome = {
720                let mut st = crate::state::STATE.lock().await;
721                apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
722            };
723            if let Some(o) = outcome {
724                persist_chat(&cid, &o).await;
725            }
726        }
727
728        let content = {
729            let st = crate::state::STATE.lock().await;
730            st.find_message(&msg_id).map(|(_, m)| m.content)
731        };
732        assert_eq!(content.as_deref(), Some("edited!"), "the edit applied to the stored message");
733        let edit_id = events.iter().find_map(|e| matches!(e, ChatEvent::Edit { .. }).then(|| e.opened().rumor_id.to_hex())).unwrap();
734        assert!(crate::db::events::event_exists(&edit_id).unwrap(), "the MESSAGE_EDIT event is persisted (folds on reload)");
735    }
736
737    /// An edit's custom emoji must survive the whole span: the wire (NIP-30 tags
738    /// on the 3302 rumor), the live fold (the message's emoji set), and the
739    /// persisted MESSAGE_EDIT row (the reload fold re-reads tags from there).
740    /// A receiver has no pack to fall back on — the event is all they get.
741    #[tokio::test]
742    async fn an_edit_carries_its_custom_emoji_to_peers_and_across_restart() {
743        let (_tmp, _guard, me) = init();
744        let relay = MemoryRelay::new();
745        let community = service::create_community(&relay, "EditEmoji", vec!["wss://r".into()], None).await.unwrap();
746        let general = community.channels[0].id;
747        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
748        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
749
750        let msg_id = service::send_message(&relay, &community, &general, "plain").await.unwrap();
751        service::send_edit(
752            &relay, &community, &general, &msg_id,
753            "now with :vectorlove:",
754            &[("vectorlove", "https://cdn.example/vectorlove.webp")],
755        ).await.unwrap();
756
757        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
758        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
759        let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
760        events.sort_by_key(|e| (!matches!(e, ChatEvent::Message { .. }), e.opened().at_ms));
761
762        let wire_emoji = events.iter().find_map(|e| match e {
763            ChatEvent::Edit { emoji, .. } => Some(emoji.clone()),
764            _ => None,
765        }).expect("the edit decodes");
766        assert_eq!(
767            wire_emoji,
768            vec![("vectorlove".to_string(), "https://cdn.example/vectorlove.webp".to_string())],
769            "the 3302 rumor carries the NIP-30 pair on the wire"
770        );
771
772        for ev in &events {
773            let outcome = {
774                let mut st = crate::state::STATE.lock().await;
775                apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
776            };
777            if let Some(o) = outcome {
778                persist_chat(&cid, &o).await;
779            }
780        }
781
782        let folded = {
783            let st = crate::state::STATE.lock().await;
784            st.find_message(&msg_id).map(|(_, m)| m.emoji_tags.clone())
785        }.expect("message resident");
786        assert_eq!(folded.len(), 1, "the fold adopted the edit's emoji set");
787        assert_eq!(folded[0].shortcode, "vectorlove");
788        assert_eq!(folded[0].url, "https://cdn.example/vectorlove.webp");
789
790        let edit_id = events.iter().find_map(|e| matches!(e, ChatEvent::Edit { .. }).then(|| e.opened().rumor_id.to_hex())).unwrap();
791        let (_wrap, stored_tags) = crate::db::events::get_event_wrap_context(&edit_id)
792            .unwrap()
793            .expect("the MESSAGE_EDIT row is persisted");
794        let restored = crate::types::EmojiTag::extract_from_stored(&stored_tags);
795        assert_eq!(restored.len(), 1, "the MESSAGE_EDIT row carries the tags the reload fold reads");
796        assert_eq!(restored[0].shortcode, "vectorlove");
797    }
798
799    #[tokio::test]
800    async fn a_reaction_after_a_rekey_aggregates_onto_a_prior_epoch_message() {
801        // Sync stability across a rekey: a message written at epoch 0 and a reaction
802        // to it written at epoch 1 (its wrap sealed + bound under the NEW epoch key)
803        // must still aggregate — STATE keys by rumor id, not epoch, so a reaction to
804        // pre-refound history lands. The reaction's OWN binding is epoch 1; the
805        // target's is epoch 0.
806        use nostr_sdk::prelude::Timestamp;
807        let (_tmp, _guard, me) = init();
808        let relay = MemoryRelay::new();
809        let community = service::create_community(&relay, "Rekeyed", vec!["wss://r".into()], None).await.unwrap();
810        let general = community.channels[0].id;
811        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
812
813        // Epoch-0 message, opened under the epoch-0 public key, persisted.
814        let member = Keys::generate();
815        let g0 = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
816        let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "before the rekey", None, &[], vec![], 5_000);
817        let msg_id = msg.id.unwrap().to_hex();
818        let (mw, _) = chat::seal_chat_rumor(&msg, &g0, &member, Timestamp::from_secs(5), false).unwrap();
819        let ev = chat::open_chat_event(&mw, &g0, &general, community.root_epoch).unwrap();
820        assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
821
822        // Epoch-1 reaction (same root, next epoch → a distinct channel key) to that
823        // epoch-0 message, opened under the epoch-1 key.
824        let next = crate::community::Epoch(community.root_epoch.0 + 1);
825        let g1 = super::super::derive::channel_group_key(&community.community_root, &general, next);
826        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);
827        let (rw, _) = chat::seal_chat_rumor(&reaction, &g1, &member, Timestamp::from_secs(6), false).unwrap();
828        let rev = chat::open_chat_event(&rw, &g1, &general, next).unwrap();
829        let outcome = persist_chat_event(&rev, &cid, &me.public_key()).await;
830        assert!(matches!(outcome, Some(ChatPersist::Updated { .. })), "the cross-epoch reaction updates the target");
831
832        let reaction_author = {
833            let st = crate::state::STATE.lock().await;
834            st.find_message(&msg_id)
835                .and_then(|(_, m)| m.reactions.iter().find(|r| r.emoji == "🎉").map(|r| r.author_id.clone()))
836        };
837        let author = reaction_author.expect("the epoch-1 reaction aggregated onto the epoch-0 message");
838        // npub, never hex: the frontend resolves the reactor's profile and detects
839        // "my reaction" by comparing against the user's npub.
840        assert_eq!(author, member.public_key().to_bech32().unwrap(), "reaction author is stored as bech32");
841    }
842
843    #[tokio::test]
844    async fn an_un_react_removes_the_reaction_for_receivers_and_only_for_its_reactor() {
845        use nostr_sdk::prelude::Timestamp;
846        let (_tmp, _guard, me) = init();
847        let relay = MemoryRelay::new();
848        let community = service::create_community(&relay, "UnReact", vec!["wss://r".into()], None).await.unwrap();
849        let general = community.channels[0].id;
850        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
851        let (secret, epoch) = community.channel_secret(&community.channels[0]);
852        let group = super::super::derive::channel_group_key(&secret, &general, epoch);
853
854        // A message, then the member's reaction to it.
855        let member = Keys::generate();
856        let msg = chat::build_message_rumor(member.public_key(), &general, epoch, "react to me", None, &[], vec![], 5_000);
857        let msg_id = msg.id.unwrap().to_hex();
858        let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
859        let ev = chat::open_chat_event(&mw, &group, &general, epoch).unwrap();
860        assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
861
862        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);
863        let reaction_id = reaction.id.unwrap().to_hex();
864        let (rw, _) = chat::seal_chat_rumor(&reaction, &group, &member, Timestamp::from_secs(6), false).unwrap();
865        let rev = chat::open_chat_event(&rw, &group, &general, epoch).unwrap();
866        assert!(matches!(persist_chat_event(&rev, &cid, &me.public_key()).await, Some(ChatPersist::Updated { .. })));
867
868        // A NON-reactor's delete targeting the reaction is dropped outright.
869        let outsider = Keys::generate();
870        let forged = chat::build_delete_rumor(outsider.public_key(), &general, epoch, &reaction_id, super::super::kind::MESSAGE, 7_000, None);
871        let (fw, _) = chat::seal_chat_rumor(&forged, &group, &outsider, Timestamp::from_secs(7), false).unwrap();
872        let fev = chat::open_chat_event(&fw, &group, &general, epoch).unwrap();
873        assert!(persist_chat_event(&fev, &cid, &me.public_key()).await.is_none(), "only the reactor revokes their reaction");
874
875        // The REACTOR's delete removes it: STATE chip gone, kind-7 row gone
876        // (a lingering row would resurrect the chip on the next load), parent intact.
877        let revoke = chat::build_delete_rumor(member.public_key(), &general, epoch, &reaction_id, super::super::kind::MESSAGE, 8_000, None);
878        let (vw, _) = chat::seal_chat_rumor(&revoke, &group, &member, Timestamp::from_secs(8), false).unwrap();
879        let vev = chat::open_chat_event(&vw, &group, &general, epoch).unwrap();
880        assert!(matches!(persist_chat_event(&vev, &cid, &me.public_key()).await, Some(ChatPersist::ReactionRemoved { .. })));
881        let (has_reaction, parent_alive) = {
882            let st = crate::state::STATE.lock().await;
883            (
884                st.find_reaction(&reaction_id).is_some(),
885                st.find_message(&msg_id).is_some(),
886            )
887        };
888        assert!(!has_reaction, "the chip is gone from STATE");
889        assert!(parent_alive, "the parent message survives an un-react");
890        assert!(!crate::db::events::event_exists(&reaction_id).unwrap(), "the kind-7 row is deleted");
891    }
892
893    #[tokio::test]
894    async fn a_guestbook_join_fires_presence_with_its_real_rumor_id() {
895        use nostr_sdk::prelude::Timestamp;
896        use std::sync::Mutex as StdMutex;
897        let (_tmp, _guard, me) = init();
898        let relay = MemoryRelay::new();
899        let community = service::create_community(&relay, "Pres", vec!["wss://r".into()], None).await.unwrap();
900
901        #[derive(Default)]
902        struct Capture(StdMutex<Vec<(String, bool, String)>>);
903        impl InboundEventHandler for Capture {
904            fn on_community_presence(&self, _chat_id: &str, npub: &str, joined: bool, event_id: &str, _at: u64, _by: Option<&str>, _label: Option<&str>) {
905                self.0.lock().unwrap().push((npub.into(), joined, event_id.into()));
906            }
907        }
908
909        let member = Keys::generate();
910        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
911        let rumor = guestbook::build_join_rumor(member.public_key(), None, 5_000);
912        let (wrap, _) = super::super::guestbook::seal_guestbook_rumor(&rumor, &gb, &member, Timestamp::from_secs(5)).unwrap();
913        let expected_id = rumor.id.unwrap().to_hex();
914
915        let cap = Capture::default();
916        let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
917        assert!(matches!(out, DispatchedV2::Presence { joined: true, .. }));
918        let seen = cap.0.lock().unwrap().clone();
919        assert_eq!(seen.len(), 1);
920        assert_eq!(seen[0].0, member.public_key().to_bech32().unwrap());
921        // The REAL rumor id keys the line — an empty id would collapse every
922        // distinct join into one dedup slot (first wins, the rest vanish).
923        assert_eq!(seen[0].2, expected_id, "presence carries the join's own rumor id");
924    }
925
926    #[tokio::test]
927    async fn a_guestbook_kick_dispatches_its_target_and_raises_no_presence_line() {
928        // Routing regression: a Kick used to fall to `Ignored`, so realtime dropped it
929        // and the target never left anyone's memberlist (nor their own community).
930        use nostr_sdk::prelude::Timestamp;
931        use std::sync::Mutex as StdMutex;
932        let (_tmp, _guard, me) = init();
933        let relay = MemoryRelay::new();
934        let community = service::create_community(&relay, "Kicks", vec!["wss://r".into()], None).await.unwrap();
935
936        #[derive(Default)]
937        struct Capture(StdMutex<usize>);
938        impl InboundEventHandler for Capture {
939            fn on_community_presence(&self, _c: &str, _n: &str, _j: bool, _e: &str, _a: u64, _b: Option<&str>, _l: Option<&str>) {
940                *self.0.lock().unwrap() += 1;
941            }
942        }
943
944        let target = Keys::generate();
945        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
946        let rumor = guestbook::build_kick_rumor(me.public_key(), target.public_key(), None, 5_000);
947        let (wrap, _) = super::super::guestbook::seal_guestbook_rumor(&rumor, &gb, &me, Timestamp::from_secs(5)).unwrap();
948
949        let cap = Capture::default();
950        let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
951        match out {
952            DispatchedV2::Kick { target: t } => assert_eq!(t, target.public_key(), "the kick names its target"),
953            other => panic!("a Kick must reach the store, got {other:?}"),
954        }
955        // A kick shapes the memberlist, not the feed.
956        assert_eq!(*cap.0.lock().unwrap(), 0, "a kick raises no presence line");
957    }
958
959    #[tokio::test]
960    async fn a_webxdc_peer_ad_fires_the_shared_handler_and_own_echo_drops() {
961        use nostr_sdk::prelude::Timestamp;
962        use std::sync::Mutex as StdMutex;
963        let (_tmp, _guard, me) = init();
964        let relay = MemoryRelay::new();
965        let community = service::create_community(&relay, "XDC", vec!["wss://r".into()], None).await.unwrap();
966        let general = community.channels[0].id;
967
968        #[derive(Default)]
969        struct Capture(StdMutex<Vec<(String, String, Option<String>)>>);
970        impl InboundEventHandler for Capture {
971            fn on_community_webxdc(&self, _chat_id: &str, npub: &str, topic_id: &str, node_addr: Option<&str>, _event_id: &str, _created_at: u64) {
972                self.0.lock().unwrap().push((npub.into(), topic_id.into(), node_addr.map(String::from)));
973            }
974        }
975
976        let topic = "B".repeat(52);
977        let content = crate::webxdc::peer_signal_content(&topic, Some("iroh:node/xyz"));
978        let (secret, epoch) = community.channel_secret(&community.channels[0]);
979        let group = super::super::derive::channel_group_key(&secret, &general, epoch);
980
981        // A PEER's ad fires the shared v1 handler surface.
982        let peer = Keys::generate();
983        let rumor = chat::build_webxdc_rumor(peer.public_key(), &general, epoch, &content, vec![], 5_000);
984        let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &peer, Timestamp::from_secs(5), false).unwrap();
985        let cap = Capture::default();
986        let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
987        assert!(matches!(out, DispatchedV2::Ignored), "fired inline, nothing to persist v2-side");
988        let seen = cap.0.lock().unwrap().clone();
989        assert_eq!(seen.len(), 1);
990        assert_eq!(seen[0].0, peer.public_key().to_bech32().unwrap());
991        assert_eq!(seen[0].1, topic);
992        assert_eq!(seen[0].2.as_deref(), Some("iroh:node/xyz"));
993
994        // Our OWN echo never re-fires — the local realtime layer tracks itself.
995        let own = chat::build_webxdc_rumor(me.public_key(), &general, epoch, &content, vec![], 6_000);
996        let (own_wrap, _) = chat::seal_chat_rumor(&own, &group, &me, Timestamp::from_secs(6), false).unwrap();
997        let cap2 = Capture::default();
998        dispatch_wrap(&own_wrap, &community, &me.public_key(), &cap2);
999        assert!(cap2.0.lock().unwrap().is_empty(), "own-device echo drops");
1000    }
1001
1002    #[tokio::test]
1003    async fn a_v2_message_with_an_imeta_attachment_surfaces_as_an_attachment() {
1004        use nostr_sdk::prelude::Timestamp;
1005        let (_tmp, _guard, me) = init();
1006        let relay = MemoryRelay::new();
1007        let community = service::create_community(&relay, "Files", vec!["wss://r".into()], None).await.unwrap();
1008        let general = community.channels[0].id;
1009        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1010        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1011
1012        // Build a valid NIP-92 imeta tag via the same encoder the v2 file pipeline
1013        // uses, so the round-trip mirrors production exactly.
1014        let attachment = crate::types::Attachment {
1015            id: "a".repeat(64),
1016            key: "0".repeat(64),
1017            nonce: "1".repeat(32),
1018            extension: "png".into(),
1019            name: "photo.png".into(),
1020            url: "https://blossom.example/abc".into(),
1021            path: String::new(),
1022            size: 4096,
1023            img_meta: None,
1024            downloading: false,
1025            downloaded: false,
1026            webxdc_topic: None,
1027            group_id: None,
1028            original_hash: Some("b".repeat(64)),
1029            fallback_urls: Vec::new(),
1030        };
1031        let imeta = crate::community::attachments::attachment_to_imeta(&attachment);
1032        let member = Keys::generate();
1033        let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "here's a file", None, &[], vec![imeta], 5_000);
1034        let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
1035        let ev = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
1036
1037        let outcome = persist_chat_event(&ev, &cid, &me.public_key()).await;
1038        let Some(ChatPersist::New(msg)) = outcome else {
1039            panic!("the file message persists as new");
1040        };
1041        assert_eq!(msg.attachments.len(), 1, "the imeta tag parsed into one attachment");
1042        let att = &msg.attachments[0];
1043        assert!(att.url.contains("blossom.example"), "attachment url carried through: {}", att.url);
1044        assert_eq!(att.extension, "png", "extension carried through");
1045    }
1046
1047    #[tokio::test]
1048    async fn a_banned_members_every_chat_event_is_dropped_on_sight() {
1049        // CORD-04 §4: a banned npub vanishes — message, reaction, edit, delete,
1050        // typing, and presence alike. Severance only cuts their READ; this fold
1051        // gate is what makes the ban hold against old-epoch keys they still have.
1052        use nostr_sdk::prelude::Timestamp;
1053        let (_tmp, _guard, me) = init();
1054        let relay = MemoryRelay::new();
1055        let community = service::create_community(&relay, "BanGate", vec!["wss://r".into()], None).await.unwrap();
1056        let general = community.channels[0].id;
1057        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1058        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1059        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1060        let rogue = Keys::generate();
1061
1062        // Pre-ban: the rogue's message folds like anyone's.
1063        let m1 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "pre-ban", None, &[], vec![], 5_000);
1064        let m1_id = m1.id.unwrap().to_hex();
1065        let (w1, _) = chat::seal_chat_rumor(&m1, &group, &rogue, Timestamp::from_secs(5), false).unwrap();
1066        let ev = chat::open_chat_event(&w1, &group, &general, community.root_epoch).unwrap();
1067        assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
1068
1069        // The ban lands (the fold's persisted banlist).
1070        crate::db::community::set_community_banlist(&cid_hex, &[rogue.public_key().to_hex()], 1_000).unwrap();
1071
1072        // Post-ban: every kind they author drops.
1073        let m2 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "post-ban", None, &[], vec![], 6_000);
1074        let (w2, _) = chat::seal_chat_rumor(&m2, &group, &rogue, Timestamp::from_secs(6), false).unwrap();
1075        let ev = chat::open_chat_event(&w2, &group, &general, community.root_epoch).unwrap();
1076        assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a banned message is dropped");
1077
1078        let edit = chat::build_edit_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, "rewritten", &[], 7_000);
1079        let (we, _) = chat::seal_chat_rumor(&edit, &group, &rogue, Timestamp::from_secs(7), false).unwrap();
1080        let ev = chat::open_chat_event(&we, &group, &general, community.root_epoch).unwrap();
1081        assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a banned edit is dropped");
1082
1083        let del = chat::build_delete_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, super::super::kind::MESSAGE, 8_000, None);
1084        let (wd, _) = chat::seal_chat_rumor(&del, &group, &rogue, Timestamp::from_secs(8), false).unwrap();
1085        let ev = chat::open_chat_event(&wd, &group, &general, community.root_epoch).unwrap();
1086        assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a banned delete is dropped");
1087        assert!(
1088            crate::state::STATE.lock().await.find_message(&m1_id).is_some(),
1089            "their pre-ban message survives their own post-ban delete"
1090        );
1091
1092        // Typing + presence fire inline — the dispatcher's own gate covers them.
1093        let rec = Recorder::default();
1094        let typ = chat::build_typing_rumor(rogue.public_key(), &general, community.root_epoch, 9_000);
1095        let (wt, _) = chat::seal_chat_rumor(&typ, &group, &rogue, Timestamp::from_secs(9), true).unwrap();
1096        assert!(matches!(dispatch_wrap(&wt, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
1097        // A banned member's Join draws no presence line but still dispatches for
1098        // STORAGE: the fold subtracts the banlist reversibly, so an unban can
1099        // resurrect a Join that legally raced the ban window — a store-side drop
1100        // would eat it forever.
1101        let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1102        let join = guestbook::build_join_rumor(rogue.public_key(), None, 10_000);
1103        let (wj, _) = guestbook::seal_guestbook_rumor(&join, &gb, &rogue, Timestamp::from_secs(10)).unwrap();
1104        assert!(matches!(dispatch_wrap(&wj, &community, &me.public_key(), &rec), DispatchedV2::Presence { joined: true, .. }));
1105        assert!(rec.presence.lock().unwrap().is_empty(), "no presence callback for a banned join");
1106
1107        // An innocent author still folds normally.
1108        let innocent = Keys::generate();
1109        let m3 = chat::build_message_rumor(innocent.public_key(), &general, community.root_epoch, "innocent", None, &[], vec![], 11_000);
1110        let (w3, _) = chat::seal_chat_rumor(&m3, &group, &innocent, Timestamp::from_secs(11), false).unwrap();
1111        let ev = chat::open_chat_event(&w3, &group, &general, community.root_epoch).unwrap();
1112        assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
1113    }
1114
1115    #[tokio::test]
1116    async fn my_own_typing_from_another_client_never_paints_me_as_typing() {
1117        use nostr_sdk::prelude::Timestamp;
1118        let (_tmp, _guard, me) = init();
1119        let relay = MemoryRelay::new();
1120        let community = service::create_community(&relay, "Typing", vec!["wss://r".into()], None).await.unwrap();
1121        let general = community.channels[0].id;
1122        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1123        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1124        let rec = Recorder::default();
1125
1126        // A second client of OURS (same key, its own wrap id — so the send-side
1127        // wrap dedup can't catch it) signals typing.
1128        let mine = chat::build_typing_rumor(me.public_key(), &general, community.root_epoch, 5_000);
1129        let (wm, _) = chat::seal_chat_rumor(&mine, &group, &me, Timestamp::from_secs(5), true).unwrap();
1130        assert!(
1131            matches!(dispatch_wrap(&wm, &community, &me.public_key(), &rec), DispatchedV2::Ignored),
1132            "our own typing signal is not someone typing at us"
1133        );
1134        assert!(rec.typing.lock().unwrap().is_empty(), "no typing callback for our own signal");
1135
1136        // Someone else typing still lands.
1137        let peer = Keys::generate();
1138        let theirs = chat::build_typing_rumor(peer.public_key(), &general, community.root_epoch, 6_000);
1139        let (wt, _) = chat::seal_chat_rumor(&theirs, &group, &peer, Timestamp::from_secs(6), true).unwrap();
1140        assert!(matches!(dispatch_wrap(&wt, &community, &me.public_key(), &rec), DispatchedV2::Typing { .. }));
1141        let got = rec.typing.lock().unwrap();
1142        assert_eq!(got.len(), 1, "exactly the peer's signal fired");
1143        assert_eq!(got[0].0, cid);
1144        assert_eq!(got[0].1, peer.public_key().to_bech32().unwrap());
1145    }
1146
1147    #[tokio::test]
1148    async fn an_armada_threaded_reply_persists_and_fires_as_an_inline_reply() {
1149        use nostr_sdk::prelude::Timestamp;
1150        let (_tmp, _guard, me) = init();
1151        let relay = MemoryRelay::new();
1152        let community = service::create_community(&relay, "Thread", vec!["wss://r".into()], None).await.unwrap();
1153        let general = community.channels[0].id;
1154        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1155        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1156
1157        // A member posts a root message, then a kind-1111 threaded reply to it
1158        // (the shape Armada sends).
1159        let member = Keys::generate();
1160        let root = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "thread root", None, &[], vec![], 5_000);
1161        let root_id = root.id.unwrap().to_hex();
1162        let (rw, _) = chat::seal_chat_rumor(&root, &group, &member, Timestamp::from_secs(5), false).unwrap();
1163        let reply = chat::build_comment_rumor(
1164            member.public_key(),
1165            &general,
1166            community.root_epoch,
1167            "thread reply",
1168            &root_id,
1169            super::super::kind::MESSAGE,
1170            &member.public_key().to_hex(),
1171            None,
1172            &[],
1173            6_000,
1174        );
1175        let reply_id = reply.id.unwrap().to_hex();
1176        let (tw, _) = chat::seal_chat_rumor(&reply, &group, &member, Timestamp::from_secs(6), false).unwrap();
1177
1178        for w in [&rw, &tw] {
1179            let ev = chat::open_chat_event(w, &group, &general, community.root_epoch).unwrap();
1180            let outcome = persist_chat_event(&ev, &cid, &me.public_key()).await;
1181            assert!(matches!(outcome, Some(ChatPersist::New(_))), "both persist as new messages");
1182        }
1183        // The reply row carries its parent as inline reply context, resolved
1184        // from the persisted root (v1's reply-preview parity).
1185        let held = {
1186            let st = crate::state::STATE.lock().await;
1187            st.find_message(&reply_id).map(|(_, m)| m)
1188        }
1189        .expect("the threaded reply is resident");
1190        assert_eq!(held.replied_to, root_id, "the immediate parent is the reply context");
1191        assert_eq!(held.content, "thread reply");
1192
1193        // Its author deletes it — target kind 1111 (the delete e/k grammar).
1194        let del = chat::build_delete_rumor(member.public_key(), &general, community.root_epoch, &reply_id, super::super::kind::COMMENT, 7_000, None);
1195        let (dw, _) = chat::seal_chat_rumor(&del, &group, &member, Timestamp::from_secs(7), false).unwrap();
1196        let ev = chat::open_chat_event(&dw, &group, &general, community.root_epoch).unwrap();
1197        let outcome = persist_chat_event(&ev, &cid, &me.public_key()).await;
1198        assert!(matches!(outcome, Some(ChatPersist::Removed(id)) if id == reply_id), "the author's delete removes their thread reply");
1199    }
1200
1201    #[tokio::test]
1202    async fn an_edit_replay_never_refires() {
1203        use nostr_sdk::prelude::Timestamp;
1204        let (_tmp, _guard, me) = init();
1205        let relay = MemoryRelay::new();
1206        let community = service::create_community(&relay, "EditReplay", vec!["wss://r".into()], None).await.unwrap();
1207        let general = community.channels[0].id;
1208        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1209        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1210
1211        // Another member posts, then edits their own message.
1212        let member = Keys::generate();
1213        let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "v1 text", None, &[], vec![], 5_000);
1214        let msg_id = msg.id.unwrap().to_hex();
1215        let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
1216        let edit = chat::build_edit_rumor(member.public_key(), &general, community.root_epoch, &msg_id, "v2 text", &[], 6_000);
1217        let (ew, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(6), false).unwrap();
1218        for w in [&mw, &ew] {
1219            if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
1220                let _ = persist_chat_event(&ev, &cid, &me.public_key()).await;
1221            }
1222        }
1223
1224        // A RE-WRAP of the same signed EDIT (fresh outer id) must not re-fire: the
1225        // MESSAGE_EDIT row dedups it, exactly like the other three chat kinds.
1226        let (replay, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(7), false).unwrap();
1227        assert_ne!(replay.id, ew.id, "a re-wrap is a distinct outer event");
1228        let ev = chat::open_chat_event(&replay, &group, &general, community.root_epoch).unwrap();
1229        assert!(
1230            persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(),
1231            "a replayed edit yields no outcome (no handler re-fire)"
1232        );
1233    }
1234
1235    #[tokio::test]
1236    async fn a_forged_edit_from_a_non_author_is_ignored() {
1237        // Author-scoping on edits (the counterpart to the forged-delete guard): a
1238        // member (holds the channel key) forges an EDIT of someone else's message.
1239        // It must not rewrite the content.
1240        use nostr_sdk::prelude::Timestamp;
1241        let (_tmp, _guard, me) = init();
1242        let relay = MemoryRelay::new();
1243        let community = service::create_community(&relay, "EditGuard", vec!["wss://r".into()], None).await.unwrap();
1244        let general = community.channels[0].id;
1245        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1246        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1247
1248        // The real author posts a message.
1249        let author = Keys::generate();
1250        let msg = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, "original", None, &[], vec![], 5_000);
1251        let msg_id = msg.id.unwrap().to_hex();
1252        let (mw, _) = chat::seal_chat_rumor(&msg, &group, &author, Timestamp::from_secs(5), false).unwrap();
1253        let ev = chat::open_chat_event(&mw, &group, &general, community.root_epoch).unwrap();
1254        persist_chat_event(&ev, &cid, &me.public_key()).await;
1255
1256        // A stranger (member, holds the key) forges an edit of the author's message.
1257        let stranger = Keys::generate();
1258        let edit = chat::build_edit_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, "TAMPERED", &[], 6_000);
1259        let (ew, _) = chat::seal_chat_rumor(&edit, &group, &stranger, Timestamp::from_secs(6), false).unwrap();
1260        let ev = chat::open_chat_event(&ew, &group, &general, community.root_epoch).unwrap();
1261        assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a forged edit yields no outcome");
1262
1263        let content = {
1264            let st = crate::state::STATE.lock().await;
1265            st.find_message(&msg_id).map(|(_, m)| m.content)
1266        };
1267        assert_eq!(content.as_deref(), Some("original"), "the message content is unchanged by the forged edit");
1268    }
1269
1270    #[tokio::test]
1271    async fn a_reaction_cannot_be_injected_across_channels() {
1272        // A member holds BOTH channels' keys, so they can seal a valid reaction in
1273        // channel A whose target is a message resident in channel B. The
1274        // cross-channel guard (a reaction lands only on a same-channel target) must
1275        // drop it — else reactions could be injected onto messages in channels the
1276        // reaction was never sealed under.
1277        use nostr_sdk::prelude::Timestamp;
1278        let (_tmp, _guard, me) = init();
1279        let relay = MemoryRelay::new();
1280        let mut community = service::create_community(&relay, "TwoChan", vec!["wss://r".into()], None).await.unwrap();
1281        let chan_a = community.channels[0].id;
1282        let chan_b = service::create_public_channel(&relay, &community, "b").await.unwrap();
1283        community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
1284        let a_hex = crate::simd::hex::bytes_to_hex_32(&chan_a.0);
1285
1286        // A message lives in channel B.
1287        let author = Keys::generate();
1288        let gb = super::super::derive::channel_group_key(&community.community_root, &chan_b, community.root_epoch);
1289        let msg = chat::build_message_rumor(author.public_key(), &chan_b, community.root_epoch, "in B", None, &[], vec![], 5_000);
1290        let msg_id = msg.id.unwrap().to_hex();
1291        let (mw, _) = chat::seal_chat_rumor(&msg, &gb, &author, Timestamp::from_secs(5), false).unwrap();
1292        let bev = chat::open_chat_event(&mw, &gb, &chan_b, community.root_epoch).unwrap();
1293        persist_chat_event(&bev, &crate::simd::hex::bytes_to_hex_32(&chan_b.0), &me.public_key()).await;
1294
1295        // A reaction sealed in channel A, targeting the channel-B message.
1296        let ga = super::super::derive::channel_group_key(&community.community_root, &chan_a, community.root_epoch);
1297        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);
1298        let (rw, _) = chat::seal_chat_rumor(&reaction, &ga, &author, Timestamp::from_secs(6), false).unwrap();
1299        let aev = chat::open_chat_event(&rw, &ga, &chan_a, community.root_epoch).unwrap();
1300        // Applied under channel A (where it was sealed) — the target is in B.
1301        let outcome = persist_chat_event(&aev, &a_hex, &me.public_key()).await;
1302        assert!(outcome.is_none(), "a cross-channel reaction is dropped");
1303        let reacted = {
1304            let st = crate::state::STATE.lock().await;
1305            st.find_message(&msg_id).map(|(_, m)| !m.reactions.is_empty()).unwrap_or(false)
1306        };
1307        assert!(!reacted, "the channel-B message gained no reaction from the channel-A injection");
1308    }
1309
1310    #[tokio::test]
1311    async fn a_forged_delete_from_a_non_author_is_ignored() {
1312        use nostr_sdk::prelude::Timestamp;
1313        let (_tmp, _guard, me) = init();
1314        let relay = MemoryRelay::new();
1315        let community = service::create_community(&relay, "Forge", vec!["wss://r".into()], None).await.unwrap();
1316        let general = community.channels[0].id;
1317        let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1318        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1319
1320        // `me` posts a message and it's persisted into STATE.
1321        let msg_id = service::send_message(&relay, &community, &general, "mine").await.unwrap();
1322        let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
1323        let wraps = relay.fetch(&q, &community.relays).await.unwrap();
1324        for w in &wraps {
1325            if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
1326                let mut st = crate::state::STATE.lock().await;
1327                apply_chat_to_state(&mut st, &ev, &cid, &me.public_key());
1328            }
1329        }
1330
1331        // A STRANGER (a member, so holds the channel key) forges a delete of `me`'s message.
1332        let stranger = nostr_sdk::prelude::Keys::generate();
1333        let del = chat::build_delete_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, super::super::kind::MESSAGE, 9_000, None);
1334        let (wrap, _) = chat::seal_chat_rumor(&del, &group, &stranger, Timestamp::from_secs(9), false).unwrap();
1335        let event = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
1336
1337        let outcome = {
1338            let mut st = crate::state::STATE.lock().await;
1339            apply_chat_to_state(&mut st, &event, &cid, &me.public_key())
1340        };
1341        assert!(outcome.is_none(), "a forged delete from a non-author yields no removal");
1342        let survives = {
1343            let st = crate::state::STATE.lock().await;
1344            st.find_message(&msg_id).is_some()
1345        };
1346        assert!(survives, "the message survives the forged delete (live view + DB stay consistent)");
1347    }
1348
1349    /// Owner + a granted admin + two posted messages, with the roster and the
1350    /// admin's Grant head persisted the way `follow_control` leaves them. Returns
1351    /// the pieces a moderation-delete test needs to seal one and judge it.
1352    struct ModerationBed {
1353        community: super::super::community::CommunityV2,
1354        general: crate::community::ChannelId,
1355        chat_id: String,
1356        group: super::super::derive::GroupKey,
1357        admin: Keys,
1358        admin_citation: crate::community::edition::AuthorityCitation,
1359    }
1360
1361    async fn moderation_bed(relay: &MemoryRelay, name: &str) -> ModerationBed {
1362        let community = service::create_community(relay, name, vec!["wss://r".into()], None).await.unwrap();
1363        let general = community.channels[0].id;
1364        let chat_id = crate::simd::hex::bytes_to_hex_32(&general.0);
1365        let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1366        let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1367
1368        let admin = Keys::generate();
1369        service::grant_admin(relay, &community, &admin.public_key()).await.unwrap();
1370
1371        // What `follow_control` persists after folding: the authorized roster, so
1372        // the receive path resolves ranks without a fetch.
1373        let view = service::fetch_authority(relay, &community).await;
1374        crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
1375
1376        // The admin's `vac`: the Grant edition the owner just published for them.
1377        let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
1378        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1379        let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex)
1380            .unwrap()
1381            .expect("the owner's own grant publish stores its head");
1382        let admin_citation = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
1383
1384        ModerationBed { community, general, chat_id, group, admin, admin_citation }
1385    }
1386
1387    /// Seal `author`'s message onto the chat plane and apply it, returning its rumor id.
1388    async fn post_as(bed: &ModerationBed, author: &Keys, body: &str, at: u64, me: &PublicKey) -> String {
1389        use nostr_sdk::prelude::Timestamp;
1390        let rumor = chat::build_message_rumor(author.public_key(), &bed.general, bed.community.root_epoch, body, None, &[], vec![], at);
1391        let (wrap, _) = chat::seal_chat_rumor(&rumor, &bed.group, author, Timestamp::from_secs(at / 1000), false).unwrap();
1392        let event = chat::open_chat_event(&wrap, &bed.group, &bed.general, bed.community.root_epoch).unwrap();
1393        let id = rumor.id.unwrap().to_hex();
1394        let mut st = crate::state::STATE.lock().await;
1395        apply_chat_to_state(&mut st, &event, &bed.chat_id, me);
1396        id
1397    }
1398
1399    /// Seal `actor`'s kind-5 against `target` and run it through the receive path.
1400    async fn delete_as(
1401        bed: &ModerationBed,
1402        actor: &Keys,
1403        target: &str,
1404        citation: Option<&crate::community::edition::AuthorityCitation>,
1405        at: u64,
1406        me: &PublicKey,
1407    ) -> Option<ChatPersist> {
1408        use nostr_sdk::prelude::Timestamp;
1409        let del = chat::build_delete_rumor(actor.public_key(), &bed.general, bed.community.root_epoch, target, super::super::kind::MESSAGE, at, citation);
1410        let (wrap, _) = chat::seal_chat_rumor(&del, &bed.group, actor, Timestamp::from_secs(at / 1000), false).unwrap();
1411        let event = chat::open_chat_event(&wrap, &bed.group, &bed.general, bed.community.root_epoch).unwrap();
1412        let mut st = crate::state::STATE.lock().await;
1413        apply_chat_to_state(&mut st, &event, &bed.chat_id, me)
1414    }
1415
1416    #[tokio::test]
1417    async fn an_admins_moderation_delete_removes_a_members_message() {
1418        let (_tmp, _guard, me) = init();
1419        let relay = MemoryRelay::new();
1420        let bed = moderation_bed(&relay, "Mod").await;
1421        let member = Keys::generate();
1422
1423        let victim = post_as(&bed, &member, "spam", 1_000, &me.public_key()).await;
1424        let outcome = delete_as(&bed, &bed.admin, &victim, Some(&bed.admin_citation), 2_000, &me.public_key()).await;
1425
1426        assert!(matches!(outcome, Some(ChatPersist::Removed(ref id)) if *id == victim), "MANAGE_MESSAGES + outrank removes it");
1427        assert!(crate::state::STATE.lock().await.find_message(&victim).is_none());
1428    }
1429
1430    #[tokio::test]
1431    async fn an_admin_cannot_moderation_delete_the_owners_message() {
1432        // The owner is position 0, supreme and never a valid target. This holds
1433        // only while the owner RESOLVES — an owner who needs no Role ranks last
1434        // without one.
1435        let (_tmp, _guard, me) = init();
1436        let relay = MemoryRelay::new();
1437        let bed = moderation_bed(&relay, "Sacred").await;
1438
1439        let owners_message = post_as(&bed, &me, "the owner speaks", 1_000, &me.public_key()).await;
1440        let outcome = delete_as(&bed, &bed.admin, &owners_message, Some(&bed.admin_citation), 2_000, &me.public_key()).await;
1441
1442        assert!(outcome.is_none(), "an admin never outranks the owner");
1443        assert!(crate::state::STATE.lock().await.find_message(&owners_message).is_some());
1444    }
1445
1446    #[tokio::test]
1447    async fn a_moderation_delete_without_a_synced_citation_is_refused() {
1448        // CORD-04 §5: a non-owner must name the Grant it acts under, and we must
1449        // hold it. Uncited, the actor's rank is unprovable — fail closed.
1450        let (_tmp, _guard, me) = init();
1451        let relay = MemoryRelay::new();
1452        let bed = moderation_bed(&relay, "Uncited").await;
1453        let member = Keys::generate();
1454
1455        let victim = post_as(&bed, &member, "spam", 1_000, &me.public_key()).await;
1456        assert!(delete_as(&bed, &bed.admin, &victim, None, 2_000, &me.public_key()).await.is_none());
1457        assert!(crate::state::STATE.lock().await.find_message(&victim).is_some());
1458
1459        // The same delete WITH its citation lands — proving the refusal above was
1460        // the citation and not the rank.
1461        assert!(delete_as(&bed, &bed.admin, &victim, Some(&bed.admin_citation), 3_000, &me.public_key()).await.is_some());
1462    }
1463
1464    #[tokio::test]
1465    async fn a_roleless_member_cannot_moderation_delete_anyone() {
1466        let (_tmp, _guard, me) = init();
1467        let relay = MemoryRelay::new();
1468        let bed = moderation_bed(&relay, "Roleless").await;
1469        let (member, rando) = (Keys::generate(), Keys::generate());
1470
1471        let victim = post_as(&bed, &member, "hello", 1_000, &me.public_key()).await;
1472        // A citation is no substitute for a grant: cite the ADMIN's entity while
1473        // holding nothing, and the roster check still refuses.
1474        let outcome = delete_as(&bed, &rando, &victim, Some(&bed.admin_citation), 2_000, &me.public_key()).await;
1475
1476        assert!(outcome.is_none(), "no MANAGE_MESSAGES, no removal");
1477        assert!(crate::state::STATE.lock().await.find_message(&victim).is_some());
1478    }
1479
1480    #[tokio::test]
1481    async fn a_member_still_deletes_their_own_message_uncited() {
1482        // The self-delete path must not acquire a citation requirement.
1483        let (_tmp, _guard, me) = init();
1484        let relay = MemoryRelay::new();
1485        let bed = moderation_bed(&relay, "Self").await;
1486        let member = Keys::generate();
1487
1488        let mine = post_as(&bed, &member, "oops", 1_000, &me.public_key()).await;
1489        let outcome = delete_as(&bed, &member, &mine, None, 2_000, &me.public_key()).await;
1490
1491        assert!(matches!(outcome, Some(ChatPersist::Removed(ref id)) if *id == mine));
1492    }
1493
1494    #[tokio::test]
1495    async fn a_foreign_wrap_is_not_ours() {
1496        let (_tmp, _guard, me) = init();
1497        let relay = MemoryRelay::new();
1498        let community = service::create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
1499
1500        // A wrap from an unrelated stream key (e.g. a DM giftwrap, or another
1501        // community) must not match any plane.
1502        let stranger = super::super::derive::channel_group_key(&[0x99u8; 32], &community.channels[0].id, community.root_epoch);
1503        let rumor = chat::build_message_rumor(me.public_key(), &community.channels[0].id, community.root_epoch, "not yours", None, &[], vec![], 1_000);
1504        let (wrap, _) = chat::seal_chat_rumor(&rumor, &stranger, &me, nostr_sdk::prelude::Timestamp::from_secs(1), false).unwrap();
1505
1506        let rec = Recorder::default();
1507        assert!(matches!(dispatch_wrap(&wrap, &community, &me.public_key(), &rec), DispatchedV2::NotOurs));
1508        assert!(rec.messages.lock().unwrap().is_empty());
1509    }
1510}