Skip to main content

vector_core/community/
inbound.rs

1//! Inbound processing: turn a verified, opened Community message into a `Message`
2//! in `STATE` under its channel chat (→ app state). Pure conversion
3//! (`build_message`) is separated from the STATE mutation (`ingest_message`) so the
4//! conversion is unit-testable without any global state.
5
6use nostr_sdk::prelude::{Event, PublicKey};
7use nostr_sdk::prelude::ToBech32;
8
9use super::envelope::{open_message_multi, OpenedMessage};
10use super::Channel;
11use crate::state::ChatState;
12use crate::stored_event::event_kind;
13use crate::types::Message;
14
15/// Convert a verified [`OpenedMessage`] into a STATE `Message` via the SHARED content parser.
16///
17/// A Concord 3300 normalizes to a text rumor and runs through `rumor::process_rumor` — the exact same
18/// path a NIP-17 DM text message takes — so content, reply ref, emoji, ms (incl. the future-clamp),
19/// and author-by-conversation-type are parsed in ONE place for every transport. The only Concord-
20/// specific layering is attachments: Concord carries NIP-92 `imeta` (multi-file + caption), already
21/// parsed in `open_message`, so they're set on top of the shared text result.
22/// Build a normalized `(RumorEvent, RumorContext)` from an opened Concord inner so it can run through
23/// the SHARED `rumor::process_rumor`. `kind` is the canonical content kind the sub-kind maps to
24/// (3300→14, 3301→reaction, 3302→edit, 3305→deletion). The binding/banlist/authority checks already
25/// happened at the transport layer; this is purely the bridge to the shared content parser.
26fn concord_rumor(
27    opened: &OpenedMessage,
28    kind: nostr_sdk::prelude::Kind,
29    my_pubkey: &PublicKey,
30) -> (crate::rumor::RumorEvent, crate::rumor::RumorContext) {
31    use crate::rumor::{ConversationType, RumorContext, RumorEvent};
32    (
33        RumorEvent {
34            id: opened.message_id,
35            kind,
36            content: opened.content.clone(),
37            tags: opened.tags.clone(),
38            created_at: opened.created_at,
39            pubkey: opened.author,
40        },
41        RumorContext {
42            sender: opened.author,
43            is_mine: opened.author == *my_pubkey,
44            conversation_id: opened.channel_id.to_hex(),
45            conversation_type: ConversationType::Community,
46        },
47    )
48}
49
50pub fn build_message(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Message {
51    use crate::rumor::{process_rumor, RumorProcessingResult};
52    let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::PrivateDirectMessage, my_pubkey);
53    let mut msg = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
54        Ok(RumorProcessingResult::TextMessage(m)) => m,
55        // A 3300 is always a caption/text message, so this never fires — but never drop a message on
56        // a parser quirk: fall back to the minimal direct fields.
57        _ => Message {
58            id: opened.message_id.to_hex(),
59            content: opened.content.clone(),
60            at: opened.ms.unwrap_or_else(|| opened.created_at.as_secs().saturating_mul(1000)),
61            mine: opened.author == *my_pubkey,
62            npub: opened.author.to_bech32().ok(),
63            ..Default::default()
64        },
65    };
66    // Transport-specific: Concord attachments are NIP-92 imeta (already parsed). Link the outer wire
67    // id for the shared dedup. The shared parser already set content/reply/emoji/ms/npub.
68    msg.attachments = opened.attachments.clone();
69    // Drop any blob URL a foreign client (e.g. Armada) also inlined into the caption.
70    msg.content = super::attachments::strip_attachment_urls(&msg.content, &msg.attachments);
71    msg.wrapper_event_id = Some(opened.wrapper_id.to_hex());
72    msg
73}
74
75/// Ingest a verified Community message into STATE under its channel chat, creating
76/// the chat (as `ChatType::Community`) if absent. Returns the added `Message` (so the
77/// caller can persist + emit it), or `None` if it was a duplicate (dedup on the inner
78/// message id).
79pub fn ingest_message(
80    state: &mut ChatState,
81    opened: &OpenedMessage,
82    my_pubkey: &PublicKey,
83) -> Option<Message> {
84    let chat_id = opened.channel_id.to_hex();
85    let msg = build_message(opened, my_pubkey);
86    // DB-level dedup: an inner id already in the events table is KNOWN — don't re-ingest into
87    // STATE or re-emit it. A boot/catch-up sweep re-fetches the whole channel page, but in-memory
88    // STATE only holds the per-chat hydration window, so it can't dedup the tail on its own. Without
89    // this, replayed sends (incl. our own) resurface as "new", re-firing reads/notifications. Mirrors
90    // the DM pipeline: outer dedup (wrapper-id cache) + inner dedup (events table). Known events live
91    // in the DB and load from there.
92    if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
93        return None;
94    }
95    state.ensure_community_chat(&chat_id);
96    if state.add_message_to_chat(&chat_id, &msg) {
97        Some(msg)
98    } else {
99        None
100    }
101}
102
103/// The result of processing an inbound wire event: a brand-new message (3300), an update to
104/// an existing message (a reaction 3301 / edit 3302 applied to its target), or a tombstone
105/// (a delete 3305 that removed its target). New/Updated surface as a UI `message_new` /
106/// `message_update`; Removed surfaces as a `message_removed`.
107pub enum IncomingEvent {
108    NewMessage(Message),
109    /// An existing message changed (reaction or edit). `message` is the live-updated view for the
110    /// UI. `edit_event` is `Some` only for edits: the `MESSAGE_EDIT` StoredEvent the caller persists
111    /// (event-sourced, folded on reload like DM edits) instead of overwriting the row. Reactions
112    /// leave it `None` and the caller re-saves the message row (which carries the new reaction).
113    Updated { target_id: String, message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
114    Removed { target_id: String },
115    /// A reaction was revoked by its author (a 3305 tombstone whose target is a reaction id).
116    /// The caller drops the reaction's kind-7 row and re-emits `message` so chips refresh live.
117    /// Distinct from `Removed` (whole message) and `Updated` (re-saves the row, which is additive
118    /// and so can't express a removal). `message_id` is the parent for the UI update.
119    ReactionRemoved { message_id: String, reaction_id: String, message: Message },
120    /// A join/leave presence announcement (kind 3306). `npub` is the announcing member; the
121    /// caller persists + surfaces it as a `MemberJoined`/`MemberLeft` system event. `event_id`
122    /// is the inner id (dedup key). `created_at` is the inner's authenticated timestamp (secs) so a
123    /// HISTORICALLY-synced join/leave lands at the right place in the timeline, not at ingest-time
124    /// "now". `invited_by`/`invited_label` carry attribution on an invite-join (who/which-link
125    /// brought them) — `None` for a plain join/leave. Not a message.
126    Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option<String>, invited_label: Option<String> },
127    /// A cooperative kick (3309) targeting THE LOCAL USER, authorized (signer held `KICK` + outranked
128    /// us). The caller performs the self-removal teardown (wipe local chat data, RETAIN the held
129    /// epoch keys). A kick of ANOTHER member surfaces as `Presence { joined: false }`
130    /// instead, so it falls out of the observed member list without a dedicated arm.
131    Kicked { community_id: String },
132    /// A voluntary leave-presence (3306, content "leave") whose inner author IS the local npub — i.e. a
133    /// leave I (or another of my devices) published. route to the same self-removal teardown as
134    /// `Kicked`/ban so a leave on device A tears the community down on device B too. Safe because the
135    /// presence inner is real-npub-signed (only my own devices can author a leave for my npub). The
136    /// teardown is idempotent, so the publishing device tearing down on its own echoed leave is a no-op.
137    SelfLeft { community_id: String },
138    /// A WebXDC realtime peer signal (3310): a member advertising their Iroh node for a Mini App
139    /// session (`node_addr` = Some) or announcing they stopped playing (`node_addr` = None). The
140    /// caller persists it (kind-30078 row keyed by `topic_id`, the DM-parity shape) and — when a
141    /// realtime channel for the topic is live — feeds the peer to the gossip layer. Not a message.
142    WebxdcPeer {
143        npub: String,
144        topic_id: String,
145        /// Base32 iroh node address — `Some` for an advertisement, `None` for peer-left.
146        node_addr: Option<String>,
147        event_id: String,
148        created_at: u64,
149    },
150    /// A typing indicator (3311): a member is composing in this channel. Ephemeral — never persisted
151    /// or folded; the caller feeds it to the live typing tracker and emits `typing-update`. `until` is
152    /// the unix-secs the typer should stop being shown as active (receiver-computed, ~30s out).
153    Typing { npub: String, until: u64 },
154}
155
156/// Open a single incoming wire event against `channel`, verify the binding, and apply
157/// it to STATE by sub-kind: a message is ingested, a reaction/edit is applied to its
158/// target. Events that fail to open (wrong key, splice, forged sig, bad version) or that
159/// dedup/target-miss are dropped (returns `None`). This is the per-event handler the
160/// real-time subscription routes each arriving 3300/3301/3302 event through.
161pub fn process_incoming(
162    state: &mut ChatState,
163    event: &Event,
164    channel: &Channel,
165    my_pubkey: &PublicKey,
166) -> Option<IncomingEvent> {
167    // Outer-event dedup, shared with DMs via the cross-transport ledger: a wire event we've already
168    // processed is either recorded as some inner's `wrapper_event_id` (row-creating sub-kinds) or in
169    // the `processed_wrappers` ledger (non-row sub-kinds). Skip it BEFORE decryption — the same role
170    // the wrapper-id cache plays for gift-wraps. The per-inner-id check in ingest_message is the
171    // backstop for the same inner re-published under a fresh wire event.
172    let outer_bytes = event.id.to_bytes();
173    if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
174        || crate::db::wrappers::processed_wrapper_exists(&outer_bytes)
175    {
176        return None;
177    }
178    // binary seal: a dissolved community DROPS every subsequent event — control or message, any author
179    // (owner included), any claimed time. NO timestamp comparison: the seal is the flag, not a time.
180    // Already-persisted events stay (no retroactive purge); this only stops NEW events from landing.
181    // CARVE-OUT: own-message DELETIONS (3305) always pass — data ownership means anyone can scrub their own
182    // content from a dead community, and a delete only removes the author's OWN message (it can't inject, so
183    // it doesn't reopen the backdating attack the seal exists to stop). `apply_delete` restricts a dissolved
184    // community's deletes to SELF-deletes (moderation-hides are blocked).
185    if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE {
186        return None;
187    }
188    // Select the decryption key by the event's epoch pseudonym across ALL held epochs (post-rekey
189    // catch-up), so a message posted under an older epoch still opens. Falls back to the head epoch for
190    // send-built/test channels (read_epoch_keys).
191    let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) {
192        Ok(o) => o,
193        Err(e) => {
194            crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e);
195            return None;
196        }
197    };
198    // Banlist (the "anti-memberlist"): drop EVERY event kind from a banned author — message,
199    // reaction, edit, delete, presence — so a banned member vanishes entirely, presence and all.
200    if channel.banned.contains(&opened.author) {
201        crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex());
202        return None;
203    }
204    let outcome = match opened.kind {
205        k if k == event_kind::COMMUNITY_MESSAGE => {
206            ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage)
207        }
208        k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey),
209        k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey),
210        k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey),
211        k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey),
212        k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey),
213        k if k == event_kind::COMMUNITY_WEBXDC => apply_webxdc(&opened, my_pubkey),
214        k if k == event_kind::COMMUNITY_TYPING => apply_typing(&opened, my_pubkey),
215        _ => None,
216    };
217    // Record the outer id in the shared ledger for NON-message sub-kinds, which have no inner row to
218    // carry a `wrapper_event_id` (messages are covered atomically by that column on save). These are
219    // idempotent on replay, so recording at process time is safe. Gives every sub-kind the same
220    // pre-decryption skip on a re-fetch that messages already get. Typing is exempt — it's a frequent,
221    // realtime-only ephemeral signal; recording every keystroke ping would bloat the ledger for no gain.
222    if let Some(ref evt) = outcome {
223        if !matches!(evt, IncomingEvent::NewMessage(_) | IncomingEvent::Typing { .. }) {
224            let _ = crate::db::wrappers::save_processed_wrapper(
225                &outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD,
226            );
227        }
228    }
229    outcome
230}
231
232/// Interpret a presence announcement (3306). The inner author is the member; content "leave"
233/// marks a departure, anything else (e.g. "join") an arrival. No STATE mutation here — the
234/// caller turns this into a persisted system event (which is where dedup-by-id happens).
235fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
236    // Content is "leave", plain "join", or an attributed-join JSON `{"by":"<npub>","l":"<label>"}`.
237    let joined = opened.content != "leave";
238    // voluntary-leave self-teardown: a leave whose inner author is MY npub means I (or another of my
239    // devices) left → route to the same self-removal teardown as a kick/ban, so the leave propagates to
240    // every device that syncs it. Safe because the inner is real-npub-signed (only my devices author it).
241    if !joined && opened.author == *my_pubkey {
242        if let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) {
243            // Only a leave NEWER than the current join is a real teardown. A leave OLDER than the join is
244            // a historical leave from a PRIOR membership cycle (re-accepting an invite writes a fresh,
245            // later join time) — it must NOT tear down the re-join (mirrors apply_kick's staleness gate),
246            // but it SHOULD still surface as a "left" system event so my own join/leave history matches
247            // what other members see. So: fresh leave → SelfLeft (teardown); stale leave → fall through to
248            // the normal Presence emission below (a MemberLeft history line). saturating_mul: the inner
249            // created_at isn't relay-clamped.
250            let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
251            let join_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0);
252            if opened.created_at.as_secs().saturating_mul(1000) > join_ms {
253                return Some(IncomingEvent::SelfLeft { community_id: cid });
254            }
255            crate::log_debug!("[community] self-leave predates this join — rendering as history, not teardown");
256        }
257    }
258    let (invited_by, invited_label) = if joined {
259        serde_json::from_str::<serde_json::Value>(&opened.content)
260            .ok()
261            .map(|v| {
262                // `by` is attacker-controlled (the joiner builds their own presence), so VALIDATE it is a
263                // real pubkey before surfacing — else a hostile join could inject arbitrary text into the
264                // member list as a fake "inviter". Drop a non-pubkey. Bound the free-text label too.
265                let by = v.get("by").and_then(|b| b.as_str())
266                    .filter(|s| PublicKey::parse(s).is_ok())
267                    .map(str::to_string);
268                let label = v.get("l").and_then(|l| l.as_str())
269                    .map(|s| s.chars().take(48).collect::<String>())
270                    .filter(|s| !s.is_empty());
271                (by, label)
272            })
273            .unwrap_or((None, None))
274    } else {
275        (None, None)
276    };
277    Some(IncomingEvent::Presence {
278        npub: opened.author.to_bech32().ok()?,
279        joined,
280        event_id: opened.message_id.to_hex(),
281        created_at: clamp_inner_secs(opened.created_at.as_secs()),
282        invited_by,
283        invited_label,
284    })
285}
286
287/// Clamp an author-controlled inner timestamp before it becomes a persisted
288/// sort key — a forged far-future stamp would otherwise pin the event at the
289/// timeline edge forever (mirrors the webxdc handlers' clamp).
290fn clamp_inner_secs(secs: u64) -> u64 {
291    let now = std::time::SystemTime::now()
292        .duration_since(std::time::UNIX_EPOCH)
293        .map(|d| d.as_secs())
294        .unwrap_or(0);
295    secs.min(now + 300)
296}
297
298/// Interpret a WebXDC peer signal (3310). JSON content: `{"op":"ad","topic":...,"addr":...}` for an
299/// advertisement, `{"op":"left","topic":...}` for a departure. The inner author is the player (real-npub
300/// signed, so a member can't forge another's presence). Own-device echoes are dropped — the local
301/// realtime layer already tracks itself. Both fields are author-controlled: the topic must be a
302/// 52-char base32 TopicId and the addr is size-bounded (the realtime layer's decode is the final word).
303fn apply_webxdc(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
304    if opened.author == *my_pubkey {
305        return None;
306    }
307    let (topic_id, node_addr) = crate::webxdc::parse_peer_signal(&opened.content)?;
308    Some(IncomingEvent::WebxdcPeer {
309        npub: opened.author.to_bech32().ok()?,
310        topic_id,
311        node_addr,
312        event_id: opened.message_id.to_hex(),
313        created_at: opened.created_at.as_secs(),
314    })
315}
316
317/// Interpret a typing indicator (3311). Content is "typing"; the inner author is the typer (real-npub
318/// signed). Own-device echoes are dropped. `until` is computed receiver-side (now + 30s) rather than
319/// trusting the sender's clock — typing is realtime, so a fixed local window is both simpler and immune
320/// to a forged far-future timestamp pinning a phantom typer.
321fn apply_typing(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
322    if opened.author == *my_pubkey {
323        return None;
324    }
325    if opened.content != "typing" {
326        return None;
327    }
328    let now = std::time::SystemTime::now()
329        .duration_since(std::time::UNIX_EPOCH)
330        .map(|d| d.as_secs())
331        .unwrap_or(0);
332    // A typing signal is live for 30s from ITS OWN send time, never the wall
333    // clock — relay-to-relay sync replays stored months-old signals, and a
334    // `now + 30` here would paint a fresh bubble for every one of them.
335    // (v2 typing rides an ephemeral wrap kind; v1's 3311 is stored plane data.)
336    let until = opened.created_at.as_secs() + 30;
337    if until <= now || opened.created_at.as_secs() > now + 30 {
338        return None;
339    }
340    Some(IncomingEvent::Typing {
341        npub: opened.author.to_bech32().ok()?,
342        until,
343    })
344}
345
346/// Apply an inbound cooperative kick (3309). The kicker's REAL npub is the inner author; `content` is the
347/// target member's hex pubkey. Honored only when the kicker (a) cites a grant we've SYNCED
348/// (`actor_authority_pinned`) AND (b) holds `KICK` + strictly outranks the target in the
349/// floor-protected roster (`can_act_on_member`; the owner is never a valid target there, so owner-
350/// protection falls out with no hardcoded carve-out, and a self-kick is refused since you don't strictly
351/// outrank yourself). NOT a
352/// rekey and NOT persisted: a kick of THE LOCAL USER yields `Kicked` (the caller tears down locally); a
353/// kick of another member reuses `Presence { joined: false }` so they drop out of the observed member
354/// list. An unauthorized or uncited kick is dropped. Per, only a kick NEWER than this account's join
355/// is obeyed, so re-accepting an invite cleanly overrides a stale kick replayed from channel history.
356fn apply_kick(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
357    use crate::community::roles::Permissions;
358    let target = PublicKey::parse(opened.content.trim()).ok()?;
359    let target_hex = target.to_hex();
360    let kicker_hex = opened.author.to_hex();
361    let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
362    let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &kicker_hex, opened.citation.as_ref());
363    if !(pinned && channel.roster.can_act_on_member(&kicker_hex, owner_hex.as_deref(), &target_hex, Permissions::KICK)) {
364        crate::log_debug!("[community] dropped kick: {kicker_hex} not authorized to kick {target_hex}");
365        return None;
366    }
367    let cid_hex = crate::db::community::community_id_for_channel(&channel.id.to_hex()).ok().flatten()?;
368    // "obey the latest kick newer than my current join": ignore any kick older than this account's
369    // join (the community row's first-save time, in ms). The inner's non-randomized `created_at` is
370    // seconds, so scale to ms. A kick tears the row down, so re-accepting an invite writes a fresh, later
371    // join time → a stale kick from before the re-join is cleanly overridden, no kicklist to maintain.
372    let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
373    let join_ms = crate::db::community::community_created_at_ms(&cid).unwrap_or(0);
374    // saturating: the inner `created_at` is NOT relay-clamped (it rides inside the ciphertext), so a
375    // hostile (already-authorized) kicker could set it near u64::MAX and overflow the ms scale.
376    if opened.created_at.as_secs().saturating_mul(1000) <= join_ms {
377        crate::log_debug!("[community] dropped stale kick of {target_hex} (predates this join)");
378        return None;
379    }
380    if target == *my_pubkey {
381        return Some(IncomingEvent::Kicked { community_id: cid_hex });
382    }
383    // Kicked someone else → reflect it in the observed member list as a leave (no dedicated arm).
384    Some(IncomingEvent::Presence {
385        npub: target.to_bech32().ok()?,
386        joined: false,
387        event_id: opened.message_id.to_hex(),
388        created_at: clamp_inner_secs(opened.created_at.as_secs()),
389        invited_by: None,
390        invited_label: None,
391    })
392}
393
394/// Does this wire event authenticate against the channel's keys — now (open +
395/// MAC) or on a prior sight (dedup ledgers)? The outer `created_at` is
396/// otherwise unauthenticated relay input: sync cursors must only ever advance
397/// over events that pass this, or one junk event stamped far-future/past at
398/// the channel's cleartext pseudonym wedges the session's fetch floor/ceiling.
399/// Ledger hits skip decryption, so steady-state re-syncs stay cheap.
400///
401/// CAVEAT: the ledger half is keyed by event id, NOT channel-scoped — a relay
402/// replaying channel A's real events into channel B's page authenticates here.
403/// Cursor skew from that is bounded by genuinely-authored (signature-pinned)
404/// times, roughly equivalent to the relay's existing withholding power; do not
405/// repurpose this as a channel-membership check.
406pub fn event_authenticates(event: &Event, channel: &Channel) -> bool {
407    if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
408        || crate::db::wrappers::processed_wrapper_exists(&event.id.to_bytes())
409    {
410        return true;
411    }
412    open_message_multi(event, &channel.id, &channel.read_epoch_keys()).is_ok()
413}
414
415/// Process a fetched batch of raw channel events (backfill / cold-start) in a SAFE order:
416/// messages (3300) first so a reaction/edit (3301/3302) finds its target already in STATE
417/// before its reference applies — relay return order is arbitrary, and a control event whose
418/// target hasn't been ingested yet is silently dropped. Each event goes through
419/// [`process_incoming`] (open + verify + dedup), so undecryptable/forged/duplicate events
420/// yield nothing. Returns the applied events in processing order for the caller to persist + emit.
421///
422/// KNOWN LIMITATION (cross-page): the two-pass ordering only covers targets WITHIN this batch.
423/// A reaction/edit on one page whose target message lives on an older, not-yet-fetched page is
424/// dropped and not re-applied when that page later arrives (the older page won't re-contain the
425/// reaction). Acceptable while history is shallow; when deep scroll-backfill matures this wants a
426/// pending-control buffer keyed by target id (re-drained on target ingest), mirroring the DM
427/// `PENDING_EVENTS` path.
428pub fn process_channel_batch(
429    state: &mut ChatState,
430    events: &[Event],
431    channel: &Channel,
432    my_pubkey: &PublicKey,
433) -> Vec<IncomingEvent> {
434    let mut out = Vec::new();
435    // Pass 1: messages; Pass 2: control events (reactions/edits). The outer kind mirrors the
436    // inner kind (seal enforces it), so it's a reliable partition key without decrypting.
437    for want_message in [true, false] {
438        for ev in events {
439            let is_message = ev.kind.as_u16() == event_kind::COMMUNITY_MESSAGE;
440            if is_message != want_message {
441                continue;
442            }
443            if let Some(evt) = process_incoming(state, ev, channel, my_pubkey) {
444                out.push(evt);
445            }
446        }
447    }
448    out
449}
450
451/// Apply an inbound reaction (3301) to its target message. The reaction is PARSED by the shared
452/// `process_rumor` (3301→kind 7: target, emoji, NIP-30 image); only the STATE apply (dedup on the
453/// reaction's inner id, so local + relay echoes collapse) is Concord-side.
454fn apply_reaction(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
455    use crate::rumor::{process_rumor, RumorProcessingResult};
456    let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::Reaction, my_pubkey);
457    let reaction = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
458        Ok(RumorProcessingResult::Reaction(r)) => r,
459        _ => return None,
460    };
461    let target_id = reaction.reference_id.clone();
462    // Cross-channel guard: a reaction may only land on a target resident in the SAME channel it was
463    // sealed under. The reaction's binding triad authenticates its own channel/epoch but says nothing
464    // about where its target lives, so without this a member holding one channel's key could inject
465    // reactions onto another channel's messages. (Community channel chats are keyed by channel-id hex.)
466    let expected_chat = opened.channel_id.to_hex();
467    if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == expected_chat) {
468        return None;
469    }
470    let (_chat_id, was_added) = state.add_reaction_to_message(&target_id, reaction)?;
471    if !was_added {
472        return None;
473    }
474    let (_chat, message) = state.find_message(&target_id)?;
475    Some(IncomingEvent::Updated { target_id, message, edit_event: None })
476}
477
478/// Apply an inbound edit (3302) to its target message. PARSED by the shared `process_rumor`
479/// (3302→edit: target, new content, edited_at via the shared ms resolver). The author-scoped gate
480/// (only the original author may edit their own message) + the canonical edit applier are Concord-side.
481fn apply_edit(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
482    use crate::rumor::{process_rumor, RumorProcessingResult};
483    let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::from(event_kind::MESSAGE_EDIT), my_pubkey);
484    let (target_id, new_content, edited_at, emoji_tags, edit_event) = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
485        Ok(RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, event }) => (message_id, new_content, edited_at, emoji_tags, event),
486        _ => return None,
487    };
488    // Author-scoped: you can't edit someone else's message (not a parser concern — needs the resident
489    // target's author).
490    let editor_npub = opened.author.to_bech32().ok()?;
491    let target_author = state.find_message(&target_id).and_then(|(_, m)| m.npub)?;
492    if target_author != editor_npub {
493        crate::log_debug!("[community] dropped edit from non-author of {}", target_id);
494        return None;
495    }
496    // The canonical edit applier seeds history with the original ONCE, dedups by `edited_at` (a
497    // relay-replayed edit is a no-op, not history corruption), sorts, and swaps the content.
498    let (_chat_id, message) = state.update_message(&target_id, |m| {
499        m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone());
500    })?;
501    // Persist the edit as a folded MESSAGE_EDIT event (caller sets chat_id), mirroring DMs —
502    // no row overwrite, no JSON snapshot.
503    Some(IncomingEvent::Updated { target_id, message, edit_event: Some(Box::new(edit_event)) })
504}
505
506/// version-pinned authority for a directed authority action (moderation-hide 3305, kick 3309):
507/// does the actor's cited grant prove authority we've actually SYNCED? The owner is supreme (cites
508/// nothing). A non-owner must cite the grant that authorizes them, and we must hold that grant in our
509/// persisted heads at ≥ the cited version (with the cited hash at the tip) — else fail closed (don't
510/// honor an action claiming authority we can't confirm). This is the COMPLETENESS half only; the actual
511/// permission + outrank is the SEPARATE `can_act_on_member` check against the floor-protected roster (so
512/// a since-demoted actor is refused there: refuse-superseded). The block-until-synced FETCH escalation
513/// isn't possible in this sync inbound path; an action citing a version we haven't synced is dropped and
514/// re-evaluated on the next roster sync (the documented sync-path limit).
515fn actor_authority_pinned(
516    channel: &Channel,
517    owner_hex: Option<&str>,
518    actor_hex: &str,
519    citation: Option<&super::edition::AuthorityCitation>,
520) -> bool {
521    if owner_hex == Some(actor_hex) {
522        return true; // owner is supreme and cites nothing
523    }
524    if citation.is_none() {
525        return false; // a non-owner authority action MUST carry a citation
526    }
527    let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) else {
528        return false; // can't resolve the community → can't confirm the cited grant → fail closed
529    };
530    let cid_bytes = crate::simd::hex::hex_to_bytes_32(&cid);
531    let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
532    let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
533        &crate::community::CommunityId(cid_bytes),
534        &actor_bytes,
535    ));
536    let head: Vec<super::roster::EntityHead> = crate::db::community::get_edition_head(&cid, &grant_hex)
537        .ok()
538        .flatten()
539        .map(|(version, self_hash)| super::roster::EntityHead { entity_hex: grant_hex.clone(), version, self_hash, inner_id: [0u8; 32], citation: None })
540        .into_iter()
541        .collect();
542    super::roster::authority_citation_satisfied(&head, owner_hex, actor_hex, &grant_hex, citation)
543}
544
545fn apply_delete(state: &mut ChatState, opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
546    use crate::community::roles::Permissions;
547    use crate::rumor::{process_rumor, RumorProcessingResult};
548    // Target PARSED by the shared deletion parser (3305→kind 5; rejects an ambiguous multi-`e` target).
549    // The author-delete vs moderation-hide AUTHORITY decision below stays Concord-side — it reads the
550    // synced roster, which the parser knows nothing about, and never mutates consensus.
551    let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::EventDeletion, my_pubkey);
552    let target_id = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
553        Ok(RumorProcessingResult::DeletionRequest { target_event_id }) => target_event_id,
554        _ => return None,
555    };
556    let deleter = opened.author;
557    let deleter_hex = deleter.to_hex();
558
559    // A 3305 may target a REACTION rather than a message (both are event ids). Reactions are
560    // author-revocable only in v1 (no moderation-strip): the deleter must be the reactor. Handled
561    // before the message path so a reaction id never falls through to message-removal logic.
562    if let Some((_chat_id, message_id, author_npub, _is_comm)) = state.find_reaction(&target_id) {
563        let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == deleter).unwrap_or(false);
564        if !reactor_ok {
565            crate::log_debug!("[community] dropped reaction-revoke: {deleter_hex} is not the reactor of {target_id}");
566            return None;
567        }
568        return state
569            .remove_reaction_from_message(&message_id, &target_id)
570            .map(|(_cid, message)| IncomingEvent::ReactionRemoved {
571                message_id,
572                reaction_id: target_id,
573                message,
574            });
575    }
576
577    let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
578    // pinned authority for a moderation-hide: the deleter's cited grant must be one we've synced
579    // (fail closed otherwise). Orthogonal to the outrank below; self-deletes don't consult it.
580    let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &deleter_hex, opened.citation.as_ref());
581
582    // Resolve the target's author from the resident copy (needed for both the self-delete check and the
583    // hide outrank check). If the target isn't resident (an older page), we can't resolve the author.
584    let target_author = state
585        .find_message(&target_id)
586        .and_then(|(_, m)| m.npub.clone())
587        .and_then(|n| PublicKey::parse(&n).ok());
588
589    if let Some(author) = target_author {
590        // Self-delete: the message's own author removes it.
591        if author == deleter {
592            return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
593        }
594        // Moderation-hide: deleter must (a) cite an authorizing grant we've synced (`pinned`) AND
595        // (b) hold MANAGE_MESSAGES + outrank the target's author. The owner is never a valid target of
596        // `can_act_on_member` (so owner-protection falls out — no hardcoded carve-out), and a
597        // peer/superior can't be hidden either. Blocked once dissolved: a dead community accepts no
598        // new authority actions — only SELF-deletes (handled above) survive the seal.
599        if pinned && !channel.dissolved && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES) {
600            return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
601        }
602        crate::log_debug!("[community] dropped delete: {deleter_hex} not authorized to remove {target_id}");
603        return None;
604    }
605
606    // Target not resident in memory. If it's still in the DB (just paged out of the window), authorize
607    // against its REAL author — owner-protection and the outrank both apply exactly as for a resident
608    // target, so an admin can't tombstone the owner's (or a peer's) paged-out message.
609    if let Ok(Some(author_npub)) = crate::db::events::event_author(&target_id) {
610        if let Ok(author) = PublicKey::parse(&author_npub) {
611            // Dissolved gate mirrors the resident path: the seal blocks
612            // moderation-hides over paged-out targets too — only self-deletes
613            // survive in a dead community.
614            let ok = author == deleter
615                || (pinned
616                    && !channel.dissolved
617                    && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES));
618            if ok {
619                return Some(IncomingEvent::Removed { target_id });
620            }
621            crate::log_debug!("[community] dropped out-of-window delete: {deleter_hex} not authorized over {target_id}");
622            return None;
623        }
624    }
625
626    // Author unknown (in neither STATE nor DB — a hide racing ahead of its target). There's nothing to
627    // remove and no real author to authorize against yet, so DON'T act and DON'T let this record in the
628    // dedup ledger (returning None keeps `process_incoming` from recording the outer id). The hide stays
629    // un-deduped and RE-APPLIES on a later sync once the target is resident — the resident path then
630    // authorizes against the real author. (A speculative `Removed` here would be a no-op emit AND, via the
631    // ledger, dedup the hide forever → the message would never get hidden once it arrived: bypass.)
632    // Mirrors reaction/edit, which also return None on an absent target.
633    None
634}
635
636/// Route an incoming wire event by its `z` pseudonym tag to the matching channel in
637/// `routes`, then open + ingest it. Returns the added `Message`, or `None` if the
638/// event carries no `z` tag, names a pseudonym we don't route, or fails to open/dedup.
639/// Pure over the passed `state` + `routes`, so the routing is unit-testable without
640/// the live subscription loop.
641pub fn route_incoming(
642    state: &mut ChatState,
643    event: &Event,
644    routes: &std::collections::HashMap<String, Channel>,
645    my_pubkey: &PublicKey,
646) -> Option<IncomingEvent> {
647    let pseudonym = event.tags.iter().find_map(|t| {
648        let s = t.as_slice();
649        (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
650    })?;
651    let channel = routes.get(&pseudonym)?;
652    process_incoming(state, event, channel, my_pubkey)
653}
654
655#[cfg(test)]
656mod tests {
657    use nostr_sdk::prelude::FinalizeEvent;
658    use super::*;
659    use crate::community::derive::channel_pseudonym;
660    use std::collections::HashMap;
661    use crate::community::envelope::{build_inner_full, build_inner_typed, open_message, seal_message, seal_with_signed_inner};
662    use crate::community::edition::AuthorityCitation;
663    use crate::community::{Channel, ChannelId, ChannelKey, Epoch};
664    use crate::state::ChatState;
665    use nostr_sdk::prelude::{Keys, Tag};
666
667    /// A DB-backed community owned by `owner` with `admin` granted the Admin role (MANAGE_MESSAGES,
668    /// position 1) and the admin's grant head recorded at v1 — so `apply_delete` can resolve the
669    /// community (`community_id_for_channel`) and verify a moderation hide's pinned authority against the
670    /// persisted grant head. Returns the reloaded channel (carrying the denormalized roster + protected
671    /// owner) and a valid citation pinning the admin's v1 grant. Holds the DB test guard for the test.
672    fn db_roster_channel(
673        owner: &Keys,
674        admin: &PublicKey,
675    ) -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Channel, AuthorityCitation) {
676        use crate::community::roles::{CommunityRoles, MemberGrant, Role};
677        use nostr_sdk::prelude::ToBech32;
678        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
679        crate::db::close_database();
680        let tmp = tempfile::tempdir().unwrap();
681        let account = owner.public_key().to_bech32().unwrap();
682        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
683        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
684        crate::db::set_current_account(account.clone()).unwrap();
685        crate::db::init_database(&account).unwrap();
686        crate::state::MY_SECRET_KEY.store_from_keys(owner, &[]);
687        crate::state::set_my_public_key(owner.public_key());
688
689        let mut community = crate::community::Community::create("HQ", "general", vec!["r".into()]);
690        let cid = community.id.to_hex();
691        community.owner_attestation = Some(
692            crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
693                .finalize(owner)
694                .unwrap()
695                .as_json(),
696        );
697        crate::db::community::save_community(&community).unwrap();
698
699        // Grant the admin MANAGE_MESSAGES (Admin role) and cache it so the reloaded channel's roster
700        // ranks them; record their grant head at v1 so the citation below verifies.
701        let role = Role::admin("a".repeat(64));
702        let roster = CommunityRoles {
703            grants: vec![MemberGrant { member: admin.to_hex(), role_ids: vec![role.role_id.clone()] }],
704            roles: vec![role],
705        };
706        crate::db::community::set_community_roles(&cid, &roster, 0).unwrap();
707        let entity_id = crate::community::derive::grant_locator(&community.id, &admin.to_bytes());
708        let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
709        let hash = [0x5Au8; 32];
710        crate::db::community::set_edition_head(&cid, &entity_hex, 1, &hash).unwrap();
711
712        let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
713        let channel = reloaded.channels[0].clone();
714        (tmp, guard, channel, AuthorityCitation { entity_id, version: 1, edition_hash: hash })
715    }
716
717    /// Seal a 3305 moderation hide of `target` as `author`, optionally carrying a `vac` citation.
718    fn seal_hide(channel: &Channel, author: &Keys, target: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
719        let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
720        let inner = build_inner_full(
721            author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_DELETE, "", ms, Some(target), &[], &extra,
722        )
723        .finalize(author)
724        .unwrap();
725        seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
726    }
727
728    /// Ingest a message authored by `author` into `channel`, returning its inner id.
729    fn ingest_msg_in(state: &mut ChatState, channel: &Channel, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
730        let outer = seal_message(author, &channel.key, &channel.id, channel.epoch, content, ms).unwrap();
731        match process_incoming(state, &outer, channel, &viewer.public_key()) {
732            Some(IncomingEvent::NewMessage(m)) => m.id,
733            _ => panic!("expected a new message"),
734        }
735    }
736
737    fn opened_from(author: &Keys, content: &str, ms: u64) -> OpenedMessage {
738        let key = ChannelKey([0x33u8; 32]);
739        let chan = ChannelId([0x44u8; 32]);
740        let outer = seal_message(author, &key, &chan, Epoch(0), content, ms).unwrap();
741        open_message(&outer, &key, &chan, Epoch(0)).unwrap()
742    }
743
744    fn test_channel() -> Channel {
745        Channel { id: ChannelId([0x44u8; 32]), key: ChannelKey([0x33u8; 32]), epoch: Epoch(0), name: "t".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false }
746    }
747
748    /// Seal a typed control event (reaction/edit) referencing `target`, as `author`.
749    fn seal_typed(author: &Keys, kind: u16, content: &str, ms: u64, target: &str) -> Event {
750        let c = test_channel();
751        let inner = build_inner_typed(author.public_key(), &c.id, c.epoch, kind, content, ms, Some(target), &[])
752            .finalize(author)
753            .unwrap();
754        seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
755    }
756
757    fn ingest_msg(state: &mut ChatState, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
758        let c = test_channel();
759        let outer = seal_message(author, &c.key, &c.id, c.epoch, content, ms).unwrap();
760        match process_incoming(state, &outer, &c, &viewer.public_key()) {
761            Some(IncomingEvent::NewMessage(m)) => m.id,
762            _ => panic!("expected a new message"),
763        }
764    }
765
766    #[test]
767    fn inbound_reaction_applies_to_target_and_dedups() {
768        use crate::stored_event::event_kind;
769        let mut state = ChatState::new();
770        let alice = Keys::generate();
771        let bob = Keys::generate();
772        let target = ingest_msg(&mut state, &alice, "hi", 1, &bob);
773
774        let react = seal_typed(&bob, event_kind::COMMUNITY_REACTION, "🔥", 2, &target);
775        match process_incoming(&mut state, &react, &test_channel(), &bob.public_key()) {
776            Some(IncomingEvent::Updated { target_id, message, edit_event: None }) => {
777                assert_eq!(target_id, target);
778                assert!(message.reactions.iter().any(|r| r.emoji == "🔥"), "reaction applied to target");
779            }
780            _ => panic!("expected a reaction update"),
781        }
782        // The exact same reaction event again → deduped (no second update).
783        assert!(process_incoming(&mut state, &react, &test_channel(), &bob.public_key()).is_none());
784    }
785
786    #[test]
787    fn bot_routing_tag_rides_the_v1_inner_into_addressed_bots() {
788        use nostr_sdk::prelude::ToBech32;
789        use crate::community::envelope::{build_inner_full, seal_with_signed_inner};
790        let mut state = ChatState::new();
791        let alice = Keys::generate();
792        let bot = Keys::generate();
793        let c = test_channel();
794
795        // A picker send: the `bot` routing tag rides the inner verbatim.
796        let inner = build_inner_full(
797            alice.public_key(),
798            &c.id,
799            c.epoch,
800            crate::stored_event::event_kind::COMMUNITY_MESSAGE,
801            "/roll 20",
802            5,
803            None,
804            &[],
805            &[crate::bot_interface::bot_tag(&bot.public_key())],
806        )
807        .finalize(&alice)
808        .unwrap();
809        let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
810
811        // The real open + ingest path lifts the tag into `addressed_bots` —
812        // the field SDK bots consult to skip commands picked for another bot.
813        let opened = open_message(&outer, &c.key, &c.id, c.epoch).unwrap();
814        let msg = build_message(&opened, &alice.public_key());
815        assert_eq!(msg.addressed_bots, vec![bot.public_key().to_bech32().unwrap()]);
816
817        match process_incoming(&mut state, &outer, &c, &alice.public_key()) {
818            Some(IncomingEvent::NewMessage(m)) => {
819                assert_eq!(m.addressed_bots.len(), 1, "ingest keeps the routing tag");
820            }
821            _ => panic!("expected a new message"),
822        }
823    }
824
825    #[test]
826    fn reaction_cross_channel_is_rejected() {
827        // A member holding one channel's key must not be able to seal a reaction under THAT channel
828        // that lands on a message resident in ANOTHER channel.
829        use crate::stored_event::event_kind;
830        use crate::community::envelope::{build_inner_typed, seal_with_signed_inner};
831        let mut state = ChatState::new();
832        let alice = Keys::generate();
833        let bob = Keys::generate();
834        let chan_a = test_channel();
835        let chan_b = Channel {
836            id: ChannelId([0x55u8; 32]), key: ChannelKey([0x66u8; 32]), epoch: Epoch(0),
837            name: "b".into(), banned: Vec::new(), protected: Vec::new(),
838            roster: Default::default(), epoch_keys: Vec::new(), dissolved: false,
839        };
840        // Target message lives in channel A.
841        let target = ingest_msg_in(&mut state, &chan_a, &alice, "hi", 1, &bob);
842        // Bob seals a reaction under channel B that points at A's message.
843        let inner = build_inner_typed(
844            bob.public_key(), &chan_b.id, chan_b.epoch, event_kind::COMMUNITY_REACTION, "🔥", 2, Some(&target), &[],
845        ).finalize(&bob).unwrap();
846        let outer = seal_with_signed_inner(&Keys::generate(), &inner, &chan_b.key, &chan_b.id, chan_b.epoch).unwrap();
847        // Opened under channel B, but the target is in A → rejected, nothing applied.
848        assert!(
849            process_incoming(&mut state, &outer, &chan_b, &bob.public_key()).is_none(),
850            "a reaction sealed under another channel must not apply to this channel's message"
851        );
852        let (_c, msg) = state.find_message(&target).unwrap();
853        assert!(msg.reactions.is_empty(), "cross-channel reaction must not be applied");
854    }
855
856    #[test]
857    fn inbound_message_carries_multi_attachments() {
858        use crate::stored_event::event_kind;
859        use crate::community::attachments::attachment_to_imeta;
860        use crate::community::envelope::build_inner_full;
861        use crate::types::Attachment;
862        let mut state = ChatState::new();
863        let alice = Keys::generate();
864        let bob = Keys::generate();
865        let c = test_channel();
866
867        let mk = |n: &str, ext: &str| Attachment {
868            id: "x".into(), key: "0".repeat(64), nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(n.as_bytes())),
869            extension: ext.into(), name: n.into(), url: format!("https://b/{n}"),
870            path: String::new(), size: 9, img_meta: None, downloading: false, downloaded: false,
871            webxdc_topic: None, group_id: None, original_hash: Some("a".repeat(64)),
872            fallback_urls: Vec::new(),
873        };
874        let imetas = vec![attachment_to_imeta(&mk("a.png", "png")), attachment_to_imeta(&mk("b.txt", "txt"))];
875        let inner = build_inner_full(
876            alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_MESSAGE,
877            "caption", 5, None, &[], &imetas,
878        ).finalize(&alice).unwrap();
879        let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
880
881        match process_incoming(&mut state, &outer, &c, &bob.public_key()) {
882            Some(IncomingEvent::NewMessage(m)) => {
883                assert_eq!(m.content, "caption", "caption + attachments coexist in one event");
884                assert_eq!(m.attachments.len(), 2);
885                assert_eq!(m.attachments[0].name, "a.png");
886                assert_eq!(m.attachments[1].name, "b.txt");
887                assert!(m.attachments.iter().all(|a| a.group_id.is_none()));
888            }
889            _ => panic!("expected new message with attachments"),
890        }
891    }
892
893    #[test]
894    fn inbound_edit_only_honored_from_original_author() {
895        use crate::stored_event::event_kind;
896        let mut state = ChatState::new();
897        let alice = Keys::generate();
898        let target = ingest_msg(&mut state, &alice, "original", 1, &alice);
899
900        // Author edits her own message → applied.
901        let edit = seal_typed(&alice, event_kind::COMMUNITY_EDIT, "edited!", 2, &target);
902        match process_incoming(&mut state, &edit, &test_channel(), &alice.public_key()) {
903            Some(IncomingEvent::Updated { message, edit_event, .. }) => {
904                assert_eq!(message.content, "edited!");
905                assert!(message.edited);
906                // Event-sourced: the edit rides a foldable MESSAGE_EDIT event, not a row overwrite.
907                let ev = edit_event.expect("edit surfaces a MESSAGE_EDIT event to persist");
908                assert_eq!(ev.kind, event_kind::MESSAGE_EDIT);
909                assert_eq!(ev.reference_id.as_deref(), Some(target.as_str()));
910                assert_eq!(ev.content, "edited!");
911            }
912            _ => panic!("expected an edit update"),
913        }
914
915        // A different author trying to edit alice's message → dropped, content unchanged.
916        let mallory = Keys::generate();
917        let hijack = seal_typed(&mallory, event_kind::COMMUNITY_EDIT, "hijacked", 3, &target);
918        assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
919        assert_eq!(state.find_message(&target).unwrap().1.content, "edited!");
920    }
921
922    #[test]
923    fn cooperative_delete_only_honored_from_original_author() {
924        use crate::stored_event::event_kind;
925        let mut state = ChatState::new();
926        let alice = Keys::generate();
927        let mallory = Keys::generate();
928        let target = ingest_msg(&mut state, &alice, "secret", 1, &alice);
929
930        // Someone else's delete of alice's message → dropped, message survives.
931        let hijack = seal_typed(&mallory, event_kind::COMMUNITY_DELETE, "", 2, &target);
932        assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
933        assert!(state.find_message(&target).is_some(), "non-author delete must not remove");
934
935        // Author's own delete (signed by a FRESH key, no retained message key needed) → removed.
936        let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 3, &target);
937        match process_incoming(&mut state, &del, &test_channel(), &alice.public_key()) {
938            Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
939            _ => panic!("expected a removal"),
940        }
941        assert!(state.find_message(&target).is_none(), "message gone after author delete");
942
943        // Replaying the delete (or one arriving for an already-gone target) → silent no-op.
944        let replay = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 4, &target);
945        assert!(process_incoming(&mut state, &replay, &test_channel(), &alice.public_key()).is_none());
946    }
947
948    #[test]
949    fn dissolved_community_still_honors_an_own_message_delete() {
950        use crate::stored_event::event_kind;
951        let mut state = ChatState::new();
952        let alice = Keys::generate();
953        let target = ingest_msg(&mut state, &alice, "alice's own message", 1, &alice);
954        let mut ch = test_channel();
955        ch.dissolved = true;
956        // carve-out (data ownership): the binary seal blocks all NEW content, but a member can always
957        // scrub their OWN past message even from a dead community — a 3305 self-delete passes the seal.
958        let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &target);
959        match process_incoming(&mut state, &del, &ch, &alice.public_key()) {
960            Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
961            _ => panic!("a self-delete must be honored in a dissolved community"),
962        }
963        assert!(state.find_message(&target).is_none(), "own message scrubbed from the dead community");
964    }
965
966    #[test]
967    fn admin_moderation_hide_removes_any_message() {
968        let owner = Keys::generate();
969        let admin = Keys::generate(); // granted MANAGE_MESSAGES in the roster
970        let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
971        let alice = Keys::generate(); // author of the target
972        let mallory = Keys::generate(); // unprivileged member
973        let mut state = ChatState::new();
974        let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
975
976        // A member with no MANAGE_MESSAGES role (and no citation to offer) cannot hide someone else's.
977        let hijack = seal_hide(&c, &mallory, &target, 2, None);
978        assert!(process_incoming(&mut state, &hijack, &c, &alice.public_key()).is_none());
979        assert!(state.find_message(&target).is_some(), "unprivileged hide rejected");
980
981        // The admin (hide signed by their REAL npub, citing their synced grant) hides a member's message.
982        let hide = seal_hide(&c, &admin, &target, 3, Some(&cite));
983        match process_incoming(&mut state, &hide, &c, &alice.public_key()) {
984            Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
985            _ => panic!("expected admin moderation-hide to remove the message"),
986        }
987        assert!(state.find_message(&target).is_none(), "admin hide removed the message");
988    }
989
990    #[test]
991    fn admin_hide_without_a_citation_is_dropped() {
992        // a non-owner moderation hide MUST cite the grant that authorizes them. An admin who holds
993        // MANAGE_MESSAGES but ships an UNCITED hide is dropped (fail closed — we never act on authority
994        // that isn't pinned to a synced grant version).
995        let owner = Keys::generate();
996        let admin = Keys::generate();
997        let (_tmp, _guard, c, _cite) = db_roster_channel(&owner, &admin.public_key());
998        let alice = Keys::generate();
999        let mut state = ChatState::new();
1000        let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1001
1002        let hide = seal_hide(&c, &admin, &target, 2, None); // no citation
1003        assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1004        assert!(state.find_message(&target).is_some(), "an uncited admin hide is dropped");
1005    }
1006
1007    #[test]
1008    fn hide_citing_an_unsynced_grant_version_is_dropped() {
1009        // The sync-floor: an admin who cites a grant version we have NOT synced (ahead of our persisted
1010        // head) is dropped — we can't confirm the authority, so we don't act (block-until-synced degrades
1011        // to drop in the sync inbound path; it re-evaluates once the grant syncs).
1012        let owner = Keys::generate();
1013        let admin = Keys::generate();
1014        let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1015        let alice = Keys::generate();
1016        let mut state = ChatState::new();
1017        let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1018
1019        // Our held head for the admin's grant is v1; the hide cites a future v2 nobody has yet.
1020        let ahead = AuthorityCitation { version: 2, ..cite };
1021        let hide = seal_hide(&c, &admin, &target, 2, Some(&ahead));
1022        assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1023        assert!(state.find_message(&target).is_some(), "a hide citing an unsynced version is dropped");
1024    }
1025
1026    #[test]
1027    fn hide_with_a_forged_citation_hash_is_dropped() {
1028        // fork guard: an admin citing their real grant entity + version but the WRONG hash is dropped.
1029        let owner = Keys::generate();
1030        let admin = Keys::generate();
1031        let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1032        let alice = Keys::generate();
1033        let mut state = ChatState::new();
1034        let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1035
1036        let forged = AuthorityCitation { edition_hash: [0xEE; 32], ..cite };
1037        let hide = seal_hide(&c, &admin, &target, 2, Some(&forged));
1038        assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1039        assert!(state.find_message(&target).is_some(), "a forged-hash citation is dropped");
1040    }
1041
1042    #[test]
1043    fn protected_owner_cannot_be_moderation_hidden_but_others_can() {
1044        let owner = Keys::generate(); // protected, implicit position 0
1045        let admin = Keys::generate(); // granted MANAGE_MESSAGES
1046        let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1047        let mut state = ChatState::new();
1048
1049        // An admin-signed hide targeting the OWNER's message is dropped — the owner outranks every admin.
1050        let owners_msg = ingest_msg_in(&mut state, &c, &owner, "owner speaks", 1, &owner);
1051        let hide_owner = seal_hide(&c, &admin, &owners_msg, 2, Some(&cite));
1052        assert!(process_incoming(&mut state, &hide_owner, &c, &owner.public_key()).is_none());
1053        assert!(state.find_message(&owners_msg).is_some(), "owner's message is protected");
1054
1055        // A non-protected member's message CAN be moderation-hidden by the same admin.
1056        let member = Keys::generate();
1057        let members_msg = ingest_msg_in(&mut state, &c, &member, "member speaks", 3, &owner);
1058        let hide_member = seal_hide(&c, &admin, &members_msg, 4, Some(&cite));
1059        match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1060            Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, members_msg),
1061            _ => panic!("a non-protected member's message should be hideable"),
1062        }
1063    }
1064
1065    #[test]
1066    fn admin_hide_of_absent_target_defers_until_resident() {
1067        // A hide for a target resident in NEITHER STATE nor DB (racing ahead of its message) returns None
1068        // — there's nothing to remove and no real author to outrank yet. Critically it must NOT emit a
1069        // speculative Removed: that emit is a no-op (delete_event on an absent id does nothing) AND, via
1070        // the cross-transport dedup ledger, would dedup the hide forever, so the message would never get
1071        // hidden once it finally arrived (a moderation bypass). Returning None keeps the hide
1072        // un-deduped so it RE-APPLIES on a later sync once the target pages in (resident path authorizes
1073        // against the real author). Mirrors reaction/edit on an absent target.
1074        let owner = Keys::generate();
1075        let admin = Keys::generate();
1076        let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1077        let mut state = ChatState::new();
1078        let absent_target = "f".repeat(64); // never ingested into STATE or DB
1079
1080        let hide = seal_hide(&c, &admin, &absent_target, 1, Some(&cite));
1081        assert!(
1082            process_incoming(&mut state, &hide, &c, &Keys::generate().public_key()).is_none(),
1083            "a hide of an absent target defers (None) rather than falsely tombstoning + self-deduping",
1084        );
1085
1086        // A NON-privileged hide of an out-of-window message stays a no-op (no MANAGE_MESSAGES grant).
1087        let mallory = Keys::generate();
1088        let hijack = seal_hide(&c, &mallory, &absent_target, 2, None);
1089        assert!(process_incoming(&mut state, &hijack, &c, &mallory.public_key()).is_none());
1090
1091        // And a PRIVILEGED admin (holds MANAGE_MESSAGES) who ships an UNCITED hide of the unknown target
1092        // is ALSO dropped — the author-unknown branch is gated on `pinned`, not the permission bit alone.
1093        let uncited = seal_hide(&c, &admin, &absent_target, 3, None);
1094        assert!(
1095            process_incoming(&mut state, &uncited, &c, &Keys::generate().public_key()).is_none(),
1096            "an admin's uncited hide of an unknown target is dropped (pinned gates the author-unknown path)"
1097        );
1098    }
1099
1100    #[tokio::test]
1101    async fn out_of_window_hide_authorizes_against_db_author() {
1102        use crate::types::Message;
1103        // A paged-out target (in the DB, not resident in memory) is authorized against its REAL author:
1104        // an admin can hide a regular member's paged-out message, but NOT the owner's (owner-protection
1105        // holds even when the message is out of the in-memory window).
1106        use nostr_sdk::prelude::ToBech32;
1107        let owner = Keys::generate();
1108        let admin = Keys::generate(); // granted MANAGE_MESSAGES
1109        let member = Keys::generate();
1110        let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1111
1112        // Persist two messages to the DB only — a fresh ChatState holds neither.
1113        let owner_msg = "a".repeat(64);
1114        let member_msg = "b".repeat(64);
1115        let mk = |id: &str, author: &Keys, at: u64| {
1116            let mut m = Message::default();
1117            m.id = id.to_string();
1118            m.npub = Some(author.public_key().to_bech32().unwrap());
1119            m.at = at;
1120            m
1121        };
1122        crate::db::events::save_message("chatoow", &mk(&owner_msg, &owner, 1)).await.unwrap();
1123        crate::db::events::save_message("chatoow", &mk(&member_msg, &member, 2)).await.unwrap();
1124
1125        let mut state = ChatState::new();
1126        // Admin hide of the OWNER's paged-out message → dropped (owner is supreme, never a target).
1127        let hide_owner = seal_hide(&c, &admin, &owner_msg, 3, Some(&cite));
1128        assert!(
1129            process_incoming(&mut state, &hide_owner, &c, &member.public_key()).is_none(),
1130            "owner's paged-out message must not be hideable by an admin"
1131        );
1132        // Admin hide of a regular member's paged-out message → Removed (tombstoned).
1133        let hide_member = seal_hide(&c, &admin, &member_msg, 4, Some(&cite));
1134        match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1135            Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg),
1136            _ => panic!("admin should hide a member's paged-out message"),
1137        }
1138
1139        // Dissolved seal covers paged-out targets too: an admin moderation-hide of a
1140        // member's DB-only message is dropped in a dead community, while the author's
1141        // own self-delete of their paged-out message still passes (data ownership).
1142        let member_msg2 = "c".repeat(64);
1143        crate::db::events::save_message("chatoow", &mk(&member_msg2, &member, 5)).await.unwrap();
1144        let mut sealed = c.clone();
1145        sealed.dissolved = true;
1146        let hide_sealed = seal_hide(&sealed, &admin, &member_msg2, 6, Some(&cite));
1147        assert!(
1148            process_incoming(&mut state, &hide_sealed, &sealed, &owner.public_key()).is_none(),
1149            "a dissolved community accepts no moderation-hide, resident or paged-out"
1150        );
1151        let self_del = seal_hide(&sealed, &member, &member_msg2, 7, None);
1152        match process_incoming(&mut state, &self_del, &sealed, &owner.public_key()) {
1153            Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg2),
1154            _ => panic!("a self-delete of a paged-out message must survive the dissolved seal"),
1155        }
1156        crate::db::close_database();
1157    }
1158
1159    /// Seal a 3309 cooperative kick of `target_hex` as `author`, optionally carrying a `vac` citation.
1160    fn seal_kick(channel: &Channel, author: &Keys, target_hex: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
1161        let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1162        let inner = build_inner_full(
1163            author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
1164        )
1165        .finalize(author)
1166        .unwrap();
1167        seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1168    }
1169
1170    /// A kick inner timestamp safely AFTER `db_roster_channel`'s community save, so the join-time
1171    /// guard honors it. `build_inner_full` derives `created_at = ms / 1000`, so we add a 5s margin.
1172    fn post_join_ms() -> u64 {
1173        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1174        (now + 5) * 1000
1175    }
1176
1177    #[test]
1178    fn cited_admin_kick_of_local_user_yields_self_removal() {
1179        let owner = Keys::generate();
1180        let admin = Keys::generate();
1181        let member = Keys::generate();
1182        let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1183        let mut state = ChatState::new();
1184        // Admin kicks `member`; the LOCAL viewer is `member` → Kicked (the caller tears down locally).
1185        let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1186        match process_incoming(&mut state, &kick, &channel, &member.public_key()) {
1187            Some(IncomingEvent::Kicked { community_id }) => assert!(!community_id.is_empty()),
1188            _ => panic!("expected Kicked"),
1189        }
1190        crate::db::close_database();
1191    }
1192
1193    #[test]
1194    fn cited_admin_kick_of_other_member_is_a_leave() {
1195        use nostr_sdk::prelude::ToBech32;
1196        let owner = Keys::generate();
1197        let admin = Keys::generate();
1198        let member = Keys::generate();
1199        let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1200        let mut state = ChatState::new();
1201        // Admin kicks `member`; the LOCAL viewer is the owner → reflected as a leave Presence so `member`
1202        // drops out of the observed member list (no dedicated arm).
1203        let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1204        match process_incoming(&mut state, &kick, &channel, &owner.public_key()) {
1205            Some(IncomingEvent::Presence { npub, joined, .. }) => {
1206                assert!(!joined);
1207                assert_eq!(npub, member.public_key().to_bech32().unwrap());
1208            }
1209            _ => panic!("expected leave Presence"),
1210        }
1211        crate::db::close_database();
1212    }
1213
1214    #[test]
1215    fn uncited_kick_is_dropped() {
1216        let owner = Keys::generate();
1217        let admin = Keys::generate();
1218        let member = Keys::generate();
1219        let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1220        let mut state = ChatState::new();
1221        let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, None);
1222        assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1223            "a non-owner kick without a citation is dropped");
1224        crate::db::close_database();
1225    }
1226
1227    #[test]
1228    fn unprivileged_kick_is_dropped() {
1229        let owner = Keys::generate();
1230        let admin = Keys::generate();
1231        let mallory = Keys::generate();
1232        let member = Keys::generate();
1233        let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1234        let mut state = ChatState::new();
1235        // Mallory holds no grant; even replaying the admin's citation, mallory's own grant locator has no
1236        // synced head AND the roster doesn't rank them with KICK → dropped (double-gated).
1237        let kick = seal_kick(&channel, &mallory, &member.public_key().to_hex(), 1, Some(&cite));
1238        assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1239            "a kick from an unranked actor is dropped");
1240        crate::db::close_database();
1241    }
1242
1243    #[test]
1244    fn kick_of_owner_is_dropped() {
1245        let owner = Keys::generate();
1246        let admin = Keys::generate();
1247        let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1248        let mut state = ChatState::new();
1249        // The owner is never a valid target of an authority action (owner-protection, no hardcoded carve-out).
1250        let kick = seal_kick(&channel, &admin, &owner.public_key().to_hex(), post_join_ms(), Some(&cite));
1251        assert!(process_incoming(&mut state, &kick, &channel, &owner.public_key()).is_none(),
1252            "an admin cannot kick the owner");
1253        crate::db::close_database();
1254    }
1255
1256    #[test]
1257    fn stale_kick_predating_join_is_dropped() {
1258        let owner = Keys::generate();
1259        let admin = Keys::generate();
1260        let member = Keys::generate();
1261        let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1262        let mut state = ChatState::new();
1263        // A fully-authorized kick whose inner timestamp PREDATES this account's join (ms=1 → created_at 0)
1264        // is ignored, so a re-accepted invite isn't undone by a stale kick replayed from history.
1265        let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, Some(&cite));
1266        assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1267            "a kick older than the current join is dropped");
1268        crate::db::close_database();
1269    }
1270
1271    #[test]
1272    fn webxdc_signals_parse_ad_and_left_and_reject_garbage() {
1273        use crate::stored_event::event_kind;
1274        use nostr_sdk::prelude::ToBech32;
1275        let mut state = ChatState::new();
1276        let alice = Keys::generate();
1277        let c = test_channel();
1278        let viewer = Keys::generate();
1279        let mk = |content: &str, ms: u64| {
1280            let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_WEBXDC, content, ms, None, &[])
1281                .finalize(&alice).unwrap();
1282            seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1283        };
1284        let topic = crate::webxdc::mint_topic_id("game-hash", "sender");
1285
1286        // Advertisement: topic + addr surface, author attributed.
1287        let ad = serde_json::json!({ "op": "ad", "topic": topic, "addr": "BASE32NODEADDR" }).to_string();
1288        match process_incoming(&mut state, &mk(&ad, 1), &c, &viewer.public_key()) {
1289            Some(IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, .. }) => {
1290                assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "player is the inner author");
1291                assert_eq!(topic_id, topic);
1292                assert_eq!(node_addr.as_deref(), Some("BASE32NODEADDR"));
1293            }
1294            _ => panic!("expected a webxdc advertisement"),
1295        }
1296
1297        // Peer-left: no addr.
1298        let left = serde_json::json!({ "op": "left", "topic": topic }).to_string();
1299        match process_incoming(&mut state, &mk(&left, 2), &c, &viewer.public_key()) {
1300            Some(IncomingEvent::WebxdcPeer { node_addr, .. }) => {
1301                assert!(node_addr.is_none(), "peer-left carries no addr");
1302            }
1303            _ => panic!("expected a webxdc peer-left"),
1304        }
1305
1306        // Own echo is dropped — the local realtime layer already tracks itself.
1307        assert!(
1308            process_incoming(&mut state, &mk(&ad, 3), &c, &alice.public_key()).is_none(),
1309            "own webxdc signal must be ignored"
1310        );
1311
1312        // Garbage: malformed topic (author-controlled), unknown op, ad missing addr, non-JSON.
1313        let bad_topic = serde_json::json!({ "op": "ad", "topic": "../../etc", "addr": "X" }).to_string();
1314        assert!(process_incoming(&mut state, &mk(&bad_topic, 4), &c, &viewer.public_key()).is_none());
1315        let bad_op = serde_json::json!({ "op": "explode", "topic": topic }).to_string();
1316        assert!(process_incoming(&mut state, &mk(&bad_op, 5), &c, &viewer.public_key()).is_none());
1317        let no_addr = serde_json::json!({ "op": "ad", "topic": topic }).to_string();
1318        assert!(process_incoming(&mut state, &mk(&no_addr, 6), &c, &viewer.public_key()).is_none());
1319        assert!(process_incoming(&mut state, &mk("not json", 7), &c, &viewer.public_key()).is_none());
1320    }
1321
1322    #[test]
1323    fn typing_indicator_parses_drops_own_echo_and_rejects_garbage() {
1324        use crate::stored_event::event_kind;
1325        use nostr_sdk::prelude::ToBech32;
1326        let mut state = ChatState::new();
1327        let alice = Keys::generate();
1328        let c = test_channel();
1329        let viewer = Keys::generate();
1330        let mk = |content: &str, ms: u64| {
1331            let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_TYPING, content, ms, None, &[])
1332                .finalize(&alice).unwrap();
1333            seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1334        };
1335        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1336
1337        // A FRESH "typing" signal from another member surfaces, attributed to the inner
1338        // author, expiring ~30s after ITS OWN send time (never the receiver's clock —
1339        // relay backfill replays stored old signals).
1340        match process_incoming(&mut state, &mk("typing", now * 1000), &c, &viewer.public_key()) {
1341            Some(IncomingEvent::Typing { npub, until }) => {
1342                assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "typer is the inner author");
1343                assert!(until >= now && until <= now + 31, "until = signal send time + 30s");
1344            }
1345            _ => panic!("expected a typing indicator"),
1346        }
1347
1348        // Own echo is dropped — we never show ourselves typing.
1349        assert!(
1350            process_incoming(&mut state, &mk("typing", now * 1000), &c, &alice.public_key()).is_none(),
1351            "own typing signal must be ignored"
1352        );
1353
1354        // Wrong content (a 3311 carrying anything but "typing") is rejected.
1355        assert!(process_incoming(&mut state, &mk("nope", now * 1000), &c, &viewer.public_key()).is_none());
1356    }
1357
1358    #[test]
1359    fn presence_announcements_parse_join_and_leave() {
1360        use crate::stored_event::event_kind;
1361        let mut state = ChatState::new();
1362        let alice = Keys::generate();
1363        let c = test_channel();
1364        let viewer = Keys::generate();
1365        let mk = |content: &str, ms: u64| {
1366            let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, content, ms, None, &[])
1367                .finalize(&alice).unwrap();
1368            seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1369        };
1370        match process_incoming(&mut state, &mk("join", 1), &c, &viewer.public_key()) {
1371            Some(IncomingEvent::Presence { npub, joined, .. }) => {
1372                assert!(joined, "content 'join' → joined");
1373                assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "announcer is the inner author");
1374            }
1375            _ => panic!("expected a join presence"),
1376        }
1377        match process_incoming(&mut state, &mk("leave", 2), &c, &viewer.public_key()) {
1378            Some(IncomingEvent::Presence { joined, invited_by, .. }) => {
1379                assert!(!joined, "content 'leave' → not joined");
1380                assert!(invited_by.is_none(), "a plain leave carries no attribution");
1381            }
1382            _ => panic!("expected a leave presence"),
1383        }
1384        // attributed join: content is `{"by":"<npub>","l":"<label>"}` → invited_by/label surface
1385        // (only when `by` is a REAL pubkey — a forged non-npub is dropped).
1386        let jean = Keys::generate().public_key().to_bech32().unwrap();
1387        let attributed = serde_json::json!({ "by": jean, "l": "Reddit" }).to_string();
1388        match process_incoming(&mut state, &mk(&attributed, 3), &c, &viewer.public_key()) {
1389            Some(IncomingEvent::Presence { joined, invited_by, invited_label, .. }) => {
1390                assert!(joined, "an attributed-join JSON is still a join");
1391                assert_eq!(invited_by.as_deref(), Some(jean.as_str()), "valid inviter npub surfaced");
1392                assert_eq!(invited_label.as_deref(), Some("Reddit"), "link label surfaced");
1393            }
1394            _ => panic!("expected an attributed join presence"),
1395        }
1396        // A forged non-pubkey `by` is dropped (no arbitrary text leaks into attribution).
1397        let forged = serde_json::json!({ "by": "haha not an npub", "l": "x" }).to_string();
1398        match process_incoming(&mut state, &mk(&forged, 4), &c, &viewer.public_key()) {
1399            Some(IncomingEvent::Presence { invited_by, .. }) => assert!(invited_by.is_none(), "forged inviter dropped"),
1400            _ => panic!("expected a join presence"),
1401        }
1402    }
1403
1404    #[test]
1405    fn leave_presence_authored_by_local_npub_yields_self_left() {
1406        use crate::stored_event::event_kind;
1407        // a leave-presence whose inner author IS the local npub is a self-removal → SelfLeft, so the
1408        // leave propagates to every device. A DB-backed channel is needed (community_id resolution).
1409        let owner = Keys::generate();
1410        let admin = Keys::generate();
1411        let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1412        let mut state = ChatState::new();
1413        // A FRESH self-leave (newer than the join) is the teardown case — build it after the
1414        // community's recorded join time so the staleness gate treats it as a real SelfLeft.
1415        let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1416        let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
1417        let leave_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0) + 10_000;
1418        let leave = {
1419            let inner = build_inner_typed(owner.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", leave_ms, None, &[])
1420                .finalize(&owner).unwrap();
1421            seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1422        };
1423        match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1424            Some(IncomingEvent::SelfLeft { community_id }) => assert!(!community_id.is_empty()),
1425            _ => panic!("expected SelfLeft"),
1426        }
1427        crate::db::close_database();
1428    }
1429
1430    #[test]
1431    fn leave_presence_authored_by_another_npub_stays_a_plain_leave() {
1432        use crate::stored_event::event_kind;
1433        use nostr_sdk::prelude::ToBech32;
1434        // A leave by SOMEONE ELSE is just a member-list departure, NOT a self-removal.
1435        let owner = Keys::generate();
1436        let admin = Keys::generate();
1437        let other = Keys::generate();
1438        let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1439        let mut state = ChatState::new();
1440        let leave = {
1441            let inner = build_inner_typed(other.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", 2, None, &[])
1442                .finalize(&other).unwrap();
1443            seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1444        };
1445        // LOCAL viewer is the owner; the leave is `other`'s → plain Presence{joined:false}.
1446        match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1447            Some(IncomingEvent::Presence { npub, joined, .. }) => {
1448                assert!(!joined);
1449                assert_eq!(npub, other.public_key().to_bech32().unwrap());
1450            }
1451            _ => panic!("expected plain leave Presence"),
1452        }
1453        crate::db::close_database();
1454    }
1455
1456    #[test]
1457    fn self_delete_still_applies_after_keep_keys_teardown() {
1458        // after a self-removal teardown that RETAINS the epoch keys, a 3305 self-delete of one's own
1459        // past message still works — the channel is reconstructed from the retained key and the delete opens.
1460        let owner = Keys::generate();
1461        let admin = Keys::generate();
1462        let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1463        let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1464        let chan_hex = channel.id.to_hex();
1465        let epoch = channel.epoch.0;
1466
1467        // The local user posts a message under the channel's current epoch.
1468        let mut state = ChatState::new();
1469        let target = ingest_msg_in(&mut state, &channel, &owner, "mine", 1, &owner);
1470
1471        // Self-removal teardown that retains keys, then reconstruct the channel from the RETAINED key.
1472        crate::db::community::delete_community_retain_keys(&cid).unwrap();
1473        let retained = crate::db::community::held_epoch_key(&cid, &chan_hex, epoch).unwrap()
1474            .expect("epoch key retained after keep-keys teardown");
1475        let mut rebuilt = channel.clone();
1476        rebuilt.key = ChannelKey(retained);
1477        rebuilt.epoch = Epoch(epoch);
1478
1479        // A 3305 self-delete authored by the local user opens under the retained key and removes the message.
1480        let del = seal_hide(&rebuilt, &owner, &target, 2, None);
1481        match process_incoming(&mut state, &del, &rebuilt, &owner.public_key()) {
1482            Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
1483            _ => panic!("expected the self-delete to apply under the retained key"),
1484        }
1485        crate::db::close_database();
1486    }
1487
1488    #[test]
1489    fn banned_author_events_are_dropped_including_presence() {
1490        use crate::stored_event::event_kind;
1491        let mut state = ChatState::new();
1492        let alice = Keys::generate(); // will be banned
1493        let bob = Keys::generate();
1494        let mut c = test_channel();
1495        c.banned = vec![alice.public_key()];
1496
1497        // A banned author's message is dropped before any STATE mutation.
1498        let spam = seal_message(&alice, &c.key, &c.id, c.epoch, "spam", 1).unwrap();
1499        assert!(process_incoming(&mut state, &spam, &c, &bob.public_key()).is_none(), "banned message dropped");
1500
1501        // A banned author's PRESENCE is dropped too (the anti-memberlist must hide them entirely).
1502        let pres_inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, "join", 2, None, &[])
1503            .finalize(&alice).unwrap();
1504        let pres = seal_with_signed_inner(&Keys::generate(), &pres_inner, &c.key, &c.id, c.epoch).unwrap();
1505        assert!(process_incoming(&mut state, &pres, &c, &bob.public_key()).is_none(), "banned presence dropped");
1506
1507        // A non-banned author is unaffected.
1508        let ok = seal_message(&bob, &c.key, &c.id, c.epoch, "hi", 3).unwrap();
1509        assert!(matches!(process_incoming(&mut state, &ok, &c, &bob.public_key()), Some(IncomingEvent::NewMessage(_))), "non-banned applied");
1510    }
1511
1512    #[test]
1513    fn cooperative_delete_applies_after_message_in_batch_order() {
1514        use crate::stored_event::event_kind;
1515        let mut state = ChatState::new();
1516        let alice = Keys::generate();
1517        let c = test_channel();
1518
1519        // A 3300 message and its author's 3305 delete, fed as ONE batch with the delete listed
1520        // first — process_channel_batch must ingest the message (pass 1) before the delete
1521        // (pass 2), so the tombstone lands on a present target.
1522        let msg_outer = seal_message(&alice, &c.key, &c.id, c.epoch, "bye", 1).unwrap();
1523        let opened = open_message(&msg_outer, &c.key, &c.id, c.epoch).unwrap();
1524        let inner_id = opened.message_id.to_hex();
1525        let del_outer = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &inner_id);
1526
1527        let applied = process_channel_batch(&mut state, &[del_outer, msg_outer], &c, &alice.public_key());
1528        assert!(applied.iter().any(|e| matches!(e, IncomingEvent::NewMessage(_))));
1529        assert!(applied.iter().any(|e| matches!(e, IncomingEvent::Removed { .. })));
1530        assert!(state.find_message(&inner_id).is_none(), "delete applied despite arriving first");
1531    }
1532
1533    #[test]
1534    fn build_message_sets_mine_and_author() {
1535        let me = Keys::generate();
1536        let opened = opened_from(&me, "hello", 4242);
1537        let msg = build_message(&opened, &me.public_key());
1538        assert_eq!(msg.content, "hello");
1539        assert_eq!(msg.at, 4242);
1540        assert!(msg.mine, "author == me → mine");
1541        assert_eq!(msg.npub, me.public_key().to_bech32().ok());
1542        assert_eq!(msg.id, opened.message_id.to_hex());
1543
1544        // A message from someone else is not mine.
1545        let other_view = build_message(&opened, &Keys::generate().public_key());
1546        assert!(!other_view.mine);
1547    }
1548
1549    #[test]
1550    fn ingest_creates_community_chat_and_adds_message() {
1551        let mut state = ChatState::new();
1552        let alice = Keys::generate();
1553        let opened = opened_from(&alice, "gm", 1);
1554
1555        assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some());
1556        // A Community chat now exists, keyed by the channel id, typed Community.
1557        let chat = state.chats.iter().find(|c| c.id == opened.channel_id.to_hex()).expect("chat");
1558        assert!(chat.is_community(), "channel chat must be ChatType::Community");
1559    }
1560
1561    #[test]
1562    fn process_incoming_ingests_valid_drops_foreign() {
1563        let mut state = ChatState::new();
1564        let alice = Keys::generate();
1565        let key = ChannelKey([0x33u8; 32]);
1566        let chan = ChannelId([0x44u8; 32]);
1567        let channel = Channel { id: chan, key: key.clone(), epoch: Epoch(0), name: "g".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false };
1568
1569        // A valid event for this channel lands.
1570        let outer = seal_message(&alice, &key, &chan, Epoch(0), "real", 1).unwrap();
1571        assert!(process_incoming(&mut state, &outer, &channel, &alice.public_key()).is_some());
1572        assert!(state.chats.iter().any(|c| c.is_community()));
1573
1574        // An event for a DIFFERENT channel (wrong key) is dropped, no chat created.
1575        let other_key = ChannelKey([0x99u8; 32]);
1576        let other_chan = ChannelId([0xaau8; 32]);
1577        let foreign = seal_message(&alice, &other_key, &other_chan, Epoch(0), "nope", 1).unwrap();
1578        assert!(process_incoming(&mut state, &foreign, &channel, &alice.public_key()).is_none());
1579        assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1580    }
1581
1582    #[test]
1583    fn ingest_dedups_on_message_id() {
1584        let mut state = ChatState::new();
1585        let alice = Keys::generate();
1586        let opened = opened_from(&alice, "once", 1);
1587
1588        assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some(), "first add");
1589        assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_none(), "duplicate not re-added");
1590        // Still exactly one Community chat.
1591        assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1592    }
1593
1594    #[test]
1595    fn dedup_keys_on_inner_id_across_distinct_outer_events() {
1596        // The real invariant: a re-broadcast of the SAME inner message (same
1597        // inner id) wrapped in a DIFFERENT outer event must dedup. Sealing twice with
1598        // identical params yields the same inner event (created_at is derived from
1599        // ms, so it's deterministic) but distinct outer events (fresh ephemeral key +
1600        // nonce). The second must NOT add a second message.
1601        let mut state = ChatState::new();
1602        let alice = Keys::generate();
1603        let key = ChannelKey([0x33u8; 32]);
1604        let chan = ChannelId([0x44u8; 32]);
1605        let channel = Channel { id: chan, key: key.clone(), epoch: Epoch(0), name: "g".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false };
1606
1607        let outer_a = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1608        let outer_b = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1609        assert_ne!(outer_a.id, outer_b.id, "distinct outer events (fresh ephemeral + nonce)");
1610
1611        assert!(process_incoming(&mut state, &outer_a, &channel, &alice.public_key()).is_some());
1612        assert!(
1613            process_incoming(&mut state, &outer_b, &channel, &alice.public_key()).is_none(),
1614            "same inner message id must dedup despite a different outer event"
1615        );
1616    }
1617
1618    #[test]
1619    fn route_incoming_routes_by_pseudonym() {
1620        let mut state = ChatState::new();
1621        let alice = Keys::generate();
1622        let key = ChannelKey([0x33u8; 32]);
1623        let chan = ChannelId([0x44u8; 32]);
1624        let channel = Channel { id: chan, key: key.clone(), epoch: Epoch(0), name: "g".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false };
1625
1626        // Routing table keyed by the channel's epoch pseudonym.
1627        let mut routes = HashMap::new();
1628        routes.insert(channel_pseudonym(&key, &chan, Epoch(0)).to_hex(), channel.clone());
1629
1630        // An event tagged with that pseudonym routes + lands.
1631        let outer = seal_message(&alice, &key, &chan, Epoch(0), "routed", 1).unwrap();
1632        assert!(route_incoming(&mut state, &outer, &routes, &alice.public_key()).is_some());
1633
1634        // An event for an UNROUTED pseudonym (different channel) is ignored.
1635        let other_key = ChannelKey([0x55u8; 32]);
1636        let other_chan = ChannelId([0x66u8; 32]);
1637        let unrouted = seal_message(&alice, &other_key, &other_chan, Epoch(0), "x", 1).unwrap();
1638        assert!(route_incoming(&mut state, &unrouted, &routes, &alice.public_key()).is_none());
1639    }
1640
1641    #[test]
1642    fn ms_none_falls_back_to_created_at() {
1643        // Directly construct an OpenedMessage with no ms tag → `at` = created_at*1000.
1644        use nostr_sdk::prelude::{EventId, Timestamp, Tags};
1645        let author = Keys::generate();
1646        let opened = OpenedMessage {
1647            message_id: EventId::from_byte_array([0u8; 32]),
1648            author: author.public_key(),
1649            content: "no ms".into(),
1650            channel_id: ChannelId([1u8; 32]),
1651            epoch: Epoch(0),
1652            ms: None,
1653            created_at: Timestamp::from_secs(1500),
1654            kind: 3300,
1655            attachments: vec![],
1656            citation: None,
1657            wrapper_id: EventId::from_byte_array([0u8; 32]),
1658            tags: Tags::new(),
1659        };
1660        assert_eq!(build_message(&opened, &author.public_key()).at, 1_500_000);
1661    }
1662
1663    // Relay-to-relay sync replays STORED old 3311s (a fresh relay backfilling from
1664    // an established one floods months of them); a typing bubble must expire from
1665    // the signal's own send time, never paint fresh off the wall clock.
1666    #[test]
1667    fn replayed_old_typing_signals_are_dropped() {
1668        let author = Keys::generate();
1669        let me = Keys::generate();
1670        let now_ms = std::time::SystemTime::now()
1671            .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
1672
1673        let fresh = opened_from(&author, "typing", now_ms);
1674        match apply_typing(&fresh, &me.public_key()) {
1675            Some(IncomingEvent::Typing { until, .. }) => {
1676                assert!(until > now_ms / 1000, "a live signal stays visible");
1677                assert!(until <= now_ms / 1000 + 31, "expiry derives from the signal, not the clock");
1678            }
1679            _ => panic!("fresh signal must show typing"),
1680        }
1681
1682        let replayed = opened_from(&author, "typing", now_ms - 3_600_000);
1683        assert!(apply_typing(&replayed, &me.public_key()).is_none(), "an hour-old replay never paints");
1684
1685        let future = opened_from(&author, "typing", now_ms + 600_000);
1686        assert!(apply_typing(&future, &me.public_key()).is_none(), "future-dated junk never pins a bubble");
1687    }
1688}