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